diff --git a/README.md b/README.md
index 1416f19..4a59339 100644
--- a/README.md
+++ b/README.md
@@ -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
@@ -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
diff --git a/public/assets/upi-qr.svg b/public/assets/upi-qr.svg
new file mode 100644
index 0000000..5b37fdd
--- /dev/null
+++ b/public/assets/upi-qr.svg
@@ -0,0 +1 @@
+
diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts
index d5d18ba..8d89ae0 100644
--- a/src/app/api/register/route.ts
+++ b/src/app/api/register/route.ts
@@ -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 }
);
}
@@ -142,13 +142,13 @@ 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;
@@ -156,7 +156,7 @@ export async function POST(req: NextRequest) {
// 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 }
);
}
@@ -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 }
);
}
@@ -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 ?? ''}`)) {
diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx
index 2b3095a..62e1bfb 100644
--- a/src/app/register/page.tsx
+++ b/src/app/register/page.tsx
@@ -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.
diff --git a/src/components/innovision/AuthOverlay.tsx b/src/components/innovision/AuthOverlay.tsx
index 5c859bf..002dec2 100644
--- a/src/components/innovision/AuthOverlay.tsx
+++ b/src/components/innovision/AuthOverlay.tsx
@@ -193,7 +193,7 @@ export default function AuthOverlay({ v }: { v: V }) {
Pay the fee
-
+
diff --git a/src/components/innovision/Innovision.tsx b/src/components/innovision/Innovision.tsx
index c4df0e9..6f47c92 100644
--- a/src/components/innovision/Innovision.tsx
+++ b/src/components/innovision/Innovision.tsx
@@ -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';
@@ -363,12 +364,17 @@ export class Innovision extends Component
{
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');
@@ -1843,9 +1849,9 @@ export class Innovision extends Component {
// @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'; }
@@ -2131,7 +2137,7 @@ export class Innovision extends Component {
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;
}
@@ -2307,14 +2313,20 @@ export class Innovision extends Component {
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 };
@@ -2360,18 +2372,31 @@ export class Innovision extends Component {
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;
}
@@ -2471,7 +2496,7 @@ export class Innovision extends Component {
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,
diff --git a/src/config/launch.ts b/src/config/launch.ts
index 11d154e..c1091de 100644
--- a/src/config/launch.ts
+++ b/src/config/launch.ts
@@ -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. */
diff --git a/src/config/payment.ts b/src/config/payment.ts
new file mode 100644
index 0000000..fa389d0
--- /dev/null
+++ b/src/config/payment.ts
@@ -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';
diff --git a/src/lib/security.ts b/src/lib/security.ts
index 8a96a16..2537604 100644
--- a/src/lib/security.ts
+++ b/src/lib/security.ts
@@ -243,9 +243,12 @@ export interface RegistrationInput {
export const REGISTRATION_GENDERS = ['male', 'female', 'others'] as const;
export type RegistrationGender = (typeof REGISTRATION_GENDERS)[number];
+/** 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 RegistrationValidation =
| { ok: true; data: RegistrationInput }
- | { ok: false; error: string };
+ | { ok: false; error: string; field: RegistrationField };
const optionalString = (v: unknown): string | null | undefined =>
v === undefined || v === null ? null : typeof v === 'string' ? v : undefined;
@@ -278,7 +281,7 @@ export function validateRegistrationInput(
opts: { ownerId: string }
): RegistrationValidation {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
- return { ok: false, error: 'Invalid request body.' };
+ return { ok: false, error: 'Invalid request body.', field: 'form' };
}
const b = body as Record;
@@ -291,42 +294,46 @@ export function validateRegistrationInput(
const rawUtr = optionalString(b.utr);
if (!name || !rawPhone || !college) {
- return { ok: false, error: 'Missing required fields: name, college, and phone are mandatory.' };
+ return {
+ ok: false,
+ error: 'Missing required fields: name, college, and phone are mandatory.',
+ field: !name ? 'name' : !college ? 'college' : 'phone',
+ };
}
if (enrollment === undefined || proofPath === undefined || rawUtr === undefined) {
- return { ok: false, error: 'Invalid field types in registration payload.' };
+ return { ok: false, error: 'Invalid field types in registration payload.', field: 'form' };
}
if (name.length < 2 || name.length > 100) {
- return { ok: false, error: 'Name must be between 2 and 100 characters.' };
+ return { ok: false, error: 'Name must be between 2 and 100 characters.', field: 'name' };
}
if (college.length < 3 || college.length > 200) {
- return { ok: false, error: 'College name must be between 3 and 200 characters.' };
+ return { ok: false, error: 'College name must be between 3 and 200 characters.', field: 'college' };
}
if (!(REGISTRATION_GENDERS as readonly string[]).includes(gender)) {
- return { ok: false, error: 'Select a gender: male, female or others.' };
+ return { ok: false, error: 'Select a gender: male, female or others.', field: 'gender' };
}
const phone = rawPhone.replace(/\D/g, '').replace(/^(91|0)(?=\d{10}$)/, '');
if (!/^[6-9]\d{9}$/.test(phone)) {
- return { ok: false, error: 'Use a valid 10-digit Indian mobile number.' };
+ return { ok: false, error: 'Use a valid 10-digit Indian mobile number.', field: 'phone' };
}
const enrollment_no = enrollment ? enrollment.trim() : '';
if (enrollment_no.length > 50 || (enrollment_no && !/^[A-Za-z0-9/_ -]+$/.test(enrollment_no))) {
- return { ok: false, error: 'Enrollment number contains invalid characters.' };
+ return { ok: false, error: 'Enrollment number contains invalid characters.', field: 'enrollment_no' };
}
if (!proofPath) {
- return { ok: false, error: 'Payment screenshot is required.' };
+ return { ok: false, error: 'Payment screenshot is required.', field: 'payfile' };
}
// The path is only accepted inside the caller's own folder (ownerId comes from the verified session), so a
// registration can never reference another user's proof. /api/register then checks the object really exists.
if (!isPaymentProofPath(proofPath, opts.ownerId)) {
- return { ok: false, error: 'Invalid payment screenshot upload. Please re-upload your screenshot.' };
+ return { ok: false, error: 'Invalid payment screenshot upload. Please re-upload your screenshot.', field: 'payfile' };
}
const utr = (rawUtr || '').replace(/\s+/g, '');
if (!/^\d{12}$/.test(utr)) {
- return { ok: false, error: 'A valid 12-digit UPI transaction ID (UTR) is required.' };
+ return { ok: false, error: 'A valid 12-digit UPI transaction ID (UTR) is required.', field: 'utr' };
}
return {
diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts
index a4e2ed2..e1a9fbb 100644
--- a/src/lib/supabase.ts
+++ b/src/lib/supabase.ts
@@ -608,6 +608,8 @@ 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). */
+ field?: string;
}> {
try {
const supabase = getSupabase();
@@ -639,6 +641,7 @@ export async function createRegistration(
success: false,
status: res.status,
error: httpErrorMessage(res) || result.message || result.error || 'Registration failed',
+ ...(typeof result.field === 'string' ? { field: result.field } : {}),
};
}