Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
49ae386
Made a seperate route for merch
meenamrutha03 Oct 18, 2026
eb15b43
Fix Netlify deploy error by adding ImageKit environment variables (6a…
netlify-bot Oct 18, 2026
3b2fd99
Merge pull request #2 from meenamrutha03/agent-variables-1c40
meenamrutha03 Oct 18, 2026
5a484f5
Merge pull request #43 from ABHImaybeJEET/development
Krish-2118 Oct 18, 2026
048d6b6
created a separate merch route
meenamrutha03 Oct 18, 2026
6b71b06
Merge branch 'development' into development
Krish-2118 Oct 18, 2026
6813d47
Merge pull request #45 from meenamrutha03/development
Krish-2118 Oct 18, 2026
c0b70da
feat(merch): move the merch store to its own /merch page
Krish-2118 Oct 19, 2026
b164e3e
feat(register): run the real registration flow on localhost dev
Krish-2118 Oct 19, 2026
ee23ee4
feat(referrals): add referral codes and a staff referral leaderboard
Krish-2118 Oct 19, 2026
c611e32
feat(errors): add custom 404 and error pages
Krish-2118 Oct 19, 2026
1a4ae81
feat(payment): set the registration fee to ₹999
Krish-2118 Oct 19, 2026
7418265
Merge branch 'development' of OpencodeNIT-R/project-spacex into main
Krish-2118 Oct 19, 2026
b687a00
feat(register): open registrations in production
Krish-2118 Oct 19, 2026
6a4a245
fix: resolve syntax and export errors, update merch orbit view and as…
ABHImaybeJEET Oct 19, 2026
b9155fd
Merge pull request #46 from ABHImaybeJEET/development
Krish-2118 Oct 20, 2026
2a5ad99
Merge branch 'development' of OpencodeNIT-R/project-spacex into main
Krish-2118 Oct 20, 2026
f1e890d
feat: referral code and merch page
Krish-2118 Oct 20, 2026
086da54
Merge pull request #47 from Krish-2118/development
Krish-2118 Oct 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const eslintConfig = defineConfig([
"next-env.d.ts",
"test-results/**",
"playwright-report/**",
"scripts/**",
"designer-handoff/**",
]),
]);

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\"",
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/admin/events/order/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
12 changes: 1 addition & 11 deletions src/app/api/admin/events/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,14 @@ 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';

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. */
Expand Down
69 changes: 69 additions & 0 deletions src/app/api/admin/referrals/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
6 changes: 5 additions & 1 deletion src/app/api/events/route.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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')
Expand Down
6 changes: 5 additions & 1 deletion src/app/api/gallery/route.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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')
Expand Down
31 changes: 29 additions & 2 deletions src/app/api/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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({
Expand Down
6 changes: 5 additions & 1 deletion src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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}}
4 changes: 2 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ export const viewport: Viewport = {

export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en" className={`${dmSerif.variable} ${manrope.variable}`}>
<body>
<html lang="en" className={`${dmSerif.variable} ${manrope.variable}`} suppressHydrationWarning>
<body suppressHydrationWarning>
{children}
<Analytics />
</body>
Expand Down
16 changes: 16 additions & 0 deletions src/app/merch/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -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',
});
}
37 changes: 24 additions & 13 deletions src/app/merch/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <MerchDroppingSoon />;
redirect('/#/merch');
export default function MerchRoute() {
return MERCH_STORE_OPEN ? <MerchPage /> : <MerchDroppingSoon />;
}
2 changes: 2 additions & 0 deletions src/app/merch/twitter-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// The X/Twitter card uses the same image as Open Graph.
export { default, alt, size, contentType } from './opengraph-image';
59 changes: 59 additions & 0 deletions src/app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -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/<unknown world>, which
* dynamicParams = false already turns away). Rendered inside the root layout, so fonts and globals.css apply.
*/
export default function NotFound() {
return (
<ErrorScreen
code="404"
eyebrow="ERROR 404 · OFF THE STAR MAP"
title="This page drifted out of orbit."
actions={
<>
<Link href="/" style={primaryAction}>BACK TO HOME</Link>
<Link href="/events" style={ghostAction}>
<GhostFill />
EXPLORE EVENTS
</Link>
</>
}
after={
<nav aria-label="Popular pages" style={{ marginTop: '40px', paddingTop: '28px', borderTop: '1px solid rgba(236,232,223,.14)' }}>
<p style={{ margin: '0 0 16px', fontSize: '12px', fontWeight: 700, letterSpacing: '.24em', color: 'rgba(236,232,223,.6)' }}>OR JUMP STRAIGHT TO</p>
<ul style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', gap: '10px', margin: 0, padding: 0, listStyle: 'none' }}>
{DESTINATIONS.map((d) => (
<li key={d.href}>
<Link href={d.href} className="nf-chip" style={{ display: 'inline-flex', alignItems: 'center', gap: '10px', minHeight: '44px', padding: '0 16px', border: '1px solid rgba(236,232,223,.22)', background: 'rgba(236,232,223,.04)', color: '#ECE8DF', fontSize: '14px', fontWeight: 600, textDecoration: 'none' }}>
<span aria-hidden="true" style={{ width: '8px', height: '8px', borderRadius: '50%', background: d.dot }}></span>
{d.label}
</Link>
</li>
))}
</ul>
</nav>
}
>
The link may be old or mistyped, or the page has moved. Nothing is lost: head back to base, or pick a destination
below.
</ErrorScreen>
);
}
11 changes: 6 additions & 5 deletions src/app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -8,9 +8,10 @@ import { WORLDS } from '@/components/innovision/data';
*
* /events and one page per event world (/events/<slug>) 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.
*/
Expand All @@ -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',
];
Expand Down
Loading
Loading