diff --git a/.gitignore b/.gitignore index fd39b90..eb21281 100644 --- a/.gitignore +++ b/.gitignore @@ -49,8 +49,10 @@ next-env.d.ts /test-results/ /playwright-report/ -# tests (kept locally only; run with `npm test` / `npx playwright test`) -/tests/ +# Regression tests are versioned; generated results remain ignored above. # local tooling (check:assets, payment-proof migration; used by the local-only tests) /scripts/ + +# Isolated pass component test bundle +.pass-test/ diff --git a/components.json b/components.json new file mode 100644 index 0000000..99955cd --- /dev/null +++ b/components.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui" + } +} diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx new file mode 100644 index 0000000..b245a05 --- /dev/null +++ b/components/ui/dialog.tsx @@ -0,0 +1 @@ +export * from "@/components/ui/dialog"; diff --git a/components/ui/event-ticket.tsx b/components/ui/event-ticket.tsx new file mode 100644 index 0000000..93c1005 --- /dev/null +++ b/components/ui/event-ticket.tsx @@ -0,0 +1,1054 @@ +"use client" + +import * as React from "react" +import DecryptText from "@/components/ui/decrypt-text" + +/** + * Event ticket — a two-sided pass. + * Front: black VIP pass with a halftone Moon that follows the pointer's light, + * plus a clarinet and a rocket that react on hover. + * Back: silver boarding pass with a perforated, tearable stub and a QR slot. + * Click either face (or the switch below) to turn it over. + */ + +// #region pass — pure helpers + +export const clamp01 = (v: number): number => (v > 0 ? (v < 1 ? v : 1) : 0) + +export const smoothstep = (a: number, b: number, x: number): number => { + if (a === b) return x < a ? 0 : 1 + const t = clamp01((x - a) / (b - a)) + return t * t * (3 - 2 * t) +} + +const finite = (v: number): number => (Number.isFinite(v) ? v : 0) + +export const toDMS = (deg: number, pos: string, neg: string): string => { + const d = finite(deg) + let s = Math.round(Math.abs(d) * 3600) + const D = Math.floor(s / 3600) + s -= D * 3600 + const M = Math.floor(s / 60) + s -= M * 60 + const pad = (n: number): string => (n < 10 ? "0" : "") + n + return D + "°" + pad(M) + "'" + pad(s) + '"' + (d < 0 ? neg : pos) +} + +export const coordsDMS = (lat: number, lon: number): string => toDMS(lat, "N", "S") + " " + toDMS(lon, "E", "W") + +export const coordsDecimal = (lat: number, lon: number): string => + finite(lat).toFixed(5) + "°, " + finite(lon).toFixed(5) + "°" + +export const project = (lat: number, lon: number): number[] => { + const a = (finite(lat) * Math.PI) / 180 + const b = (finite(lon) * Math.PI) / 180 + return [Math.cos(a) * Math.sin(b), -Math.sin(a), Math.cos(a) * Math.cos(b)] +} + +export const angDist = (lat1: number, lon1: number, lat2: number, lon2: number): number => { + const p = project(lat1, lon1) + const q = project(lat2, lon2) + const d = p[0] * q[0] + p[1] * q[1] + p[2] * q[2] + return (Math.acos(d > 1 ? 1 : d < -1 ? -1 : d) * 180) / Math.PI +} + +export const hash2 = (i: number, j: number): number => { + let h = (Math.imul(i | 0, 374761393) + Math.imul(j | 0, 668265263)) | 0 + h = Math.imul(h ^ (h >>> 13), 1274126177) + h ^= h >>> 16 + return (h >>> 0) / 4294967296 +} + +export const valueNoise = (x: number, y: number): number => { + const i = Math.floor(x) + const j = Math.floor(y) + const fx = x - i + const fy = y - j + const u = fx * fx * (3 - 2 * fx) + const v = fy * fy * (3 - 2 * fy) + const a = hash2(i, j) + const b = hash2(i + 1, j) + const c = hash2(i, j + 1) + const d = hash2(i + 1, j + 1) + return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v +} + +/** Dark seas: lat, lon, radius (deg), depth. */ +export const MARIA: number[][] = [ + [18, -57, 26, 0.7], + [33, -16, 17, 0.85], + [28, 17.5, 9.5, 0.85], + [8.5, 31, 11, 0.85], + [17, 59, 7, 0.9], + [-8, 51, 9, 0.7], + [-15, 35, 5, 0.7], + [-21, -17, 10, 0.65], + [-24, -39, 5, 0.75], + [-10, -23, 6, 0.6], + [13, 4, 4, 0.6], + [57, 0, 8, 0.6], + [56, -30, 7, 0.55], + [56, 28, 6, 0.5], +] + +/** Bright young craters: lat, lon, radius, brightness. */ +export const BRIGHT: number[][] = [ + [-43.3, -11.2, 5, 0.45], + [9.6, -20.1, 3.5, 0.35], + [8.1, -38, 2.5, 0.3], + [23.7, -47.4, 2.5, 0.4], +] + +export const albedo = (lat: number, lon: number): number => { + let keep = 1 + for (const m of MARIA) { + const t = angDist(lat, lon, m[0], m[1]) / m[2] + if (t < 1) { + const w = 1 - t * t + keep *= 1 - m[3] * w * w + } + } + let a = 0.22 + 0.66 * keep + for (const b of BRIGHT) { + const t = angDist(lat, lon, b[0], b[1]) / b[2] + if (t < 1) a += b[3] * (1 - t) * (1 - t) + } + a += (valueNoise(lat * 0.21 + 40, lon * 0.21 + 40) - 0.5) * 0.18 + a += (valueNoise(lat * 0.9, lon * 0.9) - 0.5) * 0.08 + return clamp01(a) +} + +export const turnFor = (lon: number): number => Math.max(0, Math.min(60, 12 - finite(lon))) + +export const moonDots = (cx: number, cy: number, r: number, cell: number, turn: number): number[] => { + const out: number[] = [] + if (!(r > 0) || !(cell > 0)) return out + const row = cell * 0.866 + let k = 0 + for (let y = cy - r; y <= cy + r; y += row, k++) { + for (let x = cx - r + (k % 2 ? cell / 2 : 0); x <= cx + r; x += cell) { + const nx = (x - cx) / r + const ny = (y - cy) / r + const q = nx * nx + ny * ny + if (q >= 1) continue + const nz = Math.sqrt(1 - q) + const lat = (Math.asin(-ny) * 180) / Math.PI + const lon = (Math.atan2(nx, nz) * 180) / Math.PI - finite(turn) + out.push(x, y, nx, ny, nz, albedo(lat, lon)) + } + } + return out +} + +export const lightFrom = (px: number, py: number): number[] => { + const phi = ((clamp01(finite(px)) - 0.5) * 2 * 150 * Math.PI) / 180 + const v = [Math.sin(phi), (clamp01(finite(py)) - 0.5) * 0.9, Math.cos(phi)] + const n = Math.hypot(v[0], v[1], v[2]) + return [v[0] / n, v[1] / n, v[2] / n] +} + +export const idleAt = (seconds: number): number => 0.56 + Math.sin((finite(seconds) * Math.PI * 2) / 26) * 0.07 + +export const dotRadius = (alb: number, nx: number, ny: number, nz: number, l: number[], cell: number): number => { + const lit = smoothstep(-0.08, 0.32, nx * l[0] + ny * l[1] + nz * l[2]) + const b = clamp01(alb) * (0.07 + 0.93 * lit) * (0.7 + 0.3 * nz) + return cell * 0.46 * Math.pow(b, 0.8) +} + +export const tiltFrom = (px: number, py: number, max: number): number[] => [ + -(clamp01(finite(py)) - 0.5) * 2 * max, + (clamp01(finite(px)) - 0.5) * 2 * max, +] + +export const tearDecision = (dx: number, v: number, w: number): boolean => + dx > w * 0.2 || (v > 0.5 && dx > w * 0.05) + +export const resist = (dx: number, w: number): number => { + const d = Math.max(0, finite(dx)) + const lim = Math.max(1, w) * 0.12 + return d < lim ? d : lim + (d - lim) * 0.22 +} + +export const mulberry = (seed: number): (() => number) => { + let a = seed >>> 0 + return () => { + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +export const starField = (seed: number, n: number, x0: number, x1: number, y0: number, y1: number): number[][] => { + const rnd = mulberry(seed) + const out: number[][] = [] + for (let i = 0; i < n; i++) { + const big = rnd() < 0.12 + out.push([ + x0 + rnd() * (x1 - x0), + y0 + rnd() * (y1 - y0), + big ? 0.9 + rnd() * 0.8 : 0.3 + rnd() * 0.5, + big || rnd() < 0.1 ? 1 : 0, + Math.round(rnd() * 40) / 10, + ]) + } + return out +} +// #endregion + +export type Side = "front" | "back" + +export type EventTicketProps = { + /** Headline on both faces. */ + title?: string + /** Class of pass: the front's corner and the back's header. */ + tier?: string + /** The front's date. */ + date?: string + /** Bottom-left text on the front. "\n" breaks a line. */ + welcome?: string + /** Fine print beside the stub. "\n" breaks a line. */ + eyebrow?: string + /** The two words floating in the art. */ + words?: [string, string] + /** Two lines of fine print along the front's foot. */ + notes?: [string, string] + passenger?: string + from?: string + to?: string + craft?: string + dateCode?: string + gate?: string + group?: string + seat?: string + boardingTime?: string + /** Landing-site name, printed on both faces and marked on the Moon. */ + site?: string + /** Landing-site selenographic degrees. */ + lat?: number + lon?: number + /** Hide the reference artwork's lunar coordinates for terrestrial events. */ + showCoordinates?: boolean + /** Use actual participant/registration fields instead of the reference's flight fields. */ + registrationPass?: boolean + animateInfo?: boolean + seq?: string + featuring?: string + /** The big thin line on the back. */ + headline?: string + /** What the stamp says when the stub comes off. */ + stamp?: string + /** Labels for the switch under the ticket. */ + sideLabels?: [string, string] + /** QR slot on the back. Any node: , . Stays on the main piece after the stub is torn. */ + qr?: React.ReactNode + /** Caption under the QR slot. */ + qrLabel?: string + night?: string + starInk?: string + paper?: string + paperInk?: string + stampColor?: string + fontCondensed?: string + fontMono?: string + fontDisplay?: string + defaultSide?: Side + onSideChange?: (side: Side) => void + defaultTorn?: boolean + onTear?: (torn: boolean) => void + /** Largest tilt in degrees. 0 holds it flat. */ + tilt?: number + /** The height follows the 1000 : 330 ticket. */ + maxWidth?: number | string + className?: string +} + +const VIEW_W = 1000 +const VIEW_H = 330 +const MOON = { x: 53 / 226, y: 201 / 330, r: 128 / 226 } + +const CONDENSED = + '"Oswald", "Bebas Neue", "Avenir Next Condensed", "Helvetica Neue", "Arial Narrow", "Roboto Condensed", "Liberation Sans Narrow", "DejaVu Sans Condensed", sans-serif' +const MONO = '"Courier Prime", "IBM Plex Mono", "Courier New", ui-monospace, monospace' +const DISPLAY = '"Futura", "Century Gothic", "Avenir Next", "Josefin Sans", "Quicksand", ui-sans-serif, sans-serif' + +const pts = (a: number[][]): string => a.map((p) => p[0] + "," + p[1]).join(" ") + +const STARS = starField(1969, 120, 240, 990, 10, 320) + +const CSS = [ + ".lbp{position:relative;width:100%;margin:0 auto}", + ".lbp-stage{position:relative;width:100%;perspective:1800px;touch-action:pan-y}", + ".lbp-tilt,.lbp-flip{position:absolute;inset:0;transform-style:preserve-3d}", + ".lbp-tilt{transform:rotateX(var(--rx,0deg)) rotateY(var(--ry,0deg));transition:transform .6s cubic-bezier(.2,.8,.2,1)}", + ".lbp-flip{cursor:pointer;transition:transform 1.05s cubic-bezier(.65,-0.2,.25,1.2)}", + ".lbp-face{position:absolute;inset:0;backface-visibility:hidden;-webkit-backface-visibility:hidden;", + "filter:drop-shadow(0 1.4cqw 1.8cqw rgba(0,0,0,.2)) drop-shadow(0 .25cqw .35cqw rgba(0,0,0,.14))}", + ".lbp-back{transform:rotateY(180deg)}", + ".lbp-night{position:absolute;inset:0;overflow:hidden;border-radius:.35cqw;background:var(--lbp-night);color:var(--lbp-star);", + "box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--lbp-star) 9%,transparent)}", + ".lbp-layer{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}", + ".lbp-grain{opacity:.16;mix-blend-mode:screen}", + ".lbp-piece .lbp-grain{opacity:.3;mix-blend-mode:multiply}", + ".lbp-glow{background:radial-gradient(circle at var(--mx,50%) var(--my,40%),color-mix(in srgb,var(--lbp-star) 12%,transparent),transparent 42%)}", + ".lbp-piece{position:absolute;top:0;bottom:0;overflow:hidden;color:var(--lbp-ink);", + "background:radial-gradient(ellipse at 16% 12%,rgba(255,255,255,.52),transparent 46%),repeating-linear-gradient(0deg,rgba(50,42,28,.018) 0,rgba(50,42,28,.018) 1px,transparent 1px,transparent 4px),linear-gradient(162deg,color-mix(in srgb,var(--lbp-paper) 82%,#fff) 0%,var(--lbp-paper) 48%,color-mix(in srgb,var(--lbp-paper) 88%,#000) 100%)}", + ".lbp-main{left:0;width:77.4%;border-radius:.35cqw 0 0 .35cqw;padding:2.3cqw 2.6cqw 2.1cqw;display:flex;flex-direction:column;", + "-webkit-mask:radial-gradient(circle at 100% 50%,#0000 .2cqw,#000 .24cqw) 0 0/100% .86cqw repeat-y;", + "mask:radial-gradient(circle at 100% 50%,#0000 .2cqw,#000 .24cqw) 0 0/100% .86cqw repeat-y}", + ".lbp-stub{left:77.4%;width:22.6%;border-radius:0 .35cqw .35cqw 0;padding:2.3cqw 1.9cqw 2.1cqw;display:flex;flex-direction:column;", + "font:inherit;text-align:left;border:0;margin:0;cursor:grab;touch-action:pan-y;transform-origin:0 100%;", + "transition:transform .75s cubic-bezier(.2,.9,.25,1.25);", + "-webkit-mask:radial-gradient(circle at 0 50%,#0000 .2cqw,#000 .24cqw) 0 0/100% .86cqw repeat-y;", + "mask:radial-gradient(circle at 0 50%,#0000 .2cqw,#000 .24cqw) 0 0/100% .86cqw repeat-y}", + ".lbp-stub:active{cursor:grabbing}", + ".lbp-stub:focus-visible{outline:2px solid var(--lbp-stamp);outline-offset:-4px}", + ".lbp-stub.is-torn{transform:translate(2.4cqw,1.1cqw) rotate(4deg);cursor:pointer}", + ".lbp-foil{background:linear-gradient(115deg,transparent calc(var(--mx,50%) - 24%),rgba(255,255,255,.62) var(--mx,50%),transparent calc(var(--mx,50%) + 24%)) no-repeat;mix-blend-mode:soft-light}", + ".lbp-main .lbp-foil{background-size:129.2% 100%;background-position:0 0}", + ".lbp-stub .lbp-foil{background-size:442.5% 100%;background-position:100% 0}", + ".lbp-c{font-family:var(--lbp-cond,var(--font-sans,sans-serif));font-weight:700;line-height:1;letter-spacing:.02em}", + ".lbp-m{font-family:var(--lbp-mono,monospace);text-transform:uppercase;line-height:1.05;letter-spacing:.03em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}", + ".lbp-d{font-family:var(--lbp-disp,var(--font-display,serif));font-weight:400;text-transform:uppercase;letter-spacing:.12em;line-height:1;white-space:nowrap}", + ".lbp-title{font-family:var(--lbp-disp,var(--font-display,serif));font-weight:500;letter-spacing:.02em;line-height:1.06;white-space:normal;overflow-wrap:anywhere}", + ".lbp-l{display:block;font-family:var(--lbp-cond,var(--font-sans,sans-serif));font-weight:700;text-transform:uppercase;font-size:.6cqw;letter-spacing:.08em;line-height:1;opacity:.82;margin-bottom:.45cqw;white-space:nowrap}", + ".lbp-at{position:absolute;white-space:nowrap}", + ".lbp-pre{white-space:pre-line}", + ".lbp-head{display:flex;align-items:baseline;justify-content:space-between;gap:1.5cqw;padding-bottom:1cqw;border-bottom:1px solid var(--lbp-rule)}", + ".lbp-grid{display:grid;grid-template-columns:1.08fr 1fr .78fr;border-bottom:1px solid var(--lbp-rule)}", + ".lbp-cell{padding:1cqw 1.2cqw 1cqw 0;min-width:0}", + ".lbp-cell+.lbp-cell,.lbp-grid>:nth-child(5),.lbp-grid>:nth-child(6){border-left:1px solid var(--lbp-rule);padding-left:1.2cqw}", + ".lbp-grid>:nth-child(4){border-left:0;padding-left:0}", + ".lbp-grid>:nth-child(5),.lbp-grid>:nth-child(6){border-top:1px solid var(--lbp-rule)}", + ".lbp-row{display:flex;gap:2.4cqw}", + ".lbp-foot{flex:1;display:flex;align-items:center;gap:2cqw;padding-top:1.1cqw}", + ".lbp-qr{position:relative;z-index:2;flex:0 0 auto;display:flex;flex-direction:column;align-items:center;cursor:default}", + ".lbp-qrbox{box-sizing:border-box;width:10.5cqw;height:10.5cqw;padding:.55cqw;background:#fff;color:#000;border:1px solid var(--lbp-rule);border-radius:.3cqw;display:flex;align-items:center;justify-content:center}", + ".lbp-qrbox>svg,.lbp-qrbox>img,.lbp-qrbox>canvas{display:block;width:100%;height:100%;max-width:none}", + ".lbp-qrbox.is-empty{background:transparent;border:1px dashed var(--lbp-rule);font-family:var(--lbp-cond);font-size:1.4cqw;letter-spacing:.2em;opacity:.7}", + ".lbp-sf{margin-top:1.45cqw;min-width:0}", + ".lbp-person{margin:0;font-family:var(--lbp-cond);font-weight:700;font-size:3cqw;line-height:1.15;letter-spacing:-.045em;overflow-wrap:anywhere}", + ".lbp-registration .lbp-main::after{content:'';position:absolute;right:13%;bottom:-8cqw;width:18cqw;height:18cqw;border:1px solid #78634318;border-radius:50%;box-shadow:0 0 0 1.2cqw #78634306;pointer-events:none}", + ".lbp-registration-grid .lbp-traveler{grid-column:span 2}.lbp-registration-grid .lbp-person{font-size:2.9cqw;animation:none!important}", + ".lbp-registration-grid .lbp-m{font-family:var(--lbp-cond);line-height:1.4;letter-spacing:0;text-transform:none;font-weight:600}.lbp-registration-grid .lbp-origin{font-size:1.4cqw;font-weight:500}", + ".lbp-registration-grid>.lbp-cell:nth-child(2) .lbp-m{font-family:var(--lbp-mono);font-weight:500}", + ".lbp-registration .lbp-stub .lbp-m{font-family:var(--lbp-cond);text-transform:none;letter-spacing:0;font-weight:600;line-height:1.3}", + ".lbp-registration-grid>:nth-child(4){border-left:1px solid var(--lbp-rule);padding-left:1.2cqw}", + ".lbp-registration-grid>:nth-child(3){border-left:0;padding-left:0}", + ".lbp-registration-grid>:nth-child(n+3){border-top:1px solid var(--lbp-rule)}", + ".lbp-registration .lbp-m{white-space:normal;overflow:visible;overflow-wrap:anywhere}.lbp-registration .lbp-tw{animation:none}", + ".lbp-registration .lbp-l{font-family:var(--lbp-mono);font-size:max(.76cqw,8px);line-height:1.2;letter-spacing:.1em;opacity:.65}", + ".lbp-registration .lbp-title,.lbp-registration .lbp-d{font-family:var(--lbp-cond);font-weight:650;text-transform:none;letter-spacing:-.025em;line-height:1.15}", + ".lbp-registration .lbp-night .lbp-at.lbp-d{max-width:38%;white-space:normal}.lbp-registration .lbp-night .lbp-title{white-space:nowrap;font-weight:700;letter-spacing:.04em;font-size:2.35cqw!important}", + ".lbp-registration .lbp-head{align-items:center;padding-bottom:1.1cqw}.lbp-back-brand strong{display:block;font-family:var(--lbp-cond);font-size:2.25cqw;letter-spacing:.04em;line-height:1.1;font-weight:800}.lbp-back-brand small{display:block;font-family:var(--lbp-mono);font-size:.65cqw;letter-spacing:.12em;margin-top:.5cqw;opacity:.65}", + ".lbp-date{text-align:right}.lbp-date strong{font:600 1.4cqw/1.2 var(--lbp-cond)}", + ".lbp-registration .lbp-foot .lbp-d{font-size:2.1cqw!important}.lbp-registration .lbp-foot>.lbp-qr{margin-left:auto}", + ".lbp-registration.lbp-info-static .lbp-v{animation:none!important}", + ".lbp-stamp{position:absolute;left:33%;top:52%;padding:.35cqw;border:.2cqw solid var(--lbp-stamp);border-radius:.45cqw;color:var(--lbp-stamp);", + "opacity:0;transform:rotate(-9deg);pointer-events:none;mix-blend-mode:multiply}", + ".lbp-stamp>span{display:block;padding:.45cqw 1.1cqw;border:.08cqw solid var(--lbp-stamp);border-radius:.25cqw;text-align:center}", + ".lbp-main.is-torn .lbp-stamp{opacity:.86;animation:lbp-stamp .5s cubic-bezier(.3,1.6,.5,1) both}", + "@keyframes lbp-stamp{0%{opacity:0;transform:rotate(-4deg) scale(1.9)}100%{opacity:.86;transform:rotate(-9deg) scale(1)}}", + ".lbp-hint{position:absolute;left:77.4%;bottom:1.6cqw;transform:translateX(-50%) rotate(180deg);writing-mode:vertical-rl;font-family:var(--lbp-cond);", + "font-size:.5cqw;letter-spacing:.3em;text-transform:uppercase;color:var(--lbp-ink);opacity:.45;pointer-events:none;white-space:nowrap;transition:opacity .3s}", + ".lbp-back.is-torn .lbp-hint{opacity:0}", + ".lbp-printed .lbp-v{animation:lbp-type .8s steps(14,end) both}", + "@keyframes lbp-type{from{clip-path:inset(0 100% 0 0)}to{clip-path:inset(0 0 0 0)}}", + ".lbp-registration .lbp-printed .lbp-v{animation:lbp-ink-write 1.7s steps(24,end) both;animation-delay:.55s!important}", + "@keyframes lbp-ink-write{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}}", + ".lbp-tw{animation:lbp-tw 3.4s ease-in-out infinite}", + "@keyframes lbp-tw{0%,100%{opacity:1}50%{opacity:.2}}", + ".lbp-ship{transition:transform .9s cubic-bezier(.2,.8,.2,1)}", + ".lbp-rocket:hover .lbp-ship{transform:translateX(14px)}", + ".lbp-flame{opacity:0;transform-box:fill-box;transform-origin:100% 50%;transform:scaleX(.2);transition:opacity .25s,transform .35s}", + ".lbp-rocket:hover .lbp-flame{opacity:1;transform:scaleX(1);animation:lbp-flick .09s steps(2) infinite alternate}", + "@keyframes lbp-flick{to{transform:scaleX(1.18) scaleY(1.06)}}", + ".lbp-note{opacity:0}", + ".lbp-clarinet:hover .lbp-note{animation:lbp-note 2.2s ease-out infinite}", + "@keyframes lbp-note{0%{opacity:0;transform:translate(0,0) scale(.5)}18%{opacity:1}100%{opacity:0;transform:translate(var(--tx),var(--ty)) rotate(-16deg) scale(1.1)}}", + ".lbp-body{transform-box:fill-box;transform-origin:85% 50%;transition:transform .5s}", + ".lbp-clarinet:hover .lbp-body{transform:rotate(-1.2deg)}", + "@media (prefers-reduced-motion: reduce){.lbp-tilt,.lbp-flip,.lbp-stub,.lbp-ship,.lbp-body{transition-duration:.01ms}", + ".lbp-tw,.lbp-flame,.lbp-v,.lbp-stamp,.lbp-note,.lbp-person{animation:none!important}.lbp-rocket:hover .lbp-flame{transform:none}}", +].join("") + +function Star({ size }: { size: string }) { + const p: number[][] = [] + for (let i = 0; i < 16; i++) { + const a = (i * Math.PI) / 8 - Math.PI / 2 + const r = i % 2 ? 0.34 : 1 + p.push([Math.round((12 + Math.cos(a) * r * 11) * 100) / 100, Math.round((12 + Math.sin(a) * r * 11) * 100) / 100]) + } + return ( + + ) +} + +/** A Saturn V, nose to the right, engraved in white. */ +function Rocket({ ink, night, font }: { ink: string; night: string; font: string }) { + const y = 214 + const R1 = 21 + const R3 = 14 + const R5 = 8.2 + const seam = (x: number, h: number) => + const truss: number[][] = [] + for (let x = 907, k = 0; x <= 934; x += 4.5, k++) truss.push([x, y + (k % 2 ? 2.4 : -2.4)]) + return ( + + + + + + + + {[-12, 12, 0].map((o) => ( + + ))} + + + {/* S-IC, interstage, S-II */} + + + + + + + PSLV + + + {[448, 500, 548, 564, 594, 605, 617, 642, 667, 692].map((x) => seam(x, R1))} + {/* interstage taper, S-IVB, instrument unit */} + + + + + {[734, 768, 784, 821, 825].map((x) => seam(x, x < 746 ? 17.5 : R3))} + {/* adapter, service module, command module */} + + + + + {[861, 876, 892].map((x) => seam(x, R5))} + + {/* launch escape tower */} + + + + + + + + + + ) +} + +/** A B♭ clarinet, bell to the left. Point at it and it plays. */ +function Clarinet({ ink, night }: { ink: string; night: string }) { + const y = 96 + const holes = [392, 418, 446, 474, 502, 530, 610, 640, 668, 696, 724] + const rings = [446, 474, 502, 640, 668, 696] + const pads = [404, 460, 488, 520, 620, 654, 702, 738] + const notes = [ + { x: 300, d: 0, tx: "-34px", ty: "-52px" }, + { x: 310, d: 0.75, tx: "-12px", ty: "-64px" }, + { x: 296, d: 1.5, tx: "-48px", ty: "-30px" }, + ] + return ( + + + {notes.map((n, i) => ( + + + + + {i === 1 && } + + + ))} + + + + + + + + + + + + + + + + {/* turned shading along the underside */} + + {/* keywork */} + + {holes.map((x) => ( + + ))} + {rings.map((x) => ( + + ))} + {pads.map((x) => ( + + ))} + + + + + ) +} + +function Grain({ id }: { id: string }) { + return ( + + ) +} + +function Field({ label, value, size, d }: { label: string; value: string; size: number; d?: number }) { + return ( + + {label} + + {value} + + + ) +} + +export default function EventTicket({ + title = "INNOVISION 2026", + tier = "VIP", + date = "13 Nov '26", + welcome = "Welcome\naboard", + eyebrow = "The 1st station of\nthe space tour", + words = ["Space", "Jazz"], + notes = ["太空爵士・宇宙巡演", "首站—静海基地"], + passenger = "Neil Armstrong", + from = "Earth", + to = "Moon", + craft = "IV26-9042", + dateCode = "13NOV26", + gate = "F3", + group = "C", + seat = "21J", + boardingTime = "UTC2000", + site = "Tranquility Base", + lat = 0.67416, + lon = 23.47314, + showCoordinates = true, + registrationPass = false, + animateInfo = true, + seq = "SEQ 060 MFDBAN 974294U89", + featuring = "Featuring all stars crew", + headline = "Space Jazz", + stamp = "Boarded", + sideLabels = ["VIP pass", "Boarding pass"], + qr, + qrLabel = "Scan at entry", + night = "#0b0b0c", + starInk = "#f1f0eb", + paper = "#c9c9c6", + paperInk = "#1b1b1b", + stampColor = "#a3362a", + fontCondensed = CONDENSED, + fontMono = MONO, + fontDisplay = DISPLAY, + defaultSide = "front", + onSideChange, + defaultTorn = false, + onTear, + tilt = 6, + maxWidth = 960, + className = "", +}: EventTicketProps) { + const uid = React.useId().replace(/[^a-zA-Z0-9]/g, "") + const [turns, setTurns] = React.useState(defaultSide === "back" ? 1 : 0) + const side: Side = turns % 2 ? "back" : "front" + const [printed, setPrinted] = React.useState(defaultSide === "back") + const [torn, setTorn] = React.useState(defaultTorn) + const [reduced, setReduced] = React.useState(false) + + const stageRef = React.useRef(null) + const tiltRef = React.useRef(null) + const moonRef = React.useRef(null) + const stubRef = React.useRef(null) + const pointer = React.useRef(null) + const sideRef = React.useRef(side) + const redraw = React.useRef<() => void>(() => {}) + const drag = React.useRef<{ x: number; t: number; dx: number; v: number; moved: boolean } | null>(null) + const swallowClick = React.useRef(false) + const cbs = React.useRef({ onSideChange, onTear }) + React.useEffect(() => { + sideRef.current = side + cbs.current = { onSideChange, onTear } + }, [side, onSideChange, onTear]) + + React.useEffect(() => { + const mq = window.matchMedia("(prefers-reduced-motion: reduce)") + const onMq = () => setReduced(mq.matches) + onMq() + mq.addEventListener("change", onMq) + return () => mq.removeEventListener("change", onMq) + }, []) + + const firstSide = React.useRef(true) + React.useEffect(() => { + if (firstSide.current) { + firstSide.current = false + return + } + cbs.current.onSideChange?.(side) + }, [side]) + + const firstTear = React.useRef(true) + React.useEffect(() => { + if (firstTear.current) { + firstTear.current = false + return + } + cbs.current.onTear?.(torn) + }, [torn]) + + // The Moon: a halftone redrawn from a cached lattice whenever the light moves. + React.useEffect(() => { + const canvas = moonRef.current + const ctx = canvas && canvas.getContext("2d") + if (!canvas || !ctx) return + let dots: number[] = [] + let cell = 1 + let W = 0 + let H = 0 + let cx = 0 + let cy = 0 + let R = 0 + let dpr = 1 + const turn = turnFor(lon) + const here = project(lat, lon + turn) + let light = lightFrom(idleAt(0), 0.38) + let raf = 0 + let visible = true + + const draw = (pulse: number) => { + if (!W) return + ctx.clearRect(0, 0, W, H) + ctx.fillStyle = starInk + ctx.beginPath() + for (let i = 0; i < dots.length; i += 6) { + const r = dotRadius(dots[i + 5], dots[i + 2], dots[i + 3], dots[i + 4], light, cell) + if (r < 0.3) continue + ctx.moveTo(dots[i] + r, dots[i + 1]) + ctx.arc(dots[i], dots[i + 1], r, 0, Math.PI * 2) + } + ctx.fill() + if (showCoordinates && here[2] > 0.05) { + const mx = cx + R * here[0] + const my = cy + R * here[1] + const s = R * 0.034 + ctx.lineWidth = 3.2 * dpr + ctx.strokeStyle = night + ctx.beginPath() + ctx.arc(mx, my, s, 0, Math.PI * 2) + ctx.stroke() + ctx.lineWidth = 1.2 * dpr + ctx.strokeStyle = starInk + ctx.stroke() + ctx.fillStyle = starInk + ctx.beginPath() + ctx.arc(mx, my, 1.2 * dpr, 0, Math.PI * 2) + ctx.fill() + if (pulse > 0) { + ctx.globalAlpha = 1 - pulse + ctx.beginPath() + ctx.arc(mx, my, s + R * 0.1 * pulse, 0, Math.PI * 2) + ctx.stroke() + ctx.globalAlpha = 1 + } + } + } + + const build = () => { + dpr = Math.min(2, window.devicePixelRatio || 1) + W = Math.round(canvas.clientWidth * dpr) + H = Math.round(canvas.clientHeight * dpr) + if (!W || !H) return + canvas.width = W + canvas.height = H + R = W * MOON.r + cx = W * MOON.x + cy = H * MOON.y + cell = Math.max(2.1 * dpr, R / 36) + dots = moonDots(cx, cy, R, cell, turn) + draw(0) + } + + const target = (t: number) => { + const p = pointer.current + return p ? lightFrom(p[0], p[1]) : lightFrom(idleAt(t), 0.38) + } + + const tick = (now: number) => { + raf = requestAnimationFrame(tick) + if (sideRef.current !== "front") return + const to = target(now / 1000) + for (let k = 0; k < 3; k++) light[k] += (to[k] - light[k]) * 0.09 + const n = Math.hypot(light[0], light[1], light[2]) || 1 + light = [light[0] / n, light[1] / n, light[2] / n] + draw(((now / 1000) % 2.4) / 2.4) + } + const start = () => { + if (!raf && visible && !reduced && !registrationPass) raf = requestAnimationFrame(tick) + } + const stop = () => { + cancelAnimationFrame(raf) + raf = 0 + } + + redraw.current = () => { + if (raf) return + light = target(0) + draw(0) + } + + build() + const observer = new ResizeObserver(build) + observer.observe(canvas) + const io = new IntersectionObserver(([e]) => { + visible = e.isIntersecting + if (visible) start() + else stop() + }) + io.observe(canvas) + start() + return () => { + stop() + observer.disconnect() + io.disconnect() + redraw.current = () => {} + } + }, [lat, lon, night, starInk, reduced, registrationPass, showCoordinates]) + + const flip = () => { + setPrinted(true) + setTurns((t) => t + 1) + } + const show = (s: Side) => { + if (s !== side) flip() + } + + const onMove = (e: React.PointerEvent) => { + const el = stageRef.current + const t = tiltRef.current + if (!el || !t) return + const r = el.getBoundingClientRect() + const px = clamp01((e.clientX - r.left) / r.width) + const py = clamp01((e.clientY - r.top) / r.height) + t.style.setProperty("--mx", (px * 100).toFixed(1) + "%") + t.style.setProperty("--my", (py * 100).toFixed(1) + "%") + if (tilt && !reduced && e.pointerType === "mouse") { + const a = tiltFrom(px, py, tilt) + t.style.setProperty("--rx", a[0].toFixed(2) + "deg") + t.style.setProperty("--ry", a[1].toFixed(2) + "deg") + } + pointer.current = [px, py] + redraw.current() + } + const onLeave = () => { + const t = tiltRef.current + if (t) { + t.style.setProperty("--rx", "0deg") + t.style.setProperty("--ry", "0deg") + } + pointer.current = null + redraw.current() + } + + const stubDown = (e: React.PointerEvent) => { + if (torn || e.button !== 0) return + drag.current = { x: e.clientX, t: performance.now(), dx: 0, v: 0, moved: false } + e.currentTarget.setPointerCapture(e.pointerId) + } + const stubMove = (e: React.PointerEvent) => { + const d = drag.current + const el = stubRef.current + if (!d || !el) return + const now = performance.now() + const dx = Math.max(0, e.clientX - d.x) + d.v = (dx - d.dx) / Math.max(1, now - d.t) + d.t = now + d.dx = dx + if (dx > 4) d.moved = true + const w = el.offsetWidth + const shown = resist(dx, w) + el.style.transition = "none" + el.style.transform = "translateX(" + shown.toFixed(1) + "px) rotate(" + ((shown / Math.max(1, w)) * 7).toFixed(2) + "deg)" + } + const stubUp = () => { + const d = drag.current + const el = stubRef.current + drag.current = null + if (!d || !el) return + el.style.transition = "" + el.style.transform = "" + if (d.moved) { + swallowClick.current = true + if (tearDecision(d.dx, d.v, el.offsetWidth)) setTorn(true) + } + } + const stubClick = (e: React.MouseEvent) => { + e.stopPropagation() + if (swallowClick.current) { + swallowClick.current = false + return + } + setTorn((v) => !v) + } + + const dms = coordsDMS(lat, lon) + const dec = coordsDecimal(lat, lon) + const front = side === "front" + const vars = { + "--lbp-night": night, + "--lbp-star": starInk, + "--lbp-paper": paper, + "--lbp-ink": paperInk, + "--lbp-rule": "color-mix(in srgb, " + paperInk + " 55%, transparent)", + "--lbp-stamp": stampColor, + "--lbp-cond": fontCondensed, + "--lbp-mono": fontMono, + "--lbp-disp": fontDisplay, + } as React.CSSProperties + const at = (x: number, y: number): React.CSSProperties => ({ left: x / 10 + "%", top: (y / VIEW_H) * 100 + "%" }) + + return ( +
+ +
+
+
+ {/* ---------------- front ---------------- */} +
+
+
+
+ + +
+ +
8 ? "1.85cqw" : "2.6cqw", maxWidth: "19cqw", whiteSpace: "normal", lineHeight: 1.1, display: "flex", alignItems: "center", gap: ".55cqw" }}> + + {tier} +
+
+ {welcome} +
+
+ {eyebrow} +
+
+ {title} +
+
+ {date} +
+
+ {words[0]} +
+
+ {words[1]} +
+
+ {notes[0]} +
+
+ {notes[1]} +
+
+ {site} +
+
+ {showCoordinates ? `[${dms}]` : "PAYMENT VERIFIED"} +
+
+
+ + {/* ---------------- back: boarding pass + QR ---------------- */} +
+
+ +
+
+ {registrationPass ? <> +
{title}PERSONAL EVENT PASS
+
Event date{date}
+ : <> + {registrationPass ? "INNO PASS" : "Boarding pass"} + {title} + {tier} + } +
+ {registrationPass ?
+
Issued to

+
+
From · College
{from}
+
+
+
:
+
+ +
+
+ +
+
+ +
+
+ + +
+
+ + + +
+
+ {boardingTime && } +
+
} +
+
+ {registrationPass ? "Destination" : "Landing site"} +
{site}
+ {showCoordinates &&
{dec}
} + {seq} +
+
+
{featuring}
+
{headline}
+
+ {/* QR slot: stays on the main piece, clicks don't flip the ticket */} +
e.stopPropagation()}> +
{qr || seat}
+ {qrLabel} +
+
+ +
+ + +
+
+
+
+ +
+ {(["front", "back"] as Side[]).map((s, i) => ( + + ))} +
+

+ {registrationPass ? `Innovision pass for ${passenger}. College: ${from}. Venue: ${to}. Registration ID: ${seat}. Payment verified.${torn ? " Stub detached." : ""}` : front + ? tier + " pass. " + title + ", " + date + ". " + site + ", " + dms + "." + : "Boarding pass for " + passenger + ", " + from + " to " + to + ". Gate " + gate + ", group " + group + ", seat " + seat + ", " + boardingTime + "." + (torn ? " Stub torn off: " + stamp + "." : "")} +

+
+ ) +} diff --git a/docs/pass-experience.md b/docs/pass-experience.md new file mode 100644 index 0000000..9b0e8fd --- /dev/null +++ b/docs/pass-experience.md @@ -0,0 +1,39 @@ +# Registration, payment approval and INNO pass + +The live flow is Google login → participant details → payment QR → screenshot and 12-digit UTR → pending team review → approved pass. College and roll number are entered by the participant. College is never inferred from their email. Financial approval always comes from the authenticated backend. + +## Reviewer preview and the single off switch + +`REGISTRATION_PREVIEW_ENABLED` in `src/config/launch.ts` is currently **false**: every preview entry point, mock user, local upload and simulated approval is off. Set it to **true** and redeploy only to bring the reviewer preview back for a review build. There is no UI toggle. This is independent of `REGISTRATIONS_OPEN`, which controls whether live registration is open. + +- `/?preview=register` starts all steps, with a blank college field. +- Preview participants select a local screenshot and enter a 12-digit test UTR. No payment, registration, upload or acknowledgement is sent to the server. +- Submission shows pending review. **Simulate team approval** demonstrates the final pass using the details entered in the form. +- `/?preview=pass` opens an explicitly marked sample pass directly for design review. It contains no working entry QR. +- A real API failure never falls back to a mock approved participant, even while preview is enabled. +- All server authorization remains enforced while the preview switch is on. Turning it off requires no replacement backend or routing change. + +## Deployment prerequisites + +Apply `supabase/manual/pass_experience.sql` to the existing Supabase database. New installations can use the updated `supabase/schema.sql`. The idempotent migration creates an owner-authorized acknowledgement table and functions; it does not update financial status. Hosted database migration has not been performed by this code change. + +The existing entry-pass tables/functions and a valid `ENTRY_PASS_ENCRYPTION_KEY` are required for QR check-in. The pass itself and PDF remain available without a QR. The scanner continues to use the existing staff-authorized opaque-token lookup and atomic check-in functions, including duplicate-check-in protection. No default signing secret or simulated server approval is used. + +## Pass behavior + +- `/api/pass` intentionally uses POST: first access can mint an owner's opaque QR token. It returns real name, college, registration ID and backend status. Tokens are only available for confirmed registrations with active passes. +- Visible registration/pass screens refresh on focus, visibility and a 15-second interval. Hidden tabs avoid polling. Preview records do not poll the live registration API. +- Database claims elect one tab to show the celebration. A 30-second lease recovers abandoned claims. In-flight claims are shared across React remounts. +- Acknowledgement happens after the pass has painted, or an explicit skip. Navigation cleanup acknowledges only a presented pass. Failed acknowledgements retain the claim and retry on focus/connection recovery. No local-storage value is the source of truth. +- Subsequent acknowledged visits show the static pass. Failure of the celebration service preserves access to the approved pass. +- Phones receive a separate vertical card. Desktop retains the illustrated two-sided ticket. Long details use the flexible card when necessary. The real college is distinct from the NIT Rourkela venue. +- Ticket details write in once; the participant's name briefly decrypts into its exact original text. The screen-reader name is always unchanged. Reduced motion disables both effects, and acknowledged revisits are static. The registration ticket has no idle canvas rendering loop. +- Desktop opens on the black artwork. The first switch to **Your pass** triggers its text reveal; it does not loop on later flips. Phones retain the vertical card. Registration accents use softened champagne-gold derived from the existing palette. Manrope carries the pass name and body details; monospace is reserved for labels and identifiers. The event date is 13 November 2026, with a clearly separated college-to-NIT route and venue-city coordinates. +- **Save PDF** directly downloads a personalized PDF. PDF/QR export libraries are loaded only on click. Text is drawn with browser brand fonts before embedding, preserving Unicode names. The QR is included only when the backend supplies a token. The existing PNG export helper is retained for older callers. +- Keyboard controls, visible focus and QR-dialog focus restoration remain available. Font license notices are retained. + +## Verification + +`npm run typecheck`, focused ESLint checks, `npm test`, and `npm run build` verify types, compilation, registration validation, the POST transport contract, and real PostgreSQL approval/acknowledgement ownership, contention and lease recovery. + +The optional `npm run test:pass` browser harness is separate from production and mocks only transport. Browser checks are not run when reviewing code without Chrome. These checks do not constitute a 1,000-user load test; production capacity depends on the deployed application and Supabase configuration. diff --git a/package-lock.json b/package-lock.json index b151427..6eb0ede 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,17 +8,24 @@ "name": "project-spacex", "version": "0.1.0", "dependencies": { + "@radix-ui/react-dialog": "^1.3.1", "@supabase/supabase-js": "^2.117.2", "@vercel/analytics": "^2.0.1", + "@yudiel/react-qr-scanner": "^2.6.0", "@zxing/browser": "^0.2.1", "@zxing/library": "^0.23.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "gsap": "^3.15.0", "imagekit": "^6.0.0", "jspdf": "^4.2.1", + "lucide-react": "^1.61.0", "next": "16.3.8", "qrcode": "^1.5.4", + "qrcode.react": "^4.2.0", "react": "19.2.8", - "react-dom": "19.2.8" + "react-dom": "19.2.8", + "tailwind-merge": "^3.7.0" }, "devDependencies": { "@electric-sql/pglite": "^0.5.8", @@ -27,13 +34,29 @@ "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", + "autoprefixer": "^10.6.1", "eslint": "^9", "eslint-config-next": "16.3.8", "husky": "^9.1.7", "lint-staged": "^16.4.0", + "postcss": "^8.5.31", + "tailwindcss": "^3.4.19", "typescript": "^5" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.3.0.tgz", + "integrity": "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1347,6 +1370,323 @@ "node": ">=20" } }, + "node_modules/@radix-ui/primitive": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.2.0.tgz", + "integrity": "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.3.0.tgz", + "integrity": "sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.3.1.tgz", + "integrity": "sha512-+0E4qvVTwjAWra9sEhJ+9BTsvHE00zkUuNw1VGh7S+Gc13dfYk+S1PVQdchWDj+XYHhAai7Z7IpzXVDCGsDplg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-dismissable-layer": "1.2.1", + "@radix-ui/react-focus-guards": "1.2.0", + "@radix-ui/react-focus-scope": "1.3.1", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.2.0", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-slot": "1.5.0", + "@radix-ui/react-use-controllable-state": "1.3.0", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.2.1.tgz", + "integrity": "sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-callback-ref": "1.1.5", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.2.0.tgz", + "integrity": "sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.3.1.tgz", + "integrity": "sha512-ugeMhbXt4y5S2T1GnIqbWDKEN962PDwnX1fN0eKOOgf5f63Dh2mn8F1FUySlgW0iF6GiVsRF32DzuDIWTUyQJg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-callback-ref": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.2.0.tgz", + "integrity": "sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.11.tgz", + "integrity": "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz", + "integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.5.0.tgz", + "integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.5.tgz", + "integrity": "sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.3.0.tgz", + "integrity": "sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1466,6 +1806,12 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/emscripten": { + "version": "1.41.6", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.6.tgz", + "integrity": "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1524,7 +1870,7 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1534,7 +1880,7 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.3.0" @@ -2208,6 +2554,23 @@ } } }, + "node_modules/@yudiel/react-qr-scanner": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@yudiel/react-qr-scanner/-/react-qr-scanner-2.6.0.tgz", + "integrity": "sha512-WjNcSt7qquYUz1LVbwKzSnRQI47sNrh4Vh6nNiGKVxnYcP+KgN5q9rcSzXyZ/7GRV9PPrnzoIuE5YdOxB9yAog==", + "license": "MIT", + "dependencies": { + "barcode-detector": "^3.1.3", + "webrtc-adapter": "^9.0.5" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19" + } + }, "node_modules/@zxing/browser": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.2.1.tgz", @@ -2338,6 +2701,34 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2345,6 +2736,18 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", @@ -2538,6 +2941,43 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/autoprefixer": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.6.1.tgz", + "integrity": "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.9", + "caniuse-lite": "^1.0.30001810", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -2593,6 +3033,15 @@ "dev": true, "license": "MIT" }, + "node_modules/barcode-detector": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.2.3.tgz", + "integrity": "sha512-PLinpWGiOQoQH4X+YObWZ6iXeffXKDM3d4vnOD86481SvukIfvkf7jEE/tud9oI4HTDxtDdlHaFilA5Kn7Xa7A==", + "license": "MIT", + "dependencies": { + "zxing-wasm": "3.1.6" + } + }, "node_modules/base64-arraybuffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", @@ -2615,15 +3064,28 @@ "node": ">=6.0.0" } }, - "node_modules/brace-expansion": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, "node_modules/braces": { @@ -2741,6 +3203,16 @@ "node": ">=6" } }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001814", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", @@ -2798,6 +3270,56 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -2912,6 +3434,15 @@ "node": ">=8" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3013,11 +3544,24 @@ "utrie": "^1.0.2" } }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -3169,12 +3713,32 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/dijkstrajs": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", "license": "MIT" }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -4065,6 +4629,35 @@ "node": ">= 6" } }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4174,6 +4767,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -4575,6 +5177,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-boolean-object": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", @@ -5001,15 +5616,13 @@ } }, "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "bin": { - "jiti": "lib/jiti-cli.mjs" + "jiti": "bin/jiti.js" } }, "node_modules/js-tokens": { @@ -5166,6 +5779,26 @@ "node": ">= 0.8.0" } }, + "node_modules/lilconfig": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.4.tgz", + "integrity": "sha512-zJZyBmQhArDRefuWTypZxwDpFDIYmrj8ypSBvwGD5P13Y95s6DjzKgu4DVdGbeQa5PDEsAOCAPNk4X+QOR006w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, "node_modules/lint-staged": { "version": "16.4.0", "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", @@ -5323,6 +5956,21 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.61.0.tgz", + "integrity": "sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==", + "license": "ISC", + "peerDependencies": { + "@types/react": "*", + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5419,10 +6067,22 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { - "version": "3.3.19", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", - "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", @@ -5570,6 +6230,16 @@ "node": ">=18" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5580,6 +6250,16 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -5868,6 +6548,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/playwright": { "version": "1.63.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", @@ -5916,6 +6606,191 @@ "node": ">= 0.4" } }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-import/node_modules/resolve": { + "version": "1.22.13", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.13.tgz", + "integrity": "sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.17.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.5.tgz", + "integrity": "sha512-TEHFzb5KRDKaOgJ4TYE+0jyJtFbjLvkdVOu0fZ2b6MTWzoqZd4Le+61aEppWkvTlYePgOX4ATL1wsAIPMLTw6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5974,6 +6849,15 @@ "node": ">=10.13.0" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6033,6 +6917,95 @@ "dev": true, "license": "MIT" }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/read-cache": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz", + "integrity": "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -6273,6 +7246,12 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/sdp": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz", + "integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==", + "license": "MIT" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -6786,6 +7765,39 @@ } } }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -6822,6 +7834,118 @@ "node": ">=12.0.0" } }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tailwind-merge": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz", + "integrity": "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/tailwindcss/node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tailwindcss/node_modules/resolve": { + "version": "1.22.13", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.13.tgz", + "integrity": "sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.17.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -6832,6 +7956,29 @@ "utrie": "^1.0.2" } }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/tinyexec": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", @@ -6925,6 +8072,13 @@ "node": ">=14.0.0" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -6970,6 +8124,21 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.10.0.tgz", + "integrity": "sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -7190,6 +8359,56 @@ "punycode": "^2.1.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, "node_modules/utrie": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", @@ -7213,6 +8432,19 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/webrtc-adapter": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz", + "integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==", + "license": "BSD-3-Clause", + "dependencies": { + "sdp": "^3.2.0" + }, + "engines": { + "node": ">=6.0.0", + "npm": ">=3.10.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7591,6 +8823,19 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zxing-wasm": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.6.tgz", + "integrity": "sha512-mU7UNuvl+hOT+nlSOQTMHZ2tM8lRMsbjDIp5IO/+HmBl/FOq77q0OSZPJ3cm42A9c1MKU6Uve67FiGdkhsrT9g==", + "license": "MIT", + "dependencies": { + "@types/emscripten": "^1.41.6", + "type-fest": "^5.10.0" + }, + "peerDependencies": { + "@types/emscripten": ">=1.39.6" + } } } } diff --git a/package.json b/package.json index 557d316..41deb0c 100644 --- a/package.json +++ b/package.json @@ -8,21 +8,30 @@ "start": "next start", "lint": "eslint", "test": "node --experimental-strip-types --no-warnings --test \"tests/unit/*.test.mjs\"", + "typecheck": "tsc --noEmit", + "test:pass": "playwright test --config playwright.pass.config.ts", "prepare": "husky", "check:assets": "node scripts/check-assets.mjs" }, "dependencies": { + "@radix-ui/react-dialog": "^1.3.1", "@supabase/supabase-js": "^2.117.2", "@vercel/analytics": "^2.0.1", + "@yudiel/react-qr-scanner": "^2.6.0", "@zxing/browser": "^0.2.1", "@zxing/library": "^0.23.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "gsap": "^3.15.0", "imagekit": "^6.0.0", "jspdf": "^4.2.1", + "lucide-react": "^1.61.0", "next": "16.3.8", "qrcode": "^1.5.4", + "qrcode.react": "^4.2.0", "react": "19.2.8", - "react-dom": "19.2.8" + "react-dom": "19.2.8", + "tailwind-merge": "^3.7.0" }, "devDependencies": { "@electric-sql/pglite": "^0.5.8", @@ -31,10 +40,13 @@ "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", + "autoprefixer": "^10.6.1", "eslint": "^9", "eslint-config-next": "16.3.8", "husky": "^9.1.7", "lint-staged": "^16.4.0", + "postcss": "^8.5.31", + "tailwindcss": "^3.4.19", "typescript": "^5" }, "lint-staged": { diff --git a/playwright.pass.config.ts b/playwright.pass.config.ts new file mode 100644 index 0000000..fcc5aef --- /dev/null +++ b/playwright.pass.config.ts @@ -0,0 +1,11 @@ +import { defineConfig, devices } from '@playwright/test'; +export default defineConfig({ + testDir: './tests/browser', testMatch: 'pass.spec.ts', timeout: 30_000, + globalTeardown: './tests/browser/teardown.ts', + use: { baseURL: 'http://127.0.0.1:3191', channel: 'chrome', trace: 'retain-on-failure' }, + webServer: { command: 'node tests/browser/serve-pass.cjs', url: 'http://127.0.0.1:3191', timeout: 60_000 }, + projects: [ + { name: 'desktop', use: { ...devices['Desktop Chrome'] } }, + { name: 'phone', use: { ...devices['Pixel 5'], viewport: { width: 320, height: 740 } } }, + ], +}); diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..4045a1c --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; +export default config; diff --git a/public/assets/payment-qr-code.webp b/public/assets/payment-qr-code.webp new file mode 100644 index 0000000..3cf1c94 Binary files /dev/null and b/public/assets/payment-qr-code.webp differ diff --git a/public/assets/payment-qr.webp b/public/assets/payment-qr.webp new file mode 100644 index 0000000..8739476 Binary files /dev/null and b/public/assets/payment-qr.webp differ diff --git a/src/app/admin/scan/page.tsx b/src/app/admin/scan/page.tsx index 46fe4b8..6bc00ba 100644 --- a/src/app/admin/scan/page.tsx +++ b/src/app/admin/scan/page.tsx @@ -1,31 +1,31 @@ -import type { Metadata } from 'next'; -import { cookies, headers } from 'next/headers'; -import { redirect } from 'next/navigation'; -import { verifyRegistrationStaff } from '@/lib/auth-server'; -import EntryScanner from '@/components/entry/EntryScanner'; -import SessionRestore from '@/components/entry/SessionRestore'; +import type { Metadata } from "next"; +import { cookies, headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { verifyRegistrationStaff } from "@/lib/auth-server"; +import EntryScanner from "@/components/entry/EntryScanner"; +import SessionRestore from "@/components/entry/SessionRestore"; export const metadata: Metadata = { - title: 'Check-in scanner', + title: "Check-in Scanner | INNOVISION 2026", robots: { index: false, follow: false }, }; /** - * /admin/scan: the venue check-in scanner, for the registration team and admins only. - * The role is checked here from the database (never from anything the client sends); every scan and check-in is - * authorized again by /api/admin/entry/* and by the database functions behind them. + * /admin/scan: Venue check-in scanner for the registration team and admins. + * Uses the established staff-authorized opaque-token lookup and atomic check-in endpoints. */ export default async function ScanPage() { const source = { headers: await headers(), cookies: await cookies() }; const auth = await verifyRegistrationStaff(source); - if ('error' in auth) { - // Expired access token but a refresh token is present: refresh once in the browser instead of bouncing to login. - if (auth.status === 401 && source.cookies.get('inn_refresh_token')?.value) return ; - redirect(auth.status === 401 ? '/?auth=login&required=scan' : '/'); + if ("error" in auth) { + if (auth.status === 401 && source.cookies.get("inn_refresh_token")?.value) { + return ; + } + redirect(auth.status === 401 ? "/?auth=login&required=scan" : "/"); } const name = auth.user.user_metadata?.full_name || auth.user.user_metadata?.name; - const staffLabel = `${typeof name === 'string' && name ? name : auth.user.email ?? 'staff'} (${auth.role})`; + const staffLabel = `${typeof name === "string" && name ? name : auth.user.email ?? "staff"} (${auth.role})`; return ; } diff --git a/src/app/api/pass/experience/route.ts b/src/app/api/pass/experience/route.ts new file mode 100644 index 0000000..369b8ff --- /dev/null +++ b/src/app/api/pass/experience/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthenticatedUser } from '@/lib/auth-server'; +import { getSupabaseAsUser } from '@/lib/supabase'; +import { createRateLimiter, readJsonObject } from '@/lib/security'; +import { entryRateLimited, entryRpcFailure } from '@/lib/entry-route'; + +const limiter = createRateLimiter({ limit: 40, windowMs: 60_000 }); + +export async function POST(req: NextRequest) { + const auth = await getAuthenticatedUser(req); + if (!auth) return NextResponse.json({ error: 'Sign in required.' }, { status: 401 }); + const limited = entryRateLimited(limiter, auth.user.id); + if (limited) return limited; + const parsed = await readJsonObject(req); + if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: parsed.status }); + const body = parsed.value; + if (typeof body.action !== 'string' || !['claim', 'acknowledge'].includes(body.action) || + typeof body.claim !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(body.claim)) { + return NextResponse.json({ error: 'Invalid experience request.' }, { status: 400 }); + } + const db = getSupabaseAsUser(auth.token); + const { data, error } = await db.rpc(body.action === 'claim' ? 'claim_pass_experience' : 'acknowledge_pass_experience', { p_claim: body.claim }); + if (error) return entryRpcFailure(error, '/api/pass/experience'); + return NextResponse.json({ result: data }, { headers: { 'Cache-Control': 'private, no-store' } }); +} diff --git a/src/app/api/pass/route.ts b/src/app/api/pass/route.ts index 9d343ea..4313fba 100644 --- a/src/app/api/pass/route.ts +++ b/src/app/api/pass/route.ts @@ -3,6 +3,7 @@ import { getAuthenticatedUser } from '@/lib/auth-server'; import { getSupabaseAsUser } from '@/lib/supabase'; import { createRateLimiter } from '@/lib/security'; import { decryptEntryToken, encryptEntryToken, entryPassKey, mintEntryToken } from '@/lib/entry-pass'; +import type { ParticipantPass } from '@/lib/pass-experience'; export const runtime = 'nodejs'; @@ -45,22 +46,27 @@ export async function POST(req: NextRequest) { } const mine = data as MyEntryPass; - const pass = { + const details = await db.from('registrations').select('college') + .eq('user_id', auth.user.id).eq('registration_id', mine.registration_id ?? '').maybeSingle(); + if (details.error) return NextResponse.json({ error: 'Could not load your registration.' }, { status: 500 }); + const pass: ParticipantPass = { registrationStatus: mine.registration_status, passStatus: mine.pass_status, registrationId: mine.registration_id ?? null, name: mine.name ?? null, + college: details.data?.college ?? null, checkedIn: !!mine.checked_in, token: null as string | null, }; if (mine.registration_status !== 'confirmed' || mine.pass_status !== 'active' || !mine.pass) { - return NextResponse.json({ success: true, pass }); + return NextResponse.json({ success: true, pass }, { headers: { 'Cache-Control': 'private, no-store' } }); } const key = entryPassKey(); if (!key) { console.error('/api/pass: ENTRY_PASS_ENCRYPTION_KEY is missing or not 32 bytes'); - return NextResponse.json({ error: 'Entry passes are not available yet. Please try again later.' }, { status: 503 }); + pass.qrUnavailable = 'Your payment is verified. The check-in QR is temporarily unavailable; please check again later.'; + return NextResponse.json({ success: true, pass }, { headers: { 'Cache-Control': 'private, no-store' } }); } let ciphertext = mine.token_ciphertext ?? null; @@ -83,7 +89,7 @@ export async function POST(req: NextRequest) { console.error('/api/pass: stored token does not decrypt (wrong ENTRY_PASS_ENCRYPTION_KEY?)'); return NextResponse.json({ error: 'Your pass could not be loaded. Please contact the help desk.' }, { status: 500 }); } - return NextResponse.json({ success: true, pass }); + return NextResponse.json({ success: true, pass }, { headers: { 'Cache-Control': 'private, no-store' } }); } catch (err: unknown) { console.error('/api/pass error:', err); return NextResponse.json({ error: 'Could not load your pass.' }, { status: 500 }); diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts index d87ba7b..d3f568b 100644 --- a/src/app/api/register/route.ts +++ b/src/app/api/register/route.ts @@ -11,7 +11,6 @@ import { generateRegistrationId, isFilterSafeEmail, NEW_REGISTRATION_STATUS, - NIT_ROURKELA_COLLEGE, readJsonObject, REGISTRATION_FEE_INR, validateRegistrationInput, @@ -145,7 +144,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: parsed.error, field: parsed.field }, { status: 400 }); } const input = parsed.data; - const college = studentType === 'internal' ? NIT_ROURKELA_COLLEGE : input.college; + const college = input.college; const adminClient = getSupabaseAdmin(token); diff --git a/src/app/api/tickets/route.ts b/src/app/api/tickets/route.ts new file mode 100644 index 0000000..6ec99a7 --- /dev/null +++ b/src/app/api/tickets/route.ts @@ -0,0 +1,3 @@ +/** Compatibility alias. Pass access may mint an opaque token, so it uses POST. */ +export const runtime = 'nodejs'; +export { POST } from '../pass/route'; diff --git a/src/app/api/tickets/verify/route.ts b/src/app/api/tickets/verify/route.ts new file mode 100644 index 0000000..5edc44c --- /dev/null +++ b/src/app/api/tickets/verify/route.ts @@ -0,0 +1,3 @@ +/** Compatibility alias. Same staff authorization, opaque tokens and atomic check-in contract. */ +export const runtime = 'nodejs'; +export { POST } from '../../admin/entry/check-in/route'; diff --git a/src/app/globals.css b/src/app/globals.css index a0de7ef..c03c78e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,3 +1,7 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + /* ---------- base (from the Innovision 2026 design) ---------- */ *,*::before,*::after{box-sizing:border-box} /* ---------- typography ---------- @@ -6,7 +10,51 @@ --font-sans Manrope, variable: subheadings, eyebrow labels, buttons, navigation, body copy and data (IDs, prices in lists). Weights by role: 400 body, 500 navigation and secondary text, 700 labels, buttons and subheadings, 800 small badges and ID numbers. Display text is never bolded (the face has no bold). */ -:root{--font-display:var(--font-dm-serif),'Times New Roman',serif;--font-sans:var(--font-manrope),'Segoe UI',system-ui,sans-serif} +:root { + --background: 42 22% 90%; + --foreground: 30 5% 8%; + --card: 42 22% 90%; + --card-foreground: 30 5% 8%; + --popover: 0 0% 100%; + --popover-foreground: 30 5% 8%; + --primary: 38 92% 50%; + --primary-foreground: 0 0% 100%; + --secondary: 40 10% 80%; + --secondary-foreground: 30 5% 8%; + --muted: 40 10% 85%; + --muted-foreground: 30 5% 45%; + --accent: 38 92% 50%; + --accent-foreground: 0 0% 100%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 40 10% 75%; + --input: 40 10% 75%; + --ring: 38 92% 50%; + --radius: 0.5rem; + --font-display:var(--font-dm-serif),'Times New Roman',serif; + --font-sans:var(--font-manrope),'Segoe UI',system-ui,sans-serif; +} +.dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 240 4.9% 83.9%; +} html,body{margin:0;height:100%;overflow:hidden;background:#ECE8DF;color:#141312;font-family:var(--font-sans);-webkit-font-smoothing:antialiased;-webkit-tap-highlight-color:transparent} img{display:block;max-width:none;user-select:none;-webkit-user-drag:none} button{font:inherit;color:inherit} diff --git a/src/app/page.tsx b/src/app/page.tsx index b5be0de..b5c5388 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,8 +2,7 @@ import Innovision from "@/components/innovision/Innovision"; import { SITE_DESCRIPTION } from "@/config/site"; import { homeJsonLd, jsonLdScript, pageMetadata } from "@/lib/seo"; -// Canonical "/" also covers the query-string entry points (?auth=login, ?open=register, OAuth ?code=) and every -// #/ hash route, which all render this one page. +// Canonical "/" covers the main Innovision 2026 website experience export const metadata = pageMetadata({ title: null, description: SITE_DESCRIPTION, path: "/" }); export default function Home() { diff --git a/src/app/ticket/page.tsx b/src/app/ticket/page.tsx new file mode 100644 index 0000000..737465c --- /dev/null +++ b/src/app/ticket/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function TicketPage() { + redirect("/"); +} diff --git a/src/components/innovision/ApprovedPassTicket.module.css b/src/components/innovision/ApprovedPassTicket.module.css new file mode 100644 index 0000000..754ebe1 --- /dev/null +++ b/src/components/innovision/ApprovedPassTicket.module.css @@ -0,0 +1,12 @@ +.root { width:100%; max-width:920px; margin-inline:auto; min-width:0; } +.preparing { aspect-ratio:1000/330; min-height:180px; } +.root > article { max-width:560px; margin-inline:auto; } +.actions { display:flex; justify-content:center; flex-wrap:wrap; gap:10px; margin-top:22px; } +.actions button { min-height:46px; padding:12px 22px; border:1px solid #ece8df40; border-radius:6px; background:transparent; color:#ece8df; font:600 13px/1.4 var(--font-sans),sans-serif; cursor:pointer; transition:background-color .2s; } +.actions button:hover { background:#ece8df10; }.actions .print { background:#ece8df; border-color:#ece8df; color:#141312; }.actions .print:hover { background:#fff8eb; } +.actions button:focus-visible { outline:2px solid #c8b17a; outline-offset:4px; }.actions button:disabled { opacity:.6; cursor:wait; } +.note { margin:12px 0 0; text-align:center; font-size:12px; line-height:1.5; color:#ece8df99; } +.dialog { width:calc(100% - 32px); max-width:380px; padding:26px; border:1px solid #ece8df30; background:#100f0e; color:#ece8df; border-radius:12px; } +.largeQr { margin:8px auto; width:min(100%,280px); }.largeQr svg { display:block; width:100%; height:auto; }.qrName { margin:0; text-align:center; overflow-wrap:anywhere; font-size:16px; }.qrName span { display:inline-block; margin-top:6px; font:12px ui-monospace,monospace; color:#ece8df99; } +.printRoot { display:none; } +@media(max-width:680px) { .root { max-width:440px; }.actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); gap:9px; margin-top:16px; }.actions button { padding-inline:12px; min-height:48px; }.note { max-width:34ch; margin-inline:auto; } } diff --git a/src/components/innovision/ApprovedPassTicket.tsx b/src/components/innovision/ApprovedPassTicket.tsx new file mode 100644 index 0000000..629fa64 --- /dev/null +++ b/src/components/innovision/ApprovedPassTicket.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { QRCodeSVG } from 'qrcode.react'; +import EventTicket from '@/components/ui/event-ticket'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; +import type { ParticipantPass } from '@/lib/pass-experience'; +import { EVENT_DATE, VENUE_CITY } from '@/config/site'; +import PassKeepsake from './PassKeepsake'; +import styles from './ApprovedPassTicket.module.css'; + +export default function ApprovedPassTicket({ pass, animate = false }: { pass: ParticipantPass; animate?: boolean }) { + const [compact, setCompact] = useState(null); + const [qrOpen, setQrOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(''); + const [infoOpened, setInfoOpened] = useState(false); + const qrButton = useRef(null); + useEffect(() => { + const media = matchMedia('(max-width: 900px)'); + const update = () => setCompact(media.matches); + update(); + media.addEventListener('change', update); + return () => media.removeEventListener('change', update); + }, []); + + const savePdf = async () => { + if (saving) return; + setSaving(true); + setSaveError(''); + try { + const { downloadPassPdf } = await import('./download-pass'); + await downloadPassPdf(pass); + } catch { + setSaveError('Could not save your pass. Please try again. Your pass is still available here.'); + } finally { + setSaving(false); + } + }; + + return ( +
+ {/* Only mount the illustrated canvas ticket on larger screens. */} + {compact === null ? + ); +} diff --git a/src/components/innovision/AuthOverlay.module.css b/src/components/innovision/AuthOverlay.module.css new file mode 100644 index 0000000..8835a05 --- /dev/null +++ b/src/components/innovision/AuthOverlay.module.css @@ -0,0 +1,69 @@ +.layout { position:relative; display:flex; flex-direction:column; align-items:center; gap:18px; width:100%; max-width:640px; margin:0 auto; padding:10px 24px 32px; transition:max-width .4s ease; } +.intro { min-width:0; width:100%; text-align:center; --auth-title-size:clamp(32px,3.5vw,46px); } +.intro p { margin-inline:auto!important; margin-top:12px!important; } +.intro ol { text-align:left; } +.panelBody :focus-visible { outline-color:#c8b17a; } +.previewNote { margin:0 auto 12px; padding:8px 16px; color:#e4d3a4; font:11px/1.5 var(--font-sans),sans-serif; text-align:center; } +.previewApprove { padding:12px 18px; min-height:44px; border:1px dashed #c8b17a; border-radius:4px; color:#e4d3a4; background:#c8b17a14; cursor:pointer; } + +/* When pass view is active — expand to full desktop proportions */ +.layout[data-pass-view="true"], +.layout[data-approved="true"] { + max-width: 1140px !important; + width: 100% !important; + padding: 8px 24px 32px !important; +} + +.layout[data-pass-view="true"] > .intro, +.layout[data-approved="true"] > .intro { + display: none !important; +} + +.layout[data-pass-view="true"] .panel, +.layout[data-approved="true"] .panel { + max-width: 1140px !important; + width: 100% !important; +} + +.layout[data-pass-view="true"] .panelBorder, +.layout[data-approved="true"] .panelBorder, +.panelBorder[data-pass-border="true"] { + background: transparent !important; + padding: 0 !important; + clip-path: none !important; +} + +.layout[data-pass-view="true"] .panelBody, +.layout[data-approved="true"] .panelBody, +.panelBody[data-pass-panel="true"] { + background: transparent !important; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + clip-path: none !important; + padding: 0 !important; + box-shadow: none !important; +} + +.panel { position:relative; width:100%; min-width:0; max-width:560px; } +.panelBody { position:relative; padding:clamp(22px,3vw,36px); background:rgba(16,15,14,.9); backdrop-filter:blur(10px); clip-path:polygon(19.5px 0,100% 0,100% calc(100% - 19.5px),calc(100% - 19.5px) 100%,0 100%,0 19.5px); } +/* Static orbital details give the centered form a setting without rendering work. */ +@media(min-width:1100px) { + .layout:not([data-pass-view="true"])::before, + .layout:not([data-pass-view="true"])::after { content:''; position:absolute; width:210px; height:210px; border:1px solid rgba(200,177,122,.12); border-radius:50%; pointer-events:none; background:radial-gradient(circle at 50% 0,#c8b17a66 0 2px,transparent 3px); } + .layout:not([data-pass-view="true"])::before { left:-214px; top:170px; box-shadow:0 0 0 36px rgba(200,177,122,.025); } + .layout:not([data-pass-view="true"])::after { right:-214px; top:330px; width:140px; height:140px; border-style:dashed; } +} +@media(max-width:900px) { + .layout { gap:22px; padding:16px 20px 36px; } + .intro,.panel { width:100%; max-width:540px; justify-self:center; } + .intro { padding-top:0; } + .layout[data-pass-view="true"] .panel, + .layout[data-approved="true"] .panel { max-width: 100% !important; } +} +@media(max-width:480px) { + .layout { padding-inline:14px; gap:18px; } + .panelBody { padding:22px 18px; } + .panelBody[data-pass-panel=true] { padding:0 !important; } + .layout[data-pass-view="true"],.layout[data-approved="true"] { padding:8px 16px 28px!important; } +} +@media(prefers-reduced-motion:reduce) { .layout { transition:none; } } diff --git a/src/components/innovision/AuthOverlay.tsx b/src/components/innovision/AuthOverlay.tsx index 1f1b0e5..23bfead 100644 --- a/src/components/innovision/AuthOverlay.tsx +++ b/src/components/innovision/AuthOverlay.tsx @@ -2,24 +2,32 @@ // @ts-nocheck "use client"; /* eslint-disable @next/next/no-img-element -- decorative/user-uploaded images not suited for next/image */ -import React, { useState } from 'react'; +import React, { useRef, useState } from 'react'; +import { useDialogFocus } from './useDialogFocus'; import type { V } from './types'; import { Logo } from './icons'; import EntryPassCard from './EntryPassCard'; +import styles from './AuthOverlay.module.css'; + +/* ── Payment QR lightbox state lives here, outside JSX ── */ export default function AuthOverlay({ v }: { v: V }) { + const dialog = useRef(null); + useDialogFocus(v.authHidden === 'false', dialog, v.closeAuthH); const [userCollege, setUserCollege] = useState(null); const collegeValue = userCollege !== null ? userCollege : (v.regVals?.college || ''); + const [payQrOpen, setPayQrOpen] = useState(false); return ( -
+ <> +
-
+
@@ -33,28 +41,29 @@ export default function AuthOverlay({ v }: { v: V }) { {v.showSwitch ? ( {v.switchQ} - + ) : null}
-
-
+ {v.isPreviewUser &&

Registration preview · No payment or registration is sent to the server.

} +
+
-

{v.authTitle}

+

{v.authTitle}

{v.authSub}

-
    +
      {v.prog.map((p, pI) => (
    1. - + {p.label} @@ -63,40 +72,40 @@ export default function AuthOverlay({ v }: { v: V }) {
    2. ))}
    -
      +
        {v.prog.map((p, pI) => (
      1. - + {p.short}
      2. ))}
-
-
-
+
+
+

Your details

{v.gNote ? (

- + {v.gNote}

) : null}
GENDER {v.genderOptions.map((g) => ( - @@ -165,13 +172,28 @@ export default function AuthOverlay({ v }: { v: V }) {

Pay the fee

-
- {`UPI - - - - - + {/* Payment QR — prominent, click to enlarge */} +
+ + TAP TO ENLARGE
@@ -182,17 +204,18 @@ export default function AuthOverlay({ v }: { v: V }) { UPI ID {v.upi} - +
- OPEN UPI APP + OPEN UPI APP
    -
  1. 1Scan the code with any UPI app.
  2. -
  3. 2Pay exactly ₹{v.fee}.
  4. -
  5. 3Screenshot the success screen. You'll upload it next.
  6. +
  7. 1Scan the code with any UPI app.
  8. +
  9. 2Pay exactly ₹{v.fee}.
  10. +
  11. 3Screenshot the success screen. You'll upload it next.
+ {v.isPreviewUser &&

Preview only: don’t make a payment. Continue to try the screenshot upload and 12-digit UTR fields.

}
@@ -200,19 +223,19 @@ export default function AuthOverlay({ v }: { v: V }) {
PAYMENT SCREENSHOT -
@@ -260,48 +283,60 @@ export default function AuthOverlay({ v }: { v: V }) {
+ {v.isDev ? ( +
+ REGISTRATION PREVIEW + + +
+ ) : null} + {v.gErr ? ({v.gErr}) : null}
-
-
-
- -
-

BOARDING PASS

-

PASSENGER

-

{v.passName}

-
-

COLLEGE

{v.passCollege}

-

GATE

NIT Rourkela

-

FEST

Innovision 2026

-
-
-
-
-

REGISTRATION ID

{v.passId}

- {v.passStatus} -
- -
-
- {v.passQr ? : null} - {v.refCode ? ( -
- 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}

-
- EXPLORE THE WORLDS - + ) : null} + +
@@ -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", + }} + > + {`UPI +

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 */} - {`Entry -

- 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 ( +
+
+
NIT ROURKELA PRESENTSINNOVISION 2026
+ PAYMENT
VERIFIED
+
+
EVENT DATE{EVENT_DATE.label}
+
+ ISSUED TO +

+
+
From · College
{pass.college}
+
To · Venue
NITNIT Rourkela
+
+
+
+ ); +} 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 ( +
+
+ ); +} 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 (
- + {open &&
+ +
}
; +} +createRoot(document.getElementById('root')!).render(); diff --git a/tests/browser/loader.cjs b/tests/browser/loader.cjs new file mode 100644 index 0000000..4a06a24 --- /dev/null +++ b/tests/browser/loader.cjs @@ -0,0 +1,9 @@ +/* eslint-disable @typescript-eslint/no-require-imports -- webpack loaders use the CommonJS loader API */ +const ts = require('typescript'); +module.exports = function(source) { + if (this.resourcePath.endsWith('.css')) { + const names = [...source.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => match[1]); + return `const s=document.createElement('style');s.textContent=${JSON.stringify(source)};document.head.append(s);export default ${JSON.stringify(Object.fromEntries(names.map(n => [n,n])))};`; + } + return ts.transpileModule(source, { compilerOptions: { jsx: ts.JsxEmit.ReactJSX, target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } }).outputText; +}; diff --git a/tests/browser/pass.spec.ts b/tests/browser/pass.spec.ts new file mode 100644 index 0000000..eacbcc3 --- /dev/null +++ b/tests/browser/pass.spec.ts @@ -0,0 +1,126 @@ +import { test, expect, type Page, type BrowserContext } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; + +const approved = { registrationStatus: 'confirmed', passStatus: 'active', registrationId: 'IV26-1024', name: 'Ananya Priyadarshini Subramanian', college: 'International Institute of Engineering, Technology and Advanced Interdisciplinary Studies', token: 'opaque-real-token-fixture', checkedIn: false }; +async function backend(context: BrowserContext, initial = approved) { + const state = { pass: initial, seen: false, claimed: false, claims: 0, acks: 0 }; + await context.route('**/api/pass', route => { + expect(route.request().method()).toBe('POST'); + return route.fulfill({ json: { pass: state.pass } }); + }); + await context.route('**/api/pass/experience', route => { + const body = route.request().postDataJSON(); + if (body.action === 'acknowledge') { state.seen = true; state.acks++; return route.fulfill({ json: { result: true } }); } + state.claims++; + const result = state.seen ? 'seen' : state.claimed ? 'busy' : 'claimed'; state.claimed = true; + return route.fulfill({ json: { result } }); + }); + return state; +} +async function open(page: Page) { await page.goto('/'); await page.getByRole('button', { name: 'My pass', exact: true }).click(); } +async function showDetails(page: Page) { + const toggle = page.getByRole('button', { name: /Your pass$/i }); + if (await toggle.count()) await toggle.click(); +} + +test('pending/rejected never reveal; approved data is personalized and persists', async ({ page, context }) => { + const state = await backend(context, { ...approved, registrationStatus: 'pending', token: '' }); + await open(page); await expect(page.getByText('Payment under review.', { exact: false })).toBeVisible(); + expect(state.claims).toBe(0); + state.pass = { ...approved, registrationStatus: 'rejected', token: '' }; + await page.evaluate(() => window.dispatchEvent(new Event('focus'))); + await expect(page.getByText('Your payment was not approved.', { exact: false })).toBeVisible(); + expect(state.claims).toBe(0); + state.pass = approved; + await page.evaluate(() => window.dispatchEvent(new Event('focus'))); + await expect(page.getByRole('heading', { name: 'You’re in, Ananya.' })).toBeVisible(); + await expect(page.locator('[data-pass-phase]')).toHaveAttribute('data-pass-phase', 'settled'); + await showDetails(page); + await expect(page.getByRole('heading', { name: approved.name })).toBeVisible(); + await expect(page.getByText(approved.college, { exact: true })).toBeVisible(); + await expect.poll(() => state.acks).toBe(1); + await page.reload(); await page.getByRole('button', { name: 'My pass', exact: true }).click(); + await expect(page.locator('[data-pass-phase]')).toHaveAttribute('data-pass-phase', 'settled'); + await expect(page.getByRole('button', { name: 'SKIP', exact: true })).toHaveCount(0); +}); + +test('keyboard skip, focus restoration and a real PDF download', async ({ page, context }, testInfo) => { + const state = await backend(context); + await open(page); + const skip = page.getByRole('button', { name: 'SKIP', exact: true }); + await expect(skip).toBeVisible(); await skip.focus(); await page.keyboard.press('Enter'); + await expect(page.locator('[data-pass-phase]')).toHaveAttribute('data-pass-phase', 'settled'); + const downloadEvent = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Save PDF', exact: true }).click(); + const download = await downloadEvent; + expect(download.suggestedFilename()).toBe('INNOVISION-IV26-1024.pdf'); + await download.saveAs(testInfo.outputPath('downloaded-pass.pdf')); + const bytes = await readFile((await download.path())!); + expect(bytes.subarray(0, 5).toString()).toBe('%PDF-'); + expect(bytes.length).toBeGreaterThan(10000); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).toHaveCount(0); + await expect(page.getByRole('button', { name: 'My pass', exact: true })).toBeFocused(); + await expect.poll(() => state.acks).toBe(1); +}); + +test('reduced motion and long/unbroken text remain readable without horizontal overflow', async ({ page, context }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + const name = 'Ananya '.repeat(12).trim(); const college = 'Institute'.repeat(23); + const state = await backend(context, { ...approved, name, college }); + await open(page); + await expect(page.locator('[data-pass-phase]')).toHaveAttribute('data-pass-phase', 'settled'); + await expect(page.getByRole('heading', { name, exact: true })).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + expect(await page.locator('article h3, article dd').evaluateAll(els => els.every(el => el.scrollWidth <= el.clientWidth))).toBe(true); + await expect.poll(() => state.acks).toBe(1); +}); + +test('parallel tabs elect one reveal', async ({ page, context }) => { + await backend(context); const second = await context.newPage(); + await Promise.all([open(page), open(second)]); + await expect.poll(async () => (await page.getByRole('button', { name: 'SKIP', exact: true }).count()) + (await second.getByRole('button', { name: 'SKIP', exact: true }).count())).toBe(1); + await Promise.all([showDetails(page), showDetails(second)]); + await expect(page.getByRole('heading', { name: approved.name })).toBeAttached(); + await expect(second.getByRole('heading', { name: approved.name })).toBeAttached(); +}); + +test('desktop stub detaches with the keyboard and retains the pass', async ({ page, context }) => { + test.skip((page.viewportSize()?.width ?? 0) <= 680, 'Phone pass is an immediately readable vertical card.'); + await backend(context); await open(page); + await expect(page.locator('[data-pass-phase]')).toHaveAttribute('data-pass-phase', 'settled'); + await showDetails(page); + const stub = page.getByRole('button', { name: 'Tear off the stub' }); + await stub.focus(); await page.keyboard.press('Enter'); + await expect(page.getByRole('button', { name: 'Reattach the stub' })).toHaveAttribute('aria-pressed', 'true'); + await expect(page.getByRole('heading', { name: approved.name })).toBeVisible(); +}); + +test('ordinary close/reopen does not replay', async ({ page, context }) => { + const state = await backend(context); await open(page); + await expect.poll(() => state.acks).toBe(1); + await page.keyboard.press('Escape'); await page.getByRole('button', { name: 'My pass', exact: true }).click(); + await expect(page.locator('[data-pass-phase]')).toHaveAttribute('data-pass-phase', 'settled'); + await expect(page.getByRole('button', { name: 'SKIP', exact: true })).toHaveCount(0); + expect(state.acks).toBe(1); +}); + +test('claim-service failure preserves a static pass and PDF without a fake QR', async ({ page, context }) => { + await backend(context, { ...approved, token: '' }); + await context.route('**/api/pass/experience', route => route.fulfill({ status: 503, json: { error: 'Unavailable' } })); + await open(page); + await expect(page.locator('[data-pass-phase]')).toHaveAttribute('data-pass-phase', 'settled'); + await showDetails(page); + await expect(page.getByRole('heading', { name: approved.name })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Save PDF', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'View entry QR' })).toHaveCount(0); +}); + +test('revocation between pass fetch and claim never celebrates', async ({ page, context }) => { + await backend(context); + await context.route('**/api/pass/experience', route => route.fulfill({ json: { result: 'ineligible' } })); + await open(page); + await expect(page.getByText('Registration Status Updated', { exact: true })).toBeVisible(); + await expect(page.getByRole('article')).toHaveCount(0); + await expect(page.getByRole('button', { name: 'SKIP', exact: true })).toHaveCount(0); +}); diff --git a/tests/browser/serve-pass.cjs b/tests/browser/serve-pass.cjs new file mode 100644 index 0000000..1c9531d --- /dev/null +++ b/tests/browser/serve-pass.cjs @@ -0,0 +1,45 @@ +/* eslint-disable @typescript-eslint/no-require-imports -- standalone CommonJS webpack test runner */ +// Isolated component harness; no fixture routes or fake identities enter the Next.js app. +const path = require('node:path'); +const fs = require('node:fs'); +const http = require('node:http'); +const { webpack } = require('next/dist/compiled/webpack/webpack'); +const root = path.resolve(__dirname, '../..'); +const output = path.join(root, '.pass-test'); +webpack({ + mode: 'development', devtool: false, entry: path.join(__dirname, 'harness.tsx'), + plugins: [new webpack.DefinePlugin({ 'process.env.NEXT_PUBLIC_SITE_URL': 'undefined', 'process.env.VERCEL_ENV': 'undefined' })], + output: { path: output, filename: 'bundle.js', publicPath: '/' }, + resolve: { extensions: ['.tsx', '.ts', '.js'], alias: { '@/lib/supabase$': path.join(__dirname, 'session.ts'), '@': path.join(root, 'src') } }, + module: { rules: [{ test: /\.(tsx?|css)$/, exclude: /node_modules/, use: path.join(__dirname, 'loader.cjs') }] }, +}, (error, stats) => { + if (error || stats.hasErrors()) { console.error(error || stats.toString('errors-only')); process.exit(1); } + http.createServer((req, res) => { + const url = new URL(req.url, 'http://127.0.0.1:3191'); + const preview = new URL(req.headers.referer || 'http://127.0.0.1:3191').searchParams.get('preview'); + if (preview && req.method === 'POST' && url.pathname.startsWith('/api/pass')) { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + if (url.pathname === '/api/pass/experience') { + let body = ''; req.on('data', data => { body += data; }); + req.on('end', () => res.end(JSON.stringify({ result: JSON.parse(body).action === 'claim' ? 'claimed' : true }))); + } else res.end(JSON.stringify({ pass: { + registrationStatus: preview === 'pending' ? 'pending' : preview === 'rejected' ? 'rejected' : 'confirmed', + passStatus: preview === 'pending' || preview === 'rejected' ? 'none' : 'active', + registrationId: 'PREVIEW-ONLY', name: 'Sample Participant', college: 'Sample College', checkedIn: false, token: null, + } })); + return; + } + if (req.url === '/__shutdown' && req.method === 'POST') { + res.end('closed', () => setImmediate(() => process.exit(0))); return; + } + if (url.pathname === '/') { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + return res.end(`
`); + } + const name = (req.url || '').slice(1); + const file = name === 'font.ttf' ? path.join(root, 'src/assets/fonts/DMSerifDisplay-Regular.ttf') : /^[\w.-]+\.js$/.test(name) ? path.join(output, name) : null; + if (!file || !fs.existsSync(file)) { res.statusCode = 404; return res.end(); } + res.setHeader('Content-Type', name.endsWith('.js') ? 'text/javascript; charset=utf-8' : 'font/ttf'); fs.createReadStream(file).pipe(res); + }).listen(3191, '127.0.0.1', () => console.log('Pass harness listening on 3191')); +}); diff --git a/tests/browser/session.ts b/tests/browser/session.ts new file mode 100644 index 0000000..45545dc --- /dev/null +++ b/tests/browser/session.ts @@ -0,0 +1,2 @@ +// Test-only transport boundary. Production still uses the authenticated Supabase session. +export const getSupabase = () => ({ auth: { getSession: async () => ({ data: { session: null } }) } }); diff --git a/tests/browser/teardown.ts b/tests/browser/teardown.ts new file mode 100644 index 0000000..6b37f39 --- /dev/null +++ b/tests/browser/teardown.ts @@ -0,0 +1,4 @@ +/** Close the isolated server directly; Windows process-tree termination can stall in restricted shells. */ +export default async function teardown() { + await fetch('http://127.0.0.1:3191/__shutdown', { method: 'POST' }).catch(() => {}); +} diff --git a/tests/unit/decrypt-text.test.mjs b/tests/unit/decrypt-text.test.mjs new file mode 100644 index 0000000..0ca6379 --- /dev/null +++ b/tests/unit/decrypt-text.test.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { decryptFrame } from '../../src/lib/decrypt-text.ts'; + +test('the decrypt effect always resolves to the exact participant name', () => { + for (const name of ['Ananya Rao', 'Élodie O’Connor', 'आरव कुमार', '李明']) { + assert.equal(decryptFrame(name, 1, 99), name); + assert.equal(decryptFrame(name, 2, 99), name); + } +}); + +test('the intermediate mask preserves name spacing and punctuation', () => { + const name = 'A Rao-Singh'; + const mask = decryptFrame(name, 0, 3); + assert.equal(mask.length, name.length); + assert.equal(mask[1], ' '); + assert.equal(mask[5], '-'); + assert.equal(decryptFrame(name, .5, 3).slice(0, 5), name.slice(0, 5)); +}); diff --git a/tests/unit/pass-experience.test.mjs b/tests/unit/pass-experience.test.mjs new file mode 100644 index 0000000..9e1fb72 --- /dev/null +++ b/tests/unit/pass-experience.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { readFile } from 'node:fs/promises'; +import { PGlite } from '@electric-sql/pglite'; +import { isApprovedPass, passStatusMessage } from '../../src/lib/pass-experience.ts'; + +const pass = { registrationStatus: 'confirmed', passStatus: 'active', registrationId: 'IV26-1024', name: 'A real participant', college: 'Their own college', token: null, checkedIn: false }; +test('only a complete, server-confirmed active pass is eligible; QR is optional', () => { + assert.equal(isApprovedPass(pass), true); + for (const registrationStatus of ['none', 'pending', 'rejected']) { + assert.equal(isApprovedPass({ ...pass, registrationStatus }), false); + assert.doesNotMatch(passStatusMessage({ ...pass, registrationStatus }), /payment has been verified/i); + } + for (const passStatus of ['none', 'revoked']) assert.equal(isApprovedPass({ ...pass, passStatus }), false); + for (const field of ['name', 'college', 'registrationId']) assert.equal(isApprovedPass({ ...pass, [field]: null }), false); +}); + +test('real PostgreSQL acknowledgement lifecycle, ownership, RLS, contention and lease recovery', async () => { + const db = new PGlite(); + try { + await db.exec(` + CREATE ROLE anon; CREATE ROLE authenticated; + CREATE SCHEMA auth; + CREATE FUNCTION auth.uid() RETURNS uuid LANGUAGE sql AS $$ SELECT nullif(current_setting('request.jwt.claim.sub', true), '')::uuid $$; + GRANT USAGE ON SCHEMA auth TO authenticated; + CREATE TABLE registrations (id uuid PRIMARY KEY, user_id uuid, registration_id text, name text, college text, status text, created_at timestamptz default now()); + CREATE TABLE entry_passes (id uuid PRIMARY KEY, registration_id uuid REFERENCES registrations(id), status text); + INSERT INTO registrations VALUES ('10000000-0000-0000-0000-000000000001','20000000-0000-0000-0000-000000000001','IV26-1024','Participant One','College One','pending',now()); + INSERT INTO registrations VALUES ('10000000-0000-0000-0000-000000000002','20000000-0000-0000-0000-000000000002','IV26-2048','Participant Two','College Two','confirmed',now()); + INSERT INTO entry_passes VALUES ('30000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000001','active'); + INSERT INTO entry_passes VALUES ('30000000-0000-0000-0000-000000000002','10000000-0000-0000-0000-000000000002','active'); + `); + await db.exec(await readFile(new URL('../../supabase/manual/pass_experience.sql', import.meta.url), 'utf8')); + // Idempotent migration, safe to apply alongside schema.sql. + await db.exec(await readFile(new URL('../../supabase/manual/pass_experience.sql', import.meta.url), 'utf8')); + const user = async n => db.exec(`SET ROLE authenticated; SET request.jwt.claim.sub = '20000000-0000-0000-0000-00000000000${n}';`); + const call = async (fn, n) => (await db.query(`select ${fn}($1::uuid) as result`, [`40000000-0000-0000-0000-00000000000${n}`])).rows[0].result; + await user(1); + assert.equal(await call('claim_pass_experience', 1), 'ineligible'); + assert.equal(await call('acknowledge_pass_experience', 1), false); + await assert.rejects(db.query('SELECT * FROM pass_experiences'), /permission denied/); + await db.exec("RESET ROLE; UPDATE registrations SET status='rejected' WHERE registration_id='IV26-1024';"); + await user(1); assert.equal(await call('claim_pass_experience', 1), 'ineligible'); + await db.exec("RESET ROLE; UPDATE registrations SET status='confirmed' WHERE registration_id='IV26-1024';"); + await user(1); + const competing = await Promise.all([call('claim_pass_experience', 1), call('claim_pass_experience', 2)]); + assert.deepEqual(competing.sort(), ['busy', 'claimed']); + assert.equal(await call('claim_pass_experience', 1), 'busy'); + await db.exec('RESET ROLE'); + assert.equal((await db.query('select seen_at from pass_experiences')).rows[0].seen_at, null, 'claiming/loading is not acknowledgement'); + await user(2); + assert.equal(await call('acknowledge_pass_experience', 1), false, 'another account cannot acknowledge the first account'); + assert.equal(await call('claim_pass_experience', 3), 'claimed'); + await user(1); + assert.equal(await call('acknowledge_pass_experience', 9), false, 'wrong tab claim cannot acknowledge'); + assert.equal(await call('acknowledge_pass_experience', 1), true); + assert.equal(await call('acknowledge_pass_experience', 1), true, 'idempotent acknowledgement'); + assert.equal(await call('claim_pass_experience', 2), 'seen', 'refresh/revisit never replays'); + await db.exec("RESET ROLE; UPDATE pass_experiences SET claim_until=now()-interval '1 minute' WHERE registration_id='10000000-0000-0000-0000-000000000002';"); + await user(2); assert.equal(await call('claim_pass_experience', 4), 'claimed', 'abandoned unpresented lease is recoverable'); + assert.equal(await call('acknowledge_pass_experience', 3), false, 'expired claimant cannot acknowledge a replacement claim'); + await db.exec('RESET ROLE'); + assert.deepEqual((await db.query('select status from registrations order by registration_id')).rows.map(r => r.status), ['confirmed', 'confirmed'], 'acknowledgement never changes payment status'); + await db.exec("SET ROLE authenticated; SET request.jwt.claim.sub = '';"); + await assert.rejects(call('claim_pass_experience', 1), /Sign in required/); + } finally { await db.close(); } +}); diff --git a/tests/unit/pass-transport.test.mjs b/tests/unit/pass-transport.test.mjs new file mode 100644 index 0000000..b8a7eea --- /dev/null +++ b/tests/unit/pass-transport.test.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { passRequestInit } from '../../src/lib/pass-experience.ts'; + +test('pass access uses the token-minting POST contract and never caches participant data', () => { + const options = passRequestInit(undefined, 'fixture-session'); + assert.equal(options.method, 'POST'); + assert.equal(options.cache, 'no-store'); + assert.equal(options.headers.Authorization, 'Bearer fixture-session'); + assert.equal(options.body, undefined); + assert.equal(options.headers['Content-Type'], undefined); +}); + +test('only explicit acknowledgement/claim bodies use a keepalive POST', () => { + const body = { action: 'acknowledge', claim: 'fixture-claim' }; + const options = passRequestInit(body, 'fixture-session'); + assert.equal(options.method, 'POST'); + assert.equal(options.keepalive, true); + assert.equal(options.headers['Content-Type'], 'application/json'); + assert.deepEqual(JSON.parse(options.body), body); +}); diff --git a/tests/unit/registration-details.test.mjs b/tests/unit/registration-details.test.mjs new file mode 100644 index 0000000..3a38db8 --- /dev/null +++ b/tests/unit/registration-details.test.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { validateRegistrationInput } from '../../src/lib/security.ts'; + +const ownerId = '20000000-0000-4000-8000-000000000001'; +const details = { + name: 'Ananya Rao', college: ' Example Institute of Technology ', + phone: '9876543210', gender: 'female', enrollment_no: ' CS/2026-42 ', + payment_proof_path: `${ownerId}/30000000-0000-4000-8000-000000000001.jpg`, + utr: '123456789012', +}; + +test('registration retains the entered college and roll number', () => { + const result = validateRegistrationInput(details, { ownerId }); + assert.equal(result.ok, true); + assert.equal(result.data.college, 'Example Institute of Technology'); + assert.equal(result.data.enrollment_no, 'CS/2026-42'); +}); + +test('every participant must provide a college and a roll number', () => { + for (const field of ['college', 'enrollment_no']) { + for (const value of ['', ' ', undefined]) { + const result = validateRegistrationInput({ ...details, [field]: value }, { ownerId }); + assert.equal(result.ok, false); + assert.equal(result.field, field); + } + } +}); diff --git a/tsconfig.json b/tsconfig.json index 71253ff..cb104a6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,6 +25,10 @@ "paths": { "@/*": [ "./src/*" + ], + "@/components/ui/*": [ + "./src/components/ui/*", + "./components/ui/*" ] } },