Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ src/
│ └── events/ # Chrome for the /events pages
├── config/
│ ├── launch.ts # Launch switches (registrations, merch store)
│ ├── payment.ts # Registration fee, UPI ID and UPI payment link
│ └── site.ts # Canonical URL and site name
├── lib/ # Server and shared helpers: supabase, imagekit, auth, validation, security, seo
└── proxy.ts # Request proxy (Next 16's middleware): CSRF, rate limits, auth gate
Expand Down Expand Up @@ -188,6 +189,13 @@ export const MERCH_STORE_OPEN = true;
```

While a switch is `false`, its buttons and links lead to a "coming soon" page, and the real flow stays untouched.
The registration APIs (`/api/register`, `/api/upload`) check the same switch on the server.

### Change the registration fee or UPI ID

Edit [`src/config/payment.ts`](src/config/payment.ts). The fee must match `REGISTRATION_FEE_INR` in
[`src/lib/security.ts`](src/lib/security.ts), which is what the server records. Then regenerate
`public/assets/upi-qr.svg` from the new `UPI_PAY_LINK` with any QR encoder, and scan it to check before deploying.

### Add gallery photos

Expand Down
1 change: 1 addition & 0 deletions public/assets/upi-qr.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 6 additions & 6 deletions src/app/api/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ async function utrConflictResponse(client: SupabaseClient, userId: string) {
});
}
return NextResponse.json(
{ error: 'This UPI transaction ID (UTR) has already been used for another registration.' },
{ error: 'This UPI transaction ID (UTR) has already been used for another registration.', field: 'utr' },
{ status: 409 }
);
}
Expand Down Expand Up @@ -142,21 +142,21 @@ export async function POST(req: NextRequest) {
// 2. Parse and Validate Form Payload
const body = await readJsonObject(req);
if (!body.ok) {
return NextResponse.json({ error: body.error }, { status: body.status });
return NextResponse.json({ error: body.error, field: 'form' }, { status: body.status });
}

// ownerId comes from the verified session: a payment proof path is only accepted inside this user's own folder.
const parsed = validateRegistrationInput(body.value, { ownerId: user.id });
if (!parsed.ok) {
return NextResponse.json({ error: parsed.error }, { status: 400 });
return NextResponse.json({ error: parsed.error, field: parsed.field }, { status: 400 });
}
const input = parsed.data;
const college = studentType === 'internal' ? NIT_ROURKELA_COLLEGE : input.college;

// Exclusion rule: Students from ITER - SOA are not allowed to register
if (isIterSoaCollege(college) || isIterSoaEmail(verifiedEmail)) {
return NextResponse.json(
{ error: ITER_SOA_ERROR_MESSAGE },
{ error: ITER_SOA_ERROR_MESSAGE, field: 'college' },
{ status: 403 }
);
}
Expand Down Expand Up @@ -192,7 +192,7 @@ export async function POST(req: NextRequest) {
// so Storage RLS only lets them see their own folder).
if (!(await paymentProofExists(token, input.payment_proof_path))) {
return NextResponse.json(
{ error: 'Payment screenshot not found. Please re-upload your screenshot.' },
{ error: 'Payment screenshot not found. Please re-upload your screenshot.', field: 'payfile' },
{ status: 400 }
);
}
Expand Down Expand Up @@ -234,7 +234,7 @@ export async function POST(req: NextRequest) {
return utrConflictResponse(adminClient, user.id);
}
if (isDuplicatePhoneError(regError)) {
return NextResponse.json({ error: PHONE_IN_USE_ERROR_MESSAGE }, { status: 409 });
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 ?? ''}`)) {
Expand Down
18 changes: 13 additions & 5 deletions src/app/register/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,19 @@ import RegistrationsOpeningSoon from '@/components/coming-soon/RegistrationsOpen
import { pageMetadata } from '@/lib/seo';
import { redirectToRegistrationFlow } from './redirectToRegistrationFlow';

export const metadata = pageMetadata({
title: 'Registrations Opening Soon',
description: 'Registrations for Innovision 2026: The Celestial Odyssey, the techno-management fest of NIT Rourkela, Odisha, open soon.',
path: '/register',
});
export const metadata = pageMetadata(
REGISTRATIONS_OPEN
? {
title: 'Register',
description: 'Register for Innovision 2026: The Celestial Odyssey, the techno-management fest of NIT Rourkela, Odisha.',
path: '/register',
}
: {
title: 'Registrations Opening Soon',
description: 'Registrations for Innovision 2026: The Celestial Odyssey, the techno-management fest of NIT Rourkela, Odisha, open soon.',
path: '/register',
}
);

/**
* /register page route.
Expand Down
2 changes: 1 addition & 1 deletion src/components/innovision/AuthOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ export default function AuthOverlay({ v }: { v: V }) {
<h3 data-s-in="" style={{ margin: "0 0 4px", fontFamily: "var(--font-display)", fontWeight: "400", fontSize: "clamp(24px,2.2vw,32px)", lineHeight: "1.1" }}>Pay the fee</h3>
<div data-s-in="" style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit,minmax(min(100%,200px),1fr))", gap: "28px", alignItems: "center" }}>
<div style={{ position: "relative", width: "100%", maxWidth: "240px", justifySelf: "center", aspectRatio: "1", padding: "12px", background: "#ECE8DF" }}>
<image-slot id="upi-qr" shape="rect" fit="contain" placeholder="Drop your UPI QR code" style={{ display: "block", width: "100%", height: "100%" }}></image-slot>
<img src={v.upiQr} alt={`UPI QR code: pay ₹${v.fee} to ${v.upi}`} width={240} height={240} decoding="async" draggable={false} style={{ display: "block", width: "100%", height: "100%", objectFit: "contain", imageRendering: "pixelated" }} />
<span style={{ position: "absolute", inset: "0", overflow: "hidden", pointerEvents: "none" }}><span data-scan="qr" style={{ position: "absolute", inset: "0", opacity: "0" }}><span style={{ position: "absolute", left: "0", right: "0", top: "-64px", height: "64px", background: "linear-gradient(rgba(220,183,106,0),rgba(220,183,106,.3))" }}></span><span style={{ position: "absolute", left: "0", right: "0", top: "0", height: "2px", background: "#F3DFA8" }}></span></span></span>
<span style={{ position: "absolute", left: "-9px", top: "-9px", borderLeftWidth: "2px", borderTopWidth: "2px", width: "22px", height: "22px", borderColor: "oklch(0.8 0.12 85)", borderStyle: "solid", borderWidth: "0" }}></span>
<span style={{ position: "absolute", right: "-9px", top: "-9px", borderRightWidth: "2px", borderTopWidth: "2px", width: "22px", height: "22px", borderColor: "oklch(0.8 0.12 85)", borderStyle: "solid", borderWidth: "0" }}></span>
Expand Down
55 changes: 40 additions & 15 deletions src/components/innovision/Innovision.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
type GalleryPhoto,
} from '@/lib/supabase';
import { REGISTRATIONS_OPEN, REGISTRATIONS_SOON_PATH, MERCH_STORE_OPEN, MERCH_SOON_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 { isIterSoaCollege, isIterSoaEmail, ITER_SOA_ERROR_MESSAGE, isValidGoogleDriveUrl, GENDER_OPTIONS as GENDERS } from '@/lib/validation';
import { httpErrorMessage, isTransientFailure } from '@/lib/api-errors';
Expand Down Expand Up @@ -363,12 +364,17 @@ export class Innovision extends Component<Props, State> {
if (!REGISTRATIONS_OPEN && (isOpenRegister || search.get('required') === 'register')) {
this.showRegistrationsSoon(true);
} else if (isAuthRequired) {
// Remembered across the Google sign-in round trip, so the student lands on the form once signed in.
if (search.get('required') === 'register') sessionStorage.setItem('inv_pending_action', 'register');
setTimeout(() => {
this.openAuth('login');
this.toast('Please log in to register.');
}, 1200);
} else if (isOpenRegister) {
setTimeout(() => {
// Waits for the session check, so a signed-in student isn't sent to log in first.
setTimeout(async () => {
if (!this._authReady.done) await this._authReady.p;
if (!this.alive) return;
if (!this.state.user) {
sessionStorage.setItem('inv_pending_action', 'register');
this.openAuth('login');
Expand Down Expand Up @@ -1843,9 +1849,9 @@ export class Innovision extends Component<Props, State> {
// @ts-ignore
newId() { return 'IV26-' + (Math.floor(Math.random() * 9000) + 1000); }
drop(err, k) { const e = { ...err }; delete e[k]; return e; }
fee() { const n = Math.round(Number(this.props.regFee ?? 499)); return n > 0 ? n : 499; }
fee() { return REGISTRATION_FEE; }
// @ts-ignore
upi() { return String(this.props.upiId || 'innovision@sbi').trim(); }
upi() { return UPI_ID; }
speed() { const n = Number(this.props.riftSpeed ?? 1); return n > 0 ? n : 1; }
// @ts-ignore
fmtSize(n) { return n < 1048576 ? Math.max(1, Math.round(n / 1024)) + ' KB' : (n / 1048576).toFixed(1) + ' MB'; }
Expand Down Expand Up @@ -2131,7 +2137,7 @@ export class Innovision extends Component<Props, State> {
const isImg = /^image\//.test(f.type);

if (!isImg) {
this.fail({ [ek]: 'Upload the screenshot as an image (JPG or PNG).' });
this.fail({ [ek]: 'Upload the screenshot as an image (JPG, PNG or WebP).' });
return;
}

Expand Down Expand Up @@ -2307,14 +2313,20 @@ export class Innovision extends Component<Props, State> {
const gender = v('gender');

if (name.length < 2) err.name = 'Tell us your full name.';
else if (name.length > 100) err.name = 'Keep your name under 100 characters.';
if (!GENDERS.some((g) => g.value === gender)) err.gender = 'Select your gender.';
if (!isInternal && college.length < 3) {
err.college = 'Which college are you from?';
} else if (!isInternal && college.length > 200) {
err.college = 'Keep the college name under 200 characters.';
} else if (!isInternal && (isIterSoaCollege(college) || isIterSoaEmail(email))) {
err.college = ITER_SOA_ERROR_MESSAGE;
}
if (!/^[6-9]\d{9}$/.test(phone)) err.phone = 'Use a 10-digit Indian mobile number.';
if (isInternal && !enrollment_no) err.enrollment_no = 'Enter your NIT Rourkela Roll / Enrollment number.';
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).';
}

if (this.fail(err)) return;
this.reg = { name, college, email, phone, enrollment_no, gender };
Expand Down Expand Up @@ -2360,18 +2372,31 @@ export class Innovision extends Component<Props, State> {
const saveRes = await createRegistration(regPayload);
const savedReg = saveRes.registration || (saveRes.success ? regPayload : null);
if (!savedReg) {
this.toast(saveRes.error || 'Registration failed');
if (isTransientFailure(saveRes.status)) {
// Rate limited, server error or offline: stay on this step with the UTR and screenshot intact, so the
// student can submit again by hand once they have waited. Nothing is resubmitted automatically.
this.setState({ busyLbl: '', err: { form: saveRes.error || 'Registration failed' } });
const msg = saveRes.error || 'Registration failed';
this.toast(msg);
const field = saveRes.field || '';
const detailsField = ['name', 'college', 'phone', 'gender', 'enrollment_no'].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.
this.setState({ busyLbl: '', err: { form: msg } });
return;
}
this.setState({
busyLbl: '',
step: 0,
err: { college: saveRes.error || 'Registration failed' },
});
if (field === 'payfile') {
// The stored screenshot can't be used: pick it again (a fresh upload gets a fresh storage path).
this.removeFile('pay');
this.setState({ busyLbl: '', err: { payfile: msg } });
return;
}
if (field === 'utr') {
this.setState({ busyLbl: '' });
this.fail({ utr: msg });
return;
}
// Details from the first step (name, college, phone, gender, enrollment number): go back to fix them.
this.setState({ busyLbl: '' });
this.toStep(0);
this.setState({ err: { [field]: msg } });
return;
}

Expand Down Expand Up @@ -2471,7 +2496,7 @@ export class Innovision extends Component<Props, State> {
err: E, bc, inv, genderOptions: GENDERS,
upPay: up('pay', 'payfile', 'Drop the screenshot here or browse (Max 1MB)'),
fee, upi, copyUpi: this.copyUpi, copyLbl: s.copied ? 'COPIED' : 'COPY',
upiLink: 'upi://pay?pa=' + encodeURIComponent(upi) + '&pn=' + encodeURIComponent('Innovision NIT Rourkela') + '&am=' + fee + '&cu=INR&tn=' + encodeURIComponent('Innovision 2026 registration'),
upiLink: UPI_PAY_LINK, upiQr: UPI_QR_SRC,
upiAppD: s.narrow ? 'inline-flex' : 'none',
authSubmit: this.authSubmit, clearErr: this.clearErr, switchMode: this.switchMode, closeAuthH: () => this.closeAuth(),
showSwitch: am !== 'pass' && !reg && !!s.user,
Expand Down
2 changes: 1 addition & 1 deletion src/config/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* registration: src/app/register/redirectToRegistrationFlow.ts → the auth / registration overlay in Innovision.tsx
* merch: the #/merch store view, src/components/innovision/MerchView.tsx
*/
export const REGISTRATIONS_OPEN = false;
export const REGISTRATIONS_OPEN = true;
export const MERCH_STORE_OPEN = false;

/** Where the REGISTER buttons point while registrations are closed. */
Expand Down
26 changes: 26 additions & 0 deletions src/config/payment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Registration payment details shown on the "Pay the fee" step of the registration form.
*
* The server never reads these: POST /api/register always records REGISTRATION_FEE_INR from src/lib/security.ts.
* Keep REGISTRATION_FEE in step with it (a unit test checks they match).
*
* public/assets/upi-qr.svg encodes UPI_PAY_LINK. If the UPI ID, payee name or fee changes, regenerate it from the new
* link (any QR encoder, error correction M) and check it scans to the new link.
*/
export const REGISTRATION_FEE = 499;
export const UPI_ID = 'innovision@sbi';
export const UPI_PAYEE_NAME = 'Innovision NIT Rourkela';
export const UPI_NOTE = 'Innovision 2026 registration';

/**
* `upi://pay` deep link: opens the student's UPI app with the payee, amount and note filled in. The VPA stays
* unencoded (its characters are URL-safe, and some UPI apps reject `%40` in place of `@`).
*/
export const UPI_PAY_LINK =
'upi://pay?pa=' + UPI_ID +
'&pn=' + encodeURIComponent(UPI_PAYEE_NAME) +
'&am=' + REGISTRATION_FEE +
'&cu=INR&tn=' + encodeURIComponent(UPI_NOTE);

/** Static QR code for UPI_PAY_LINK. */
export const UPI_QR_SRC = '/assets/upi-qr.svg';
Loading