diff --git a/apps/site/.cspell.json b/apps/site/.cspell.json index c9819cee..c1ed7d7f 100644 --- a/apps/site/.cspell.json +++ b/apps/site/.cspell.json @@ -24,6 +24,7 @@ "Cottonbureau", "didnt", "Eldorado", + "Ebike", "exceedance", "FOUC", "Fourplex", @@ -55,6 +56,7 @@ "maint", "Masto", "MDX", + "micromobility", "merch", "moreperfectunion", "navigations", diff --git a/apps/site/functions/account/index.ts b/apps/site/functions/account/index.ts index 4a4ce6a7..c9d39cad 100644 --- a/apps/site/functions/account/index.ts +++ b/apps/site/functions/account/index.ts @@ -56,8 +56,11 @@ 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..cfee767d 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,12 +26,13 @@ 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], - ]) { - if (!answer) continue; - rewriter = rewriter.on(`input[name="${field}"][value="${answer}"]`, { + const checked = [ + ...(input.ownsCar === 'yes' ? ['car'] : []), + ...input.micromobility, + ...(input.ownsCar === 'no' && input.micromobility.length === 0 ? ['none'] : []), + ]; + for (const answer of checked) { + rewriter = rewriter.on(`input[name="transport_options"][value="${answer}"]`, { element(element) { element.setAttribute('checked', ''); }, 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..869270d5 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, step.interests) + : 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..33ad202a 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; @@ -49,6 +56,17 @@ export function readJoinForm(form: FormData): JoinInput { const interests = form .getAll('interests') .filter((value): value is Interest => INTERESTS.includes(value as Interest)); + const selectedTransport = form + .getAll('transport_options') + .filter( + (value): value is string => + typeof value === 'string' && ['car', 'none', ...MICROMOBILITY].includes(value), + ); + const specificTransport = selectedTransport.filter((value) => value !== 'none'); + const legacyCar = text(form, 'owns_car'); + const legacyMicromobility = form + .getAll('micromobility') + .filter((value): value is Micromobility => MICROMOBILITY.includes(value as Micromobility)); return { origin, email: text(form, 'email').toLowerCase(), @@ -59,13 +77,21 @@ export function readJoinForm(form: FormData): JoinInput { address: text(form, 'address'), interests: [...new Set(interests)], discordUsername: text(form, 'discord_username'), - 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, + ownsCar: selectedTransport.length + ? specificTransport.includes('car') + ? 'yes' + : 'no' + : ['yes', 'no'].includes(legacyCar) + ? (legacyCar as 'yes' | 'no') + : null, + micromobility: [ + ...new Set( + (selectedTransport.length ? specificTransport : legacyMicromobility).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..71ce5d8f 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,14 @@ async function sendConfirmation( fetcher: typeof fetch, ): Promise { const link = await removalLink(env, recipient.personId); + const featured = await featuredWelcomeAction( + env.PLATFORM_DB, + recipient.referral, + recipient.interests, + ); const { text, html } = memberWelcomeEmail({ givenName: recipient.givenName, - action: welcomeAction(recipient.interests, recipient.referral), + action: welcomeAction(recipient.interests, featured), unsubscribeUrl: link, }); await sendEmail( @@ -259,7 +264,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..d6cff8e3 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,39 @@ export interface WelcomeAction { href: string; } -export function welcomeAction( +export async function featuredWelcomeAction( + db: Db, + referral: string | null, interests: Interest[], - referral: 'wwd' | null, now = new Date(), -): 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, - }; +): Promise { + const timestamp = now.toISOString(); + const interestFilter = + interests.length > 0 + ? `AND (interest IS NULL OR interest IN (${interests.map(() => '?').join(', ')}))` + : 'AND interest IS NULL'; + try { + return await db + .prepare( + `SELECT title, description, label, href FROM onboarding_actions + WHERE active = 1 AND starts_at <= ? AND ends_at > ? + AND (referral_source IS NULL OR referral_source != ?) + ${interestFilter} + ORDER BY priority DESC, (interest IS NOT NULL) DESC, starts_at DESC LIMIT 1`, + ) + .bind(timestamp, timestamp, referral ?? '', ...interests) + .first(); + } catch { + console.error('Could not read onboarding actions'); + return null; } +} + +export function welcomeAction( + interests: Interest[], + featured: WelcomeAction | null, +): WelcomeAction { + 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..0f8c9e06 100644 --- a/apps/site/platform/messages/en.ts +++ b/apps/site/platform/messages/en.ts @@ -22,10 +22,14 @@ export const en = { interestNews: 'Transit news', 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)', - answerYes: 'Yes', - answerNo: 'No', + transportHeading: 'What do you own for getting around? (optional)', + transportHint: 'Select all that apply.', + transportCar: 'Car', + transportNone: 'None of these', + micromobilityBike: 'Bike (traditional)', + micromobilityEbike: 'E-bike', + micromobilityScooter: 'Scooter', + micromobilitySkateboard: 'Skateboard', consentLabel: "Add me to LVBT's mailing list. This makes me an LVBT member. I can unsubscribe at any time.", submit: 'Join LVBT', @@ -134,8 +138,7 @@ export const en = { heading: 'Your account', greetingNamed: 'Hi, {name}.', greeting: 'Hi.', - membershipHeading: 'Membership', - memberSince: "You've been an LVBT member since {date}.", + memberSince: 'LVBT member since {date}', notOnList: "You're not on LVBT's mailing list right now.", rejoin: 'Rejoin the mailing list', rejoinConsent: @@ -145,11 +148,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/0008_onboarding_action_targeting.sql b/apps/site/platform/storage/migrations/0008_onboarding_action_targeting.sql new file mode 100644 index 00000000..d131fbf9 --- /dev/null +++ b/apps/site/platform/storage/migrations/0008_onboarding_action_targeting.sql @@ -0,0 +1,6 @@ +-- An action can be aimed at one membership interest. Several actions can +-- belong to the same referring campaign without sharing a primary key. +ALTER TABLE onboarding_actions ADD COLUMN referral_source TEXT; +ALTER TABLE onboarding_actions ADD COLUMN interest TEXT; + +UPDATE onboarding_actions SET referral_source = id WHERE referral_source IS NULL; diff --git a/apps/site/platform/storage/migrations/schema.md b/apps/site/platform/storage/migrations/schema.md index 2ab4e449..5a57d9d3 100644 --- a/apps/site/platform/storage/migrations/schema.md +++ b/apps/site/platform/storage/migrations/schema.md @@ -88,6 +88,18 @@ 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. `id` identifies the action. +`referral_source` optionally names its campaign or partner; someone arriving from that source sees a +different action instead of being sent back. `interest` optionally targets one of the join form +interests, so an event or public meeting can reach the people who selected it. `title`, +`description`, `label`, and `href` are the visible action. `starts_at` and `ends_at` are UTC ISO +timestamps; `priority` selects among overlapping actions; `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. Migrations 0007 and 0008 seed Week Without Driving as the first time-limited +action and assign its referral source. + ## 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/public/scripts/join-steps.js b/apps/site/public/scripts/join-steps.js index 7524c72d..b3e02553 100644 --- a/apps/site/public/scripts/join-steps.js +++ b/apps/site/public/scripts/join-steps.js @@ -50,6 +50,19 @@ second.hidden = true; continueButton.addEventListener('click', continueToInterests); backButton.addEventListener('click', () => show(1)); + form.querySelector('#transport_options')?.addEventListener('change', (event) => { + const changed = event.target; + if (!changed?.matches?.('input[name="transport_options"]') || !changed.checked) return; + const options = form.querySelectorAll('input[name="transport_options"]'); + if (changed.value === 'none') { + options.forEach((option) => { + if (option.value !== 'none') option.checked = false; + }); + } else { + const none = form.querySelector('input[name="transport_options"][value="none"]'); + if (none) none.checked = false; + } + }); form.addEventListener( 'submit', (event) => { diff --git a/apps/site/src/components/app/KeyValueSummary.astro b/apps/site/src/components/app/KeyValueSummary.astro index efddf544..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 }; } @@ -19,16 +21,19 @@ interface Props { const { rows } = Astro.props; --- -
+
{ rows.map((row) => ( - +
-
- - Get involved - {/* Mobile controls: Join CTA (hidden when nav open) + hamburger toggle */}
- - Join - + + - +
-

{t('account.detailsHeading')}

-
- -
-

{t('account.languageNote')}

+
+
+

+

+
+ +
+
- +
+

+

+ + +
-

{t('account.dataHeading')}

- +
+

+

+ +
+
diff --git a/apps/site/tests/member-welcome.test.ts b/apps/site/tests/member-welcome.test.ts index 13dbd0b4..015430eb 100644 --- a/apps/site/tests/member-welcome.test.ts +++ b/apps/site/tests/member-welcome.test.ts @@ -1,38 +1,70 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { memberWelcomeEmail, welcomeAction } from '../platform/member-welcome'; +import { + featuredWelcomeAction, + memberWelcomeEmail, + welcomeAction, +} from '../platform/member-welcome'; +import { memoryDb } from './support/platform-db'; -void test('the campaign is the first step only while it is timely and not already the referral', () => { +void test('configured campaigns are timely and skip their own referrals', async () => { + const db = memoryDb(); const during = new Date('2026-10-02T18:00:00Z'); - assert.match(welcomeAction([], null, during).href, /^https:\/\/lvwwd\.org\/take-part\//); - assert.equal(welcomeAction([], 'wwd', during).href, 'https://lasvegasfortransit.org/go/'); - assert.equal( - welcomeAction([], null, new Date('2026-10-09T07:00:00Z')).href, - 'https://lasvegasfortransit.org/go/', + assert.match( + (await featuredWelcomeAction(db, null, [], during))?.href ?? '', + /^https:\/\/lvwwd\.org\/take-part\//, ); + assert.equal(await featuredWelcomeAction(db, 'wwd', [], during), null); + assert.ok(await featuredWelcomeAction(db, 'partner_name', [], during)); + assert.equal(await featuredWelcomeAction(db, null, [], new Date('2026-10-09T07:00:00Z')), null); }); -void test('evergreen first steps follow interests and always have a useful destination', () => { - const later = new Date('2026-10-15T18:00:00Z'); +void test('new event and meeting actions can target an interest without new app code', async () => { + const db = memoryDb(); + db.raw + .prepare( + `INSERT INTO onboarding_actions + (id, referral_source, interest, title, description, label, href, starts_at, ends_at, priority) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'council-october', + 'council', + 'meetings', + 'Speak at the council meeting', + 'Share what you need from transit.', + 'Meeting details', + 'https://lasvegasfortransit.org/events/', + '2026-10-01T00:00:00.000Z', + '2026-10-30T00:00:00.000Z', + 200, + ); + const during = new Date('2026-10-02T18:00:00Z'); assert.equal( - welcomeAction(['meetings'], null, later).href, - 'https://lasvegasfortransit.org/events/', + (await featuredWelcomeAction(db, null, ['meetings'], during))?.title, + 'Speak at the council meeting', ); - assert.equal( - welcomeAction(['events'], null, later).href, - 'https://lasvegasfortransit.org/events/', + assert.match((await featuredWelcomeAction(db, null, ['events'], during))?.href ?? '', /lvwwd/); + assert.match( + (await featuredWelcomeAction(db, 'council', ['meetings'], during))?.href ?? '', + /lvwwd/, ); +}); + +void test('evergreen first steps follow interests and always have a useful destination', () => { + assert.equal(welcomeAction(['meetings'], null).href, 'https://lasvegasfortransit.org/events/'); + assert.equal(welcomeAction(['events'], null).href, 'https://lasvegasfortransit.org/events/'); assert.equal( - welcomeAction(['volunteering'], null, later).href, + welcomeAction(['volunteering'], null).href, 'https://lasvegasfortransit.org/join/#team', ); - assert.equal(welcomeAction(['news'], null, later).href, 'https://lasvegasfortransit.org/go/'); + assert.equal(welcomeAction(['news'], null).href, 'https://lasvegasfortransit.org/go/'); }); void test('welcome email escapes the name and keeps actions available with images blocked', () => { const email = memberWelcomeEmail({ givenName: '', - action: welcomeAction(['events'], 'wwd', new Date('2026-10-02T18:00:00Z')), + action: welcomeAction(['events'], null), unsubscribeUrl: 'https://lasvegasfortransit.org/join/remove/?token=signed', }); assert.match(email.html, /Welcome to LVBT, <Alex>!/); diff --git a/apps/site/tests/platform-join.test.ts b/apps/site/tests/platform-join.test.ts index f5d20d17..67855d8d 100644 --- a/apps/site/tests/platform-join.test.ts +++ b/apps/site/tests/platform-join.test.ts @@ -98,6 +98,30 @@ void test('joining with only an email and the box ticked makes a member everywhe assert.equal(email.headers['List-Unsubscribe-Post'], 'List-Unsubscribe=One-Click'); }); +void test('join form keeps valid referral sources and selected micromobility options', () => { + const input = form({ + referral: 'Partner_42', + micromobility: ['bike', 'e-bike', 'scooter', 'skateboard', 'unknown', 'bike'], + }); + assert.equal(input.referral, 'partner_42'); + assert.deepEqual(input.micromobility, ['bike', 'e-bike', 'scooter', 'skateboard']); + assert.equal(form({ referral: 'https://example.org/' }).referral, null); +}); + +void test('one getting-around question preserves car and micromobility answers', () => { + const selected = form({ + transport_options: ['car', 'bike', 'e-bike', 'scooter', 'skateboard', 'none'], + }); + assert.equal(selected.ownsCar, 'yes'); + assert.deepEqual(selected.micromobility, ['bike', 'e-bike', 'scooter', 'skateboard']); + const noVehicles = form({ transport_options: 'none' }); + assert.equal(noVehicles.ownsCar, 'no'); + assert.deepEqual(noVehicles.micromobility, []); + const onlyBike = form({ transport_options: 'bike' }); + assert.equal(onlyBike.ownsCar, 'no'); + assert.deepEqual(onlyBike.micromobility, ['bike']); +}); + void test('without the box ticked nothing is saved', async () => { const db = memoryDb(); const { calls, fetcher } = fakeServices();