Added volunteer signup.

This commit is contained in:
Pen Anderson 2026-03-03 17:59:35 -06:00
parent ace7f11a60
commit 8dc5d3ed01
12 changed files with 1258 additions and 49 deletions

View file

@ -11,6 +11,8 @@
import Users from './pages/Users.svelte'
import Import from './pages/Import.svelte'
import Kiosk from './pages/Kiosk.svelte'
import VolunteerSignup from './pages/VolunteerSignup.svelte'
import ConfirmEmail from './pages/ConfirmEmail.svelte'
import GateUI from './pages/GateUI.svelte'
import ScheduleBoard from './pages/ScheduleBoard.svelte'
import Settings from './pages/Settings.svelte'
@ -25,8 +27,11 @@
let updateAvailable = $state(false)
let mobileNavOpen = $state(false)
// Check if this is a kiosk token URL before doing anything else
// Check if this is a public page (no auth needed)
const kioskToken = $derived(window.location.hash.match(/^#\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
const isVolunteerSignup = $derived(window.location.hash.startsWith('#/volunteer-signup'))
const isConfirmEmail = $derived(window.location.hash.startsWith('#/confirm/'))
const isPublicPage = $derived(!!kioskToken || isVolunteerSignup || isConfirmEmail)
async function checkVersion() {
try {
@ -39,8 +44,8 @@
onMount(async () => {
checkVersion()
// Kiosk pages don't need auth
if (kioskToken) {
// Public pages don't need auth
if (isPublicPage) {
loading = false
return
}
@ -87,6 +92,10 @@
<!-- checking session -->
{:else if kioskToken}
<Kiosk />
{:else if isVolunteerSignup}
<VolunteerSignup />
{:else if isConfirmEmail}
<ConfirmEmail />
{:else if !session}
<Login onlogin={onLogin} />
{:else if role === 'gate'}

View file

@ -109,6 +109,12 @@ export const api = {
get: () => apiJSON('/api/settings'),
update: (data) => apiJSON('/api/settings', { method: 'PUT', body: JSON.stringify(data) }),
testEmail: (to) => apiJSON('/api/settings/test-email', { method: 'POST', body: JSON.stringify({ to }) }),
toggleShiftSignups: (open) => apiJSON('/api/settings/shift-signups', { method: 'POST', body: JSON.stringify({ open }) }),
},
signup: {
config: () => kioskFetch('/api/public/signup-config'),
submit: (data) => kioskFetch('/api/public/signup', { method: 'POST', body: JSON.stringify(data) }),
confirm: (token) => kioskFetch('/api/public/confirm', { method: 'POST', body: JSON.stringify({ token }) }),
},
import: async (formData) => {
const res = await apiFetch('/api/import', { method: 'POST', body: formData })

View file

@ -96,3 +96,50 @@ describe('api methods', () => {
expect(f.mock.calls[0][0]).toBe('/api/sync/pull')
})
})
describe('signup methods', () => {
it('signup.config fetches config without auth', async () => {
const f = mockFetch({ departments: [], volunteer_note_label: 'Note' })
await api.signup.config()
const [url, opts] = f.mock.calls[0]
expect(url).toBe('/api/public/signup-config')
expect(opts.headers['Authorization']).toBeUndefined()
})
it('signup.submit posts form data without auth', async () => {
const f = mockFetch({ ok: true })
await api.signup.submit({ preferred_name: 'Titania', email: 'titania@example.com' })
const [url, opts] = f.mock.calls[0]
expect(url).toBe('/api/public/signup')
expect(opts.method).toBe('POST')
expect(JSON.parse(opts.body)).toEqual({ preferred_name: 'Titania', email: 'titania@example.com' })
expect(opts.headers['Authorization']).toBeUndefined()
})
it('signup.confirm posts token without auth', async () => {
const f = mockFetch({ status: 'confirmed' })
await api.signup.confirm('abc123')
const [url, opts] = f.mock.calls[0]
expect(url).toBe('/api/public/confirm')
expect(opts.method).toBe('POST')
expect(JSON.parse(opts.body)).toEqual({ token: 'abc123' })
expect(opts.headers['Authorization']).toBeUndefined()
})
it('signup.submit throws on 400', async () => {
mockFetch({ error: 'preferred name and email are required' }, 400)
await expect(api.signup.submit({})).rejects.toThrow('preferred name and email are required')
})
})
describe('settings shift signups', () => {
it('toggleShiftSignups posts open flag', async () => {
await saveSession('tok', { id: 1 })
const f = mockFetch({ shift_signups_open: true })
await api.settings.toggleShiftSignups(true)
const [url, opts] = f.mock.calls[0]
expect(url).toBe('/api/settings/shift-signups')
expect(opts.method).toBe('POST')
expect(JSON.parse(opts.body)).toEqual({ open: true })
})
})

View file

@ -0,0 +1,148 @@
<script>
import { onMount } from 'svelte'
import { api } from '../api.js'
let status = $state('loading')
let kioskLink = $state('')
let error = $state('')
onMount(async () => {
const match = window.location.hash.match(/^#\/confirm\/(.+)/)
const token = match?.[1] ?? ''
if (!token) {
status = 'invalid'
return
}
try {
const result = await api.signup.confirm(token)
status = result.status ?? 'invalid'
if (result.kiosk_link) kioskLink = result.kiosk_link
} catch (err) {
error = err.message
status = 'error'
}
})
</script>
<div class="kiosk">
<div class="kiosk-header">
<div class="kiosk-brand">Turn<span>pike</span> &nbsp;<span class="kiosk-role">Email Confirmation</span></div>
</div>
<div class="kiosk-body">
{#if status === 'loading'}
<div class="kiosk-center">Confirming...</div>
{:else if status === 'confirmed'}
<div class="kiosk-card" style="text-align:center">
<div class="confirm-icon">&#10003;</div>
<h2 style="font-size:1.2rem;font-weight:700;margin-bottom:0.5rem">Email Confirmed</h2>
<p style="color:var(--c-muted);line-height:1.6;margin:0">
Your email address has been verified. Thank you for signing up!
</p>
{#if kioskLink}
<div class="kiosk-link-box">
<p style="color:var(--c-text);font-weight:600;margin-bottom:0.5rem">Shift signups are open!</p>
<a href={kioskLink} class="kbtn kbtn-primary">Choose Your Shifts</a>
</div>
{/if}
</div>
{:else if status === 'already_confirmed'}
<div class="kiosk-card" style="text-align:center">
<h2 style="font-size:1.2rem;font-weight:700;margin-bottom:0.5rem">Already Confirmed</h2>
<p style="color:var(--c-muted);line-height:1.6;margin:0">
This email address was already confirmed. No further action needed.
</p>
</div>
{:else if status === 'error'}
<div class="kiosk-error">{error}</div>
{:else}
<div class="kiosk-card" style="text-align:center">
<h2 style="font-size:1.2rem;font-weight:700;margin-bottom:0.5rem">Invalid Link</h2>
<p style="color:var(--c-muted);line-height:1.6;margin:0">
This confirmation link is not valid or has already been used.
</p>
</div>
{/if}
</div>
</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: 540px;
margin: 0 auto;
padding: 1.5rem 1rem;
width: 100%;
}
.kiosk-center { display: flex; align-items: center; justify-content: center; padding: 2rem 0; }
.kiosk-card {
background: var(--c-surface);
border: 1px solid var(--c-border);
border-radius: 10px;
padding: 2rem 1.5rem;
}
.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;
text-align: center;
line-height: 1.7;
}
.confirm-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(34,197,94,0.15);
color: #4ade80;
font-size: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1rem;
}
.kiosk-link-box {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--c-border);
}
.kbtn {
display: inline-flex; align-items: center; justify-content: center;
padding: 0.55rem 1.25rem; border-radius: 6px;
border: 1px solid transparent;
font-size: 0.875rem; font-weight: 500; cursor: pointer;
font-family: var(--font);
text-decoration: none;
transition: background 150ms;
}
.kbtn-primary { background: var(--c-accent); color: #fff; }
.kbtn-primary:hover { background: var(--c-accent-h); }
</style>

View file

@ -16,6 +16,10 @@
let smtpFromName = $state('')
let baseURL = $state('')
let testEmail = $state('')
let noteLabel = $state('Additional note')
let noteRequired = $state(false)
let shiftSignupsOpen = $state(false)
let togglingSignups = $state(false)
onMount(async () => {
try {
@ -27,6 +31,9 @@
smtpFrom = s.smtp_from ?? ''
smtpFromName = s.smtp_from_name ?? ''
baseURL = s.base_url ?? ''
noteLabel = s.volunteer_note_label ?? 'Additional note'
noteRequired = s.volunteer_note_required ?? false
shiftSignupsOpen = s.shift_signups_open ?? false
} catch (err) {
error = err.message
} finally {
@ -48,6 +55,8 @@
smtp_from: smtpFrom,
smtp_from_name: smtpFromName,
base_url: baseURL,
volunteer_note_label: noteLabel,
volunteer_note_required: noteRequired,
})
smtpPassword = ''
success = 'Settings saved.'
@ -58,6 +67,23 @@
}
}
async function toggleSignups() {
const opening = !shiftSignupsOpen
if (opening && !confirm('This will email all confirmed volunteers their shift signup links. Continue?')) return
togglingSignups = true
error = ''
success = ''
try {
const result = await api.settings.toggleShiftSignups(opening)
shiftSignupsOpen = result.shift_signups_open
success = opening ? 'Shift signups opened. Emails are being sent.' : 'Shift signups closed.'
} catch (err) {
error = err.message
} finally {
togglingSignups = false
}
}
async function sendTest() {
if (!testEmail) return
testing = true
@ -135,7 +161,7 @@
</form>
<!-- Test email -->
<div class="card">
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Test Email</h2>
<div style="display:flex;gap:0.5rem;align-items:flex-end">
<div class="form-group" style="flex:1;margin-bottom:0">
@ -147,5 +173,44 @@
</button>
</div>
</div>
<!-- Volunteer Signup -->
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Volunteer Signup</h2>
<div class="form-group">
<label for="s-note-label">Note Field Label</label>
<input id="s-note-label" bind:value={noteLabel} placeholder="Additional note" />
</div>
<label style="display:flex;align-items:center;gap:0.5rem;font-size:0.875rem;cursor:pointer">
<input type="checkbox" bind:checked={noteRequired} />
Note field is required
</label>
<p class="text-muted" style="font-size:0.78rem;margin-top:0.75rem">
Signup form: <a href="/#/volunteer-signup" target="_blank" style="color:var(--c-accent)">/#/volunteer-signup</a>
</p>
</div>
<!-- Shift Signups -->
<div class="card">
<h2 style="font-size:0.95rem;font-weight:700;margin-bottom:1rem">Shift Signups</h2>
<div style="display:flex;align-items:center;gap:1rem">
<span style="font-size:0.875rem">
Status: <strong>{shiftSignupsOpen ? 'Open' : 'Closed'}</strong>
</span>
<button class="btn {shiftSignupsOpen ? 'btn-ghost' : 'btn-primary'}"
onclick={toggleSignups} disabled={togglingSignups}>
{#if togglingSignups}
Working…
{:else}
{shiftSignupsOpen ? 'Close Signups' : 'Open Signups'}
{/if}
</button>
</div>
{#if !shiftSignupsOpen}
<p class="text-muted" style="font-size:0.78rem;margin-top:0.75rem">
Opening signups will email all confirmed volunteers their shift signup links.
</p>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,241 @@
<script>
import { onMount } from 'svelte'
import { api } from '../api.js'
let loading = $state(true)
let submitting = $state(false)
let error = $state('')
let submitted = $state(false)
let config = $state(null)
let preferredName = $state('')
let ticketName = $state('')
let email = $state('')
let pronouns = $state('')
let phone = $state('')
let departmentId = $state('')
let note = $state('')
onMount(async () => {
try {
config = await api.signup.config()
} catch (err) {
error = err.message
} finally {
loading = false
}
})
async function submit(e) {
e.preventDefault()
submitting = true
error = ''
try {
const data = {
preferred_name: preferredName.trim(),
email: email.trim(),
}
if (ticketName.trim()) data.ticket_name = ticketName.trim()
if (pronouns.trim()) data.pronouns = pronouns.trim()
if (phone.trim()) data.phone = phone.trim()
if (departmentId) data.department_id = Number(departmentId)
if (note.trim()) data.note = note.trim()
await api.signup.submit(data)
submitted = true
} catch (err) {
error = err.message
} finally {
submitting = false
}
}
</script>
<div class="kiosk">
<div class="kiosk-header">
<div class="kiosk-brand">Turn<span>pike</span> &nbsp;<span class="kiosk-role">Volunteer Signup</span></div>
</div>
<div class="kiosk-body">
{#if loading}
<div class="kiosk-center">Loading...</div>
{:else if submitted}
<div class="kiosk-card" style="text-align:center">
<h2 style="font-size:1.3rem;font-weight:700;margin-bottom:0.75rem">Thank you!</h2>
<p style="color:var(--c-muted);line-height:1.6;margin:0">
We've sent a confirmation email to <strong style="color:var(--c-text)">{email}</strong>.
Please check your inbox and click the link to confirm your signup.
</p>
</div>
{:else}
{#if config?.event_name && config.event_name !== 'the event'}
<h2 class="signup-event-name">{config.event_name}</h2>
{/if}
{#if error}
<div class="kiosk-alert">{error}</div>
{/if}
<form onsubmit={submit}>
<div class="kiosk-card">
<div class="signup-field">
<label for="s-name">Preferred Name <span class="req">*</span></label>
<input id="s-name" bind:value={preferredName} required placeholder="What should we call you?" />
</div>
<div class="signup-field">
<label for="s-ticket">Ticket Name</label>
<input id="s-ticket" bind:value={ticketName} placeholder="Name on your ticket (if different)" />
</div>
<div class="signup-field">
<label for="s-email">Email <span class="req">*</span></label>
<input id="s-email" type="email" bind:value={email} required placeholder="you@example.com" />
</div>
<div class="signup-row">
<div class="signup-field">
<label for="s-pronouns">Pronouns</label>
<input id="s-pronouns" bind:value={pronouns} placeholder="e.g. she/her" />
</div>
<div class="signup-field">
<label for="s-phone">Phone</label>
<input id="s-phone" type="tel" bind:value={phone} placeholder="Optional" />
</div>
</div>
{#if config?.departments?.length > 0}
<div class="signup-field">
<label for="s-dept">Department Preference</label>
<select id="s-dept" bind:value={departmentId}>
<option value="">No preference</option>
{#each config.departments as dept}
<option value={dept.id}>{dept.name}</option>
{/each}
</select>
</div>
{/if}
<div class="signup-field">
<label for="s-note">
{config?.volunteer_note_label ?? 'Additional note'}
{#if config?.volunteer_note_required}<span class="req">*</span>{/if}
</label>
<textarea id="s-note" bind:value={note} rows="3"
required={config?.volunteer_note_required}
placeholder=""></textarea>
</div>
<button type="submit" class="kbtn kbtn-primary" style="width:100%;margin-top:0.5rem" disabled={submitting}>
{submitting ? 'Submitting...' : 'Sign Up'}
</button>
</div>
</form>
{/if}
</div>
</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: 540px;
margin: 0 auto;
padding: 1.5rem 1rem;
width: 100%;
}
.kiosk-center { display: flex; align-items: center; justify-content: center; }
.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;
}
.signup-event-name {
font-size: 1.15rem;
font-weight: 700;
text-align: center;
margin-bottom: 1rem;
color: var(--c-text);
}
.signup-field {
margin-bottom: 1rem;
}
.signup-field label {
display: block;
font-size: 0.8rem;
font-weight: 600;
color: var(--c-muted);
margin-bottom: 0.3rem;
}
.signup-field input,
.signup-field select,
.signup-field textarea {
width: 100%;
padding: 0.55rem 0.75rem;
border: 1px solid var(--c-border);
border-radius: 6px;
background: var(--c-bg);
color: var(--c-text);
font-family: var(--font);
font-size: 0.875rem;
box-sizing: border-box;
}
.signup-field input:focus,
.signup-field select:focus,
.signup-field textarea:focus {
outline: none;
border-color: var(--c-accent);
}
.signup-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.req { color: var(--c-accent); }
.kbtn {
display: inline-flex; align-items: center; justify-content: center;
padding: 0.55rem 1rem; border-radius: 6px;
border: 1px solid transparent;
font-size: 0.875rem; font-weight: 500; cursor: pointer;
font-family: var(--font);
transition: background 150ms;
}
.kbtn:disabled { opacity: 0.5; cursor: not-allowed; }
.kbtn-primary { background: var(--c-accent); color: #fff; }
.kbtn-primary:hover:not(:disabled) { background: var(--c-accent-h); }
</style>