diff --git a/README.md b/README.md
index 88b09ef..71132c4 100644
--- a/README.md
+++ b/README.md
@@ -1,21 +1,21 @@
# Turnpike
-Self-hosted event attendee and volunteer management. One instance, one event.
+Self-hosted event ticketing and volunteer management. One instance, one event.
Turnpike handles gate check-in, volunteer scheduling, and department coordination for events ranging from a single evening to a multi-day festival. It works offline — gate volunteers can check people in without a network connection and sync when connectivity returns.
## Features
-- **Attendee management** — CSV import (CrowdWork/Zeffy auto-detected), party-size tracking, search, check-in
+- **Participant & ticket management** — CSV import (CrowdWork/Zeffy auto-detected), search, check-in
- **Volunteer scheduling** — departments, shifts with capacity, conflict detection, 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
+- **Public volunteer signup** — self-registration form with email confirmation, auto-participant linking
+- **Volunteer kiosk** — public volunteer flow: signup, email confirmation, code-authenticated shift self-scheduling
+- **Gate kiosk** — full-screen check-in UI with QR scanner for gatekeepers
- **Schedule** — create shifts, assign volunteers, manage assignments with conflict awareness
-- **Role-based access** — admin, coordinator, volunteer lead (department-scoped), gate
+- **Role-based access** — admin, ticketing, staffing, colead (department-scoped), gatekeeper
- **Offline-first PWA** — installs on phones/tablets, full offline check-in with background sync
- **Real-time** — check-ins and changes broadcast live via SSE
-- **SMTP email** — send volunteer token links directly or export CSV for bulk email platforms
+- **SMTP email** — volunteer confirmation emails, kiosk link distribution when shift signups open
- **Single binary** — Go backend embeds the frontend; no runtime dependencies
## Tech Stack
@@ -60,10 +60,11 @@ See [docs/INSTALLATION.md](docs/INSTALLATION.md) for systemd, Docker, NixOS, and
| Role | Access |
|------|--------|
-| `admin` | Full access: attendee import, user management, SMTP settings, all departments and shifts |
-| `coordinator` | All departments: volunteers, shifts, schedule. No user management or settings |
-| `volunteer_lead` | Own department only: volunteers and shifts scoped to assigned department |
-| `gate` | Full-screen check-in UI with QR scanner. No access to other pages |
+| `admin` | Full access: participant import, user management, SMTP settings, all departments and shifts |
+| `ticketing` | Participants, tickets, import. No user management |
+| `staffing` | All departments: volunteers, shifts, schedule. No user management or settings |
+| `colead` | Own department only: volunteers and shifts scoped to assigned department(s) |
+| `gatekeeper` | Full-screen Gate Kiosk with QR scanner. No access to other pages |
See [docs/USAGE.md](docs/USAGE.md) for detailed workflow documentation.
@@ -91,7 +92,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 signup, volunteer kiosk, gate check-in, schedule
+- [Usage Guide](docs/USAGE.md) — event setup, participant import, volunteer signup, volunteer kiosk, gate check-in, schedule
- [Installation Guide](docs/INSTALLATION.md) — building, deploying, systemd, Docker, NixOS, reverse proxy, backup
## License
diff --git a/db.go b/db.go
index d93807d..0f814e7 100644
--- a/db.go
+++ b/db.go
@@ -174,6 +174,23 @@ func migrateV2(db *sql.DB) error {
addColumnIfMissing(db, "volunteers", "pronouns TEXT NOT NULL DEFAULT ''")
addColumnIfMissing(db, "volunteers", "email_confirmed INTEGER NOT NULL DEFAULT 0")
addColumnIfMissing(db, "volunteers", "confirmation_token TEXT")
+ addColumnIfMissing(db, "volunteers", "confirmed INTEGER NOT NULL DEFAULT 0")
+ addColumnIfMissing(db, "volunteers", "confirmed_at TEXT")
+ addColumnIfMissing(db, "volunteers", "kiosk_code TEXT")
+ db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_volunteers_kiosk_code ON volunteers(kiosk_code) WHERE kiosk_code IS NOT NULL`)
+ // Migrate kiosk codes from tickets to volunteers (idempotent).
+ db.Exec(`
+ UPDATE volunteers SET kiosk_code = (
+ SELECT t.code FROM tickets t
+ WHERE t.participant_id = volunteers.participant_id
+ AND t.code IS NOT NULL AND t.deleted_at IS NULL
+ LIMIT 1
+ ) WHERE kiosk_code IS NULL AND participant_id IS NOT NULL`)
+ // Delete stub tickets whose code has been migrated to the volunteer.
+ db.Exec(`
+ DELETE FROM tickets
+ WHERE source = 'manual' AND external_id = '' AND code IS NOT NULL
+ AND participant_id IN (SELECT id FROM volunteers WHERE kiosk_code IS NOT NULL)`)
// Widen the uniqueness constraint from name-only to (name, ticket_id).
db.Exec(`DROP INDEX IF EXISTS idx_attendees_name`)
db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_attendees_name_ticket ON attendees(name, ticket_id) WHERE deleted_at IS NULL`)
@@ -184,6 +201,7 @@ func migrateV2(db *sql.DB) error {
// and links volunteers to participants via participant_id.
func migrateV3(db *sql.DB) error {
addColumnIfMissing(db, "volunteers", "participant_id INTEGER REFERENCES participants(id)")
+ addColumnIfMissing(db, "participants", "ticket_name TEXT NOT NULL DEFAULT ''")
// Seed participants from volunteers first (better name data: preferred_name).
db.Exec(`
@@ -392,8 +410,11 @@ type Volunteer struct {
IsLead bool `json:"is_lead"`
CheckedIn bool `json:"checked_in"`
CheckedInAt *string `json:"checked_in_at,omitempty"`
+ Confirmed bool `json:"confirmed"`
+ ConfirmedAt *string `json:"confirmed_at,omitempty"`
EmailConfirmed bool `json:"email_confirmed"`
ConfirmationToken *string `json:"-"`
+ KioskCode *string `json:"kiosk_code,omitempty"`
Note string `json:"note"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
@@ -404,6 +425,7 @@ type Participant struct {
ID int `json:"id"`
Email string `json:"email"`
PreferredName string `json:"preferred_name"`
+ TicketName string `json:"ticket_name"`
Phone string `json:"phone"`
Pronouns string `json:"pronouns"`
Note string `json:"note"`
@@ -840,7 +862,7 @@ func (app *App) attendeeCounts() (total, checkedIn int, err error) {
// --- Participants ---
-const participantCols = `id, email, preferred_name, phone, pronouns, note, created_at, updated_at, deleted_at`
+const participantCols = `id, email, preferred_name, ticket_name, phone, pronouns, note, created_at, updated_at, deleted_at`
func (app *App) listParticipants(search, since string) ([]Participant, error) {
var q string
@@ -880,8 +902,8 @@ func (app *App) getParticipantByEmail(email string) (*Participant, error) {
func (app *App) createParticipant(p Participant) (*Participant, error) {
res, err := app.db.Exec(
- `INSERT INTO participants (email, preferred_name, phone, pronouns, note, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
- strings.ToLower(p.Email), p.PreferredName, p.Phone, p.Pronouns, p.Note, now(),
+ `INSERT INTO participants (email, preferred_name, ticket_name, phone, pronouns, note, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ strings.ToLower(p.Email), p.PreferredName, p.TicketName, p.Phone, p.Pronouns, p.Note, now(),
)
if err != nil {
return nil, err
@@ -892,9 +914,9 @@ func (app *App) createParticipant(p Participant) (*Participant, error) {
func (app *App) updateParticipant(p Participant) error {
_, err := app.db.Exec(
- `UPDATE participants SET email=?, preferred_name=?, phone=?, pronouns=?, note=?, updated_at=?
+ `UPDATE participants SET email=?, preferred_name=?, ticket_name=?, phone=?, pronouns=?, note=?, updated_at=?
WHERE id=? AND deleted_at IS NULL`,
- strings.ToLower(p.Email), p.PreferredName, p.Phone, p.Pronouns, p.Note, now(), p.ID,
+ strings.ToLower(p.Email), p.PreferredName, p.TicketName, p.Phone, p.Pronouns, p.Note, now(), p.ID,
)
return err
}
@@ -937,7 +959,7 @@ func queryParticipants(db *sql.DB, q string, args ...any) ([]Participant, error)
for rows.Next() {
var p Participant
if err := rows.Scan(
- &p.ID, &p.Email, &p.PreferredName, &p.Phone, &p.Pronouns, &p.Note,
+ &p.ID, &p.Email, &p.PreferredName, &p.TicketName, &p.Phone, &p.Pronouns, &p.Note,
&p.CreatedAt, &p.UpdatedAt, &p.DeletedAt,
); err != nil {
return nil, err
@@ -1184,7 +1206,8 @@ const volunteerSelect = `v.id, v.participant_id, v.attendee_id,
COALESCE(NULLIF(p.phone,''), v.phone),
COALESCE(NULLIF(p.pronouns,''), v.pronouns),
v.department_id, v.is_lead, v.checked_in, v.checked_in_at,
- v.email_confirmed, v.confirmation_token, v.note,
+ v.confirmed, v.confirmed_at,
+ v.email_confirmed, v.confirmation_token, v.kiosk_code, v.note,
v.created_at, v.updated_at, v.deleted_at`
const volunteerFrom = `FROM volunteers v LEFT JOIN participants p ON p.id = v.participant_id`
@@ -1293,6 +1316,19 @@ func (app *App) checkInVolunteer(id, userID int) (*Volunteer, error) {
return v, nil
}
+func (app *App) confirmVolunteer(id int) (*Volunteer, error) {
+ t := now()
+ _, err := app.db.Exec(
+ `UPDATE volunteers SET confirmed=1, confirmed_at=?, updated_at=?
+ WHERE id=? AND deleted_at IS NULL AND confirmed=0`,
+ t, t, id,
+ )
+ if err != nil {
+ return nil, err
+ }
+ return app.getVolunteer(id)
+}
+
func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
rows, err := db.Query(q, args...)
if err != nil {
@@ -1303,13 +1339,14 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
for rows.Next() {
var v Volunteer
var participantID, attendeeID, deptID sql.NullInt64
- var isLead, checkedIn, emailConfirmed int
- var confirmationToken sql.NullString
+ var isLead, checkedIn, confirmed, emailConfirmed int
+ var confirmationToken, confirmedAt, kioskCode sql.NullString
if err := rows.Scan(
&v.ID, &participantID, &attendeeID, &v.Name, &v.PreferredName, &v.TicketName,
&v.Email, &v.Phone, &v.Pronouns, &deptID,
&isLead, &checkedIn, &v.CheckedInAt,
- &emailConfirmed, &confirmationToken, &v.Note,
+ &confirmed, &confirmedAt,
+ &emailConfirmed, &confirmationToken, &kioskCode, &v.Note,
&v.CreatedAt, &v.UpdatedAt, &v.DeletedAt,
); err != nil {
return nil, err
@@ -1329,8 +1366,15 @@ func queryVolunteers(db *sql.DB, q string, args ...any) ([]Volunteer, error) {
if confirmationToken.Valid {
v.ConfirmationToken = &confirmationToken.String
}
+ if confirmedAt.Valid {
+ v.ConfirmedAt = &confirmedAt.String
+ }
+ if kioskCode.Valid {
+ v.KioskCode = &kioskCode.String
+ }
v.IsLead = isLead == 1
v.CheckedIn = checkedIn == 1
+ v.Confirmed = confirmed == 1
v.EmailConfirmed = emailConfirmed == 1
result = append(result, v)
}
@@ -1362,19 +1406,43 @@ func (app *App) confirmVolunteerEmail(id int) error {
return err
}
-// listConfirmedVolunteersNeedingCode returns confirmed volunteers whose participant
-// has no ticket with a code yet.
-func (app *App) listConfirmedVolunteersNeedingCode() ([]Volunteer, error) {
+func (app *App) getVolunteerByKioskCode(code string) (*Volunteer, error) {
+ rows, err := queryVolunteers(app.db,
+ `SELECT `+volunteerSelect+` `+volunteerFrom+` WHERE v.kiosk_code = ? AND v.deleted_at IS NULL LIMIT 1`, code)
+ if err != nil || len(rows) == 0 {
+ return nil, err
+ }
+ return &rows[0], nil
+}
+
+func (app *App) assignKioskCode(id int, code string) error {
+ _, err := app.db.Exec(
+ `UPDATE volunteers SET kiosk_code=?, updated_at=? WHERE id=?`, code, now(), id)
+ return err
+}
+
+// listVolunteersNeedingKioskCode returns email-confirmed volunteers without a kiosk code.
+func (app *App) listVolunteersNeedingKioskCode() ([]Volunteer, error) {
return queryVolunteers(app.db, `
SELECT `+volunteerSelect+` `+volunteerFrom+`
- WHERE v.email_confirmed = 1 AND v.deleted_at IS NULL
- AND v.participant_id IS NOT NULL
- AND NOT EXISTS (
- SELECT 1 FROM tickets t
- WHERE t.participant_id = v.participant_id
- AND t.code IS NOT NULL
- AND t.deleted_at IS NULL
- )`)
+ WHERE v.email_confirmed = 1 AND v.kiosk_code IS NULL AND v.deleted_at IS NULL`)
+}
+
+func (app *App) generateVolunteerKioskCode() (string, error) {
+ for range 10 {
+ t, err := generateToken()
+ if err != nil {
+ return "", err
+ }
+ var count int
+ if err := app.db.QueryRow(`SELECT COUNT(*) FROM volunteers WHERE kiosk_code = ?`, t).Scan(&count); err != nil {
+ return "", fmt.Errorf("check kiosk code uniqueness: %w", err)
+ }
+ if count == 0 {
+ return t, nil
+ }
+ }
+ return "", fmt.Errorf("failed to generate unique kiosk code")
}
func generateConfirmationToken() (string, error) {
diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md
index 1f9a967..9bc0dbc 100644
--- a/docs/INSTALLATION.md
+++ b/docs/INSTALLATION.md
@@ -105,23 +105,27 @@ docker run -p 8180:8180 \
## NixOS
-Turnpike builds with `buildGoModule` using the pure-Go SQLite driver (no CGO):
+Turnpike builds with `buildGoModule` + `buildNpmPackage` (pure-Go SQLite, no CGO). The frontend is built separately and copied into the Go build:
```nix
+frontendDist = pkgs.buildNpmPackage {
+ pname = "turnpike-frontend";
+ src = "${src}/frontend";
+ npmDepsHash = "sha256-...";
+ buildPhase = "npm run build";
+ installPhase = "cp -r dist $out";
+};
+
turnpike = pkgs.buildGoModule {
pname = "turnpike";
- version = "0.1.0";
- src = ./path/to/turnpike; # must include vendor/ and frontend/dist/
- vendorHash = null;
+ src = fetchgit { url = "..."; rev = "v2.0.0"; hash = "sha256-..."; };
+ vendorHash = "sha256-...";
env.CGO_ENABLED = 0;
+ preBuild = "cp -r ${frontendDist} frontend/dist";
};
```
-The source directory must contain:
-- Go source files and `vendor/` (run `go mod vendor`)
-- Pre-built frontend at `frontend/dist/` (run `cd frontend && npm run build`)
-
-A complete NixOS module example with `DynamicUser`, `StateDirectory`, and agenix secrets is in the project's `homelab/turnpike.nix`.
+A complete NixOS module with `DynamicUser`, `StateDirectory`, and secrets is in the project's `homelab/turnpike.nix`.
## Reverse Proxy
diff --git a/docs/USAGE.md b/docs/USAGE.md
index de4e765..c08ec50 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -12,23 +12,22 @@ After logging in, create accounts for your team under **Users**. Each user gets
| Role | What they see | What they can do |
|------|--------------|------------------|
-| **admin** | All pages + Settings | Everything: attendee import, user management, SMTP config, departments, shifts, volunteers |
-| **coordinator** | Dashboard, Schedule, Volunteers, Departments | Manage volunteers, departments, and shifts across all departments. Cannot manage users or settings |
-| **volunteer_lead** | Schedule, Volunteers, Departments | Manage volunteers and shifts within their assigned department only |
-| **gate** | Full-screen Gate UI | Check in attendees (search + QR scan). No access to other pages |
+| **admin** | All pages + Settings | Everything: participant import, user management, SMTP config, departments, shifts, volunteers |
+| **ticketing** | Participants, Tickets, Import | Manage participants and tickets, run CSV imports |
+| **staffing** | Dashboard, Schedule, Volunteers, Departments | Manage volunteers, departments, and shifts across all departments. No user management or settings |
+| **colead** | Dashboard, Schedule, Volunteers | Manage volunteers and shifts within their assigned department(s) only |
+| **gatekeeper** | Full-screen Gate Kiosk | Check in ticket holders (search + QR scan). No access to other pages |
-Ticketing and ops staff should use the **admin** role. The `ticketing` role exists in the codebase but is effectively unused — admin covers all ticketing functions.
-
-Volunteer leads are scoped to a single department. When creating a volunteer_lead user, assign their department.
+Coleads are scoped to one or more departments. When creating a colead user, assign their department(s).
## Event Setup
-1. **Configure your event** — go to the Dashboard and set the event name and dates.
+1. **Configure your event** — go to **Settings** and set the event name, venue, dates, and timezone. These appear on the Dashboard and volunteer signup page.
2. **Create departments** — under Departments, add each department your event needs (e.g., Gate, Greeters, Rangers, Build, LNT).
-3. **Import attendees** — see next section.
+3. **Import participants** — see next section.
4. **Create shifts** — under Schedule, create shifts for each department with day, start/end time, and capacity.
-## Importing Attendees
+## Importing Participants
Go to **Import** and upload a CSV file. Turnpike auto-detects two formats:
@@ -36,7 +35,7 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats:
| Column | Maps to |
|--------|---------|
-| `Patron Name` | Name |
+| `Patron Name` | Ticket name |
| `Patron Email` | Email |
| `Order Number` | Ticket ID |
| `Tier Name` | Ticket type |
@@ -45,7 +44,7 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats:
| Column | Maps to |
|--------|---------|
-| `name` (required) | Name |
+| `name` (required) | Ticket name |
| `email` | Email |
| `ticket_id` | Ticket ID |
| `ticket_type` | Ticket type |
@@ -53,27 +52,21 @@ Go to **Import** and upload a CSV file. Turnpike auto-detects two formats:
Column matching is case-insensitive. Extra columns are ignored. BOM-encoded files (Windows Excel exports) are handled automatically.
-### Party-size dedup
+### Participants and tickets
-CrowdWork exports one row per ticket, even when the same person bought multiple tickets in one order. Turnpike handles this automatically:
+Each row in the CSV creates one **ticket**. Participants are deduplicated by email — multiple tickets with the same email address are linked to a single participant record. The import result shows `inserted` (new tickets) and `skipped` (exact duplicates).
-- First row for "Titania Fairweather" (order 1234) creates a record with `party_size=1`
-- Subsequent rows with the same name + order number increment `party_size` (no duplicate record)
-- Result: one attendee record, `party_size=3` if three tickets were purchased
-
-The import result shows `inserted` (new records), `grouped` (merged into existing party), and `skipped` (exact duplicates).
-
-Re-importing the same CSV is safe — existing records are skipped, not duplicated.
+Re-importing the same CSV is safe — exact duplicates are skipped, not duplicated.
## Volunteer Signup
-Turnpike provides a public signup form for volunteers at `/#/volunteer-signup`. No login is required.
+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}`).
+2. Turnpike creates a volunteer record and auto-links it to an existing participant by email match, or creates a new participant 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.
@@ -90,7 +83,7 @@ In **Settings**, the "Volunteer Signup" card controls:
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.
+- **Opening** signups generates kiosk codes for all registered (email-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.
@@ -99,12 +92,23 @@ If a volunteer confirms their email while signups are already open, they receive
Under **Volunteers**, you can:
-- Create volunteers manually (name, email, department)
-- Link a volunteer to an existing attendee record (for dual check-in at the gate)
-- Assign volunteers to departments
-- Check in volunteers
+- Create volunteers manually (name, email, department, co-lead, note)
+- Edit existing volunteers (department, co-lead, note) via the inline Edit button
+- Confirm registered volunteers (admin, staffing, colead)
+- Mark volunteers as ready (briefed at the volunteer station)
-Volunteers are separate from attendees. A person can be both an attendee (ticket holder) and a volunteer (shift worker). Linking them enables the gate team to check in both records simultaneously.
+### Volunteer statuses
+
+| Status | Meaning | Who sets it |
+|--------|---------|-------------|
+| **Unconfirmed** | Signed up but hasn't confirmed their email | Automatic (not yet done) |
+| **Registered** | Email confirmed — volunteer is in the system | Automatic (email link) |
+| **Confirmed** | Staff has verified the volunteer is assigned and ready to be scheduled | Admin, staffing, or co-lead |
+| **Ready** | Briefed at the volunteer station, cleared to report for shifts | Gate staff at check-in |
+
+**Confirmation** is a deliberate staff action — it signals that you're expecting the volunteer for shifts. Use the **Confirm** button on a registered volunteer's row. Marking a volunteer as a co-lead (`is_lead`) automatically confirms them.
+
+Volunteers are separate from participants. A person can be both a ticket holder and a volunteer. When a volunteer signs up via the public form, they are automatically linked to their participant record by email.
## Shift Scheduling
@@ -124,19 +128,21 @@ Shifts can be reordered within a department to reflect priority or sequence usin
## Volunteer Kiosk
-The kiosk lets volunteers self-select shifts without logging in.
+The Volunteer Kiosk is the public-facing flow for volunteers: signup, email confirmation, and shift self-scheduling. The shift scheduling page lets volunteers self-select shifts without logging in.
### Setup
-1. **Generate tokens** — on the Attendees page, click "Generate Tokens." This creates a unique 8-character code for every attendee that doesn't have one.
-2. **Distribute tokens** — two options:
- - **Export CSV** — downloads a file with columns `Email Address`, `First Name`, `Token`, `Signup Link`. Import this into MailChimp, Zeffy, or any email platform.
- - **Email directly** — if SMTP is configured (see below), use "Email All" to send token links, or email individually per attendee.
-3. **Set base URL** — in Settings, set the public base URL (e.g., `https://turnpike.example.com`). Token links use this URL.
+Kiosk links are generated and distributed automatically through the volunteer signup flow:
+
+1. Volunteers sign up via the public signup form (`/volunteer-signup`) and confirm their email.
+2. In **Settings**, open shift signups. This generates kiosk codes for all registered (email-confirmed) volunteers and emails them their links. A confirmation dialog warns before sending.
+3. If a volunteer confirms their email while signups are already open, they receive their kiosk link immediately.
+
+**Set base URL** — in Settings, set the public base URL (e.g., `https://turnpike.example.com`). Kiosk links use this URL.
### Volunteer experience
-Each volunteer receives a link like `https://turnpike.example.com/#/v/ABC12345`. This opens a mobile-friendly page showing:
+Each volunteer receives a link like `https://turnpike.example.com/v/ABC12345`. This opens a mobile-friendly page showing:
- Their name and department
- Currently assigned shifts
@@ -144,22 +150,22 @@ Each volunteer receives a link like `https://turnpike.example.com/#/v/ABC12345`.
Claiming a shift checks for time conflicts. If a conflict exists, the volunteer sees which shifts overlap and can confirm to proceed anyway.
-No login is required. The 8-character token authenticates the request.
+No login is required. The kiosk code authenticates the request.
-### Token format
+### Code format
-Tokens use the character set `A-Z, 2-9` (excluding 0/O, 1/I/L to avoid ambiguity when reading aloud or on printed badges).
+Kiosk codes use the character set `A-Z, 2-9` (excluding 0/O, 1/I/L to avoid ambiguity when reading aloud or on printed badges).
-## Gate Check-In
+## Gate Kiosk
-Users with the **gate** role see a dedicated full-screen UI:
+Users with the **gatekeeper** role see a dedicated full-screen Gate Kiosk:
- **QR scanner** — uses the device camera via the BarcodeDetector API. Scanned codes populate the search field.
-- **Search** — type a name to filter attendees in real-time (searches local IndexedDB, works offline).
-- **Party check-in** — for attendees with `party_size > 1`, the gate UI shows progress ("2/3 checked in") and offers "Check in 1" or "Check in all remaining."
-- **Volunteer dual check-in** — if an attendee is linked to a volunteer record, the gate UI shows their volunteer status and offers to check in both simultaneously.
+- **Search** — type a name to filter tickets in real-time (searches local IndexedDB, works offline).
- **Recent check-ins** — the last 10 check-ins are shown for quick reference.
+Admins and ticketing leads can also check in tickets directly from the **Participants** page by expanding a participant's tickets.
+
Gate devices should install Turnpike as a PWA (Add to Home Screen) for the best experience. Check-ins are stored locally and sync when connectivity is available.
## Schedule
@@ -170,7 +176,7 @@ The Schedule page is the primary UI for managing shifts and volunteer assignment
- Each shift card shows: name, time, capacity (used/total), assigned volunteers
- Conflict badges when a volunteer has overlapping shifts on the same day
-**Coordinators and admins** see all departments. **Volunteer leads** see only their assigned department.
+**Admins and staffing** see all departments. **Coleads** see only their assigned department(s).
Actions available:
- Create new shifts (+ Add shift button)
@@ -182,7 +188,7 @@ Actions available:
## SMTP Configuration
-SMTP enables token email distribution and test emails. Configure in **Settings** (admin only):
+SMTP enables volunteer confirmation emails, kiosk link distribution, and test emails. Configure in **Settings** (admin only):
| Field | Description |
|-------|-------------|
@@ -203,13 +209,13 @@ Turnpike is a Progressive Web App (PWA). After the first load, it works offline:
- **Gate check-ins** are stored in the browser's IndexedDB and sync when connectivity returns.
- **Real-time updates** use Server-Sent Events (SSE). When the connection drops, the client reconnects automatically.
-- **Sync** pulls all changes from the server on startup and periodically thereafter. Local changes are queued in an outbox and flushed in order.
+- **Sync** pulls all changes from the server on startup and periodically thereafter.
Install Turnpike as a PWA (Add to Home Screen on mobile, or Install App in desktop Chrome) for the best offline experience.
## CSV Exports
-Two CSV exports are available from the Attendees page:
+CSV exports are available from the Participants page:
-- **Attendee export** — all attendee records with check-in status
-- **Token link export** — columns: `Email Address`, `First Name`, `Token`, `Signup Link`. Only includes attendees with tokens. Compatible with MailChimp and Zeffy for bulk email campaigns.
+- **Participant export** — all participant records with check-in status
+- **Ticket export** — all ticket records with codes and check-in status
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index 7f28a8a..7ad6bc9 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -9,10 +9,10 @@
import Departments from './pages/Departments.svelte'
import Users from './pages/Users.svelte'
import Import from './pages/Import.svelte'
- import Kiosk from './pages/Kiosk.svelte'
+ import VolunteerKiosk from './pages/VolunteerKiosk.svelte'
import VolunteerSignup from './pages/VolunteerSignup.svelte'
import ConfirmEmail from './pages/ConfirmEmail.svelte'
- import GateUI from './pages/GateUI.svelte'
+ import GateKiosk from './pages/GateKiosk.svelte'
import ScheduleBoard from './pages/ScheduleBoard.svelte'
import Settings from './pages/Settings.svelte'
import Nav from './components/Nav.svelte'
@@ -96,7 +96,7 @@
{#if loading}
{:else if kioskToken}
-
+ Your department{myDeptNames.length > 1 ? 's' : ''}: + {myDeptNames.join(', ')} +
+ {/if} - {#if total > 0} -