Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/site/migrations/0001_participants.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions apps/site/migrations/0005_partner_credit.sql
Original file line number Diff line number Diff line change
@@ -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));
19 changes: 19 additions & 0 deletions apps/site/public/scripts/my-week.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand All @@ -295,6 +313,7 @@
bindTrip();
bindReminders();
bindSignOut();
bindSomeoneElse();
return undefined;
}

Expand Down
149 changes: 149 additions & 0 deletions apps/site/public/scripts/needs-connection.js
Original file line number Diff line number Diff line change
@@ -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);
})();
19 changes: 17 additions & 2 deletions apps/site/public/scripts/offline-page.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
18 changes: 18 additions & 0 deletions apps/site/public/scripts/old-links.js
Original file line number Diff line number Diff line change
@@ -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);
})();
68 changes: 67 additions & 1 deletion apps/site/public/scripts/participant-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.';
Expand Down Expand Up @@ -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,
};
})();
Loading
Loading