diff --git a/apps/site/migrations/0001_participants.sql b/apps/site/migrations/0001_participants.sql index fb3befb..a4432a7 100644 --- a/apps/site/migrations/0001_participants.sql +++ b/apps/site/migrations/0001_participants.sql @@ -80,7 +80,8 @@ CREATE TABLE bingo ( ); -- Fixed-window request counters. key is the SHA-256 of the limit's name and --- the IP address or contact; window_start counts hours since 1970. +-- the IP address or contact; window_start is the minute (counted from 1970) +-- the window began (worker/rate-limit.ts). CREATE TABLE rate_limits ( key TEXT NOT NULL, window_start INTEGER NOT NULL, diff --git a/apps/site/migrations/0005_partner_credit.sql b/apps/site/migrations/0005_partner_credit.sql new file mode 100644 index 0000000..79be083 --- /dev/null +++ b/apps/site/migrations/0005_partner_credit.sql @@ -0,0 +1,14 @@ +-- Where a sign-up came from, for volunteers' counts. Deleted with the rest +-- of the participant on November 30, 2026 (worker/cleanup.ts). +-- +-- * partner is the slug of the partner group whose ?ref= link brought the +-- person (src/lib/partners.ts), or null. It is set when the sign-up is +-- made and never changes. +-- * shared_device is 1 when a volunteer signed the person up on a shared +-- tablet or phone, after tapping "Sign up someone else" in that tab. LVBT's +-- newsletter sign-up records it as the source of the request. +-- +-- The Worker writes both after the sign-up itself, so a database without +-- this migration still takes sign-ups, just without the credit. +ALTER TABLE participants ADD COLUMN partner TEXT; +ALTER TABLE participants ADD COLUMN shared_device INTEGER NOT NULL DEFAULT 0 CHECK (shared_device IN (0, 1)); diff --git a/apps/site/public/scripts/my-week.js b/apps/site/public/scripts/my-week.js index 5415284..05c9ac1 100644 --- a/apps/site/public/scripts/my-week.js +++ b/apps/site/public/scripts/my-week.js @@ -18,6 +18,8 @@ const MAX_ENTRIES = 8; const MAX_PHOTO_BYTES = 10 * 1024 * 1024; const PREVIEW_KEY = 'lvwwd_preview_link'; + // 12:00 am on October 9, 2026, in Las Vegas. + const SIGN_UP_ENDS = Date.parse('2026-10-09T07:00:00Z'); const params = new URLSearchParams(window.location.search); let me = null; @@ -275,6 +277,22 @@ }); } + // "Sign up someone else", for a volunteer signing people up on one + // tablet: signs this device out and opens the empty sign-up form. The + // sign-up that was open stays saved. Gone once sign-up closes. + function bindSomeoneElse() { + const block = document.querySelector('[data-someone-else]'); + const button = document.querySelector('[data-someone-else-button]'); + if (!block || !button || Date.now() >= SIGN_UP_ENDS) return; + block.hidden = false; + button.addEventListener('click', async () => { + setText('[data-someone-else-status]', ''); + const { ok, message } = await api.signUpSomeoneElse(); + if (ok) window.location.href = '/sign-up'; + else setText('[data-someone-else-status]', message); + }); + } + async function start() { // Without the flag cookie this phone isn't signed in; don't ask. if (!/(?:^|; )lvwwd_signed_in=1(?:;|$)/.test(document.cookie)) return showSignedOut(); @@ -295,6 +313,7 @@ bindTrip(); bindReminders(); bindSignOut(); + bindSomeoneElse(); return undefined; } diff --git a/apps/site/public/scripts/needs-connection.js b/apps/site/public/scripts/needs-connection.js new file mode 100644 index 0000000..fb33d3c --- /dev/null +++ b/apps/site/public/scripts/needs-connection.js @@ -0,0 +1,149 @@ +/** + * Offline notices for the forms that need a connection: Sign up, today's + * trip, reminders and "Send my link again" on My week, and Get my link. + * + * Each form (or section) carries data-needs-connection with its notice + * key, and each button that sends something to lvwwd.org carries + * data-needs-connection-button. While the phone is offline, the notice + * sits directly above the form's first such button, where a screen reader + * reaches it first, and every such button is dimmed with aria-disabled: it + * can still be reached with Tab, but a tap sends nothing and the notice is + * read again. Whatever the visitor typed, picked or attached stays as it + * is, and nothing is kept to send later. When the connection returns, the + * notices go, the buttons work again, and screen readers hear "You're back + * online." + * + * navigator.onLine only says whether the phone has a network. A request + * that still can't reach lvwwd.org shows the form's own error message. + * Once a form has ended (sign-up and trips at 12:00 am on October 9, + * reminders at 8:00 am on October 8, Las Vegas time) it gets no notice. + * A personal "Open my week" link opened offline gets the offline page + * from the service worker, and offline-page.js gives it its own notice. + */ +(() => { + const NOTICES = { + 'sign-up': 'You’re offline. You can sign up as soon as you’re back online.', + trip: 'You’re offline. My week needs a connection to show your entries and send your post.', + 'edit-details': 'You’re offline. You can save your details as soon as you’re back online.', + 'send-link': 'You’re offline. You can send your link as soon as you’re back online.', + 'get-link': 'You’re offline. You can get your link as soon as you’re back online.', + reminders: 'You’re offline. You can sign up for reminders when you’re back online.', + }; + const TRIPS_END = Date.parse('2026-10-09T07:00:00Z'); + const ENDS = { + 'sign-up': TRIPS_END, + trip: TRIPS_END, + reminders: Date.parse('2026-10-08T15:00:00Z'), + }; + const BACK_ONLINE = 'You’re back online.'; + const CLEAR_AFTER_MS = 3000; + const BUTTON = '[data-needs-connection-button]'; + const DIMMED = '[data-needs-connection-button][aria-disabled="true"]'; + + const places = () => [...document.querySelectorAll('[data-needs-connection]')]; + if (places().length === 0) return; + + // Read politely when the connection returns, then emptied so the same + // words are read again next time. + const announcer = document.createElement('p'); + announcer.className = 'sr-only'; + announcer.setAttribute('aria-live', 'polite'); + document.body.append(announcer); + let clearing = 0; + + function textFor(place) { + const key = place.getAttribute('data-needs-connection') ?? ''; + if (Date.now() >= (ENDS[key] ?? Infinity)) return null; + return NOTICES[key] ?? null; + } + + // Directly above the first button, as a child of the form itself, so a + // button inside a label or a row keeps its row together. + function spotFor(place) { + let spot = place.querySelector(BUTTON); + if (!spot) return null; + while (spot.parentElement && spot.parentElement !== place) spot = spot.parentElement; + return spot; + } + + function noticeFor(place) { + const existing = place.querySelector('[data-offline-notice]'); + if (existing) return existing; + const notice = document.createElement('p'); + notice.className = 'offline-notice'; + notice.setAttribute('data-offline-notice', ''); + notice.setAttribute('aria-live', 'assertive'); + const icon = document.createElement('span'); + icon.className = 'offline-notice__icon'; + icon.setAttribute('aria-hidden', 'true'); + icon.textContent = '!'; + notice.append(icon, document.createElement('span')); + const spot = spotFor(place); + if (spot) spot.before(notice); + else place.prepend(notice); + return notice; + } + + // Emptying the words and writing them again a moment later makes screen + // readers read them again. A notice already there when the page opens is + // read in page order instead. + function say(notice, text, aloud) { + const words = notice.lastElementChild; + if (!words) return; + if (!aloud) { + words.textContent = text; + return; + } + words.textContent = ''; + window.setTimeout(() => (words.textContent = text), 100); + } + + function goOffline(aloud) { + places().forEach((place) => { + const text = textFor(place); + if (!text) return; + say(noticeFor(place), text, aloud); + place.querySelectorAll(BUTTON).forEach((button) => { + button.setAttribute('aria-disabled', 'true'); + }); + }); + } + + function goOnline() { + document.querySelectorAll('[data-offline-notice]').forEach((notice) => notice.remove()); + document.querySelectorAll(DIMMED).forEach((button) => button.removeAttribute('aria-disabled')); + announcer.textContent = BACK_ONLINE; + window.clearTimeout(clearing); + clearing = window.setTimeout(() => (announcer.textContent = ''), CLEAR_AFTER_MS); + } + + // A dimmed button sends nothing: its click (which a tap, Enter or Space + // on it makes) is stopped before the page's own handlers see it. + document.addEventListener( + 'click', + (event) => { + const button = event.target instanceof Element ? event.target.closest(DIMMED) : null; + if (!button) return; + event.preventDefault(); + event.stopImmediatePropagation(); + const place = button.closest('[data-needs-connection]'); + const notice = place?.querySelector('[data-offline-notice]'); + const text = place ? textFor(place) : null; + if (notice && text) say(notice, text, true); + }, + true, + ); + document.addEventListener( + 'submit', + (event) => { + if (!(event.target instanceof HTMLFormElement) || !event.target.querySelector(DIMMED)) return; + event.preventDefault(); + event.stopImmediatePropagation(); + }, + true, + ); + + window.addEventListener('offline', () => goOffline(true)); + window.addEventListener('online', goOnline); + if (!navigator.onLine) goOffline(false); +})(); diff --git a/apps/site/public/scripts/offline-page.js b/apps/site/public/scripts/offline-page.js index 0950920..9bea4cc 100644 --- a/apps/site/public/scripts/offline-page.js +++ b/apps/site/public/scripts/offline-page.js @@ -3,15 +3,30 @@ * when none are yet. The service worker shows this page at the address * the visitor asked for, so My week and Sign up get their own line about * needing a connection. Without JavaScript the whole list shows. + * + * A personal "Open my week" link (/my-week?t=...) opened offline says to + * open the link again, and tries it again by itself as soon as the phone + * is back online: the Worker then signs the phone in and opens My week. */ (() => { const text = document.querySelector('[data-offline-text]'); const privateText = document.querySelector('[data-offline-private]'); + const openLink = document.querySelector('[data-offline-open-link]'); const none = document.querySelector('[data-offline-none]'); const list = document.querySelector('[data-offline-list]'); - if (!text || !privateText || !none || !list) return; + if (!text || !privateText || !openLink || !none || !list) return; - if (/^\/(?:my-week|sign-up)(?:\/|$)/.test(window.location.pathname)) { + const path = window.location.pathname; + const isLink = + /^\/my-week\/?$/.test(path) && new URLSearchParams(window.location.search).has('t'); + if (isLink) { + text.hidden = true; + list.hidden = true; + openLink.hidden = false; + window.addEventListener('online', () => window.location.reload()); + return; + } + if (/^\/(?:my-week|sign-up)(?:\/|$)/.test(path)) { text.hidden = true; privateText.hidden = false; } diff --git a/apps/site/public/scripts/old-links.js b/apps/site/public/scripts/old-links.js new file mode 100644 index 0000000..76ac1cc --- /dev/null +++ b/apps/site/public/scripts/old-links.js @@ -0,0 +1,18 @@ +/** + * Old section links of the one-page lvwwd.org, such as lvwwd.org/#giveaway, + * open the page that section became. Browsers never send the part after # + * to the server, so the Worker can't redirect these; this runs in the head + * of Home, before the page shows. It replaces the address rather than + * adding one, so Back returns to where the visitor came from. Any other + * section link, or none, stays on Home. Without JavaScript, Home opens. + */ +(() => { + const MOVED = { + '#how-to-participate': '/take-part', + '#giveaway': '/giveaway', + '#resources': '/resources', + '#partners': '/partners', + }; + const to = MOVED[window.location.hash.toLowerCase()]; + if (to) window.location.replace(to + window.location.search); +})(); diff --git a/apps/site/public/scripts/participant-api.js b/apps/site/public/scripts/participant-api.js index 928027b..230d54a 100644 --- a/apps/site/public/scripts/participant-api.js +++ b/apps/site/public/scripts/participant-api.js @@ -9,6 +9,12 @@ * Worker writes the site key into its data-sitekey attribute. * - showPreviewLink(container, link) shows the "Open my week" link on the * preview Worker, which returns it instead of only sending it. + * - whereFrom() is what the sign-up sends about where it came from: the + * partner link opened in this tab (site-nav.js keeps it) and whether a + * volunteer is signing people up on this device. + * - signUpSomeoneElse() is "Sign up someone else": it signs this device + * out, clears what it kept for the last person, and remembers for this + * tab that it is a shared device. It resolves to { ok, message }. */ (() => { const OFFLINE = 'Couldn’t reach lvwwd.org. Check your connection and try again.'; @@ -145,5 +151,65 @@ container.hidden = false; } - window.lvwwdApi = { call, botCheck, showPreviewLink, OFFLINE }; + const REF_KEY = 'lvwwd_ref'; + const SHARED_KEY = 'lvwwd_shared_device'; + const NEXT_KEY = 'lvwwd_next_person'; + const UNREACHABLE = 'We couldn’t reach the server. Check your connection and try again.'; + + // Session storage lasts as long as the tab, and a private window keeps + // nothing after it closes. Any of it may be refused. + function stored(storage, key) { + try { + return window[storage].getItem(key); + } catch { + return null; + } + } + function store(storage, key, value) { + try { + if (value === null) window[storage].removeItem(key); + else window[storage].setItem(key, value); + } catch { + // Refused: the page works the same, only without the memory. + } + } + + function whereFrom() { + const ref = stored('sessionStorage', REF_KEY); + return { + ...(ref ? { ref } : {}), + sharedDevice: stored('sessionStorage', SHARED_KEY) === '1', + }; + } + + async function signUpSomeoneElse() { + const { ok, status, data } = await call('POST', '/api/signout', {}); + // 401: the session had already ended, which is the same result. + if (!ok && status !== 401) return { ok: false, message: status ? data.message : UNREACHABLE }; + // The last person's bingo marks are saved with their sign-up; the next + // person starts a fresh card. The key is bingo.js's STORAGE_KEY. + store('localStorage', 'lvwwd_bingo_2026', null); + store('sessionStorage', 'lvwwd_preview_link', null); + store('sessionStorage', SHARED_KEY, '1'); + store('sessionStorage', NEXT_KEY, '1'); + document.dispatchEvent(new CustomEvent('lvwwd:signed-out')); + return { ok: true, message: '' }; + } + + /** True once, on the first sign-up form shown after "Sign up someone else". */ + function readyForNextPerson() { + const ready = stored('sessionStorage', NEXT_KEY) === '1'; + store('sessionStorage', NEXT_KEY, null); + return ready; + } + + window.lvwwdApi = { + call, + botCheck, + showPreviewLink, + whereFrom, + signUpSomeoneElse, + readyForNextPerson, + OFFLINE, + }; })(); diff --git a/apps/site/public/scripts/sign-up.js b/apps/site/public/scripts/sign-up.js index 12842ff..86ea645 100644 --- a/apps/site/public/scripts/sign-up.js +++ b/apps/site/public/scripts/sign-up.js @@ -7,6 +7,12 @@ * not signed in here; the Worker sends that person their link instead, * and the page says so. Add ?edit=1 to change the details of the person * signed in on this phone (PATCH /api/me); the contact can't be changed. + * + * A phone that is signed in sees "You're signed up" in place of the form. + * Until sign-up closes, it also offers "Sign up someone else", for a + * volunteer signing people up one after another on one tablet: it signs + * the device out and shows the empty form, ready for the next person. + * Each sign-up sends the partner link this tab was opened with, if any. */ (() => { const api = window.lvwwdApi; @@ -16,6 +22,9 @@ const FIELDS = ['firstName', 'contact', 'zip', 'instagram', 'age']; const PREVIEW_KEY = 'lvwwd_preview_link'; + // 12:00 am on October 9, 2026, in Las Vegas. + const SIGN_UP_ENDS = Date.parse('2026-10-09T07:00:00Z'); + const nextPerson = form.querySelector('[data-next-person]'); const statusLine = form.querySelector('[data-signup-status]'); const submit = form.querySelector('[data-signup-submit]'); const submitLabel = submit?.textContent ?? 'Sign up'; @@ -139,6 +148,7 @@ } const { ok, status, data } = await api.call('POST', '/api/signup', { ...details, + ...api.whereFrom(), turnstileToken, }); bot.reset(); @@ -178,6 +188,8 @@ function startEditing(me) { editing = true; + // Offline, needs-connection.js says saving waits, not signing up. + form.setAttribute('data-needs-connection', 'edit-details'); field('firstName').value = me.firstName ?? ''; const contact = field('contact'); contact.value = me.contactMasked ?? ''; @@ -193,15 +205,52 @@ form.hidden = false; } + // The empty form for the next person, with the line that says the last + // sign-up is saved. Focus goes to that line, so a screen reader reads it + // and the next Tab reaches "First name". + function showReadyForNext() { + form.reset(); + showErrors({}); + setStatus(''); + startSignUp(); + if (!(nextPerson instanceof HTMLElement)) return; + nextPerson.hidden = false; + nextPerson.focus(); + } + + function offerSomeoneElse() { + const block = already.querySelector('[data-someone-else]'); + const button = already.querySelector('[data-someone-else-button]'); + const status = already.querySelector('[data-someone-else-status]'); + if (!block || !button || Date.now() >= SIGN_UP_ENDS) return; + block.hidden = false; + button.addEventListener('click', async () => { + if (status) status.textContent = ''; + const { ok, message } = await api.signUpSomeoneElse(); + if (!ok) { + if (status) status.textContent = message; + return; + } + api.readyForNextPerson(); + already.hidden = true; + showReadyForNext(); + }); + } + function showAlready(me) { - const nameLine = already.querySelector('[data-signup-already-name]'); - if (nameLine) nameLine.textContent = `You signed up as ${me.firstName}.`; + const title = already.querySelector('[data-signup-already-title]'); + if (title) title.textContent = `You’re signed up, ${me.firstName}.`; + offerSomeoneElse(); already.hidden = false; } // Someone signed in on this phone sees who they are, or edits with ?edit=1. async function start() { - if (!/(?:^|; )lvwwd_signed_in=1(?:;|$)/.test(document.cookie)) return startSignUp(); + if (!/(?:^|; )lvwwd_signed_in=1(?:;|$)/.test(document.cookie)) { + // Just after "Sign up someone else" on My week. + if (api.readyForNextPerson()) return showReadyForNext(); + return startSignUp(); + } form.hidden = true; const { ok, status, data } = await api.call('GET', '/api/me'); if (!ok) { diff --git a/apps/site/public/scripts/site-nav.js b/apps/site/public/scripts/site-nav.js index 1114d94..3cb9d3d 100644 --- a/apps/site/public/scripts/site-nav.js +++ b/apps/site/public/scripts/site-nav.js @@ -17,15 +17,34 @@ }); } + // A partner's link or QR code adds ?ref= to any page. + // The sign-up form sends it along, so the sign-up counts for that + // partner. It is kept for this tab only, and the last one opened wins. + // The Worker checks it against the partner roster. + const ref = new URLSearchParams(window.location.search).get('ref')?.trim().toLowerCase(); + if (ref && /^[a-z0-9-]{1,64}$/.test(ref)) { + try { + window.sessionStorage.setItem('lvwwd_ref', ref); + } catch { + // Storage refused: the sign-up simply isn't credited. + } + } + // Once someone has signed up, the sign-up buttons become "My week". The // Worker sets this readable flag next to the real, HttpOnly session // cookie; the flag carries no identity, only "this phone is signed in". + // "Sign up someone else" signs the phone out without leaving the page, + // and says so with the lvwwd:signed-out event; the buttons change back. + const links = [...document.querySelectorAll('[data-signup-link]')]; + const labels = [...document.querySelectorAll('[data-signup-label]')]; + const hrefs = links.map((link) => link.getAttribute('href')); + const texts = labels.map((label) => label.textContent); if (/(?:^|; )lvwwd_signed_in=1(?:;|$)/.test(document.cookie)) { - document.querySelectorAll('[data-signup-link]').forEach((link) => { - link.setAttribute('href', '/my-week'); - }); - document.querySelectorAll('[data-signup-label]').forEach((label) => { - label.textContent = 'My week'; - }); + links.forEach((link) => link.setAttribute('href', '/my-week')); + labels.forEach((label) => (label.textContent = 'My week')); } + document.addEventListener('lvwwd:signed-out', () => { + links.forEach((link, i) => link.setAttribute('href', hrefs[i] ?? '/sign-up')); + labels.forEach((label, i) => (label.textContent = texts[i])); + }); })(); diff --git a/apps/site/src/lib/partners.ts b/apps/site/src/lib/partners.ts index fd609d4..61a0731 100644 --- a/apps/site/src/lib/partners.ts +++ b/apps/site/src/lib/partners.ts @@ -1,15 +1,15 @@ import { wwd } from './wwd'; -// The Partners page roster. It is driven by `wwd.partners` -// (apps/site/src/lib/wwd.ts), which is an empty array until LVBT confirms -// its first partner by email — see "Show the organizations taking part on -// the Partners page". That array's declared shape only carries `name` and -// an optional `url`; a real roster entry also needs a slug (for its -// `?ref=` link), a type and a one-sentence blurb. Rather than widen -// the shared `wwd.ts` type for a shape nothing currently uses, this file -// casts the (always empty, for now) array to the fuller shape a populated -// roster will need. When the roster gets its first real entry, `wwd.ts` -// should grow those fields directly and this cast can be dropped. +// The partner roster, for the Partners page and for crediting sign-ups. +// It is driven by `wwd.partners` (apps/site/src/lib/wwd.ts), which stays +// an empty array until LVBT confirms its first partner by email. That +// array's declared shape only carries `name` and an optional `url`; a real +// roster entry also needs a slug (the short name in its `?ref=` +// link), a type and a one-sentence blurb. Rather than widen the shared +// `wwd.ts` type for a shape nothing uses yet, this file casts the (always +// empty, for now) array to the fuller shape a populated roster needs. When +// the roster gets its first real entry, `wwd.ts` should grow those fields +// directly and this cast can be dropped. export type PartnerType = | 'Community and neighborhood groups' | 'Student groups' @@ -30,6 +30,19 @@ export interface Partner { export const partners: Partner[] = wwd.partners as Partner[]; +/** + * The partner a `?ref=` link names, or null when it names none on the + * roster (a typo, or a link to a group that has left). Capital letters and + * spaces around the slug are ignored. The Worker uses this to credit a + * sign-up, so the roster above is the only list of partners. + */ +export function partnerForRef(ref: unknown): Partner | null { + if (typeof ref !== 'string') return null; + const slug = ref.trim().toLowerCase(); + if (!/^[a-z0-9-]{1,64}$/.test(slug)) return null; + return partners.find((partner) => partner.slug === slug) ?? null; +} + export const contactEmail = 'wwd@lasvegasfortransit.org'; export const joinMailto = `mailto:${contactEmail}?subject=${encodeURIComponent( diff --git a/apps/site/src/pages/index.astro b/apps/site/src/pages/index.astro index 92ef52b..1bc0610 100644 --- a/apps/site/src/pages/index.astro +++ b/apps/site/src/pages/index.astro @@ -97,6 +97,8 @@ const leaderMailto = `mailto:wwd@lasvegasfortransit.org?subject=${encodeURICompo + {/* Old one-page links like /#giveaway: runs before the page shows, so it can't wait. */} + {/* HERO: what it is, when, and one button. */}
diff --git a/apps/site/src/pages/my-week.astro b/apps/site/src/pages/my-week.astro index 2ae8741..3cea245 100644 --- a/apps/site/src/pages/my-week.astro +++ b/apps/site/src/pages/my-week.astro @@ -6,12 +6,15 @@ import { lvbt } from '../lib/site'; import { wwd } from '../lib/wwd'; // My week: the page a signed-up person comes back to. It shows their -// entries as the eight days of the week, the one button that checks them -// in for today, an optional photo, reminders, and their details. The page +// entries as the eight days of the week, the form that sends today's +// shared trip, reminders, their details, and, at the foot, "Sign up +// someone else" for a volunteer signing people up on one tablet. The page // is the same for everyone; /scripts/my-week.js fills it in from the // Worker's API for the person signed in on this phone, or shows the -// signed-out state. Opening an "Open my week" link (/my-week?t=...) is -// handled by the Worker before this page loads. +// signed-out state. /scripts/needs-connection.js says so, and grays out +// the buttons that send something, while the phone is offline. Opening an +// "Open my week" link (/my-week?t=...) is handled by the Worker before +// this page loads. const title = 'My week · Week Without Driving Las Vegas'; // How someone got around without driving. Any one of them makes the day a @@ -88,7 +91,7 @@ const shortcuts = [ {/* Today's trip, in three steps: how, the post, and sending it in. */} -
{/* REMINDERS */} -
+

Remind me to share my trip

One reminder a day, October 1 to 8, at about 8 am. Stop anytime.

@@ -269,12 +281,18 @@ const shortcuts = [ Change my details
-
+

Open My week on another phone

We sent you a link. Open it on any phone to come back here.

- Send my link again + Send my link again
@@ -292,6 +310,13 @@ const shortcuts = [ >

+ {/* For a volunteer signing people up one after another on one device. */} +