Skip to content

Rework the public forms, and answer every submission by email - #263

Merged
digibrander merged 6 commits into
mainfrom
development
Sep 9, 2026
Merged

digibrander merged 6 commits into
mainfrom
development

Conversation

@jadhavgaurav

@jadhavgaurav jadhavgaurav commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

What

Two strands that must ship together.

Forms (040c8b5e). Phone becomes one control everywhere: a country selector carrying the dial code plus a digits-only input, composed into E.164, with the country preselected from the visitor's IP and reused as the lead's country. The high-intent forms require a company email. Validation moves off native browser bubbles onto inline messages. Book a Demo drops company and country and gains a message field. Adds conversion events to the four lead forms that had none, including Book a Demo, which was untracked entirely.

Email (bbedea65, c00f2dc3, 6a6a2e7a). Every form now answers the person who submitted it, through one shared layout, with Payload's own mail routed through Brevo instead of stdout.

Plus f471399a, a types regeneration: registering the Brevo adapter shifts plugin init order, which moves the upload prefix field position. Purely positional, but verify:types is a CI gate.

Deploy note

The production book-a-demo form row has already had company set to optional, ahead of this merge.

That was necessary because both apps deploy from main, so this merge fires the CMS and Vercel builds at once and Vercel usually wins the race. The new Book a Demo form no longer sends company, and the live row still required it, so every submission would have 400'd in that window. Flipping it early removes the race: the currently deployed form still sends the field (harmless) and the new one does not (also harmless).

Remaining prod step, non-blocking and additive, after the CMS image is live:

docker exec -it cleanstart-cms-1 sh
/app/node_modules/.bin/tsx scripts/apply-form-field-changes.ts --dry-run
/app/node_modules/.bin/tsx scripts/apply-form-field-changes.ts

That adds enter_message, switches phone to the tel type and sets requireBusinessEmail. None of it is load-bearing: validateFields iterates the form definition, so fields the site sends that the definition lacks are ignored.

HubSpot, already applied

website-book-a-demo updated and published: enter_message added, company no longer required. "Create and associate Companies with Contacts" enabled, without the retroactive backfill.

Notable

  • New @cleanstart/forms package holds the rules the browser and API must agree on. The client checks a curated list for instant feedback; the API checks the full 13,797-domain corpus and returns a field-level issue the form renders in place.
  • The HubSpot handler retries once without the fields a 400 names, so an unknown property degrades to "not forwarded" rather than losing the contact.
  • Lenis intercepts wheel events, so the country list and careers select needed data-lenis-prevent to scroll at all.
  • Focus ring uses #3960F9: the site's .cs-btn-* cyan measures 2.2:1 on white form cards and fails WCAG 1.4.11, where the blue clears it at 4.8:1.
  • Turnstile rejections on the partner form are now logged. That branch wrote nothing and logged nothing, which is why an empty partner_applications table could not be explained.

Verification

cms   lint ✓  typecheck ✓  test ✓ (1992)  build ✓
web   lint ✓  typecheck ✓  test ✓ (458)   build ✓
types drift gate ✓

The forms commit was additionally built and tested in a clean worktree to prove it stands alone.

Phone is now one control everywhere: a country selector carrying the dial
code plus a digits-only input, composed into E.164. The country preselects
from the visitor's IP through a new /api/geo route reading Vercel's
x-vercel-ip-country, falling back to the browser locale, and stays editable.
That selection is also where the lead's country now comes from, so Book a
Demo no longer asks for it separately.

Email on the high-intent forms must be a company address. The rule lives in
a new @cleanstart/forms package so the browser and the API cannot disagree:
the client checks a curated list for instant feedback, the API checks the
full 13,797-domain corpus and returns a field-level issue the form renders
in place. Newsletter, gated downloads and job applications deliberately
still accept a personal address.

Book a Demo drops company and country and gains a message field. Company is
derived from the work-email domain by the HubSpot handler when a submission
carries none, so the CRM record keeps one without asking for it.

Validation moved off native browser bubbles onto inline messages under each
field, on a shared field surface with a keyboard-only focus ring. The
country list and the careers select were both unusable with a mouse wheel:
Lenis intercepts wheel events, so nested scroll containers need
data-lenis-prevent.

The HubSpot handler now retries once without the fields a 400 names, so an
unknown property degrades to "that field was not forwarded" instead of
losing the whole contact.

Also adds conversion events to the four lead forms that had none, including
Book a Demo, which was untracked entirely.
Payload had no email adapter configured, so it fell back to writing outgoing
mail to the console. Production has been logging the warning on every boot.
The practical effect is that admin password reset has never worked: an editor
clicking "Forgot password" gets nothing, the reset link goes to the container's
stdout, and recovery needs shell access to the droplet.

Routes Payload's mail through the Brevo credentials the public forms already
use, so there is no second transport and no second secret. Addresses arrive in
any of nodemailer's shapes and are normalised. A failed send is logged loudly
rather than swallowed, because a lost password-reset mail is exactly the
failure that must not be silent.
The four emails the site sent each hand-wrote their own body, so they looked
like four different companies. Callers now describe content as blocks and one
shell owns presentation.

Design is taken from the three templates already live in Brevo rather than
invented: Arial forced with !important, the slate scale plus the brand blue,
the 140px wordmark, an eyebrow label, and a footer inside the card under a
hairline rule. The postal address that footer carries is a CAN-SPAM
requirement and no code-built email had one.

Email HTML is not web HTML, and the shell reflects that. Tables rather than
flex or grid, because Outlook on Windows renders through Word and supports
neither, nor float, nor max-width on divs. Inline styles, because Gmail strips
style blocks in several contexts. Button padding on the cell rather than the
anchor, because Word ignores display:inline-block and an anchor-padded button
collapses there into coloured text. A preheader on every email, because
clients otherwise scrape the first body text, which is the logo alt.

Dark mode is handled where it can be. Outlook.com stamps data-ogsc/data-ogsb
on what it rewrites, so the card, text and button are pinned back to stop the
half-inverted result. Outlook on Windows ignores all of it and inverts
wholesale; that stays legible because the logo now carries its own light
plate. The shipped wordmark is a dark mark on a 73% transparent background,
which disappears entirely on an inverted card, and that affects the live Brevo
templates too.

apps/cms/emails/ is generated from the registry so a design change lands as a
reviewable diff, with emails:check as the drift gate.
Six of the site's forms answered a submission with silence. The lead was
stored and relayed to the CRM, but the person who filled it in received
nothing: demo requests, contact messages, newsletter signups, deal
registrations, and 1,482 job applications. Gated resources were worse than
silent, since the download link was returned only in the HTTP response, so
closing the tab lost the asset and removed the only reason to give a real
address.

Confirmations are ours over Brevo rather than a HubSpot form follow-up. A
follow-up is a marketing send gated on subscription status, so a visitor who
declines marketing consent would get no acknowledgement of a demo request they
just made. These are transactional and must not depend on that. The handler is
keyed by form slug, so a form with no template written for it is skipped and a
new form cannot start emailing visitors by accident.

Also removes the BREVO_TEMPLATE_ID and PARTNER_*_TEMPLATE_ID branches. They won
in production, so careers and partner sent the old dashboard design while the
rest of the site sent the shared layout, and they handed submitter input to
Brevo as raw params. Brevo interpolates merge tags unescaped, so a cover letter
containing markup reached HR's inbox as live HTML. The builders escape every
value. Those three env vars are now dead and can be unset.

Partner Turnstile rejections are logged. That branch writes nothing and sends
nothing, so a real applicant failing the bot check was indistinguishable from
nobody applying, which is why the partner table sat empty with no way to tell
why.
Registering the Brevo adapter in payload.config.ts shifts plugin init order,
which moves where the upload `prefix` field lands in the generated types.
Purely positional, no type surface changed, but `verify:types` is a CI gate
and fails on the drift.

Regenerated with `pnpm generate:types` and confirmed stable across repeated
runs rather than hand-edited.
@jadhavgaurav jadhavgaurav changed the title Rework the public forms around phone, email and company Rework the public forms, and answer every submission by email Sep 9, 2026
@digibrander
digibrander merged commit 995f0a7 into main Sep 9, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants