diff --git a/README.md b/README.md
index b526fbe..cd596c4 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@ Turnpike handles gate check-in, volunteer scheduling, and department coordinatio
- **Attendee management** — CSV import (CrowdWork/Zeffy auto-detected), party-size tracking, search, check-in
- **Volunteer scheduling** — departments, shifts with capacity, conflict detection, drag-and-drop reordering
+- **Public volunteer signup** — self-registration form with email confirmation, auto-attendee linking
- **Volunteer kiosk** — token-authenticated self-service shift signup, no login required
- **Gate check-in** — full-screen UI with QR scanner, party check-in ("2/3 checked in"), volunteer dual check-in
- **Schedule board** — department leads and coordinators manage shift assignments with conflict awareness
@@ -90,7 +91,7 @@ The Vite dev server runs on `:5173` and proxies `/api` requests to the Go server
## Documentation
-- [Usage Guide](docs/USAGE.md) — event setup, attendee import, volunteer kiosk, gate check-in, schedule board
+- [Usage Guide](docs/USAGE.md) — event setup, attendee import, volunteer signup, volunteer kiosk, gate check-in, schedule board
- [Installation Guide](docs/INSTALLATION.md) — building, deploying, systemd, Docker, NixOS, reverse proxy, backup
## License
diff --git a/docs/USAGE.md b/docs/USAGE.md
index 80b25f0..8e0d174 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -65,6 +65,36 @@ The import result shows `inserted` (new records), `grouped` (merged into existin
Re-importing the same CSV is safe — existing records are skipped, not duplicated.
+## Volunteer Signup
+
+Turnpike provides a public signup form for volunteers at `/#/volunteer-signup`. No login is required.
+
+### Signup flow
+
+1. Volunteer visits the signup form and fills in: preferred name (required), ticket name, email (required), pronouns, phone, department preference, and an optional note.
+2. Turnpike creates a volunteer record and auto-links it to an existing attendee by email match, or creates a new attendee record.
+3. A confirmation email is sent with a unique link (`/#/confirm/{token}`).
+4. The volunteer clicks the link to confirm their email.
+5. If shift signups are already open, the confirmation page includes a link to the kiosk for shift selection.
+
+Duplicate signups with the same email silently succeed — no error is shown and no duplicate is created. This prevents email enumeration.
+
+### Configuring the signup form
+
+In **Settings**, the "Volunteer Signup" card controls:
+
+- **Note field label** — customize the label shown on the form (default: "Additional note")
+- **Note field required** — when checked, volunteers must fill in the note to submit
+
+### Opening shift signups
+
+In **Settings**, the "Shift Signups" card has an open/close toggle:
+
+- **Opening** signups generates kiosk tokens for all confirmed volunteers and emails them their shift signup links. A confirmation dialog warns before sending.
+- **Closing** signups prevents new kiosk links from being issued on confirmation, but existing links continue to work.
+
+If a volunteer confirms their email while signups are already open, they receive their kiosk link immediately in the confirmation response and via email.
+
## Managing Volunteers
Under **Volunteers**, you can:
diff --git a/email.go b/email.go
index 8d1d463..0a1c65d 100644
--- a/email.go
+++ b/email.go
@@ -133,7 +133,7 @@ func (app *App) sendTokenEmail(a Attendee) error {
cfg := app.loadSMTPConfig()
eventName := app.eventName()
- link := fmt.Sprintf("%s/#/v/%s", app.resolveBaseURL(), *a.VolunteerToken)
+ link := fmt.Sprintf("%s/v/%s", app.resolveBaseURL(), *a.VolunteerToken)
subject := fmt.Sprintf("Your volunteer link for %s", eventName)
body := fmt.Sprintf(
"Hi %s,\n\nThank you for volunteering at %s!\n\nYour volunteer token: %s\nYour signup link: %s\n\nUse this link to sign up for available shifts in your department.\n\nSee you there!\n",
@@ -146,7 +146,7 @@ func (app *App) sendTokenEmail(a Attendee) error {
func (app *App) sendConfirmationEmail(to, name, confirmToken string) error {
cfg := app.loadSMTPConfig()
eventName := app.eventName()
- link := fmt.Sprintf("%s/#/confirm/%s", app.resolveBaseURL(), confirmToken)
+ link := fmt.Sprintf("%s/confirm/%s", app.resolveBaseURL(), confirmToken)
subject := fmt.Sprintf("Please confirm your email for %s", eventName)
body := fmt.Sprintf(
"Hi %s,\n\nThank you for signing up to volunteer at %s!\n\nPlease confirm your email address by visiting:\n%s\n\nIf you did not sign up, you can safely ignore this email.\n",
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 179cc2c..e9c0638 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -23,16 +23,22 @@
let session = $state(null)
let loading = $state(true)
- let route = $state(window.location.hash || '#/')
+ let route = $state(window.location.pathname)
let updateAvailable = $state(false)
let mobileNavOpen = $state(false)
// 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 kioskToken = $derived(route.match(/^\/v\/([A-Z0-9]+)/i)?.[1] ?? '')
+ const isVolunteerSignup = $derived(route.startsWith('/volunteer-signup'))
+ const isConfirmEmail = $derived(route.startsWith('/confirm/'))
const isPublicPage = $derived(!!kioskToken || isVolunteerSignup || isConfirmEmail)
+ function navigate(path) {
+ history.pushState(null, '', path)
+ route = path
+ mobileNavOpen = false
+ }
+
async function checkVersion() {
try {
const res = await fetch('/api/version')
@@ -56,8 +62,8 @@
startSSE()
startSyncLoop()
}
- window.addEventListener('hashchange', () => {
- route = window.location.hash || '#/'
+ window.addEventListener('popstate', () => {
+ route = window.location.pathname
mobileNavOpen = false
})
@@ -67,17 +73,17 @@
function onLogin(s) {
session = s
- window.location.hash = '#/'
+ navigate('/')
syncPull().then(() => { startSSE(); startSyncLoop() })
}
async function onLogout() {
await clearSession()
session = null
- window.location.hash = '#/login'
+ navigate('/login')
}
- const path = $derived(route.replace(/^#/, '') || '/')
+ const path = $derived(route || '/')
const role = $derived(session?.user?.role ?? '')
@@ -107,7 +113,7 @@
{#if mobileNavOpen}
mobileNavOpen = false} onkeydown={() => {}}>
{/if}
-
+
-
+
Shift Signups
@@ -212,5 +224,30 @@
{/if}
+
+
+
+
Data Management
+
+ Permanently delete all records of a given type. This cannot be undone.
+