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}
-
- {: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}
+
showAdd = !showAdd}>+ Add
Export CSV
{generating ? '…' : '⚿ Generate Codes'}
@@ -133,6 +188,41 @@
+ {#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}
+
+ {:else}
+ { addTicketFor = p.id; newTicketName = newTicketType = newTicketExtId = '' }}>
+ + Add ticket
+
+ {/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"))