diff --git a/eslint.config.mjs b/eslint.config.mjs index cecadc2..38dc892 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,6 +14,8 @@ const eslintConfig = defineConfig([ "next-env.d.ts", "test-results/**", "playwright-report/**", + "scripts/**", + "designer-handoff/**", ]), ]); diff --git a/package.json b/package.json index 54558f0..557d316 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build", + "build": "next build --webpack", "start": "next start", "lint": "eslint", "test": "node --experimental-strip-types --no-warnings --test \"tests/unit/*.test.mjs\"", diff --git a/src/app/api/admin/events/order/route.ts b/src/app/api/admin/events/order/route.ts index d14d06c..2ba8d8b 100644 --- a/src/app/api/admin/events/order/route.ts +++ b/src/app/api/admin/events/order/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { revalidatePath } from 'next/cache'; import { verifyEventStaff } from '@/lib/auth-server'; import { isUuid, readJsonObject } from '@/lib/security'; -import { ALLOWED_EVENT_CATEGORIES, type AllowedEventCategory } from '../route'; +import { ALLOWED_EVENT_CATEGORIES, type AllowedEventCategory } from '@/lib/events'; export const runtime = 'nodejs'; diff --git a/src/app/api/admin/events/route.ts b/src/app/api/admin/events/route.ts index db061bf..94d7c4d 100644 --- a/src/app/api/admin/events/route.ts +++ b/src/app/api/admin/events/route.ts @@ -3,6 +3,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'; import { verifyEventStaff, isValidGoogleDriveUrl, isWebPImage } from '@/lib/auth-server'; import { getImageKit } from '@/lib/imagekit'; import { formFile, formText, isUuid, jsonText, readFormLimited, readJsonObject } from '@/lib/security'; +import { ALLOWED_EVENT_CATEGORIES, type AllowedEventCategory } from '@/lib/events'; export const runtime = 'nodejs'; @@ -10,17 +11,6 @@ const MAX_POSTER_SIZE = 1 * 1024 * 1024; // 1MB strictly enforced // Poster plus the text fields and multipart overhead. const MAX_FORM_BYTES = MAX_POSTER_SIZE + 64 * 1024; -// In display order: flagship, standout, main, then DTS and fun (which share one world on the site). -export const ALLOWED_EVENT_CATEGORIES = [ - 'flagship events', - 'standout events', - 'main events', - 'dts events', - 'fun events', -] as const; - -export type AllowedEventCategory = typeof ALLOWED_EVENT_CATEGORIES[number]; - const TEXT_LIMITS = { title: 200, description: 5000, format: 100, duration: 100, venue: 200, brochure_url: 2048 } as const; /** Returns an error message if any provided text field exceeds its maximum length. */ diff --git a/src/app/api/admin/referrals/route.ts b/src/app/api/admin/referrals/route.ts new file mode 100644 index 0000000..7d39ba0 --- /dev/null +++ b/src/app/api/admin/referrals/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { verifyRegistrationStaff } from '@/lib/auth-server'; +import { sanitizeFilterValue } from '@/lib/security'; + +export const runtime = 'nodejs'; + +const REFERRALS_PAGE_SIZE = 30; + +/** + * GET /api/admin/referrals?page=1&q= + * Referral leaderboard for admins and the registration team: one row per registrant whose code was used by at least + * one confirmed or pending registration (public.referral_leaderboard, supabase/schema.sql section 14), most confirmed + * referrals first. Paged on the server, REFERRALS_PAGE_SIZE rows per page; `q` searches name, email, college and code. + */ +export async function GET(req: NextRequest) { + try { + const auth = await verifyRegistrationStaff(req); + if ('error' in auth) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + + const { searchParams } = new URL(req.url); + const page = Number(searchParams.get('page') || '1'); + if (!Number.isInteger(page) || page < 1 || page > 100_000) { + return NextResponse.json({ error: 'Invalid page' }, { status: 400 }); + } + // Stripped of PostgREST filter syntax so a search term can't inject extra `.or()` conditions. + const query = sanitizeFilterValue(searchParams.get('q')); + + const from = (page - 1) * REFERRALS_PAGE_SIZE; + let dbQuery = auth.supabase + .from('referral_leaderboard') + .select( + 'rank, id, registration_id, name, email, college, referral_code, status, confirmed_referrals, pending_referrals', + { count: 'exact' } + ) + // A total order (id last), so rows never repeat or go missing between pages. + .order('confirmed_referrals', { ascending: false }) + .order('pending_referrals', { ascending: false }) + .order('created_at', { ascending: true }) + .order('id', { ascending: true }) + .range(from, from + REFERRALS_PAGE_SIZE - 1); + + if (query) { + dbQuery = dbQuery.or( + `name.ilike.%${query}%,email.ilike.%${query}%,college.ilike.%${query}%,referral_code.ilike.%${query}%,registration_id.ilike.%${query}%` + ); + } + + const { data, error, count } = await dbQuery; + // PostgREST answers 416 (PGRST103) for a page past the end, e.g. after the board shrank: report it as empty + // (the dashboard then goes back to page 1). + if (error && error.code !== 'PGRST103') { + console.error('/api/admin/referrals database error:', error); + return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 }); + } + + return NextResponse.json({ + success: true, + referrers: error ? [] : data ?? [], + total: count ?? 0, + page, + pageSize: REFERRALS_PAGE_SIZE, + }); + } catch (err: unknown) { + console.error('/api/admin/referrals error:', err); + return NextResponse.json({ error: 'Failed to fetch the referral leaderboard' }, { status: 500 }); + } +} diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts index 426a718..d8b3175 100644 --- a/src/app/api/events/route.ts +++ b/src/app/api/events/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from 'next/server'; -import { getSupabaseAdmin } from '@/lib/supabase'; +import { getSupabaseAdmin, isSupabaseConfigured, isSupabaseAdminConfigured } from '@/lib/supabase'; import { PUBLIC_EVENT_COLUMNS } from '@/lib/events'; export const runtime = 'nodejs'; @@ -17,6 +17,10 @@ export async function GET() { return NextResponse.json(cached.body, { headers: CACHE_HEADERS }); } + if (!isSupabaseConfigured() && !isSupabaseAdminConfigured()) { + return NextResponse.json({ success: true, events: [] }, { headers: CACHE_HEADERS }); + } + const supabase = getSupabaseAdmin(); const { data: events, error } = await supabase .from('events') diff --git a/src/app/api/gallery/route.ts b/src/app/api/gallery/route.ts index f8d4ada..58d2ab1 100644 --- a/src/app/api/gallery/route.ts +++ b/src/app/api/gallery/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from 'next/server'; -import { getSupabaseAdmin } from '@/lib/supabase'; +import { getSupabaseAdmin, isSupabaseConfigured, isSupabaseAdminConfigured } from '@/lib/supabase'; export const runtime = 'nodejs'; @@ -16,6 +16,10 @@ export async function GET() { return NextResponse.json(cached.body, { headers: CACHE_HEADERS }); } + if (!isSupabaseConfigured() && !isSupabaseAdminConfigured()) { + return NextResponse.json({ success: true, gallery: [] }, { headers: CACHE_HEADERS }); + } + const supabase = getSupabaseAdmin(); const { data: gallery, error } = await supabase .from('gallery') diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts index 6d8f103..d87ba7b 100644 --- a/src/app/api/register/route.ts +++ b/src/app/api/register/route.ts @@ -183,9 +183,34 @@ export async function POST(req: NextRequest) { ); } + // Optional referral: the code must belong to a registration that wasn't rejected. Checked last, after the UTR and + // proof, so probing codes costs a real payment proof and a slot of the per-user rate limit. + let referredBy: string | null = null; + if (input.referred_by_code) { + const { data: referrer, error: referrerErr } = await adminClient + .from('registrations') + .select('id, status') + .eq('referral_code', input.referred_by_code) + .maybeSingle(); + if (referrerErr) { + console.error('Referral code lookup failed:', referrerErr); + return NextResponse.json({ error: 'Failed to check the referral code. Please try again.' }, { status: 500 }); + } + if (!referrer || referrer.status === 'rejected') { + return NextResponse.json( + { + error: 'No registration has this referral code. Check it with the friend who shared it, or leave the field empty.', + field: 'referred_by_code', + }, + { status: 400 } + ); + } + referredBy = referrer.id; + } + // 4. Status and fee are server constants, the same for every registrant (no free tier): any status, amount or // student_type in the body is ignored. Staff confirm or reject after checking the payment. - // 5. Insert Registration into Database (retrying if the random IV26-XXXX id collides) + // 5. Insert Registration into Database (retrying if the random IV26-XXXX id or the generated referral code collides) const basePayload = { user_id: user.id, name: input.name, @@ -200,6 +225,8 @@ export async function POST(req: NextRequest) { utr: input.utr, amount: REGISTRATION_FEE_INR, status: NEW_REGISTRATION_STATUS, + // The registrant's own referral_code is generated by the database (trg_assign_referral_code). + referred_by: referredBy, }; let newReg = null; @@ -223,7 +250,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: PHONE_IN_USE_ERROR_MESSAGE, field: 'phone' }, { status: 409 }); } // Unique violation on user_id (concurrent double submit): return the registration that won. - if (!/registration_id/i.test(`${regError.message} ${regError.details ?? ''}`)) { + if (!/registration_id|referral_code/i.test(`${regError.message} ${regError.details ?? ''}`)) { const { data: winner } = await findExistingRegistration(adminClient, user, verifiedEmail); if (winner) { return NextResponse.json({ diff --git a/src/app/globals.css b/src/app/globals.css index 683cac0..a0de7ef 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -102,7 +102,6 @@ body::after{content:'';position:fixed;inset:0;pointer-events:none;z-index:9999;o .hv-gold:hover{color:oklch(0.8 0.12 85) !important} .hv-bronze:hover{color:#8a6a2a !important} .hv-bronze-fill:hover{background:#8a6a2a !important;color:#ECE8DF !important} -.hv-bronze-bg:hover{background:#8a6a2a !important} .hv-gold-bg:hover{background:oklch(0.8 0.12 85) !important} .hv-link-paper:hover{opacity:1 !important;color:#ECE8DF !important} .hv-link-white:hover{opacity:1 !important;color:#fff !important} @@ -736,3 +735,8 @@ body::after{content:'';position:fixed;inset:0;pointer-events:none;z-index:9999;o @keyframes ev-fade{from{opacity:0}} @keyframes ev-rise{from{opacity:0;transform:translateY(24px) scale(.97)}} @media (prefers-reduced-motion:reduce){.ev-pop,.ev-pop-box{animation:none}.hv-ticket-open:hover,.hv-ticket-open:focus-visible{transform:none}} + +/* 404 page quick links (src/app/not-found.tsx): inline styles set the resting look, so these win with !important. */ +.nf-chip{transition:border-color .3s,background-color .3s} +.nf-chip:hover,.nf-chip:focus-visible{border-color:oklch(0.8 0.12 85) !important;background:rgba(236,232,223,.1) !important} +@media (prefers-reduced-motion:reduce){.nf-chip{transition:none}} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c698d38..2e27fc5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -43,8 +43,8 @@ export const viewport: Viewport = { export default function RootLayout({ children }: LayoutProps<"/">) { return ( - - + + {children} diff --git a/src/app/merch/opengraph-image.tsx b/src/app/merch/opengraph-image.tsx new file mode 100644 index 0000000..2209009 --- /dev/null +++ b/src/app/merch/opengraph-image.tsx @@ -0,0 +1,16 @@ +import { MERCH_STORE_OPEN } from '@/config/launch'; +import { OG_SIZE, ogCard } from '@/lib/og-card'; + +// The link-preview card for /merch (Open Graph; twitter-image.tsx reuses it). Rendered once at build time. + +export const alt = 'INNOVISION 2026 | NIT ROURKELA: the official merch of The Celestial Odyssey'; +export const size = OG_SIZE; +export const contentType = 'image/png'; + +export default function OpengraphImage() { + return ogCard({ + kicker: 'INNOVISION 2026 · OFFICIAL', + title: 'MERCH', + subtitle: MERCH_STORE_OPEN ? 'T-SHIRT · HOODIE · ZIPPER' : 'DROPPING SOON', + }); +} diff --git a/src/app/merch/page.tsx b/src/app/merch/page.tsx index a422738..94e59bb 100644 --- a/src/app/merch/page.tsx +++ b/src/app/merch/page.tsx @@ -1,21 +1,32 @@ -import { redirect } from 'next/navigation'; import { MERCH_STORE_OPEN } from '@/config/launch'; import MerchDroppingSoon from '@/components/coming-soon/MerchDroppingSoon'; +import MerchPage from '@/components/innovision/MerchPage'; import { pageMetadata } from '@/lib/seo'; -export const metadata = pageMetadata({ - title: 'Official Merch Dropping Soon', - description: - 'The official Innovision 2026 merch collection from NIT Rourkela drops soon: limited-run tees, hoodies and keepsakes, pre-ordered online and collected on campus during the fest.', - path: '/merch', -}); +// The share card is this segment's own (opengraph-image.tsx, twitter-image.tsx). +export const metadata = pageMetadata( + MERCH_STORE_OPEN + ? { + title: 'Official Merch', + description: + 'Explore official Innovision 2026 hoodies, T-shirts and zippers from NIT Rourkela. View all four artwork colours and order online.', + path: '/merch', + ownImage: true, + } + : { + title: 'Official Merch Dropping Soon', + description: + 'The official Innovision 2026 merch from NIT Rourkela drops soon: a T-shirt, a zipper and a hoodie in four colours each, booked online and collected on campus during the fest.', + path: '/merch', + ownImage: true, + } +); /** - * /merch page route. - * While the store is closed (src/config/launch.ts) it shows the "dropping soon" teaser. - * Once open, it sends visitors to the store view inside the main experience (#/merch, MerchView.tsx). + * /merch page route: the merch's only page (the home page's MERCH links and #/merch all lead here). + * While the store is closed (src/config/launch.ts) it shows the "dropping soon" teaser; once open, the orbit store + * (MerchPage). */ -export default function MerchPage() { - if (!MERCH_STORE_OPEN) return ; - redirect('/#/merch'); +export default function MerchRoute() { + return MERCH_STORE_OPEN ? : ; } diff --git a/src/app/merch/twitter-image.tsx b/src/app/merch/twitter-image.tsx new file mode 100644 index 0000000..2eea725 --- /dev/null +++ b/src/app/merch/twitter-image.tsx @@ -0,0 +1,2 @@ +// The X/Twitter card uses the same image as Open Graph. +export { default, alt, size, contentType } from './opengraph-image'; diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..25886b5 --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,59 @@ +import Link from 'next/link'; +import type { Metadata } from 'next'; +import ErrorScreen, { GOLD, GhostFill, ghostAction, primaryAction } from '@/components/innovision/ErrorScreen'; +import { WORLDS } from '@/components/innovision/data'; +import { MERCH_PATH, REGISTRATIONS_SOON_PATH } from '@/config/launch'; + +// Overrides the layout's site-wide "index, follow", which would otherwise contradict the noindex Next adds to a 404. +export const metadata: Metadata = { + title: 'Page not found', + robots: { index: false, follow: true }, +}; + +/** Places that always exist, offered as the way back: the four event worlds, merch and registration. */ +const DESTINATIONS = [ + ...WORLDS.map((w) => ({ href: `/events/${w.slug}`, label: w.name, dot: w.accentL })), + { href: MERCH_PATH, label: 'Merch', dot: GOLD }, + { href: REGISTRATIONS_SOON_PATH, label: 'Register', dot: GOLD }, +]; + +/** + * 404 for the whole site: any URL no route matches, and every notFound() (e.g. /events/, which + * dynamicParams = false already turns away). Rendered inside the root layout, so fonts and globals.css apply. + */ +export default function NotFound() { + return ( + + BACK TO HOME + + + EXPLORE EVENTS + + + } + after={ + + } + > + The link may be old or mistyped, or the page has moved. Nothing is lost: head back to base, or pick a destination + below. + + ); +} diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 72537d1..e337036 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,5 +1,5 @@ import type { MetadataRoute } from 'next'; -import { MERCH_STORE_OPEN, REGISTRATIONS_OPEN } from '@/config/launch'; +import { REGISTRATIONS_OPEN } from '@/config/launch'; import { SITE_URL } from '@/config/site'; import { WORLDS } from '@/components/innovision/data'; @@ -8,9 +8,10 @@ import { WORLDS } from '@/components/innovision/data'; * * /events and one page per event world (/events/) are the crawlable counterparts of the animated explorer, * whose #/ hash routes on the home page search engines treat as the home page itself; the gallery and schedule views - * have no pages of their own. /register and /merch are listed only while they render their public teaser pages; once - * their launch switch (src/config/launch.ts) is on, they redirect instead (to sign-in, or to the #/merch store view) - * and are left out. /privacy and /terms are always listed. Staff pages (/admin) and the API never appear. + * have no pages of their own. /merch is always listed: it is the merch's only page, the "dropping soon" teaser or the + * store itself depending on its launch switch (src/config/launch.ts). /register is listed only while it renders its + * public teaser page; once registrations open it redirects to sign-in instead and is left out. /privacy and /terms are + * always listed. Staff pages (/admin) and the API never appear. * * No lastModified: nothing in the project records when a page's content last changed, and a build date is not that. */ @@ -20,7 +21,7 @@ export default function sitemap(): MetadataRoute.Sitemap { '/events', ...WORLDS.map((w) => `/events/${w.slug}`), ...(REGISTRATIONS_OPEN ? [] : ['/register']), - ...(MERCH_STORE_OPEN ? [] : ['/merch']), + '/merch', '/privacy', '/terms', ]; diff --git a/src/components/innovision/AdminDashboard.tsx b/src/components/innovision/AdminDashboard.tsx index 4730637..2b2b193 100644 --- a/src/components/innovision/AdminDashboard.tsx +++ b/src/components/innovision/AdminDashboard.tsx @@ -9,6 +9,7 @@ const genderLabel = (value: string) => GENDER_OPTIONS.find((g) => g.value === va import EventsManager from './admin/EventsManager'; import GalleryManager from './admin/GalleryManager'; import EntryManager from './admin/EntryManager'; +import ReferralLeaderboard from './admin/ReferralLeaderboard'; import { httpErrorMessage } from '@/lib/api-errors'; interface AdminDashboardProps { @@ -31,7 +32,7 @@ export default function AdminDashboard({ const canReviewRegistrations = isAdmin || isRegTeam; const defaultTab = canReviewRegistrations ? 'registrations' : 'events'; - const [tab, setTab] = useState<'registrations' | 'events' | 'gallery' | 'users' | 'entry'>(defaultTab); + const [tab, setTab] = useState<'registrations' | 'referrals' | 'events' | 'gallery' | 'users' | 'entry'>(defaultTab); const [registrations, setRegistrations] = useState([]); const [users, setUsers] = useState([]); const [loadingRegs, setLoadingRegs] = useState(false); @@ -204,7 +205,7 @@ export default function AdminDashboard({ let activeTab = tab; if (tab === 'users' && !isAdmin) activeTab = defaultTab; if (tab === 'entry' && !isAdmin) activeTab = defaultTab; - if (tab === 'registrations' && !canReviewRegistrations) activeTab = defaultTab; + if ((tab === 'registrations' || tab === 'referrals') && !canReviewRegistrations) activeTab = defaultTab; if ((tab === 'events' || tab === 'gallery') && !isAdmin && !isItTeam) activeTab = defaultTab; if (!isOpen || !isStaff) return null; @@ -334,6 +335,7 @@ export default function AdminDashboard({ 'Amount (INR)', 'UPI UTR', 'Payment Screenshot (private ref)', + 'Referral Code', 'Registered At', ]; @@ -350,6 +352,7 @@ export default function AdminDashboard({ r.amount, r.utr, r.payment_proof_path, + r.referral_code, r.created_at, ].map(csvCell)); @@ -832,6 +835,27 @@ export default function AdminDashboard({ )} + {canReviewRegistrations && ( + + )} + {(isAdmin || isItTeam) && ( + + + Share it with friends. When they register with your code and their registration is approved, it counts as your referral. + + ) : null}

{v.passNote}

EXPLORE THE WORLDS diff --git a/src/components/innovision/BagPanel.tsx b/src/components/innovision/BagPanel.tsx deleted file mode 100644 index 22d0b8a..0000000 --- a/src/components/innovision/BagPanel.tsx +++ /dev/null @@ -1,57 +0,0 @@ -/* eslint-disable @next/next/no-img-element -- decorative layers are animated directly by GSAP */ -import type { V } from './types'; - -/** Shopping bag drawer (opened from the store's cart pill). */ -export default function BagPanel({ v }: { v: V }) { - return ( - - ); -} diff --git a/src/components/innovision/CartPill.tsx b/src/components/innovision/CartPill.tsx deleted file mode 100644 index e13aad1..0000000 --- a/src/components/innovision/CartPill.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { V } from './types'; - -/** Floating cart summary shown on the store. */ -export default function CartPill({ v }: { v: V }) { - return ( -
-
- {v.cartCountL} - {v.cartTotal} - - - -
-
- ); -} diff --git a/src/components/innovision/Curtain.tsx b/src/components/innovision/Curtain.tsx index 101f8b2..6392f7d 100644 --- a/src/components/innovision/Curtain.tsx +++ b/src/components/innovision/Curtain.tsx @@ -1,3 +1,7 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import gsap from 'gsap'; import type { V } from './types'; /** A tiny seeded random generator (mulberry32), so every render draws the same clouds on server and client. */ @@ -61,18 +65,212 @@ function Clouds({ l, flip }: { l: (typeof LAYERS)[number]; flip?: boolean }) { ); } +export interface CurtainProps { + v?: Partial; + kicker?: string; + label?: string; + title?: string; + autoPlay?: boolean; + holdSeconds?: number; + delay?: number; + onCovered?: () => Promise | void; + onReveal?: () => void; + onComplete?: () => void; +} + +/** + * Runs the curtain transition animation matching the Gallery transition: + * 1. Clouds rise from the bottom to fully cover the screen (duration: 1.3s, stagger: 0.09s, ease: power3.inOut). + * 2. Pauses at covered; executes `covered` callback. + * 3. Clouds exit upward to reveal content (duration: 1.4s, stagger: 0.09s from end, ease: power3.inOut). + * 4. Calls `reveal` right as exit starts (+0.35s / '<'). + */ +export function runCurtainTransition( + container?: HTMLElement | Document | null, + options: { + delay?: number; + covered?: () => Promise | void; + reveal?: () => void; + } = {} +): Promise { + const { delay = 0, covered, reveal } = options; + const g = gsap; + const doc = typeof document !== 'undefined' ? document : null; + const root = container ?? doc; + + if (!root) { + if (covered) covered(); + if (reveal) reveal(); + return Promise.resolve(); + } + + const target = (root as HTMLElement).matches?.('[data-curtain]') + ? (root as HTMLElement) + : (root as HTMLElement).querySelector?.('[data-curtain]') || + doc?.querySelector('[data-curtain]') || null; + + if (!target) { + if (covered) covered(); + if (reveal) reveal(); + return Promise.resolve(); + } + + const L = target.querySelectorAll('[data-c-layer]'); + const c = target; + + return new Promise((resolve) => { + g.set(c, { pointerEvents: 'auto' }); + g.set(L, { y: 0, yPercent: 100 / 3, force3D: true, willChange: 'transform' }); + c.removeAttribute('data-idle'); + + const preventScroll = (e: Event) => e.preventDefault(); + c.addEventListener('wheel', preventScroll, { passive: false }); + c.addEventListener('touchmove', preventScroll, { passive: false }); + + const tl = g.timeline({ + delay, + defaults: { force3D: true }, + onComplete: () => { + c.removeEventListener('wheel', preventScroll); + c.removeEventListener('touchmove', preventScroll); + g.set(L, { willChange: 'auto' }); + g.set(c, { pointerEvents: 'none' }); + c.setAttribute('data-idle', ''); + resolve(); + }, + }); + + tl.to(L, { yPercent: -100 / 3, duration: 1.3, ease: 'power3.inOut', stagger: 0.09 }) + .add(() => { + tl.pause(); + Promise.resolve() + .then(() => covered && covered()) + .catch((e) => console.warn('curtain', e)) + .then(() => { + requestAnimationFrame(() => { + if (tl && (tl.isActive() || tl.paused())) { + tl.resume(); + } + }); + }); + }) + .add(() => { + if (reveal) reveal(); + }, '+=0.35') + .to(L, { yPercent: -100, duration: 1.4, ease: 'power3.inOut', stagger: { each: 0.09, from: 'end' } }, '<'); + }); +} + /** Layered cloud curtain used for view transitions. */ -export default function Curtain({ v }: { v: V }) { +export default function Curtain({ + v, + kicker, + label, + title, + autoPlay = false, + holdSeconds = 2, + delay = 0, + onCovered, + onReveal, + onComplete, +}: CurtainProps = {}) { + const displayKicker = kicker ?? v?.curtainKicker ?? 'NOW ENTERING'; + const displayLabel = title ?? label ?? v?.curtainLabel ?? ''; + const curtainRef = useRef(null); + const callbacksRef = useRef({ onCovered, onReveal, onComplete }); + + useEffect(() => { + callbacksRef.current = { onCovered, onReveal, onComplete }; + }); + + useEffect(() => { + if (!autoPlay) return; + + const c = curtainRef.current; + if (!c) return; + + let holdTimeout: ReturnType | null = null; + + const ctx = gsap.context(() => { + runCurtainTransition(c, { + delay, + covered: async () => { + if (callbacksRef.current.onCovered) await callbacksRef.current.onCovered(); + if (holdSeconds > 0) { + await new Promise((r) => { + holdTimeout = setTimeout(r, holdSeconds * 1000); + }); + } + }, + reveal: () => { + if (callbacksRef.current.onReveal) callbacksRef.current.onReveal(); + }, + }).then(() => { + if (callbacksRef.current.onComplete) callbacksRef.current.onComplete(); + }); + }, c); + + return () => { + if (holdTimeout) clearTimeout(holdTimeout); + ctx.revert(); + }; + }, [autoPlay, delay, holdSeconds]); + return ( -