Add Check-in / Ice / Banquet scan modes
- Ice mode: prepaid ice bags (Ice Total / Ice Redeemed columns) redeemed independently of ticket check-ins; grab all bags at once or some now. - Banquet mode: donor total (online + offline) looked up by the ticket's email via the Donors Master List, with a manual email override. New DonorService + POST /api/banquet. - Redeem generalized over a resource (tickets|ice); audit records ice actions. - App gains a mode selector; webhook maps ice_bags (defaults to ICE_BAGS_DEFAULT when only a boolean ice option is present). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b36d63a6a4
commit
d293e53ee6
15 changed files with 587 additions and 125 deletions
|
|
@ -12,6 +12,14 @@ NOCODB_TABLE_ID=
|
||||||
# Optional: table ID of "2026 Ticket Audit Logs". If unset, audit logging is skipped.
|
# Optional: table ID of "2026 Ticket Audit Logs". If unset, audit logging is skipped.
|
||||||
NOCODB_AUDIT_TABLE_ID=
|
NOCODB_AUDIT_TABLE_ID=
|
||||||
|
|
||||||
|
# Banquet mode — donor tables (optional; if the master list is unset, banquet is disabled)
|
||||||
|
NOCODB_DONORS_TABLE_ID=
|
||||||
|
NOCODB_DONOR_ONLINE_TABLE_ID=
|
||||||
|
NOCODB_DONOR_OFFLINE_TABLE_ID=
|
||||||
|
|
||||||
|
# Ice bags granted when a purchase includes ice but the webhook sends only a boolean
|
||||||
|
ICE_BAGS_DEFAULT=3
|
||||||
|
|
||||||
# MailerSend
|
# MailerSend
|
||||||
MAILERSEND_API_TOKEN=
|
MAILERSEND_API_TOKEN=
|
||||||
MAIL_FROM_EMAIL=tickets@beartariacampgrounds.com
|
MAIL_FROM_EMAIL=tickets@beartariacampgrounds.com
|
||||||
|
|
|
||||||
|
|
@ -53,5 +53,9 @@ Android users can also "Add to Home Screen" from Chrome instead of using Obtaini
|
||||||
|
|
||||||
## Using it
|
## Using it
|
||||||
|
|
||||||
- **Scan:** point the camera at a ticket QR. Pick how many people are entering now (a family can split arrivals across the day on one code), then **Check in**. Green + chime = success and the count is saved; red + buzz = a problem (already fully redeemed, not a valid ticket, or a network/database error — the reason is shown).
|
Pick a **mode** with the buttons under the title:
|
||||||
- **Admin** (top-right): if scanning won't work, search by **name, email, or ticket code**, then use the buttons to check people in or undo a mistaken check-in.
|
|
||||||
|
- **Check-in** — scan a ticket QR, pick how many people are entering now (a family can split arrivals across the day on one code), then **Check in**. Green + chime = saved; red + buzz = a problem (already fully redeemed, not a valid ticket, or a network/database error — the reason is shown).
|
||||||
|
- **Ice** — scan a ticket, hand out prepaid ice bags (defaults to all remaining; adjust if they only want some).
|
||||||
|
- **Banquet** — scan a ticket to see that buyer's total donations, or type an email and tap **Look up**.
|
||||||
|
- **Admin** (top-right): if scanning won't work, search by **name, email, or ticket code**, use the buttons to check people in or undo, and view check-in history.
|
||||||
|
|
|
||||||
29
README.md
29
README.md
|
|
@ -3,7 +3,10 @@
|
||||||
End-to-end ticketing for the 2026 event:
|
End-to-end ticketing for the 2026 event:
|
||||||
|
|
||||||
1. **Purchase** — a FluentForms checkout on `tickets.beartariacampgrounds.com` POSTs a webhook to this backend, which writes a row to NocoDB, generates a unique ticket **QR code**, and emails it to the buyer via MailerSend (subject *"2026 Beartaria Campgrounds Tickets"*).
|
1. **Purchase** — a FluentForms checkout on `tickets.beartariacampgrounds.com` POSTs a webhook to this backend, which writes a row to NocoDB, generates a unique ticket **QR code**, and emails it to the buyer via MailerSend (subject *"2026 Beartaria Campgrounds Tickets"*).
|
||||||
2. **Gate** — staff scan the QR with **Camp Scan** (Android app + iPhone PWA, one Expo codebase). It validates the code, lets staff check in however many people are arriving on that visit, decrements the remaining count in NocoDB, and flashes **green + chime** / **red + buzz** with the name, counts, extras (parking/ice), and DB-update confirmation.
|
2. **Gate** — staff scan the QR with **Camp Scan** (Android app + iPhone PWA, one Expo codebase). It flashes **green + chime** / **red + buzz** with name, counts, extras, and DB-update confirmation. Three scan modes:
|
||||||
|
- **Check-in** — check in however many people are arriving on that visit; decrements the ticket count.
|
||||||
|
- **Ice** — hand out prepaid ice bags (all at once or some now); decrements a separate ice count.
|
||||||
|
- **Banquet** — show the scanned buyer's total donations (online + offline), looked up by their email; also supports a manual email lookup.
|
||||||
3. **Admin** — a panel in the same app for manual lookup by name/email/code and button-based check-in/undo when scanning fails.
|
3. **Admin** — a panel in the same app for manual lookup by name/email/code and button-based check-in/undo when scanning fails.
|
||||||
|
|
||||||
One QR per purchase is **reusable across visits** until all its tickets are redeemed (e.g. a family arriving in two groups on one code). Children under 4 are free and not counted.
|
One QR per purchase is **reusable across visits** until all its tickets are redeemed (e.g. a family arriving in two groups on one code). Children under 4 are free and not counted.
|
||||||
|
|
@ -45,8 +48,10 @@ The app expects the **2026 Campground Tickets** table to be a clone of the 2025
|
||||||
| `Redeemed` | Number (default 0) |
|
| `Redeemed` | Number (default 0) |
|
||||||
| `SubmissionKey` | SingleLineText |
|
| `SubmissionKey` | SingleLineText |
|
||||||
| `LastScanAt` | DateTime |
|
| `LastScanAt` | DateTime |
|
||||||
|
| `Ice Total` | Number (prepaid ice bags) |
|
||||||
|
| `Ice Redeemed` | Number (default 0) |
|
||||||
|
|
||||||
Total redeemable tickets = sum of the age-bracket columns **excluding `Ages 0-3`** (free). Column names are mapped in [`backend/src/fields.ts`](./backend/src/fields.ts) — change them there if the real titles differ. Put the table's ID (right-click table → *Copy Table ID*) in `NOCODB_TABLE_ID`.
|
Total redeemable tickets = sum of the age-bracket columns **excluding `Ages 0-3`** (free). Ice bags remaining = `Ice Total − Ice Redeemed`. Column names are mapped in [`backend/src/fields.ts`](./backend/src/fields.ts) — change them there if the real titles differ. Put the table's ID (right-click table → *Copy Table ID*) in `NOCODB_TABLE_ID`.
|
||||||
|
|
||||||
### Audit log table — "2026 Ticket Audit Logs"
|
### Audit log table — "2026 Ticket Audit Logs"
|
||||||
|
|
||||||
|
|
@ -62,7 +67,19 @@ Every check-in and undo is recorded to a separate table so the crew can review w
|
||||||
| `Name` | SingleLineText |
|
| `Name` | SingleLineText |
|
||||||
| `Remaining After` | Number |
|
| `Remaining After` | Number |
|
||||||
|
|
||||||
Put its table ID in `NOCODB_AUDIT_TABLE_ID`. Leave the var empty to disable audit logging (check-ins still work). The admin panel shows global recent activity and per-ticket history from this table.
|
Put its table ID in `NOCODB_AUDIT_TABLE_ID`. Leave the var empty to disable audit logging (check-ins still work). The admin panel shows global recent activity and per-ticket history from this table. Ice pickups are logged too (actions `ice` / `ice-undo`).
|
||||||
|
|
||||||
|
### Banquet mode — donor tables
|
||||||
|
|
||||||
|
Banquet mode reads existing donor tables (no new columns). Set these table IDs:
|
||||||
|
|
||||||
|
| Var | Table |
|
||||||
|
|---|---|
|
||||||
|
| `NOCODB_DONORS_TABLE_ID` | Donors Master List (authoritative `Total Donations` / `Total Online Donations` / `Total Offline Donations`, matched by `Email` or `Alternate Email`) |
|
||||||
|
| `NOCODB_DONOR_ONLINE_TABLE_ID` | Donor Online Transactions (fallback sum of `Donation Amount` by email) |
|
||||||
|
| `NOCODB_DONOR_OFFLINE_TABLE_ID` | Donor Offline Transactions (fallback) |
|
||||||
|
|
||||||
|
If `NOCODB_DONORS_TABLE_ID` is unset, banquet mode is disabled. Column names are mapped in [`backend/src/services/donors.ts`](./backend/src/services/donors.ts).
|
||||||
|
|
||||||
> `CampTickets TEST` and `CampTickets Audit TEST` tables already exist in NocoDB for testing. Point `NOCODB_TABLE_ID` / `NOCODB_AUDIT_TABLE_ID` at them for dry runs, then switch to the real 2026 tables for production.
|
> `CampTickets TEST` and `CampTickets Audit TEST` tables already exist in NocoDB for testing. Point `NOCODB_TABLE_ID` / `NOCODB_AUDIT_TABLE_ID` at them for dry runs, then switch to the real 2026 tables for production.
|
||||||
|
|
||||||
|
|
@ -150,6 +167,7 @@ On the ticket form: **Settings & Integrations → Webhook → Add Webhook**.
|
||||||
| `submission_id` | the entry/submission ID (for idempotency; content-hash fallback if omitted) |
|
| `submission_id` | the entry/submission ID (for idempotency; content-hash fallback if omitted) |
|
||||||
| `ages_0_3`, `ages_4_7`, `ages_8_12`, `ages_13_17`, `ages_18_25`, `ages_26_45`, `ages_46_64`, `ages_65` | headcount per bracket |
|
| `ages_0_3`, `ages_4_7`, `ages_8_12`, `ages_13_17`, `ages_18_25`, `ages_26_45`, `ages_46_64`, `ages_65` | headcount per bracket |
|
||||||
| `car_parking`, `rv_parking`, `ice_access`, `is_donor` | yes/no or 1/0 |
|
| `car_parking`, `rv_parking`, `ice_access`, `is_donor` | yes/no or 1/0 |
|
||||||
|
| `ice_bags` | prepaid ice bag count (optional; if omitted and `ice_access` is truthy, defaults to `ICE_BAGS_DEFAULT`) |
|
||||||
| `address`, `payment_method` | optional |
|
| `address`, `payment_method` | optional |
|
||||||
|
|
||||||
On success the buyer receives the QR email. Re-sends of the same submission are idempotent (no duplicate rows/emails). If an email fails, the row is still created and returns HTTP 502 (visible in FluentForms' log); re-send later with `POST /api/tickets/{code}/resend-email` (staff-auth'd).
|
On success the buyer receives the QR email. Re-sends of the same submission are idempotent (no duplicate rows/emails). If an email fails, the row is still created and returns HTTP 502 (visible in FluentForms' log); re-send later with `POST /api/tickets/{code}/resend-email` (staff-auth'd).
|
||||||
|
|
@ -174,8 +192,9 @@ The runner runs jobs in a `node:22-bookworm` container and installs the Android
|
||||||
|---|---|
|
|---|---|
|
||||||
| `POST /api/auth/login` `{pin}` | Exchange PIN for a token |
|
| `POST /api/auth/login` `{pin}` | Exchange PIN for a token |
|
||||||
| `POST /webhook` (secret header) | FluentForms purchase → create ticket + email |
|
| `POST /webhook` (secret header) | FluentForms purchase → create ticket + email |
|
||||||
| `POST /api/lookup` `{code}` | Read a ticket by code (no mutation) |
|
| `POST /api/lookup` `{code}` | Read a ticket by code (includes ice counts; no mutation) |
|
||||||
| `POST /api/redeem` `{code, count}` | Check in `count` people (negative undoes); serialized per code |
|
| `POST /api/redeem` `{code, count, resource?}` | Redeem `count` of `resource` (`tickets` default, or `ice`); negative undoes; serialized per code+resource |
|
||||||
|
| `POST /api/banquet` `{code?\|email?}` | Donor total for a scanned ticket's email or a manual email |
|
||||||
| `GET /api/tickets?q=` | Search by name/email or exact code |
|
| `GET /api/tickets?q=` | Search by name/email or exact code |
|
||||||
| `GET /api/audit?code=&limit=` | Recent check-in log (all, or one code) |
|
| `GET /api/audit?code=&limit=` | Recent check-in log (all, or one code) |
|
||||||
| `POST /api/tickets/{code}/resend-email` | Re-send the QR email |
|
| `POST /api/tickets/{code}/resend-email` | Re-send the QR email |
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,32 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { StyleSheet, View, Text, Pressable, ScrollView } from "react-native";
|
import { StyleSheet, View, Text, Pressable, ScrollView, TextInput } from "react-native";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
import QRScanner from "../components/QRScanner";
|
import QRScanner from "../components/QRScanner";
|
||||||
import ResultOverlay, { OverlayStatus } from "../components/ResultOverlay";
|
import ResultOverlay from "../components/ResultOverlay";
|
||||||
import { lookup, redeem, logout, type TicketView } from "../lib/api";
|
import { lookup, redeem, banquet, logout, type TicketView, type DonorLookup } from "../lib/api";
|
||||||
import { feedbackSuccess, feedbackError } from "../lib/feedback";
|
import { feedbackSuccess, feedbackError } from "../lib/feedback";
|
||||||
import { theme } from "../lib/theme";
|
import { theme } from "../lib/theme";
|
||||||
|
|
||||||
type Phase = "scanning" | "busy" | "confirm" | "success" | "error";
|
type Mode = "tickets" | "ice" | "banquet";
|
||||||
|
type Phase = "scanning" | "busy" | "confirm" | "success" | "error" | "banquet";
|
||||||
|
|
||||||
|
const MODES: { key: Mode; label: string; icon: string }[] = [
|
||||||
|
{ key: "tickets", label: "Check-in", icon: "🎟️" },
|
||||||
|
{ key: "ice", label: "Ice", icon: "🧊" },
|
||||||
|
{ key: "banquet", label: "Banquet", icon: "🍽️" },
|
||||||
|
];
|
||||||
|
|
||||||
export default function ScannerScreen() {
|
export default function ScannerScreen() {
|
||||||
|
const [mode, setMode] = useState<Mode>("tickets");
|
||||||
const [phase, setPhase] = useState<Phase>("scanning");
|
const [phase, setPhase] = useState<Phase>("scanning");
|
||||||
const [ticket, setTicket] = useState<TicketView | null>(null);
|
const [ticket, setTicket] = useState<TicketView | null>(null);
|
||||||
const [count, setCount] = useState(1);
|
const [count, setCount] = useState(1);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [checkedIn, setCheckedIn] = useState(0);
|
const [checkedIn, setCheckedIn] = useState(0);
|
||||||
|
const [donor, setDonor] = useState<DonorLookup | null>(null);
|
||||||
|
const [donorTicketName, setDonorTicketName] = useState("");
|
||||||
|
const [manualEmail, setManualEmail] = useState("");
|
||||||
const resumeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const resumeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const resume = useCallback(() => {
|
const resume = useCallback(() => {
|
||||||
|
|
@ -24,12 +35,25 @@ export default function ScannerScreen() {
|
||||||
setMessage("");
|
setMessage("");
|
||||||
setCheckedIn(0);
|
setCheckedIn(0);
|
||||||
setCount(1);
|
setCount(1);
|
||||||
|
setDonor(null);
|
||||||
|
setDonorTicketName("");
|
||||||
setPhase("scanning");
|
setPhase("scanning");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => () => {
|
const switchMode = useCallback(
|
||||||
if (resumeTimer.current) clearTimeout(resumeTimer.current);
|
(m: Mode) => {
|
||||||
}, []);
|
setMode(m);
|
||||||
|
resume();
|
||||||
|
},
|
||||||
|
[resume],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (resumeTimer.current) clearTimeout(resumeTimer.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const showError = useCallback((msg: string) => {
|
const showError = useCallback((msg: string) => {
|
||||||
feedbackError();
|
feedbackError();
|
||||||
|
|
@ -37,46 +61,73 @@ export default function ScannerScreen() {
|
||||||
setPhase("error");
|
setPhase("error");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleScan = useCallback(
|
const runBanquet = useCallback(
|
||||||
async (raw: string) => {
|
async (input: { code?: string; email?: string }) => {
|
||||||
setPhase("busy");
|
setPhase("busy");
|
||||||
try {
|
try {
|
||||||
const res = await lookup(raw);
|
const res = await banquet(input);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
showError(`Database error: ${res.detail}`);
|
const msgs: Record<string, string> = {
|
||||||
|
not_found: "That ticket wasn't found.",
|
||||||
|
no_email: "No email to look up.",
|
||||||
|
banquet_disabled: "Banquet lookup isn't configured.",
|
||||||
|
db_error: `Database error: ${res.detail ?? ""}`,
|
||||||
|
};
|
||||||
|
showError(msgs[res.reason] ?? "Lookup failed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!res.found) {
|
setDonor(res.donor);
|
||||||
showError(`Not a valid ticket:\n${raw.slice(0, 40)}`);
|
setDonorTicketName(res.ticketName);
|
||||||
return;
|
if (res.donor.found) feedbackSuccess();
|
||||||
}
|
else feedbackError();
|
||||||
setTicket(res.ticket);
|
setPhase("banquet");
|
||||||
setCount(Math.min(1, res.ticket.remaining));
|
|
||||||
setPhase("confirm");
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.name === "AuthError") {
|
if (e?.name === "AuthError") return router.replace("/login");
|
||||||
router.replace("/login");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showError(e?.message ?? "Lookup failed");
|
showError(e?.message ?? "Lookup failed");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[showError],
|
[showError],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCheckIn = useCallback(async () => {
|
const handleScan = useCallback(
|
||||||
|
async (raw: string) => {
|
||||||
|
if (mode === "banquet") {
|
||||||
|
runBanquet({ code: raw });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPhase("busy");
|
||||||
|
try {
|
||||||
|
const res = await lookup(raw);
|
||||||
|
if (!res.ok) return showError(`Database error: ${res.detail}`);
|
||||||
|
if (!res.found) return showError(`Not a valid ticket:\n${raw.slice(0, 40)}`);
|
||||||
|
const remaining = mode === "ice" ? res.ticket.ice.remaining : res.ticket.remaining;
|
||||||
|
setTicket(res.ticket);
|
||||||
|
// Ice: default to grabbing all remaining bags at once. Tickets: default 1.
|
||||||
|
setCount(mode === "ice" ? Math.max(1, remaining) : Math.min(1, remaining));
|
||||||
|
setPhase("confirm");
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.name === "AuthError") return router.replace("/login");
|
||||||
|
showError(e?.message ?? "Lookup failed");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mode, runBanquet, showError],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRedeem = useCallback(async () => {
|
||||||
if (!ticket || count < 1) return;
|
if (!ticket || count < 1) return;
|
||||||
setPhase("busy");
|
setPhase("busy");
|
||||||
try {
|
try {
|
||||||
const res = await redeem(ticket.code, count);
|
const res = await redeem(ticket.code, count, mode === "ice" ? "ice" : "tickets");
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
const remaining = mode === "ice" ? res.ticket?.ice.remaining ?? 0 : res.ticket?.remaining ?? 0;
|
||||||
|
const noun = mode === "ice" ? "ice bags" : "tickets";
|
||||||
const reasons: Record<string, string> = {
|
const reasons: Record<string, string> = {
|
||||||
exhausted: "All tickets on this code are already redeemed.",
|
exhausted: `All ${noun} on this code are already redeemed.`,
|
||||||
insufficient: `Only ${res.ticket?.remaining ?? 0} left on this ticket.`,
|
insufficient: `Only ${remaining} ${noun} left on this code.`,
|
||||||
not_found: "Ticket not found.",
|
not_found: "Ticket not found.",
|
||||||
db_error: `Database error: ${res.detail ?? ""}`,
|
db_error: `Database error: ${res.detail ?? ""}`,
|
||||||
};
|
};
|
||||||
showError(reasons[res.reason] ?? "Check-in failed");
|
showError(reasons[res.reason] ?? "Redemption failed");
|
||||||
if (res.ticket) setTicket(res.ticket);
|
if (res.ticket) setTicket(res.ticket);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -86,19 +137,19 @@ export default function ScannerScreen() {
|
||||||
setPhase("success");
|
setPhase("success");
|
||||||
resumeTimer.current = setTimeout(resume, 4000);
|
resumeTimer.current = setTimeout(resume, 4000);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.name === "AuthError") {
|
if (e?.name === "AuthError") return router.replace("/login");
|
||||||
router.replace("/login");
|
showError(e?.message ?? "Redemption failed");
|
||||||
return;
|
|
||||||
}
|
|
||||||
showError(e?.message ?? "Check-in failed");
|
|
||||||
}
|
}
|
||||||
}, [ticket, count, resume, showError]);
|
}, [ticket, count, mode, resume, showError]);
|
||||||
|
|
||||||
const doLogout = useCallback(async () => {
|
const doLogout = useCallback(async () => {
|
||||||
await logout();
|
await logout();
|
||||||
router.replace("/login");
|
router.replace("/login");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const isIce = mode === "ice";
|
||||||
|
const successNoun = isIce ? (checkedIn === 1 ? "bag of ice" : "bags of ice") : "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
|
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
|
||||||
<View style={styles.topbar}>
|
<View style={styles.topbar}>
|
||||||
|
|
@ -113,22 +164,43 @@ export default function ScannerScreen() {
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.modeBar}>
|
||||||
|
{MODES.map((m) => (
|
||||||
|
<Pressable
|
||||||
|
key={m.key}
|
||||||
|
style={[styles.modeBtn, mode === m.key && styles.modeBtnActive]}
|
||||||
|
onPress={() => switchMode(m.key)}
|
||||||
|
>
|
||||||
|
<Text style={[styles.modeText, mode === m.key && styles.modeTextActive]}>
|
||||||
|
{m.icon} {m.label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.scannerArea}>
|
<View style={styles.scannerArea}>
|
||||||
<QRScanner onScan={handleScan} active={phase === "scanning"} />
|
<QRScanner onScan={handleScan} active={phase === "scanning"} />
|
||||||
{phase === "scanning" && (
|
{phase === "scanning" && (
|
||||||
<View pointerEvents="none" style={styles.reticle}>
|
<View pointerEvents="none" style={styles.reticle}>
|
||||||
<View style={styles.reticleBox} />
|
<View style={styles.reticleBox} />
|
||||||
<Text style={styles.hint}>Point the camera at a ticket QR code</Text>
|
<Text style={styles.hint}>
|
||||||
|
{mode === "banquet"
|
||||||
|
? "Scan a ticket to see donation total"
|
||||||
|
: isIce
|
||||||
|
? "Scan a ticket to hand out ice"
|
||||||
|
: "Point the camera at a ticket QR code"}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{phase === "confirm" && ticket && (
|
{phase === "confirm" && ticket && (
|
||||||
<ResultOverlay status="neutral" onDismiss={undefined}>
|
<ResultOverlay status="neutral">
|
||||||
<ConfirmCard
|
<ConfirmCard
|
||||||
ticket={ticket}
|
ticket={ticket}
|
||||||
|
isIce={isIce}
|
||||||
count={count}
|
count={count}
|
||||||
setCount={setCount}
|
setCount={setCount}
|
||||||
onCheckIn={handleCheckIn}
|
onConfirm={handleRedeem}
|
||||||
onCancel={resume}
|
onCancel={resume}
|
||||||
/>
|
/>
|
||||||
</ResultOverlay>
|
</ResultOverlay>
|
||||||
|
|
@ -137,27 +209,43 @@ 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>
|
||||||
<Text style={styles.bigTitle}>Checked in {checkedIn}</Text>
|
{isIce ? (
|
||||||
<Text style={styles.name}>{ticket.name}</Text>
|
<>
|
||||||
<Text style={styles.counts}>
|
<Text style={styles.bigTitle}>
|
||||||
{ticket.redeemed} of {ticket.total} redeemed · {ticket.remaining} remaining
|
{checkedIn} {successNoun}
|
||||||
</Text>
|
</Text>
|
||||||
<ExtrasRow ticket={ticket} />
|
<Text style={styles.name}>{ticket.name}</Text>
|
||||||
|
<Text style={styles.counts}>
|
||||||
|
{ticket.ice.redeemed} of {ticket.ice.total} bags taken · {ticket.ice.remaining} left
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={styles.bigTitle}>Checked in {checkedIn}</Text>
|
||||||
|
<Text style={styles.name}>{ticket.name}</Text>
|
||||||
|
<Text style={styles.counts}>
|
||||||
|
{ticket.redeemed} of {ticket.total} redeemed · {ticket.remaining} remaining
|
||||||
|
</Text>
|
||||||
|
<ExtrasRow ticket={ticket} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Text style={styles.dbConfirm}>Database updated</Text>
|
<Text style={styles.dbConfirm}>Database updated</Text>
|
||||||
<Text style={styles.tapHint}>Tap to scan the next ticket</Text>
|
<Text style={styles.tapHint}>Tap to scan the next ticket</Text>
|
||||||
</ResultOverlay>
|
</ResultOverlay>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{phase === "banquet" && (
|
||||||
|
<ResultOverlay status={donor?.found ? "success" : "error"} onDismiss={resume}>
|
||||||
|
<BanquetResult donor={donor} ticketName={donorTicketName} />
|
||||||
|
<Text style={styles.tapHint}>Tap to scan again</Text>
|
||||||
|
</ResultOverlay>
|
||||||
|
)}
|
||||||
|
|
||||||
{phase === "error" && (
|
{phase === "error" && (
|
||||||
<ResultOverlay status="error" onDismiss={resume}>
|
<ResultOverlay status="error" onDismiss={resume}>
|
||||||
<Text style={styles.bigIcon}>✕</Text>
|
<Text style={styles.bigIcon}>✕</Text>
|
||||||
<Text style={styles.bigTitle}>Problem</Text>
|
<Text style={styles.bigTitle}>Problem</Text>
|
||||||
<Text style={styles.errorMsg}>{message}</Text>
|
<Text style={styles.errorMsg}>{message}</Text>
|
||||||
{ticket && (
|
|
||||||
<Text style={styles.counts}>
|
|
||||||
{ticket.name} · {ticket.remaining} remaining
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Text style={styles.tapHint}>Tap to try again</Text>
|
<Text style={styles.tapHint}>Tap to try again</Text>
|
||||||
</ResultOverlay>
|
</ResultOverlay>
|
||||||
)}
|
)}
|
||||||
|
|
@ -168,15 +256,68 @@ export default function ScannerScreen() {
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{mode === "banquet" && (phase === "scanning" || phase === "banquet") && (
|
||||||
|
<View style={styles.emailBar}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.emailInput}
|
||||||
|
placeholder="Or look up an email manually"
|
||||||
|
placeholderTextColor={theme.textDim}
|
||||||
|
value={manualEmail}
|
||||||
|
onChangeText={setManualEmail}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="email-address"
|
||||||
|
returnKeyType="search"
|
||||||
|
onSubmitEditing={() => manualEmail.trim() && runBanquet({ email: manualEmail.trim() })}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
style={styles.emailBtn}
|
||||||
|
onPress={() => manualEmail.trim() && runBanquet({ email: manualEmail.trim() })}
|
||||||
|
>
|
||||||
|
<Text style={styles.emailBtnText}>Look up</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function money(n: number): string {
|
||||||
|
return "$" + (Math.round(n * 100) / 100).toLocaleString(undefined, { maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function BanquetResult({ donor, ticketName }: { donor: DonorLookup | null; ticketName: string }) {
|
||||||
|
if (!donor) return null;
|
||||||
|
if (!donor.found) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text style={styles.bigIcon}>—</Text>
|
||||||
|
<Text style={styles.bigTitle}>No donations found</Text>
|
||||||
|
<Text style={styles.name}>{donor.email}</Text>
|
||||||
|
{!!ticketName && <Text style={styles.counts}>Ticket: {ticketName}</Text>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text style={styles.bigTitle}>{donor.name || ticketName || donor.email}</Text>
|
||||||
|
<Text style={styles.donorTotal}>{money(donor.total)}</Text>
|
||||||
|
<Text style={styles.counts}>total donated</Text>
|
||||||
|
<View style={styles.donorBreak}>
|
||||||
|
<Text style={styles.donorBreakItem}>Online {money(donor.online)}</Text>
|
||||||
|
<Text style={styles.donorBreakItem}>Offline {money(donor.offline)}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.donorEmail}>{donor.email}</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ExtrasRow({ ticket }: { ticket: TicketView }) {
|
function ExtrasRow({ ticket }: { ticket: TicketView }) {
|
||||||
const tags: string[] = [];
|
const tags: string[] = [];
|
||||||
if (ticket.extras.carParking) tags.push("🚗 Car parking");
|
if (ticket.extras.carParking) tags.push("🚗 Car parking");
|
||||||
if (ticket.extras.rvParking) tags.push("🚐 RV parking");
|
if (ticket.extras.rvParking) tags.push("🚐 RV parking");
|
||||||
if (ticket.extras.iceAccess) tags.push("🧊 Ice access");
|
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.isDonor) tags.push("⭐ Donor");
|
||||||
if (ticket.extras.freeUnder4 > 0) tags.push(`👶 ${ticket.extras.freeUnder4} under 4 (free)`);
|
if (ticket.extras.freeUnder4 > 0) tags.push(`👶 ${ticket.extras.freeUnder4} under 4 (free)`);
|
||||||
if (!tags.length) return null;
|
if (!tags.length) return null;
|
||||||
|
|
@ -193,45 +334,50 @@ function ExtrasRow({ ticket }: { ticket: TicketView }) {
|
||||||
|
|
||||||
function ConfirmCard({
|
function ConfirmCard({
|
||||||
ticket,
|
ticket,
|
||||||
|
isIce,
|
||||||
count,
|
count,
|
||||||
setCount,
|
setCount,
|
||||||
onCheckIn,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
ticket: TicketView;
|
ticket: TicketView;
|
||||||
|
isIce: boolean;
|
||||||
count: number;
|
count: number;
|
||||||
setCount: (n: number) => void;
|
setCount: (n: number) => void;
|
||||||
onCheckIn: () => void;
|
onConfirm: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
const exhausted = ticket.remaining <= 0;
|
const remaining = isIce ? ticket.ice.remaining : ticket.remaining;
|
||||||
|
const total = isIce ? ticket.ice.total : ticket.total;
|
||||||
|
const redeemed = isIce ? ticket.ice.redeemed : ticket.redeemed;
|
||||||
|
const exhausted = remaining <= 0;
|
||||||
|
const unit = isIce ? "bags of ice" : "tickets";
|
||||||
|
const question = isIce ? "How many ice bags?" : "How many are entering now?";
|
||||||
|
const cta = isIce ? `Give ${count}` : `Check in ${count}`;
|
||||||
|
|
||||||
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>
|
||||||
<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" }}>{ticket.remaining}</Text> of{" "}
|
<Text style={{ color: theme.successBright, fontWeight: "800" }}>{remaining}</Text> of {total} {unit} remaining
|
||||||
{ticket.total} remaining
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.cardSub}>{ticket.redeemed} already redeemed</Text>
|
<Text style={styles.cardSub}>{redeemed} already redeemed</Text>
|
||||||
<ExtrasRow ticket={ticket} />
|
{!isIce && <ExtrasRow ticket={ticket} />}
|
||||||
|
{isIce && total === 0 && <Text style={styles.exhausted}>This ticket did not prepay for ice.</Text>}
|
||||||
|
|
||||||
{exhausted ? (
|
{exhausted ? (
|
||||||
<Text style={styles.exhausted}>All tickets on this code are already redeemed.</Text>
|
total > 0 && <Text style={styles.exhausted}>All {unit} on this code are already redeemed.</Text>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Text style={styles.stepperLabel}>How many are entering now?</Text>
|
<Text style={styles.stepperLabel}>{question}</Text>
|
||||||
<View style={styles.stepper}>
|
<View style={styles.stepper}>
|
||||||
<StepBtn label="–" onPress={() => setCount(Math.max(1, count - 1))} disabled={count <= 1} />
|
<StepBtn label="–" onPress={() => setCount(Math.max(1, count - 1))} disabled={count <= 1} />
|
||||||
<Text style={styles.stepValue}>{count}</Text>
|
<Text style={styles.stepValue}>{count}</Text>
|
||||||
<StepBtn
|
<StepBtn label="+" onPress={() => setCount(Math.min(remaining, count + 1))} disabled={count >= remaining} />
|
||||||
label="+"
|
|
||||||
onPress={() => setCount(Math.min(ticket.remaining, count + 1))}
|
|
||||||
disabled={count >= ticket.remaining}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
<Pressable style={styles.checkinBtn} onPress={onCheckIn}>
|
<Pressable style={styles.checkinBtn} onPress={onConfirm}>
|
||||||
<Text style={styles.checkinText}>Check in {count}</Text>
|
<Text style={styles.checkinText}>{cta}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -262,71 +408,74 @@ const styles = StyleSheet.create({
|
||||||
brand: { color: theme.text, fontSize: 18, fontWeight: "700" },
|
brand: { color: theme.text, fontSize: 18, fontWeight: "700" },
|
||||||
topActions: { flexDirection: "row", gap: 18 },
|
topActions: { flexDirection: "row", gap: 18 },
|
||||||
link: { color: theme.textDim, fontSize: 15, fontWeight: "600" },
|
link: { color: theme.textDim, fontSize: 15, fontWeight: "600" },
|
||||||
|
|
||||||
|
modeBar: { flexDirection: "row", gap: 8, paddingHorizontal: 12, paddingBottom: 8 },
|
||||||
|
modeBtn: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: theme.card,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.cardBorder,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
modeBtnActive: { backgroundColor: theme.primary, borderColor: theme.primary },
|
||||||
|
modeText: { color: theme.textDim, fontSize: 15, fontWeight: "700" },
|
||||||
|
modeTextActive: { color: "#fff" },
|
||||||
|
|
||||||
scannerArea: { flex: 1, position: "relative", overflow: "hidden" },
|
scannerArea: { flex: 1, position: "relative", overflow: "hidden" },
|
||||||
reticle: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, alignItems: "center", justifyContent: "center" },
|
reticle: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, alignItems: "center", justifyContent: "center" },
|
||||||
reticleBox: {
|
reticleBox: { width: 240, height: 240, borderWidth: 3, borderColor: "rgba(255,255,255,0.85)", borderRadius: 24 },
|
||||||
width: 240,
|
hint: { color: "#fff", marginTop: 20, fontSize: 15, textAlign: "center", paddingHorizontal: 20, textShadowColor: "#000", textShadowRadius: 4 },
|
||||||
height: 240,
|
busy: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, alignItems: "center", justifyContent: "center", backgroundColor: "rgba(0,0,0,0.4)" },
|
||||||
borderWidth: 3,
|
|
||||||
borderColor: "rgba(255,255,255,0.85)",
|
|
||||||
borderRadius: 24,
|
|
||||||
},
|
|
||||||
hint: { color: "#fff", marginTop: 20, fontSize: 15, textShadowColor: "#000", textShadowRadius: 4 },
|
|
||||||
busy: {
|
|
||||||
position: "absolute", top: 0, left: 0, right: 0, bottom: 0,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
backgroundColor: "rgba(0,0,0,0.4)",
|
|
||||||
},
|
|
||||||
busyText: { color: "#fff", fontSize: 18, fontWeight: "600" },
|
busyText: { color: "#fff", fontSize: 18, fontWeight: "600" },
|
||||||
|
|
||||||
bigIcon: { color: "#fff", fontSize: 96, fontWeight: "900", lineHeight: 104 },
|
bigIcon: { color: "#fff", fontSize: 96, fontWeight: "900", lineHeight: 104 },
|
||||||
bigTitle: { color: "#fff", fontSize: 34, fontWeight: "800", marginTop: 4 },
|
bigTitle: { color: "#fff", fontSize: 32, fontWeight: "800", marginTop: 4, textAlign: "center" },
|
||||||
name: { color: "#fff", fontSize: 24, fontWeight: "700", marginTop: 12, textAlign: "center" },
|
name: { color: "#fff", fontSize: 24, fontWeight: "700", marginTop: 12, textAlign: "center" },
|
||||||
counts: { color: "rgba(255,255,255,0.95)", fontSize: 18, marginTop: 8, textAlign: "center" },
|
counts: { color: "rgba(255,255,255,0.95)", fontSize: 18, marginTop: 8, textAlign: "center" },
|
||||||
dbConfirm: { color: "#fff", fontSize: 15, marginTop: 16, fontWeight: "600" },
|
dbConfirm: { color: "#fff", fontSize: 15, marginTop: 16, fontWeight: "600" },
|
||||||
errorMsg: { color: "#fff", fontSize: 18, marginTop: 12, textAlign: "center", lineHeight: 24 },
|
errorMsg: { color: "#fff", fontSize: 18, marginTop: 12, textAlign: "center", lineHeight: 24 },
|
||||||
tapHint: { color: "rgba(255,255,255,0.75)", fontSize: 14, marginTop: 24 },
|
tapHint: { color: "rgba(255,255,255,0.75)", fontSize: 14, marginTop: 24 },
|
||||||
|
|
||||||
|
donorTotal: { color: "#fff", fontSize: 64, fontWeight: "900", marginTop: 10 },
|
||||||
|
donorBreak: { flexDirection: "row", gap: 18, marginTop: 14 },
|
||||||
|
donorBreakItem: { color: "rgba(255,255,255,0.95)", fontSize: 16, fontWeight: "600" },
|
||||||
|
donorEmail: { color: "rgba(255,255,255,0.85)", fontSize: 14, marginTop: 14 },
|
||||||
|
|
||||||
tags: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, marginTop: 14 },
|
tags: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, marginTop: 14 },
|
||||||
tag: {
|
tag: { color: "#fff", backgroundColor: "rgba(255,255,255,0.18)", paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, fontSize: 13, overflow: "hidden" },
|
||||||
color: "#fff",
|
|
||||||
backgroundColor: "rgba(255,255,255,0.18)",
|
|
||||||
paddingHorizontal: 10,
|
|
||||||
paddingVertical: 5,
|
|
||||||
borderRadius: 999,
|
|
||||||
fontSize: 13,
|
|
||||||
overflow: "hidden",
|
|
||||||
},
|
|
||||||
|
|
||||||
card: { maxHeight: "100%", width: "100%" },
|
card: { maxHeight: "100%", width: "100%" },
|
||||||
cardContent: { alignItems: "center", paddingVertical: 8 },
|
cardContent: { alignItems: "center", paddingVertical: 8 },
|
||||||
cardName: { color: theme.text, fontSize: 26, fontWeight: "800", textAlign: "center" },
|
cardName: { color: theme.text, fontSize: 26, fontWeight: "800", textAlign: "center" },
|
||||||
cardCode: { color: theme.textDim, fontSize: 15, marginTop: 4, letterSpacing: 1 },
|
cardCode: { color: theme.textDim, fontSize: 15, marginTop: 4, letterSpacing: 1 },
|
||||||
cardCounts: { color: theme.text, fontSize: 22, marginTop: 16 },
|
cardCounts: { color: theme.text, fontSize: 22, marginTop: 16, textAlign: "center" },
|
||||||
cardSub: { color: theme.textDim, fontSize: 14, marginTop: 4 },
|
cardSub: { color: theme.textDim, fontSize: 14, marginTop: 4 },
|
||||||
exhausted: { color: theme.dangerBright, fontSize: 17, marginTop: 20, textAlign: "center", fontWeight: "600" },
|
exhausted: { color: theme.dangerBright, fontSize: 17, marginTop: 20, textAlign: "center", fontWeight: "600" },
|
||||||
stepperLabel: { color: theme.text, fontSize: 16, marginTop: 22 },
|
stepperLabel: { color: theme.text, fontSize: 16, marginTop: 22 },
|
||||||
stepper: { flexDirection: "row", alignItems: "center", gap: 24, marginTop: 12 },
|
stepper: { flexDirection: "row", alignItems: "center", gap: 24, marginTop: 12 },
|
||||||
stepBtn: {
|
stepBtn: { width: 64, height: 64, borderRadius: 32, backgroundColor: theme.primary, alignItems: "center", justifyContent: "center" },
|
||||||
width: 64,
|
|
||||||
height: 64,
|
|
||||||
borderRadius: 32,
|
|
||||||
backgroundColor: theme.primary,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
stepBtnDisabled: { backgroundColor: theme.cardBorder },
|
stepBtnDisabled: { backgroundColor: theme.cardBorder },
|
||||||
stepBtnText: { color: "#fff", fontSize: 32, fontWeight: "800", lineHeight: 36 },
|
stepBtnText: { color: "#fff", fontSize: 32, fontWeight: "800", lineHeight: 36 },
|
||||||
stepValue: { color: theme.text, fontSize: 44, fontWeight: "800", minWidth: 64, textAlign: "center" },
|
stepValue: { color: theme.text, fontSize: 44, fontWeight: "800", minWidth: 64, textAlign: "center" },
|
||||||
checkinBtn: {
|
checkinBtn: { backgroundColor: theme.successBright, paddingHorizontal: 40, paddingVertical: 16, borderRadius: 14, marginTop: 24 },
|
||||||
backgroundColor: theme.successBright,
|
|
||||||
paddingHorizontal: 40,
|
|
||||||
paddingVertical: 16,
|
|
||||||
borderRadius: 14,
|
|
||||||
marginTop: 24,
|
|
||||||
},
|
|
||||||
checkinText: { color: "#06210f", fontSize: 22, fontWeight: "800" },
|
checkinText: { color: "#06210f", fontSize: 22, fontWeight: "800" },
|
||||||
cancelBtn: { marginTop: 16, padding: 10 },
|
cancelBtn: { marginTop: 16, padding: 10 },
|
||||||
cancelText: { color: theme.textDim, fontSize: 16 },
|
cancelText: { color: theme.textDim, fontSize: 16 },
|
||||||
|
|
||||||
|
emailBar: { flexDirection: "row", gap: 10, paddingHorizontal: 12, paddingVertical: 10, backgroundColor: theme.bg },
|
||||||
|
emailInput: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.card,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.cardBorder,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 12,
|
||||||
|
color: theme.text,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
emailBtn: { backgroundColor: theme.primary, borderRadius: 12, paddingHorizontal: 18, justifyContent: "center" },
|
||||||
|
emailBtnText: { color: "#fff", fontSize: 16, fontWeight: "700" },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,12 @@ export const API_BASE =
|
||||||
? ""
|
? ""
|
||||||
: (process.env.EXPO_PUBLIC_API_URL ?? "https://scan.beartariacampgrounds.com").replace(/\/+$/, "");
|
: (process.env.EXPO_PUBLIC_API_URL ?? "https://scan.beartariacampgrounds.com").replace(/\/+$/, "");
|
||||||
|
|
||||||
|
export interface ResourceCount {
|
||||||
|
total: number;
|
||||||
|
redeemed: number;
|
||||||
|
remaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TicketView {
|
export interface TicketView {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -18,6 +24,7 @@ export interface TicketView {
|
||||||
total: number;
|
total: number;
|
||||||
redeemed: number;
|
redeemed: number;
|
||||||
remaining: number;
|
remaining: number;
|
||||||
|
ice: ResourceCount;
|
||||||
extras: {
|
extras: {
|
||||||
carParking: boolean;
|
carParking: boolean;
|
||||||
rvParking: boolean;
|
rvParking: boolean;
|
||||||
|
|
@ -108,10 +115,33 @@ export type RedeemResult =
|
||||||
detail?: string;
|
detail?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function redeem(code: string, count: number): Promise<RedeemResult> {
|
export type Resource = "tickets" | "ice";
|
||||||
|
|
||||||
|
export function redeem(code: string, count: number, resource: Resource = "tickets"): Promise<RedeemResult> {
|
||||||
return authed<RedeemResult>("/api/redeem", {
|
return authed<RedeemResult>("/api/redeem", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ code, count }),
|
body: JSON.stringify({ code, count, resource }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DonorLookup {
|
||||||
|
found: boolean;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
online: number;
|
||||||
|
offline: number;
|
||||||
|
total: number;
|
||||||
|
source: "master" | "transactions" | "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BanquetResult =
|
||||||
|
| { ok: true; ticketName: string; donor: DonorLookup }
|
||||||
|
| { ok: false; reason: "not_found" | "no_email" | "db_error" | "banquet_disabled"; detail?: string };
|
||||||
|
|
||||||
|
export function banquet(input: { code?: string; email?: string }): Promise<BanquetResult> {
|
||||||
|
return authed<BanquetResult>("/api/banquet", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,17 @@ const schema = z.object({
|
||||||
// Optional "2026 Ticket Audit Logs" table. If unset, audit logging is skipped.
|
// Optional "2026 Ticket Audit Logs" table. If unset, audit logging is skipped.
|
||||||
NOCODB_AUDIT_TABLE_ID: z.string().optional(),
|
NOCODB_AUDIT_TABLE_ID: z.string().optional(),
|
||||||
|
|
||||||
|
// Donor tables for Banquet mode. If the master-list id is unset, banquet is
|
||||||
|
// disabled. Online/offline are used as a fallback when a donor is not in the
|
||||||
|
// master list.
|
||||||
|
NOCODB_DONORS_TABLE_ID: z.string().optional(),
|
||||||
|
NOCODB_DONOR_ONLINE_TABLE_ID: z.string().optional(),
|
||||||
|
NOCODB_DONOR_OFFLINE_TABLE_ID: z.string().optional(),
|
||||||
|
|
||||||
|
// Prepaid ice bags granted when a purchase includes ice but the webhook only
|
||||||
|
// sends a boolean (not an explicit bag count).
|
||||||
|
ICE_BAGS_DEFAULT: z.coerce.number().default(3),
|
||||||
|
|
||||||
MAILERSEND_API_TOKEN: z.string().min(1),
|
MAILERSEND_API_TOKEN: z.string().min(1),
|
||||||
MAIL_FROM_EMAIL: z.string().email(),
|
MAIL_FROM_EMAIL: z.string().email(),
|
||||||
MAIL_FROM_NAME: z.string().default("Beartaria Campgrounds"),
|
MAIL_FROM_NAME: z.string().default("Beartaria Campgrounds"),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { NocoDBClient } from "./services/nocodb.js";
|
||||||
import { Mailer } from "./services/mailer.js";
|
import { Mailer } from "./services/mailer.js";
|
||||||
import { RedeemQueue } from "./services/redeemQueue.js";
|
import { RedeemQueue } from "./services/redeemQueue.js";
|
||||||
import { AuditLogger } from "./services/audit.js";
|
import { AuditLogger } from "./services/audit.js";
|
||||||
|
import { DonorService } from "./services/donors.js";
|
||||||
|
|
||||||
/** Shared services wired once at startup and hung off the Fastify instance. */
|
/** Shared services wired once at startup and hung off the Fastify instance. */
|
||||||
export interface AppContext {
|
export interface AppContext {
|
||||||
|
|
@ -11,6 +12,7 @@ export interface AppContext {
|
||||||
mailer: Mailer;
|
mailer: Mailer;
|
||||||
queue: RedeemQueue;
|
queue: RedeemQueue;
|
||||||
audit: AuditLogger;
|
audit: AuditLogger;
|
||||||
|
donors: DonorService;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildContext(config: Config): AppContext {
|
export function buildContext(config: Config): AppContext {
|
||||||
|
|
@ -20,6 +22,7 @@ export function buildContext(config: Config): AppContext {
|
||||||
mailer: new Mailer(config),
|
mailer: new Mailer(config),
|
||||||
queue: new RedeemQueue(),
|
queue: new RedeemQueue(),
|
||||||
audit: new AuditLogger(config),
|
audit: new AuditLogger(config),
|
||||||
|
donors: new DonorService(config),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ export const COL = {
|
||||||
redeemed: "Redeemed",
|
redeemed: "Redeemed",
|
||||||
submissionKey: "SubmissionKey",
|
submissionKey: "SubmissionKey",
|
||||||
lastScanAt: "LastScanAt",
|
lastScanAt: "LastScanAt",
|
||||||
|
iceTotal: "Ice Total", // prepaid ice bags
|
||||||
|
iceRedeemed: "Ice Redeemed", // bags picked up
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Age-bracket columns, in order. */
|
/** Age-bracket columns, in order. */
|
||||||
|
|
@ -74,6 +76,12 @@ export function ageBreakdown(rec: NocoRecord): { bracket: string; count: number;
|
||||||
})).filter((b) => b.count > 0);
|
})).filter((b) => b.count > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResourceCount {
|
||||||
|
total: number;
|
||||||
|
redeemed: number;
|
||||||
|
remaining: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TicketView {
|
export interface TicketView {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -81,6 +89,7 @@ export interface TicketView {
|
||||||
total: number;
|
total: number;
|
||||||
redeemed: number;
|
redeemed: number;
|
||||||
remaining: number;
|
remaining: number;
|
||||||
|
ice: ResourceCount;
|
||||||
extras: {
|
extras: {
|
||||||
carParking: boolean;
|
carParking: boolean;
|
||||||
rvParking: boolean;
|
rvParking: boolean;
|
||||||
|
|
@ -91,9 +100,15 @@ export interface TicketView {
|
||||||
ages: { bracket: string; count: number; free: boolean }[];
|
ages: { bracket: string; count: number; free: boolean }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function computeIceTotal(rec: NocoRecord): number {
|
||||||
|
return num(rec[COL.iceTotal]);
|
||||||
|
}
|
||||||
|
|
||||||
export function toView(rec: NocoRecord): TicketView {
|
export function toView(rec: NocoRecord): TicketView {
|
||||||
const total = computeTotal(rec);
|
const total = computeTotal(rec);
|
||||||
const redeemed = num(rec[COL.redeemed]);
|
const redeemed = num(rec[COL.redeemed]);
|
||||||
|
const iceTotal = computeIceTotal(rec);
|
||||||
|
const iceRedeemed = num(rec[COL.iceRedeemed]);
|
||||||
return {
|
return {
|
||||||
code: String(rec[COL.code] ?? ""),
|
code: String(rec[COL.code] ?? ""),
|
||||||
name: String(rec[COL.name] ?? ""),
|
name: String(rec[COL.name] ?? ""),
|
||||||
|
|
@ -101,6 +116,11 @@ export function toView(rec: NocoRecord): TicketView {
|
||||||
total,
|
total,
|
||||||
redeemed,
|
redeemed,
|
||||||
remaining: Math.max(0, total - redeemed),
|
remaining: Math.max(0, total - redeemed),
|
||||||
|
ice: {
|
||||||
|
total: iceTotal,
|
||||||
|
redeemed: iceRedeemed,
|
||||||
|
remaining: Math.max(0, iceTotal - iceRedeemed),
|
||||||
|
},
|
||||||
extras: {
|
extras: {
|
||||||
carParking: bool(rec[COL.carParking]),
|
carParking: bool(rec[COL.carParking]),
|
||||||
rvParking: bool(rec[COL.rvParking]),
|
rvParking: bool(rec[COL.rvParking]),
|
||||||
|
|
|
||||||
|
|
@ -76,13 +76,54 @@ export async function ticketRoutes(app: FastifyInstance): Promise<void> {
|
||||||
properties: {
|
properties: {
|
||||||
code: { type: "string", minLength: 1, maxLength: 64 },
|
code: { type: "string", minLength: 1, maxLength: 64 },
|
||||||
count: { type: "integer", minimum: -100, maximum: 100 },
|
count: { type: "integer", minimum: -100, maximum: 100 },
|
||||||
|
resource: { type: "string", enum: ["tickets", "ice"] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async (req) => {
|
async (req) => {
|
||||||
const { code, count } = req.body as { code: string; count?: number };
|
const { code, count, resource } = req.body as {
|
||||||
return redeem(app.ctx, normalizeCode(code), count ?? 1);
|
code: string;
|
||||||
|
count?: number;
|
||||||
|
resource?: "tickets" | "ice";
|
||||||
|
};
|
||||||
|
return redeem(app.ctx, normalizeCode(code), count ?? 1, resource ?? "tickets");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Banquet mode: total donations for the email on a ticket (or a manual email).
|
||||||
|
app.post(
|
||||||
|
"/api/banquet",
|
||||||
|
{
|
||||||
|
preHandler: requireStaff,
|
||||||
|
schema: {
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
code: { type: "string", maxLength: 64 },
|
||||||
|
email: { type: "string", maxLength: 200 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (req, reply) => {
|
||||||
|
if (!app.ctx.donors.enabled) {
|
||||||
|
return reply.code(422).send({ ok: false, reason: "banquet_disabled" });
|
||||||
|
}
|
||||||
|
const { code, email } = req.body as { code?: string; email?: string };
|
||||||
|
let lookupEmail = (email ?? "").trim();
|
||||||
|
let ticketName = "";
|
||||||
|
// If a ticket code was scanned, resolve its email.
|
||||||
|
if (!lookupEmail && code) {
|
||||||
|
const res = await lookupByCode(app.ctx, normalizeCode(code));
|
||||||
|
if (!res.ok) return { ok: false, reason: "db_error", detail: res.detail };
|
||||||
|
if (!res.found) return { ok: false, reason: "not_found" };
|
||||||
|
lookupEmail = res.ticket.email;
|
||||||
|
ticketName = res.ticket.name;
|
||||||
|
}
|
||||||
|
if (!lookupEmail) return { ok: false, reason: "no_email" };
|
||||||
|
const donor = await app.ctx.donors.lookup(lookupEmail);
|
||||||
|
return { ok: true, ticketName, donor };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,15 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
|
||||||
.digest("hex")
|
.digest("hex")
|
||||||
.slice(0, 32);
|
.slice(0, 32);
|
||||||
|
|
||||||
|
// Ice: prefer an explicit bag count; else grant the default when a boolean
|
||||||
|
// ice option is truthy; else 0.
|
||||||
|
let iceBags = 0;
|
||||||
|
if (body.ice_bags !== undefined && body.ice_bags !== null && body.ice_bags !== "") {
|
||||||
|
iceBags = toNumber(body.ice_bags);
|
||||||
|
} else if (body.ice_access !== undefined && toBool(body.ice_access)) {
|
||||||
|
iceBags = app.ctx.config.ICE_BAGS_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
let result: Awaited<ReturnType<typeof createTicket>>;
|
let result: Awaited<ReturnType<typeof createTicket>>;
|
||||||
try {
|
try {
|
||||||
result = await createTicket(app.ctx, {
|
result = await createTicket(app.ctx, {
|
||||||
|
|
@ -70,6 +79,7 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
|
||||||
carParking: body.car_parking !== undefined ? toBool(body.car_parking) : undefined,
|
carParking: body.car_parking !== undefined ? toBool(body.car_parking) : undefined,
|
||||||
rvParking: body.rv_parking !== undefined ? toBool(body.rv_parking) : undefined,
|
rvParking: body.rv_parking !== undefined ? toBool(body.rv_parking) : undefined,
|
||||||
iceAccess: body.ice_access !== undefined ? toBool(body.ice_access) : undefined,
|
iceAccess: body.ice_access !== undefined ? toBool(body.ice_access) : undefined,
|
||||||
|
iceBags,
|
||||||
paymentMethod: body.payment_method !== undefined ? String(body.payment_method) : undefined,
|
paymentMethod: body.payment_method !== undefined ? String(body.payment_method) : undefined,
|
||||||
ages,
|
ages,
|
||||||
submissionKey,
|
submissionKey,
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export interface AuditEntry {
|
||||||
name: string;
|
name: string;
|
||||||
remainingAfter: number;
|
remainingAfter: number;
|
||||||
at: string; // ISO
|
at: string; // ISO
|
||||||
action: "check-in" | "undo";
|
action: "check-in" | "undo" | "ice" | "ice-undo";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuditRow extends AuditEntry {
|
export interface AuditRow extends AuditEntry {
|
||||||
|
|
|
||||||
110
backend/src/services/donors.ts
Normal file
110
backend/src/services/donors.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import type { Config } from "../config.js";
|
||||||
|
|
||||||
|
export interface DonorLookup {
|
||||||
|
found: boolean;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
online: number;
|
||||||
|
offline: number;
|
||||||
|
total: number;
|
||||||
|
source: "master" | "transactions" | "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up a donor's total giving for Banquet mode. Primary source is the
|
||||||
|
* "Donors Master List" (which carries pre-rolled Total Donations / Total
|
||||||
|
* Online / Total Offline). Falls back to summing the online + offline
|
||||||
|
* transaction tables by email when the donor isn't in the master list.
|
||||||
|
*/
|
||||||
|
export class DonorService {
|
||||||
|
private readonly base: string;
|
||||||
|
private readonly token: string;
|
||||||
|
private readonly masterId: string | null;
|
||||||
|
private readonly onlineId: string | null;
|
||||||
|
private readonly offlineId: string | null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
cfg: Pick<
|
||||||
|
Config,
|
||||||
|
| "NOCODB_BASE_URL"
|
||||||
|
| "NOCODB_API_TOKEN"
|
||||||
|
| "NOCODB_DONORS_TABLE_ID"
|
||||||
|
| "NOCODB_DONOR_ONLINE_TABLE_ID"
|
||||||
|
| "NOCODB_DONOR_OFFLINE_TABLE_ID"
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, "");
|
||||||
|
this.token = cfg.NOCODB_API_TOKEN;
|
||||||
|
this.masterId = cfg.NOCODB_DONORS_TABLE_ID ?? null;
|
||||||
|
this.onlineId = cfg.NOCODB_DONOR_ONLINE_TABLE_ID ?? null;
|
||||||
|
this.offlineId = cfg.NOCODB_DONOR_OFFLINE_TABLE_ID ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get enabled(): boolean {
|
||||||
|
return this.masterId !== null || (this.onlineId !== null && this.offlineId !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async list(tableId: string, where: string, limit = 50): Promise<any[]> {
|
||||||
|
const url = new URL(`${this.base}/api/v2/tables/${tableId}/records`);
|
||||||
|
if (where) url.searchParams.set("where", where);
|
||||||
|
url.searchParams.set("limit", String(limit));
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
headers: { "xc-token": this.token, "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`NocoDB ${res.status} on donor lookup`);
|
||||||
|
const body: any = await res.json();
|
||||||
|
return body?.list ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async lookup(rawEmail: string): Promise<DonorLookup> {
|
||||||
|
const email = rawEmail.trim();
|
||||||
|
const esc = email.replace(/[(),]/g, " ");
|
||||||
|
const empty: DonorLookup = { found: false, email, name: "", online: 0, offline: 0, total: 0, source: "none" };
|
||||||
|
if (!email) return empty;
|
||||||
|
|
||||||
|
// 1) Master list (authoritative rolled-up totals), matching either email.
|
||||||
|
if (this.masterId) {
|
||||||
|
const rows = await this.list(
|
||||||
|
this.masterId,
|
||||||
|
`(Email,eq,${esc})~or(Alternate Email,eq,${esc})`,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
if (rows.length) {
|
||||||
|
const r = rows[0];
|
||||||
|
const online = num(r["Total Online Donations"]);
|
||||||
|
const offline = num(r["Total Offline Donations"]);
|
||||||
|
const total = r["Total Donations"] !== undefined ? num(r["Total Donations"]) : online + offline;
|
||||||
|
const name =
|
||||||
|
r["Display Name"] ||
|
||||||
|
[r["First Name"], r["Last Name"]].filter(Boolean).join(" ") ||
|
||||||
|
r["Bear Name"] ||
|
||||||
|
"";
|
||||||
|
return { found: true, email, name, online, offline, total, source: "master" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Fallback: sum transaction tables by email.
|
||||||
|
if (this.onlineId && this.offlineId) {
|
||||||
|
const [onlineRows, offlineRows] = await Promise.all([
|
||||||
|
this.list(this.onlineId, `(Email,eq,${esc})`, 200),
|
||||||
|
this.list(this.offlineId, `(Email,eq,${esc})`, 200),
|
||||||
|
]);
|
||||||
|
const online = sum(onlineRows, "Donation Amount");
|
||||||
|
const offline = sum(offlineRows, "Donation Amount");
|
||||||
|
const name = onlineRows[0]?.["Bear Name"] || onlineRows[0]?.["Name"] || offlineRows[0]?.["Name"] || "";
|
||||||
|
const found = online + offline > 0 || onlineRows.length + offlineRows.length > 0;
|
||||||
|
return { found, email, name, online, offline, total: online + offline, source: found ? "transactions" : "none" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(v: unknown): number {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sum(rows: any[], field: string): number {
|
||||||
|
return rows.reduce((acc, r) => acc + num(r[field]), 0);
|
||||||
|
}
|
||||||
|
|
@ -80,6 +80,7 @@ export function fakeContext(db: FakeNocoDB): AppContext {
|
||||||
},
|
},
|
||||||
recent: async () => auditEntries,
|
recent: async () => auditEntries,
|
||||||
} as any,
|
} as any,
|
||||||
|
donors: { enabled: false, lookup: async () => ({ found: false }) } as any,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,36 @@ describe("redeem", () => {
|
||||||
expect((ctx.audit as any).entries).toHaveLength(0);
|
expect((ctx.audit as any).entries).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("redeems ice bags independently of ticket check-ins", async () => {
|
||||||
|
const db = new FakeNocoDB();
|
||||||
|
await seedTicket(db, {
|
||||||
|
code: "BC26-ICE-0003",
|
||||||
|
ages: { "Ages 26-45": 2 },
|
||||||
|
});
|
||||||
|
// Give the ticket 3 prepaid ice bags.
|
||||||
|
db.rows[0]["Ice Total"] = 3;
|
||||||
|
db.rows[0]["Ice Redeemed"] = 0;
|
||||||
|
const ctx = fakeContext(db);
|
||||||
|
|
||||||
|
// Grab all 3 bags at once.
|
||||||
|
const ice = await redeem(ctx, "BC26-ICE-0003", 3, "ice");
|
||||||
|
expect(ice.ok).toBe(true);
|
||||||
|
if (ice.ok) expect(ice.ticket.ice.remaining).toBe(0);
|
||||||
|
expect(db.rows[0]["Ice Redeemed"]).toBe(3);
|
||||||
|
// Ticket check-ins are untouched by ice redemption.
|
||||||
|
expect(db.rows[0].Redeemed ?? 0).toBe(0);
|
||||||
|
|
||||||
|
// No ice left.
|
||||||
|
const again = await redeem(ctx, "BC26-ICE-0003", 1, "ice");
|
||||||
|
expect(again.ok).toBe(false);
|
||||||
|
if (!again.ok) expect(again.reason).toBe("exhausted");
|
||||||
|
|
||||||
|
// Ticket check-in still works after ice is gone.
|
||||||
|
const checkin = await redeem(ctx, "BC26-ICE-0003", 2, "tickets");
|
||||||
|
expect(checkin.ok).toBe(true);
|
||||||
|
if (checkin.ok) expect(checkin.ticket.remaining).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns not_found for unknown codes", async () => {
|
it("returns not_found for unknown codes", async () => {
|
||||||
const ctx = fakeContext(new FakeNocoDB());
|
const ctx = fakeContext(new FakeNocoDB());
|
||||||
const r = await redeem(ctx, "BC26-ZZZZ-9999", 1);
|
const r = await redeem(ctx, "BC26-ZZZZ-9999", 1);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,23 @@
|
||||||
import type { AppContext } from "./context.js";
|
import type { AppContext } from "./context.js";
|
||||||
import { generateCode } from "./services/code.js";
|
import { generateCode } from "./services/code.js";
|
||||||
import { COL, toView, computeTotal, type NocoRecord, type TicketView } from "./fields.js";
|
import { COL, toView, computeTotal, computeIceTotal, type NocoRecord, type TicketView } from "./fields.js";
|
||||||
|
|
||||||
export type { TicketView };
|
export type { TicketView };
|
||||||
|
|
||||||
|
export type Resource = "tickets" | "ice";
|
||||||
|
|
||||||
|
interface ResourceConfig {
|
||||||
|
totalFn: (rec: NocoRecord) => number;
|
||||||
|
redeemedCol: string;
|
||||||
|
auditIn: "check-in" | "ice";
|
||||||
|
auditUndo: "undo" | "ice-undo";
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESOURCES: Record<Resource, ResourceConfig> = {
|
||||||
|
tickets: { totalFn: computeTotal, redeemedCol: COL.redeemed, auditIn: "check-in", auditUndo: "undo" },
|
||||||
|
ice: { totalFn: computeIceTotal, redeemedCol: COL.iceRedeemed, auditIn: "ice", auditUndo: "ice-undo" },
|
||||||
|
};
|
||||||
|
|
||||||
export type LookupResult =
|
export type LookupResult =
|
||||||
| { ok: true; found: true; ticket: TicketView }
|
| { ok: true; found: true; ticket: TicketView }
|
||||||
| { ok: true; found: false }
|
| { ok: true; found: false }
|
||||||
|
|
@ -41,13 +55,21 @@ export type RedeemResult =
|
||||||
* check in more people than the ticket allows). Negative count undoes a
|
* check in more people than the ticket allows). Negative count undoes a
|
||||||
* mistaken check-in, clamped so Redeemed never drops below 0.
|
* mistaken check-in, clamped so Redeemed never drops below 0.
|
||||||
*/
|
*/
|
||||||
export async function redeem(ctx: AppContext, code: string, count: number): Promise<RedeemResult> {
|
export async function redeem(
|
||||||
|
ctx: AppContext,
|
||||||
|
code: string,
|
||||||
|
count: number,
|
||||||
|
resource: Resource = "tickets",
|
||||||
|
): Promise<RedeemResult> {
|
||||||
const n = Math.trunc(count);
|
const n = Math.trunc(count);
|
||||||
if (!Number.isFinite(n) || n === 0) {
|
if (!Number.isFinite(n) || n === 0) {
|
||||||
return { ok: false, reason: "insufficient", detail: "count must be a non-zero integer" };
|
return { ok: false, reason: "insufficient", detail: "count must be a non-zero integer" };
|
||||||
}
|
}
|
||||||
|
const cfg = RESOURCES[resource];
|
||||||
|
|
||||||
return ctx.queue.run(code, async () => {
|
// Serialize per (code, resource) so ice and ticket redemptions don't block
|
||||||
|
// each other but same-resource scans still can't double-spend.
|
||||||
|
return ctx.queue.run(`${resource}:${code}`, async () => {
|
||||||
let rec: NocoRecord | null;
|
let rec: NocoRecord | null;
|
||||||
try {
|
try {
|
||||||
rec = await ctx.nocodb.findByCode(code);
|
rec = await ctx.nocodb.findByCode(code);
|
||||||
|
|
@ -56,8 +78,8 @@ export async function redeem(ctx: AppContext, code: string, count: number): Prom
|
||||||
}
|
}
|
||||||
if (!rec) return { ok: false, reason: "not_found" };
|
if (!rec) return { ok: false, reason: "not_found" };
|
||||||
|
|
||||||
const total = computeTotal(rec);
|
const total = cfg.totalFn(rec);
|
||||||
const redeemed = Number(rec[COL.redeemed]) || 0;
|
const redeemed = Number(rec[cfg.redeemedCol]) || 0;
|
||||||
const remaining = Math.max(0, total - redeemed);
|
const remaining = Math.max(0, total - redeemed);
|
||||||
|
|
||||||
if (n > 0 && remaining === 0) {
|
if (n > 0 && remaining === 0) {
|
||||||
|
|
@ -70,22 +92,23 @@ export async function redeem(ctx: AppContext, code: string, count: number): Prom
|
||||||
const next = Math.min(total, Math.max(0, redeemed + n));
|
const next = Math.min(total, Math.max(0, redeemed + n));
|
||||||
try {
|
try {
|
||||||
const updated = await ctx.nocodb.update(rec.Id, {
|
const updated = await ctx.nocodb.update(rec.Id, {
|
||||||
[COL.redeemed]: next,
|
[cfg.redeemedCol]: next,
|
||||||
[COL.lastScanAt]: new Date().toISOString(),
|
[COL.lastScanAt]: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
// Trust our computed `next` but prefer the DB's echoed value if present.
|
// Trust our computed `next` but prefer the DB's echoed value if present.
|
||||||
const confirmed = { ...rec, [COL.redeemed]: Number(updated?.[COL.redeemed] ?? next) };
|
const confirmed = { ...rec, [cfg.redeemedCol]: Number(updated?.[cfg.redeemedCol] ?? next) };
|
||||||
const view = toView(confirmed);
|
const view = toView(confirmed);
|
||||||
const delta = next - redeemed;
|
const delta = next - redeemed;
|
||||||
|
const remainingAfter = resource === "ice" ? view.ice.remaining : view.remaining;
|
||||||
if (delta !== 0) {
|
if (delta !== 0) {
|
||||||
// Non-fatal: audit failures never block a check-in.
|
// Non-fatal: audit failures never block a check-in.
|
||||||
await ctx.audit.log({
|
await ctx.audit.log({
|
||||||
code: view.code,
|
code: view.code,
|
||||||
people: delta,
|
people: delta,
|
||||||
name: view.name,
|
name: view.name,
|
||||||
remainingAfter: view.remaining,
|
remainingAfter,
|
||||||
at: new Date().toISOString(),
|
at: new Date().toISOString(),
|
||||||
action: delta >= 0 ? "check-in" : "undo",
|
action: delta >= 0 ? cfg.auditIn : cfg.auditUndo,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { ok: true, ticket: view, checkedIn: delta };
|
return { ok: true, ticket: view, checkedIn: delta };
|
||||||
|
|
@ -109,6 +132,7 @@ export interface WebhookInput {
|
||||||
carParking?: boolean;
|
carParking?: boolean;
|
||||||
rvParking?: boolean;
|
rvParking?: boolean;
|
||||||
iceAccess?: boolean;
|
iceAccess?: boolean;
|
||||||
|
iceBags?: number; // prepaid ice bags
|
||||||
paymentMethod?: string;
|
paymentMethod?: string;
|
||||||
ages: Record<string, number>; // NocoDB age-column title -> count
|
ages: Record<string, number>; // NocoDB age-column title -> count
|
||||||
submissionKey: string;
|
submissionKey: string;
|
||||||
|
|
@ -137,6 +161,8 @@ export async function createTicket(
|
||||||
[COL.email]: input.email,
|
[COL.email]: input.email,
|
||||||
[COL.code]: code,
|
[COL.code]: code,
|
||||||
[COL.redeemed]: 0,
|
[COL.redeemed]: 0,
|
||||||
|
[COL.iceTotal]: input.iceBags ?? 0,
|
||||||
|
[COL.iceRedeemed]: 0,
|
||||||
[COL.submissionKey]: input.submissionKey,
|
[COL.submissionKey]: input.submissionKey,
|
||||||
...input.ages,
|
...input.ages,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue