From 4d080821b9c6f2937eb03dea7f4d675bc5cdd3c5 Mon Sep 17 00:00:00 2001 From: Krish Agarwal Date: Fri, 13 Nov 2026 08:45:37 +0530 Subject: [PATCH] Add on-spot registration allowed by staff at the desk Registration team or an admin enters a walk-in's email and fee (ALLOW ON-SPOT), which issues a phase-0 payment pass. Only that account can then register through the usual form; there is no public on-spot page or switch. On-spot registrations never move the online seat counter and never include accommodation or a registration kit, shown on the pay step, MY PASS, PDF and the admin list. Also ignore spreadsheets anywhere in the repo (not just the root) and /output/. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 18 ++- .../api/admin/registrations/on-spot/route.ts | 106 ++++++++++++++++++ src/app/api/pass/route.ts | 2 + src/app/api/register/route.ts | 6 +- src/app/api/register/start-payment/route.ts | 29 ++++- src/components/innovision/AdminDashboard.tsx | 18 ++- .../innovision/ApprovedPassTicket.tsx | 4 +- src/components/innovision/AuthOverlay.tsx | 6 + src/components/innovision/EntryPassCard.tsx | 4 +- src/components/innovision/Innovision.tsx | 2 +- src/components/innovision/PassTicket.tsx | 4 +- .../innovision/admin/SeatCounterCard.tsx | 59 +++++++++- src/components/innovision/download-pass.ts | 4 +- src/lib/pass-experience.ts | 8 ++ src/lib/seats.ts | 24 +++- 15 files changed, 266 insertions(+), 28 deletions(-) create mode 100644 src/app/api/admin/registrations/on-spot/route.ts diff --git a/.gitignore b/.gitignore index 50e6539..7d33962 100644 --- a/.gitignore +++ b/.gitignore @@ -57,11 +57,19 @@ next-env.d.ts # Isolated pass component test bundle .pass-test/ -# spreadsheets in the repo root (bank statements, registration exports with -# students' personal data, ad-hoc sheets): never commit -/*.xlsx -/*.xls -/*.csv +# spreadsheets anywhere in the repo (bank statements, registration exports with +# students' personal data, UTR comparisons, ad-hoc sheets): never commit +*.xlsx +*.xls +*.xlsm +*.xlsb +*.csv +*.tsv +*.ods +*.numbers + +# generated import/processing output (event sheets, dry-run reports, SQL plans) +/output/ # Office lock files ~$* diff --git a/src/app/api/admin/registrations/on-spot/route.ts b/src/app/api/admin/registrations/on-spot/route.ts new file mode 100644 index 0000000..880f5f2 --- /dev/null +++ b/src/app/api/admin/registrations/on-spot/route.ts @@ -0,0 +1,106 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { verifyRegistrationStaff } from '@/lib/auth-server'; +import { isFilterSafeEmail, readJsonObject } from '@/lib/security'; +import { isFee, MAX_FEE, ON_SPOT_PHASE } from '@/lib/seats'; + +export const runtime = 'nodejs'; + +// No time limit in practice: the grant is given in person at the desk and lasts until it is used. The columns are +// NOT NULL, so this is just a long way off. +const VALID_FOR_MS = 30 * 24 * 60 * 60 * 1000; + +/** + * POST /api/admin/registrations/on-spot { email, amount } + * Registration-Team/Admin only. ALLOW ON-SPOT: a walk-in at the college desk. There is no public on-spot page and no + * switch: only the account staff name here may register, at `amount`, with no accommodation and no registration kit. + * Staff only do this for someone standing at the desk, so the desk is the location check. It doesn't create a + * registration: the student fills in the usual form, pays and submits their proof, and it is reviewed as usual + * (approve only once the UTR and amount are on the bank statement). Granting the same email again replaces the grant. + */ +export async function POST(req: NextRequest) { + try { + const auth = await verifyRegistrationStaff(req); + if ('error' in auth) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + + const parsed = await readJsonObject(req); + if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: parsed.status }); + const { email, amount } = parsed.value; + + const normalized = typeof email === 'string' ? email.toLowerCase().trim() : ''; + if (!normalized || !isFilterSafeEmail(normalized)) { + return NextResponse.json({ error: 'Enter the email address the student signs in with.' }, { status: 400 }); + } + if (!isFee(amount)) { + return NextResponse.json({ error: `The fee must be a whole number of rupees from 1 to ${MAX_FEE}.` }, { status: 400 }); + } + + const { supabase, user } = auth; + const { data: profile, error: profileError } = await supabase + .from('profiles') + .select('id') + .eq('email', normalized) + .maybeSingle(); + if (profileError) { + console.error('/api/admin/registrations/on-spot profile lookup failed:', profileError); + return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 }); + } + if (!profile) { + return NextResponse.json( + { error: 'No account with that email. Ask the student to sign in on the site once, then try again.' }, + { status: 404 } + ); + } + if (profile.id === user.id) { + return NextResponse.json({ error: 'You cannot allow yourself. Ask another staff member.' }, { status: 403 }); + } + + const { data: active, error: activeError } = await supabase + .from('registrations') + .select('registration_id') + .eq('user_id', profile.id) + .neq('status', 'rejected') + .limit(1) + .maybeSingle(); + if (activeError) { + console.error('/api/admin/registrations/on-spot registration lookup failed:', activeError); + return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 }); + } + if (active) { + return NextResponse.json({ error: `This student already has registration ${active.registration_id}.` }, { status: 409 }); + } + + // Only one pass counts at a time: the newest. + const now = new Date(); + const { error: retireError } = await supabase + .from('payment_passes') + .update({ used_at: now.toISOString() }) + .eq('user_id', profile.id) + .is('used_at', null); + if (retireError) { + console.error('/api/admin/registrations/on-spot retire failed:', retireError); + return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 }); + } + // pay_by in the future: the form shows the payment details at this fee. accommodation false: on-spot never has it. + const until = new Date(now.getTime() + VALID_FOR_MS).toISOString(); + const { error: insertError } = await supabase.from('payment_passes').insert({ + user_id: profile.id, + phase: ON_SPOT_PHASE, + amount, + pay_by: until, + submit_by: until, + issued_by: user.id, + note: 'on-spot', + accommodation: false, + }); + if (insertError) { + console.error('/api/admin/registrations/on-spot insert failed:', insertError); + return NextResponse.json({ error: 'Database operation failed.' }, { status: 500 }); + } + return NextResponse.json({ success: true }); + } catch (err: unknown) { + console.error('/api/admin/registrations/on-spot error:', err); + return NextResponse.json({ error: 'Failed to allow on-spot registration.' }, { status: 500 }); + } +} diff --git a/src/app/api/pass/route.ts b/src/app/api/pass/route.ts index 4d960cc..2ccd8ac 100644 --- a/src/app/api/pass/route.ts +++ b/src/app/api/pass/route.ts @@ -5,6 +5,7 @@ import { createRateLimiter } from '@/lib/security'; import { decryptEntryToken, encryptEntryToken, entryPassKey, mintEntryToken } from '@/lib/entry-pass'; import type { ParticipantPass } from '@/lib/pass-experience'; import { attemptsLeft } from '@/lib/rejections'; +import { ON_SPOT_PHASE } from '@/lib/seats'; export const runtime = 'nodejs'; @@ -60,6 +61,7 @@ export async function POST(req: NextRequest) { name: mine.name ?? null, college: details.data?.college ?? null, accommodation: details.data?.accommodation !== false, + onSpot: details.data?.phase === ON_SPOT_PHASE, checkedIn: !!mine.checked_in, token: null as string | null, }; diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts index 8d55185..7b0061c 100644 --- a/src/app/api/register/route.ts +++ b/src/app/api/register/route.ts @@ -15,7 +15,7 @@ import { PHONE_IN_USE_ERROR_MESSAGE, } from '@/lib/validation'; import { ATTEMPTS_USED_UP_ERROR, attemptsLeft, isAttemptsUsedUpError } from '@/lib/rejections'; -import { accommodationForSubmit, FORM_OUT_OF_DATE_MESSAGE, isAccommodationRefusal, REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats'; +import { accommodationForSubmit, FORM_OUT_OF_DATE_MESSAGE, isAccommodationRefusal, isOnSpotPhase, REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats'; import { classifyStudentType, createRateLimiter, @@ -248,7 +248,9 @@ export async function POST(req: NextRequest) { // Saved when the student started paying (start-payment), never taken from the body alone. Null: this form never told // them whether accommodation is included. - const accommodation = accommodationForSubmit(input.gender, input.no_accommodation, payment); + // On-spot (staff at the desk, phase 0) never includes accommodation, whatever the form sent. + const onSpot = isOnSpotPhase(payment.phase); + const accommodation = onSpot ? false : accommodationForSubmit(input.gender, input.no_accommodation, payment); if (accommodation === null) { return NextResponse.json({ code: 'form_out_of_date', error: FORM_OUT_OF_DATE_MESSAGE, field: 'no_accommodation' }, { status: 409 }); } diff --git a/src/app/api/register/start-payment/route.ts b/src/app/api/register/start-payment/route.ts index 2db8f78..8807eec 100644 --- a/src/app/api/register/start-payment/route.ts +++ b/src/app/api/register/start-payment/route.ts @@ -3,7 +3,7 @@ import { getAuthenticatedUser } from '@/lib/auth-server'; import { getSupabaseAdmin } from '@/lib/supabase'; import { registrationsClosedResponse, registrationsOpen } from '@/lib/registration-gate'; import { ATTEMPTS_USED_UP_ERROR, attemptsLeft } from '@/lib/rejections'; -import { FORM_OUT_OF_DATE_MESSAGE, MALE_ACCOMMODATION_CLOSED_MESSAGE, REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats'; +import { FORM_OUT_OF_DATE_MESSAGE, isOnSpotPhase, MALE_ACCOMMODATION_CLOSED_MESSAGE, REGISTRATIONS_FULL_MESSAGE } from '@/lib/seats'; import { createRateLimiter, readJsonObject, REGISTRATION_GENDERS } from '@/lib/security'; export const runtime = 'nodejs'; @@ -70,11 +70,28 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: ATTEMPTS_USED_UP_ERROR }, { status: 403 }); } + // An on-spot pass (staff at the desk, POST /api/admin/registrations/on-spot) never includes accommodation, whatever + // the box says, and it isn't swapped for the online fee. + const { data: latestPass, error: passError } = await client + .from('payment_passes') + .select('phase') + .eq('user_id', user.id) + .is('used_at', null) + .gt('submit_by', new Date().toISOString()) + .order('created_at', { ascending: false }) + .limit(1) + .maybeSingle(); + if (passError) { + console.error('/api/register/start-payment pass lookup failed:', passError); + return NextResponse.json({ error: 'Could not start the payment. Please try again.' }, { status: 500 }); + } + const onSpot = isOnSpotPhase(latestPass?.phase); + const { data, error } = await client.rpc('start_payment_with_accommodation', { p_user_id: user.id, - p_fresh: body.value.fresh === true, + p_fresh: onSpot ? false : body.value.fresh === true, p_gender: gender, - p_no_accommodation: noAccommodation, + p_no_accommodation: onSpot ? true : noAccommodation, }); if (error || !data) { console.error('/api/register/start-payment pass failed:', error); @@ -94,8 +111,10 @@ export async function POST(req: NextRequest) { return NextResponse.json({ state: data.state, amount: data.amount, - currentFee: data.current_fee ?? null, - accommodation: data.accommodation, + // An on-spot student is offered only their on-spot fee. + currentFee: onSpot ? null : data.current_fee ?? null, + accommodation: onSpot ? false : data.accommodation, + onSpot: isOnSpotPhase(data.phase), }); } catch (err: unknown) { console.error('API /api/register/start-payment error:', err); diff --git a/src/components/innovision/AdminDashboard.tsx b/src/components/innovision/AdminDashboard.tsx index ad8f009..c619b50 100644 --- a/src/components/innovision/AdminDashboard.tsx +++ b/src/components/innovision/AdminDashboard.tsx @@ -13,6 +13,7 @@ import ReferralLeaderboard from './admin/ReferralLeaderboard'; import AmbassadorsManager from './admin/AmbassadorsManager'; import SeatCounterCard from './admin/SeatCounterCard'; import { httpErrorMessage } from '@/lib/api-errors'; +import { isOnSpotPhase, phaseLabel } from '@/lib/seats'; import { MAX_REGISTRATION_ATTEMPTS, ordinal, REJECTION_REASONS, rejectionLabel, type RejectionReason } from '@/lib/rejections'; import { ID_CARD_REUPLOAD_REASONS, idCardReuploadLabel, type IdCardReuploadReason } from '@/lib/id-cards'; @@ -78,7 +79,7 @@ export default function AdminDashboard({ // Client-side over the loaded list: registrations with or without their college ID card. const [idCardFilter, setIdCardFilter] = useState<'all' | 'missing' | 'uploaded'>('all'); // Client-side over the loaded list too: registrations with or without accommodation. - const [accommodationFilter, setAccommodationFilter] = useState<'all' | 'with' | 'without'>('all'); + const [accommodationFilter, setAccommodationFilter] = useState<'all' | 'with' | 'without' | 'onspot'>('all'); // Bumped whenever the seat count may have moved (list reloaded, registration reviewed): the SEAT COUNTER card reloads. const [seatsKey, setSeatsKey] = useState(0); @@ -453,7 +454,10 @@ export default function AdminDashboard({ const visibleRegistrations = registrations.filter( (r) => (idCardFilter === 'all' || (r.status !== 'rejected' && (idCardFilter === 'missing') === !r.id_card_path)) && - (accommodationFilter === 'all' || (accommodationFilter === 'with') === (r.accommodation !== false)) + (accommodationFilter === 'all' || + (accommodationFilter === 'onspot' + ? isOnSpotPhase(r.phase) + : (accommodationFilter === 'with') === (r.accommodation !== false))) ); // Export Registrations CSV @@ -506,7 +510,7 @@ export default function AdminDashboard({ rejectionLabel(r.rejection_reason), r.attempt ?? 1, r.amount, - r.phase ?? '', + phaseLabel(r.phase), r.allowed_by ? 'Yes' : '', r.utr, r.payment_proof_path, @@ -1441,7 +1445,7 @@ export default function AdminDashboard({ aria-label="Accommodation" value={accommodationFilter} onChange={(e) => { - setAccommodationFilter(e.target.value as 'all' | 'with' | 'without'); + setAccommodationFilter(e.target.value as 'all' | 'with' | 'without' | 'onspot'); setRegPage(1); }} style={{ @@ -1457,6 +1461,7 @@ export default function AdminDashboard({ + {pass.token && } -

Accommodation: {pass.accommodation === false ? 'not included' : 'included'}

+

Accommodation: {passAccommodationLabel(pass).toLowerCase()}

{pass.qrUnavailable || 'Keep your pass handy. Your registration ID is always available here.'}

{saveError &&

{saveError}

} diff --git a/src/components/innovision/AuthOverlay.tsx b/src/components/innovision/AuthOverlay.tsx index e6e1c59..ea6e5dc 100644 --- a/src/components/innovision/AuthOverlay.tsx +++ b/src/components/innovision/AuthOverlay.tsx @@ -253,6 +253,12 @@ export default function AuthOverlay({ v }: { v: V }) {

Pay the fee

{/* The server's answer, not the box: seen before any way to pay, since a payment can't be refunded. */} + {offer?.onSpot ? ( +

+ ON-SPOT REGISTRATION · ₹{offer.amount} + No accommodation and no registration kit are included. +

+ ) : null} {offer ? (

ACCOMMODATION: {offer.accommodation ? "INCLUDED" : "NOT INCLUDED"} diff --git a/src/components/innovision/EntryPassCard.tsx b/src/components/innovision/EntryPassCard.tsx index 86731d8..7b099e3 100644 --- a/src/components/innovision/EntryPassCard.tsx +++ b/src/components/innovision/EntryPassCard.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; -import { isApprovedPass, passStatusMessage, type ParticipantPass } from '@/lib/pass-experience'; +import { isApprovedPass, passAccommodationLabel, passStatusMessage, type ParticipantPass } from '@/lib/pass-experience'; import { passRequest } from './pass-client'; import PassIssued from './PassIssued'; import IdCardUpload from './IdCardUpload'; @@ -204,7 +204,7 @@ function PassState({ ) : null} {isPending ? ( -

Accommodation: {pass.accommodation === false ? 'not included' : 'included'}

+

Accommodation: {passAccommodationLabel(pass).toLowerCase()}

) : null} {reason ? ( diff --git a/src/components/innovision/Innovision.tsx b/src/components/innovision/Innovision.tsx index ecbe0d4..72c25fa 100644 --- a/src/components/innovision/Innovision.tsx +++ b/src/components/innovision/Innovision.tsx @@ -2676,7 +2676,7 @@ export class Innovision extends Component { // offline, server error) means no payment details. const preview = REGISTRATION_PREVIEW_ENABLED && user.id === 'dev-preview-user'; const started = preview - ? Promise.resolve({ offer: { state: 'pay' as const, amount: PREVIEW_REGISTRATION_FEE, currentFee: PREVIEW_REGISTRATION_FEE, accommodation: !no_accommodation } }) + ? Promise.resolve({ offer: { state: 'pay' as const, amount: PREVIEW_REGISTRATION_FEE, currentFee: PREVIEW_REGISTRATION_FEE, accommodation: !no_accommodation, onSpot: false } }) : startPayment(gender, no_accommodation); this.setState({ busyLbl: referred_by_code ? 'CHECKING CODE' : 'CHECKING' }); Promise.all([started, referred_by_code ? checkReferralCode(referred_by_code) : null]).then(([pay, res]) => { diff --git a/src/components/innovision/PassTicket.tsx b/src/components/innovision/PassTicket.tsx index 5383ea2..5634999 100644 --- a/src/components/innovision/PassTicket.tsx +++ b/src/components/innovision/PassTicket.tsx @@ -1,6 +1,6 @@ 'use client'; -import { isApprovedPass, type ParticipantPass } from '@/lib/pass-experience'; +import { isApprovedPass, passAccommodationLabel, type ParticipantPass } from '@/lib/pass-experience'; import ApprovedPassTicket from './ApprovedPassTicket'; import styles from './PassIssued.module.css'; import { EVENT_DATE } from '@/config/site'; @@ -51,7 +51,7 @@ export default function PassTicket({ pass, animate = false }: {
ACCOMMODATION - {pass.accommodation === false ? 'Not included' : 'Included'} + {passAccommodationLabel(pass)}
diff --git a/src/components/innovision/admin/SeatCounterCard.tsx b/src/components/innovision/admin/SeatCounterCard.tsx index f4de6b0..02c0edb 100644 --- a/src/components/innovision/admin/SeatCounterCard.tsx +++ b/src/components/innovision/admin/SeatCounterCard.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { httpErrorMessage } from '@/lib/api-errors'; -import { formatSeats, MAX_FEE, MAX_SEATS, nextPhaseError, seatCountError } from '@/lib/seats'; +import { formatSeats, isFee, MAX_FEE, MAX_SEATS, nextPhaseError, seatCountError } from '@/lib/seats'; /* * SEAT COUNTER card on the admin Registrations tab (GET/PATCH /api/admin/seats). Total seats and seats left are what @@ -59,6 +59,8 @@ export default function SeatCounterCard({ getAuthToken, canEdit, canAllowPayment const [phaseForm, setPhaseForm] = useState({ open: false, confirming: false, fee: '', total: '', left: '', error: '' }); const [allowForm, setAllowForm] = useState({ open: false, email: '', amount: '', note: '', error: '', done: '' }); const [maleSwitch, setMaleSwitch] = useState({ confirming: false, error: '' }); + // The fee is kept after a grant: the desk usually charges everyone the same. + const [onSpotForm, setOnSpotForm] = useState({ open: false, confirming: false, email: '', fee: '', error: '', done: '' }); // AdminDashboard recreates getAuthToken on every render; read it through a ref so loading isn't restarted each time. const tokenRef = useRef(getAuthToken); useEffect(() => { @@ -162,6 +164,28 @@ export default function SeatCounterCard({ getAuthToken, canEdit, canAllowPayment } }; + // Two steps, like a new phase: the student is shown this fee as soon as it is saved. + const submitOnSpot = async (e: React.FormEvent) => { + e.preventDefault(); + const email = onSpotForm.email.trim(); + const fee = onSpotForm.fee.trim() === '' ? NaN : Number(onSpotForm.fee); + if (!email) { setOnSpotForm((f) => ({ ...f, done: '', confirming: false, error: "Enter the student's sign-in email." })); return; } + if (!isFee(fee)) { setOnSpotForm((f) => ({ ...f, done: '', confirming: false, error: `The fee must be a whole number of rupees from 1 to ${MAX_FEE}.` })); return; } + if (!onSpotForm.confirming) { setOnSpotForm((f) => ({ ...f, done: '', error: '', confirming: true })); return; } + try { + setSaving(true); + await post('/api/admin/registrations/on-spot', { email, amount: fee }); + setOnSpotForm({ + open: true, confirming: false, email: '', fee: onSpotForm.fee, error: '', + done: `${email} can now register on-spot at ₹${formatSeats(fee)}. Ask them to open REGISTER on their phone.`, + }); + } catch (err: unknown) { + setOnSpotForm((f) => ({ ...f, done: '', confirming: false, error: err instanceof Error ? err.message : 'Failed to allow on-spot registration' })); + } finally { + setSaving(false); + } + }; + const changeMaleAccommodation = async (open: boolean) => { try { setSaving(true); @@ -346,6 +370,39 @@ export default function SeatCounterCard({ getAuthToken, canEdit, canAllowPayment )} ) : null} + + {canAllowPayments ? ( +
+ {!onSpotForm.open ? ( + + ) : ( +
+ + Only for a student standing at the desk. They sign in on their phone first, then you enter their email. They fill in the usual form and pay this fee. On-spot includes no accommodation and no registration kit, so tell them before they pay. Approve only once the UTR and amount are on the bank statement. + + + + + + {onSpotForm.confirming ? ( + + Allow {onSpotForm.email.trim()} to register on-spot at ₹{formatSeats(Number(onSpotForm.fee))}? + + ) : null} + {onSpotForm.done ? {onSpotForm.done} : null} + {onSpotForm.error ? {onSpotForm.error} : null} +
+ )} +
+ ) : null} ); } diff --git a/src/components/innovision/download-pass.ts b/src/components/innovision/download-pass.ts index ad5caed..fb5c6a4 100644 --- a/src/components/innovision/download-pass.ts +++ b/src/components/innovision/download-pass.ts @@ -1,5 +1,5 @@ import { EVENT_DATE, SITE_NAME } from '@/config/site'; -import type { ParticipantPass } from '@/lib/pass-experience'; +import { passAccommodationLabel, type ParticipantPass } from '@/lib/pass-experience'; /** Draw with the site's installed fonts; raster text preserves names in any script. */ async function renderPass(pass: ParticipantPass, qr: string | null) { @@ -46,7 +46,7 @@ async function renderPass(pass: ParticipantPass, qr: string | null) { y += 24; text('VENUE', '16px monospace', '#5c574f'); y += 34; text('NIT Rourkela', `24px ${sans}`); y += 46; text('ACCOMMODATION', '16px monospace', '#5c574f'); y += 34; - text(pass.accommodation === false ? 'Not included' : 'Included', `24px ${sans}`); y += 40; + text(passAccommodationLabel(pass), `24px ${sans}`); y += 40; ctx.strokeStyle = '#b5a78d'; ctx.setLineDash([4, 7]); ctx.beginPath(); ctx.moveTo(25, y); ctx.lineTo(675, y); ctx.stroke(); ctx.setLineDash([]); y += 42; text('REGISTRATION ID', '16px monospace', '#5c574f'); y += 38; text(pass.registrationId ?? '', 'bold 30px monospace'); y += 40; diff --git a/src/lib/pass-experience.ts b/src/lib/pass-experience.ts index bb8f161..63b25f9 100644 --- a/src/lib/pass-experience.ts +++ b/src/lib/pass-experience.ts @@ -10,6 +10,8 @@ export interface ParticipantPass { qrUnavailable?: string; /** Whether this registration includes accommodation. */ accommodation?: boolean; + /** An on-spot registration (allowed at the desk): no accommodation and no registration kit. */ + onSpot?: boolean; /** Rejected registrations only: the reason staff gave (src/lib/rejections.ts), if any. */ rejectionReason?: string | null; /** Rejected registrations only: how many more times the student may register. */ @@ -23,6 +25,12 @@ export interface ParticipantPass { idCardReuploadReason?: string | null; } +/** What the pass says about accommodation, e.g. "Not included · On-spot, no registration kit". */ +export function passAccommodationLabel(pass: Pick): string { + if (pass.onSpot) return 'Not included · On-spot, no registration kit'; + return pass.accommodation === false ? 'Not included' : 'Included'; +} + export function isApprovedPass(pass: ParticipantPass): boolean { return pass.registrationStatus === 'confirmed' && pass.passStatus === 'active' && !!pass.registrationId && !!pass.name && !!pass.college; diff --git a/src/lib/seats.ts b/src/lib/seats.ts index f47dc91..028f0a2 100644 --- a/src/lib/seats.ts +++ b/src/lib/seats.ts @@ -94,6 +94,24 @@ export function seatCountError(total: unknown, left: unknown): string | null { export const isFee = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 1 && (n as number) <= MAX_FEE; +/** + * On-spot registration: a walk-in at the college desk. Staff enter the student's email and fee (POST + * /api/admin/registrations/on-spot), which issues a payment pass with this phase; the registration it produces keeps it. + * registration_counter.phase is always >= 1, so the online seat counter never moves for these. On-spot registrations + * get no accommodation and no registration kit. + */ +export const ON_SPOT_PHASE = 0; + +export const ON_SPOT_NOTICE = 'On-spot registration: no accommodation and no registration kit.'; + +export const isOnSpotPhase = (phase: unknown): boolean => phase === ON_SPOT_PHASE; + +/** A registration's phase for staff: ON-SPOT, or the phase number. */ +export function phaseLabel(phase: number | null | undefined): string { + if (phase === null || phase === undefined) return ''; + return isOnSpotPhase(phase) ? 'ON-SPOT' : String(phase); +} + /** Why an admin's next phase can't be started, or null if it can. */ export function nextPhaseError(fee: unknown, total: unknown, left: unknown): string | null { if (!isFee(fee)) return `The fee must be a whole number of rupees from 1 to ${MAX_FEE}.`; @@ -132,12 +150,14 @@ export interface PaymentOffer { currentFee: number | null; /** Whether this registration includes accommodation: the form shows this before the payment details. */ accommodation: boolean; + /** An on-spot registration allowed by staff at the desk: no accommodation and no registration kit. */ + onSpot: boolean; } /** The offer from a POST /api/register/start-payment response, or null if it isn't a valid one. */ export function parsePaymentOffer(value: unknown): PaymentOffer | null { if (!value || typeof value !== 'object') return null; - const { state, amount, currentFee, accommodation } = value as Record; + const { state, amount, currentFee, accommodation, onSpot } = value as Record; if ((state !== 'pay' && state !== 'already_paid') || !isFee(amount) || typeof accommodation !== 'boolean') return null; - return { state, amount, currentFee: isFee(currentFee) ? currentFee : null, accommodation }; + return { state, amount, currentFee: isFee(currentFee) ? currentFee : null, accommodation, onSpot: onSpot === true }; }