Add /crush33 comp-ticket portal + ticket-type badge on scan
Some checks failed
Build Android APK / build-apk (push) Failing after 1h11m4s

Portal (/crush33): password-gated page (PORTAL_PASSWORD) for admins to create
entry-only tickets from just name + email, with a category
(Guest/Worker/Performer/Volunteer/Speaker). Creates a 1-admission ticket, emails
the QR, and shows the QR on-screen. New Ticket Type column.

Scanner: shows a prominent TYPE badge (🎭 PERFORMER, 🛠️ WORKER, …) on the
confirm + success screens and in admin, so staff can see it's a special ticket.
Added Worker + Performer personas to /test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-13 03:30:08 +00:00
parent b7c77afe02
commit 4b78c82b35
9 changed files with 230 additions and 0 deletions

View file

@ -202,6 +202,7 @@ function TicketCard({ ticket, onAdjust }: { ticket: TicketView; onAdjust: (t: Ti
const tags: string[] = []; const tags: string[] = [];
const e = ticket.extras; const e = ticket.extras;
if (ticket.ticketType) tags.push(`🎫 ${ticket.ticketType}`);
if (e.donorTier === "member") tags.push("🐻 Member"); if (e.donorTier === "member") tags.push("🐻 Member");
else if (e.isDonor) tags.push("⭐ Donor"); else if (e.isDonor) tags.push("⭐ Donor");
if (e.carParking) tags.push("🚗 Car"); if (e.carParking) tags.push("🚗 Car");

View file

@ -242,6 +242,7 @@ export default function ScannerScreen() {
{phase === "success" && ticket && ( {phase === "success" && ticket && (
<ResultOverlay status="success" onDismiss={resume}> <ResultOverlay status="success" onDismiss={resume}>
<Text style={styles.bigIcon}></Text> <Text style={styles.bigIcon}></Text>
<TypeBadge type={ticket.ticketType} />
{isIce ? ( {isIce ? (
<> <>
<Text style={styles.bigTitle}> <Text style={styles.bigTitle}>
@ -366,6 +367,25 @@ function ExtrasRow({ ticket }: { ticket: TicketView }) {
); );
} }
const TYPE_ICON: Record<string, string> = {
Guest: "🎫",
Worker: "🛠️",
Performer: "🎭",
Volunteer: "🙌",
Speaker: "🎤",
};
function TypeBadge({ type }: { type: string }) {
if (!type) return null;
return (
<View style={styles.typeBadge}>
<Text style={styles.typeBadgeText}>
{(TYPE_ICON[type] ?? "🎫") + " " + type.toUpperCase()}
</Text>
</View>
);
}
function AdultNames({ names }: { names: string[] }) { function AdultNames({ names }: { names: string[] }) {
if (!names.length) return null; if (!names.length) return null;
return ( return (
@ -405,6 +425,7 @@ function ConfirmCard({
return ( return (
<ScrollView style={styles.card} contentContainerStyle={styles.cardContent}> <ScrollView style={styles.card} contentContainerStyle={styles.cardContent}>
<Text style={styles.cardName}>{ticket.name}</Text> <Text style={styles.cardName}>{ticket.name}</Text>
<TypeBadge type={ticket.ticketType} />
<Text style={styles.cardCode}>{ticket.code}</Text> <Text style={styles.cardCode}>{ticket.code}</Text>
<Text style={styles.cardCounts}> <Text style={styles.cardCounts}>
<Text style={{ color: theme.successBright, fontWeight: "800" }}>{remaining}</Text> of {total} {unit} remaining <Text style={{ color: theme.successBright, fontWeight: "800" }}>{remaining}</Text> of {total} {unit} remaining
@ -507,6 +528,14 @@ const styles = StyleSheet.create({
donorFigureDivider: { width: 1, alignSelf: "stretch", backgroundColor: "rgba(255,255,255,0.35)", marginVertical: 8 }, donorFigureDivider: { width: 1, alignSelf: "stretch", backgroundColor: "rgba(255,255,255,0.35)", marginVertical: 8 },
donorEmail: { color: "rgba(255,255,255,0.85)", fontSize: 14, marginTop: 18 }, donorEmail: { color: "rgba(255,255,255,0.85)", fontSize: 14, marginTop: 18 },
typeBadge: {
backgroundColor: "rgba(255,255,255,0.22)",
borderRadius: 999,
paddingHorizontal: 18,
paddingVertical: 8,
marginTop: 10,
},
typeBadgeText: { color: "#fff", fontSize: 20, fontWeight: "900", letterSpacing: 1 },
namesBox: { marginTop: 12, alignItems: "center", gap: 3 }, namesBox: { marginTop: 12, alignItems: "center", gap: 3 },
nameLine: { color: "#fff", fontSize: 18, fontWeight: "600", textAlign: "center" }, nameLine: { color: "#fff", fontSize: 18, fontWeight: "600", textAlign: "center" },
tags: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, marginTop: 14 }, tags: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, marginTop: 14 },

View file

@ -21,6 +21,7 @@ export interface TicketView {
code: string; code: string;
name: string; name: string;
email: string; email: string;
ticketType: string;
total: number; total: number;
redeemed: number; redeemed: number;
remaining: number; remaining: number;

View file

@ -48,6 +48,8 @@ const schema = z.object({
WEBHOOK_SECRET: z.string().min(1), WEBHOOK_SECRET: z.string().min(1),
EVENT_PIN: z.string().min(1), EVENT_PIN: z.string().min(1),
// Shared password for the /crush33 comp-ticket portal (workers/guests).
PORTAL_PASSWORD: z.string().optional(),
TOKEN_SECRET: z.string().min(16), TOKEN_SECRET: z.string().min(16),
TOKEN_TTL: z.string().default("30d"), TOKEN_TTL: z.string().default("30d"),

View file

@ -24,6 +24,7 @@ export const COL = {
utv: "UTV", utv: "UTV",
iceAccess: "Ice Access", iceAccess: "Ice Access",
paymentMethod: "Payment Method", paymentMethod: "Payment Method",
ticketType: "Ticket Type", // "" for regular; Guest/Worker/Performer/Volunteer/Speaker for portal comps
// Columns this system manages: // Columns this system manages:
code: "Ticket Code", code: "Ticket Code",
@ -94,6 +95,7 @@ export interface TicketView {
code: string; code: string;
name: string; name: string;
email: string; email: string;
ticketType: string; // "" for regular; Guest/Worker/... for special tickets
total: number; total: number;
redeemed: number; redeemed: number;
remaining: number; remaining: number;
@ -121,6 +123,7 @@ export function toView(rec: NocoRecord): TicketView {
code: String(rec[COL.code] ?? ""), code: String(rec[COL.code] ?? ""),
name: String(rec[COL.name] ?? ""), name: String(rec[COL.name] ?? ""),
email: String(rec[COL.email] ?? ""), email: String(rec[COL.email] ?? ""),
ticketType: String(rec[COL.ticketType] ?? ""),
total, total,
redeemed, redeemed,
remaining: Math.max(0, total - redeemed), remaining: Math.max(0, total - redeemed),

View file

@ -0,0 +1,170 @@
import { timingSafeEqual } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { createTicket } from "../ticketService.js";
import { renderQrPng, renderQrDataUrl } from "../services/qrcode.js";
const TYPES = ["Guest", "Worker", "Performer", "Volunteer", "Speaker"];
function safeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a || "");
const bb = Buffer.from(b || "");
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}
/**
* /crush33 password-gated comp-ticket portal for admins. Creates entry-only
* tickets (1 admission, no demographics/ice) with a category (Guest/Worker/)
* that shows on the scanner. Password checked server-side per request.
*/
export async function portalRoutes(app: FastifyInstance): Promise<void> {
app.get("/crush33", async (_req, reply) => {
reply.type("text/html").send(PAGE);
});
app.post(
"/api/portal/create-ticket",
{ config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const cfg = app.ctx.config;
if (!cfg.PORTAL_PASSWORD) return reply.code(404).send({ error: "portal_disabled" });
const b = (req.body ?? {}) as { password?: string; name?: string; email?: string; type?: string };
if (!b.password || !safeEqual(b.password, cfg.PORTAL_PASSWORD)) {
return reply.code(401).send({ error: "bad_password" });
}
const name = String(b.name ?? "").trim();
const email = String(b.email ?? "").trim();
const type = TYPES.includes(String(b.type)) ? String(b.type) : "Guest";
if (!name || !email) {
return reply.code(400).send({ error: "missing_fields", detail: "name and email are required" });
}
let result: Awaited<ReturnType<typeof createTicket>>;
try {
result = await createTicket(app.ctx, {
name,
adultNames: [name],
email,
ticketType: type,
counts: { adults: 1, youth: 0, kids12: 0, kids9: 0, kids4: 0 },
submissionKey: `portal:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`,
});
} catch (e: any) {
req.log.error({ err: e }, "portal: create failed");
return reply.code(502).send({ error: "db_error", detail: e?.message });
}
// Email the QR (best-effort — the portal also shows it on-screen).
let emailSent = false;
if (!app.ctx.mailer.isBlockedRecipient(email)) {
try {
const png = await renderQrPng(result.code);
await app.ctx.mailer.sendTicket({ toEmail: email, toName: name, code: result.code, quantity: 1, qrPng: png });
emailSent = true;
} catch (e: any) {
req.log.error({ err: e, code: result.code }, "portal: email failed");
}
}
const qr = await renderQrDataUrl(result.code);
return { ok: true, code: result.code, type, name, emailSent, qr };
},
);
}
const PAGE = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0f1a12" />
<title>Camp Scan Comp Tickets</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; background: #0f1a12; color: #eaf2ec; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; }
.wrap { max-width: 460px; margin: 0 auto; padding: 28px 20px 64px; }
header { text-align: center; margin-bottom: 22px; }
.logo { font-size: 52px; }
h1 { font-size: 22px; margin: 8px 0 2px; }
.sub { color: #9db3a4; font-size: 14px; margin: 0; }
label { display: block; font-size: 13px; color: #9db3a4; margin: 14px 0 5px; }
input, select { width: 100%; background: #16241a; border: 1px solid #24382a; border-radius: 12px; padding: 14px; color: #eaf2ec; font-size: 16px; }
button { width: 100%; background: #25c05a; color: #06210f; font-weight: 800; font-size: 18px; border: none; padding: 15px; border-radius: 13px; margin-top: 18px; }
button:disabled { opacity: 0.5; }
.msg { margin-top: 14px; font-size: 15px; font-weight: 600; text-align: center; min-height: 20px; }
.err { color: #e04343; }
.ok { color: #58d68d; }
.result { display: none; text-align: center; margin-top: 18px; background: #16241a; border: 1px solid #24382a; border-radius: 14px; padding: 18px; }
.result img { width: 220px; height: 220px; background: #fff; border-radius: 10px; padding: 8px; }
.result .code { font-family: ui-monospace, Menlo, monospace; font-size: 20px; letter-spacing: 2px; margin: 12px 0 4px; color: #58d68d; }
.result .who { font-size: 16px; color: #c4d6c9; }
.hint { color: #6c8f74; font-size: 12px; text-align: center; margin-top: 10px; }
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="logo">🐻</div>
<h1>Comp Ticket Portal</h1>
<p class="sub">Entry-only tickets for workers &amp; guests</p>
</header>
<label>Portal password</label>
<input id="pw" type="password" autocomplete="current-password" placeholder="Shared admin password" />
<label>Ticket type</label>
<select id="type">
<option>Guest</option><option>Worker</option><option>Performer</option>
<option>Volunteer</option><option>Speaker</option>
</select>
<label>Full name</label>
<input id="name" type="text" autocomplete="off" placeholder="Attendee name" />
<label>Email</label>
<input id="email" type="email" autocomplete="off" autocapitalize="none" placeholder="Where to send the ticket" />
<button id="go">Create ticket</button>
<div id="msg" class="msg"></div>
<div id="result" class="result">
<img id="qr" alt="Ticket QR" />
<div class="code" id="rcode"></div>
<div class="who" id="rwho"></div>
<div class="hint" id="rmail"></div>
<button id="another" style="background:transparent;color:#eaf2ec;border:1px solid #2e7d32;font-size:15px;">Create another</button>
</div>
</div>
<script>
var $ = function (id) { return document.getElementById(id); };
function setMsg(t, ok) { var m = $("msg"); m.textContent = t; m.className = "msg " + (ok ? "ok" : "err"); }
$("go").addEventListener("click", function () {
var pw = $("pw").value, name = $("name").value.trim(), email = $("email").value.trim(), type = $("type").value;
if (!pw) return setMsg("Enter the portal password.");
if (!name || !email) return setMsg("Name and email are required.");
$("go").disabled = true; setMsg("Creating…", true);
fetch("/api/portal/create-ticket", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: pw, name: name, email: email, type: type })
}).then(function (r) { return r.json().then(function (d) { return { s: r.status, d: d }; }); })
.then(function (x) {
$("go").disabled = false;
if (x.s === 401) return setMsg("Wrong password.");
if (x.s !== 200 || !x.d.ok) return setMsg(x.d.detail || x.d.error || "Failed to create ticket.");
setMsg("");
$("qr").src = x.d.qr; $("rcode").textContent = x.d.code;
$("rwho").textContent = x.d.type + " · " + x.d.name;
$("rmail").textContent = x.d.emailSent ? "Emailed to " + email : "Email not sent — show/screenshot this QR.";
$("result").style.display = "block";
$("name").value = ""; $("email").value = "";
})
.catch(function () { $("go").disabled = false; setMsg("Network error."); });
});
$("another").addEventListener("click", function () { $("result").style.display = "none"; $("name").focus(); });
</script>
</body>
</html>`;

View file

@ -15,6 +15,7 @@ interface Persona {
utv?: boolean; utv?: boolean;
isDonor?: boolean; isDonor?: boolean;
donorTier?: string; donorTier?: string;
ticketType?: string;
exhaust?: boolean; // pre-redeem all tickets so it scans as "exhausted" exhaust?: boolean; // pre-redeem all tickets so it scans as "exhausted"
blurb: string; blurb: string;
} }
@ -72,6 +73,24 @@ const PERSONAS: Persona[] = [
exhaust: true, exhaust: true,
blurb: "2 tickets, already fully redeemed. Check-in mode → red 'exhausted'.", blurb: "2 tickets, already fully redeemed. Check-in mode → red 'exhausted'.",
}, },
{
key: "worker",
name: "Wanda Worker",
email: "worker@test.beartaria",
adultNames: ["Wanda Worker"],
counts: C(1),
ticketType: "Worker",
blurb: "Entry-only WORKER comp ticket. Check-in mode → green with a Worker badge.",
},
{
key: "performer",
name: "Perry Performer",
email: "performer@test.beartaria",
adultNames: ["Perry Performer"],
counts: C(1),
ticketType: "Performer",
blurb: "Entry-only PERFORMER comp ticket. Check-in mode → green with a Performer badge.",
},
]; ];
const INVALID_CODE = "BC26-0000-0000"; // not in the DB → scans as "not found" const INVALID_CODE = "BC26-0000-0000"; // not in the DB → scans as "not found"
@ -94,6 +113,7 @@ export async function testRoutes(app: FastifyInstance): Promise<void> {
utv: p.utv, utv: p.utv,
isDonor: p.isDonor, isDonor: p.isDonor,
donorTier: p.donorTier, donorTier: p.donorTier,
ticketType: p.ticketType,
submissionKey: `test:${p.key}`, submissionKey: `test:${p.key}`,
}); });
// Keep the "exhausted" persona fully redeemed on every load so its state // Keep the "exhausted" persona fully redeemed on every load so its state

View file

@ -14,6 +14,7 @@ import { testRoutes } from "./routes/test.js";
import { installRoutes } from "./routes/install.js"; import { installRoutes } from "./routes/install.js";
import { webhookDocRoutes } from "./routes/webhookDoc.js"; import { webhookDocRoutes } from "./routes/webhookDoc.js";
import { publicLookupRoutes } from "./routes/publicLookup.js"; import { publicLookupRoutes } from "./routes/publicLookup.js";
import { portalRoutes } from "./routes/portal.js";
export async function build() { export async function build() {
const config = loadConfig(); const config = loadConfig();
@ -36,6 +37,7 @@ export async function build() {
await app.register(installRoutes); await app.register(installRoutes);
await app.register(webhookDocRoutes); await app.register(webhookDocRoutes);
await app.register(publicLookupRoutes); await app.register(publicLookupRoutes);
await app.register(portalRoutes);
// Serve the exported Expo web build (if present) with SPA fallback. // Serve the exported Expo web build (if present) with SPA fallback.
const webDir = config.WEB_DIR ?? join(process.cwd(), "web"); const webDir = config.WEB_DIR ?? join(process.cwd(), "web");

View file

@ -130,6 +130,7 @@ export interface WebhookInput {
name: string; name: string;
adultNames?: string[]; adultNames?: string[];
email: string; email: string;
ticketType?: string; // Guest/Worker/Performer/Volunteer/Speaker for portal comps
address?: string; address?: string;
isDonor?: boolean; isDonor?: boolean;
donorTier?: string; donorTier?: string;
@ -178,6 +179,7 @@ export async function createTicket(
[COL.submissionKey]: input.submissionKey, [COL.submissionKey]: input.submissionKey,
}; };
if (input.adultNames && input.adultNames.length) fields[COL.adultNames] = input.adultNames.join("\n"); if (input.adultNames && input.adultNames.length) fields[COL.adultNames] = input.adultNames.join("\n");
if (input.ticketType) fields[COL.ticketType] = input.ticketType;
if (input.address !== undefined) fields[COL.address] = input.address; if (input.address !== undefined) fields[COL.address] = input.address;
if (input.isDonor !== undefined) fields[COL.isDonor] = input.isDonor; if (input.isDonor !== undefined) fields[COL.isDonor] = input.isDonor;
if (input.donorTier !== undefined) fields[COL.donorTier] = input.donorTier; if (input.donorTier !== undefined) fields[COL.donorTier] = input.donorTier;