From f297bd1ef5d54666a477600c8425fa6076ff8d2e Mon Sep 17 00:00:00 2001 From: Willie Chalmers III Date: Wed, 30 Sep 2026 17:04:29 -0700 Subject: [PATCH 1/5] feat: Refresh the member account experience Give membership and next actions clearer visual weight, replace row dividers with detail cards, and group account controls under a single profile menu for signed-in members. Co-authored-by: Codex --- apps/site/functions/account/index.ts | 1 + .../src/components/app/KeyValueSummary.astro | 10 +- apps/site/src/components/site/Header.astro | 129 ++++++++++---- .../src/components/site/ProfileIcon.astro | 8 + apps/site/src/pages/account/index.astro | 162 +++++++++++------- 5 files changed, 208 insertions(+), 102 deletions(-) create mode 100644 apps/site/src/components/site/ProfileIcon.astro diff --git a/apps/site/functions/account/index.ts b/apps/site/functions/account/index.ts index 4a4ce6a7..28cf9d95 100644 --- a/apps/site/functions/account/index.ts +++ b/apps/site/functions/account/index.ts @@ -56,6 +56,7 @@ export const onRequestGet: PagesFunction = async ({ env, request ), ) .on('[data-slot="mailing-list"]', show()); + filled = filled.on('[data-slot="member-check"]', show()); } else { filled = filled.on('[data-slot="rejoin"]', show()); } diff --git a/apps/site/src/components/app/KeyValueSummary.astro b/apps/site/src/components/app/KeyValueSummary.astro index efddf544..889683d1 100644 --- a/apps/site/src/components/app/KeyValueSummary.astro +++ b/apps/site/src/components/app/KeyValueSummary.astro @@ -19,16 +19,16 @@ interface Props { const { rows } = Astro.props; --- -
+
{ rows.map((row) => ( - +
-
- - Get involved - {/* Mobile controls: Join CTA (hidden when nav open) + hamburger toggle */}
- - Join - + + - +
From 434f1ff50d67fd8a7dea2a1144901b8d44121bd2 Mon Sep 17 00:00:00 2001 From: Willie Chalmers III Date: Wed, 30 Sep 2026 17:18:44 -0700 Subject: [PATCH 2/5] feat: Improve membership onboarding and account navigation Members can describe the micromobility options they own and find account sections more easily. They see a timely next step without being sent back to the campaign that referred them. Featured actions can change in the database without a site release. Co-authored-by: Codex --- apps/site/functions/account/index.ts | 4 +- apps/site/functions/join/_form.ts | 14 ++- apps/site/functions/join/_page.ts | 6 +- apps/site/functions/join/member/index.ts | 4 +- .../functions/join/member/welcome/index.ts | 7 +- apps/site/platform/core/join-form.ts | 27 +++-- apps/site/platform/join.ts | 7 +- apps/site/platform/member-welcome.ts | 38 ++++--- apps/site/platform/messages/en.ts | 11 +- .../migrations/0007_onboarding_actions.sql | 25 +++++ .../platform/storage/migrations/schema.md | 10 ++ .../src/components/app/KeyValueSummary.astro | 7 +- apps/site/src/components/join/JoinForm.astro | 21 ++-- apps/site/src/pages/account/index.astro | 101 +++++++++++++----- apps/site/tests/member-welcome.test.ts | 38 +++---- apps/site/tests/platform-join.test.ts | 10 ++ 16 files changed, 235 insertions(+), 95 deletions(-) create mode 100644 apps/site/platform/storage/migrations/0007_onboarding_actions.sql diff --git a/apps/site/functions/account/index.ts b/apps/site/functions/account/index.ts index 28cf9d95..c9d39cad 100644 --- a/apps/site/functions/account/index.ts +++ b/apps/site/functions/account/index.ts @@ -58,7 +58,9 @@ export const onRequestGet: PagesFunction = async ({ env, request .on('[data-slot="mailing-list"]', show()); filled = filled.on('[data-slot="member-check"]', show()); } else { - filled = filled.on('[data-slot="rejoin"]', show()); + filled = filled + .on('[data-slot="rejoin"]', show()) + .on('[data-slot="membership-inactive"]', show()); } const text = notice.key && Object.hasOwn(NOTICES, notice.key) ? NOTICES[notice.key]() : undefined; diff --git a/apps/site/functions/join/_form.ts b/apps/site/functions/join/_form.ts index 177f0085..86de529a 100644 --- a/apps/site/functions/join/_form.ts +++ b/apps/site/functions/join/_form.ts @@ -12,7 +12,7 @@ export interface FormState { input?: JoinInput; errors?: JoinErrors; notice?: string; - referral?: 'wwd' | null; + referral?: string | null; } const TEXT_FIELDS: [string, keyof JoinInput][] = [ @@ -26,10 +26,7 @@ const TEXT_FIELDS: [string, keyof JoinInput][] = [ ]; function restoreTravelAnswers(rewriter: HTMLRewriter, input: JoinInput): HTMLRewriter { - for (const [field, answer] of [ - ['owns_car', input.ownsCar], - ['owns_bike', input.ownsBike], - ]) { + for (const [field, answer] of [['owns_car', input.ownsCar]]) { if (!answer) continue; rewriter = rewriter.on(`input[name="${field}"][value="${answer}"]`, { element(element) { @@ -75,6 +72,13 @@ export async function renderJoinForm( }, }); } + for (const option of input.micromobility) { + rewriter = rewriter.on(`input[name="micromobility"][value="${option}"]`, { + element(element) { + element.setAttribute('checked', ''); + }, + }); + } rewriter = restoreTravelAnswers(rewriter, input); if (input.consent) { rewriter = rewriter.on('input[name="consent"]', { diff --git a/apps/site/functions/join/_page.ts b/apps/site/functions/join/_page.ts index 3e0f3569..cdb68ab4 100644 --- a/apps/site/functions/join/_page.ts +++ b/apps/site/functions/join/_page.ts @@ -7,7 +7,7 @@ // Cloudflare Pages does not treat this file as a route. import { signToken, verifyToken } from '../../platform/core/signing'; -import { INTERESTS, type Interest } from '../../platform/core/join-form'; +import { INTERESTS, validReferral, type Interest } from '../../platform/core/join-form'; import type { PlatformEnv } from '../../platform/join'; import type { Db } from '../../platform/storage/db'; @@ -123,7 +123,7 @@ export interface JoinStep { email: string; address: string; interests: Interest[]; - referral: 'wwd' | null; + referral: string | null; } export async function joinStepCookie(secret: string, step: JoinStep): Promise { @@ -156,7 +156,7 @@ export async function readJoinStep(secret: string, request: Request): Promise INTERESTS.includes(interest as Interest)), - referral: payload.data?.referral === 'wwd' ? 'wwd' : null, + referral: validReferral(payload.data?.referral ?? null), }; } diff --git a/apps/site/functions/join/member/index.ts b/apps/site/functions/join/member/index.ts index 7dcaba95..4aa56b8f 100644 --- a/apps/site/functions/join/member/index.ts +++ b/apps/site/functions/join/member/index.ts @@ -6,7 +6,7 @@ // script runs and as an ordinary form when it doesn't. import { ulid } from '../../../platform/core/ids'; -import { readJoinForm } from '../../../platform/core/join-form'; +import { readJoinForm, validReferral } from '../../../platform/core/join-form'; import { processJoin, type JoinOutcome } from '../../../platform/join'; import { t } from '../../../platform/messages'; import { renderJoinForm, type FormState } from '../_form'; @@ -45,7 +45,7 @@ export const onRequestGet: PagesFunction = async ({ env, request }) => request, { formToken: ulid(), - referral: new URL(request.url).searchParams.get('from') === 'wwd' ? 'wwd' : null, + referral: validReferral(new URL(request.url).searchParams.get('from')), }, 200, ); diff --git a/apps/site/functions/join/member/welcome/index.ts b/apps/site/functions/join/member/welcome/index.ts index b1638e41..bca18dd0 100644 --- a/apps/site/functions/join/member/welcome/index.ts +++ b/apps/site/functions/join/member/welcome/index.ts @@ -4,7 +4,7 @@ // submitted, read from the signed join-step cookie. import { t } from '../../../../platform/messages'; -import { welcomeAction } from '../../../../platform/member-welcome'; +import { featuredWelcomeAction, welcomeAction } from '../../../../platform/member-welcome'; import { builtPage, finish, @@ -34,7 +34,10 @@ export const onRequestGet: PagesFunction = async ({ env, request }) => showEmailText(t('welcome.sentTo', { email: step.email })), ); } - const action = welcomeAction(step.interests, step.referral); + const featured = platform + ? await featuredWelcomeAction(platform.PLATFORM_DB, step.referral) + : null; + const action = welcomeAction(step.interests, featured); rewriter = rewriter .on('[data-slot="next-action-title"]', showText(action.title)) .on('[data-slot="next-action-description"]', showText(action.description)) diff --git a/apps/site/platform/core/join-form.ts b/apps/site/platform/core/join-form.ts index fd03dd21..4947d19d 100644 --- a/apps/site/platform/core/join-form.ts +++ b/apps/site/platform/core/join-form.ts @@ -3,6 +3,13 @@ export const INTERESTS = ['events', 'meetings', 'volunteering', 'news'] as const; export type Interest = (typeof INTERESTS)[number]; +export const MICROMOBILITY = ['bike', 'e-bike', 'scooter', 'skateboard'] as const; +export type Micromobility = (typeof MICROMOBILITY)[number]; + +export function validReferral(value: string | null): string | null { + const referral = value?.trim().toLowerCase() ?? ''; + return /^[a-z0-9][a-z0-9_-]{0,79}$/.test(referral) ? referral : null; +} export type JoinOrigin = 'join_form' | 'newsletter_box'; @@ -23,9 +30,9 @@ export interface JoinInput { interests: Interest[]; discordUsername: string; ownsCar: 'yes' | 'no' | null; - ownsBike: 'yes' | 'no' | null; - /** Identifies someone who arrived from the separate WWD campaign. */ - referral: 'wwd' | null; + micromobility: Micromobility[]; + /** Source supplied by the referring campaign or partner. */ + referral: string | null; consent: boolean; /** One-time token from the form, used to make a double submit harmless. */ formToken: string; @@ -62,10 +69,16 @@ export function readJoinForm(form: FormData): JoinInput { ownsCar: ['yes', 'no'].includes(text(form, 'owns_car')) ? (text(form, 'owns_car') as 'yes' | 'no') : null, - ownsBike: ['yes', 'no'].includes(text(form, 'owns_bike')) - ? (text(form, 'owns_bike') as 'yes' | 'no') - : null, - referral: text(form, 'referral') === 'wwd' ? 'wwd' : null, + micromobility: [ + ...new Set( + form + .getAll('micromobility') + .filter((value): value is Micromobility => + MICROMOBILITY.includes(value as Micromobility), + ), + ), + ], + referral: validReferral(text(form, 'referral')), consent: form.get('consent') === 'yes', formToken: text(form, 'form_token'), honeypot: text(form, 'website'), diff --git a/apps/site/platform/join.ts b/apps/site/platform/join.ts index 935423e3..ae371bde 100644 --- a/apps/site/platform/join.ts +++ b/apps/site/platform/join.ts @@ -16,7 +16,7 @@ import { hashWithSecret, signToken } from './core/signing'; import { subscribe, type SubscribeResult } from './integrations/beehiiv'; import { geocodeToBlock, type GeocodeResult } from './integrations/census'; import { emailConfigured, sendEmail } from './integrations/email'; -import { memberWelcomeEmail, welcomeAction } from './member-welcome'; +import { featuredWelcomeAction, memberWelcomeEmail, welcomeAction } from './member-welcome'; import { t } from './messages'; import type { Db } from './storage/db'; import { @@ -110,9 +110,10 @@ async function sendConfirmation( fetcher: typeof fetch, ): Promise { const link = await removalLink(env, recipient.personId); + const featured = await featuredWelcomeAction(env.PLATFORM_DB, recipient.referral); const { text, html } = memberWelcomeEmail({ givenName: recipient.givenName, - action: welcomeAction(recipient.interests, recipient.referral), + action: welcomeAction(recipient.interests, featured), unsubscribeUrl: link, }); await sendEmail( @@ -259,7 +260,7 @@ async function recordMember( referral: input.referral, discordUsername: input.discordUsername || null, ownsCar: input.ownsCar, - ownsBike: input.ownsBike, + micromobility: input.micromobility, }, }); return person.id; diff --git a/apps/site/platform/member-welcome.ts b/apps/site/platform/member-welcome.ts index 12642474..8569764e 100644 --- a/apps/site/platform/member-welcome.ts +++ b/apps/site/platform/member-welcome.ts @@ -1,10 +1,8 @@ import type { Interest } from './core/join-form'; import { escapeHtml } from './integrations/email'; +import type { Db } from './storage/db'; const SITE = 'https://lasvegasfortransit.org'; -const WWD = 'https://lvwwd.org/take-part/?utm_source=lvbt&utm_medium=welcome_email'; -const WWD_OPENS = Date.parse('2026-09-30T00:00:00-07:00'); -const WWD_CLOSES = Date.parse('2026-10-09T00:00:00-07:00'); export interface WelcomeAction { title: string; @@ -13,20 +11,32 @@ export interface WelcomeAction { href: string; } +export async function featuredWelcomeAction( + db: Db, + referral: string | null, + now = new Date(), +): Promise { + const timestamp = now.toISOString(); + try { + return await db + .prepare( + `SELECT title, description, label, href FROM onboarding_actions + WHERE active = 1 AND starts_at <= ? AND ends_at > ? AND id != ? + ORDER BY priority DESC, starts_at DESC LIMIT 1`, + ) + .bind(timestamp, timestamp, referral ?? '') + .first(); + } catch { + console.error('Could not read onboarding actions'); + return null; + } +} + export function welcomeAction( interests: Interest[], - referral: 'wwd' | null, - now = new Date(), + featured: WelcomeAction | null, ): WelcomeAction { - if (referral !== 'wwd' && now.getTime() >= WWD_OPENS && now.getTime() < WWD_CLOSES) { - return { - title: 'Take part in Week Without Driving', - description: - 'October 1–8: try a trip without driving and tell us what you notice. Anyone can take part, even without signing up for the giveaway.', - label: 'See how to take part', - href: WWD, - }; - } + if (featured) return featured; if (interests.includes('meetings')) { return { title: 'Speak up for better transit', diff --git a/apps/site/platform/messages/en.ts b/apps/site/platform/messages/en.ts index 2f5a4acf..3013105d 100644 --- a/apps/site/platform/messages/en.ts +++ b/apps/site/platform/messages/en.ts @@ -23,7 +23,11 @@ export const en = { discordUsernameLabel: 'Discord username (optional)', discordUsernameHint: 'We can use this later to give you the right roles in our server.', ownsCarHeading: 'Do you own a car? (optional)', - ownsBikeHeading: 'Do you own a bike? (optional)', + micromobilityHeading: 'What micromobility options do you own? (optional)', + micromobilityBike: 'Bike (traditional)', + micromobilityEbike: 'E-bike', + micromobilityScooter: 'Scooter', + micromobilitySkateboard: 'Skateboard', answerYes: 'Yes', answerNo: 'No', consentLabel: @@ -134,7 +138,7 @@ export const en = { heading: 'Your account', greetingNamed: 'Hi, {name}.', greeting: 'Hi.', - membershipHeading: 'Membership', + membershipHeading: 'Your LVBT membership', memberSince: "You've been an LVBT member since {date}.", notOnList: "You're not on LVBT's mailing list right now.", rejoin: 'Rejoin the mailing list', @@ -145,11 +149,8 @@ export const en = { email: 'Email', phone: 'Phone', area: 'Area', - language: 'Language', notGiven: 'Not given', change: 'Change', - languageValue: 'English', - languageNote: 'More languages are coming when we have volunteers to translate.', listHeading: 'Mailing list', onList: "You're on LVBT's mailing list.", leave: 'Leave the mailing list', diff --git a/apps/site/platform/storage/migrations/0007_onboarding_actions.sql b/apps/site/platform/storage/migrations/0007_onboarding_actions.sql new file mode 100644 index 00000000..0be98ed9 --- /dev/null +++ b/apps/site/platform/storage/migrations/0007_onboarding_actions.sql @@ -0,0 +1,25 @@ +-- Featured first steps are editable campaign data. The id also identifies the +-- referring source; a visitor from that source sees an evergreen next step. +CREATE TABLE onboarding_actions ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL, + label TEXT NOT NULL, + href TEXT NOT NULL, + starts_at TEXT NOT NULL, + ends_at TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 0, + active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)) +); + +CREATE INDEX onboarding_actions_current + ON onboarding_actions (active, starts_at, ends_at, priority DESC); + +INSERT INTO onboarding_actions + (id, title, description, label, href, starts_at, ends_at, priority) +VALUES + ('wwd', 'Take part in Week Without Driving', + 'October 1–8: try a trip without driving and tell us what you notice. Anyone can take part, even without signing up for the giveaway.', + 'See how to take part', + 'https://lvwwd.org/take-part/?utm_source=lvbt&utm_medium=welcome_email', + '2026-09-30T07:00:00.000Z', '2026-10-09T07:00:00.000Z', 100); diff --git a/apps/site/platform/storage/migrations/schema.md b/apps/site/platform/storage/migrations/schema.md index 2ab4e449..81ef653b 100644 --- a/apps/site/platform/storage/migrations/schema.md +++ b/apps/site/platform/storage/migrations/schema.md @@ -88,6 +88,16 @@ record said that couldn't be stored on the new person, such as an email another Each pair is queued once per reason. The rules are in `platform/storage/person-service.md`, "Matching". +## onboarding_actions + +Featured actions for the member welcome email and welcome page. Each row's `id` is also the referral +source name; someone arriving from that source sees an evergreen action instead of being sent back +to the same campaign. `title`, `description`, `label`, and `href` are the visible action. +`starts_at` and `ends_at` are UTC ISO timestamps; `priority` selects among overlapping campaigns; +`active` allows staff to turn one off without deleting it. Add or edit rows in D1 to change the +featured action without changing application code. The migration includes Week Without Driving as +the first time-limited action. + ## merges The record of two people combined into one, with the moved rows as JSON so the merge can be undone. diff --git a/apps/site/src/components/app/KeyValueSummary.astro b/apps/site/src/components/app/KeyValueSummary.astro index 889683d1..48a49470 100644 --- a/apps/site/src/components/app/KeyValueSummary.astro +++ b/apps/site/src/components/app/KeyValueSummary.astro @@ -4,11 +4,13 @@ // "Change" is followed by the row's name for screen readers, so it is read as // "Change Phone". `dataSlot` marks a value a server fills in on request. import { linkClass } from './styles'; +import { Icon } from 'astro-icon/components'; interface Row { key: string; value: string; dataSlot?: string; + icon?: string; change?: { href: string; label: string }; } @@ -23,7 +25,10 @@ const { rows } = Astro.props; { rows.map((row) => (
-
{row.key}
+
+ {row.icon &&
{row.value}
diff --git a/apps/site/src/components/join/JoinForm.astro b/apps/site/src/components/join/JoinForm.astro index 67c3656e..3b42db7b 100644 --- a/apps/site/src/components/join/JoinForm.astro +++ b/apps/site/src/components/join/JoinForm.astro @@ -115,16 +115,21 @@ const { action } = Astro.props; { value: 'no', label: t('join.answerNo') }, ]} /> -
+ +
+ +
-
+
-

Your place in LVBT

-

{t('account.membershipHeading')}

+

{t('account.membershipHeading')}

- +
-
-

{t('account.detailsHeading')}

+
+

+

-

{t('account.languageNote')}

-