Turnpike/frontend/src/pages/GateUI.svelte

452 lines
12 KiB
Svelte
Raw Normal View History

<script>
import { onMount, onDestroy } from 'svelte'
import { liveQuery } from 'dexie'
import { db } from '../db.js'
import { api } from '../api.js'
let { session, onLogout } = $props()
let search = $state('')
let error = $state('')
let scannerMsg = $state('')
let qrSupported = $state(false)
let scanning = $state(false)
let videoRef = $state(null)
let stream = $state(null)
let detector = $state(null)
let scanInterval = $state(null)
const attendees = liveQuery(() =>
db.attendees.filter(a => !a.deleted_at).toArray()
)
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 filtered = $derived.by(() => {
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))
.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
})
onMount(() => {
qrSupported = 'BarcodeDetector' in window
})
onDestroy(() => {
stopScanner()
})
async function toggleScanner() {
if (scanning) {
stopScanner()
} else {
await startScanner()
}
}
async function startScanner() {
scannerMsg = ''
try {
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })
if (videoRef) videoRef.srcObject = stream
detector = new BarcodeDetector({ formats: ['qr_code'] })
scanning = true
scanInterval = setInterval(doScan, 150)
} catch (err) {
scannerMsg = 'Camera access denied or unavailable.'
}
}
function stopScanner() {
clearInterval(scanInterval)
scanInterval = null
stream?.getTracks().forEach(t => t.stop())
stream = null
scanning = false
if (videoRef) videoRef.srcObject = null
}
async function doScan() {
if (!detector || !videoRef || videoRef.readyState < 2) return
try {
const codes = await detector.detect(videoRef)
if (codes.length > 0) {
const val = codes[0].rawValue
search = val
stopScanner()
}
} catch {}
}
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' })
}
</script>
<div class="gate">
<div class="gate-header">
<div class="gate-brand">Turn<span>pike</span> &nbsp;<span class="gate-role">Gate Check-in</span></div>
<button class="gate-logout" onclick={onLogout}>Sign out</button>
</div>
<div class="gate-body">
<!-- Search + QR -->
<div class="gate-search-row">
<input
class="gate-search"
placeholder="Search name, ticket ID, or scan QR…"
bind:value={search}
/>
{#if qrSupported}
<button class="gbtn {scanning ? 'gbtn-danger' : 'gbtn-ghost'}" onclick={toggleScanner}>
{scanning ? '■ Stop' : '⊡ Scan QR'}
</button>
{/if}
</div>
{#if scanning}
<div class="gate-scanner">
<video bind:this={videoRef} autoplay playsinline muted class="gate-video"></video>
<div class="gate-scanner-hint">Point camera at QR code on ticket</div>
</div>
{/if}
{#if scannerMsg}
<div class="gate-msg gate-msg-warn">{scannerMsg}</div>
{/if}
{#if error}
<div class="gate-msg gate-msg-error">{error}</div>
{/if}
<!-- Matched attendee card -->
{#if selected}
{@const rem = remaining(selected)}
{@const prog = progressLabel(selected)}
<div class="gate-match">
<div class="gate-match-name">{selected.name}</div>
{#if selected.ticket_type}
<div class="gate-match-sub">{selected.ticket_type}</div>
{/if}
{#if selected.ticket_id}
<div class="gate-match-sub text-muted">#{selected.ticket_id}</div>
{/if}
{#if prog}
<div class="gate-party">
<span class="gate-party-label">{prog}</span>
</div>
{/if}
<div class="gate-match-actions">
{#if rem > 0}
<button class="gbtn gbtn-success" onclick={() => checkIn(selected, 1)}>
✓ Check in 1
</button>
{#if rem > 1}
<button class="gbtn gbtn-ghost" onclick={() => checkIn(selected, rem)}>
Check in all {rem}
</button>
{/if}
{:else}
<span class="gate-done">All checked in</span>
{/if}
{#if selected.volunteer_token && !selected.checked_in}
<button class="gbtn gbtn-ghost" onclick={() => checkInWithVolunteer(selected)}>
+ Volunteer
</button>
{/if}
</div>
</div>
{:else if search.trim().length >= 2 && filtered.length > 1}
<!-- Multiple results list -->
<div class="gate-results">
{#each filtered as a}
<button class="gate-result-row" onclick={() => search = a.ticket_id || a.name}>
<span>
<strong>{a.name}</strong>
{#if a.ticket_type} · {a.ticket_type}{/if}
</span>
<span class="badge {a.checked_in ? 'badge-checked' : 'badge-unchecked'}">
{a.checked_in ? 'In' : 'Pending'}
</span>
</button>
{/each}
</div>
{:else if search.trim().length >= 2 && filtered.length === 0}
<div class="gate-msg gate-msg-warn">No matching attendees found.</div>
{/if}
<!-- Recent check-ins -->
<div class="gate-recent">
<div class="gate-recent-title">Recent Check-ins</div>
{#if ($recentCheckIns ?? []).length === 0}
<div class="gate-recent-empty">No check-ins yet today.</div>
{:else}
{#each $recentCheckIns ?? [] as a}
<div class="gate-recent-row">
<span>{a.name}</span>
<span class="text-muted">{fmt(a.checked_in_at)}</span>
</div>
{/each}
{/if}
</div>
</div>
</div>
<style>
.gate {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--c-bg);
color: var(--c-text);
font-family: var(--font);
}
.gate-header {
background: var(--c-surface);
border-bottom: 1px solid var(--c-border);
padding: 0.85rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.gate-brand {
font-size: 1.1rem;
font-weight: 700;
letter-spacing: -0.02em;
}
.gate-brand span:first-of-type { color: var(--c-accent); }
.gate-role {
font-size: 0.8rem;
font-weight: 400;
color: var(--c-muted);
letter-spacing: 0;
}
.gate-logout {
background: none;
border: none;
color: var(--c-muted);
cursor: pointer;
font-size: 0.8rem;
font-family: var(--font);
padding: 0.3rem 0.6rem;
border-radius: 4px;
}
.gate-logout:hover { color: var(--c-text); background: rgba(255,255,255,0.05); }
.gate-body {
max-width: 680px;
margin: 0 auto;
padding: 1.5rem 1rem;
width: 100%;
}
.gate-search-row {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.gate-search {
flex: 1;
font-size: 1.1rem;
padding: 0.7rem 1rem;
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: 8px;
color: var(--c-text);
font-family: var(--font);
}
.gate-search:focus { outline: none; border-color: var(--c-accent); }
.gate-scanner {
margin-bottom: 1rem;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--c-border);
position: relative;
}
.gate-video {
width: 100%;
display: block;
max-height: 280px;
object-fit: cover;
background: #000;
}
.gate-scanner-hint {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0,0,0,0.6);
color: #fff;
text-align: center;
padding: 0.4rem;
font-size: 0.78rem;
}
.gate-msg {
padding: 0.7rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.gate-msg-error { background: rgba(239,68,68,0.12); border: 1px solid rgba(239,68,68,0.3); color: #fca5a5; }
.gate-msg-warn { background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.3); color: var(--c-warn); }
.gate-match {
background: var(--c-surface);
border: 1px solid var(--c-accent);
border-radius: 10px;
padding: 1.25rem;
margin-bottom: 1rem;
}
.gate-match-name { font-size: 1.4rem; font-weight: 700; margin-bottom: 0.2rem; }
.gate-match-sub { color: var(--c-muted); font-size: 0.875rem; }
.gate-party {
margin: 0.5rem 0;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.gate-party-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--c-warn);
background: rgba(245,158,11,0.15);
padding: 0.2rem 0.6rem;
border-radius: 99px;
}
.gate-match-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 1rem;
}
.gate-done {
color: var(--c-success);
font-size: 0.875rem;
font-weight: 600;
display: flex;
align-items: center;
}
.gate-results {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: 8px;
overflow: hidden;
margin-bottom: 1rem;
}
.gate-result-row {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: none;
border: none;
border-bottom: 1px solid var(--c-border);
color: var(--c-text);
font-family: var(--font);
font-size: 0.875rem;
cursor: pointer;
text-align: left;
}
.gate-result-row:last-child { border-bottom: none; }
.gate-result-row:hover { background: rgba(255,255,255,0.04); }
.gate-recent { margin-top: 2rem; }
.gate-recent-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--c-muted);
margin-bottom: 0.5rem;
}
.gate-recent-empty { color: var(--c-muted); font-size: 0.875rem; }
.gate-recent-row {
display: flex;
justify-content: space-between;
padding: 0.4rem 0;
font-size: 0.875rem;
border-bottom: 1px solid var(--c-border);
}
.gate-recent-row:last-child { border-bottom: none; }
.gbtn {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.5rem 1rem; border-radius: 6px;
border: 1px solid transparent;
font-size: 0.875rem; font-weight: 500; cursor: pointer;
font-family: var(--font); white-space: nowrap;
transition: background 150ms, border-color 150ms;
}
.gbtn:disabled { opacity: 0.5; cursor: not-allowed; }
.gbtn-success { background: var(--c-success); color: #000; font-weight: 600; }
.gbtn-success:hover:not(:disabled) { filter: brightness(1.1); }
.gbtn-danger { background: var(--c-danger); color: #fff; }
.gbtn-danger:hover:not(:disabled) { filter: brightness(1.1); }
.gbtn-ghost { background: transparent; color: var(--c-muted); border-color: var(--c-border); }
.gbtn-ghost:hover:not(:disabled) { color: var(--c-text); border-color: var(--c-text); }
@media (max-width: 480px) {
.gate-match-name { font-size: 1.2rem; }
.gate-search { font-size: 1rem; }
}
</style>