* refactor(web): dedupe funnel form options across the 3 forms
`skills`, `chatService` and `country` were byte-identical in all three
form definitions. They move to `packages/funnel/src/form-options.ts` and
reach the pages through `AFFORM_OPTIONS`; the form files now describe
rendering only.
Lists verified ordered-identical to the originals, entry for entry.
Closes#140
* refactor(civi-crm): derive notion id/label maps from shared funnel options
`SKILLS_MAP`, `CHAT_SERVICE_MAP` and `COUNTRY_MAP` were a fourth copy of
the funnel option lists. They are now `toLabelMap()` over the shared
arrays, the way `HEAR_ABOUT_MAP` already was, so an id cannot mean one
thing in the dropdown and another in Notion.
All 275 derived entries verified identical to the previous literals, so
the Notion and n8n writes are unchanged. The one exception is country
1054, normalised to `Côte d'Ivoire` -- the spelling Notion already stored
and the one the other apostrophised country names use. The dropdown label
and the Ghost member note now match it.
* feat(funnel): add per-form required field list
Single source for which funnel answers are required, plus the check the
intake endpoint runs a submission against.
* refactor(web): build the funnel form schema from the shared list
Form definitions no longer carry a `required` flag, they describe
rendering only. The three pages pass REQUIRED_FIELDS_BY_FORM into
ConnectFormSection, which derives the zod schema and the required markers
from it.
* fix(civi-crm): validate required funnel fields on afform-submit
Only formName, the hear-about answer and the captcha were checked, so a
direct POST could create a record with no name, email or country. A
payload missing a required answer now gets a 400 naming the fields.
* docs(funnel): document the required-field validation
The sitemap fetched RFPs strictly while generateStaticParams and the RFP
listing used a lenient wrapper that degraded to an empty array. Under
`output: 'export'` there is no runtime fallback, so a GitHub hiccup during
generateStaticParams shipped a site whose RFP detail routes all 404 while
the sitemap still advertised them.
Every caller now uses the throwing fetch, so the page set and the sitemap
always agree. Transient failures (403/429/5xx and network errors) retry
with exponential backoff — including the directory listing, which was
single-shot — while a 404 falls straight through to the next fallback URL
instead of burning two pointless retries.
Sitemap RFP entries now come from fetchGithubRfpsStrict, which throws when the Github listing or any individual RFP file cannot be read. Previously a failed fetch silently returned an empty list, so a Github outage shipped a sitemap with no RFP URLs while CI stayed green. The RFP pages keep the lenient loader and still degrade to an empty listing.
The static export smoke test derives RFP routes from the exported HTML instead of making a second Github call, which removes the build-time vs check-time race and makes the check fully offline.
Technology stack pages read the parent /technology-stack page copy for the parent breadcrumb name, instead of deriving it from the child page's own hero eyebrow.
Organization @id and url use the normalised site origin, and the structured data tests derive their base URL from siteConfig rather than hardcoding logos.co.
* refactor(civi-crm): remove case management pages and API
The Circle Case UI, its API routes and the CiviCRM query layer they used
are gone. Keeps the app shell out entirely: no pages remain, so the
layout, styles and UI dependencies go with them.
* refactor(civi-crm): drop CiviCRM write from the intake endpoint
Submissions now go to Notion and, for the steward form, n8n. The legacy
afformCircleContactForm name was CiviCRM-only and is no longer accepted;
the fields[] payload is ignored.
* chore(web): remove CiviCRM afform generator scripts
They fetched form definitions from the CiviCRM API, which is gone. The
generated files under lib/civicrm are hand-maintained from now on.
* refactor(web): trim funnel form definitions and move to lib/funnel-forms
Fields keep only what renders the form: formKey, label, inputType,
required, repeatable. The CiviCRM entity/join/fieldName plumbing and the
fields[] POST payload are gone, along with the unused circle contact
form.
country was marked required but exempt from validation through its
CiviCRM Address join, so it is now enforced like every other required
select.
* docs: rewrite civi-crm and funnel docs for the intake-only app
Drops the CiviCRM API guide and the case management architecture, and
updates the funnel reference to the Notion + n8n flow.
* chore(web): add a debug level to the logger
Logs in every build but tests, for output a deployed preview needs.
* feat(web): forward submitted form data on newsletter signup
Every field a form collects is sent to the subscribe endpoint, select
values as their labels rather than CiviCRM option ids. Logs the payload.
* docs(funnel): document the forwarded subscribe payload
* fix(web): build the funnel newsletter payload from answers only
Keeps the intake captcha token and field defs out of the newsletter
service whatever a caller puts in extraPayload.
* refactor(funnel): move profile map to @repo/funnel
The profile label is now needed by apps/web too. Keeps it from drifting
from the Notion Profile select.
* feat(web): subscribe funnel opt-ins to newsletters
Each ticked checkbox subscribes the submitted email to its Ghost
newsletter and appends a note to the member profile. Fired after the
intake POST resolves, sequentially, and never surfaces as a form error.
* docs(funnel): document newsletter opt-ins
* fix: increase small text to 12px
* fix: use initiative CTA label
* refactor: use Tailwind text-xs utility
* fix: make homepage banner dismissible
* fix: update Discord links and banner text
* fix: refresh Discord invite links
* feat: add @repo/funnel package for shared hear-about constants
Single source of truth for the "How did you first hear about Logos?"
question, options, and id -> label map used by apps/web and apps/civi-crm.
* feat: map hear-about answer to a Notion select on intake
Unknown option ids are dropped so tampering cannot create new select
options; field defs without a CiviCRM fieldName are skipped in
buildAfformValues so the Afform payload is unchanged.
* feat: add required hear-about field to the three funnel forms
Injected below the chat name/service row on coalition-partner,
activist-builder and activist-leader-steward. Web/Notion-only: defs
without a fieldName are filtered out of the CiviCRM fields payload.
* docs: document hear-about field in funnel reference
* refactor: extract resolveHearAboutLabel from Notion builder
Share the option-id -> label resolution so the route can reuse the
builder's exact accept/write predicate.
* fix: enforce required hear-about field server-side
Reject funnel submissions whose hear-about answer is missing or not a
known option id, instead of relying on client-side validation alone.
* chore(web): sentence-case circle titles and resources copy
Applies movement copy review to shared circle content (also rendered on /circles): initiative card titles, resources section heading and description.
* chore(web): update movement page copy
Applies the movement site copy review: hero, intro heading/body, action cards, campaign, find, coalition, and builder sections.
* docs: add page-copy single-source spec and implementation plan
* feat(content): add typed home section schemas and migrate home copy into content
Add 7 bespoke home* section schemas (+ techStack ctas) to the page section
union and populate content/pages/en/home.json with home copy moved verbatim
from messages.
* refactor(web): source home page copy from @repo/content; drop home namespace from messages
Feed every home section from content via findSection/data props (replacing
next-intl getTranslations), remove the home namespace from messages/en.json,
and add a regression guard plus content-based tests. Render output unchanged.
* docs: add get-started/movement page-copy dedup spec and plan
* feat(content): add get-started & movement section schemas and content docs
Move get-started/movement page copy verbatim into content/pages so it is
single-sourced in @repo/content.
* refactor(web): source get-started/movement copy from content; drop those namespaces from messages
Feed both pages from content via findSection/data props (replacing next-intl),
switch them to content-based metadata, delete pages.getStarted/movement from
messages, and add content-based tests + ownership guard. Render unchanged.
* docs: add navbar-pages CMS copy spec and plan
* feat(content): add navbar page section schemas + Pages docs (book, brand-kit, research, node-programme, lambda-prize, manifesto, media, podcast, broadcast, tech-stack explorer)
* refactor(web): source navbar pages copy from content; drop those namespaces from messages
Migrate book, brand-kit, research (incl. rich-text via a tagged-text renderer),
node-programme, lambda-prize, manifesto, media, podcast, logos-broadcast-network,
and the tech-stack explorer to read copy from content via findSection/data props
and content-based metadata; remove the migrated pages.* namespaces from messages;
update guards + contracts. Render output unchanged.
* feat(web): source activist/coalition pages copy from content (CMS title/description)
Migrate activistBuilder, activistLeaderSteward, and coalitionPartner page
metadata from next-intl messages.pages.* into @repo/content PageCopy JSON,
following the established bookCopy pattern.
- Add activistBuilderCopySectionSchema, activistLeaderStewardCopySectionSchema,
coalitionPartnerCopySectionSchema to packages/content/src/schemas/pages.ts
and wire them into the pageSectionSchema discriminated union
- Create content/pages/en/activist-builder.json,
activist-leader-steward.json, coalition-partner.json with verbatim
title/description/heading values from the old messages namespace
- Switch all three route pages from createTranslatedPageMetadata to
createPageMetadata(ROUTE), removing the NAMESPACE constant
- Remove activistBuilder, activistLeaderSteward, coalitionPartner from
apps/web/messages/en.json pages object
- Add schema parse tests, renderToStaticMarkup page tests, messages ownership
guards, and content-route contract entries for all three pages
* chore(web): remove dead faq (pages.faq + unused FaqSection components)
* fix: remove stale faq links
* fix: pass payload env through turbo build
* fix: preserve lambda prize support labels
* fix: preserve live copy after master rebase
* fix: address page copy review feedback
* refactor: move design guide copy to content
* feat: add schemas for home and page copy sections
- Introduced home section schemas including homeSocialProof, homeChoosePath, homeDecide, homeStartBuilding, homeAbout, homeUseCases, and homeBuilderPortal.
- Added page copy section schemas such as getStartedCopy, movementCopy, bookCopy, designGuideCopy, activistBuilderCopy, activistLeaderStewardCopy, coalitionPartnerCopy, researchCopy, nodeProgrammeCopy, lambdaPrizeCopy, mediaCopy, podcastCopy, broadcastCopy, and manifestoCopy.
- Created shared schema for section keys to maintain consistency across sections.
* chore(web): remove dead pages.faq from messages
* fix: resolve page copy migration conflicts
* fix: source connect form page copy from content
* fix: remove duplicated afform page copy exports
* fix: stop generating afform page copy exports
* fix(cms): require only server actions key
* fix(cms): load env files before build validation
* fix(cms): make env loader available during build
* fix(cms): load next env via require
* test(cms): isolate missing env validation
* feat: add homepage testnet highlight
* fix: restore lambda prize support links
* feat: clamp civi-crm notion text properties to 2000 chars
Extract the rich_text slice into a named NOTION_TEXT_MAX_LENGTH
constant and also clamp the Name title, which was previously unbounded.
* feat: limit web funnel form fields to 2000 chars
Add a 2000-char zod max and maxLength cap on text/textarea fields, and
show a live x/2000 character counter under each textarea.
* feat: flag over-limit form fields instead of trimming
Remove the hard maxLength cap so users can type/paste past the 2000-char
limit; turn the field red with an inline counter and message once over,
keeping submission blocked by the existing schema.
* feat: scroll to first invalid field on blocked submit
* refactor(civi-crm): move NOTION_TEXT_MAX_LENGTH to notion constants