Rework the public forms, and answer every submission by email - #263
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 uploadprefixfield position. Purely positional, butverify:typesis a CI gate.Deploy note
The production
book-a-demoform row has already hadcompanyset 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 sendscompany, 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:
That adds
enter_message, switchesphoneto theteltype and setsrequireBusinessEmail. None of it is load-bearing:validateFieldsiterates the form definition, so fields the site sends that the definition lacks are ignored.HubSpot, already applied
website-book-a-demoupdated and published:enter_messageadded,companyno longer required. "Create and associate Companies with Contacts" enabled, without the retroactive backfill.Notable
@cleanstart/formspackage 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.data-lenis-preventto scroll at all.#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.partner_applicationstable could not be explained.Verification
The forms commit was additionally built and tested in a clean worktree to prove it stands alone.