New form schema: up to 10 named adults, youth 13-16, kids 10-12 / 5-9 / 0-4, donor tier/vouchers (from the lookups), parking/RV/UTV/ice payment fields. - Scannable total = adults + youth + kids 10-12 + kids 5-9 (kids 0-4 free). - Store + display adult names on a good scan; show donor tier, UTV, vouchers. - Ice: payment_ice = 1-4 tickets ($20 each), 1 ticket = 3 bags. - New NocoDB 2026 schema (with Id PK); webhook parses compound names, quantity/payment fields (nested objects or money strings). - Our webhook keeps sending the QR ticket email (FluentForms sends the receipt); from address is now info@beartariacampgrounds.com. Updated /test personas, /webhook-doc, tests, and the app display. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
173 lines
5.7 KiB
TypeScript
173 lines
5.7 KiB
TypeScript
import type { FastifyInstance } from "fastify";
|
|
import { createTicket } from "../ticketService.js";
|
|
import { renderQrDataUrl } from "../services/qrcode.js";
|
|
import { COL } from "../fields.js";
|
|
|
|
interface Persona {
|
|
key: string;
|
|
name: string;
|
|
email: string;
|
|
adultNames?: string[];
|
|
counts: { adults: number; youth: number; kids12: number; kids9: number; kids4: number };
|
|
iceBags?: number;
|
|
carParking?: boolean;
|
|
rvParking?: boolean;
|
|
utv?: boolean;
|
|
isDonor?: boolean;
|
|
donorTier?: string;
|
|
exhaust?: boolean; // pre-redeem all tickets so it scans as "exhausted"
|
|
blurb: string;
|
|
}
|
|
|
|
const C = (adults = 0, youth = 0, kids12 = 0, kids9 = 0, kids4 = 0) => ({ adults, youth, kids12, kids9, kids4 });
|
|
|
|
// A curated set covering the different attribute combinations to test.
|
|
const PERSONAS: Persona[] = [
|
|
{
|
|
key: "solo",
|
|
name: "Solo Sam",
|
|
email: "solo@test.beartaria",
|
|
adultNames: ["Solo Sam"],
|
|
counts: C(1),
|
|
blurb: "1 ticket, no extras. Check-in mode → green, 1/1.",
|
|
},
|
|
{
|
|
key: "family",
|
|
name: "Family Fay",
|
|
email: "family@test.beartaria",
|
|
adultNames: ["Family Fay", "Frank Fay"],
|
|
counts: C(2, 0, 0, 3, 2), // 2 adults + 3 kids(5-9) = 5 scannable; 2 kids 0-4 free
|
|
iceBags: 3,
|
|
carParking: true,
|
|
blurb:
|
|
"5 tickets (2 adults + 3 kids 5-9; two 0-4 free), car parking, 3 ice bags. Check-in a few at a time to test QR reuse + see adult names; then Ice mode.",
|
|
},
|
|
{
|
|
key: "donor2",
|
|
name: "Donor Dan",
|
|
email: "donor@example.test",
|
|
adultNames: ["Donor Dan", "Donna Dan"],
|
|
counts: C(2),
|
|
rvParking: true,
|
|
utv: true,
|
|
isDonor: true,
|
|
donorTier: "member",
|
|
blurb:
|
|
"2 tickets, RV + UTV, donor/member. Banquet mode: this test email has no real donations — use Banquet's manual email lookup with a real donor's address.",
|
|
},
|
|
{
|
|
key: "ice",
|
|
name: "Ice Ike",
|
|
email: "ice@test.beartaria",
|
|
adultNames: ["Ice Ike"],
|
|
counts: C(1),
|
|
iceBags: 6, // 2 ice tickets
|
|
blurb: "1 ticket + 6 ice bags (2 ice tickets). Ice mode → grab bags, then scan again → exhausted.",
|
|
},
|
|
{
|
|
key: "exhausted",
|
|
name: "Done Dora",
|
|
email: "done@test.beartaria",
|
|
counts: C(2),
|
|
exhaust: true,
|
|
blurb: "2 tickets, already fully redeemed. Check-in mode → red 'exhausted'.",
|
|
},
|
|
];
|
|
|
|
const INVALID_CODE = "BC26-0000-0000"; // not in the DB → scans as "not found"
|
|
|
|
export async function testRoutes(app: FastifyInstance): Promise<void> {
|
|
if (!app.ctx.config.ENABLE_TEST_PAGE) return;
|
|
|
|
app.get("/test", async (_req, reply) => {
|
|
const cards: { code: string; qr: string; name: string; blurb: string }[] = [];
|
|
|
|
for (const p of PERSONAS) {
|
|
const result = await createTicket(app.ctx, {
|
|
name: p.name,
|
|
adultNames: p.adultNames,
|
|
email: p.email,
|
|
counts: p.counts,
|
|
iceBags: p.iceBags,
|
|
carParking: p.carParking,
|
|
rvParking: p.rvParking,
|
|
utv: p.utv,
|
|
isDonor: p.isDonor,
|
|
donorTier: p.donorTier,
|
|
submissionKey: `test:${p.key}`,
|
|
});
|
|
// Keep the "exhausted" persona fully redeemed on every load so its state
|
|
// is deterministic (total = scannable count from the persona's counts).
|
|
if (p.exhaust) {
|
|
const { adults, youth, kids12, kids9 } = p.counts;
|
|
await app.ctx.nocodb.update(result.record.Id, { [COL.redeemed]: adults + youth + kids12 + kids9 });
|
|
}
|
|
cards.push({
|
|
code: result.code,
|
|
qr: await renderQrDataUrl(result.code),
|
|
name: p.name,
|
|
blurb: p.blurb,
|
|
});
|
|
}
|
|
|
|
// An invalid one to test the not-found path.
|
|
cards.push({
|
|
code: INVALID_CODE,
|
|
qr: await renderQrDataUrl(INVALID_CODE),
|
|
name: "Not a real ticket",
|
|
blurb: "Any mode → red 'not a valid ticket'.",
|
|
});
|
|
|
|
reply.type("text/html").send(renderPage(cards));
|
|
});
|
|
}
|
|
|
|
function esc(s: string): string {
|
|
return String(s).replace(/[&<>"]/g, (c) =>
|
|
c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """,
|
|
);
|
|
}
|
|
|
|
function renderPage(cards: { code: string; qr: string; name: string; blurb: string }[]): string {
|
|
const items = cards
|
|
.map(
|
|
(c) => `
|
|
<div class="card">
|
|
<img src="${c.qr}" alt="QR for ${esc(c.code)}" />
|
|
<div class="meta">
|
|
<div class="name">${esc(c.name)}</div>
|
|
<div class="code">${esc(c.code)}</div>
|
|
<div class="blurb">${esc(c.blurb)}</div>
|
|
</div>
|
|
</div>`,
|
|
)
|
|
.join("");
|
|
return `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Camp Scan — Test QR codes</title>
|
|
<style>
|
|
:root { color-scheme: dark; }
|
|
body { margin:0; background:#0f1a12; color:#eaf2ec; font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif; }
|
|
header { padding:20px 16px; background:#1b5e20; }
|
|
h1 { margin:0; font-size:20px; }
|
|
header p { margin:6px 0 0; font-size:14px; color:#cfe6d4; }
|
|
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:16px; padding:16px; }
|
|
.card { background:#16241a; border:1px solid #24382a; border-radius:14px; padding:16px; display:flex; gap:14px; align-items:center; }
|
|
.card img { width:120px; height:120px; background:#fff; border-radius:8px; padding:6px; flex:none; }
|
|
.name { font-weight:700; font-size:16px; }
|
|
.code { font-family:ui-monospace,Menlo,monospace; color:#9db3a4; font-size:13px; letter-spacing:1px; margin:2px 0 6px; }
|
|
.blurb { font-size:13px; color:#c4d6c9; line-height:1.4; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>🐻 Camp Scan — test tickets</h1>
|
|
<p>Open the app on another device and scan these. Switch modes (Check-in / Ice / Banquet) to test each. Reload to re-seed if a record was deleted.</p>
|
|
</header>
|
|
<div class="grid">${items}</div>
|
|
</body>
|
|
</html>`;
|
|
}
|