diff --git a/messages/en.json b/messages/en.json
index c2248b9bd..7d353ae3f 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -14,6 +14,10 @@
"header_about": "About",
"header_community": "Community",
"header_get_involved": "Get involved",
+ "header_subscribe": "Subscribe",
+ "home_newsletter_heading": "Subscribe",
+ "home_newsletter_button": "Sign up",
+ "home_newsletter_description": "Sign up to our newsletter and receive updates about how to support our movement.",
"header_our_strategy": "Our strategy",
"header_why": "Why this matters",
"header_find_group": "Find your local group",
diff --git a/src/lib/components/Home.svelte b/src/lib/components/Home.svelte
index 5dd5581b8..c04b59e70 100644
--- a/src/lib/components/Home.svelte
+++ b/src/lib/components/Home.svelte
@@ -52,7 +52,12 @@
-
+
diff --git a/src/lib/components/navbar/wide/WideNavbar.svelte b/src/lib/components/navbar/wide/WideNavbar.svelte
index 94f063236..5d75c02d5 100644
--- a/src/lib/components/navbar/wide/WideNavbar.svelte
+++ b/src/lib/components/navbar/wide/WideNavbar.svelte
@@ -2,6 +2,10 @@
import UniversalNavbar from '../universal/UniversalNavbar.svelte'
import Navlink from '../Navlink.svelte'
import NavDropdown from '../NavDropdown.svelte'
+ import MailIcon from '@lucide/svelte/icons/mail'
+ import LinkWithoutIcon from '$lib/components/LinkWithoutIcon.svelte'
+ import { page } from '$app/state'
+ import { localizeHref } from '$lib/paraglide/runtime'
import type { NavItem } from '../navItems'
interface Props {
@@ -17,6 +21,15 @@
{#each items as item, i (item.label)}
{#if item.children}
+ {:else if item.mail}
+ {@const active = !!item.href && localizeHref(page.url.pathname) === localizeHref(item.href)}
+
+
+ {item.label}
+
{:else}
{item.label}
diff --git a/src/lib/components/onboarding/OnboardingFlow.svelte b/src/lib/components/onboarding/OnboardingFlow.svelte
index 5950d583e..d1b9a04b0 100644
--- a/src/lib/components/onboarding/OnboardingFlow.svelte
+++ b/src/lib/components/onboarding/OnboardingFlow.svelte
@@ -46,13 +46,31 @@
const intentOptions = $derived(getIntentOptions(msgs))
let {
+ initialEmail = '',
initialCountry = '',
initialCity = '',
- initialLanguages = [] as string[]
+ initialFullName = '',
+ initialLanguages = [] as string[],
+ initialRecordId = '',
+ startStep = 1,
+ initialKeepInformed = false,
+ initialChapterShare = false
}: {
+ initialEmail?: string
initialCountry?: string
initialCity?: string
+ initialFullName?: string
initialLanguages?: string[]
+ // The /subscribe flow creates the record up front and then hands off here
+ // to let people go further. It seeds the created record id and starts at
+ // the intent step, so this submission updates that record instead of
+ // creating a duplicate, and pre-sets the newsletter opt-in they already made.
+ initialRecordId?: string
+ startStep?: 1 | 2
+ initialKeepInformed?: boolean
+ // The chapter-updates choice made on the subscribe form, reposted so backing
+ // out of Volunteer/Lead restores it instead of leaving the escalation.
+ initialChapterShare?: boolean
} = $props()
// Starts true so an unanswered request keeps the anti-spam check required.
@@ -84,7 +102,7 @@
}
})
- let step: 1 | 2 | 3 | 4 = $state(1)
+ let step: 1 | 2 | 3 | 4 = $state(startStep)
let flowEl: HTMLDivElement | undefined = $state()
// Scroll back to the top of the flow on step changes. The volunteer form
@@ -102,9 +120,10 @@
let mode: 'contact' | 'browse' = $state('contact')
let intent: IntentKey | null = $state(null)
// Airtable record id from the step-2 submission; later submissions send it
- // back so the server updates the record instead of creating another.
- let recordId = $state('')
- let keepInformed = $state(false)
+ // back so the server updates the record instead of creating another. When the
+ // /subscribe flow hands off, it's seeded with the record already created there.
+ let recordId = $state(initialRecordId)
+ let keepInformed = $state(initialKeepInformed)
let submitting = $state(false)
let browseSignedUp = $state(false)
let honeypot = $state('')
@@ -124,13 +143,24 @@
// Step 1: basic info (shared with the browse-mode inline signup and the
// volunteer form, which pre-fills from the same state)
let basics = $state({
- fullName: '',
- email: '',
+ fullName: initialFullName,
+ email: initialEmail,
country: initialCountry,
city: initialCity,
newsletter: false
})
+ // A newsletter signup elsewhere (e.g. the homepage box) can hand off here
+ // via ?subscribe-email=...; that email arrives after mount. Apply it once into
+ // an empty field, then never again — so it neither clobbers typed input nor
+ // snaps back when the visitor clears it to fix a typo.
+ let prefillApplied = $state(false)
+ $effect(() => {
+ if (prefillApplied || !initialEmail) return
+ if (!basics.email) basics.email = initialEmail
+ prefillApplied = true
+ })
+
const validLanguageStored = new Set(LANGUAGES.map((l) => l.stored))
const filteredInitialLanguages = initialLanguages.filter((l) => validLanguageStored.has(l))
const defaultLanguages =
@@ -165,10 +195,19 @@
// user stays in the flow.
let becomePayingMember = $state(false)
+ // The /subscribe "do more" hand-off: we start at the intent step with a record
+ // already created and its email opt-ins already chosen. In that mode the step
+ // only asks how involved they want to be — the opt-in cards and a second
+ // consent are hidden so it can't re-ask (or silently undo) what they already
+ // picked on the subscribe form.
+ const isContinuation = $derived(startStep === 2 && !!initialRecordId)
+
// GDPR data-processing consent gating every record-creating submission
// (step 2 + browse). Chapter-sharing consent is not bundled here: it lives
// in the optional "Keep me informed" opt-in, since we only share details
// with a local chapter when the person asks to be connected to one.
+ // Stays false so it never pre-checks a visible consent box; the continuation
+ // already consented on the subscribe form and is exempted at the submit gate.
let gdprConsent = $state(false)
// Phone: dial code prefilled from country of residence, editable in case
@@ -199,18 +238,30 @@
agreements.conduct
)
- const stepperLabels = $derived(
- intent === 'volunteer'
- ? [
- msgs.onboarding_step_about,
- msgs.onboarding_step_intent,
- msgs.onboarding_step_volunteer_form,
- msgs.onboarding_step_confirmed
- ]
- : intent === 'lead'
- ? [msgs.onboarding_step_about, msgs.onboarding_step_intent, msgs.onboarding_step_next_steps]
- : [msgs.onboarding_step_about, msgs.onboarding_step_intent, msgs.onboarding_step_confirmed]
- )
+ const stepperLabels = $derived.by(() => {
+ const labels =
+ intent === 'volunteer'
+ ? [
+ msgs.onboarding_step_about,
+ msgs.onboarding_step_intent,
+ msgs.onboarding_step_volunteer_form,
+ msgs.onboarding_step_confirmed
+ ]
+ : intent === 'lead'
+ ? [
+ msgs.onboarding_step_about,
+ msgs.onboarding_step_intent,
+ msgs.onboarding_step_next_steps
+ ]
+ : [
+ msgs.onboarding_step_about,
+ msgs.onboarding_step_intent,
+ msgs.onboarding_step_confirmed
+ ]
+ // The /subscribe continuation begins after the subscribe form, so it never
+ // showed an "About you" step — drop it and start the stepper at Intent.
+ return isContinuation ? labels.slice(1) : labels
+ })
// Lead path: if the country already has a chapter, offer regional/city
// leadership instead of founding a national group. Fetched lazily when the
@@ -367,7 +418,10 @@
{/snippet}
{#snippet checkboxConfirmations()}
- {#if keepInformed || basics.newsletter}
+
+ {#if !isContinuation && (keepInformed || basics.newsletter)}
+ Sign up to our newsletter and receive updates about how to support our movement, from getting
+ involved in the current campaign to taking part in a local event.
+
+ {/if}
+
+
+{#if phase === 'form'}
+
+
+
+{:else if phase === 'thanks'}
+
+
Thanks for signing up!
+
+ Are you interested in doing more? Whether you have five minutes or five hours a week, there's
+ a place for you.
+
+ (phase = 'more')}>Get involved
+
+{:else}
+
+{/if}
+
+
diff --git a/src/lib/components/onboarding/options.ts b/src/lib/components/onboarding/options.ts
index dd8dcb268..b83a4e09d 100644
--- a/src/lib/components/onboarding/options.ts
+++ b/src/lib/components/onboarding/options.ts
@@ -7,6 +7,13 @@ export type IntentKey = 'act-now' | 'volunteer' | 'lead'
export const SIGNUP_SOURCE = 'June 2026 onboarding flow'
+// The /subscribe newsletter form, told apart from /join because the two ask for
+// chapter consent differently and nothing else in the row says which form wrote
+// it. The CRM's OPTIN_FORM_SOURCES should list this too: it also classifies on a
+// non-empty Intent, so the newsletter opt-in gate holds either way, but a source
+// it doesn't know trips the import's drift canary.
+export const SUBSCRIBE_SIGNUP_SOURCE = 'June 2026 subscribe form'
+
// Full 196-country list from the Tally form.
export const COUNTRIES = [
'Afghanistan',
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 712af1b37..5fa986a45 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -40,6 +40,8 @@ type StrictFrontmatterMeta = {
categories?: Categories[]
image?: string
showImage?: boolean
+ /** If false, the page-template h1 is not rendered (the page supplies its own heading) */
+ showTitle?: boolean
/** If true, this post will appear in the Latest News section on the homepage */
news?: boolean
/**
diff --git a/src/posts/subscribe.md b/src/posts/subscribe.md
new file mode 100644
index 000000000..ec19e07df
--- /dev/null
+++ b/src/posts/subscribe.md
@@ -0,0 +1,19 @@
+---
+title: Subscribe
+metaTitle: Subscribe to PauseAI
+description: Sign up to get updates from PauseAI.
+showTitle: false
+---
+
+
+
+
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 7739fb676..30ed89cf3 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -308,12 +308,20 @@
}
.menu-band :global(nav) {
- width: min(var(--page-width), 100% - 2 * var(--page-gutter));
+ width: min(var(--header-width), 100% - 2 * var(--page-gutter));
margin-inline: auto;
/* Tighter vertical padding than the component's responsive default. */
--vspace: 1.85rem;
}
+ /* The header on non-hero pages sits inside .layout, whose max-inline-size is
+ the (narrower) content width. Let it use the wider header width so all
+ top-level nav items fit on one row beside the logo. */
+ .layout > :global(.wide-navbar) {
+ width: min(var(--header-width), 100dvw - 2 * var(--page-gutter));
+ justify-self: center;
+ }
+
.layout {
height: 100%;
position: relative;
diff --git a/src/routes/[slug]/+page.svelte b/src/routes/[slug]/+page.svelte
index fbb14e397..5eb9cbf08 100644
--- a/src/routes/[slug]/+page.svelte
+++ b/src/routes/[slug]/+page.svelte
@@ -19,7 +19,8 @@
description,
image,
author,
- showImage = true
+ showImage = true,
+ showTitle = true
} = $derived({ title: data.slug, ...data.meta })
let parent = $derived(data.slug.split('/').slice(0, -1).join('/'))
@@ -42,15 +43,17 @@
{#if parent}
View all {parent}
{/if}
-
-
diff --git a/src/routes/embed/onboarding-form/+page.server.ts b/src/routes/embed/onboarding-form/+page.server.ts
index 095ba2a6a..04870055f 100644
--- a/src/routes/embed/onboarding-form/+page.server.ts
+++ b/src/routes/embed/onboarding-form/+page.server.ts
@@ -18,6 +18,7 @@ import {
LANGUAGES,
MOTIVATIONS,
SIGNUP_SOURCE,
+ SUBSCRIBE_SIGNUP_SOURCE,
SKILLS,
WEEKLY_HOURS,
type Intent
@@ -104,14 +105,22 @@ export const actions: Actions = {
const existingRecordId = getString(data, 'record_id')
// The volunteer detail fields are only present on the step-3 form post.
const hasVolunteerDetails = data.get('volunteer_details') === 'on'
+ // The /subscribe newsletter form. It requires the same four fields as /join,
+ // but decouples chapter sharing from the privacy consent: the /join form
+ // bundles the two, this one shares only on an explicit local-chapter tick.
+ const isSubscribeForm = data.get('subscribe_form') === '1'
+ const wantsChapter = data.get('chapter_share') === 'on'
- if (!fullName || !email || !country || !city) {
+ // Creates (both forms) require all four fields; an update (the volunteer
+ // step, or the subscribe "do more" hand-off) patches an existing record and
+ // skips the check, so it can't re-require what the original create collected.
+ if (!existingRecordId && (!fullName || !email || !country || !city)) {
return fail(400, { message: 'Please fill in your name, email, country and city.' })
}
if (!/^\S+@\S+\.\S+$/.test(email)) {
return fail(400, { message: 'Please enter a valid email address.' })
}
- if (!COUNTRIES.includes(country)) {
+ if (country && !COUNTRIES.includes(country)) {
return fail(400, { message: 'Please select a country from the list.' })
}
if (!isIntent(intent)) {
@@ -123,18 +132,44 @@ export const actions: Actions = {
return fail(400, { message: 'Please agree to the data processing consent to continue.' })
}
+ // Chapter sharing:
+ // - /join create bundles it into the single privacy checkbox -> true
+ // - /subscribe create shares only when they tick the local-updates box
+ // - an update (the /subscribe "do more" hand-off, or the volunteer step)
+ // leaves the signup-time choice alone, except Volunteer/Lead, whose
+ // local involvement means they hear from a chapter regardless
+ let chapterShare: boolean | undefined
+ if (existingRecordId) {
+ if (intent === 'Volunteer' || intent === 'Lead') chapterShare = true
+ // The subscribe "do more" step reposts the signup-time choice, so backing
+ // out of Volunteer/Lead restores it rather than leaving the escalation.
+ else if (isSubscribeForm) chapterShare = wantsChapter
+ } else {
+ chapterShare = isSubscribeForm ? wantsChapter : true
+ }
+
const fields: FieldSet = {
- 'Full name': fullName,
Email: email,
- Country: country,
- City: city,
Intent: intent,
- 'Signup source': SIGNUP_SOURCE,
'Email subscription': keepInformed,
- // One required consent checkbox covers both: agreeing to the privacy
- // policy and, as part of it, sharing details with the local chapter.
- 'Data privacy policy agreed': true,
- 'GDPR chapter share permission': true
+ // Signing up is itself the privacy-policy consent: the /join checkbox
+ // and the /subscribe microcopy both link it.
+ 'Data privacy policy agreed': true
+ }
+ // Which form produced the row — provenance, so it's written once at create and
+ // never on an update, or the volunteer step (which carries no subscribe marker)
+ // would rewrite a /subscribe row as a /join one.
+ if (!existingRecordId) {
+ fields['Signup source'] = isSubscribeForm ? SUBSCRIBE_SIGNUP_SOURCE : SIGNUP_SOURCE
+ }
+ // A create always writes the basics (they're validated above). An update only
+ // overwrites them when non-empty, so a partial post can't blank what the
+ // create collected.
+ if (!existingRecordId || fullName) fields['Full name'] = fullName
+ if (!existingRecordId || country) fields.Country = country
+ if (!existingRecordId || city) fields.City = city
+ if (chapterShare !== undefined) {
+ fields['GDPR chapter share permission'] = chapterShare
}
if (intent === 'Volunteer' && hasVolunteerDetails) {
@@ -181,10 +216,6 @@ export const actions: Actions = {
}
}
- // Chapter routing is recorded only; notifying the chapter stays a manual
- // Airtable process (plan decision 6).
- const chapter = await lookupChapter(fetch, country)
-
if (live) {
let recordId: string | undefined = existingRecordId || undefined
if (recordId) {
@@ -208,6 +239,10 @@ export const actions: Actions = {
return { success: true, recordId }
}
+ // Chapter routing is recorded only for stub inspection; notifying the
+ // chapter stays a manual Airtable process (plan decision 6). The live
+ // branch never reads it, so it's resolved here rather than on every submit.
+ const chapter = await lookupChapter(fetch, country)
const submission = recordStubSubmission({
airtable: {
baseId: AIRTABLE_BASE_ID,
diff --git a/src/styles/styles.css b/src/styles/styles.css
index 65b9148c2..c8db738c0 100644
--- a/src/styles/styles.css
+++ b/src/styles/styles.css
@@ -11,6 +11,9 @@ html {
/* Major theme stuff */
--page-width: 40rem; /* Please update in media queries and $lib/config.ts */
+ /* The header/nav gets a little more room than the content column so all
+ top-level items fit on one row beside the logo (see +layout.svelte). */
+ --header-width: 48rem;
/* Colors */
--t-color-main: #f68b1e; /* Default main color - saturated orange */