452 lines
12 KiB
Svelte
452 lines
12 KiB
Svelte
|
|
<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> <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>
|