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

- + {`UPI 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 } : {}), }; }