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
6 changes: 1 addition & 5 deletions apps/site/functions/join/_form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,7 @@ const TEXT_FIELDS: [string, keyof JoinInput][] = [
];

function restoreTravelAnswers(rewriter: HTMLRewriter, input: JoinInput): HTMLRewriter {
const checked = [
...(input.ownsCar === 'yes' ? ['car'] : []),
...input.micromobility,
...(input.ownsCar === 'no' && input.micromobility.length === 0 ? ['none'] : []),
];
const checked = [...(input.ownsCar === 'yes' ? ['car'] : []), ...input.micromobility];
for (const answer of checked) {
rewriter = rewriter.on(`input[name="transport_options"][value="${answer}"]`, {
element(element) {
Expand Down
3 changes: 2 additions & 1 deletion apps/site/platform/core/join-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export interface JoinInput {
honeypot: string;
}

export type JoinField = 'email' | 'zip' | 'phone' | 'consent';
export type JoinField = 'email' | 'given_name' | 'zip' | 'phone' | 'consent';
export type JoinErrors = Partial<Record<JoinField, 'invalid' | 'required'>>;

const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
Expand Down Expand Up @@ -118,6 +118,7 @@ export function formatPhone(e164: string): string {
export function validateJoin(input: JoinInput): JoinErrors {
const errors: JoinErrors = {};
if (!EMAIL_PATTERN.test(input.email)) errors.email = input.email ? 'invalid' : 'required';
if (input.origin === 'join_form' && !input.givenName.trim()) errors.given_name = 'required';
if (input.zip && !/^\d{5}$/.test(input.zip)) errors.zip = 'invalid';
if (input.phone && normalizePhone(input.phone) === null) errors.phone = 'invalid';
if (!input.consent) errors.consent = 'required';
Expand Down
7 changes: 4 additions & 3 deletions apps/site/platform/messages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ export const en = {
intro:
"Members get LVBT's updates, invitations to events and a say in what we work on. It's free.",
emailLabel: 'Email address',
givenNameLabel: 'First name (optional)',
givenNameLabel: 'First name',
givenNameError: 'Enter your first name',
familyNameLabel: 'Last name (optional)',
phoneLabel: 'Phone number (optional)',
phoneHint: 'Only for questions about your membership. LVBT updates come by email.',
interestsHeading: 'What are you interested in? (optional)',
interestEvents: 'Coming to events',
interestMeetings: 'Speaking up at public meetings',
Expand All @@ -23,9 +25,8 @@ export const en = {
discordUsernameLabel: 'Discord username (optional)',
discordUsernameHint: 'We can use this later to give you the right roles in our server.',
transportHeading: 'What do you own for getting around? (optional)',
transportHint: 'Select all that apply.',
transportHint: 'Select all that apply, or leave this blank.',
transportCar: 'Car',
transportNone: 'None of these',
micromobilityBike: 'Bike (traditional)',
micromobilityEbike: 'E-bike',
micromobilityScooter: 'Scooter',
Expand Down
19 changes: 6 additions & 13 deletions apps/site/public/scripts/join-steps.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
const continueButton = form.querySelector('[data-join-continue]');
const backButton = form.querySelector('[data-join-back]');
const email = form.querySelector('[name="email"]');
const givenName = form.querySelector('[name="given_name"]');
const consent = form.querySelector('[name="consent"]');
const items = progress.querySelectorAll('li');
const basicHeading = document.querySelector('[data-join-heading-basic]');
Expand All @@ -38,6 +39,7 @@
continueButton,
backButton,
email,
givenName,
consent,
basicHeading,
interestsHeading,
Expand Down Expand Up @@ -66,26 +68,17 @@

function continueToInterests() {
if (!email.reportValidity()) return;
if (!givenName.value.trim()) givenName.setCustomValidity('Enter your first name');
else givenName.setCustomValidity('');
if (!givenName.reportValidity()) return;
if (!consent.reportValidity()) return;
show(2);
}

second.hidden = true;
givenName.addEventListener('input', () => givenName.setCustomValidity(''));
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) => {
Expand Down
2 changes: 1 addition & 1 deletion apps/site/src/components/NewsletterEmbed.astro
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const { variant = 'light', compact = false, stack = false } = Astro.props;
aria-label="Email address"
placeholder="you@example.com"
aria-describedby="newsletter-status"
class="flex-1 min-w-0 px-4 py-3 border-2 border-current bg-transparent placeholder:opacity-80 focus:outline-hidden"
class="flex-1 min-w-0 px-4 py-3 border-2 border-on-surface bg-surface-container text-on-surface placeholder:opacity-80 focus:outline-hidden"
/>
<button
type="submit"
Expand Down
2 changes: 1 addition & 1 deletion apps/site/src/components/PagefindSearch.astro
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ const {
min-height: 3rem;
width: 100%;
border: 2px solid var(--color-outline);
background: var(--color-surface);
background: var(--color-surface-container);
padding: 0.75rem 0.9rem;
color: var(--color-on-surface);
}
Expand Down
2 changes: 1 addition & 1 deletion apps/site/src/components/app/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export const labelClass = 'block font-bold';
export const hintClass = 'mt-1 text-sm max-w-prose';
export const errorClass = 'mt-2 font-bold text-primary-ink';
export const inputClass =
'mt-2 block w-full px-4 py-3 border-2 border-on-surface bg-surface text-on-surface disabled:opacity-60 aria-[invalid=true]:border-primary-ink';
'mt-2 block w-full px-4 py-3 border-2 border-on-surface bg-surface-container text-on-surface disabled:opacity-60 aria-[invalid=true]:border-primary-ink';
export const choiceClass = 'mt-1 size-5 shrink-0 accent-primary';

const buttonBase =
Expand Down
11 changes: 9 additions & 2 deletions apps/site/src/components/join/JoinForm.astro
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const { action } = Astro.props;
heading={t('join.errorSummaryHeading')}
errors={[
{ field: 'email', message: t('join.emailError') },
{ field: 'given_name', message: t('join.givenNameError') },
{ field: 'phone', message: t('join.phoneError') },
{ field: 'consent', message: t('join.consentError') },
]}
Expand All @@ -48,13 +49,20 @@ const { action } = Astro.props;
/>

<div class="grid gap-6 sm:grid-cols-2 max-w-md sm:max-w-none">
<TextField name="given_name" label={t('join.givenNameLabel')} autocomplete="given-name" />
<TextField
name="given_name"
label={t('join.givenNameLabel')}
autocomplete="given-name"
required
error={t('join.givenNameError')}
/>
<TextField name="family_name" label={t('join.familyNameLabel')} autocomplete="family-name" />
</div>

<TextField
name="phone"
label={t('join.phoneLabel')}
hint={t('join.phoneHint')}
type="tel"
autocomplete="tel-national"
error={t('join.phoneError')}
Expand Down Expand Up @@ -118,7 +126,6 @@ const { action } = Astro.props;
{ value: 'e-bike', label: t('join.micromobilityEbike') },
{ value: 'scooter', label: t('join.micromobilityScooter') },
{ value: 'skateboard', label: t('join.micromobilitySkateboard') },
{ value: 'none', label: t('join.transportNone') },
]}
/>

Expand Down
12 changes: 12 additions & 0 deletions apps/site/tests/platform-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,19 +90,31 @@ void test('membership status follows the newsletter consent', () => {
void test('the join form reads and checks its fields', () => {
const data = new FormData();
data.append('email', ' Ana@Example.org ');
data.append('given_name', ' Ana ');
data.append('interests', 'events');
data.append('interests', 'not-a-real-interest');
data.append('consent', 'yes');
data.append('phone', '(702) 555-0123');
const input = readJoinForm(data);
assert.equal(input.email, 'ana@example.org');
assert.equal(input.givenName, 'Ana');
assert.deepEqual(input.interests, ['events']);
assert.equal(input.origin, 'join_form');
assert.deepEqual(validateJoin(input), {});
assert.equal(normalizePhone('(702) 555-0123'), '+17025550123');
assert.equal(normalizePhone('555-0123'), null);
});

void test('the member join form requires a first name, while the newsletter box does not', () => {
const data = new FormData();
data.set('email', 'ana@example.org');
data.set('consent', 'yes');
data.set('given_name', ' ');
assert.deepEqual(validateJoin(readJoinForm(data)), { given_name: 'required' });
data.set('origin', 'newsletter_box');
assert.deepEqual(validateJoin(readJoinForm(data)), {});
});

void test('messages fill values, choose plurals and pseudo-localize', () => {
assert.equal(t('welcome.headingNamed', { name: 'Ana' }), 'Welcome to LVBT, Ana!');
const catalog = { tries: { one: '{count} try left', other: '{count} tries left' } };
Expand Down
24 changes: 20 additions & 4 deletions apps/site/tests/platform-join.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ function env(db: MemoryDb): PlatformEnv {

function form(fields: Record<string, string | string[]>): JoinInput {
const data = new FormData();
if (fields.origin !== 'newsletter_box' && !('given_name' in fields))
data.set('given_name', 'Ana');
for (const [name, value] of Object.entries(fields)) {
for (const item of Array.isArray(value) ? value : [value]) data.append(name, item);
}
Expand All @@ -71,7 +73,7 @@ const inSunriseManor: GeocodeResult = {
places: ['Sunrise Manor CDP'],
};

void test('joining with only an email and the box ticked makes a member everywhere', async () => {
void test('joining with basic details and consent makes a member everywhere', async () => {
const db = memoryDb();
const { calls, fetcher } = fakeServices();
const outcome = await processJoin(
Expand All @@ -98,6 +100,20 @@ 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('a blank first name prevents a member join before anything is saved', async () => {
const db = memoryDb();
const { calls, fetcher } = fakeServices();
const outcome = await processJoin(
env(db),
form({ email: 'ana@example.org', given_name: ' ', consent: 'yes' }),
'203.0.113.1',
{ fetcher },
);
assert.deepEqual(outcome, { kind: 'invalid', errors: { given_name: 'required' } });
assert.equal(db.raw.prepare('SELECT count(*) AS n FROM people').get()?.n, 0);
assert.equal(calls.length, 0);
});

void test('join form keeps valid referral sources and selected micromobility options', () => {
const input = form({
referral: 'Partner_42',
Expand All @@ -114,9 +130,9 @@ void test('one getting-around question preserves car and micromobility answers',
});
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 unanswered = form({});
assert.equal(unanswered.ownsCar, null);
assert.deepEqual(unanswered.micromobility, []);
const onlyBike = form({ transport_options: 'bike' });
assert.equal(onlyBike.ownsCar, 'no');
assert.deepEqual(onlyBike.micromobility, ['bike']);
Expand Down
Loading