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
2 changes: 2 additions & 0 deletions apps/site/.cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"Cottonbureau",
"didnt",
"Eldorado",
"Ebike",
"exceedance",
"FOUC",
"Fourplex",
Expand Down Expand Up @@ -55,6 +56,7 @@
"maint",
"Masto",
"MDX",
"micromobility",
"merch",
"moreperfectunion",
"navigations",
Expand Down
5 changes: 4 additions & 1 deletion apps/site/functions/account/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,11 @@ export const onRequestGet: PagesFunction<SignInPagesEnv> = 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;
Expand Down
15 changes: 8 additions & 7 deletions apps/site/functions/join/_form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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][] = [
Expand All @@ -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', '');
},
Expand Down
6 changes: 3 additions & 3 deletions apps/site/functions/join/_page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string> {
Expand Down Expand Up @@ -156,7 +156,7 @@ export async function readJoinStep(secret: string, request: Request): Promise<Jo
interests: (payload.data?.interests ?? '')
.split(',')
.filter((interest): interest is Interest => INTERESTS.includes(interest as Interest)),
referral: payload.data?.referral === 'wwd' ? 'wwd' : null,
referral: validReferral(payload.data?.referral ?? null),
};
}

Expand Down
4 changes: 2 additions & 2 deletions apps/site/functions/join/member/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -45,7 +45,7 @@ export const onRequestGet: PagesFunction<JoinEnv> = 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,
);
Expand Down
7 changes: 5 additions & 2 deletions apps/site/functions/join/member/welcome/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -34,7 +34,10 @@ export const onRequestGet: PagesFunction<JoinEnv> = 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))
Expand Down
46 changes: 36 additions & 10 deletions apps/site/platform/core/join-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
Expand All @@ -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(),
Expand All @@ -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'),
Expand Down
11 changes: 8 additions & 3 deletions apps/site/platform/join.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -110,9 +110,14 @@ async function sendConfirmation(
fetcher: typeof fetch,
): Promise<void> {
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(
Expand Down Expand Up @@ -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;
Expand Down
45 changes: 31 additions & 14 deletions apps/site/platform/member-welcome.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<WelcomeAction | null> {
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<WelcomeAction>();
} 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',
Expand Down
18 changes: 9 additions & 9 deletions apps/site/platform/messages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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:
Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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;
12 changes: 12 additions & 0 deletions apps/site/platform/storage/migrations/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading