-
YOUR REFERRAL CODE
-
-
{v.refCode}
-
-
-
+
+ {(v.passQr || v.isDev) ?
: null}
+ {v.previewPending &&
}
+
+ {/* Clean minimal footer underneath the ticket */}
+
+ {v.refCode ? (
+
+ Your Referral Code:
+ {v.refCode}
+
-
Share it with friends. When they register with your code and their registration is approved, it counts as your referral.
-
- ) : null}
-
{v.passNote}
-
@@ -334,7 +369,7 @@ export default function AuthOverlay({ v }: { v: V }) {
clipPath: "polygon(12px 0,100% 0,100% calc(100% - 12px),calc(100% - 12px) 100%,0 100%,0 12px)",
transition: "background-color .4s,opacity .3s,color .3s",
}}
- style-hover="background:oklch(0.8 0.12 85)"
+ style-hover="background:oklch(0.78 0.075 85)"
style-active="transform:scale(.98)"
>
{v.submitLbl}
@@ -353,6 +388,40 @@ export default function AuthOverlay({ v }: { v: V }) {
+ {/* Payment QR Lightbox */}
+ {payQrOpen && (
+
setPayQrOpen(false)}
+ style={{
+ position: "fixed", inset: "0", zIndex: "200",
+ display: "flex", alignItems: "center", justifyContent: "center",
+ background: "rgba(7,6,5,0.88)",
+ backdropFilter: "blur(12px)", WebkitBackdropFilter: "blur(12px)",
+ cursor: "pointer",
+ animation: "fadeInLb .15s ease",
+ }}
+ role="dialog" aria-modal="true" aria-label="Payment QR code enlarged"
+ >
+
e.stopPropagation()}
+ style={{
+ position: "relative", padding: "16px", background: "#ECE8DF",
+ borderRadius: "10px", cursor: "default",
+ boxShadow: "0 0 0 2px oklch(0.78 0.075 85), 0 40px 100px rgba(0,0,0,0.7)",
+ animation: "popInLb .22s cubic-bezier(.34,1.56,.64,1)",
+ display: "flex", flexDirection: "column", alignItems: "center", gap: "12px",
+ }}
+ >
+

+
Tap outside to close
+
+
+
+ )}
+ >
);
}
diff --git a/src/components/innovision/EntryPassCard.tsx b/src/components/innovision/EntryPassCard.tsx
index 6a19e82..8914cdf 100644
--- a/src/components/innovision/EntryPassCard.tsx
+++ b/src/components/innovision/EntryPassCard.tsx
@@ -1,220 +1,161 @@
'use client';
-import React, { useEffect, useState } from 'react';
-import { getSupabase } from '@/lib/supabase';
-import { httpErrorMessage } from '@/lib/api-errors';
-
-/*
- * The participant's QR entry pass (profile overlay). The QR holds only an opaque random token, fetched from
- * POST /api/pass for the signed-in owner and kept in memory only (never in localStorage). Shown only while the
- * registration is confirmed and the pass active; otherwise the status is explained instead.
- */
-
-interface PassInfo {
- registrationStatus: 'none' | 'pending' | 'confirmed' | 'rejected';
- passStatus: 'none' | 'active' | 'revoked';
- registrationId: string | null;
- name: string | null;
- checkedIn: boolean;
- token: string | null;
+import { useEffect, useState } from 'react';
+import { isApprovedPass, passStatusMessage, type ParticipantPass } from '@/lib/pass-experience';
+import { passRequest } from './pass-client';
+import PassIssued from './PassIssued';
+import styles from './PassIssued.module.css';
+import { REGISTRATION_PREVIEW_ENABLED } from '@/config/launch';
+
+interface EntryPassCardProps {
+ onContinue?: () => void;
+ previewPass?: ParticipantPass;
}
-type Load =
- | { state: 'loading' }
- | { state: 'error'; message: string }
- | { state: 'ready'; pass: PassInfo; qr: string | null };
-
-const GOLD = 'oklch(0.8 0.12 85)';
-const box: React.CSSProperties = {
- marginBottom: '18px',
- padding: '16px',
- background: 'rgba(236,232,223,0.06)',
- border: '1px solid rgba(236,232,223,0.12)',
-};
-const label: React.CSSProperties = { margin: 0, fontSize: '12px', fontWeight: 700, letterSpacing: '.18em', color: GOLD };
-const text: React.CSSProperties = { margin: 0, fontSize: '14px', lineHeight: 1.5, color: 'rgba(236,232,223,0.8)' };
-
-async function fetchPass(): Promise
{
- let token: string | undefined;
- try {
- token = (await getSupabase().auth.getSession()).data.session?.access_token;
- } catch {}
- const res = await fetch('/api/pass', {
- method: 'POST',
- headers: token ? { Authorization: `Bearer ${token}` } : {},
- cache: 'no-store',
- });
- const body = await res.json().catch(() => ({}));
- if (!res.ok || !body.pass) throw new Error(httpErrorMessage(res) || body.error || 'Could not load your entry pass.');
- return body.pass as PassInfo;
-}
-
-async function downloadPdf(pass: PassInfo, qr: string) {
- const { jsPDF } = await import('jspdf');
- const doc = new jsPDF({ unit: 'mm', format: 'a5' }); // 148 x 210
- const W = 148;
-
- doc.setFillColor(20, 19, 18);
- doc.rect(0, 0, W, 210, 'F');
- doc.setTextColor(220, 183, 106);
- doc.setFont('helvetica', 'bold');
- doc.setFontSize(10);
- doc.text('NIT ROURKELA · TECHNO-MANAGEMENT FEST', W / 2, 16, { align: 'center' });
- doc.setTextColor(236, 232, 223);
- doc.setFont('times', 'normal');
- doc.setFontSize(30);
- doc.text('Innovision 2026', W / 2, 30, { align: 'center' });
- doc.setFont('times', 'italic');
- doc.setFontSize(13);
- doc.text('The Celestial Odyssey', W / 2, 38, { align: 'center' });
-
- doc.setFillColor(236, 232, 223);
- doc.rect(29, 48, 90, 90, 'F');
- doc.addImage(qr, 'PNG', 32, 51, 84, 84);
-
- doc.setTextColor(220, 183, 106);
- doc.setFont('helvetica', 'bold');
- doc.setFontSize(9);
- doc.text('ENTRY PASS', W / 2, 150, { align: 'center' });
- doc.setTextColor(236, 232, 223);
- doc.setFontSize(18);
- doc.text(doc.splitTextToSize(pass.name || '', 120), W / 2, 160, { align: 'center' });
- doc.setFont('helvetica', 'normal');
- doc.setFontSize(12);
- doc.text(`Registration ID ${pass.registrationId ?? ''}`, W / 2, 174, { align: 'center' });
-
- doc.setFontSize(9);
- doc.setTextColor(200, 196, 188);
- doc.text(
- doc.splitTextToSize('Show this pass together with your college ID card at the registration desk. Valid for one check-in. Do not share this QR.', 118),
- W / 2,
- 188,
- { align: 'center' }
- );
- doc.save(`Innovision-2026-entry-pass-${pass.registrationId ?? 'pass'}.pdf`);
+export default function EntryPassCard({ onContinue, previewPass }: EntryPassCardProps) {
+ if (REGISTRATION_PREVIEW_ENABLED && previewPass) {
+ return ;
+ }
+ return ;
}
-export default function EntryPassCard() {
- const [load, setLoad] = useState({ state: 'loading' });
+function AuthenticatedEntryPassCard({ onContinue }: EntryPassCardProps) {
+ const [pass, setPass] = useState(null);
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(true);
const [attempt, setAttempt] = useState(0);
- const [downloading, setDownloading] = useState(false);
- const [downloadError, setDownloadError] = useState('');
useEffect(() => {
let cancelled = false;
- (async () => {
+ let busy = false;
+
+ const load = async () => {
+ if (busy || document.visibilityState !== 'visible') return;
+ busy = true;
+
try {
- const pass = await fetchPass();
- let qr: string | null = null;
- if (pass.token) {
- const QRCode = (await import('qrcode')).default;
- qr = await QRCode.toDataURL(pass.token, { errorCorrectionLevel: 'M', margin: 2, width: 512 });
+ const data = await passRequest('/api/pass');
+ if (!data.pass) throw new Error('Could not load your registration details.');
+ if (!cancelled) {
+ setPass(data.pass);
+ setError('');
+ setLoading(false);
}
- if (!cancelled) setLoad({ state: 'ready', pass, qr });
} catch (err) {
- if (!cancelled) setLoad({ state: 'error', message: err instanceof Error ? err.message : 'Could not load your entry pass.' });
+ if (!cancelled) {
+ setError(err instanceof Error ? err.message : 'Could not connect to verify your pass.');
+ setPass(null);
+ setLoading(false);
+ }
+ } finally {
+ busy = false;
}
- })();
+ };
+
+ void load();
+ const interval = setInterval(() => void load(), 15_000);
+ window.addEventListener('focus', load);
+ document.addEventListener('visibilitychange', load);
+
return () => {
cancelled = true;
+ clearInterval(interval);
+ window.removeEventListener('focus', load);
+ document.removeEventListener('visibilitychange', load);
};
}, [attempt]);
- if (load.state === 'loading') {
+ // Loading state: never flash approved ticket while status is unknown
+ if (loading && !pass && !error) {
return (
-
-
ENTRY PASS
-
Loading your entry pass…
+
+
● Checking registration
+
+ Checking your registration status with the backend…
+
);
}
- if (load.state === 'error') {
+ // Network / Connection Error state
+ if (error && !pass) {
return (
-
-
ENTRY PASS
-
{load.message}
+
+
Connection Error
+
{error}
);
}
- const { pass, qr } = load;
- if (pass.registrationStatus !== 'confirmed' || pass.passStatus !== 'active' || !qr) {
- const message =
- pass.registrationStatus === 'pending'
- ? 'Your entry QR appears here once the Registration Team confirms your registration.'
- : pass.registrationStatus === 'rejected'
- ? 'There is no entry pass for a declined registration. Please contact the help desk.'
- : pass.passStatus === 'revoked'
- ? 'This entry pass was cancelled. Please contact the help desk.'
- : 'Your entry pass is not available yet. Please check again later.';
+ if (!pass) return null;
+ return
;
+}
+
+function PassState({ pass, onContinue, preview = false }: { pass: ParticipantPass; onContinue?: () => void; preview?: boolean }) {
+ // Unapproved states: Pending review or Rejected
+ if (!isApprovedPass(pass)) {
+ const isPending = pass.registrationStatus === 'pending';
+ const isRejected = pass.registrationStatus === 'rejected';
+
return (
-
-
ENTRY PASS
-
{message}
+
+
+
+ {isPending ? '● Payment under review' : isRejected ? '✕ Registration declined' : '◌ Registration incomplete'}
+
+ {pass.registrationId && (
+
+ ID: {pass.registrationId}
+
+ )}
+
+
+
+ {isPending
+ ? 'Payment Under Review'
+ : isRejected
+ ? 'Registration Not Approved'
+ : 'Pass Not Yet Issued'}
+
+
+
+ {passStatusMessage(pass)}
+
+
+ {isPending && (
+
+
+ VENUE
+
+
+ National Institute of Technology, Rourkela
+
+
+ )}
);
}
+ // Approved state
return (
-
-
-
ENTRY PASS
- {pass.checkedIn && (
-
- CHECKED IN
-
- )}
-
- {/* eslint-disable-next-line @next/next/no-img-element -- a locally generated data: URL, nothing to optimise */}
-

-
- Show this QR together with your college ID card at the registration desk. Do not share it.
-
-
- {downloadError &&
{downloadError}
}
-
+
);
}
diff --git a/src/components/innovision/Innovision.tsx b/src/components/innovision/Innovision.tsx
index cdaf878..8ebbe50 100644
--- a/src/components/innovision/Innovision.tsx
+++ b/src/components/innovision/Innovision.tsx
@@ -22,6 +22,7 @@ import WorldNav from './WorldNav';
import Curtain from './Curtain';
import AboutPanel from './AboutPanel';
import ProfileOverlay from './ProfileOverlay';
+import RegistrationStatusSync from './RegistrationStatusSync';
import PhoneModal from './PhoneModal';
import EventPopup from './EventPopup';
import AdminDashboard from './AdminDashboard';
@@ -50,7 +51,7 @@ import {
type EventItem,
type GalleryPhoto,
} from '@/lib/supabase';
-import { REGISTRATIONS_OPEN, REGISTRATIONS_SOON_PATH, MERCH_PATH } from '@/config/launch';
+import { REGISTRATIONS_OPEN, REGISTRATIONS_SOON_PATH, MERCH_PATH, REGISTRATION_PREVIEW_ENABLED } 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 { isValidGoogleDriveUrl, GENDER_OPTIONS as GENDERS, normalizeReferralCode, REFERRAL_CODE_PATTERN } from '@/lib/validation';
@@ -353,17 +354,41 @@ export class Innovision extends Component
{
const isOpenRegister = search.get('open') === 'register' || window.location.hash === '#register';
const isOpenLogin = search.get('open') === 'login';
- // Registrations closed (src/config/launch.ts): a register deep link lands on the "opening soon" page instead.
- if (!REGISTRATIONS_OPEN && (isOpenRegister || search.get('required') === 'register')) {
+ // Local dev shortcuts: ?preview=pass, #pass, #ticket or #receipt opens the boarding pass & ticket directly; ?preview=register or #dev-register opens the form
+ if (REGISTRATION_PREVIEW_ENABLED && (search.get('preview') === 'pass' || window.location.hash === '#pass' || window.location.hash === '#ticket' || window.location.hash === '#receipt')) {
+ setTimeout(() => this.devOpenPass(), 600);
+ } else if (REGISTRATION_PREVIEW_ENABLED && (search.get('preview') === 'register' || window.location.hash === '#dev-register')) {
+ setTimeout(() => this.devOpenRegister(), 600);
+ } else 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, and a
// volunteer sent here by the check-in scanner ("Log in again") goes straight back to /admin/scan.
const required = search.get('required');
if (required === 'register' || required === 'scan') sessionStorage.setItem('inv_pending_action', required);
- setTimeout(() => {
- this.openAuth('login');
- this.toast(required === 'scan' ? 'Please log in again to continue scanning.' : 'Please log in to register.');
+
+ // Clean auth and required query params from URL so they don't loop on refresh
+ try {
+ const cleanParams = new URLSearchParams(window.location.search);
+ cleanParams.delete('auth');
+ cleanParams.delete('required');
+ const cleanQuery = cleanParams.toString();
+ window.history.replaceState(null, '', window.location.pathname + (cleanQuery ? `?${cleanQuery}` : '') + window.location.hash);
+ } catch {}
+
+ setTimeout(async () => {
+ if (!this._authReady.done) await this._authReady.p;
+ if (!this.alive) return;
+ if (!this.state.user) {
+ this.openAuth('login');
+ this.toast(required === 'scan' ? 'Please log in again to continue scanning.' : 'Please log in to register.');
+ } else if (required === 'scan') {
+ this.leaveTo('/admin/scan', true);
+ } else if (this.state.registration || this.pass) {
+ this.openAuth('pass');
+ } else {
+ this.openAuth('register');
+ }
}, 1200);
} else if (isOpenRegister) {
// Waits for the session check, so a signed-in student isn't sent to log in first.
@@ -1431,6 +1456,7 @@ export class Innovision extends Component {
}
onKey(e: KeyboardEvent) {
const s = this.state;
+ if (s.auth || s.profileOpen || s.phoneModalOpen) return;
if (e.key === 'Escape' && s.eventPop != null && s.view === 'detail') { this.setState({ eventPop: null }); return; }
if (e.key === 'Escape' && (s.about || s.menu)) { this.setState({ about: false, menu: false }); return; }
const v = s.view;
@@ -1744,7 +1770,7 @@ export class Innovision extends Component {
college: registration.college,
id: registration.registration_id,
email: registration.email,
- status: (registration.status || 'CONFIRMED').toUpperCase(),
+ status: (registration.status || 'pending').toUpperCase(),
utr: registration.utr,
};
}
@@ -1789,6 +1815,17 @@ export class Innovision extends Component {
}
};
+ syncRegistration = (registration: Registration | null) => {
+ if (!this.alive || (registration && registration.user_id !== this.state.user?.id)) return;
+ if (JSON.stringify(registration) === JSON.stringify(this.state.registration)) return;
+ this.pass = registration ? {
+ name: registration.name, college: registration.college, id: registration.registration_id,
+ email: registration.email, status: registration.status.toUpperCase(), utr: registration.utr,
+ } : null;
+ this.setState({ registration });
+ if (registration?.status === 'confirmed' && this.state.authMode === 'pass') this.authSpin(false);
+ };
+
refreshUserProfile = async (userId: string) => {
const epoch = this._authEpoch;
try {
@@ -1990,7 +2027,7 @@ export class Innovision extends Component {
const sc = this.$('[data-auth-scroll]'); if (sc) sc.scrollTop = 0;
const ins = this.$$('[data-a-in]'), ui = this.$('[data-a-ui]'), planet = this.$('[data-a-planet-wrap]');
g.set(root, { autoAlpha: 1, pointerEvents: 'auto' });
- this.authSpin(true);
+ this.authSpin(mode !== 'pass');
g.set(planet, { rotation: -Math.min(this.state.step, 4) * 26 });
if (this.reduce) {
sec.style.clipPath = 'none';
@@ -2070,6 +2107,7 @@ export class Innovision extends Component {
}
// @ts-ignore
focusAuth() {
+ if (this.state.authMode === 'pass') { this.$('[data-auth]')?.focus({ preventScroll: true }); return; }
if (matchMedia('(pointer: coarse)').matches) return;
// @ts-ignore
const el = this.$$('[data-auth] input').find((i) => i.type !== 'file' && i.offsetParent);
@@ -2107,7 +2145,7 @@ export class Innovision extends Component {
}
- playClick();
+ if (m !== 'pass') playClick();
this.setState({ authMode: m, step: m === 'pass' ? 4 : s.step, err: {}, gErr: '' }, () => {
if (m === 'pass') {
this.passReveal();
@@ -2177,6 +2215,13 @@ export class Innovision extends Component {
if (old && old.url && old.url.startsWith('blob:')) URL.revokeObjectURL(old.url);
const previewUrl = isImg ? URL.createObjectURL(f) : '';
+ if (REGISTRATION_PREVIEW_ENABLED && this.state.user?.id === 'dev-preview-user') {
+ this.setState((st) => ({
+ files: { ...st.files, [kind]: { name: f.name, size: f.size, url: previewUrl, pdf: false, status: 'done', remotePath: 'preview/local-only' } },
+ err: this.drop(st.err, ek),
+ }));
+ return;
+ }
this.setState(
(st) => ({
files: { ...st.files, [kind]: { name: f.name, size: f.size, url: previewUrl, pdf: !isImg, status: 'up' } },
@@ -2289,6 +2334,10 @@ export class Innovision extends Component {
const files = s.files || {};
const user = s.user;
const isInternal = (user?.student_type === 'internal') || (user?.email?.toLowerCase().endsWith('@nitrkl.ac.in'));
+ if (user?.id === 'dev-preview-user' && !REGISTRATION_PREVIEW_ENABLED) {
+ this.toast('Preview is disabled. Please sign in with Google.');
+ return;
+ }
if (s.authMode === 'login') {
const em = v('lemail'), id = v('lid').toUpperCase().replace(/\s+/g, '');
@@ -2333,7 +2382,7 @@ export class Innovision extends Component {
if (s.step === 0) {
const name = v('name').replace(/\s+/g, ' ');
- const college = isInternal ? 'National Institute of Technology, Rourkela' : v('college').replace(/\s+/g, ' ');
+ const college = v('college').replace(/\s+/g, ' ');
const email = user.email; // LOCKED - CANNOT BE ALTERED
const phone = v('phone').replace(/\D/g, '').replace(/^(91|0)(?=\d{10}$)/, '');
const enrollment_no = v('enrollment_no');
@@ -2342,13 +2391,13 @@ export class Innovision extends Component {
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) {
+ if (college.length < 3) {
err.college = 'Which college are you from?';
- } else if (!isInternal && college.length > 200) {
+ } else if (college.length > 200) {
err.college = 'Keep the college name under 200 characters.';
}
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.';
+ if (!enrollment_no) err.enrollment_no = 'Enter your college 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).';
}
@@ -2366,6 +2415,53 @@ export class Innovision extends Component {
} else if (s.step === 1) {
this.toStep(2);
} else if (s.step === 2) {
+ if (REGISTRATION_PREVIEW_ENABLED && user.id === 'dev-preview-user') {
+ const utr = v('utr').replace(/\s+/g, '');
+ if (!files.pay || files.pay.status !== 'done') err.payfile = 'Choose an image to preview the payment screenshot upload.';
+ if (!/^\d{12}$/.test(utr)) err.utr = 'Enter a 12-digit UTR to test this step. No payment is submitted.';
+ if (this.fail(err)) return;
+ this.wait('SUBMITTING', 600).then(() => {
+ const r = this.reg || {};
+ const regId = 'PREVIEW-9042';
+ this.pass = {
+ name: r.name || 'Alex Explorer',
+ college: r.college,
+ id: regId,
+ email: user.email,
+ status: 'PENDING',
+ utr,
+ };
+ const mockReg = {
+ id: 'dev-reg-temp',
+ registration_id: regId,
+ user_id: user.id,
+ name: this.pass.name,
+ email: user.email,
+ college: this.pass.college,
+ phone: r.phone || '9876543210',
+ enrollment_no: r.enrollment_no,
+ gender: r.gender,
+ student_type: 'external' as const,
+ payment_proof_path: 'mock/proof.png',
+ utr,
+ amount: 999,
+ status: 'pending' as const,
+ referral_code: 'INNO-' + Math.floor(1000 + Math.random() * 9000),
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ };
+ this.setState(
+ {
+ authMode: 'pass',
+ step: 4,
+ registration: mockReg as Registration,
+ busyLbl: '',
+ },
+ () => this.passReveal()
+ );
+ });
+ return;
+ }
const fp = files.pay, utr = v('utr').replace(/\s+/g, '');
if (!fp) err.payfile = 'Upload the screenshot of your payment.';
else if (fp.status === 'failed') err.payfile = 'Your screenshot did not upload. Use RETRY UPLOAD to send it again.';
@@ -2395,7 +2491,7 @@ export class Innovision extends Component {
};
const saveRes = await createRegistration(regPayload);
- const savedReg = saveRes.registration || (saveRes.success ? regPayload : null);
+ const savedReg = saveRes.registration;
if (!savedReg) {
const msg = saveRes.error || 'Registration failed';
this.toast(msg);
@@ -2448,16 +2544,107 @@ export class Innovision extends Component {
};
// @ts-ignore
passReveal() {
+ this.authSpin(false);
const sc = this.$('[data-auth-scroll]'); if (sc) sc.scrollTo({ top: 0, behavior: 'smooth' });
this.animPane(1, true);
- if (this.reduce) return;
- this.warp(1.8 / this.speed());
- gsap.to(this.$('[data-a-planet-wrap]'), { rotation: -4 * 26, duration: 1.8, ease: 'expo.out' });
- const w = this.$('[data-pass-wrap]');
- if (w) gsap.fromTo(w, { clipPath: 'inset(0% 0% 100% 0%)' }, { clipPath: 'inset(0% 0% 0% 0%)', duration: 1.2, ease: 'expo.out', delay: .1, onComplete: () => gsap.set(w, { clearProps: 'clipPath' }) });
- setTimeout(() => this.scan('pass'), 260);
}
// @ts-ignore
+ devOpenPass = async () => {
+ if (!REGISTRATION_PREVIEW_ENABLED) return;
+ const mockUser: any = {
+ id: 'dev-preview-user',
+ email: 'alex.explorer@nitrkl.ac.in',
+ full_name: 'Alex Explorer',
+ student_type: 'internal',
+ role: 'user',
+ avatar_url: '',
+ phone: '9876543210',
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ };
+ const mockReg: any = {
+ id: 'dev-reg-1',
+ registration_id: 'PREVIEW-9042',
+ user_id: mockUser.id,
+ name: 'Alex Explorer',
+ email: mockUser.email,
+ college: 'Example Institute of Technology',
+ phone: '9876543210',
+ enrollment_no: '122MN0999',
+ gender: 'Male',
+ student_type: 'internal',
+ payment_proof_path: 'mock/payment.png',
+ utr: '987654321098',
+ amount: 999,
+ status: 'confirmed',
+ referral_code: 'INNO-CELESTIAL',
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ };
+ this.pass = {
+ name: mockReg.name,
+ college: mockReg.college,
+ id: mockReg.registration_id,
+ email: mockReg.email,
+ status: 'CONFIRMED',
+ utr: mockReg.utr,
+ };
+ if (!this.state.auth) {
+ await this.openAuth('pass');
+ }
+ this.setState(
+ {
+ user: mockUser,
+ registration: mockReg,
+ auth: true,
+ authMode: 'pass',
+ step: 4,
+ },
+ () => this.passReveal()
+ );
+ };
+
+ // @ts-ignore
+ devOpenRegister = async () => {
+ if (!REGISTRATION_PREVIEW_ENABLED) return;
+ const mockUser: any = {
+ id: 'dev-preview-user',
+ email: 'alex.explorer@nitrkl.ac.in',
+ full_name: 'Alex Explorer',
+ student_type: 'internal',
+ role: 'user',
+ avatar_url: '',
+ phone: '9876543210',
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ };
+ this.reg = {
+ name: 'Alex Explorer',
+ college: '',
+ phone: '9876543210',
+ enrollment_no: '122MN0999',
+ gender: 'Male',
+ referred_by_code: '',
+ };
+ this.pass = null;
+ this.setState(
+ {
+ user: mockUser,
+ registration: null,
+ },
+ async () => {
+ await this.openAuth('register');
+ this.setState({ step: 0 });
+ const sc = this.$('[data-auth-scroll]'); if (sc) sc.scrollTop = 0;
+ }
+ );
+ };
+ devApproveRegistration = () => {
+ if (!REGISTRATION_PREVIEW_ENABLED || this.state.user?.id !== 'dev-preview-user' || this.state.registration?.status !== 'pending') return;
+ this.pass = { ...this.pass, status: 'CONFIRMED' };
+ this.setState((st) => ({ registration: { ...st.registration, status: 'confirmed' } }), () => this.passReveal());
+ };
+ // @ts-ignore
clearErr = (e) => {
const n = e.target && e.target.name;
if (n && this.state.err[n]) this.setState((st) => ({ err: this.drop(st.err, n) }));
@@ -2467,7 +2654,7 @@ export class Innovision extends Component {
// @ts-ignore
authVals(s) {
// @ts-ignore
- const am = s.authMode, st = s.step, reg = am === 'register', show = (b) => (b ? 'flex' : 'none'), gold = 'oklch(0.8 0.12 85)', cream = '#ECE8DF', bad = 'oklch(0.74 0.15 35)';
+ const am = s.authMode, st = s.step, reg = am === 'register', show = (b) => (b ? 'flex' : 'none'), gold = 'oklch(0.78 0.075 85)', cream = '#ECE8DF', bad = 'oklch(0.74 0.15 35)';
const E = Object.assign({ name: '', gender: '', college: '', email: '', phone: '', enrollment_no: '', referred_by_code: '', payfile: '', utr: '', lemail: '', lid: '', form: '' }, s.err);
const bc = {}, inv = {};
// @ts-ignore
@@ -2477,7 +2664,7 @@ export class Innovision extends Component {
const isInternal = (user?.student_type === 'internal') || (user?.email?.toLowerCase().endsWith('@nitrkl.ac.in'));
const regVals = {
name: user?.full_name || R.name || '',
- college: isInternal ? 'National Institute of Technology, Rourkela' : (R.college || ''),
+ college: R.college || s.registration?.college || '',
email: user?.email || '',
phone: user?.phone || R.phone || '',
enrollment_no: user?.enrollment_no || s.registration?.enrollment_no || R.enrollment_no || '',
@@ -2494,21 +2681,21 @@ export class Innovision extends Component {
pick: this.pickFile(kind), over: this.dragOver(kind), leave: this.dragLeave(kind), drop: this.dropFile(kind), remove: () => this.removeFile(kind), replace: () => this.replaceFile(kind), retry: () => this.retryUpload(kind),
};
};
- const showRail = reg || (am === 'pass' && st === 4);
+ const showRail = reg;
const notes = [regVals.name, 'by UPI', 'Submitted'];
// Same three steps for every student: there is no free tier.
- const railSteps = [['DETAILS', 'Name, gender, college, phone', 'DETAILS'], ['PAYMENT', 'by UPI', 'PAY'], ['CONFIRM', 'Screenshot and transaction ID', 'CONFIRM']];
+ const railSteps = [['DETAILS', 'Name, gender, college, phone', 'DETAILS'], ['PAYMENT', 'by UPI', 'PAY'], ['SUBMIT', 'Screenshot and transaction ID, then team review', 'SUBMIT']];
return {
noUser: !s.user, hasUser: !!s.user, showLogin: !s.narrow, loginClick: this.loginClick, noDrop: (e) => e.preventDefault(),
authHidden: String(!s.auth),
authAria: reg ? 'Register for Innovision 2026' : am === 'login' ? 'Log in to Innovision' : 'Your boarding pass',
authCols: s.narrow ? 'minmax(0,1fr)' : 'minmax(0,.9fr) minmax(0,1fr)',
- authTitle: reg ? 'Claim your seat' : am === 'login' ? 'Welcome back' : "You're on board",
+ authTitle: reg ? 'Claim your seat' : am === 'login' ? 'Welcome back' : P.status === 'CONFIRMED' ? 'Your place in the odyssey' : P.status === 'REJECTED' ? 'Payment not approved' : 'Registration received',
authSub: reg
? 'Three short stops to register for Innovision 2026 at NIT Rourkela.'
- : am === 'login' ? 'Sign in using your Google account.' : 'Your boarding pass is ready. See you at NIT Rourkela.',
+ : am === 'login' ? 'Sign in or create your account with Google.' : P.status === 'CONFIRMED' ? 'Your payment is verified. Your INNO pass is always available here.' : P.status === 'REJECTED' ? 'Please contact the registration helpdesk for assistance.' : 'Your payment is under review by the registration team. Your pass appears here after approval.',
railD: show(showRail && !s.narrow), hprogD: showRail && s.narrow ? 'grid' : 'none',
prog: railSteps.map(([label, hint, short], k) => {
const done = k < st, cur = k === st && reg, back = done && reg;
@@ -2536,20 +2723,36 @@ export class Innovision extends Component {
passName: P.name || '', passCollege: P.college || '', passCollegeD: P.college ? 'block' : 'none', passId: P.id || P.registration_id || '', passStatus: P.status ? P.status.toUpperCase() : 'PAYMENT UNDER REVIEW',
// Mounted only while the pass is open, so the QR is fetched then and not on every page load.
passQr: !!s.auth && am === 'pass' && !!s.user,
+ passUserId: s.user?.id,
passNote: P.status === 'CONFIRMED'
? 'You are confirmed. Download your entry pass now and show the QR with your college ID at the registration desk, so entry at NIT Rourkela is quick.'
: P.status === 'REJECTED'
? 'Your registration could not be approved. Please contact the helpdesk.'
- : 'Your payment is being verified and your registration will be approved soon. Keep checking MY PASS: once approved, your entry QR appears here, ready to download.',
+ : 'Your payment is under review. Only the registration team can approve it. This page updates automatically while you are here.',
// The registrant's own referral code, shared from the pass (not once the registration was rejected).
refCode: String(P.status || '').toUpperCase() !== 'REJECTED' ? s.registration?.referral_code || '' : '',
copyRefCode: this.copyReferral, refCodeLbl: s.refCopied ? 'COPIED' : 'COPY CODE',
exploreFromPass: this.exploreFromPass,
googleLogin: this.googleLogin, gBusy: s.gBusy, gErr: s.gErr, gLbl: s.gBusy ? 'Waiting for Google…' : 'Continue with Google',
- gNote: reg && st === 0 && s.user ? 'Signed in as ' + s.user.email + (isInternal ? ' (NIT RKL Student)' : '') + '.' : '',
+ gNote: reg && st === 0 && s.user ? 'Signed in as ' + s.user.email + '.' : '',
regVals,
isInternal,
user: s.user,
+ isDev: REGISTRATION_PREVIEW_ENABLED,
+ isPreviewUser: REGISTRATION_PREVIEW_ENABLED && s.user?.id === 'dev-preview-user',
+ previewPending: REGISTRATION_PREVIEW_ENABLED && s.user?.id === 'dev-preview-user' && s.registration?.status === 'pending',
+ previewPass: REGISTRATION_PREVIEW_ENABLED && s.user?.id === 'dev-preview-user' && s.registration ? {
+ registrationStatus: s.registration.status,
+ passStatus: s.registration.status === 'confirmed' ? 'active' : 'none',
+ registrationId: s.registration.registration_id,
+ name: s.registration.name,
+ college: s.registration.college,
+ checkedIn: false,
+ token: null,
+ } : undefined,
+ devApproveRegistration: this.devApproveRegistration,
+ devOpenPass: this.devOpenPass,
+ devOpenRegister: this.devOpenRegister,
};
}
linkGo = (e: MouseEvent) => {
@@ -2787,6 +2990,7 @@ export class Innovision extends Component {
// reads the live values through LiveV). Small overlays render with every update.
return (
+ {s.user && (s.auth || s.profileOpen) &&
}
diff --git a/src/components/innovision/PassIssued.module.css b/src/components/innovision/PassIssued.module.css
new file mode 100644
index 0000000..34e5c33
--- /dev/null
+++ b/src/components/innovision/PassIssued.module.css
@@ -0,0 +1,651 @@
+/* ═══════════════════════════════════════════
+ Pass Experience — Premium Dark Ticket
+═══════════════════════════════════════════ */
+.experience {
+ --paper: #ece8df;
+ --ink: #141312;
+ --gold: #c8b17a;
+ --gold2: #e4d3a4;
+ --muted: rgba(236,232,223,.5);
+ --surround: #0a0908;
+ color: var(--paper);
+ width: 100%;
+ max-width: 100%;
+ margin: 0 auto;
+ min-width: 0;
+ position: relative;
+}
+
+.issueHeader { max-width:920px; margin:0 auto 22px; text-align:center; }
+.issueHeader .issueEyebrow { margin:0 0 8px; color:var(--gold); font:600 10px/1.4 ui-monospace,monospace; letter-spacing:.18em; }
+.issueHeader h2 { margin:0; font:400 clamp(30px,4vw,44px)/1.1 var(--font-display),serif; overflow-wrap:anywhere; }
+.issueHeader p { margin:10px 0 0; color:#ece8dfaa; font-size:14px; line-height:1.5; }
+
+/* ─── Feed / Clean ticket wrapper ─── */
+.feed {
+ position: relative;
+ border-radius: 0;
+ background: transparent;
+ border: none;
+ box-shadow: none;
+ padding: 0;
+ overflow: visible;
+}
+
+/* ─── Ticket eject animation ─── */
+.paper {
+ position: relative;
+ transform-origin: bottom center;
+}
+
+.revealing .paper {
+ animation: ticketEject 0.85s cubic-bezier(0.16, 1, 0.3, 1) both;
+}
+
+.settled .paper {
+ transform: none;
+ opacity: 1;
+}
+
+@keyframes ticketEject {
+ 0% { opacity: 0; transform: translateY(60px) scale(0.96); filter: blur(4px); }
+ 35% { opacity: 1; filter: blur(0); }
+ 68% { transform: translateY(-5px) scale(1.004); }
+ 100% { transform: translateY(0) scale(1); opacity: 1; }
+}
+
+/* ═══════════════════════════════════════════
+ THE TICKET — dark, premium, space-themed
+═══════════════════════════════════════════ */
+.ticket {
+ position: relative;
+ background: #0e0c0b;
+ border-radius: 7px;
+ overflow: hidden;
+ color: var(--paper);
+ box-shadow:
+ inset 0 0 0 1px rgba(200,177,122,0.22),
+ inset 0 1px 0 rgba(255,255,255,0.06);
+}
+
+/* Starfield dots texture — pure CSS */
+.ticketBg {
+ position: absolute;
+ inset: 0;
+ background-image:
+ radial-gradient(1px 1px at 18% 22%, rgba(236,232,223,0.45) 0%, transparent 100%),
+ radial-gradient(1px 1px at 55% 8%, rgba(236,232,223,0.3) 0%, transparent 100%),
+ radial-gradient(1.5px 1.5px at 82% 15%, rgba(200,177,122,0.4) 0%, transparent 100%),
+ radial-gradient(1px 1px at 7% 55%, rgba(236,232,223,0.25) 0%, transparent 100%),
+ radial-gradient(1px 1px at 92% 48%, rgba(236,232,223,0.3) 0%, transparent 100%),
+ radial-gradient(1px 1px at 33% 72%, rgba(236,232,223,0.2) 0%, transparent 100%),
+ radial-gradient(1.5px 1.5px at 68% 80%, rgba(200,177,122,0.25) 0%, transparent 100%),
+ radial-gradient(1px 1px at 45% 40%, rgba(236,232,223,0.15) 0%, transparent 100%),
+ linear-gradient(135deg, rgba(200,177,122,0.06) 0%, transparent 50%, rgba(30,20,10,0.4) 100%);
+ pointer-events: none;
+ z-index: 0;
+}
+
+/* Gold foil top stripe */
+.foilBar {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 3px;
+ background: linear-gradient(90deg,
+ transparent 0%,
+ rgba(200,177,122,0.6) 20%,
+ rgba(228,211,164,0.95) 50%,
+ rgba(200,177,122,0.6) 80%,
+ transparent 100%
+ );
+ z-index: 2;
+}
+
+/* ─── Header ─── */
+.ticketHeader {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 16px 20px 12px;
+ border-bottom: 1px solid rgba(200,177,122,0.18);
+ background: rgba(200,177,122,0.04);
+}
+
+.brandGroup {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.brandFest {
+ font: 700 14px/1.2 monospace;
+ letter-spacing: .18em;
+ color: var(--gold2);
+ text-shadow: 0 0 20px rgba(200,177,122,0.4);
+}
+
+.brandSub {
+ font: 700 8px/1.3 monospace;
+ letter-spacing: .14em;
+ color: rgba(200,177,122,0.6);
+ text-transform: uppercase;
+}
+
+.statusVerified {
+ font: 700 10px/1 monospace;
+ letter-spacing: .14em;
+ color: #4ade80;
+ background: rgba(74,222,128,0.08);
+ border: 1px solid rgba(74,222,128,0.3);
+ padding: 5px 10px;
+ border-radius: 3px;
+ white-space: nowrap;
+ text-shadow: 0 0 12px rgba(74,222,128,0.4);
+ flex-shrink: 0;
+}
+
+/* ─── Face / Body ─── */
+.face {
+ position: relative;
+ z-index: 1;
+ padding: 18px 20px 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.nameSection {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.nameLabel {
+ margin: 0;
+ font: 700 9px/1 monospace;
+ letter-spacing: .2em;
+ color: var(--gold);
+ opacity: 0.7;
+}
+
+.name {
+ margin: 0;
+ font: 600 clamp(26px, 3vw, 34px)/1.1 var(--font-display);
+ letter-spacing: -.01em;
+ color: #fff;
+ text-shadow: 0 2px 20px rgba(200,177,122,0.18);
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.metaGrid {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.metaRow2 {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px;
+}
+
+.metaCol {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.label {
+ font: 700 8.5px/1 monospace;
+ letter-spacing: .16em;
+ color: rgba(200,177,122,0.55);
+ text-transform: uppercase;
+ margin: 0;
+}
+
+.value {
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 1.4;
+ color: rgba(236,232,223,0.88);
+ margin: 0;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.valueMono {
+ font: 700 13px/1.4 monospace;
+ letter-spacing: .06em;
+ color: var(--gold2);
+ margin: 0;
+ overflow-wrap: anywhere;
+}
+
+/* ─── Perforation ─── */
+.perforation {
+ position: relative;
+ z-index: 1;
+ height: 18px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: transparent;
+}
+
+.perforation::before,
+.perforation::after {
+ content: '';
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ background: var(--surround);
+ box-shadow: 0 0 0 1px rgba(200,177,122,0.15);
+ z-index: 1;
+}
+
+.perforation::before { left: -9px; }
+.perforation::after { right: -9px; }
+
+.perforationLine {
+ width: calc(100% - 24px);
+ border-top: 1px dashed rgba(200, 177, 122, 0.3);
+}
+
+/* ─── Stub ─── */
+.stub {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ padding: 12px 20px 16px;
+ background: rgba(200,177,122,0.03);
+ border-top: 0;
+}
+
+.stubLeft {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ min-width: 0;
+}
+
+.admitOne {
+ font: 700 10px/1 monospace;
+ letter-spacing: .22em;
+ color: var(--gold);
+ opacity: 0.8;
+}
+
+.stubId {
+ font: 700 clamp(15px,2vw,18px)/1.2 monospace;
+ letter-spacing: .06em;
+ color: #fff;
+ overflow-wrap: anywhere;
+}
+
+/* ─── QR thumbnail — SMALL, tappable ─── */
+.qrThumb {
+ flex: none;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ padding: 5px;
+ border: 1px solid rgba(200,177,122,0.4);
+ border-radius: 4px;
+ background: #fff;
+ cursor: pointer;
+ transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s;
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
+}
+
+.qrThumb:hover {
+ transform: scale(1.08);
+ border-color: var(--gold2);
+ box-shadow: 0 4px 16px rgba(200,177,122,0.35);
+}
+
+.qrThumb img {
+ display: block;
+ width: 56px;
+ height: 56px;
+}
+
+.qrThumbHint {
+ font: 700 7px/1 monospace;
+ letter-spacing: .1em;
+ color: rgba(20,19,18,0.45);
+ text-transform: uppercase;
+ white-space: nowrap;
+}
+
+/* QR placeholder (not yet available) */
+.qrPlaceholder {
+ flex: none;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 70px;
+ height: 70px;
+ border: 1px dashed rgba(200,177,122,0.3);
+ border-radius: 4px;
+ color: rgba(200,177,122,0.4);
+}
+
+/* ═══════════════════════════════════════════
+ QR Lightbox
+═══════════════════════════════════════════ */
+.qrLightbox {
+ position: fixed;
+ inset: 0;
+ z-index: 200;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(7,6,5,0.85);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+ animation: fadeIn 0.15s ease;
+ cursor: pointer;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+.qrLightboxInner {
+ background: #fff;
+ padding: 20px;
+ border-radius: 10px;
+ box-shadow:
+ 0 0 0 1px rgba(200,177,122,0.5),
+ 0 40px 100px rgba(0,0,0,0.7);
+ animation: popIn 0.2s cubic-bezier(0.34,1.56,0.64,1);
+ cursor: default;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+}
+
+@keyframes popIn {
+ from { transform: scale(0.78); opacity: 0; }
+ to { transform: scale(1); opacity: 1; }
+}
+
+.qrLightboxInner img {
+ display: block;
+ width: min(72vw, 280px);
+ height: auto;
+}
+
+.qrLightboxHint {
+ margin: 0;
+ font: 700 10px/1.4 monospace;
+ letter-spacing: .14em;
+ color: rgba(20,19,18,0.45);
+ text-transform: uppercase;
+ text-align: center;
+}
+
+/* ═══════════════════════════════════════════
+ Below-ticket elements
+═══════════════════════════════════════════ */
+.quietLine {
+ margin: 14px 0 0;
+ text-align: center;
+ font: 400 15px/1.4 var(--font-display);
+ color: rgba(236,232,223,0.75);
+ letter-spacing: .04em;
+}
+
+.actions {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: 12px;
+ margin-top: 16px;
+}
+
+.primaryBtn {
+ min-height: 48px;
+ padding: 12px 20px;
+ border: 1px solid var(--gold);
+ background: rgba(200,177,122,0.1);
+ color: var(--gold2);
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: .08em;
+ cursor: pointer;
+ transition: background-color 0.2s, color 0.2s;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ border-radius: 3px;
+}
+.primaryBtn:hover { background: rgba(200,177,122,0.2); }
+
+.secondaryBtn {
+ min-height: 48px;
+ padding: 12px 20px;
+ border: 1px solid rgba(236,232,223,0.25);
+ background: transparent;
+ color: rgba(236,232,223,0.8);
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: .08em;
+ cursor: pointer;
+ transition: border-color 0.2s, background-color 0.2s;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ border-radius: 3px;
+}
+.secondaryBtn:hover { border-color: var(--gold); background: rgba(200,177,122,0.05); }
+.secondaryBtn:disabled { opacity: 0.5; cursor: not-allowed; }
+
+/* WhatsApp */
+.whatsappCard {
+ width: 100%;
+ max-width: 640px;
+ margin: 22px auto 0;
+ padding: 16px 20px;
+ border-radius: 8px;
+ background: linear-gradient(145deg, rgba(37,211,102,0.1) 0%, rgba(18,140,126,0.04) 100%), rgba(10,9,8,0.85);
+ border: 1px solid rgba(37,211,102,0.3);
+ box-shadow: 0 8px 24px rgba(0,0,0,0.4);
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.whatsappHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.whatsappBadge {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font: 700 9.5px monospace;
+ letter-spacing: .15em;
+ text-transform: uppercase;
+ color: #25d366;
+}
+
+.whatsappBadgeDot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: #25d366;
+ box-shadow: 0 0 6px #25d366;
+ animation: none;
+}
+
+@keyframes pulseDot {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: 0.4; transform: scale(0.82); }
+}
+
+.whatsappVenue {
+ font: 700 9px monospace;
+ letter-spacing: .12em;
+ color: rgba(236,232,223,0.45);
+}
+
+.whatsappTitle {
+ margin: 0;
+ font: 500 16px/1.25 var(--font-display);
+ color: #ECE8DF;
+}
+
+.whatsappDesc {
+ margin: 3px 0 0;
+ font-size: 13px;
+ line-height: 1.45;
+ color: rgba(236,232,223,0.65);
+}
+
+.whatsappBtn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ min-height: 46px;
+ padding: 0 18px;
+ border-radius: 4px;
+ background: #25d366;
+ color: #0b1a10;
+ text-decoration: none;
+ font-weight: 700;
+ font-size: 13px;
+ letter-spacing: .08em;
+ box-shadow: 0 4px 14px rgba(37,211,102,0.28);
+ transition: background-color 0.2s, transform 0.15s;
+ cursor: pointer;
+}
+.whatsappBtn:hover { background: #2ee06f; transform: translateY(-1px); }
+
+/* Skip button during reveal */
+.skipBtn {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ z-index: 20;
+ padding: 5px 10px;
+ background: rgba(14,12,11,0.8);
+ border: 1px solid rgba(200,177,122,0.3);
+ border-radius: 3px;
+ color: rgba(200,177,122,0.9);
+ font: 700 10px/1 monospace;
+ letter-spacing: .12em;
+ cursor: pointer;
+ transition: opacity 0.2s;
+ opacity: 0.75;
+}
+.skipBtn:hover { opacity: 1; }
+
+/* Misc */
+.qrHint {
+ font-size: 12px;
+ color: rgba(236,232,223,0.5);
+ margin: 10px 0 0;
+ text-align: center;
+ letter-spacing: .02em;
+}
+
+.hint {
+ font-size: 13px;
+ color: rgba(236,232,223,0.6);
+ margin: 12px 0;
+}
+
+.errorText {
+ font-size: 13px;
+ color: #f87171;
+ margin: 10px 0 0;
+ padding: 8px 12px;
+ background: rgba(239,68,68,0.08);
+ border: 1px solid rgba(239,68,68,0.25);
+ border-radius: 3px;
+}
+
+/* Status boxes */
+.statusBox {
+ padding: 22px 20px;
+ background: rgba(236,232,223,0.03);
+ border: 1px solid rgba(236,232,223,0.12);
+ border-radius: 6px;
+ color: var(--paper);
+ width: 100%;
+ max-width: 480px;
+ margin: 0 auto;
+}
+.statusTitle { margin: 0; font: 500 18px/1.2 var(--font-display); color: #ECE8DF; }
+.statusDesc { margin: 6px 0 0; font-size: 13.5px; line-height: 1.5; color: rgba(236,232,223,0.7); }
+
+.statusBadgePending {
+ display: inline-flex; align-items: center; gap: 5px;
+ font: 700 10.5px monospace; letter-spacing: .12em;
+ color: #c8b17a; background: rgba(200,177,122,0.08);
+ padding: 4px 8px; border-radius: 3px; border: 1px solid rgba(200,177,122,0.25);
+}
+.statusBadgeDeclined {
+ display: inline-flex; align-items: center; gap: 5px;
+ font: 700 10.5px monospace; letter-spacing: .12em;
+ color: #f87171; background: rgba(239,68,68,0.08);
+ padding: 4px 8px; border-radius: 3px; border: 1px solid rgba(239,68,68,0.25);
+}
+
+.experience button:focus-visible,
+.experience a:focus-visible {
+ outline: 2px solid var(--gold);
+ outline-offset: 3px;
+}
+
+/* Reduced motion */
+@media (prefers-reduced-motion: reduce) {
+ .revealing .paper { animation: none; }
+ .paper { transition: none; }
+ .whatsappBadgeDot { animation: none; }
+}
+
+/* Mobile */
+@media(min-width:681px) {
+ .whatsappCard { max-width:920px; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:10px 24px; padding:18px 22px; margin-top:24px; background:rgba(236,232,223,.025); border-color:rgba(236,232,223,.13); box-shadow:none; }
+ .whatsappHeader { grid-column:1/-1; }
+ .whatsappBtn { align-self:center; white-space:nowrap; background:#c8b17a; border:1px solid #e4d3a4; color:#141312; box-shadow:none; font-size:13px; letter-spacing:.02em; }
+ .whatsappBtn:hover { background:#e4d3a4; transform:none; }
+}
+@media(max-width:680px) {
+ .issueHeader { max-width:440px; margin-bottom:20px; text-align:left; }
+ .issueHeader h2 { font-size:34px; }
+ .issueHeader p { max-width:34ch; font-size:13px; }
+ .whatsappCard { max-width:440px; margin-top:20px; padding:18px; border-color:rgba(236,232,223,.14); background:rgba(236,232,223,.025); box-shadow:none; }
+ .whatsappHeader { flex-wrap:wrap; }.whatsappVenue { display:none; }
+ .whatsappBadge { font-size:9px; letter-spacing:.08em; }
+ .whatsappBtn { background:#c8b17a; border:1px solid #e4d3a4; color:#141312; box-shadow:none; letter-spacing:.02em; }
+ .whatsappBtn:hover { background:#e4d3a4; }
+}
+@media (max-width: 420px) {
+ .metaRow2 { grid-template-columns: 1fr; gap: 10px; }
+ .actions { grid-template-columns: 1fr; }
+ .stub { flex-wrap: wrap; }
+}
diff --git a/src/components/innovision/PassIssued.tsx b/src/components/innovision/PassIssued.tsx
new file mode 100644
index 0000000..4717a55
--- /dev/null
+++ b/src/components/innovision/PassIssued.tsx
@@ -0,0 +1,210 @@
+'use client';
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+import type { ParticipantPass } from '@/lib/pass-experience';
+import { WHATSAPP_COMMUNITY_URL } from '@/config/site';
+import { REGISTRATION_PREVIEW_ENABLED } from '@/config/launch';
+import PassTicket from './PassTicket';
+import { passRequest } from './pass-client';
+import styles from './PassIssued.module.css';
+
+// Share pending claims across Strict Mode mounts and ordinary route remounts.
+const presented = new Map();
+const claims = new Map }>();
+
+interface PassIssuedProps {
+ pass: ParticipantPass;
+ qr: string | null;
+ onContinue?: () => void;
+ preview?: boolean;
+}
+
+export default function PassIssued({ pass, qr, preview = false }: PassIssuedProps) {
+ const id = pass.registrationId || 'unknown';
+ const isPreview = REGISTRATION_PREVIEW_ENABLED && preview;
+ const [phase, setPhase] = useState<'waiting' | 'revealing' | 'settled' | 'unavailable'>('waiting');
+ const root = useRef(null);
+ const ackBusy = useRef(false);
+
+ const acknowledge = useCallback(async () => {
+ if (isPreview) return;
+ const entry = presented.get(id);
+ if (!entry?.displayed || entry.acknowledged || ackBusy.current) return;
+ ackBusy.current = true;
+ try {
+ const data = await passRequest('/api/pass/experience', { action: 'acknowledge', claim: entry.claim });
+ if (data.result === true) {
+ entry.acknowledged = true;
+ claims.delete(id);
+ }
+ } catch {
+ // Preserve the claim and retry on focus/connection recovery or a later visit.
+ } finally {
+ ackBusy.current = false;
+ }
+ }, [id, isPreview]);
+
+ useEffect(() => {
+ let cancelled = false;
+ let started = false;
+ let timer: ReturnType | undefined;
+ let firstFrame = 0;
+ let secondFrame = 0;
+ const reduced = matchMedia('(prefers-reduced-motion: reduce)');
+
+ const show = (animate: boolean) => {
+ const moving = animate && !reduced.matches;
+ setPhase(moving ? 'revealing' : 'settled');
+ // Acknowledge only after the actual pass has painted, never during loading.
+ firstFrame = requestAnimationFrame(() => {
+ secondFrame = requestAnimationFrame(() => {
+ if (cancelled) return;
+ const entry = presented.get(id);
+ if (entry) entry.displayed = true;
+ if (!moving) void acknowledge();
+ });
+ });
+ if (moving) timer = setTimeout(() => {
+ if (!cancelled) { setPhase('settled'); void acknowledge(); }
+ }, 2400);
+ };
+
+ const check = async () => {
+ if (cancelled || started || document.visibilityState !== 'visible') return;
+ started = true;
+ if (isPreview) { show(true); return; }
+ const existing = presented.get(id);
+ if (existing?.acknowledged || existing?.displayed) { show(false); return; }
+ let pending = claims.get(id);
+ if (!pending) {
+ const claim = crypto.randomUUID();
+ pending = { claim, request: passRequest('/api/pass/experience', { action: 'claim', claim }) };
+ claims.set(id, pending);
+ }
+ try {
+ const data = await pending.request;
+ if (cancelled) return;
+ if (data.result === 'ineligible') {
+ claims.delete(id);
+ setPhase('unavailable');
+ } else if (data.result === 'claimed') {
+ if (!presented.has(id)) presented.set(id, { claim: pending.claim, acknowledged: false, displayed: false });
+ show(true);
+ } else {
+ claims.delete(id);
+ show(false); // Server says seen, or another tab owns presentation.
+ }
+ } catch {
+ claims.delete(id);
+ if (!cancelled) show(false); // Access to the approved pass survives an acknowledgement outage.
+ }
+ };
+ void check();
+ const onVisibility = () => {
+ if (document.visibilityState === 'visible') { void check(); void acknowledge(); }
+ else if (presented.get(id)?.displayed) { setPhase('settled'); void acknowledge(); }
+ };
+ const onReduced = () => {
+ if (reduced.matches) { setPhase('settled'); void acknowledge(); }
+ };
+ const onOnline = () => { void acknowledge(); };
+ document.addEventListener('visibilitychange', onVisibility);
+ window.addEventListener('online', onOnline);
+ window.addEventListener('focus', onOnline);
+ reduced.addEventListener('change', onReduced);
+ return () => {
+ cancelled = true;
+ clearTimeout(timer);
+ cancelAnimationFrame(firstFrame);
+ cancelAnimationFrame(secondFrame);
+ document.removeEventListener('visibilitychange', onVisibility);
+ window.removeEventListener('online', onOnline);
+ window.removeEventListener('focus', onOnline);
+ reduced.removeEventListener('change', onReduced);
+ void acknowledge();
+ };
+ }, [id, acknowledge, isPreview]);
+ if (phase === 'unavailable') {
+ return (
+
+ Registration Status Updated
+
+ Your registration status has been updated. Please refresh your dashboard to view the latest status.
+
+
+ );
+ }
+
+ return (
+
+
+
YOUR INNO PASS, ISSUED.
+
You’re in, {pass.name?.trim().split(/\s+/)[0]}.
+
Your payment has been verified by the registration team.
+
+ {/* Skip reveal button — only during eject animation */}
+ {phase === 'revealing' && (
+
+ )}
+
+ {phase === 'waiting' ? (
+ Preparing your pass…
+ ) : (
+ <>
+
+
+ {/* WhatsApp Community Card */}
+
+
+
+
+ OFFICIAL ATTENDEES GROUP
+
+ NIT ROURKELA
+
+
+
Stay in the loop.
+
+ Event updates, schedule changes, and help from the team — all in the official community.
+
+
+
+
+ Join the community
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/components/innovision/PassKeepsake.module.css b/src/components/innovision/PassKeepsake.module.css
new file mode 100644
index 0000000..1b3c774
--- /dev/null
+++ b/src/components/innovision/PassKeepsake.module.css
@@ -0,0 +1,71 @@
+.card {
+ --pass-ink: #141312;
+ --pass-muted: #655d50;
+ position: relative;
+ width: 100%;
+ overflow: hidden;
+ color: var(--pass-ink);
+ background: radial-gradient(ellipse at top left, #f7f8f8, transparent 70%), #eeede7;
+ border: 1px solid #d5cdbd;
+ border-radius: 14px;
+ box-shadow: 0 16px 42px #0003;
+ text-align: left;
+ font-family: var(--font-sans), sans-serif;
+}
+.brand { display:flex; align-items:center; justify-content:space-between; gap:14px; padding:24px; border-top:5px solid #c8b17a; border-bottom:1px solid #d5cdbd; }
+.brand > div { min-width:0; }
+.overline { display:block; font:600 10px/1.5 ui-monospace, monospace; letter-spacing:.13em; color:var(--pass-muted); }
+.brand strong { display:block; margin-top:5px; font-size:clamp(17px,4.8vw,23px); letter-spacing:.025em; }
+.brand strong span { font-weight:400; }
+.seal { flex:none; padding:7px 9px; border:1px solid #9b8660; border-radius:3px; color:#786343; font:700 9px/1.45 ui-monospace, monospace; letter-spacing:.07em; text-align:center; transform:rotate(-5deg); }
+.main { padding:28px 24px 24px; }
+.name { margin:10px 0 24px; font:700 clamp(29px,7.8vw,40px)/1.14 var(--font-sans), sans-serif; letter-spacing:-.045em; overflow-wrap:anywhere; }
+.dateRow { display:flex; align-items:baseline; justify-content:space-between; gap:12px; padding:12px 24px; border-bottom:1px solid #d5cdbd; }
+.dateRow span { font:500 9px/1.4 ui-monospace,monospace; letter-spacing:.12em; color:var(--pass-muted); }.dateRow strong { font-size:12px; font-weight:700; }
+.details { display:grid; gap:20px; margin:0 0 0 4px; padding-left:20px; border-left:1px dashed #b5a78d; }
+.details > div { position:relative; }
+.details > div::before { content:''; position:absolute; width:7px; height:7px; left:-24px; top:4px; border:1px solid #9b8660; border-radius:50%; background:#eeede7; }
+.destination dd { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; }
+.destination dd strong { font:600 23px/1.2 ui-monospace,monospace; letter-spacing:.05em; }
+.destination dd span { font-size:13px; color:var(--pass-muted); }
+.details dt { font:600 10px/1.5 ui-monospace, monospace; text-transform:uppercase; letter-spacing:.13em; color:var(--pass-muted); }
+.details dd { margin:4px 0 0; font-size:15px; line-height:1.5; overflow-wrap:anywhere; }
+.perforation { position:relative; height:20px; margin:0 14px; background:linear-gradient(to right,#a99d86 45%,transparent 0) center/8px 1px repeat-x; }
+.perforation::before,.perforation::after { content:''; position:absolute; top:0; width:20px; height:20px; border-radius:50%; background:#0c0b0a; }
+.perforation::before { left:-25px; }.perforation::after { right:-25px; }
+.stub { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:22px; padding:20px 24px 24px; }
+.registration { min-width:0; flex:1 1 130px; }
+.registration strong { display:block; margin-top:8px; font:600 18px/1.4 ui-monospace, monospace; overflow-wrap:anywhere; }
+.registration p { margin:12px 0 0; max-width:24ch; font-size:12px; line-height:1.5; color:var(--pass-muted); }
+.qr { flex:0 0 132px; text-align:center; margin-inline:auto; }
+.qr svg { display:block; width:132px; height:132px; max-width:100%; background:white; }
+.qr span { display:block; margin-top:6px; font:600 9px/1.4 ui-monospace, monospace; letter-spacing:.1em; color:var(--pass-muted); }
+.footer { padding:16px 24px; border-top:1px solid #d5cdbd; text-align:center; font:600 15px/1.4 var(--font-sans), sans-serif; }
+.footer small { display:block; margin-top:8px; font:500 9px/1.4 ui-monospace,monospace; color:var(--pass-muted); letter-spacing:.08em; }
+/* A single, restrained ink/write glitch; the final frame is always the real text. */
+.card[data-animate="true"] .details dd,.card[data-animate="true"] .registration strong { animation:inkWrite 1.65s steps(24,end) .25s both; }
+.details dd { animation-delay:.42s; }.registration strong { animation-delay:.55s; }
+@keyframes inkWrite {
+ 0% { clip-path:inset(0 100% 0 0); transform:translateX(0); text-shadow:none; }
+ 25% { clip-path:inset(0 76% 0 0); transform:translateX(-1px); text-shadow:1px 0 #c8b17a55; }
+ 29% { transform:translateX(1px); }
+ 50% { clip-path:inset(0 48% 0 0); transform:translateX(0); text-shadow:none; }
+ 70% { clip-path:inset(0 24% 0 0); transform:translateX(-.5px); text-shadow:.5px 0 #c8b17a55; }
+ 92%,100% { clip-path:inset(0); transform:none; text-shadow:none; }
+}
+@media(prefers-reduced-motion:reduce) { .name,.details dd,.registration strong { animation:none!important; } }
+@media(max-width:360px) {
+ .brand,.main,.stub { padding-left:19px; padding-right:19px; }
+ .brand { gap:8px; }.brand strong { font-size:16px; }.seal { font-size:8px; }
+ .qr { flex-basis:116px; }.qr svg { width:116px; height:116px; }
+}
+@media print {
+ .name,.details dd,.registration strong { animation:none!important; clip-path:none!important; transform:none!important; text-shadow:none!important; }
+ .card { border-radius:6mm; box-shadow:none; background:#f6f2e9; break-inside:avoid; -webkit-print-color-adjust:exact; print-color-adjust:exact; }
+ .brand { padding:9mm; border-top-width:2mm; }.brand strong { font-size:22pt; }.overline { font-size:8pt; }
+ .seal { font-size:9pt; padding:3mm; }.main { padding:10mm 9mm; }.name { font-size:32pt; margin:5mm 0 8mm; }
+ .details { gap:6mm; }.details dt { font-size:8pt; }.details dd { font-size:12pt; }
+ .perforation::before,.perforation::after { background:#fff; }.stub { padding:6mm 9mm 8mm; gap:8mm; }
+ .registration strong { font-size:16pt; }.registration p { font-size:9pt; }.qr { flex-basis:38mm; }.qr svg { width:38mm; height:38mm; }
+ .footer { padding:6mm; font-size:16pt; }
+}
diff --git a/src/components/innovision/PassKeepsake.tsx b/src/components/innovision/PassKeepsake.tsx
new file mode 100644
index 0000000..5fb98f3
--- /dev/null
+++ b/src/components/innovision/PassKeepsake.tsx
@@ -0,0 +1,39 @@
+import { QRCodeSVG } from 'qrcode.react';
+import type { ParticipantPass } from '@/lib/pass-experience';
+import { EVENT_DATE, VENUE_CITY } from '@/config/site';
+import DecryptText from '@/components/ui/decrypt-text';
+import styles from './PassKeepsake.module.css';
+
+/** The readable, flat version of the pass, shared by phones and paper. */
+export default function PassKeepsake({ pass, animate = false }: { pass: ParticipantPass; animate?: boolean }) {
+ return (
+
+
+ EVENT DATE{EVENT_DATE.label}
+
+
ISSUED TO
+
+
+ - From · College
- {pass.college}
+ - To · Venue
- NITNIT Rourkela
+
+
+
+
+
+
REGISTRATION ID
+
{pass.registrationId}
+
Verified by the registration team.
+
+ {pass.token &&
+
+ YOUR ENTRY QR
+
}
+
+
+
+ );
+}
diff --git a/src/components/innovision/PassTicket.tsx b/src/components/innovision/PassTicket.tsx
new file mode 100644
index 0000000..09b7d27
--- /dev/null
+++ b/src/components/innovision/PassTicket.tsx
@@ -0,0 +1,75 @@
+'use client';
+
+import { isApprovedPass, type ParticipantPass } from '@/lib/pass-experience';
+import ApprovedPassTicket from './ApprovedPassTicket';
+import styles from './PassIssued.module.css';
+import { EVENT_DATE } from '@/config/site';
+
+export default function PassTicket({ pass, animate = false }: {
+ pass: ParticipantPass;
+ qr: string | null;
+ onQrClick?: () => void;
+ animate?: boolean;
+}) {
+ if (isApprovedPass(pass)) return ;
+ // Pending approval state
+ return (
+
+
+
+
+
+
+ INNOVISION 2026
+ NIT ROURKELA · TECHNO-MANAGEMENT FEST
+
+
+ ◌ PAYMENT UNDER REVIEW
+
+
+
+
+
+
PARTICIPANT
+
{pass.name || 'Awaiting Verification'}
+
+
+
+
+ COLLEGE
+ {pass.college || 'Not provided'}
+
+
+
+ DATE
+ {EVENT_DATE.display}
+
+
+ REG ID
+ {pass.registrationId || 'PENDING'}
+
+
+
+
+
+
+
+
+
+
+
+ ADMIT ONE
+ {pass.registrationId || 'PENDING'}
+
+
+
+ UNDER REVIEW
+
+
+
+ );
+}
diff --git a/src/components/innovision/ProfileOverlay.tsx b/src/components/innovision/ProfileOverlay.tsx
index 382d404..a8eb158 100644
--- a/src/components/innovision/ProfileOverlay.tsx
+++ b/src/components/innovision/ProfileOverlay.tsx
@@ -1,8 +1,10 @@
'use client';
-import React, { useState } from 'react';
+import React, { useRef, useState } from 'react';
+import { useDialogFocus } from './useDialogFocus';
import type { UserProfile, Registration } from '@/lib/supabase';
import EntryPassCard from './EntryPassCard';
+import { REGISTRATION_PREVIEW_ENABLED } from '@/config/launch';
interface ProfileOverlayProps {
isOpen: boolean;
@@ -27,6 +29,8 @@ export default function ProfileOverlay({
onLogout,
onUpdatePhone,
}: ProfileOverlayProps) {
+ const dialog = useRef(null);
+ useDialogFocus(isOpen, dialog, onClose);
const [editingPhone, setEditingPhone] = useState(false);
const [phoneVal, setPhoneVal] = useState(user?.phone || '');
const [phoneError, setPhoneError] = useState('');
@@ -59,6 +63,8 @@ export default function ProfileOverlay({
return (
-
+