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) && (
)}
+ {/* REFERRAL LEADERBOARD (Admin + Registration-Team) */}
+ {activeTab === 'referrals' && canReviewRegistrations && (
+
+ )}
+
{/* TAB 5: VENUE ENTRY / CHECK-IN (Admin Only) */}
{activeTab === 'entry' && isAdmin && (
diff --git a/src/components/innovision/AuthOverlay.tsx b/src/components/innovision/AuthOverlay.tsx
index be7e06e..1f1b0e5 100644
--- a/src/components/innovision/AuthOverlay.tsx
+++ b/src/components/innovision/AuthOverlay.tsx
@@ -154,6 +154,12 @@ export default function AuthOverlay({ v }: { v: V }) {
+91
{v.err.phone ? ({v.err.phone}) : null}
+
@@ -280,6 +286,18 @@ export default function AuthOverlay({ v }: { v: V }) {
{v.passQr ? : null}
+ {v.refCode ? (
+
+ YOUR REFERRAL CODE
+
+ {v.refCode}
+
+
+
+
+ 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 (
-
{LAYERS.map((l, k) => (
// Low-power mode drops two of the dark middle banks (globals.css [data-lp-skip]); the front one carries the label.
-
+
{k === LAYERS.length - 1 ? (
-
- {v.curtainKicker}
- {v.curtainLabel}
+
+
+ {displayKicker}
+
+
+ {displayLabel}
+
) : (
diff --git a/src/components/innovision/ErrorFallback.tsx b/src/components/innovision/ErrorFallback.tsx
index efb8c78..16e52a9 100644
--- a/src/components/innovision/ErrorFallback.tsx
+++ b/src/components/innovision/ErrorFallback.tsx
@@ -1,47 +1,39 @@
'use client';
-import type { CSSProperties } from 'react';
-
-const GOLD = 'oklch(0.8 0.12 85)';
-const PAPER = '#ECE8DF';
-/** The site's cut-corner button shape. */
-const CUT = 'polygon(12px 0,100% 0,100% calc(100% - 12px),calc(100% - 12px) 100%,0 100%,0 12px)';
-const button: CSSProperties = {
- display: 'inline-flex', alignItems: 'center', justifyContent: 'center', minWidth: '168px', minHeight: '48px', padding: '0 28px',
- border: '0', cursor: 'pointer', font: 'inherit', fontWeight: 700, fontSize: '14px', letterSpacing: '.08em', clipPath: CUT,
-};
-// A starfield drawn with gradients: the fallback must not depend on any asset that might be what failed.
-const STARS = [
- 'radial-gradient(1.5px 1.5px at 12% 22%,rgba(236,232,223,.9),transparent)', 'radial-gradient(1px 1px at 28% 68%,rgba(236,232,223,.7),transparent)',
- 'radial-gradient(1.5px 1.5px at 47% 14%,rgba(236,232,223,.8),transparent)', 'radial-gradient(1px 1px at 63% 82%,rgba(236,232,223,.6),transparent)',
- 'radial-gradient(2px 2px at 78% 30%,rgba(236,232,223,.9),transparent)', 'radial-gradient(1px 1px at 89% 61%,rgba(236,232,223,.7),transparent)',
- 'radial-gradient(1px 1px at 6% 84%,rgba(236,232,223,.6),transparent)', 'radial-gradient(1.5px 1.5px at 38% 44%,rgba(236,232,223,.5),transparent)',
-].join(',');
+import ErrorScreen, { GhostFill, ghostAction, primaryAction } from './ErrorScreen';
/**
- * Branded crash screen shared by app/error.tsx and app/global-error.tsx: dark space theme, DM Serif Display heading,
- * Space Grotesk copy, and a way back in (retry re-renders the failed part; reload starts the page afresh).
+ * Branded crash screen shared by app/error.tsx and app/global-error.tsx. Three ways back in: retry re-renders the
+ * failed part, reload starts the page afresh, and home is a full page load of the home page (a plain , not a
+ * client-side navigation through the app that just crashed).
*/
export default function ErrorFallback({ retry, digest }: { retry: () => void; digest?: string }) {
return (
-
-
-
: null}
+ >
+ }
+ >
+ Something went wrong while drawing this part of Innovision 2026. Try again, or reload the page if it keeps happening.
+
);
}
diff --git a/src/components/innovision/ErrorScreen.tsx b/src/components/innovision/ErrorScreen.tsx
new file mode 100644
index 0000000..bd6c881
--- /dev/null
+++ b/src/components/innovision/ErrorScreen.tsx
@@ -0,0 +1,69 @@
+import type { CSSProperties, ReactNode } from 'react';
+
+/*
+ * The shared full-screen shell of the site's error pages (app/not-found.tsx, and ErrorFallback for app/error.tsx and
+ * app/global-error.tsx): dark space theme, DM Serif Display heading, Manrope copy, the site's cut-corner buttons.
+ * Inline styles and a starfield drawn with gradients only: an error page must not depend on any asset or stylesheet
+ * that might be what failed. No hooks or handlers, so server and client pages can both use it.
+ */
+
+export const GOLD = 'oklch(0.8 0.12 85)';
+export const PAPER = '#ECE8DF';
+
+/** The site's cut-corner button shape. */
+const CUT = 'polygon(12px 0,100% 0,100% calc(100% - 12px),calc(100% - 12px) 100%,0 100%,0 12px)';
+const action: CSSProperties = {
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', minWidth: '168px', minHeight: '48px', padding: '0 28px',
+ border: '0', cursor: 'pointer', font: 'inherit', fontWeight: 700, fontSize: '14px', letterSpacing: '.08em', clipPath: CUT,
+ textDecoration: 'none',
+};
+/** Filled cream button (the main way out). */
+export const primaryAction: CSSProperties = { ...action, background: PAPER, color: '#141312' };
+/** Outlined twin: a ring of the outline colour around an inner fill (), so the cut corners keep their edge. */
+export const ghostAction: CSSProperties = { ...action, position: 'relative', isolation: 'isolate', background: 'rgba(236,232,223,.5)', color: PAPER };
+export function GhostFill() {
+ return (
+
+ );
+}
+
+const STARS = [
+ 'radial-gradient(1.5px 1.5px at 12% 22%,rgba(236,232,223,.9),transparent)', 'radial-gradient(1px 1px at 28% 68%,rgba(236,232,223,.7),transparent)',
+ 'radial-gradient(1.5px 1.5px at 47% 14%,rgba(236,232,223,.8),transparent)', 'radial-gradient(1px 1px at 63% 82%,rgba(236,232,223,.6),transparent)',
+ 'radial-gradient(2px 2px at 78% 30%,rgba(236,232,223,.9),transparent)', 'radial-gradient(1px 1px at 89% 61%,rgba(236,232,223,.7),transparent)',
+ 'radial-gradient(1px 1px at 6% 84%,rgba(236,232,223,.6),transparent)', 'radial-gradient(1.5px 1.5px at 38% 44%,rgba(236,232,223,.5),transparent)',
+].join(',');
+
+interface ErrorScreenProps {
+ /** Small gold line above the heading. */
+ eyebrow: string;
+ title: string;
+ /** The explanation under the heading. */
+ children: ReactNode;
+ /** The buttons. */
+ actions: ReactNode;
+ /** Anything below the buttons (quick links, a reference code). */
+ after?: ReactNode;
+ /** Large faint numerals behind the text, e.g. "404". */
+ code?: string;
+ /** 'alert' for a crash (announced at once by screen readers); omitted for a plain missing page. */
+ role?: 'alert';
+}
+
+export default function ErrorScreen({ eyebrow, title, children, actions, after, code, role }: ErrorScreenProps) {
+ return (
+
+
+
+ {code ? (
+
{code}
+ ) : null}
+
{eyebrow}
+
{title}
+
{children}
+
{actions}
+ {after}
+
+
+ );
+}
diff --git a/src/components/innovision/ImageSlot.tsx b/src/components/innovision/ImageSlot.tsx
index aa5cf37..47e0c68 100644
--- a/src/components/innovision/ImageSlot.tsx
+++ b/src/components/innovision/ImageSlot.tsx
@@ -7,7 +7,7 @@ interface Props {
fit?: 'cover' | 'contain';
placeholder?: string;
style?: CSSProperties;
- /** 'lazy' for slots in secondary views (gallery, store); never on the first screen. */
+ /** 'lazy' for slots in secondary views (gallery); never on the first screen. */
loading?: 'lazy' | 'eager';
}
diff --git a/src/components/innovision/Innovision.tsx b/src/components/innovision/Innovision.tsx
index d452d66..cdaf878 100644
--- a/src/components/innovision/Innovision.tsx
+++ b/src/components/innovision/Innovision.tsx
@@ -13,7 +13,7 @@ import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { ScrambleTextPlugin } from 'gsap/ScrambleTextPlugin';
import {
A, WORLDS, PRELOAD_CRITICAL, PRELOAD_DEFERRED, HERO_SPARKS, LOADER_SPARKS, STATUS, SCRAMBLE, GAP, GALLERY, ARCHIVE, ikWidth, gFrame,
- LINKS, NAVBAR_HIDDEN, TUNNEL, TUNNEL_C, PRODUCTS, BRIEF, SCHED, SCHED_DAYS, SCHED_BLOCKS, PAST_SPONSORS, inr, type Product, type WorldKey,
+ LINKS, NAVBAR_HIDDEN, TUNNEL, TUNNEL_C, BRIEF, SCHED, SCHED_DAYS, SCHED_BLOCKS, PAST_SPONSORS, type WorldKey,
} from './data';
import HomeView from './HomeView';
import WorldsView from './WorldsView';
@@ -25,7 +25,6 @@ import ProfileOverlay from './ProfileOverlay';
import PhoneModal from './PhoneModal';
import EventPopup from './EventPopup';
import AdminDashboard from './AdminDashboard';
-import CartPill from './CartPill';
import Toast from './Toast';
import WorldHint from './WorldHint';
import DetailNotice from './DetailNotice';
@@ -34,6 +33,7 @@ import { LiveV } from './SiteFooter';
import type { V } from './types';
import {
getSupabase,
+ isSupabaseConfigured,
getOrCreateUserProfile,
updateUserPhone,
fetchUserRegistration,
@@ -50,10 +50,10 @@ import {
type EventItem,
type GalleryPhoto,
} from '@/lib/supabase';
-import { REGISTRATIONS_OPEN, REGISTRATIONS_SOON_PATH, MERCH_STORE_OPEN, MERCH_SOON_PATH } from '@/config/launch';
+import { REGISTRATIONS_OPEN, REGISTRATIONS_SOON_PATH, MERCH_PATH } from '@/config/launch';
import { REGISTRATION_FEE, UPI_ID, UPI_PAY_LINK, UPI_QR_SRC } from '@/config/payment';
import { SKIP_INTRO_KEY } from '@/components/coming-soon/SkipIntroOnReturn';
-import { isValidGoogleDriveUrl, GENDER_OPTIONS as GENDERS } from '@/lib/validation';
+import { isValidGoogleDriveUrl, GENDER_OPTIONS as GENDERS, normalizeReferralCode, REFERRAL_CODE_PATTERN } from '@/lib/validation';
import { httpErrorMessage, isTransientFailure } from '@/lib/api-errors';
gsap.registerPlugin(ScrollTrigger, ScrambleTextPlugin);
@@ -67,20 +67,16 @@ ScrollTrigger.config({ ignoreMobileResize: true });
const LAZY = {
detail: { sel: '[data-view="detail"]', load: () => import('./DetailView') },
gallery: { sel: '[data-view="gallery"]', load: () => import('./GalleryView') },
- merch: { sel: '[data-view="merch"]', load: () => import('./MerchView') },
schedule: { sel: '[data-view="schedule"]', load: () => import('./ScheduleView') },
menu: { sel: '[data-menu]', load: () => import('./MenuOverlay') },
auth: { sel: '[data-auth-root]', load: () => import('./AuthOverlay') },
- bag: { sel: '[data-bag]', load: () => import('./BagPanel') },
};
type LazyKey = keyof typeof LAZY;
const DetailView = dynamic(LAZY.detail.load, { ssr: false });
const GalleryView = dynamic(LAZY.gallery.load, { ssr: false });
-const MerchView = dynamic(LAZY.merch.load, { ssr: false });
const ScheduleView = dynamic(LAZY.schedule.load, { ssr: false });
const MenuOverlay = dynamic(LAZY.menu.load, { ssr: false });
const AuthOverlay = dynamic(LAZY.auth.load, { ssr: false });
-const BagPanel = dynamic(LAZY.bag.load, { ssr: false });
/** Save-Data or a 2G-class connection: nothing is fetched ahead of need. */
const slowNet = () => {
const c = (navigator as Navigator & { connection?: { saveData?: boolean; effectiveType?: string } }).connection;
@@ -89,8 +85,11 @@ const slowNet = () => {
/** A left click with no modifier: links with a page of their own (e.g. /events/...) stay in the experience for these. */
const plainClick = (e: MouseEvent) => e.button === 0 && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey;
const LOAD_FAIL = "Couldn't load that part of the site. Check your connection and try again.";
-/** Teaser pages (src/config/launch.ts) live while their flow is closed; they open without reloading the site (leaveTo). */
-const TEASERS = [!REGISTRATIONS_OPEN && REGISTRATIONS_SOON_PATH, !MERCH_STORE_OPEN && MERCH_SOON_PATH].filter(Boolean) as string[];
+/**
+ * Pages of their own that the experience links to: /merch, and the registrations teaser while that flow is closed
+ * (src/config/launch.ts). They open without reloading the site (leaveTo).
+ */
+const OWN_PAGES = [MERCH_PATH, !REGISTRATIONS_OPEN && REGISTRATIONS_SOON_PATH].filter(Boolean) as string[];
/**
* Devices that start in low-power mode: few cores or little memory, Save-Data, or reduced motion.
* iOS Safari reports 2-4 cores whatever the phone (anti-fingerprinting), so there only the frame-rate check judges.
@@ -109,13 +108,10 @@ type PureProps = { v: V; deps: readonly unknown[] };
*/
const pure = (View: ComponentType<{ v: V }>) => memo(function Pure({ v }: PureProps) { return ; },
(a, b) => a.deps.length === b.deps.length && a.deps.every((d, k) => Object.is(d, b.deps[k])));
-const HomeV = pure(HomeView), WorldsV = pure(WorldsView), DetailV = pure(DetailView), GalleryV = pure(GalleryView), MerchV = pure(MerchView), ScheduleV = pure(ScheduleView), LoaderV = pure(Loader);
+const HomeV = pure(HomeView), WorldsV = pure(WorldsView), DetailV = pure(DetailView), GalleryV = pure(GalleryView), ScheduleV = pure(ScheduleView), LoaderV = pure(Loader);
-type ViewName = 'loading' | 'home' | 'worlds' | 'detail' | 'gallery' | 'merch' | 'schedule';
+type ViewName = 'loading' | 'home' | 'worlds' | 'detail' | 'gallery' | 'schedule';
type Route = { view: Exclude; index: number; section?: string };
-/** A bag line: product id, colour index (-1 if none), size ('' if none), quantity. */
-type BagLine = { id: string; key: string; c: number; s: string; qty: number };
-type Sel = { color?: number; size?: string };
type SlideParts = { hero: HTMLElement | null; rot: HTMLElement | null; astro: HTMLElement[]; link: HTMLElement[]; outline: HTMLElement | null; labels: HTMLElement[] };
type El = HTMLElement & { _tw?: gsap.core.Tween };
type Attracted = HTMLElement & { _a: { x: number; y: number; tx: number; ty: number; s: number; on: boolean } };
@@ -138,7 +134,7 @@ interface State {
/** dIndex: world shown by the detail page; it only follows index when that page is prepared. */
view: ViewName; index: number; dIndex: number; about: boolean; compact: boolean; narrow: boolean;
menu: boolean; toastOn: boolean; toastMsg: string; curtainLabel: string; curtainKicker: string;
- gIdx: number; sel: Record; bag: BagLine[]; bagOpen: boolean; added: string | null;
+ gIdx: number;
/** Schedule: selected day index, starred event ids. */
schedDay: number; saved: string[];
/** Lighter rendering (see goLowPower); only ever switches on. */
@@ -160,16 +156,12 @@ interface State {
registration: Registration | null;
user: UserProfile | null;
authReady: boolean; auth: boolean; authMode: string; step: number; err: any; busyLbl: string; files: any; drag: string; copied: boolean; schedFilter: string;
+ /** The referral code on the pass was just copied (shows COPIED). */
+ refCopied: boolean;
dbGallery: GalleryPhoto[];
dbEvents: EventItem[];
}
-/** Bag lines read back from storage, minus anything malformed (unknown shape, bad quantity, unknown product). */
-const cleanBag = (raw: unknown): BagLine[] => (Array.isArray(raw) ? raw : [])
- .filter((l) => l && typeof l.id === 'string' && typeof l.key === 'string' && PRODUCTS.some((p) => p.id === l.key) && Number.isFinite(l.qty) && l.qty > 0)
- .map((l) => ({ id: l.id, key: l.key, c: Number.isInteger(l.c) ? l.c : -1, s: typeof l.s === 'string' ? l.s : '', qty: Math.min(99, Math.floor(l.qty)) }));
-
-const BAG_KEY = 'innovisionCart';
const SAVED_KEY = 'iv26-schedule-saved';
const HINT_KEY = 'iv26-hint-worlds';
@@ -186,7 +178,7 @@ const partList = (p: SlideParts) => [p.hero, p.rot, ...p.astro, p.outline, ...p.
export class Innovision extends Component {
rootRef = createRef();
state: State = { view: 'loading', index: 0, dIndex: 0, about: false, compact: false, narrow: false, menu: false, toastOn: false, toastMsg: '', curtainLabel: 'INNOVISION', curtainKicker: 'NOW ENTERING',
- auth: false, authMode: 'register', step: 0, err: {} as any, busyLbl: '', user: null, files: {} as any, drag: '', copied: false, gIdx: 0, sel: {}, bag: [], bagOpen: false, added: null, schedDay: 0, schedFilter: 'all', saved: [], hudSolid: false, gBusy: false, gErr: '', gUser: null, hint: false, notice: false, coarse: false, lowPower: false, lazy: {},
+ auth: false, authMode: 'register', step: 0, err: {} as any, busyLbl: '', user: null, files: {} as any, drag: '', copied: false, refCopied: false, gIdx: 0, schedDay: 0, schedFilter: 'all', saved: [], hudSolid: false, gBusy: false, gErr: '', gUser: null, hint: false, notice: false, coarse: false, lowPower: false, lazy: {},
adminOpen: false, profileOpen: false, phoneModalOpen: false, eventPop: null, registration: null, authReady: false, dbGallery: [], dbEvents: [] };
busy = false; pending = false; slideDir = 0;
authBusy = false; authClosing = false;
@@ -194,7 +186,7 @@ export class Innovision extends Component {
_authReady = (() => { let res = () => {}; const p = new Promise((r) => { res = r; }); return { p, res, done: false }; })();
/** Bumped on every sign-out, so a profile lookup still in flight can't put a logged-out visitor back. */
_authEpoch = 0;
- _toast: any; _copy: any; reg: any; pass: any; _rEls: any; _rift: any; authO: any; _warpRaf: any; _warpTw: any; _stars: any;
+ _toast: any; _copy: any; _refCopy: any; reg: any; pass: any; _rEls: any; _rift: any; authO: any; _warpRaf: any; _warpTw: any; _stars: any;
// flagship rover ticker; _rvReseq is set when roverPauses changes so the drive sequence is rebuilt
_rvTick: ((time: number, dms: number) => void) | null = null; _rvReseq = false;
// schedule: day swap in progress
@@ -222,7 +214,6 @@ export class Innovision extends Component {
private ctx?: gsap.Context;
private cleanups: (() => void)[] = [];
private _toast?: ReturnType;
- private _added?: ReturnType;
// DOM helpers (set in boot)
$: (s: string) => HTMLElement | null = () => null;
@@ -264,13 +255,11 @@ export class Innovision extends Component {
componentDidMount() {
this.alive = true;
- let bag: BagLine[] = [];
// Stored values may be from an older version or edited by hand: keep only well-formed entries.
- try { bag = cleanBag(JSON.parse(localStorage.getItem(BAG_KEY) || '[]')); } catch {}
let saved: string[] = [];
try { const sv = JSON.parse(localStorage.getItem(SAVED_KEY) || '[]'); if (Array.isArray(sv)) saved = sv.filter((x) => typeof x === 'string'); } catch {}
try { this.hintSeen = localStorage.getItem(HINT_KEY) === '1'; } catch {}
- this.setState({ bag, saved, compact: innerWidth < 1100, narrow: innerWidth < 720, coarse: matchMedia('(pointer: coarse)').matches });
+ this.setState({ saved, compact: innerWidth < 1100, narrow: innerWidth < 720, coarse: matchMedia('(pointer: coarse)').matches });
if (lowPowerDevice()) this.goLowPower();
// Resize work forces layout (title fit, map panels), so it runs at most once per frame.
let rz = 0;
@@ -295,7 +284,7 @@ export class Innovision extends Component {
this.alive = false;
// A remount (dev strict mode) reuses this instance: low-power mode and the view tickers are set up again.
document.documentElement.removeAttribute('data-lowpower'); this.lowPower = false; this.viewTicks = [];
- clearTimeout(this._toast); clearTimeout(this._added); clearTimeout(this._hintT); clearTimeout(this._noticeT);
+ clearTimeout(this._toast); clearTimeout(this._hintT); clearTimeout(this._noticeT);
this._dayBusy = false;
this.cleanups.splice(0).forEach((fn) => fn());
this.io?.disconnect(); this.io = undefined; this.off.clear(); this.amb.clear();
@@ -340,14 +329,14 @@ export class Innovision extends Component {
// A page restored from the back/forward cache doesn't boot again, so a flag set meanwhile by a teaser page
// must not carry over to the next real load.
this.listen(window, 'pageshow', (e) => { if ((e as PageTransitionEvent).persisted) { try { sessionStorage.removeItem(SKIP_INTRO_KEY); } catch {} } });
- // Plain links to a teaser page (MERCH in the navbar, menu, footer and merch section) switch pages client-side.
+ // Plain links to a page of its own (MERCH in the navbar, menu and footer; REGISTER while closed) switch pages client-side.
this.listen(document, 'click', (e) => {
const ev = e as globalThis.MouseEvent;
if (ev.defaultPrevented || ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
const a = (ev.target as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null;
if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download')) return;
const u = new URL(a.href);
- if (u.origin !== location.origin || !TEASERS.includes(u.pathname)) return;
+ if (u.origin !== location.origin || !OWN_PAGES.includes(u.pathname)) return;
ev.preventDefault();
this.leaveTo(u.pathname + u.search);
});
@@ -362,6 +351,7 @@ export class Innovision extends Component {
const search = new URLSearchParams(window.location.search);
const isAuthRequired = search.get('required') === 'register' || search.get('auth') === 'login';
const isOpenRegister = search.get('open') === 'register' || window.location.hash === '#register';
+ const isOpenLogin = search.get('open') === 'login';
// Registrations closed (src/config/launch.ts): a register deep link lands on the "opening soon" page instead.
if (!REGISTRATIONS_OPEN && (isOpenRegister || search.get('required') === 'register')) {
@@ -390,6 +380,13 @@ export class Innovision extends Component {
this.openAuth('register');
}
}, 1200);
+ } else if (isOpenLogin) {
+ // LOG IN / PROFILE from a page of its own (the merch page's top bar): log in, or the profile if signed in.
+ setTimeout(async () => {
+ if (!this._authReady.done) await this._authReady.p;
+ if (!this.alive) return;
+ if (this.state.user) this.openProfile(); else this.openAuth('login');
+ }, 1200);
}
}
}
@@ -678,8 +675,8 @@ export class Innovision extends Component {
this._warmed = true;
if (slowNet()) return;
// The overlay and view a visitor is most likely to open next are fetched ahead too (their own hover/tap also does).
- // The teaser pages too, so REGISTER / MERCH open at once (a no-op on the dev server).
- this.cleanups.push(this.idle(() => { this.prefetch('auth'); this.prefetch('detail'); TEASERS.forEach((p) => this.props.router?.prefetch(p)); }));
+ // The pages of their own too, so MERCH (and REGISTER while closed) open at once (a no-op on the dev server).
+ this.cleanups.push(this.idle(() => { this.prefetch('auth'); this.prefetch('detail'); OWN_PAGES.forEach((p) => this.props.router?.prefetch(p)); }));
const queue = [...PRELOAD_DEFERRED];
let cancel = () => {}, live = true;
const batch = () => {
@@ -720,7 +717,7 @@ export class Innovision extends Component {
attach(k: LazyKey) {
if (this._attached.has(k)) return;
this._attached.add(k);
- if (k !== 'detail' && k !== 'gallery' && k !== 'merch' && k !== 'schedule') return;
+ if (k !== 'detail' && k !== 'gallery' && k !== 'schedule') return;
const el = this.$(LAZY[k].sel)!;
// Hidden views pause their loops (see syncLoops); the loops for its [data-spin] etc. start paused.
el.setAttribute('data-idle', '');
@@ -848,7 +845,6 @@ export class Innovision extends Component {
async prepView(to: Route) {
if (to.view in LAZY && !(await this.mount(to.view as LazyKey))) return false;
if (to.view === 'home') { gsap.set(this.$$('[data-hero-disc],[data-hero-sweep]'), { autoAlpha: 1, scale: 1 }); this.$('[data-view="home"]')!.scrollTop = 0; }
- else if (to.view === 'merch') this.$('[data-view="merch"]')!.scrollTop = 0;
else if (to.view === 'schedule') this.$('[data-view="schedule"]')!.scrollTop = 0;
else if (to.view === 'gallery') { this.gZ = -2600; this.gTarget = -2600; }
else { await this.setSlide(to.index); if (to.view === 'detail') await this.prepDetail(to.index); }
@@ -862,7 +858,6 @@ export class Innovision extends Component {
if (to.view === 'home') return this.homeEnter();
if (to.view === 'gallery') return this.galleryEnter();
if (to.view === 'schedule') return this.schedEnter();
- if (to.view === 'merch') return gsap.timeline().fromTo(this.$('[data-view="merch"] [data-m-reveal]'), { autoAlpha: 0, y: 40 }, { autoAlpha: 1, y: 0, duration: 1.2, ease: 'expo.out', stagger: .06 }, .1);
if (to.view === 'worlds') this.queueHint();
return to.view === 'detail' ? this.detailEnter() : this.worldsEnter(to.index);
}
@@ -950,25 +945,12 @@ export class Innovision extends Component {
this.gTy = y;
};
- /* ---------- store / bag ---------- */
+ /* ---------- toast ---------- */
toast(msg: string) {
this.setState({ toastOn: true, toastMsg: msg });
clearTimeout(this._toast);
this._toast = setTimeout(() => this.setState({ toastOn: false }), 3200);
}
- saveBag(bag: BagLine[]) { try { localStorage.setItem(BAG_KEY, JSON.stringify(bag)); } catch {} }
- setBag(bag: BagLine[]) { this.setState({ bag }); this.saveBag(bag); }
- pick(id: string, patch: Sel) { this.setState((st) => ({ sel: { ...st.sel, [id]: { ...(st.sel[id] || {}), ...patch } } })); }
- addToCart(p: Product, size: string | null, color: number) {
- const c = p.colors ? color : -1, s = size || '', id = p.id + '|' + c + '|' + s;
- const bag = this.state.bag.map((l) => ({ ...l })), f = bag.find((l) => l.id === id);
- if (f) f.qty += 1; else bag.push({ id, key: p.id, c, s, qty: 1 });
- this.setBag(bag);
- this.setState({ added: p.id });
- clearTimeout(this._added);
- this._added = setTimeout(() => this.setState({ added: null }), 1400);
- }
- setQty(id: string, d: number) { this.setBag(this.state.bag.map((l) => (l.id === id ? { ...l, qty: l.qty + d } : l)).filter((l) => l.qty > 0)); }
/* ---------- home ---------- */
homeEnter() {
@@ -1080,7 +1062,7 @@ export class Innovision extends Component {
/** Puts the HUD on its frosted bar once the active view's page has scrolled under it. */
hudSync() {
const v = this.state.view;
- const sc = v === 'detail' ? this.$('[data-d-scroller]') : v === 'home' || v === 'merch' || v === 'schedule' ? this.$('[data-view="' + v + '"]') : null;
+ const sc = v === 'detail' ? this.$('[data-d-scroller]') : v === 'home' || v === 'schedule' ? this.$('[data-view="' + v + '"]') : null;
const solid = !!sc && sc.scrollTop > 24;
if (solid !== this.state.hudSolid) this.setState({ hudSolid: solid });
if (solid && v === 'detail') this.hideNotice();
@@ -1370,9 +1352,9 @@ export class Innovision extends Component {
const f = WORLDS.findIndex((w) => w.slug === k || w.key === k), idx = this.state.index;
if (v === 'worlds') return { view: 'worlds', index: f >= 0 ? f : idx };
if (v === 'world' && f >= 0) return { view: 'detail', index: f };
- // Store closed (src/config/launch.ts): #/merch bookmarks go to the "dropping soon" page.
- if (v === 'merch' && !MERCH_STORE_OPEN) { this.leaveTo(MERCH_SOON_PATH, true); return { view: 'home', index: idx }; }
- if (v === 'gallery' || v === 'merch' || v === 'schedule') return { view: v, index: idx };
+ // The merch has a page of its own: old #/merch links and bookmarks go there.
+ if (v === 'merch') { this.leaveTo(MERCH_PATH, true); return { view: 'home', index: idx }; }
+ if (v === 'gallery' || v === 'schedule') return { view: v, index: idx };
if (v === 'sponsors') return { view: 'home', section: 'sponsors', index: idx };
return { view: 'home', index: idx };
}
@@ -1384,10 +1366,10 @@ export class Innovision extends Component {
if (this.state.view === 'loading') return;
if (this.busy) { this.pending = true; return; }
const to = this.parse(), s = this.state;
- if (s.about || s.menu || s.bagOpen) this.setState({ about: false, menu: false, bagOpen: false });
+ if (s.about || s.menu) this.setState({ about: false, menu: false });
if (to.view === s.view && to.view === 'home') { if (to.section) this.scrollHome(to.section, true); return; }
if (to.view === s.view && to.view === 'gallery') { this.gTarget = 0; return; }
- if (to.view === s.view && (to.view === 'merch' || to.view === 'schedule' || to.index === s.index)) return;
+ if (to.view === s.view && (to.view === 'schedule' || to.index === s.index)) return;
this.busy = true;
try { await this.transition(to); } finally { this.busy = false; this.slideDir = 0; }
if (this.pending) { this.pending = false; this.route(); }
@@ -1408,7 +1390,7 @@ export class Innovision extends Component {
await this.slideTo(to.index, this.slideDir || (to.index > this.state.index ? 1 : -1));
return;
}
- const label = to.view === 'worlds' ? 'The Worlds' : to.view === 'merch' ? 'The Store' : to.view === 'gallery' ? 'The Gallery' : to.view === 'schedule' ? 'The Schedule' : w.name;
+ const label = to.view === 'worlds' ? 'The Worlds' : to.view === 'gallery' ? 'The Gallery' : to.view === 'schedule' ? 'The Schedule' : w.name;
// A lazy view's chunk downloads while the curtain falls.
if (to.view in LAZY) this.prefetch(to.view as LazyKey);
const back: Route = { view: from as Route['view'], index: this.state.index };
@@ -1450,7 +1432,7 @@ export class Innovision extends Component {
onKey(e: KeyboardEvent) {
const s = this.state;
if (e.key === 'Escape' && s.eventPop != null && s.view === 'detail') { this.setState({ eventPop: null }); return; }
- if (e.key === 'Escape' && (s.about || s.menu || s.bagOpen)) { this.setState({ about: false, menu: false, bagOpen: false }); return; }
+ if (e.key === 'Escape' && (s.about || s.menu)) { this.setState({ about: false, menu: false }); return; }
const v = s.view;
if (v === 'worlds') {
if (e.key === 'ArrowRight') { e.preventDefault(); this.stepSlide(1); }
@@ -1636,6 +1618,10 @@ export class Innovision extends Component {
this.cleanups.push(() => clearTimeout(slow));
try {
purgeLocalStorageTokens();
+ if (!isSupabaseConfigured()) {
+ this.markAuthReady();
+ return;
+ }
const supabase = getSupabase();
// 1. Check the active session. getSession waits for the client to start, which (detectSessionInUrl) already
@@ -2283,6 +2269,14 @@ export class Innovision extends Component {
const no = () => this.toast('UPI ID: ' + t);
try { if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(t).then(ok, no); else no(); } catch (e) { no(); }
};
+ /** Copies the registrant's own referral code from the pass. */
+ copyReferral = () => {
+ const code = this.state.registration?.referral_code;
+ if (!code) return;
+ const ok = () => { this.setState({ refCopied: true }); clearTimeout(this._refCopy); this._refCopy = setTimeout(() => this.setState({ refCopied: false }), 1600); };
+ const no = () => this.toast('Your referral code: ' + code);
+ try { if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(code).then(ok, no); else no(); } catch { no(); }
+ };
// @ts-ignore
authSubmit = (e) => {
e.preventDefault();
@@ -2358,9 +2352,14 @@ export class Innovision extends Component {
else if (enrollment_no.length > 50 || (enrollment_no && !/^[A-Za-z0-9/_ -]+$/.test(enrollment_no))) {
err.enrollment_no = 'Use only letters, numbers, spaces and / _ - (up to 50 characters).';
}
+ // Optional; whether the code belongs to someone is checked by the server on submit.
+ const referred_by_code = normalizeReferralCode(v('referred_by_code'));
+ if (referred_by_code && !REFERRAL_CODE_PATTERN.test(referred_by_code)) {
+ err.referred_by_code = 'Referral codes are 6 letters and numbers. Check the code, or leave it empty.';
+ }
if (this.fail(err)) return;
- this.reg = { name, college, email, phone, enrollment_no, gender };
+ this.reg = { name, college, email, phone, enrollment_no, gender, referred_by_code };
// Every student, NIT Rourkela included, continues to Step 1 (payment).
this.toStep(1);
@@ -2392,6 +2391,7 @@ export class Innovision extends Component {
utr,
amount: this.fee(),
status: 'pending' as const,
+ ...(r.referred_by_code ? { referred_by_code: r.referred_by_code } : {}),
};
const saveRes = await createRegistration(regPayload);
@@ -2400,7 +2400,7 @@ export class Innovision extends Component {
const msg = saveRes.error || 'Registration failed';
this.toast(msg);
const field = saveRes.field || '';
- const detailsField = ['name', 'college', 'phone', 'gender', 'enrollment_no'].includes(field);
+ const detailsField = ['name', 'college', 'phone', 'gender', 'enrollment_no', 'referred_by_code'].includes(field);
if (isTransientFailure(saveRes.status) || !(detailsField || field === 'payfile' || field === 'utr')) {
// Rate limited, server error, offline, closed or signed out: stay on this step with the UTR and screenshot
// intact, so the student can submit again by hand. Nothing is resubmitted automatically.
@@ -2418,7 +2418,8 @@ export class Innovision extends Component {
this.fail({ utr: msg });
return;
}
- // Details from the first step (name, college, phone, gender, enrollment number): go back to fix them.
+ // Details from the first step (name, college, phone, gender, enrollment number, referral code): go back to
+ // fix them.
this.setState({ busyLbl: '' });
this.toStep(0);
this.setState({ err: { [field]: msg } });
@@ -2467,7 +2468,7 @@ export class Innovision extends Component {
authVals(s) {
// @ts-ignore
const am = s.authMode, st = s.step, reg = am === 'register', show = (b) => (b ? 'flex' : 'none'), gold = 'oklch(0.8 0.12 85)', cream = '#ECE8DF', bad = 'oklch(0.74 0.15 35)';
- const E = Object.assign({ name: '', gender: '', college: '', email: '', phone: '', enrollment_no: '', payfile: '', utr: '', lemail: '', lid: '', form: '' }, s.err);
+ const E = Object.assign({ name: '', gender: '', college: '', email: '', phone: '', enrollment_no: '', referred_by_code: '', payfile: '', utr: '', lemail: '', lid: '', form: '' }, s.err);
const bc = {}, inv = {};
// @ts-ignore
Object.keys(E).forEach((k) => { bc[k] = E[k] ? bad : 'rgba(236,232,223,.28)'; inv[k] = String(!!E[k]); });
@@ -2481,6 +2482,7 @@ export class Innovision extends Component {
phone: user?.phone || R.phone || '',
enrollment_no: user?.enrollment_no || s.registration?.enrollment_no || R.enrollment_no || '',
gender: R.gender || s.registration?.gender || '',
+ referred_by_code: R.referred_by_code || '',
};
// @ts-ignore
const up = (kind, ek, prompt) => {
@@ -2539,6 +2541,9 @@ export class Innovision extends Component {
: P.status === 'REJECTED'
? 'Your registration could not be approved. Please contact the helpdesk.'
: 'Your payment is being verified and your registration will be approved soon. Keep checking MY PASS: once approved, your entry QR appears here, ready to download.',
+ // The registrant's own referral code, shared from the pass (not once the registration was rejected).
+ refCode: String(P.status || '').toUpperCase() !== 'REJECTED' ? s.registration?.referral_code || '' : '',
+ copyRefCode: this.copyReferral, refCodeLbl: s.refCopied ? 'COPIED' : 'COPY CODE',
exploreFromPass: this.exploreFromPass,
googleLogin: this.googleLogin, gBusy: s.gBusy, gErr: s.gErr, gLbl: s.gBusy ? 'Waiting for Google…' : 'Continue with Google',
gNote: reg && st === 0 && s.user ? 'Signed in as ' + s.user.email + (isInternal ? ' (NIT RKL Student)' : '') + '.' : '',
@@ -2547,11 +2552,10 @@ export class Innovision extends Component {
user: s.user,
};
}
- checkout = () => { this.toast('Pre-orders open with registrations. Stay in orbit.'); };
linkGo = (e: MouseEvent) => {
e.preventDefault();
const href = e.currentTarget.getAttribute('href') || '#/';
- this.setState({ menu: false, about: false, bagOpen: false });
+ this.setState({ menu: false, about: false });
this.go(href);
};
onWheel = (e: WheelEvent) => {
@@ -2577,24 +2581,19 @@ export class Innovision extends Component {
// On a world's page the world switcher shows only at the top, where you land after Enter; once the page scrolls
// (hudSolid) it gives the scene and the mission list the whole screen.
const navShown = navOn && !(s.view === 'detail' && s.hudSolid);
- const act = s.view === 'merch' ? 'merch' : s.view === 'gallery' ? 'gallery' : s.view === 'schedule' ? 'schedule' : navOn ? 'events' : 'home';
- // Store closed (src/config/launch.ts): MERCH links leave for the "dropping soon" page.
- const merchHref = MERCH_STORE_OPEN ? '#/merch' : MERCH_SOON_PATH;
+ const act = s.view === 'gallery' ? 'gallery' : s.view === 'schedule' ? 'schedule' : navOn ? 'events' : 'home';
const routed = (k: string) => k === 'events' || k === 'merch' || k === 'gallery' || k === 'schedule';
const navLinks = LINKS.map(([label, k]) => ({
label: label.toUpperCase(), name: label, cur: String(k === act) as 'true' | 'false', o: k === act ? 1 : .68, bar: k === act ? 1 : 0, dc: k === act ? 'oklch(0.8 0.12 85)' : '#ECE8DF',
- href: k === 'events' ? '#/worlds/' + w.slug : k === 'merch' ? merchHref : k === 'gallery' ? '#/gallery' : k === 'schedule' ? '#/schedule' : '#/',
+ href: k === 'events' ? '#/worlds/' + w.slug : k === 'merch' ? MERCH_PATH : k === 'gallery' ? '#/gallery' : k === 'schedule' ? '#/schedule' : '#/',
onClick: (e: MouseEvent) => {
if (routed(k)) { if (this.state.about || this.state.menu) this.setState({ about: false, menu: false }); return; }
e.preventDefault(); this.goSection(k === 'home' ? null : k);
},
}));
- const lines = s.bag.map((l) => ({ ...l, p: PRODUCTS.find((x) => x.id === l.key) })).filter((l): l is BagLine & { p: Product } => !!l.p);
- const count = lines.reduce((a, l) => a + l.qty, 0), total = lines.reduce((a, l) => a + l.qty * l.p.price, 0);
- const cartOn = s.view === 'merch' && count > 0;
return {
// Navbar (top bar and phone menu) hides Schedule; the view, #/schedule route and footer link stay.
- navLinks, hudLinks: navLinks.filter((_, i) => !NAVBAR_HIDDEN.includes(LINKS[i][1])), footLinks: navLinks.slice(0, 5), wide: !s.compact, merchHref,
+ navLinks, hudLinks: navLinks.filter((_, i) => !NAVBAR_HIDDEN.includes(LINKS[i][1])), footLinks: navLinks.slice(0, 5), wide: !s.compact,
// Wide screens open the About drawer; compact screens open the full-screen menu.
// The MENU button only exists on compact screens; wide screens show LOG IN in its place.
menuButton: () => { this.openLazy('menu', { menu: true }); },
@@ -2623,25 +2622,6 @@ export class Innovision extends Component {
tunnelTotal: String(TUNNEL.length).padStart(2, '0'),
// Logos are sized by the square root of their aspect ratio, so wide wordmarks and square marks carry similar weight.
pastSponsors: PAST_SPONSORS.map((s) => ({ ...s, src: ikWidth(s.logo, 400), w: Math.min(78, 36 * Math.sqrt(s.width / s.height)).toFixed(1) + '%' })),
- products: PRODUCTS.map((p) => {
- const sl = s.sel[p.id] || {}, size = sl.size || (p.sizes ? 'M' : null), color = sl.color || 0;
- return {
- ...p, slot: 'merch-' + p.id, ph: p.name + ' product photo', priceL: inr(p.price), hasTag: !!p.tag, tagU: (p.tag || '').toUpperCase(),
- hasSizes: !!p.sizes, hasColors: !!p.colors, colorName: p.colors ? p.colors[color][0] : '',
- sizes: (p.sizes || []).map((z) => ({ z, on: z === size, bg: z === size ? '#141312' : 'transparent', fg: z === size ? '#ECE8DF' : '#141312', onClick: () => this.pick(p.id, { size: z }) })),
- colors: (p.colors || []).map(([n, c], k) => ({ n, c, on: k === color, ring: k === color ? '#141312' : 'transparent', onClick: () => this.pick(p.id, { color: k }) })),
- add: () => this.addToCart(p, size, color), addLabel: s.added === p.id ? 'ADDED ✓' : 'ADD TO CART', addBg: s.added === p.id ? '#8a6a2a' : '#141312',
- };
- }),
- cartO: cartOn ? 1 : 0, cartY: cartOn ? '0px' : '24px', cartPE: (cartOn ? 'auto' : 'none') as 'auto' | 'none',
- cartCountL: count + (count === 1 ? ' item' : ' items'), cartTotal: inr(total),
- clearCart: () => this.setBag([]),
- checkout: this.checkout,
- bagLines: lines.map((l) => ({ name: l.p.name, meta: [l.c >= 0 && l.p.colors?.[l.c] ? l.p.colors[l.c][0] : '', l.s].filter(Boolean).join(' · '), qty: l.qty, lineStr: inr(l.qty * l.p.price), inc: () => this.setQty(l.id, 1), dec: () => this.setQty(l.id, -1), remove: () => this.setQty(l.id, -l.qty) })),
- bagCountStr: String(count), subtotalStr: inr(total), bagEmpty: count === 0,
- bagVis: (s.bagOpen ? 'visible' : 'hidden') as 'visible' | 'hidden', bagDelay: s.bagOpen ? '0s' : '.8s', bagO: s.bagOpen ? 1 : 0, bagX: s.bagOpen ? '0%' : '100%', bagHidden: !s.bagOpen,
- openBag: () => { this.openLazy('bag', { bagOpen: true }); },
- closeBag: () => this.setState({ bagOpen: false }),
toastMsg: s.toastMsg,
heroChars: 'INNOVISION'.split('').map((ch) => ({ ch })),
briefWords: BRIEF.split(' '),
@@ -2812,7 +2792,6 @@ export class Innovision extends Component {
{s.lazy.detail && }
{s.lazy.gallery && }
- {s.lazy.merch && }
{s.lazy.schedule && }
@@ -2847,8 +2826,6 @@ export class Innovision extends Component {
currentUser={s.user}
/>
)}
- {s.lazy.bag && }
-
diff --git a/src/components/innovision/MerchHud.tsx b/src/components/innovision/MerchHud.tsx
new file mode 100644
index 0000000..8e15584
--- /dev/null
+++ b/src/components/innovision/MerchHud.tsx
@@ -0,0 +1,173 @@
+'use client';
+/* eslint-disable @next/next/no-img-element -- decorative starfield behind the menu, as in MenuOverlay */
+
+import { useEffect, useRef, useState, type CSSProperties } from 'react';
+import Link from 'next/link';
+import { REGISTRATIONS_OPEN, REGISTRATIONS_SOON_PATH, MERCH_PATH } from '@/config/launch';
+import { LINKS, NAVBAR_HIDDEN, WORLDS } from './data';
+import { Logo } from './icons';
+
+/* The explorer's top bar (Hud.tsx) and phone menu (MenuOverlay.tsx) for the merch page, which lives outside the
+ explorer: the same sizes, classes and blend, with plain links into it. Hash routes are full loads (as in
+ EventsChrome), so the explorer reads them once it boots; the page's SkipIntroOnReturn skips its loader. */
+
+const LABEL = 'clamp(13px,.95vw,16px)';
+const INK = '#141312', PAPER = '#ECE8DF', GOLD = 'oklch(0.8 0.12 85)';
+const FROST = 'rgba(236,232,223,.86)';
+const EASE = 'cubic-bezier(.25,1,.1,1)';
+
+const HREF: Record = {
+ home: '/', events: `/#/worlds/${WORLDS[0].slug}`, schedule: '/#/schedule', gallery: '/#/gallery', sponsors: '/#/sponsors', merch: MERCH_PATH,
+};
+const NAV = LINKS.filter(([, k]) => !NAVBAR_HIDDEN.includes(k)).map(([name, k]) => ({ name, label: name.toUpperCase(), href: HREF[k], cur: k === 'merch' }));
+/** The explorer opens registration (or the pass, or log in first) for these, as for its own REGISTER buttons. */
+const REGISTER_HREF = REGISTRATIONS_OPEN ? '/?open=register' : REGISTRATIONS_SOON_PATH;
+const ACCOUNT_HREF = '/?open=login';
+const INSTAGRAM = 'https://www.instagram.com/inno.nitr_official?psln=bWNqdmk1c3V4eWFl';
+
+/**
+ * Layout switches by CSS, so the server render is already right: the nav row from 1100px, LOG IN from 720px, MENU
+ * below 1100px. The wordmark goes below 520px as on the explorer's world pages (globals.css .hud-wordmark-detail).
+ */
+const CSS = `
+.mh-nav{display:none}
+@media (min-width:1100px){.mh-nav{display:flex}.mh-menu{display:none !important}}
+@media (max-width:719px){.mh-login{display:none !important}}
+@media (min-width:720px){.mh-menu-login{display:none !important}}
+@media (max-width:339px){.mh-back-label{display:none}}
+`;
+
+export default function MerchHud() {
+ const [solid, setSolid] = useState(false);
+ const [menu, setMenu] = useState(false);
+ // null until the cookie is read after mount: the account slot holds its place, as the explorer's does.
+ const [signedIn, setSignedIn] = useState(null);
+ const menuBtn = useRef(null);
+ const closeBtn = useRef(null);
+
+ /* Frosted once the page scrolls under the bar. The page is its own scroller, so listen for every scroll. */
+ useEffect(() => {
+ const onScroll = (e: Event) => {
+ const t = e.target as Element | Document;
+ const top = t instanceof Element ? t.scrollTop : document.scrollingElement?.scrollTop ?? 0;
+ setSolid(top > 8);
+ };
+ document.addEventListener('scroll', onScroll, { capture: true, passive: true });
+ return () => document.removeEventListener('scroll', onScroll, { capture: true });
+ }, []);
+
+ /* A session cookie (set by the explorer's sign-in) means the visitor is signed in. */
+ useEffect(() => {
+ const id = requestAnimationFrame(() => setSignedIn(/(?:^|;\s*)inn_access_token=/.test(document.cookie)));
+ return () => cancelAnimationFrame(id);
+ }, []);
+
+ /* The menu: Escape closes it, and focus moves into it and back to MENU. */
+ useEffect(() => {
+ if (!menu) return;
+ closeBtn.current?.focus();
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setMenu(false); };
+ addEventListener('keydown', onKey);
+ const btn = menuBtn.current;
+ return () => { removeEventListener('keydown', onKey); btn?.focus(); };
+ }, [menu]);
+
+ const fg = solid ? INK : '#fff';
+ const ghost: CSSProperties = { position: 'relative', isolation: 'isolate', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', padding: 'clamp(12px,.8vw,14px) clamp(20px,1.3vw,24px)', textDecoration: 'none', fontWeight: '700', fontSize: LABEL, letterSpacing: '.08em', color: 'inherit', background: 'currentColor', clipPath: 'polygon(8px 0,100% 0,100% calc(100% - 8px),calc(100% - 8px) 100%,0 100%,0 8px)' };
+ const ghostFill: CSSProperties = { position: 'absolute', inset: '1.5px', zIndex: '-1', background: solid ? PAPER : '#000', clipPath: 'polygon(7.4px 0,100% 0,100% calc(100% - 7.4px),calc(100% - 7.4px) 100%,0 100%,0 7.4px)' };
+ const accountLabel = signedIn ? 'PROFILE' : 'LOG IN';
+
+ return (
+ <>
+
+
+
+
+
+
+
+ {/* Kept down to 340px (the explorer drops it below 379px): here BACK is the main way home. */}
+ BACK
+
+
+
+
+ INNOVISION
+
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/components/innovision/MerchPage.tsx b/src/components/innovision/MerchPage.tsx
new file mode 100644
index 0000000..94b34c7
--- /dev/null
+++ b/src/components/innovision/MerchPage.tsx
@@ -0,0 +1,45 @@
+'use client';
+
+import { useCallback, useRef, useState } from 'react';
+import SkipIntroOnReturn from '@/components/coming-soon/SkipIntroOnReturn';
+import Curtain from '@/components/innovision/Curtain';
+import MerchOrbitView from '@/components/innovision/MerchOrbitView';
+import { useMerchV } from '@/components/innovision/useMerchV';
+
+/** The /merch page (src/app/merch/page.tsx) while the store is open: the orbit store in its own scroller. */
+export default function MerchPage() {
+ const v = useMerchV();
+ const [revealed, setRevealed] = useState(false);
+ const contentRef = useRef(null);
+
+ const handleReveal = useCallback(() => {
+ if (contentRef.current) {
+ contentRef.current.style.visibility = 'visible';
+ }
+ setRevealed(true);
+ }, []);
+
+ return (
+
+ {/* BACK (or the logo) returns to the home page without its loader, as from the site's other pages. */}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/innovision/MerchView.tsx b/src/components/innovision/MerchView.tsx
deleted file mode 100644
index 1a1a0d8..0000000
--- a/src/components/innovision/MerchView.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-/* eslint-disable @next/next/no-img-element -- decorative layers are animated directly by GSAP */
-import ImageSlot from './ImageSlot';
-import type { V } from './types';
-
-/** The Odyssey Store: product grid with colour and size pickers. */
-export default function MerchView({ v }: { v: V }) {
- return (
-
-
-
-
-
-
OFFICIAL MERCH · INNOVISION 2026
-
The Odyssey Store
-
-
Limited-run gear for the crew. Pre-order now, collect at the merch desk during the fest.
-
-
-
-
- {v.products.map((p, pI) => (
-
-
-
- {p.hasTag && (
- {p.tagU}
- )}
-
-
-
{p.name}
- {p.priceL}
-
-
{p.desc}
- {p.hasColors && (
-
- COLOUR
-
- {p.colors.map((c, cI) => (
-
- ))}
-
- {p.colorName}
-
- )}
- {p.hasSizes && (
-
- SIZE
-
- {p.sizes.map((z, zI) => (
- {z.z}
- ))}
-
-
- )}
- {p.addLabel}
-
- ))}
-
-
Prices include GST. Pre-orders are collected at the merch desk on campus during the fest.
-
-
- );
-}
diff --git a/src/components/innovision/admin/ReferralLeaderboard.tsx b/src/components/innovision/admin/ReferralLeaderboard.tsx
new file mode 100644
index 0000000..2eb8786
--- /dev/null
+++ b/src/components/innovision/admin/ReferralLeaderboard.tsx
@@ -0,0 +1,179 @@
+'use client';
+
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { httpErrorMessage } from '@/lib/api-errors';
+
+/*
+ * Admin "Leaderboard" tab (admins and the registration team): who brought in the most registrations with their
+ * referral code. Read from GET /api/admin/referrals, which pages on the server (30 per page). Only referrers with at
+ * least one confirmed or pending referral are listed; rank counts confirmed (approved) referrals only.
+ */
+
+interface ReferralLeaderboardProps {
+ getAuthToken: () => Promise;
+}
+
+interface Referrer {
+ rank: number;
+ id: string;
+ registration_id: string;
+ name: string;
+ email: string;
+ college: string;
+ referral_code: string;
+ status: 'pending' | 'confirmed';
+ confirmed_referrals: number;
+ pending_referrals: number;
+}
+
+const GOLD = 'oklch(0.8 0.12 85)';
+const th: React.CSSProperties = { padding: '10px 12px', textAlign: 'left', fontSize: '11px', fontWeight: 700, letterSpacing: '.14em', color: 'rgba(236,232,223,0.6)', borderBottom: '1px solid rgba(236,232,223,0.14)', whiteSpace: 'nowrap' };
+const td: React.CSSProperties = { padding: '10px 12px', fontSize: '13px', borderBottom: '1px solid rgba(236,232,223,0.08)', verticalAlign: 'top' };
+const num: React.CSSProperties = { ...td, textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 700 };
+const smallBtn: React.CSSProperties = { padding: '6px 12px', border: '1px solid rgba(236,232,223,0.35)', background: 'transparent', color: '#ECE8DF', fontSize: '11px', fontWeight: 700, letterSpacing: '.12em', cursor: 'pointer', whiteSpace: 'nowrap' };
+const input: React.CSSProperties = { minHeight: '40px', padding: '0 12px', background: 'rgba(236,232,223,0.06)', border: '1px solid rgba(236,232,223,0.2)', color: '#ECE8DF', fontSize: '14px' };
+
+export default function ReferralLeaderboard({ getAuthToken }: ReferralLeaderboardProps) {
+ const [search, setSearch] = useState('');
+ const [query, setQuery] = useState('');
+ const [page, setPage] = useState(1);
+ const [rows, setRows] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [pageSize, setPageSize] = useState(30);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+ const requestSeq = useRef(0);
+ // AdminDashboard recreates getAuthToken on every render; read it through a ref so loading isn't restarted each time.
+ const tokenRef = useRef(getAuthToken);
+ useEffect(() => {
+ tokenRef.current = getAuthToken;
+ }, [getAuthToken]);
+
+ // Debounced search.
+ useEffect(() => {
+ const t = setTimeout(() => { setQuery(search.trim()); setPage(1); }, 400);
+ return () => clearTimeout(t);
+ }, [search]);
+
+ const load = useCallback(async () => {
+ const seq = ++requestSeq.current;
+ setLoading(true);
+ try {
+ const token = await tokenRef.current();
+ const params = new URLSearchParams({ page: String(page) });
+ if (query) params.set('q', query);
+ const res = await fetch(`/api/admin/referrals?${params}`, {
+ headers: token ? { Authorization: `Bearer ${token}` } : {},
+ credentials: 'include',
+ cache: 'no-store',
+ });
+ const body = await res.json().catch(() => ({}));
+ if (seq !== requestSeq.current) return; // a newer request superseded this one
+ if (!res.ok) throw new Error(httpErrorMessage(res) || body.error || 'Failed to load the leaderboard.');
+ const list: Referrer[] = Array.isArray(body.referrers) ? body.referrers : [];
+ // The board shrank under a later page (e.g. a search): start again from the first page.
+ if (!list.length && page > 1) { setPage(1); return; }
+ setRows(list);
+ setTotal(Number(body.total) || 0);
+ if (Number(body.pageSize) > 0) setPageSize(Number(body.pageSize));
+ setError('');
+ } catch (err) {
+ if (seq === requestSeq.current) setError(err instanceof Error ? err.message : 'Failed to load the leaderboard.');
+ } finally {
+ if (seq === requestSeq.current) setLoading(false);
+ }
+ }, [page, query]);
+
+ useEffect(() => {
+ const first = setTimeout(load, 0);
+ return () => clearTimeout(first);
+ }, [load]);
+
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
+ const start = total === 0 ? 0 : (page - 1) * pageSize + 1;
+ const end = Math.min(page * pageSize, total);
+
+ return (
+
+
+
+ Registrants whose referral code was used. Accepted counts referred
+ registrations that were approved and decides the rank; Pending are
+ still awaiting payment review. Rejected ones never count.
+
+
+ );
+}
diff --git a/src/components/merch/MerchStore.tsx b/src/components/merch/MerchStore.tsx
new file mode 100644
index 0000000..35aeca6
--- /dev/null
+++ b/src/components/merch/MerchStore.tsx
@@ -0,0 +1,61 @@
+import Link from 'next/link';
+import EventsChrome from '@/components/events/EventsChrome';
+import events from '@/components/events/EventsPage.module.css';
+import SkipIntroOnReturn from '@/components/coming-soon/SkipIntroOnReturn';
+import { MERCH } from '@/config/merch';
+import { jsonLdScript, listPageJsonLd } from '@/lib/seo';
+import MerchCard from './MerchCard';
+import styles from './Merch.module.css';
+
+export const MERCH_DESCRIPTION =
+ 'The official Innovision 2026 merch from NIT Rourkela: a T-shirt, a zipper and a hoodie, each in four colours. Book online and collect on campus during the fest.';
+
+const TRAIL: [string, string][] = [['Home', '/'], ['Official Merch', '/merch']];
+
+/** /merch while MERCH_STORE_OPEN is true (src/config/launch.ts): the pieces in src/config/merch.ts, booked through the order form. */
+export default function MerchStore() {
+ const jsonLd = listPageJsonLd({
+ name: 'INNOVISION 2026 Official Merch', description: MERCH_DESCRIPTION, path: '/merch', trail: TRAIL,
+ items: MERCH.map((m) => ({ name: m.name })),
+ });
+
+ return (
+
+
+
+
+
OFFICIAL MERCH · INNOVISION 2026
+
The Odyssey Store
+
+ Limited-run gear for the crew: the official T-shirt, zipper and hoodie, each in four colours. Pick a colour, hit
+ Book now and fill in the order form. Orders are collected at the merch desk on campus during the fest.
+
Choose the T-shirt, zipper or hoodie and the colour you want.
+
Fill in the form
Book now opens the official order form in a new tab. Enter your size and details there.
+
Collect on campus
Pick up your order at the merch desk at NIT Rourkela during the fest.
+
+
+
+ );
+}
diff --git a/src/config/launch.ts b/src/config/launch.ts
index 11d154e..dfa8930 100644
--- a/src/config/launch.ts
+++ b/src/config/launch.ts
@@ -1,19 +1,22 @@
/**
* Launch switches for flows that are built but not yet live.
*
- * While a switch is `false`, its entry points (HUD / hero / closing REGISTER buttons, MERCH nav and teaser links)
- * send visitors to a teaser page instead of the real flow:
- * REGISTRATIONS_OPEN = false → /register renders
- * MERCH_STORE_OPEN = false → /merch renders , and #/merch redirects there
+ * While a switch is `false`, its page shows a teaser instead of the real flow:
+ * REGISTRATIONS_OPEN = false → /register renders , and the REGISTER buttons lead there
+ * MERCH_STORE_OPEN = false → /merch renders
*
* The real flows are untouched and come back by flipping the switch to `true`:
* registration: src/app/register/redirectToRegistrationFlow.ts → the auth / registration overlay in Innovision.tsx
- * merch: the #/merch store view, src/components/innovision/MerchView.tsx
+ * merch: /merch renders (src/components/merch), with the pieces and form in src/config/merch.ts
+ * The MERCH links and #/merch lead to /merch either way.
+ *
+ * REGISTRATIONS_OPEN gates the proxy auth gate, /register, the REGISTER buttons and the /api/register + /api/upload
+ * writes (src/lib/registration-gate.ts), the same way in every environment.
*/
export const REGISTRATIONS_OPEN = false;
-export const MERCH_STORE_OPEN = false;
+export const MERCH_STORE_OPEN = true;
/** Where the REGISTER buttons point while registrations are closed. */
export const REGISTRATIONS_SOON_PATH = '/register';
-/** Where MERCH links point while the store is closed. */
-export const MERCH_SOON_PATH = '/merch';
+/** The merch page, where every MERCH link points. */
+export const MERCH_PATH = '/merch';
diff --git a/src/config/merch.ts b/src/config/merch.ts
new file mode 100644
index 0000000..af70ed9
--- /dev/null
+++ b/src/config/merch.ts
@@ -0,0 +1,71 @@
+/**
+ * The official merch shown on /merch (src/app/merch/page.tsx). Orders go through the organisers' external order form:
+ * every piece has its own BOOK NOW button, which opens that form in a new tab. The site keeps no cart and stores no orders.
+ *
+ * Still to come from the organisers: the form link, the colourways, the prices and the product photos.
+ * - MERCH_FORM_URL: until it is set, BOOK NOW shows "BOOKING OPENS SOON" and links nowhere.
+ * - colours: the names and swatches below are placeholders. A colour's `image` (a path under public/ or an ImageKit
+ * URL, ideally a 4:5 photo) replaces the drawn silhouette for that colour.
+ * - price: shown on the card once set (GST included); left out, the card shows no price.
+ */
+
+/** The order form (e.g. a Google Form) every BOOK NOW button opens, unless a piece has a `formUrl` of its own. */
+export const MERCH_FORM_URL = '';
+
+/**
+ * Optional Google Form pre-fill: the `entry.` names of the form's "item" and "colour" questions (from the form's
+ * "Get pre-filled link"). When set, BOOK NOW opens the form with the piece and the colour picked on the card filled in.
+ */
+export const MERCH_FORM_PREFILL: { item?: string; colour?: string } = {};
+
+export type GarmentKind = 'tee' | 'zipper' | 'hoodie';
+
+export interface MerchColour {
+ name: string;
+ /** Swatch and silhouette colour. */
+ swatch: string;
+ /** Product photo for this colourway. */
+ image?: string;
+}
+
+export interface MerchItem {
+ id: string;
+ kind: GarmentKind;
+ name: string;
+ desc: string;
+ /** In rupees, GST included. */
+ price?: number;
+ sizes: string[];
+ colours: MerchColour[];
+ /** A form of its own for this piece, instead of MERCH_FORM_URL. */
+ formUrl?: string;
+}
+
+const SIZES = ['S', 'M', 'L', 'XL', 'XXL'];
+
+// Placeholder colourways until the official ones are shared; give each piece its own list if they differ.
+const COLOURS: MerchColour[] = [
+ { name: 'Black', swatch: '#1b1a19' },
+ { name: 'White', swatch: '#efede8' },
+ { name: 'Navy', swatch: '#1f2c4a' },
+ { name: 'Maroon', swatch: '#64212a' },
+];
+
+export const MERCH: MerchItem[] = [
+ { id: 'tee', kind: 'tee', name: 'Official T-Shirt', desc: 'Heavyweight cotton tee with the Innovision 2026 Celestial Odyssey print.', sizes: SIZES, colours: COLOURS },
+ { id: 'zipper', kind: 'zipper', name: 'Official Zipper', desc: 'Full-zip fleece jacket with a hood and the Innovision wordmark on the chest.', sizes: SIZES, colours: COLOURS },
+ { id: 'hoodie', kind: 'hoodie', name: 'Official Hoodie', desc: 'Brushed fleece pullover hoodie with a kangaroo pocket and the Celestial Odyssey print.', sizes: SIZES, colours: COLOURS },
+];
+
+/** The address BOOK NOW opens for a piece in a colour, or null while there is no form yet. */
+export function bookingUrl(item: MerchItem, colour: MerchColour): string | null {
+ const base = item.formUrl || MERCH_FORM_URL;
+ if (!base) return null;
+ const { item: itemField, colour: colourField } = MERCH_FORM_PREFILL;
+ if (!itemField && !colourField) return base;
+ const url = new URL(base);
+ url.searchParams.set('usp', 'pp_url');
+ if (itemField) url.searchParams.set(itemField, item.name);
+ if (colourField) url.searchParams.set(colourField, colour.name);
+ return url.toString();
+}
diff --git a/src/config/payment.ts b/src/config/payment.ts
index 2b96656..5ac6bcb 100644
--- a/src/config/payment.ts
+++ b/src/config/payment.ts
@@ -11,10 +11,10 @@
* link as ONE byte-mode segment, error correction M, e.g. with the `qrcode` package:
* QRCode.toString([{ data: UPI_PAY_LINK, mode: 'byte' }], { type: 'svg', errorCorrectionLevel: 'M', margin: 1,
* color: { dark: '#0c0b0aff', light: '#ECE8DFff' } })
- * Left to pick its own segments, `qrcode` splits the link into byte + numeric + byte, and with a 4-digit fee that
+ * Left to pick its own segments, `qrcode` splits the link into byte + numeric + byte around the fee, and that
* symbol fails to decode in ZXing (the reader behind many Android apps). Check it scans to the new link.
*/
-export const REGISTRATION_FEE = 1200;
+export const REGISTRATION_FEE = 999;
export const UPI_ID = 'innovision@sbi';
export const UPI_PAYEE_NAME = 'Innovision NIT Rourkela';
export const UPI_NOTE = 'Innovision 2026 registration';
diff --git a/src/lib/events.ts b/src/lib/events.ts
index a48cec4..6a145e6 100644
--- a/src/lib/events.ts
+++ b/src/lib/events.ts
@@ -1,9 +1,23 @@
import { WORLDS, type World } from '@/components/innovision/data';
-import { getSupabaseAdmin, type EventItem } from '@/lib/supabase';
+import { getSupabaseAdmin, isSupabaseConfigured, isSupabaseAdminConfigured, type EventItem } from '@/lib/supabase';
/** Columns anyone may read: no staff user ids or storage file ids. Shared by /api/events and the /events pages. */
export const PUBLIC_EVENT_COLUMNS = 'id, title, description, poster_url, brochure_url, category, format, duration, venue, sort_order, created_at, updated_at';
+/**
+ * Categories staff can give an event, in display order: flagship, standout, main, then DTS and fun (which share one
+ * world on the site). Shared by /api/admin/events and /api/admin/events/order (route files can only export handlers).
+ */
+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];
+
export type PublicEvent = Pick;
/**
@@ -12,6 +26,9 @@ export type PublicEvent = Pick {
+ if (!isSupabaseConfigured() && !isSupabaseAdminConfigured()) {
+ return [];
+ }
try {
const { data, error } = await getSupabaseAdmin()
.from('events')
diff --git a/src/lib/imagekit.ts b/src/lib/imagekit.ts
index 8a55652..02dbd25 100644
--- a/src/lib/imagekit.ts
+++ b/src/lib/imagekit.ts
@@ -2,21 +2,18 @@
// (see src/lib/payment-proofs.ts).
import ImageKit from 'imagekit';
-let client: ImageKit | null = null;
+let instance: ImageKit | null = null;
-// Created on first use rather than at import time: `next build` imports route modules while collecting page data,
-// and the ImageKit constructor throws when keys are missing (as they are in CI).
+/**
+ * Created on first use, not at import: the SDK throws on a missing key, and `next build` evaluates route modules
+ * while collecting page data, when runtime credentials may not be available.
+ */
export function getImageKit(): ImageKit {
- if (client) return client;
-
- const publicKey = process.env.IMAGEKIT_PUBLIC_KEY || process.env.NEXT_PUBLIC_IMAGEKIT_PUBLIC_KEY || '';
- const privateKey = process.env.IMAGEKIT_PRIVATE_KEY || '';
- const urlEndpoint = process.env.IMAGEKIT_URL_ENDPOINT || process.env.NEXT_PUBLIC_IMAGEKIT_URL_ENDPOINT || '';
-
- if (!publicKey || !privateKey || !urlEndpoint) {
- throw new Error('ImageKit configuration is missing.');
- }
-
- client = new ImageKit({ publicKey, privateKey, urlEndpoint });
- return client;
+ if (instance) return instance;
+ instance = new ImageKit({
+ publicKey: process.env.IMAGEKIT_PUBLIC_KEY || process.env.NEXT_PUBLIC_IMAGEKIT_PUBLIC_KEY || '',
+ privateKey: process.env.IMAGEKIT_PRIVATE_KEY || '',
+ urlEndpoint: process.env.IMAGEKIT_URL_ENDPOINT || process.env.NEXT_PUBLIC_IMAGEKIT_URL_ENDPOINT || '',
+ });
+ return instance;
}
diff --git a/src/lib/payment-proofs.ts b/src/lib/payment-proofs.ts
index dab009b..3380bba 100644
--- a/src/lib/payment-proofs.ts
+++ b/src/lib/payment-proofs.ts
@@ -18,6 +18,7 @@ const publishableKey =
const noSession = { persistSession: false, autoRefreshToken: false };
function bucketAsUser(accessToken: string) {
+ if (!supabaseUrl || !publishableKey) return null;
return createClient(supabaseUrl, publishableKey, {
auth: noSession,
global: { headers: { Authorization: `Bearer ${accessToken}` } },
@@ -26,7 +27,7 @@ function bucketAsUser(accessToken: string) {
function bucketAsServiceRole() {
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
- if (!serviceKey) return null;
+ if (!serviceKey || !supabaseUrl) return null;
return createClient(supabaseUrl, serviceKey, { auth: noSession }).storage.from(PAYMENT_PROOFS_BUCKET);
}
@@ -49,7 +50,9 @@ export async function uploadPaymentProof(
/** True if the object exists AND the user can see it (the SELECT policy only shows a user their own folder). */
export async function paymentProofExists(accessToken: string, path: string): Promise {
- const { data, error } = await bucketAsUser(accessToken).exists(path);
+ const bucket = bucketAsUser(accessToken);
+ if (!bucket) return false;
+ const { data, error } = await bucket.exists(path);
return !error && data === true;
}
diff --git a/src/lib/security.ts b/src/lib/security.ts
index 5b7c616..c1dd56e 100644
--- a/src/lib/security.ts
+++ b/src/lib/security.ts
@@ -237,14 +237,20 @@ export interface RegistrationInput {
payment_proof_path: string;
/** 12-digit UPI transaction id. Always required. */
utr: string;
+ /** Someone else's referral code, normalized; null when left empty (optional). /api/register checks it exists. */
+ referred_by_code: string | null;
}
/** Must match GENDER_OPTIONS in validation.ts and the registrations.gender CHECK in supabase/schema.sql. */
export const REGISTRATION_GENDERS = ['male', 'female', 'others'] as const;
export type RegistrationGender = (typeof REGISTRATION_GENDERS)[number];
+/** Must match REFERRAL_CODE_PATTERN in validation.ts and registrations_referral_code_check in supabase/schema.sql. */
+export const REFERRAL_CODE_PATTERN = /^[A-HJ-NP-Z2-9]{6}$/;
+
/** Form field a validation error belongs to, so the registration form can show it on the right step. */
-export type RegistrationField = 'name' | 'college' | 'phone' | 'gender' | 'enrollment_no' | 'payfile' | 'utr' | 'form';
+export type RegistrationField =
+ | 'name' | 'college' | 'phone' | 'gender' | 'enrollment_no' | 'referred_by_code' | 'payfile' | 'utr' | 'form';
export type RegistrationValidation =
| { ok: true; data: RegistrationInput }
@@ -257,7 +263,7 @@ const optionalString = (v: unknown): string | null | undefined =>
* The one registration fee (INR) and the status every new registration starts in. There is no free or auto-confirmed
* tier: every registrant, whatever their email domain, pays and is reviewed by staff. Never read from the request.
*/
-export const REGISTRATION_FEE_INR = 1200;
+export const REGISTRATION_FEE_INR = 999;
export const NEW_REGISTRATION_STATUS = 'pending';
export const NIT_ROURKELA_COLLEGE = 'National Institute of Technology Rourkela';
@@ -292,6 +298,7 @@ export function validateRegistrationInput(
const gender = typeof b.gender === 'string' ? b.gender.trim().toLowerCase() : '';
const proofPath = optionalString(b.payment_proof_path);
const rawUtr = optionalString(b.utr);
+ const rawReferral = optionalString(b.referred_by_code);
if (!name || !rawPhone || !college) {
return {
@@ -300,7 +307,7 @@ export function validateRegistrationInput(
field: !name ? 'name' : !college ? 'college' : 'phone',
};
}
- if (enrollment === undefined || proofPath === undefined || rawUtr === undefined) {
+ if (enrollment === undefined || proofPath === undefined || rawUtr === undefined || rawReferral === undefined) {
return { ok: false, error: 'Invalid field types in registration payload.', field: 'form' };
}
if (name.length < 2 || name.length > 100) {
@@ -323,6 +330,16 @@ export function validateRegistrationInput(
return { ok: false, error: 'Enrollment number contains invalid characters.', field: 'enrollment_no' };
}
+ // Optional. Same normalization as the form (normalizeReferralCode in validation.ts): case, spaces and dashes ignored.
+ const referred_by_code = (rawReferral || '').replace(/[\s-]+/g, '').toUpperCase();
+ if (referred_by_code && !REFERRAL_CODE_PATTERN.test(referred_by_code)) {
+ return {
+ ok: false,
+ error: 'Referral codes are 6 letters and numbers. Check the code, or leave the field empty.',
+ field: 'referred_by_code',
+ };
+ }
+
if (!proofPath) {
return { ok: false, error: 'Payment screenshot is required.', field: 'payfile' };
}
@@ -346,6 +363,7 @@ export function validateRegistrationInput(
gender: gender as RegistrationGender,
payment_proof_path: proofPath,
utr,
+ referred_by_code: referred_by_code || null,
},
};
}
diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts
index 119f852..098e5f2 100644
--- a/src/lib/supabase.ts
+++ b/src/lib/supabase.ts
@@ -39,6 +39,10 @@ export interface Registration {
status: 'pending' | 'confirmed' | 'rejected';
reviewed_by?: string | null;
reviewed_at?: string | null;
+ /** This registrant's own code to share, generated by the database (absent on rows cached before referrals). */
+ referral_code?: string;
+ /** The registration whose referral code this registrant entered, if any. */
+ referred_by?: string | null;
created_at?: string;
updated_at?: string;
}
@@ -155,13 +159,38 @@ export function purgeLocalStorageTokens(): void {
} catch {}
}
+export function isSupabaseConfigured(): boolean {
+ return Boolean(supabaseUrl && supabaseKey);
+}
+
+export function isSupabaseAdminConfigured(): boolean {
+ const serviceKey =
+ process.env.SUPABASE_SERVICE_ROLE_KEY ||
+ process.env.SUPABASE_SERVICE_KEY;
+ return Boolean(supabaseUrl && (serviceKey || supabaseKey));
+}
+
// Client-side singleton Supabase client
let clientInstance: SupabaseClient | null = null;
+let dummyClientInstance: SupabaseClient | null = null;
+
+function getDummyClient(): SupabaseClient {
+ if (!dummyClientInstance) {
+ dummyClientInstance = createClient('https://placeholder.supabase.co', 'placeholder-anon-key', {
+ auth: {
+ persistSession: false,
+ autoRefreshToken: false,
+ },
+ });
+ }
+ return dummyClientInstance;
+}
export function getSupabase(): SupabaseClient {
if (clientInstance) return clientInstance;
if (!supabaseUrl || !supabaseKey) {
- console.warn('Supabase URL or Key is missing in environment variables.');
+ console.warn('Supabase URL or Key is missing in environment variables. Using placeholder client.');
+ return getDummyClient();
}
// Purge any stale tokens from localStorage immediately
@@ -230,6 +259,10 @@ export function getSupabaseAdmin(token?: string): SupabaseClient {
process.env.SUPABASE_SERVICE_ROLE_KEY ||
process.env.SUPABASE_SERVICE_KEY;
+ if (!supabaseUrl || (!serviceKey && !supabaseKey)) {
+ return getDummyClient();
+ }
+
if (serviceKey) {
return createClient(supabaseUrl, serviceKey, {
auth: {
@@ -541,7 +574,8 @@ export async function fetchUserRegistration(userId: string, email?: string): Pro
const cached = localStorage.getItem(`inv_reg_${userId}`) || (email ? localStorage.getItem(`inv_reg_${email}`) : null);
if (cached) {
const parsed = JSON.parse(cached);
- if (parsed && (parsed.registration_id || parsed.id)) {
+ // Copies cached before referral codes existed lack referral_code: those are fetched again below.
+ if (parsed && (parsed.registration_id || parsed.id) && parsed.referral_code) {
// Re-verify in background with server
setTimeout(() => {
fetchUserRegistrationServer(userId, email);
@@ -618,7 +652,10 @@ export async function fetchUserRegistrationServer(userId: string, email?: string
* Passes through /api/register which is protected by server auth middleware
*/
export async function createRegistration(
- regData: Omit
+ regData: Omit & {
+ /** Someone else's referral code entered in the form (optional). */
+ referred_by_code?: string;
+ }
): Promise<{
success: boolean;
registration?: Registration;
@@ -626,7 +663,10 @@ export async function createRegistration(
error?: string;
/** HTTP status of a failed request; undefined when no response arrived (network error). */
status?: number;
- /** Form field the server blamed for a failed request (name, college, phone, gender, enrollment_no, payfile, utr). */
+ /**
+ * Form field the server blamed for a failed request (name, college, phone, gender, enrollment_no,
+ * referred_by_code, payfile, utr).
+ */
field?: string;
}> {
try {
diff --git a/src/lib/validation.ts b/src/lib/validation.ts
index 3c44ec9..4e7ba03 100644
--- a/src/lib/validation.ts
+++ b/src/lib/validation.ts
@@ -25,6 +25,17 @@ export function isGender(value: unknown): value is Gender {
return GENDER_OPTIONS.some((g) => g.value === value);
}
+/**
+ * Referral codes: 6 characters from an alphabet without look-alikes (no 0/O, no 1/I). The database generates them
+ * (public.new_referral_code in supabase/schema.sql, section 14) and checks this same pattern.
+ */
+export const REFERRAL_CODE_PATTERN = /^[A-HJ-NP-Z2-9]{6}$/;
+
+/** What a registrant typed as someone's referral code, upper-cased without spaces or dashes ('' when left empty). */
+export function normalizeReferralCode(value: string): string {
+ return value.replace(/[\s-]+/g, '').toUpperCase();
+}
+
/**
* Validates whether a given URL is a legitimate Google Drive or Google Docs link.
*/
diff --git a/supabase/manual/add_referrals.sql b/supabase/manual/add_referrals.sql
new file mode 100644
index 0000000..e836ae9
--- /dev/null
+++ b/supabase/manual/add_referrals.sql
@@ -0,0 +1,115 @@
+-- ==============================================================================
+-- MIGRATION (run once in the Supabase SQL editor): referral codes and the referral leaderboard.
+--
+-- Why: every registrant now gets a shareable referral code, a new registrant may enter someone's code (optional),
+-- and admins / the registration team see a leaderboard of who referred the most approved registrations.
+-- This adds registrations.referral_code (generated by the database for every new row, and backfilled here for the
+-- existing ones) and registrations.referred_by, their checks and indexes, and the referral_leaderboard view.
+-- Nothing else changes: no existing column, policy or row (apart from the new code) is touched.
+--
+-- Run this BEFORE deploying the code that reads referral_code, or /api/register and the leaderboard fail.
+-- Matches section 14 of supabase/schema.sql. Kept out of supabase/migrations/ on purpose so the Supabase CLI never
+-- applies it automatically. Safe to re-run.
+-- ==============================================================================
+
+-- Every registration gets its own shareable referral_code when it is created (trg_assign_referral_code below); the
+-- registrant sees it on their pass. Entering someone else's code while registering is optional: /api/register looks
+-- it up and stores the referrer's registration in referred_by. A referral counts for the referrer once the referred
+-- registration is confirmed (payment approved); pending ones are shown apart, rejected ones never count.
+-- Both columns are fixed once written: staff reviewing through the API can only change status
+-- (trg_enforce_registration_review copies every other column back from OLD).
+-- Codes are 6 characters from an alphabet without look-alikes (no 0/O, no 1/I); the form upper-cases what is typed.
+ALTER TABLE public.registrations ADD COLUMN IF NOT EXISTS referral_code TEXT;
+ALTER TABLE public.registrations ADD COLUMN IF NOT EXISTS referred_by UUID REFERENCES public.registrations(id) ON DELETE SET NULL;
+
+CREATE OR REPLACE FUNCTION public.new_referral_code() RETURNS TEXT AS $$
+DECLARE
+ v_alphabet CONSTANT TEXT := 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
+ v_bytes BYTEA;
+ v_code TEXT;
+BEGIN
+ LOOP
+ -- The first 6 bytes of a v4 UUID are all CSPRNG output, and 256 is a multiple of 32, so every character is uniform.
+ v_bytes := uuid_send(gen_random_uuid());
+ v_code := '';
+ FOR i IN 0..5 LOOP
+ v_code := v_code || substr(v_alphabet, 1 + get_byte(v_bytes, i) % 32, 1);
+ END LOOP;
+ EXIT WHEN NOT EXISTS (SELECT 1 FROM public.registrations WHERE referral_code = v_code);
+ END LOOP;
+ RETURN v_code;
+END;
+$$ LANGUAGE plpgsql VOLATILE SET search_path = public;
+
+-- Always generated by the database, whoever inserts (a code in the insert is replaced). A trigger rather than a column
+-- default: SECURITY DEFINER, so no API role needs EXECUTE on new_referral_code().
+CREATE OR REPLACE FUNCTION public.assign_referral_code() RETURNS TRIGGER AS $$
+BEGIN
+ NEW.referral_code := public.new_referral_code();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public;
+
+DROP TRIGGER IF EXISTS trg_assign_referral_code ON public.registrations;
+CREATE TRIGGER trg_assign_referral_code
+ BEFORE INSERT ON public.registrations
+ FOR EACH ROW EXECUTE FUNCTION public.assign_referral_code();
+
+-- Codes for registrations created before referrals existed. One row per statement, so each new code is checked
+-- against the ones already handed out.
+DO $$
+DECLARE
+ v_id UUID;
+BEGIN
+ FOR v_id IN SELECT id FROM public.registrations WHERE referral_code IS NULL ORDER BY created_at, id LOOP
+ UPDATE public.registrations SET referral_code = public.new_referral_code() WHERE id = v_id;
+ END LOOP;
+END $$;
+
+ALTER TABLE public.registrations ALTER COLUMN referral_code SET NOT NULL;
+
+DO $$
+BEGIN
+ ALTER TABLE public.registrations DROP CONSTRAINT IF EXISTS registrations_referral_code_check;
+ ALTER TABLE public.registrations ADD CONSTRAINT registrations_referral_code_check
+ CHECK (referral_code ~ '^[A-HJ-NP-Z2-9]{6}$');
+ ALTER TABLE public.registrations DROP CONSTRAINT IF EXISTS registrations_referred_by_check;
+ ALTER TABLE public.registrations ADD CONSTRAINT registrations_referred_by_check
+ CHECK (referred_by IS NULL OR referred_by <> id);
+END $$;
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_registrations_referral_code ON public.registrations(referral_code);
+CREATE INDEX IF NOT EXISTS idx_registrations_referred_by ON public.registrations(referred_by);
+
+-- Leaderboard for the admin dashboard (admins and the registration team, through GET /api/admin/referrals).
+-- One row per referrer with at least one confirmed or pending referral; a rejected referrer is left out.
+-- rank counts confirmed referrals only (ties share a rank) and is computed over the whole board, before any filter
+-- the API adds, so searching never renumbers it.
+-- security_invoker: it runs with the caller's rights, so the registrations RLS decides what is counted (a regular
+-- user sees nothing: they can't read the rows of the people who used their code).
+DROP VIEW IF EXISTS public.referral_leaderboard;
+CREATE VIEW public.referral_leaderboard WITH (security_invoker = true) AS
+SELECT
+ rank() OVER (ORDER BY c.confirmed DESC)::INT AS rank,
+ r.id, r.registration_id, r.name, r.email, r.college, r.referral_code, r.status, r.created_at,
+ c.confirmed::INT AS confirmed_referrals,
+ c.pending::INT AS pending_referrals
+FROM (
+ SELECT
+ referred_by,
+ count(*) FILTER (WHERE status = 'confirmed') AS confirmed,
+ count(*) FILTER (WHERE status = 'pending') AS pending
+ FROM public.registrations
+ WHERE referred_by IS NOT NULL
+ GROUP BY referred_by
+) c
+JOIN public.registrations r ON r.id = c.referred_by
+WHERE r.status <> 'rejected' AND c.confirmed + c.pending > 0;
+
+REVOKE ALL ON public.referral_leaderboard FROM anon;
+REVOKE INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER ON public.referral_leaderboard FROM authenticated;
+GRANT SELECT ON public.referral_leaderboard TO authenticated;
+
+-- Internal: only the insert trigger and the backfill above use these.
+REVOKE EXECUTE ON FUNCTION public.new_referral_code() FROM PUBLIC, anon, authenticated, service_role;
+REVOKE EXECUTE ON FUNCTION public.assign_referral_code() FROM PUBLIC, anon, authenticated, service_role;
diff --git a/supabase/manual/registration_fee_999.sql b/supabase/manual/registration_fee_999.sql
new file mode 100644
index 0000000..b75725d
--- /dev/null
+++ b/supabase/manual/registration_fee_999.sql
@@ -0,0 +1,36 @@
+-- ==============================================================================
+-- MIGRATION (run once in the Supabase SQL editor): registration fee 1200 -> 999 in the database insert trigger.
+--
+-- Why: the fee is now ₹999. /api/register records REGISTRATION_FEE_INR (src/lib/security.ts) itself; this updates
+-- the defense-in-depth trigger public.enforce_registration_insert(), which fixes the amount of any registration an end
+-- user inserts directly (they have no INSERT privilege, so it never fires in normal use), to the same value.
+--
+-- What it does NOT do: it replaces this one function body only. No table, column, policy or existing row changes:
+-- registrations already stored at 1200 keep that amount.
+--
+-- Identical to the function in supabase/schema.sql, so re-running schema.sql has the same effect. Kept out of
+-- supabase/migrations/ on purpose so the Supabase CLI never applies it automatically. Safe to re-run.
+-- ==============================================================================
+
+CREATE OR REPLACE FUNCTION public.enforce_registration_insert() RETURNS TRIGGER AS $$
+DECLARE
+ v_email TEXT;
+ v_confirmed TIMESTAMPTZ;
+BEGIN
+ IF COALESCE(auth.role(), '') <> 'authenticated' THEN
+ RETURN NEW;
+ END IF;
+
+ SELECT lower(email), email_confirmed_at INTO v_email, v_confirmed FROM auth.users WHERE id = auth.uid();
+
+ NEW.user_id := auth.uid();
+ NEW.email := COALESCE(v_email, '');
+ NEW.registration_id := 'IV26-' || (1000 + (('x' || substr(md5(gen_random_uuid()::text), 1, 7))::bit(28)::int % 9000))::text;
+ NEW.reviewed_by := NULL;
+ NEW.reviewed_at := NULL;
+ NEW.student_type := CASE WHEN v_email LIKE '%@nitrkl.ac.in' AND v_confirmed IS NOT NULL THEN 'internal' ELSE 'external' END;
+ NEW.status := 'pending';
+ NEW.amount := 999;
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public;
diff --git a/supabase/schema.sql b/supabase/schema.sql
index e16dbd8..b370532 100644
--- a/supabase/schema.sql
+++ b/supabase/schema.sql
@@ -365,7 +365,7 @@ UPDATE TO authenticated USING (public.is_registration_staff(auth.uid())) WITH CH
-- Registrations inserted directly through the Supabase API by an end user can't choose their own status, fee,
-- student type or email: these are derived from the verified auth account exactly like /api/register does.
--- There is no free tier: every registration starts 'pending' at 1200 whatever the email domain; student_type is only
+-- There is no free tier: every registration starts 'pending' at 999 whatever the email domain; student_type is only
-- a reporting label (classifyStudentType in src/lib/security.ts).
CREATE OR REPLACE FUNCTION public.enforce_registration_insert() RETURNS TRIGGER AS $$
DECLARE
@@ -385,7 +385,7 @@ BEGIN
NEW.reviewed_at := NULL;
NEW.student_type := CASE WHEN v_email LIKE '%@nitrkl.ac.in' AND v_confirmed IS NOT NULL THEN 'internal' ELSE 'external' END;
NEW.status := 'pending';
- NEW.amount := 1200;
+ NEW.amount := 999;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public;
@@ -1304,3 +1304,107 @@ GRANT EXECUTE ON FUNCTION public.void_entry_checkin(UUID, TEXT) TO authenticated
GRANT EXECUTE ON FUNCTION public.reissue_entry_pass(UUID) TO authenticated;
GRANT EXECUTE ON FUNCTION public.my_entry_pass() TO authenticated;
GRANT EXECUTE ON FUNCTION public.claim_entry_pass_token(UUID, TEXT, TEXT) TO authenticated;
+
+
+-- 14. REFERRALS
+-- Every registration gets its own shareable referral_code when it is created (trg_assign_referral_code below); the
+-- registrant sees it on their pass. Entering someone else's code while registering is optional: /api/register looks
+-- it up and stores the referrer's registration in referred_by. A referral counts for the referrer once the referred
+-- registration is confirmed (payment approved); pending ones are shown apart, rejected ones never count.
+-- Both columns are fixed once written: staff reviewing through the API can only change status
+-- (trg_enforce_registration_review copies every other column back from OLD).
+-- Codes are 6 characters from an alphabet without look-alikes (no 0/O, no 1/I); the form upper-cases what is typed.
+ALTER TABLE public.registrations ADD COLUMN IF NOT EXISTS referral_code TEXT;
+ALTER TABLE public.registrations ADD COLUMN IF NOT EXISTS referred_by UUID REFERENCES public.registrations(id) ON DELETE SET NULL;
+
+CREATE OR REPLACE FUNCTION public.new_referral_code() RETURNS TEXT AS $$
+DECLARE
+ v_alphabet CONSTANT TEXT := 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
+ v_bytes BYTEA;
+ v_code TEXT;
+BEGIN
+ LOOP
+ -- The first 6 bytes of a v4 UUID are all CSPRNG output, and 256 is a multiple of 32, so every character is uniform.
+ v_bytes := uuid_send(gen_random_uuid());
+ v_code := '';
+ FOR i IN 0..5 LOOP
+ v_code := v_code || substr(v_alphabet, 1 + get_byte(v_bytes, i) % 32, 1);
+ END LOOP;
+ EXIT WHEN NOT EXISTS (SELECT 1 FROM public.registrations WHERE referral_code = v_code);
+ END LOOP;
+ RETURN v_code;
+END;
+$$ LANGUAGE plpgsql VOLATILE SET search_path = public;
+
+-- Always generated by the database, whoever inserts (a code in the insert is replaced). A trigger rather than a column
+-- default: SECURITY DEFINER, so no API role needs EXECUTE on new_referral_code().
+CREATE OR REPLACE FUNCTION public.assign_referral_code() RETURNS TRIGGER AS $$
+BEGIN
+ NEW.referral_code := public.new_referral_code();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public;
+
+DROP TRIGGER IF EXISTS trg_assign_referral_code ON public.registrations;
+CREATE TRIGGER trg_assign_referral_code
+ BEFORE INSERT ON public.registrations
+ FOR EACH ROW EXECUTE FUNCTION public.assign_referral_code();
+
+-- Codes for registrations created before referrals existed. One row per statement, so each new code is checked
+-- against the ones already handed out.
+DO $$
+DECLARE
+ v_id UUID;
+BEGIN
+ FOR v_id IN SELECT id FROM public.registrations WHERE referral_code IS NULL ORDER BY created_at, id LOOP
+ UPDATE public.registrations SET referral_code = public.new_referral_code() WHERE id = v_id;
+ END LOOP;
+END $$;
+
+ALTER TABLE public.registrations ALTER COLUMN referral_code SET NOT NULL;
+
+DO $$
+BEGIN
+ ALTER TABLE public.registrations DROP CONSTRAINT IF EXISTS registrations_referral_code_check;
+ ALTER TABLE public.registrations ADD CONSTRAINT registrations_referral_code_check
+ CHECK (referral_code ~ '^[A-HJ-NP-Z2-9]{6}$');
+ ALTER TABLE public.registrations DROP CONSTRAINT IF EXISTS registrations_referred_by_check;
+ ALTER TABLE public.registrations ADD CONSTRAINT registrations_referred_by_check
+ CHECK (referred_by IS NULL OR referred_by <> id);
+END $$;
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_registrations_referral_code ON public.registrations(referral_code);
+CREATE INDEX IF NOT EXISTS idx_registrations_referred_by ON public.registrations(referred_by);
+
+-- Leaderboard for the admin dashboard (admins and the registration team, through GET /api/admin/referrals).
+-- One row per referrer with at least one confirmed or pending referral; a rejected referrer is left out.
+-- rank counts confirmed referrals only (ties share a rank) and is computed over the whole board, before any filter
+-- the API adds, so searching never renumbers it.
+-- security_invoker: it runs with the caller's rights, so the registrations RLS decides what is counted (a regular
+-- user sees nothing: they can't read the rows of the people who used their code).
+DROP VIEW IF EXISTS public.referral_leaderboard;
+CREATE VIEW public.referral_leaderboard WITH (security_invoker = true) AS
+SELECT
+ rank() OVER (ORDER BY c.confirmed DESC)::INT AS rank,
+ r.id, r.registration_id, r.name, r.email, r.college, r.referral_code, r.status, r.created_at,
+ c.confirmed::INT AS confirmed_referrals,
+ c.pending::INT AS pending_referrals
+FROM (
+ SELECT
+ referred_by,
+ count(*) FILTER (WHERE status = 'confirmed') AS confirmed,
+ count(*) FILTER (WHERE status = 'pending') AS pending
+ FROM public.registrations
+ WHERE referred_by IS NOT NULL
+ GROUP BY referred_by
+) c
+JOIN public.registrations r ON r.id = c.referred_by
+WHERE r.status <> 'rejected' AND c.confirmed + c.pending > 0;
+
+REVOKE ALL ON public.referral_leaderboard FROM anon;
+REVOKE INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER ON public.referral_leaderboard FROM authenticated;
+GRANT SELECT ON public.referral_leaderboard TO authenticated;
+
+-- Internal: only the insert trigger and the backfill above use these.
+REVOKE EXECUTE ON FUNCTION public.new_referral_code() FROM PUBLIC, anon, authenticated, service_role;
+REVOKE EXECUTE ON FUNCTION public.assign_referral_code() FROM PUBLIC, anon, authenticated, service_role;