Rework webhook + model for the 2026 Tickets form

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>
This commit is contained in:
Hank 2026-07-13 03:23:06 +00:00
parent 718d1515b0
commit b7c77afe02
14 changed files with 360 additions and 210 deletions

View file

@ -201,11 +201,14 @@ function TicketCard({ ticket, onAdjust }: { ticket: TicketView; onAdjust: (t: Ti
}, [ticket.redeemed, showHistory]);
const tags: string[] = [];
if (ticket.extras.carParking) tags.push("🚗 Car");
if (ticket.extras.rvParking) tags.push("🚐 RV");
if (ticket.extras.iceAccess) tags.push("🧊 Ice");
if (ticket.extras.isDonor) tags.push("⭐ Donor");
if (ticket.extras.freeUnder4 > 0) tags.push(`👶 ${ticket.extras.freeUnder4} free`);
const e = ticket.extras;
if (e.donorTier === "member") tags.push("🐻 Member");
else if (e.isDonor) tags.push("⭐ Donor");
if (e.carParking) tags.push("🚗 Car");
if (e.rvParking) tags.push("🚐 RV");
if (e.utv) tags.push("🏍️ UTV");
if (e.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total}`);
if (e.freeUnder5 > 0) tags.push(`👶 ${e.freeUnder5} free`);
return (
<View style={styles.card}>

View file

@ -259,6 +259,7 @@ export default function ScannerScreen() {
<Text style={styles.counts}>
{ticket.redeemed} of {ticket.total} redeemed · {ticket.remaining} remaining
</Text>
<AdultNames names={ticket.adultNames} />
<ExtrasRow ticket={ticket} />
</>
)}
@ -345,11 +346,14 @@ function BanquetResult({ donor, ticketName }: { donor: DonorLookup | null; ticke
function ExtrasRow({ ticket }: { ticket: TicketView }) {
const tags: string[] = [];
if (ticket.extras.carParking) tags.push("🚗 Car parking");
if (ticket.extras.rvParking) tags.push("🚐 RV parking");
if (ticket.extras.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total} ice`);
if (ticket.extras.isDonor) tags.push("⭐ Donor");
if (ticket.extras.freeUnder4 > 0) tags.push(`👶 ${ticket.extras.freeUnder4} under 4 (free)`);
const e = ticket.extras;
if (e.donorTier === "member") tags.push("🐻 Member");
else if (e.isDonor) tags.push("⭐ Donor");
if (e.carParking) tags.push("🚗 Car parking");
if (e.rvParking) tags.push("🚐 RV parking");
if (e.utv) tags.push("🏍️ UTV/ATV");
if (e.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total} ice`);
if (e.freeUnder5 > 0) tags.push(`👶 ${e.freeUnder5} under 5 (free)`);
if (!tags.length) return null;
return (
<View style={styles.tags}>
@ -362,6 +366,19 @@ function ExtrasRow({ ticket }: { ticket: TicketView }) {
);
}
function AdultNames({ names }: { names: string[] }) {
if (!names.length) return null;
return (
<View style={styles.namesBox}>
{names.map((n, i) => (
<Text key={i} style={styles.nameLine}>
{n}
</Text>
))}
</View>
);
}
function ConfirmCard({
ticket,
isIce,
@ -393,6 +410,7 @@ function ConfirmCard({
<Text style={{ color: theme.successBright, fontWeight: "800" }}>{remaining}</Text> of {total} {unit} remaining
</Text>
<Text style={styles.cardSub}>{redeemed} already redeemed</Text>
{!isIce && <AdultNames names={ticket.adultNames} />}
{!isIce && <ExtrasRow ticket={ticket} />}
{isIce && total === 0 && <Text style={styles.exhausted}>This ticket did not prepay for ice.</Text>}
@ -489,6 +507,8 @@ const styles = StyleSheet.create({
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 },
namesBox: { marginTop: 12, alignItems: "center", gap: 3 },
nameLine: { color: "#fff", fontSize: 18, fontWeight: "600", textAlign: "center" },
tags: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, marginTop: 14 },
tag: { color: "#fff", backgroundColor: "rgba(255,255,255,0.18)", paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, fontSize: 13, overflow: "hidden" },

View file

@ -25,12 +25,16 @@ export interface TicketView {
redeemed: number;
remaining: number;
ice: ResourceCount;
adultNames: string[];
extras: {
carParking: boolean;
rvParking: boolean;
utv: boolean;
iceAccess: boolean;
isDonor: boolean;
freeUnder4: number;
donorTier: string;
vouchers: number;
freeUnder5: number;
};
ages: { bracket: string; count: number; free: boolean }[];
}