Add /crush33 comp-ticket portal + ticket-type badge on scan
Some checks failed
Build Android APK / build-apk (push) Failing after 1h11m4s
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:
parent
b7c77afe02
commit
4b78c82b35
9 changed files with 230 additions and 0 deletions
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
170
backend/src/routes/portal.ts
Normal file
170
backend/src/routes/portal.ts
Normal 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 & 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>`;
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue