Skip to content

Commit f068f93

Browse files
authored
Merge pull request #29 from Krish-2118/main
Registration flow
2 parents 692a82d + 9b533a8 commit f068f93

10 files changed

Lines changed: 118 additions & 40 deletions

File tree

‎README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ src/
145145
│ └── events/ # Chrome for the /events pages
146146
├── config/
147147
│ ├── launch.ts # Launch switches (registrations, merch store)
148+
│ ├── payment.ts # Registration fee, UPI ID and UPI payment link
148149
│ └── site.ts # Canonical URL and site name
149150
├── lib/ # Server and shared helpers: supabase, imagekit, auth, validation, security, seo
150151
└── proxy.ts # Request proxy (Next 16's middleware): CSRF, rate limits, auth gate
@@ -188,6 +189,13 @@ export const MERCH_STORE_OPEN = true;
188189
```
189190

190191
While a switch is `false`, its buttons and links lead to a "coming soon" page, and the real flow stays untouched.
192+
The registration APIs (`/api/register`, `/api/upload`) check the same switch on the server.
193+
194+
### Change the registration fee or UPI ID
195+
196+
Edit [`src/config/payment.ts`](src/config/payment.ts). The fee must match `REGISTRATION_FEE_INR` in
197+
[`src/lib/security.ts`](src/lib/security.ts), which is what the server records. Then regenerate
198+
`public/assets/upi-qr.svg` from the new `UPI_PAY_LINK` with any QR encoder, and scan it to check before deploying.
191199

192200
### Add gallery photos
193201

‎public/assets/upi-qr.svg‎

Lines changed: 1 addition & 0 deletions
Loading

‎src/app/api/register/route.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ async function utrConflictResponse(client: SupabaseClient, userId: string) {
6161
});
6262
}
6363
return NextResponse.json(
64-
{ error: 'This UPI transaction ID (UTR) has already been used for another registration.' },
64+
{ error: 'This UPI transaction ID (UTR) has already been used for another registration.', field: 'utr' },
6565
{ status: 409 }
6666
);
6767
}
@@ -142,21 +142,21 @@ export async function POST(req: NextRequest) {
142142
// 2. Parse and Validate Form Payload
143143
const body = await readJsonObject(req);
144144
if (!body.ok) {
145-
return NextResponse.json({ error: body.error }, { status: body.status });
145+
return NextResponse.json({ error: body.error, field: 'form' }, { status: body.status });
146146
}
147147

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

156156
// Exclusion rule: Students from ITER - SOA are not allowed to register
157157
if (isIterSoaCollege(college) || isIterSoaEmail(verifiedEmail)) {
158158
return NextResponse.json(
159-
{ error: ITER_SOA_ERROR_MESSAGE },
159+
{ error: ITER_SOA_ERROR_MESSAGE, field: 'college' },
160160
{ status: 403 }
161161
);
162162
}
@@ -192,7 +192,7 @@ export async function POST(req: NextRequest) {
192192
// so Storage RLS only lets them see their own folder).
193193
if (!(await paymentProofExists(token, input.payment_proof_path))) {
194194
return NextResponse.json(
195-
{ error: 'Payment screenshot not found. Please re-upload your screenshot.' },
195+
{ error: 'Payment screenshot not found. Please re-upload your screenshot.', field: 'payfile' },
196196
{ status: 400 }
197197
);
198198
}
@@ -234,7 +234,7 @@ export async function POST(req: NextRequest) {
234234
return utrConflictResponse(adminClient, user.id);
235235
}
236236
if (isDuplicatePhoneError(regError)) {
237-
return NextResponse.json({ error: PHONE_IN_USE_ERROR_MESSAGE }, { status: 409 });
237+
return NextResponse.json({ error: PHONE_IN_USE_ERROR_MESSAGE, field: 'phone' }, { status: 409 });
238238
}
239239
// Unique violation on user_id (concurrent double submit): return the registration that won.
240240
if (!/registration_id/i.test(`${regError.message} ${regError.details ?? ''}`)) {

‎src/app/register/page.tsx‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,19 @@ import RegistrationsOpeningSoon from '@/components/coming-soon/RegistrationsOpen
33
import { pageMetadata } from '@/lib/seo';
44
import { redirectToRegistrationFlow } from './redirectToRegistrationFlow';
55

6-
export const metadata = pageMetadata({
7-
title: 'Registrations Opening Soon',
8-
description: 'Registrations for Innovision 2026: The Celestial Odyssey, the techno-management fest of NIT Rourkela, Odisha, open soon.',
9-
path: '/register',
10-
});
6+
export const metadata = pageMetadata(
7+
REGISTRATIONS_OPEN
8+
? {
9+
title: 'Register',
10+
description: 'Register for Innovision 2026: The Celestial Odyssey, the techno-management fest of NIT Rourkela, Odisha.',
11+
path: '/register',
12+
}
13+
: {
14+
title: 'Registrations Opening Soon',
15+
description: 'Registrations for Innovision 2026: The Celestial Odyssey, the techno-management fest of NIT Rourkela, Odisha, open soon.',
16+
path: '/register',
17+
}
18+
);
1119

1220
/**
1321
* /register page route.

‎src/components/innovision/AuthOverlay.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ export default function AuthOverlay({ v }: { v: V }) {
193193
<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>
194194
<div data-s-in="" style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit,minmax(min(100%,200px),1fr))", gap: "28px", alignItems: "center" }}>
195195
<div style={{ position: "relative", width: "100%", maxWidth: "240px", justifySelf: "center", aspectRatio: "1", padding: "12px", background: "#ECE8DF" }}>
196-
<image-slot id="upi-qr" shape="rect" fit="contain" placeholder="Drop your UPI QR code" style={{ display: "block", width: "100%", height: "100%" }}></image-slot>
196+
<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" }} />
197197
<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>
198198
<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>
199199
<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>

‎src/components/innovision/Innovision.tsx‎

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
type GalleryPhoto,
5151
} from '@/lib/supabase';
5252
import { REGISTRATIONS_OPEN, REGISTRATIONS_SOON_PATH, MERCH_STORE_OPEN, MERCH_SOON_PATH } from '@/config/launch';
53+
import { REGISTRATION_FEE, UPI_ID, UPI_PAY_LINK, UPI_QR_SRC } from '@/config/payment';
5354
import { SKIP_INTRO_KEY } from '@/components/coming-soon/SkipIntroOnReturn';
5455
import { isIterSoaCollege, isIterSoaEmail, ITER_SOA_ERROR_MESSAGE, isValidGoogleDriveUrl, GENDER_OPTIONS as GENDERS } from '@/lib/validation';
5556
import { httpErrorMessage, isTransientFailure } from '@/lib/api-errors';
@@ -363,12 +364,17 @@ export class Innovision extends Component<Props, State> {
363364
if (!REGISTRATIONS_OPEN && (isOpenRegister || search.get('required') === 'register')) {
364365
this.showRegistrationsSoon(true);
365366
} else if (isAuthRequired) {
367+
// Remembered across the Google sign-in round trip, so the student lands on the form once signed in.
368+
if (search.get('required') === 'register') sessionStorage.setItem('inv_pending_action', 'register');
366369
setTimeout(() => {
367370
this.openAuth('login');
368371
this.toast('Please log in to register.');
369372
}, 1200);
370373
} else if (isOpenRegister) {
371-
setTimeout(() => {
374+
// Waits for the session check, so a signed-in student isn't sent to log in first.
375+
setTimeout(async () => {
376+
if (!this._authReady.done) await this._authReady.p;
377+
if (!this.alive) return;
372378
if (!this.state.user) {
373379
sessionStorage.setItem('inv_pending_action', 'register');
374380
this.openAuth('login');
@@ -1843,9 +1849,9 @@ export class Innovision extends Component<Props, State> {
18431849
// @ts-ignore
18441850
newId() { return 'IV26-' + (Math.floor(Math.random() * 9000) + 1000); }
18451851
drop(err, k) { const e = { ...err }; delete e[k]; return e; }
1846-
fee() { const n = Math.round(Number(this.props.regFee ?? 499)); return n > 0 ? n : 499; }
1852+
fee() { return REGISTRATION_FEE; }
18471853
// @ts-ignore
1848-
upi() { return String(this.props.upiId || 'innovision@sbi').trim(); }
1854+
upi() { return UPI_ID; }
18491855
speed() { const n = Number(this.props.riftSpeed ?? 1); return n > 0 ? n : 1; }
18501856
// @ts-ignore
18511857
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<Props, State> {
21312137
const isImg = /^image\//.test(f.type);
21322138

21332139
if (!isImg) {
2134-
this.fail({ [ek]: 'Upload the screenshot as an image (JPG or PNG).' });
2140+
this.fail({ [ek]: 'Upload the screenshot as an image (JPG, PNG or WebP).' });
21352141
return;
21362142
}
21372143

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

23092315
if (name.length < 2) err.name = 'Tell us your full name.';
2316+
else if (name.length > 100) err.name = 'Keep your name under 100 characters.';
23102317
if (!GENDERS.some((g) => g.value === gender)) err.gender = 'Select your gender.';
23112318
if (!isInternal && college.length < 3) {
23122319
err.college = 'Which college are you from?';
2320+
} else if (!isInternal && college.length > 200) {
2321+
err.college = 'Keep the college name under 200 characters.';
23132322
} else if (!isInternal && (isIterSoaCollege(college) || isIterSoaEmail(email))) {
23142323
err.college = ITER_SOA_ERROR_MESSAGE;
23152324
}
23162325
if (!/^[6-9]\d{9}$/.test(phone)) err.phone = 'Use a 10-digit Indian mobile number.';
23172326
if (isInternal && !enrollment_no) err.enrollment_no = 'Enter your NIT Rourkela Roll / Enrollment number.';
2327+
else if (enrollment_no.length > 50 || (enrollment_no && !/^[A-Za-z0-9/_ -]+$/.test(enrollment_no))) {
2328+
err.enrollment_no = 'Use only letters, numbers, spaces and / _ - (up to 50 characters).';
2329+
}
23182330

23192331
if (this.fail(err)) return;
23202332
this.reg = { name, college, email, phone, enrollment_no, gender };
@@ -2360,18 +2372,31 @@ export class Innovision extends Component<Props, State> {
23602372
const saveRes = await createRegistration(regPayload);
23612373
const savedReg = saveRes.registration || (saveRes.success ? regPayload : null);
23622374
if (!savedReg) {
2363-
this.toast(saveRes.error || 'Registration failed');
2364-
if (isTransientFailure(saveRes.status)) {
2365-
// Rate limited, server error or offline: stay on this step with the UTR and screenshot intact, so the
2366-
// student can submit again by hand once they have waited. Nothing is resubmitted automatically.
2367-
this.setState({ busyLbl: '', err: { form: saveRes.error || 'Registration failed' } });
2375+
const msg = saveRes.error || 'Registration failed';
2376+
this.toast(msg);
2377+
const field = saveRes.field || '';
2378+
const detailsField = ['name', 'college', 'phone', 'gender', 'enrollment_no'].includes(field);
2379+
if (isTransientFailure(saveRes.status) || !(detailsField || field === 'payfile' || field === 'utr')) {
2380+
// Rate limited, server error, offline, closed or signed out: stay on this step with the UTR and screenshot
2381+
// intact, so the student can submit again by hand. Nothing is resubmitted automatically.
2382+
this.setState({ busyLbl: '', err: { form: msg } });
23682383
return;
23692384
}
2370-
this.setState({
2371-
busyLbl: '',
2372-
step: 0,
2373-
err: { college: saveRes.error || 'Registration failed' },
2374-
});
2385+
if (field === 'payfile') {
2386+
// The stored screenshot can't be used: pick it again (a fresh upload gets a fresh storage path).
2387+
this.removeFile('pay');
2388+
this.setState({ busyLbl: '', err: { payfile: msg } });
2389+
return;
2390+
}
2391+
if (field === 'utr') {
2392+
this.setState({ busyLbl: '' });
2393+
this.fail({ utr: msg });
2394+
return;
2395+
}
2396+
// Details from the first step (name, college, phone, gender, enrollment number): go back to fix them.
2397+
this.setState({ busyLbl: '' });
2398+
this.toStep(0);
2399+
this.setState({ err: { [field]: msg } });
23752400
return;
23762401
}
23772402

@@ -2471,7 +2496,7 @@ export class Innovision extends Component<Props, State> {
24712496
err: E, bc, inv, genderOptions: GENDERS,
24722497
upPay: up('pay', 'payfile', 'Drop the screenshot here or browse (Max 1MB)'),
24732498
fee, upi, copyUpi: this.copyUpi, copyLbl: s.copied ? 'COPIED' : 'COPY',
2474-
upiLink: 'upi://pay?pa=' + encodeURIComponent(upi) + '&pn=' + encodeURIComponent('Innovision NIT Rourkela') + '&am=' + fee + '&cu=INR&tn=' + encodeURIComponent('Innovision 2026 registration'),
2499+
upiLink: UPI_PAY_LINK, upiQr: UPI_QR_SRC,
24752500
upiAppD: s.narrow ? 'inline-flex' : 'none',
24762501
authSubmit: this.authSubmit, clearErr: this.clearErr, switchMode: this.switchMode, closeAuthH: () => this.closeAuth(),
24772502
showSwitch: am !== 'pass' && !reg && !!s.user,

‎src/config/launch.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* registration: src/app/register/redirectToRegistrationFlow.ts → the auth / registration overlay in Innovision.tsx
1111
* merch: the #/merch store view, src/components/innovision/MerchView.tsx
1212
*/
13-
export const REGISTRATIONS_OPEN = false;
13+
export const REGISTRATIONS_OPEN = true;
1414
export const MERCH_STORE_OPEN = false;
1515

1616
/** Where the REGISTER buttons point while registrations are closed. */

‎src/config/payment.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Registration payment details shown on the "Pay the fee" step of the registration form.
3+
*
4+
* The server never reads these: POST /api/register always records REGISTRATION_FEE_INR from src/lib/security.ts.
5+
* Keep REGISTRATION_FEE in step with it (a unit test checks they match).
6+
*
7+
* public/assets/upi-qr.svg encodes UPI_PAY_LINK. If the UPI ID, payee name or fee changes, regenerate it from the new
8+
* link (any QR encoder, error correction M) and check it scans to the new link.
9+
*/
10+
export const REGISTRATION_FEE = 499;
11+
export const UPI_ID = 'innovision@sbi';
12+
export const UPI_PAYEE_NAME = 'Innovision NIT Rourkela';
13+
export const UPI_NOTE = 'Innovision 2026 registration';
14+
15+
/**
16+
* `upi://pay` deep link: opens the student's UPI app with the payee, amount and note filled in. The VPA stays
17+
* unencoded (its characters are URL-safe, and some UPI apps reject `%40` in place of `@`).
18+
*/
19+
export const UPI_PAY_LINK =
20+
'upi://pay?pa=' + UPI_ID +
21+
'&pn=' + encodeURIComponent(UPI_PAYEE_NAME) +
22+
'&am=' + REGISTRATION_FEE +
23+
'&cu=INR&tn=' + encodeURIComponent(UPI_NOTE);
24+
25+
/** Static QR code for UPI_PAY_LINK. */
26+
export const UPI_QR_SRC = '/assets/upi-qr.svg';

0 commit comments

Comments
 (0)