From 3906b73c61bfe5cec357eb4d140e3c7ad5f73dab Mon Sep 17 00:00:00 2001 From: Pen Anderson Date: Wed, 4 Mar 2026 14:19:51 -0600 Subject: [PATCH] Added ticket and participant creation. Revised Gate kiosk. --- frontend/src/api.js | 1 + frontend/src/pages/GateUI.svelte | 193 ++++++++++--------------- frontend/src/pages/Participants.svelte | 131 ++++++++++++++++- handle_participants.go | 22 +++ main.go | 1 + 5 files changed, 231 insertions(+), 117 deletions(-) diff --git a/frontend/src/api.js b/frontend/src/api.js index bf6fd0a..67d491e 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -66,6 +66,7 @@ export const api = { }, tickets: { list: () => apiJSON('/api/tickets'), + create: (data) => apiJSON('/api/tickets', { method: 'POST', body: JSON.stringify(data) }), checkIn: (id) => apiJSON(`/api/tickets/${id}/checkin`, { method: 'POST' }), generateCodes: () => apiJSON('/api/tickets/generate-codes', { method: 'POST' }), emailCode: (id) => apiJSON(`/api/tickets/${id}/email-code`, { method: 'POST' }), diff --git a/frontend/src/pages/GateUI.svelte b/frontend/src/pages/GateUI.svelte index ab0a658..6c7dc71 100644 --- a/frontend/src/pages/GateUI.svelte +++ b/frontend/src/pages/GateUI.svelte @@ -16,10 +16,6 @@ let detector = $state(null) let scanInterval = $state(null) - const attendees = liveQuery(() => - db.attendees.filter(a => !a.deleted_at).toArray() - ) - const tickets = liveQuery(() => db.tickets.filter(t => !t.deleted_at).toArray() ) @@ -29,19 +25,8 @@ ) const recentCheckIns = liveQuery(() => - db.attendees - .filter(a => a.checked_in && !a.deleted_at) - .toArray() - .then(arr => arr - .filter(a => a.checked_in_at) - .sort((a, b) => b.checked_in_at.localeCompare(a.checked_in_at)) - .slice(0, 10) - ) - ) - - const recentTicketCheckIns = liveQuery(() => db.tickets - .filter(t => t.checked_in_at && !t.deleted_at) + .filter(t => !!t.checked_in_at && !t.deleted_at) .toArray() .then(arr => arr .sort((a, b) => b.checked_in_at.localeCompare(a.checked_in_at)) @@ -49,38 +34,49 @@ ) ) - // Ticket matched by code (exact) or attendee matched by search + // Exact code/external_id match (QR scan or typed code) const matchedTicket = $derived.by(() => { const s = search.trim() if (!s || s.length < 2) return null const sl = s.toLowerCase() - // Exact code match (QR scan) const byCode = ($tickets ?? []).find(t => t.code?.toLowerCase() === sl) if (byCode) return byCode - // Exact external_id match return ($tickets ?? []).find(t => t.external_id?.toLowerCase() === sl) ?? null }) - const filtered = $derived.by(() => { + // Name/email search across participants + const filteredParticipants = $derived.by(() => { + if (matchedTicket) return [] const s = search.trim().toLowerCase() if (!s || s.length < 2) return [] - return ($attendees ?? []) - .filter(a => a.name.toLowerCase().includes(s) || a.ticket_id?.toLowerCase().includes(s) || a.email?.toLowerCase().includes(s)) - .sort((a, b) => a.name.localeCompare(b.name)) + return ($participants ?? []) + .filter(p => + p.preferred_name?.toLowerCase().includes(s) || + p.email?.toLowerCase().includes(s) + ) + .sort((a, b) => (a.preferred_name || '').localeCompare(b.preferred_name || '')) .slice(0, 8) }) - const selected = $derived.by(() => { - if (filtered.length === 1) return filtered[0] - const s = search.trim().toLowerCase() - return filtered.find(a => a.ticket_id?.toLowerCase() === s) ?? null + // Auto-select when exactly one participant matches + const selectedParticipant = $derived.by(() => { + if (filteredParticipants.length === 1) return filteredParticipants[0] + return null }) + function ticketsFor(participantId) { + return ($tickets ?? []).filter(t => t.participant_id === participantId && !t.deleted_at) + } + function participantFor(ticket) { if (!ticket?.participant_id) return null return ($participants ?? []).find(p => p.id === ticket.participant_id) ?? null } + function nameFor(ticket) { + return ticket.name || participantFor(ticket)?.preferred_name || '(unknown)' + } + onMount(() => { qrSupported = 'BarcodeDetector' in window }) @@ -144,40 +140,6 @@ } } - async function checkIn(attendee, count = 1) { - error = '' - try { - const result = await api.attendees.checkIn(attendee.id, { count }) - if (result.attendee) { - await db.attendees.put(result.attendee) - } - } catch (err) { - error = err.message - } - } - - async function checkInWithVolunteer(attendee) { - error = '' - try { - const result = await api.attendees.checkIn(attendee.id, { count: 1, also_volunteer: true }) - if (result.attendee) await db.attendees.put(result.attendee) - if (result.volunteer) await db.volunteers.put(result.volunteer) - } catch (err) { - error = err.message - } - } - - function remaining(a) { - return (a.party_size ?? 1) - (a.checked_in_count ?? 0) - } - - function progressLabel(a) { - const ps = a.party_size ?? 1 - const ci = a.checked_in_count ?? 0 - if (ps <= 1) return null - return `${ci}/${ps} checked in` - } - function fmt(ts) { if (!ts) return '' return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) @@ -220,11 +182,11 @@
{error}
{/if} - - {#if matchedTicket && !selected} + + {#if matchedTicket} {@const p = participantFor(matchedTicket)}
-
{matchedTicket.name || p?.preferred_name || '(unknown)'}
+
{nameFor(matchedTicket)}
{#if matchedTicket.ticket_type}
{matchedTicket.ticket_type}
{/if} @@ -244,76 +206,71 @@ {/if}
- {/if} - - {#if selected} - {@const rem = remaining(selected)} - {@const prog = progressLabel(selected)} + + {:else if selectedParticipant} + {@const pts = ticketsFor(selectedParticipant.id)}
-
{selected.name}
- {#if selected.ticket_type} -
{selected.ticket_type}
+
{selectedParticipant.preferred_name || selectedParticipant.email || '(unknown)'}
+ {#if selectedParticipant.email} +
{selectedParticipant.email}
{/if} - {#if selected.ticket_id} -
#{selected.ticket_id}
- {/if} - {#if prog} -
- {prog} + {#if pts.length === 0} +
No tickets on file
+ {:else} +
+ {#each pts as tk (tk.id)} +
+ + {tk.name || '(unnamed)'} + {#if tk.ticket_type} · {tk.ticket_type}{/if} + + {#if tk.checked_in_at} + ✓ {fmt(tk.checked_in_at)} + {:else} + + {/if} +
+ {/each}
{/if} - -
- {#if rem > 0} - - {#if rem > 1} - - {/if} - {:else} - All checked in - {/if} - - {#if selected.volunteer_token && !selected.checked_in} - - {/if} -
- {:else if search.trim().length >= 2 && filtered.length > 1} - + + + {:else if search.trim().length >= 2 && filteredParticipants.length > 1}
- {#each filtered as a} - {/each}
- {:else if search.trim().length >= 2 && filtered.length === 0 && !matchedTicket} -
No matching attendees or tickets found.
+ + {:else if search.trim().length >= 2} +
No matching participants or tickets found.
{/if}
Recent Check-ins
{#if ($recentCheckIns ?? []).length === 0} -
No check-ins yet today.
+
No check-ins yet.
{:else} - {#each $recentCheckIns ?? [] as a} + {#each $recentCheckIns ?? [] as tk}
- {a.name} - {fmt(a.checked_in_at)} + {nameFor(tk)} + {fmt(tk.checked_in_at)}
{/each} {/if} @@ -458,6 +415,16 @@ align-items: center; } + .gate-ticket-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.4rem 0.6rem; + background: var(--c-bg); + border-radius: 6px; + font-size: 0.875rem; + } + .gate-results { background: var(--c-surface); border: 1px solid var(--c-border); diff --git a/frontend/src/pages/Participants.svelte b/frontend/src/pages/Participants.svelte index b567039..726851c 100644 --- a/frontend/src/pages/Participants.svelte +++ b/frontend/src/pages/Participants.svelte @@ -11,10 +11,25 @@ let generating = $state(false) let emailing = $state(false) let mergeMode = $state(false) - let mergeSource = $state(null) // participant being merged away - let mergeTarget = $state(null) // participant to keep + let mergeSource = $state(null) + let mergeTarget = $state(null) let expandedId = $state(null) - let expandedTickets = $state([]) + + // Add participant form + let showAdd = $state(false) + let adding = $state(false) + let newName = $state('') + let newEmail = $state('') + let newPhone = $state('') + let newPronouns = $state('') + let newNote = $state('') + + // Add ticket form (per participant) + let addTicketFor = $state(null) // participant id + let addingTicket = $state(false) + let newTicketName = $state('') + let newTicketType = $state('') + let newTicketExtId = $state('') const role = $derived(session?.user?.role ?? '') const canManage = $derived(['admin', 'ticketing'].includes(role)) @@ -114,6 +129,45 @@ if (!ts) return '' return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) } + + async function addParticipant(e) { + e.preventDefault() + adding = true; error = '' + try { + const p = await api.participants.create({ + preferred_name: newName, email: newEmail, phone: newPhone, + pronouns: newPronouns, note: newNote, + }) + await db.participants.put(p) + showAdd = false + newName = newEmail = newPhone = newPronouns = newNote = '' + } catch (err) { + error = err.message + } finally { + adding = false + } + } + + async function addTicket(e, participantId) { + e.preventDefault() + addingTicket = true; error = '' + try { + const tk = await api.tickets.create({ + participant_id: participantId, + name: newTicketName, + ticket_type: newTicketType, + external_id: newTicketExtId, + source: 'manual', + }) + await db.tickets.put(tk) + addTicketFor = null + newTicketName = newTicketType = newTicketExtId = '' + } catch (err) { + error = err.message + } finally { + addingTicket = false + } + }
@@ -121,6 +175,7 @@

Participants

{#if canManage} + Export CSV
+ {#if showAdd && canManage} +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ {/if} + {#if mergeMode && mergeSource}
@@ -236,7 +326,7 @@ {/if} - {#if isExpanded && pts.length > 0} + {#if isExpanded}
@@ -270,6 +360,24 @@
{/each} + {#if canManage} + {#if addTicketFor === p.id} +
addTicket(e, p.id)}> + + + + + +
+ {:else} + + {/if} + {/if}
@@ -294,6 +402,21 @@ background: var(--c-surface); font-size: 0.875rem; } + .ticket-add-form { + display: flex; + gap: 0.4rem; + align-items: center; + flex-wrap: wrap; + padding: 0.4rem 0.6rem; + background: var(--c-bg); + border-radius: 6px; + border: 1px dashed var(--c-border); + } + .ticket-add-form input { + min-width: 0; + font-size: 0.825rem; + padding: 0.3rem 0.5rem; + } .badge-partial { background: rgba(245,158,11,0.15); color: var(--c-warn); diff --git a/handle_participants.go b/handle_participants.go index 3a5b290..6277824 100644 --- a/handle_participants.go +++ b/handle_participants.go @@ -131,6 +131,28 @@ func (app *App) handleExportParticipants(w http.ResponseWriter, r *http.Request) wr.Flush() } +func (app *App) handleCreateTicket(w http.ResponseWriter, r *http.Request) { + var t Ticket + if err := json.NewDecoder(r.Body).Decode(&t); err != nil { + writeError(w, "invalid request", http.StatusBadRequest) + return + } + if t.ParticipantID == nil { + writeError(w, "participant_id is required", http.StatusBadRequest) + return + } + if t.Source == "" { + t.Source = "manual" + } + created, err := app.createTicket(t) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + writeJSON(w, created) +} + func (app *App) handleListTickets(w http.ResponseWriter, r *http.Request) { tickets, err := app.listTickets(nil, "") if err != nil { diff --git a/main.go b/main.go index 876e756..ee2db53 100644 --- a/main.go +++ b/main.go @@ -120,6 +120,7 @@ func (app *App) registerRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /api/participants/{id}/merge/{other_id}", auth(app.handleMergeParticipants, "admin", "ticketing")) mux.HandleFunc("GET /api/tickets", auth(app.handleListTickets, "admin", "ticketing", "gatekeeper")) + mux.HandleFunc("POST /api/tickets", auth(app.handleCreateTicket, "admin", "ticketing")) mux.HandleFunc("POST /api/tickets/{id}/checkin", auth(app.handleCheckInTicket, "admin", "ticketing", "gatekeeper")) mux.HandleFunc("POST /api/tickets/generate-codes", auth(app.handleGenerateTokens, "admin", "ticketing")) mux.HandleFunc("GET /api/tickets/export-links", auth(app.handleExportTokenLinks, "admin", "ticketing"))