Turnpike/frontend/src/pages/Kiosk.svelte
Pen Anderson 1033cdb29b Created Turnpike, event attendee and volunteer management
Built after prototype, Traverse, an attendee and volunteer list
maintainer.
2026-03-03 18:07:38 -06:00

356 lines
11 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script>
import { onMount } from 'svelte'
import { api } from '../api.js'
// Token comes from the URL hash: /#/v/TOKEN
const token = $derived(window.location.hash.match(/^#\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
let state = $state(null) // { volunteer, shifts, available }
let loading = $state(true)
let error = $state('')
// Conflict dialog state
let conflictShift = $state(null)
let conflictingShifts = $state([])
let claiming = $state(false)
$effect(() => {
if (token) loadState()
})
async function loadState() {
loading = true
error = ''
try {
state = await api.kiosk.get(token)
} catch (err) {
error = err.message
} finally {
loading = false
}
}
async function claim(shift) {
claiming = true
error = ''
try {
const result = await api.kiosk.claim(token, shift.id)
if (result.conflict) {
conflictShift = shift
conflictingShifts = result.conflicting_shifts ?? []
claiming = false
return
}
state = result
} catch (err) {
error = err.message
} finally {
claiming = false
}
}
async function claimForce() {
if (!conflictShift) return
claiming = true
error = ''
try {
const result = await api.kiosk.claim(token, conflictShift.id, true)
if (!result.conflict) {
state = result
conflictShift = null
conflictingShifts = []
}
} catch (err) {
error = err.message
} finally {
claiming = false
}
}
async function unclaim(shiftId) {
error = ''
try {
state = await api.kiosk.unclaim(token, shiftId)
} catch (err) {
error = err.message
}
}
function fmt(t) {
if (!t) return ''
const [h, m] = t.split(':').map(Number)
const ampm = h < 12 ? 'am' : 'pm'
return `${h % 12 || 12}:${String(m).padStart(2, '0')}${ampm}`
}
function groupByDay(shifts) {
const days = {}
for (const s of shifts) {
if (!days[s.day]) days[s.day] = []
days[s.day].push(s)
}
return Object.entries(days).sort(([a], [b]) => a.localeCompare(b))
}
const isAssigned = $derived((shiftId) =>
state?.shifts?.some(s => s.id === shiftId) ?? false
)
</script>
<div class="kiosk">
<div class="kiosk-header">
<div class="kiosk-brand">Turn<span>pike</span> &nbsp;<span class="kiosk-role">Volunteer Portal</span></div>
</div>
{#if !token}
<div class="kiosk-body">
<div class="kiosk-error">No volunteer token found in URL.<br>Check the link you were sent.</div>
</div>
{:else if loading}
<div class="kiosk-body kiosk-center">Loading…</div>
{:else if error && !state}
<div class="kiosk-body">
<div class="kiosk-error">{error}</div>
</div>
{:else if state}
<div class="kiosk-body">
{#if error}
<div class="kiosk-alert">{error}</div>
{/if}
<!-- Conflict dialog -->
{#if conflictShift}
<div class="kiosk-overlay">
<div class="kiosk-dialog">
<h3>Scheduling Conflict</h3>
<p>
<strong>{conflictShift.name}</strong> ({fmt(conflictShift.start_time)}{fmt(conflictShift.end_time)})
overlaps with:
</p>
<ul>
{#each conflictingShifts as s}
<li>{s.name}{fmt(s.start_time)}{fmt(s.end_time)}</li>
{/each}
</ul>
<p class="kiosk-muted">You can still sign up — just confirm you're aware of the overlap.</p>
<div class="kiosk-actions">
<button class="kbtn kbtn-primary" onclick={claimForce} disabled={claiming}>
{claiming ? '…' : 'Sign up anyway'}
</button>
<button class="kbtn kbtn-ghost" onclick={() => { conflictShift = null; conflictingShifts = [] }}>
Cancel
</button>
</div>
</div>
</div>
{/if}
<!-- Volunteer header -->
<div class="kiosk-card">
<div class="kiosk-vol-name">{state.volunteer.name}</div>
<div class="kiosk-vol-meta">
{state.volunteer.email || ''}
{state.volunteer.is_lead ? ' · Department Lead' : ''}
</div>
<div class="kiosk-token">Token: <code>{token}</code></div>
</div>
<!-- Assigned shifts -->
{#if state.shifts.length > 0}
<section>
<h2 class="kiosk-section-title">My Shifts</h2>
{#each groupByDay(state.shifts) as [day, shifts]}
<div class="kiosk-day-label">{day}</div>
{#each shifts as s}
<div class="kiosk-shift-card kiosk-shift-assigned">
<div class="kiosk-shift-info">
<strong>{s.name}</strong>
<span class="kiosk-time">{fmt(s.start_time)} {fmt(s.end_time)}</span>
</div>
<button class="kbtn kbtn-ghost kbtn-sm" onclick={() => unclaim(s.id)}>Remove</button>
</div>
{/each}
{/each}
</section>
{:else}
<div class="kiosk-empty">You haven't signed up for any shifts yet.</div>
{/if}
<!-- Available shifts -->
{#if state.available.length > 0}
<section>
<h2 class="kiosk-section-title">Available Shifts</h2>
{#each groupByDay(state.available) as [day, shifts]}
<div class="kiosk-day-label">{day}</div>
{#each shifts as s}
{@const assigned = isAssigned(s.id)}
{#if !assigned}
<div class="kiosk-shift-card">
<div class="kiosk-shift-info">
<strong>{s.name}</strong>
<span class="kiosk-time">{fmt(s.start_time)} {fmt(s.end_time)}</span>
{#if s.capacity > 0}
<span class="kiosk-cap">
{s.capacity} spots
</span>
{/if}
</div>
<button class="kbtn kbtn-primary kbtn-sm" onclick={() => claim(s)} disabled={claiming}>
Sign up
</button>
</div>
{/if}
{/each}
{/each}
</section>
{:else if state.shifts.length === 0}
<div class="kiosk-empty">No shifts are currently available in your department.</div>
{/if}
</div>
{/if}
</div>
<style>
.kiosk {
min-height: 100vh;
background: var(--c-bg);
color: var(--c-text);
font-family: var(--font);
display: flex;
flex-direction: column;
}
.kiosk-header {
background: var(--c-surface);
border-bottom: 1px solid var(--c-border);
padding: 1rem 1.5rem;
display: flex;
align-items: center;
}
.kiosk-brand {
font-size: 1.1rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--c-text);
}
.kiosk-brand span:first-of-type { color: var(--c-accent); }
.kiosk-role {
font-size: 0.8rem;
font-weight: 400;
color: var(--c-muted);
letter-spacing: 0;
}
.kiosk-body {
max-width: 640px;
margin: 0 auto;
padding: 1.5rem 1rem;
width: 100%;
}
.kiosk-center { display: flex; align-items: center; justify-content: center; }
.kiosk-error {
background: rgba(239,68,68,0.12);
border: 1px solid rgba(239,68,68,0.3);
color: #fca5a5;
padding: 1rem;
border-radius: 8px;
margin: 2rem 0;
text-align: center;
line-height: 1.7;
}
.kiosk-alert {
background: rgba(239,68,68,0.1);
border: 1px solid rgba(239,68,68,0.25);
color: #fca5a5;
padding: 0.75rem 1rem;
border-radius: 6px;
margin-bottom: 1rem;
font-size: 0.875rem;
}
.kiosk-card {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: 10px;
padding: 1.25rem;
margin-bottom: 1.5rem;
}
.kiosk-vol-name { font-size: 1.3rem; font-weight: 700; margin-bottom: 0.2rem; }
.kiosk-vol-meta { color: var(--c-muted); font-size: 0.875rem; margin-bottom: 0.5rem; }
.kiosk-token { font-size: 0.78rem; color: var(--c-muted); }
.kiosk-token code {
background: rgba(99,102,241,0.15);
color: var(--c-accent-h);
padding: 0.1rem 0.4rem;
border-radius: 4px;
font-size: 0.85em;
letter-spacing: 0.08em;
}
.kiosk-section-title {
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--c-muted);
margin: 1.25rem 0 0.5rem;
}
.kiosk-day-label {
font-size: 0.78rem;
color: var(--c-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0.75rem 0 0.35rem;
}
.kiosk-shift-card {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: 8px;
padding: 0.9rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.5rem;
}
.kiosk-shift-assigned { border-color: rgba(99,102,241,0.35); }
.kiosk-shift-info { display: flex; flex-direction: column; gap: 0.2rem; }
.kiosk-shift-info strong { font-size: 0.9rem; }
.kiosk-time { font-size: 0.8rem; color: var(--c-muted); }
.kiosk-cap { font-size: 0.75rem; color: var(--c-muted); }
.kiosk-empty { color: var(--c-muted); font-size: 0.875rem; padding: 1rem 0; }
.kbtn {
display: inline-flex; align-items: center;
padding: 0.45rem 1rem; border-radius: 6px;
border: 1px solid transparent;
font-size: 0.875rem; font-weight: 500; cursor: pointer;
white-space: nowrap;
font-family: var(--font);
transition: background 150ms, border-color 150ms;
}
.kbtn:disabled { opacity: 0.5; cursor: not-allowed; }
.kbtn-sm { padding: 0.3rem 0.65rem; font-size: 0.8rem; }
.kbtn-primary { background: var(--c-accent); color: #fff; }
.kbtn-primary:hover:not(:disabled) { background: var(--c-accent-h); }
.kbtn-ghost { background: transparent; color: var(--c-muted); border-color: var(--c-border); }
.kbtn-ghost:hover:not(:disabled) { color: var(--c-text); border-color: var(--c-text); }
.kiosk-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center;
z-index: 100;
padding: 1rem;
}
.kiosk-dialog {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: 12px;
padding: 1.5rem;
max-width: 440px;
width: 100%;
}
.kiosk-dialog h3 { font-size: 1.1rem; font-weight: 700; margin-bottom: 0.75rem; }
.kiosk-dialog p { font-size: 0.875rem; margin-bottom: 0.75rem; line-height: 1.6; }
.kiosk-dialog ul { margin: 0.5rem 0 0.75rem 1.25rem; font-size: 0.875rem; line-height: 1.7; color: var(--c-muted); }
.kiosk-muted { color: var(--c-muted) !important; }
.kiosk-actions { display: flex; gap: 0.5rem; margin-top: 1rem; }
</style>