From 040c8b5e42375b1ea63ec1d9552224d365280222 Mon Sep 17 00:00:00 2001 From: Gaurav Jadhav Date: Wed, 9 Sep 2026 17:14:37 +0530 Subject: [PATCH 1/6] feat(web,cms): rework the public forms around phone, email and company 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. --- CLAUDE.md | 13 + apps/cms/package.json | 1 + apps/cms/scripts/apply-form-field-changes.ts | 163 + ..._120000_add_form_tel_and_business_email.ts | 39 + apps/cms/src/migrations/index.ts | 6 + apps/cms/src/payload-types.ts | 15 +- apps/cms/src/payload/collections/Forms.ts | 13 +- .../collections/__snapshots__/Forms.snap.json | 7 + .../lib/careers/application-schema.test.ts | 18 +- .../payload/lib/careers/application-schema.ts | 6 +- .../lib/deal-registrations/schema.test.ts | 18 +- .../payload/lib/deal-registrations/schema.ts | 6 +- .../cms/src/payload/lib/form-field-schemas.ts | 54 + .../payload/lib/lead-handlers/hubspot.test.ts | 158 +- .../src/payload/lib/lead-handlers/hubspot.ts | 77 +- .../src/payload/lib/lead-handlers/types.ts | 8 +- .../lib/lead-handlers/validate-fields.test.ts | 80 + .../lib/lead-handlers/validate-fields.ts | 38 +- .../lib/partners/partner-schema.test.ts | 25 +- .../payload/lib/partners/partner-schema.ts | 10 +- apps/web/package.json | 2 + apps/web/src/app/api/geo/route.ts | 33 + apps/web/src/app/globals.css | 25 + apps/web/src/components/forms/FieldShell.tsx | 91 + .../web/src/components/forms/FormRenderer.tsx | 84 +- apps/web/src/components/forms/PhoneField.tsx | 374 + apps/web/src/components/forms/TextField.tsx | 104 + .../web/src/components/forms/field-surface.ts | 133 + .../sections/careers/JobApplyForm.tsx | 238 +- .../sections/contact/ContactForm.tsx | 341 +- .../sections/forms/BookDemoForm.tsx | 299 +- .../sections/forms/DealRegistrationForm.tsx | 350 +- .../sections/partners/BecomePartnerCta.tsx | 226 +- apps/web/src/lib/analytics/track.ts | 4 + apps/web/src/lib/forms.ts | 3 + apps/web/src/lib/forms/countries.ts | 80 + apps/web/src/lib/forms/phone-value.test.ts | 148 + apps/web/src/lib/forms/phone-value.ts | 103 + apps/web/src/lib/forms/useDetectedCountry.ts | 92 + apps/web/src/lib/forms/validate.ts | 64 + apps/web/src/lib/leads/submitLead.ts | 18 +- packages/forms/package.json | 22 + packages/forms/scripts/refresh-domains.mjs | 70 + packages/forms/src/business-email.test.ts | 108 + packages/forms/src/business-email.ts | 78 + .../forms/src/common-free-email-domains.ts | 89 + packages/forms/src/free-email-domains.ts | 13817 ++++++++++++++++ packages/forms/src/index.ts | 14 + packages/forms/src/phone.test.ts | 49 + packages/forms/src/phone.ts | 43 + packages/forms/src/server.ts | 24 + packages/forms/tsconfig.json | 10 + packages/forms/vitest.config.ts | 8 + pnpm-lock.yaml | 29 + 54 files changed, 17494 insertions(+), 434 deletions(-) create mode 100755 apps/cms/scripts/apply-form-field-changes.ts create mode 100644 apps/cms/src/migrations/20260909_120000_add_form_tel_and_business_email.ts create mode 100644 apps/cms/src/payload/lib/form-field-schemas.ts create mode 100644 apps/web/src/app/api/geo/route.ts create mode 100644 apps/web/src/components/forms/FieldShell.tsx create mode 100644 apps/web/src/components/forms/PhoneField.tsx create mode 100644 apps/web/src/components/forms/TextField.tsx create mode 100644 apps/web/src/components/forms/field-surface.ts create mode 100644 apps/web/src/lib/forms/countries.ts create mode 100644 apps/web/src/lib/forms/phone-value.test.ts create mode 100644 apps/web/src/lib/forms/phone-value.ts create mode 100644 apps/web/src/lib/forms/useDetectedCountry.ts create mode 100644 apps/web/src/lib/forms/validate.ts create mode 100644 packages/forms/package.json create mode 100644 packages/forms/scripts/refresh-domains.mjs create mode 100644 packages/forms/src/business-email.test.ts create mode 100644 packages/forms/src/business-email.ts create mode 100644 packages/forms/src/common-free-email-domains.ts create mode 100644 packages/forms/src/free-email-domains.ts create mode 100644 packages/forms/src/index.ts create mode 100644 packages/forms/src/phone.test.ts create mode 100644 packages/forms/src/phone.ts create mode 100644 packages/forms/src/server.ts create mode 100644 packages/forms/tsconfig.json create mode 100644 packages/forms/vitest.config.ts diff --git a/CLAUDE.md b/CLAUDE.md index 43c312e0d..92a4030b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,7 @@ cleanstart-website/ monorepo · pnpm workspaces + Turborepo ├── packages/ │ ├── types/ re-exports apps/cms/payload-types │ ├── ui/ @cleanstart/ui primitives + tokens (shared by cms + web) +│ ├── forms/ @cleanstart/forms — business-email + E.164 rules (shared by cms + web) │ └── config/ tsconfig · biome · eslint ├── migrations/webflow-import/ Phase H: ETL scripts ├── infra/ docker-compose · Caddy · backup/restore @@ -65,6 +66,8 @@ cleanstart-website/ monorepo · pnpm workspaces + Turborepo **Page inventory:** `docs/web/WEB-PAGES.md` — canonical list of all pages, slugs, types, build status. Update status when a page is completed. +**`packages/forms`** is framework-agnostic and holds the rules both apps must agree on: `validateBusinessEmail`, the free-mail/disposable domain corpus, and the E.164 helpers. Two entry points: `@cleanstart/forms` is client-safe (curated ~260-domain list), while `@cleanstart/forms/server` adds the full 13,797-domain corpus and **must never be imported from a client component**. Refresh the corpus with `pnpm --filter @cleanstart/forms refresh-domains`. + **`packages/ui`** hosts the shared React primitives (`Drawer`, `Dialog`, `Popover`, `Combobox`, `ConfirmDialog`, `Spinner`, `Tooltip`, `DropdownMenu`, `ContextMenu`, `DateTimePicker`, `Toast`) plus design tokens. Consumed by both `apps/cms` and `apps/web` — no duplication between apps. When touching `apps/web`, preserve the Figma Code Connect setup: do not delete `figma.config.json` or restructure `src/components/` without understanding the connected Figma component mapping (stubs at `src/components/**/*.figma.tsx`). @@ -159,6 +162,16 @@ import { Section, Container } from "@/components/layout"; ``` +### Form fields + +Use the shared field components in `src/components/forms/`, never a per-form copy: + +- `` and `` render on the one field surface (`field-surface.ts`) and put validation messages **inline underneath the field**. Native browser validation bubbles are not used: every public form is `noValidate`. +- `` is the only way to collect a phone number. It composes E.164 from a country selector plus a digits-only input, and the selected country is where the lead's country comes from — do not add a separate country field alongside it. +- The country preselects from `useDetectedCountry()`, which reads Vercel's `x-vercel-ip-country` via `/api/geo` and falls back to the browser locale. It is a hint: never overwrite a country the visitor has already chosen. +- Email validation goes through `emailError()` in `lib/forms/validate.ts`. Pass `requireBusiness: false` only where a personal address is legitimate (newsletter, gated downloads, job applications). +- Client validation is fast feedback, not the gate. The API re-checks every rule and returns `issues[]`; map those back onto fields with `issuesToErrors()` so a server-only rejection still lands under the right input. + ### Component structure - One section per file: `src/components/sections/[page]/SectionName.tsx` diff --git a/apps/cms/package.json b/apps/cms/package.json index 6ae598350..1af6c3c42 100644 --- a/apps/cms/package.json +++ b/apps/cms/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1042.0", + "@cleanstart/forms": "workspace:*", "@cleanstart/schema": "workspace:*", "@cleanstart/types": "workspace:*", "@cleanstart/ui": "workspace:*", diff --git a/apps/cms/scripts/apply-form-field-changes.ts b/apps/cms/scripts/apply-form-field-changes.ts new file mode 100755 index 000000000..9626c208a --- /dev/null +++ b/apps/cms/scripts/apply-form-field-changes.ts @@ -0,0 +1,163 @@ +#!/usr/bin/env -S node --no-warnings --experimental-strip-types +/** + * Apply the 2026-09 forms overhaul to the `forms` field definitions. + * + * Three changes, all of them content rather than schema (the columns and enum + * values ship in migration 20260909_120000_add_form_tel_and_business_email): + * + * 1. Every `phone` field becomes `type: 'tel'`, so the web app renders it + * with a country-code selector and the API validates it as E.164. + * 2. The email field on the high-intent forms gets `requireBusinessEmail`, + * rejecting consumer webmail and disposable mailboxes. Newsletter and + * resource-capture are deliberately left off: a personal address is a + * legitimate signup there. + * 3. Book a Demo drops `company` and `country`. Company is derived from the + * email domain by the company-from-domain handler; country comes from the + * dial code chosen in the phone field. + * + * Goes through payload.update rather than SQL so the collection hooks run and + * `schemaVersion` bumps exactly as an editor's save would. Idempotent: a + * second run reports no changes. + * + * Run from apps/cms with the env file loaded: + * pnpm exec tsx --env-file=.env scripts/apply-form-field-changes.ts --dry-run + * pnpm exec tsx --env-file=.env scripts/apply-form-field-changes.ts + * + * PROD note: bumping `schemaVersion` invalidates in-flight submissions from a + * page a visitor already had open, which the endpoint answers with a + * stale-schema error and the form retries. Run in a quiet window. + */ +import { getPayload } from 'payload'; + +import payloadConfig from '../src/payload.config.ts'; + +/** + * Forms whose email field must be a company address. Newsletter and + * resource-capture are absent on purpose. + * + * Deal registration and career applications are not `forms` rows: they post to + * their own endpoints, and their rules live in `lib/form-field-schemas.ts`. + */ +const BUSINESS_EMAIL_FORMS = new Set(['book-a-demo', 'contact']); + +/** Fields to delete, per form slug. */ +const REMOVED_FIELDS: Readonly> = { + 'book-a-demo': ['company', 'country'], +}; + +/** + * Fields to append when missing, per form slug. Matched by `name`, so a field + * an editor has since renamed or re-typed is left alone. + * + * `enter_message` is the same HubSpot property the contact form already + * submits. If the HubSpot "Book a Demo" form does not define it, the Forms API + * rejects the whole submission — the handler drops the field and retries so the + * contact still syncs, and records `dropped-unknown-fields` on the lead. Adding + * the field to that form in HubSpot is what makes the message reach the CRM. + */ +const ADDED_FIELDS: Readonly> = { + 'book-a-demo': [ + { + name: 'enter_message', + type: 'textarea', + label: 'How can we help?', + required: false, + placeholder: + 'We run around 300 containers on EKS and want to cut CVE remediation time before our next audit.', + }, + ], +}; + +type FormField = { + name?: string | null; + type?: string | null; + label?: string | null; + required?: boolean | null; + placeholder?: string | null; + requireBusinessEmail?: boolean | null; + [key: string]: unknown; +}; + +const isPhoneField = (field: FormField): boolean => + typeof field.name === 'string' && /^(phone|.*_phone|.*Phone)$/u.test(field.name); + +const run = async (): Promise => { + const dryRun = process.argv.includes('--dry-run'); + const payload = await getPayload({ config: payloadConfig }); + + const forms = await payload.find({ + collection: 'forms', + limit: 200, + depth: 0, + overrideAccess: true, + }); + + let changed = 0; + + for (const form of forms.docs) { + const slug = form.slug as string | null; + if (!slug) continue; + + const fields = (form.fields ?? []) as FormField[]; + const removed = REMOVED_FIELDS[slug] ?? []; + const next: FormField[] = []; + const notes: string[] = []; + + for (const field of fields) { + if (typeof field.name === 'string' && removed.includes(field.name)) { + notes.push(`- removed ${field.name}`); + continue; + } + + const updated: FormField = { ...field }; + + if (isPhoneField(updated) && updated.type !== 'tel') { + notes.push(`- ${String(updated.name)}: ${String(updated.type)} -> tel`); + updated.type = 'tel'; + } + + if (updated.type === 'email') { + const wanted = BUSINESS_EMAIL_FORMS.has(slug); + if ((updated.requireBusinessEmail ?? false) !== wanted) { + notes.push(`- ${String(updated.name)}: requireBusinessEmail -> ${wanted}`); + updated.requireBusinessEmail = wanted; + } + } + + next.push(updated); + } + + for (const addition of ADDED_FIELDS[slug] ?? []) { + if (next.some((field) => field.name === addition.name)) continue; + notes.push(`- added ${String(addition.name)} (${String(addition.type)})`); + next.push({ ...addition }); + } + + if (notes.length === 0) { + console.log(`${slug}: no change`); + continue; + } + + changed += 1; + console.log(`${slug}:`); + for (const note of notes) console.log(` ${note}`); + + if (dryRun) continue; + + await payload.update({ + collection: 'forms', + id: form.id, + data: { fields: next }, + overrideAccess: true, + }); + } + + console.log( + dryRun + ? `\nDry run. ${changed} form(s) would change.` + : `\nUpdated ${changed} form(s).`, + ); + process.exit(0); +}; + +void run(); diff --git a/apps/cms/src/migrations/20260909_120000_add_form_tel_and_business_email.ts b/apps/cms/src/migrations/20260909_120000_add_form_tel_and_business_email.ts new file mode 100644 index 000000000..85eb8a79c --- /dev/null +++ b/apps/cms/src/migrations/20260909_120000_add_form_tel_and_business_email.ts @@ -0,0 +1,39 @@ +import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-postgres' + +/** + * Schema support for the forms overhaul: + * - a `tel` field type, so a form can declare a phone field that the web app + * renders with a country-code selector and validates as E.164; + * - `require_business_email`, which gates an email field against consumer + * webmail and disposable mailboxes. + * + * Both the live table and the versions table are altered — `forms` is a + * versioned collection with drafts. + * + * Which forms actually turn these on is content, not schema. That is applied + * through the Payload local API by + * `apps/cms/src/scripts/apply-form-field-changes.ts`, so the change flows + * through the collection's hooks and bumps `schemaVersion` the same way an + * editor's save would. + */ +export async function up({ db }: MigrateUpArgs): Promise { + await db.execute(sql`ALTER TYPE "public"."enum_forms_fields_type" ADD VALUE IF NOT EXISTS 'tel';`) + await db.execute( + sql`ALTER TYPE "public"."enum__forms_v_version_fields_type" ADD VALUE IF NOT EXISTS 'tel';`, + ) + await db.execute( + sql`ALTER TABLE "forms_fields" ADD COLUMN IF NOT EXISTS "require_business_email" boolean DEFAULT false;`, + ) + await db.execute( + sql`ALTER TABLE "_forms_v_version_fields" ADD COLUMN IF NOT EXISTS "require_business_email" boolean DEFAULT false;`, + ) +} + +export async function down({ db }: MigrateDownArgs): Promise { + await db.execute(sql`ALTER TABLE "forms_fields" DROP COLUMN IF EXISTS "require_business_email";`) + await db.execute( + sql`ALTER TABLE "_forms_v_version_fields" DROP COLUMN IF EXISTS "require_business_email";`, + ) + // Postgres cannot remove a value from an enum type. 'tel' is left in place, + // which is harmless once no row uses it. +} diff --git a/apps/cms/src/migrations/index.ts b/apps/cms/src/migrations/index.ts index 0b97af316..2e8a68476 100644 --- a/apps/cms/src/migrations/index.ts +++ b/apps/cms/src/migrations/index.ts @@ -55,6 +55,7 @@ import * as migration_20260728_120000_add_email_signatures from './20260728_1200 import * as migration_20260728_180000_add_email_signature_groups from './20260728_180000_add_email_signature_groups'; import * as migration_20260731_120000_add_legal_role from './20260731_120000_add_legal_role'; import * as migration_20260821_120000_faq_answer_richtext from './20260821_120000_faq_answer_richtext'; +import * as migration_20260909_120000_add_form_tel_and_business_email from './20260909_120000_add_form_tel_and_business_email'; export const migrations = [ { @@ -317,4 +318,9 @@ export const migrations = [ down: migration_20260821_120000_faq_answer_richtext.down, name: '20260821_120000_faq_answer_richtext', }, + { + up: migration_20260909_120000_add_form_tel_and_business_email.up, + down: migration_20260909_120000_add_form_tel_and_business_email.down, + name: '20260909_120000_add_form_tel_and_business_email', + }, ]; diff --git a/apps/cms/src/payload-types.ts b/apps/cms/src/payload-types.ts index 32dddafb6..946e5a488 100644 --- a/apps/cms/src/payload-types.ts +++ b/apps/cms/src/payload-types.ts @@ -682,7 +682,6 @@ export interface Media { * Photographer / source attribution. */ credit?: string | null; - prefix?: string | null; /** * Smart-crop focal point as percentages (0–100). Drives OG-image and 1:1 thumbnail crops. */ @@ -690,6 +689,7 @@ export interface Media { x?: number | null; y?: number | null; }; + prefix?: string | null; updatedAt: string; createdAt: string; url?: string | null; @@ -3872,7 +3872,7 @@ export interface Form { * Machine name. Becomes the JSON key on the lead record. */ name: string; - type: 'text' | 'email' | 'textarea' | 'select' | 'checkbox' | 'consent'; + type: 'text' | 'email' | 'tel' | 'textarea' | 'select' | 'checkbox' | 'consent'; /** * Visitor-facing label. */ @@ -3881,6 +3881,10 @@ export interface Form { * Consent fields are always required and cannot be unchecked here. */ required?: boolean | null; + /** + * Reject consumer webmail and disposable mailboxes (gmail, outlook, yahoo, mailinator and ~13,800 more). Leave off for newsletter and gated-download forms, where a personal address is a legitimate signup. + */ + requireBusinessEmail?: boolean | null; placeholder?: string | null; helpText?: string | null; defaultValue?: string | null; @@ -5945,6 +5949,7 @@ export interface CareerApplication { */ export interface Resume { id: number; + prefix?: string | null; updatedAt: string; createdAt: string; url?: string | null; @@ -8227,6 +8232,7 @@ export interface EmailAsset { * Where this asset is used, e.g. "Signature logo (rendered at 140px wide)". */ usage?: string | null; + prefix?: string | null; updatedAt: string; createdAt: string; url?: string | null; @@ -10764,13 +10770,13 @@ export interface MediaSelect { alt?: T; caption?: T; credit?: T; - prefix?: T; focalPoint?: | T | { x?: T; y?: T; }; + prefix?: T; updatedAt?: T; createdAt?: T; url?: T; @@ -12449,6 +12455,7 @@ export interface FormsSelect { type?: T; label?: T; required?: T; + requireBusinessEmail?: T; placeholder?: T; helpText?: T; defaultValue?: T; @@ -12630,6 +12637,7 @@ export interface SignatureTemplatesSelect { export interface EmailAssetsSelect { alt?: T; usage?: T; + prefix?: T; updatedAt?: T; createdAt?: T; url?: T; @@ -12737,6 +12745,7 @@ export interface CareerApplicationsSelect { * via the `definition` "resumes_select". */ export interface ResumesSelect { + prefix?: T; updatedAt?: T; createdAt?: T; url?: T; diff --git a/apps/cms/src/payload/collections/Forms.ts b/apps/cms/src/payload/collections/Forms.ts index 92e7e6a43..020d2b871 100644 --- a/apps/cms/src/payload/collections/Forms.ts +++ b/apps/cms/src/payload/collections/Forms.ts @@ -6,7 +6,7 @@ import { formSchemaVersionHook } from '../hooks/form-schema-version'; import { formsCoerceHook } from '../hooks/forms-coerce'; import { normalizeOptionalUrlHook, validateOptionalUrl } from '../lib/url-shape'; -const VISIBLE_LABEL_TYPES = ['text', 'email', 'textarea', 'select', 'checkbox', 'consent']; +const VISIBLE_LABEL_TYPES = ['text', 'email', 'tel', 'textarea', 'select', 'checkbox', 'consent']; const PLACEHOLDER_TYPES = ['text', 'email', 'textarea']; const VALIDATION_TYPES = ['text', 'email', 'textarea']; @@ -62,6 +62,7 @@ export const Forms: CollectionConfig = { options: [ { label: 'Text', value: 'text' }, { label: 'Email', value: 'email' }, + { label: 'Phone', value: 'tel' }, { label: 'Textarea', value: 'textarea' }, { label: 'Select', value: 'select' }, { label: 'Checkbox', value: 'checkbox' }, @@ -86,6 +87,16 @@ export const Forms: CollectionConfig = { 'Consent fields are always required and cannot be unchecked here.', }, }, + { + name: 'requireBusinessEmail', + type: 'checkbox', + defaultValue: false, + admin: { + description: + 'Reject consumer webmail and disposable mailboxes (gmail, outlook, yahoo, mailinator and ~13,800 more). Leave off for newsletter and gated-download forms, where a personal address is a legitimate signup.', + condition: (_data, sibling) => sibling?.type === 'email', + }, + }, { name: 'placeholder', type: 'text', diff --git a/apps/cms/src/payload/collections/__snapshots__/Forms.snap.json b/apps/cms/src/payload/collections/__snapshots__/Forms.snap.json index aca863ded..8640cc7e9 100644 --- a/apps/cms/src/payload/collections/__snapshots__/Forms.snap.json +++ b/apps/cms/src/payload/collections/__snapshots__/Forms.snap.json @@ -41,6 +41,9 @@ { "value": "email" }, + { + "value": "tel" + }, { "value": "textarea" }, @@ -63,6 +66,10 @@ "type": "checkbox", "name": "required" }, + { + "type": "checkbox", + "name": "requireBusinessEmail" + }, { "type": "text", "name": "placeholder" diff --git a/apps/cms/src/payload/lib/careers/application-schema.test.ts b/apps/cms/src/payload/lib/careers/application-schema.test.ts index a56443dae..94cacbca2 100644 --- a/apps/cms/src/payload/lib/careers/application-schema.test.ts +++ b/apps/cms/src/payload/lib/careers/application-schema.test.ts @@ -7,7 +7,7 @@ const valid = { firstName: 'Ada', lastName: 'Lovelace', email: 'ada@example.com', - phone: '+1 555 0100', + phone: '+14155552671', coverLetter: 'hi', linkedinUrl: 'https://linkedin.com/in/ada', }; @@ -27,3 +27,19 @@ describe('applicationFieldsSchema', () => { ).toBe(true); }); }); + +describe('applicationFieldsSchema — personal email allowed, E.164 phone required', () => { + it('accepts a free-mail address, because applicants rarely apply from a work inbox', () => { + expect(applicationFieldsSchema.safeParse({ ...valid, email: 'ada@gmail.com' }).success).toBe( + true, + ); + }); + + it('still rejects a malformed address', () => { + expect(applicationFieldsSchema.safeParse({ ...valid, email: 'ada@' }).success).toBe(false); + }); + + it('rejects a phone that is not E.164', () => { + expect(applicationFieldsSchema.safeParse({ ...valid, phone: '555 0100' }).success).toBe(false); + }); +}); diff --git a/apps/cms/src/payload/lib/careers/application-schema.ts b/apps/cms/src/payload/lib/careers/application-schema.ts index 76a83edd4..6ac7cc20a 100644 --- a/apps/cms/src/payload/lib/careers/application-schema.ts +++ b/apps/cms/src/payload/lib/careers/application-schema.ts @@ -1,13 +1,15 @@ import { z } from 'zod'; +import { emailField, optionalPhoneField } from '../form-field-schemas'; + const SLUG = /^[a-z0-9-]+$/; export const applicationFieldsSchema = z.object({ jobSlug: z.string().min(1).max(200).regex(SLUG), firstName: z.string().min(1).max(120), lastName: z.string().min(1).max(120), - email: z.string().email().max(254), - phone: z.string().max(40).optional(), + email: emailField({ requireBusiness: false }), + phone: optionalPhoneField(), location: z.string().max(160).optional(), howDidYouHear: z.string().max(120).optional(), coverLetter: z.string().max(5000).optional(), diff --git a/apps/cms/src/payload/lib/deal-registrations/schema.test.ts b/apps/cms/src/payload/lib/deal-registrations/schema.test.ts index 9099e7db5..cb48fd682 100644 --- a/apps/cms/src/payload/lib/deal-registrations/schema.test.ts +++ b/apps/cms/src/payload/lib/deal-registrations/schema.test.ts @@ -3,8 +3,8 @@ import { dealRegistrationSchema } from './schema'; const valid = { partnerName: 'Acme Partners', - partnerRep: { firstName: 'Jane', lastName: 'Doe', email: 'jane@acme.com', phone: '+1 555 0100' }, - prospect: { firstName: 'Sam', lastName: 'Lee', email: 'sam@prospect.com', phone: '555' }, + partnerRep: { firstName: 'Jane', lastName: 'Doe', email: 'jane@acme.com', phone: '+14155552671' }, + prospect: { firstName: 'Sam', lastName: 'Lee', email: 'sam@prospect.com', phone: '+442071838750' }, dealDetails: 'Wants hardened images for K8s.', source: 'https://www.cleanstart.com/deal-registration', consent: { snapshot: 'I agree…', givenAt: '2026-06-23T00:00:00.000Z', categories: ['storage'] }, @@ -33,3 +33,17 @@ describe('dealRegistrationSchema', () => { expect(dealRegistrationSchema.safeParse(minimal).success).toBe(true); }); }); + +describe('dealRegistrationSchema — company email and E.164 phone', () => { + it('rejects a free-mail address on either person', () => { + const badRep = { ...valid, partnerRep: { ...valid.partnerRep, email: 'jane@gmail.com' } }; + expect(dealRegistrationSchema.safeParse(badRep).success).toBe(false); + const badProspect = { ...valid, prospect: { ...valid.prospect, email: 'sam@yahoo.com' } }; + expect(dealRegistrationSchema.safeParse(badProspect).success).toBe(false); + }); + + it('rejects a phone that is not E.164', () => { + const bad = { ...valid, prospect: { ...valid.prospect, phone: '555' } }; + expect(dealRegistrationSchema.safeParse(bad).success).toBe(false); + }); +}); diff --git a/apps/cms/src/payload/lib/deal-registrations/schema.ts b/apps/cms/src/payload/lib/deal-registrations/schema.ts index 45fa653ec..b8eb9fdd3 100644 --- a/apps/cms/src/payload/lib/deal-registrations/schema.ts +++ b/apps/cms/src/payload/lib/deal-registrations/schema.ts @@ -1,10 +1,12 @@ import { z } from 'zod'; +import { emailField, optionalPhoneField } from '../form-field-schemas'; + const person = z.object({ firstName: z.string().min(1).max(120), lastName: z.string().min(1).max(120), - email: z.string().email().max(254), - phone: z.string().max(40).optional(), + email: emailField({ requireBusiness: true }), + phone: optionalPhoneField(), }); export const dealRegistrationSchema = z.object({ diff --git a/apps/cms/src/payload/lib/form-field-schemas.ts b/apps/cms/src/payload/lib/form-field-schemas.ts new file mode 100644 index 000000000..b95f30e97 --- /dev/null +++ b/apps/cms/src/payload/lib/form-field-schemas.ts @@ -0,0 +1,54 @@ +import { FREE_EMAIL_DOMAINS, isE164, validateBusinessEmail } from '@cleanstart/forms/server'; +import { z } from 'zod'; + +/** + * Shared Zod field shapes for the public form endpoints that own their own + * schema (partner applications, deal registrations, career applications). + * + * The `/api/leads/submit` endpoint does not use these: its rules come from the + * `forms` collection field definitions and are applied by `validate-fields.ts`. + * Both paths call the same `@cleanstart/forms` validators underneath, so a rule + * cannot drift between them. + */ + +/** + * @param requireBusiness false on the career-application form, where an + * applicant's personal address is the norm and demanding their + * employer's would exclude most candidates. + */ +export const emailField = ({ requireBusiness }: { requireBusiness: boolean }) => + z + .string() + .max(254) + .superRefine((value, ctx) => { + const result = validateBusinessEmail(value, { + freeDomains: FREE_EMAIL_DOMAINS, + requireBusiness, + }); + if (!result.ok) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: result.message }); + } + }); + +/** + * A phone number in E.164, as the browser's phone field composes it from the + * selected country's dial code plus the digits typed. + * + * The 40-character ceiling stays for defence in depth even though E.164 caps + * at 16 characters, so a hostile payload is rejected on length before the + * regex runs. + */ +export const optionalPhoneField = () => + z + .string() + .max(40) + .optional() + .superRefine((value, ctx) => { + if (value === undefined || value.length === 0) return; + if (!isE164(value)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Phone number must include its country code, for example +14155552671.', + }); + } + }); diff --git a/apps/cms/src/payload/lib/lead-handlers/hubspot.test.ts b/apps/cms/src/payload/lib/lead-handlers/hubspot.test.ts index 5c46b4f78..e5517786f 100644 --- a/apps/cms/src/payload/lib/lead-handlers/hubspot.test.ts +++ b/apps/cms/src/payload/lib/lead-handlers/hubspot.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { attributionHubspotFields, hubspotHandler } from './hubspot'; +import { attributionHubspotFields, hubspotHandler, invalidHubspotFieldNames } from './hubspot'; import type { LeadSubmission } from './types'; const submission: LeadSubmission = { @@ -126,3 +126,159 @@ describe('attributionHubspotFields', () => { expect(attributionHubspotFields(submission)).toEqual([]); }); }); + +describe('invalidHubspotFieldNames', () => { + it('pulls the field name out of the Forms API error text', () => { + expect( + invalidHubspotFieldNames( + `{"status":"error","message":"Error in 'fields.enter_message'","errors":[{"message":"Error in 'fields.enter_message'","errorType":"INVALID_METADATA"}]}`, + ), + ).toEqual(['enter_message']); + }); + + it('collects every named field once', () => { + expect( + invalidHubspotFieldNames("Error in 'fields.utm_source'. Error in 'fields.gclid'."), + ).toEqual(['utm_source', 'gclid']); + }); + + it('returns nothing for an error that names no field', () => { + expect(invalidHubspotFieldNames('{"status":"error","message":"Internal error"}')).toEqual([]); + }); +}); + +describe('hubspotHandler — unknown field recovery', () => { + it('retries without the rejected field so the contact still syncs', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(`{"message":"Error in 'fields.enter_message'"}`, { status: 400 }), + ) + .mockResolvedValueOnce(new Response('{}', { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + const result = await hubspotHandler.run( + { ...submission, fields: { ...submission.fields, enter_message: 'Need a demo next week' } }, + ctx('3a491549-929f-41df-8446-32702d793780'), + ); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const retried = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)) as { + fields: { name: string }[]; + }; + expect(retried.fields.map((f) => f.name)).toEqual(['email', 'firstname', 'company']); + expect(result).toMatchObject({ + status: 'synced', + reason: 'dropped-unknown-fields: enter_message', + }); + }); + + it('does not retry when the 400 names no field', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response('{"message":"Internal error"}', { status: 400 })); + vi.stubGlobal('fetch', fetchMock); + + const result = await hubspotHandler.run(submission, ctx('guid-1')); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ status: 'failed' }); + }); + + it('does not retry when every field was rejected, since there is nothing left to send', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response( + `{"message":"Error in 'fields.email'. Error in 'fields.firstname'. Error in 'fields.company'."}`, + { status: 400 }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await hubspotHandler.run(submission, ctx('guid-1')); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ status: 'failed' }); + }); + + it('reports failed when the retry also fails', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(`{"message":"Error in 'fields.enter_message'"}`, { status: 400 }), + ) + .mockResolvedValueOnce(new Response('{"message":"nope"}', { status: 400 })); + vi.stubGlobal('fetch', fetchMock); + + const result = await hubspotHandler.run( + { ...submission, fields: { ...submission.fields, enter_message: 'hi' } }, + ctx('guid-1'), + ); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result).toMatchObject({ status: 'failed' }); + }); +}); + +describe('hubspotHandler — company fallback', () => { + const sentFields = (mock: ReturnType): Record => { + const body = JSON.parse(String(mock.mock.calls[0]?.[1]?.body)) as { + fields: { name: string; value: string }[]; + }; + return Object.fromEntries(body.fields.map((f) => [f.name, f.value])); + }; + + it('derives company from the work-email domain when the form did not ask', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + await hubspotHandler.run( + { ...submission, fields: { email: 'pat@cleanstart.com', firstname: 'Pat' } }, + ctx('guid-1'), + ); + + expect(sentFields(fetchMock).company).toBe('Cleanstart'); + }); + + it('never overwrites a company the visitor actually typed', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + await hubspotHandler.run( + { ...submission, fields: { email: 'pat@cleanstart.com', company: 'CleanStart Inc.' } }, + ctx('guid-1'), + ); + + expect(sentFields(fetchMock).company).toBe('CleanStart Inc.'); + }); + + it('sends no company for a free-mail address rather than inventing one', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + await hubspotHandler.run( + { ...submission, fields: { email: 'pat@gmail.com', firstname: 'Pat' } }, + ctx('guid-1'), + ); + + expect(sentFields(fetchMock)).not.toHaveProperty('company'); + }); + + it('passes the country the phone selector resolved straight through', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + await hubspotHandler.run( + { + ...submission, + fields: { email: 'pat@cleanstart.com', phone: '+919876543210', country: 'India' }, + }, + ctx('guid-1'), + ); + + const sent = sentFields(fetchMock); + expect(sent.country).toBe('India'); + expect(sent.phone).toBe('+919876543210'); + }); +}); diff --git a/apps/cms/src/payload/lib/lead-handlers/hubspot.ts b/apps/cms/src/payload/lib/lead-handlers/hubspot.ts index 3e2b84d15..41fe81b73 100644 --- a/apps/cms/src/payload/lib/lead-handlers/hubspot.ts +++ b/apps/cms/src/payload/lib/lead-handlers/hubspot.ts @@ -2,8 +2,28 @@ import { Client } from '@hubspot/api-client'; import type { BasePayload } from 'payload'; import { resolveHubspotCredentials, type HubspotCredentials } from '../integrations/credentials'; +import { companyFromEmailDomain } from './enrichment'; +import { extractEmail } from './extract-fields'; import type { LeadHandler, LeadHandlerResult, LeadSubmission } from './types'; +/** + * HubSpot answers a submission carrying a field the form does not define with a + * 400 that names the offender as `fields.`, and rejects the *whole* + * submission rather than the one field. Pull those names back out so the + * submission can be retried without them. + * + * Matching is on the error text because the Forms API expresses this failure in + * `message` on some shapes and inside `errors[].message` on others. + */ +export const invalidHubspotFieldNames = (detail: string): string[] => { + const found = new Set(); + for (const match of detail.matchAll(/fields\.([A-Za-z0-9_]+)/gu)) { + const name = match[1]; + if (name) found.add(name); + } + return [...found]; +}; + /** * Build the extra HubSpot form fields carrying last-touch UTMs + ad click IDs. * Returns [] unless HUBSPOT_FORWARD_ATTRIBUTION=true, because unknown field @@ -161,6 +181,19 @@ export const hubspotHandler: LeadHandler = { if (!fields.some((f) => f.name === extra.name)) fields.push(extra); } + // Book a Demo dropped its company question: asking for something derivable + // from the work email is friction. Fill it from the email domain so the CRM + // record still carries a company, but only when the submission has none, so + // a form that does ask (Contact, Partner, Deal Registration) always wins. + // + // HubSpot does not do this itself on a Forms API submission unless the + // portal has the paid enrichment add-on; where it does, its own data + // overwrites this afterwards. + if (!fields.some((f) => f.name === 'company')) { + const derived = companyFromEmailDomain(extractEmail(ctx.formFieldDefs, submission.fields)); + if (derived) fields.push({ name: 'company', value: derived.company }); + } + const body: Record = { fields, context: { pageUri: submission.source ?? '' }, @@ -178,16 +211,35 @@ export const hubspotHandler: LeadHandler = { body.legalConsentOptions = { consent }; } + const post = async (payload: Record): Promise => + fetch(`https://api.hsforms.com/submissions/v3/integration/submit/${portalId}/${guid}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(10_000), + }); + try { - const resp = await fetch( - `https://api.hsforms.com/submissions/v3/integration/submit/${portalId}/${guid}`, - { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - signal: AbortSignal.timeout(10_000), - }, - ); + let resp = await post(body); + let droppedFields: string[] = []; + + // A field the form does not define fails the entire submission, which + // would drop the contact over one optional answer — and the operator + // only finds out from the handler log. Retry once without the offending + // fields so the identity fields still reach the CRM, and report which + // were dropped so the form can be fixed in HubSpot. + if (resp.status === 400) { + const detail = await resp.clone().text().catch(() => ''); + const invalid = invalidHubspotFieldNames(detail); + const retained = fields.filter((f) => !invalid.includes(f.name)); + if (invalid.length > 0 && retained.length > 0 && retained.length < fields.length) { + droppedFields = fields + .filter((f) => invalid.includes(f.name)) + .map((f) => f.name); + resp = await post({ ...body, fields: retained }); + } + } + if (!resp.ok) { const detail = await resp.text().catch(() => ''); return { @@ -196,6 +248,13 @@ export const hubspotHandler: LeadHandler = { error: `HubSpot ${resp.status}: ${detail.slice(0, 200)}`, }; } + if (droppedFields.length > 0) { + return { + handler: 'hubspot', + status: 'synced', + reason: `dropped-unknown-fields: ${droppedFields.join(', ')}`, + }; + } return { handler: 'hubspot', status: 'synced' }; } catch (err) { return { diff --git a/apps/cms/src/payload/lib/lead-handlers/types.ts b/apps/cms/src/payload/lib/lead-handlers/types.ts index 5573ca45d..b207fd778 100644 --- a/apps/cms/src/payload/lib/lead-handlers/types.ts +++ b/apps/cms/src/payload/lib/lead-handlers/types.ts @@ -62,7 +62,13 @@ export type LeadHandlerContext = { }; export type LeadHandlerResult = - | { handler: string; status: 'synced'; externalId?: string | undefined } + | { + handler: string; + status: 'synced'; + externalId?: string | undefined; + /** Set when the sync succeeded but something was degraded, e.g. a field the remote form rejected. */ + reason?: string | undefined; + } | { handler: string; status: 'failed'; error: string } | { handler: string; status: 'skipped'; reason: string }; diff --git a/apps/cms/src/payload/lib/lead-handlers/validate-fields.test.ts b/apps/cms/src/payload/lib/lead-handlers/validate-fields.test.ts index a5afb0521..61913358e 100644 --- a/apps/cms/src/payload/lib/lead-handlers/validate-fields.test.ts +++ b/apps/cms/src/payload/lib/lead-handlers/validate-fields.test.ts @@ -132,3 +132,83 @@ describe('validateFields', () => { if (!result.ok) expect(result.issues).toHaveLength(2); }); }); + +const tel = (overrides: Partial = {}): FormFieldDef => ({ + name: 'phone', + type: 'tel', + label: 'Phone', + required: true, + ...overrides, +}); + +describe('validateFields — business email', () => { + it('accepts a free-mail address when the field does not require a business one', () => { + const result = validateFields([email()], { email: 'jane@gmail.com' }); + expect(result.ok).toBe(true); + }); + + it('rejects free-mail when requireBusinessEmail is set', () => { + const result = validateFields([email({ requireBusinessEmail: true })], { + email: 'jane@gmail.com', + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.issues).toEqual([ + { fieldName: 'email', message: 'Please use your company email address.' }, + ]); + } + }); + + it('catches a long-tail free-mail domain the browser list does not carry', () => { + const result = validateFields([email({ requireBusinessEmail: true })], { + email: 'jane@emailfake.com', + }); + expect(result.ok).toBe(false); + }); + + it('accepts a corporate address on a business-only field', () => { + const result = validateFields([email({ requireBusinessEmail: true })], { + email: 'jane@cleanstart.com', + }); + expect(result.ok).toBe(true); + }); + + it('still rejects a malformed address either way', () => { + for (const requireBusinessEmail of [true, false]) { + const result = validateFields([email({ requireBusinessEmail })], { email: 'not-an-email' }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.issues[0]?.message).toBe('Enter a valid email address.'); + } + } + }); +}); + +describe('validateFields — tel', () => { + it('accepts E.164', () => { + expect(validateFields([tel()], { phone: '+14155552671' }).ok).toBe(true); + expect(validateFields([tel()], { phone: '+919876543210' }).ok).toBe(true); + }); + + it.each(['4155552671', '+1 415 555 2671', '(415) 555-2671', '+1415'])( + 'rejects %j, which is not E.164', + (phone) => { + const result = validateFields([tel()], { phone }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.issues[0]?.message).toBe( + 'Phone must be a phone number including its country code.', + ); + } + }, + ); + + it('rejects a missing required phone', () => { + expect(validateFields([tel()], {}).ok).toBe(false); + }); + + it('allows an optional phone to be absent or empty', () => { + expect(validateFields([tel({ required: false })], {}).ok).toBe(true); + expect(validateFields([tel({ required: false })], { phone: '' }).ok).toBe(true); + }); +}); diff --git a/apps/cms/src/payload/lib/lead-handlers/validate-fields.ts b/apps/cms/src/payload/lib/lead-handlers/validate-fields.ts index 403b671af..3c77f617b 100644 --- a/apps/cms/src/payload/lib/lead-handlers/validate-fields.ts +++ b/apps/cms/src/payload/lib/lead-handlers/validate-fields.ts @@ -11,9 +11,9 @@ * can map them back onto fields. */ -import { compileSafe } from '../safe-regex'; +import { FREE_EMAIL_DOMAINS, isE164, validateBusinessEmail } from '@cleanstart/forms/server'; -const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +import { compileSafe } from '../safe-regex'; export type FormFieldDef = { name?: string | null; @@ -26,6 +26,12 @@ export type FormFieldDef = { maxLength?: number | null; pattern?: string | null; } | null; + /** + * Set on an `email` field to reject consumer webmail and disposable + * mailboxes. Off by default: the newsletter and gated-download forms + * deliberately accept a personal address. + */ + requireBusinessEmail?: boolean | null; }; export type FieldIssue = { @@ -97,10 +103,36 @@ export const validateFields = ( if (value === undefined || value === null) continue; if (def.type === 'email') { - if (typeof value !== 'string' || !EMAIL_RE.test(value)) { + if (typeof value !== 'string') { issues.push({ fieldName: def.name, message: `${label} must be a valid email address.` }); continue; } + // The authoritative company-email gate. The browser checks a curated + // subset for instant feedback; this checks the full corpus, so a + // long-tail free-mail domain is caught here and surfaced back on the + // field via the `issues` array. + const email = validateBusinessEmail(value, { + freeDomains: FREE_EMAIL_DOMAINS, + requireBusiness: def.requireBusinessEmail === true, + }); + if (!email.ok) { + issues.push({ fieldName: def.name, message: email.message }); + continue; + } + } + + if (def.type === 'tel') { + // An optional phone left blank arrives as '' rather than being omitted. + if (value === '') continue; + // The phone field composes E.164 from the selected country's dial code + // plus the digits typed, so anything else reaching here was hand-crafted. + if (typeof value !== 'string' || !isE164(value)) { + issues.push({ + fieldName: def.name, + message: `${label} must be a phone number including its country code.`, + }); + continue; + } } if (def.type === 'select') { diff --git a/apps/cms/src/payload/lib/partners/partner-schema.test.ts b/apps/cms/src/payload/lib/partners/partner-schema.test.ts index 1589b3f85..5659bef7d 100644 --- a/apps/cms/src/payload/lib/partners/partner-schema.test.ts +++ b/apps/cms/src/payload/lib/partners/partner-schema.test.ts @@ -6,7 +6,7 @@ const valid = { firstName: 'Ada', lastName: 'Lovelace', email: 'ada@acme.com', - phone: '+1 555 0100', + phone: '+14155552671', company: 'Acme', website: 'https://acme.com', partnerReason: 'We want to integrate.', @@ -27,3 +27,26 @@ describe('partnerSubmissionSchema', () => { ).toBe(true); }); }); + +describe('partnerSubmissionSchema — any email, E.164 phone', () => { + it('accepts a free-mail address: partners often apply before company mail exists', () => { + expect(partnerSubmissionSchema.safeParse({ ...valid, email: 'ada@gmail.com' }).success).toBe( + true, + ); + }); + + it('still rejects a malformed address', () => { + expect(partnerSubmissionSchema.safeParse({ ...valid, email: 'ada@' }).success).toBe(false); + }); + + it('rejects a phone that is not E.164', () => { + for (const phone of ['4155552671', '+1 415 555 2671', '(415) 555-2671']) { + expect(partnerSubmissionSchema.safeParse({ ...valid, phone }).success).toBe(false); + } + }); + + it('allows the phone to be omitted', () => { + const { phone: _omitted, ...withoutPhone } = valid; + expect(partnerSubmissionSchema.safeParse(withoutPhone).success).toBe(true); + }); +}); diff --git a/apps/cms/src/payload/lib/partners/partner-schema.ts b/apps/cms/src/payload/lib/partners/partner-schema.ts index 5b3c9d3bb..3f29229d2 100644 --- a/apps/cms/src/payload/lib/partners/partner-schema.ts +++ b/apps/cms/src/payload/lib/partners/partner-schema.ts @@ -1,10 +1,16 @@ import { z } from 'zod'; +import { emailField, optionalPhoneField } from '../form-field-schemas'; + export const partnerSubmissionSchema = z.object({ firstName: z.string().min(1).max(120), lastName: z.string().min(1).max(120), - email: z.string().email().max(254), - phone: z.string().max(40).optional(), + // Any valid address. A prospective partner is often an individual or a + // small reseller applying before they have company mail set up, so the + // company-email gate the demo and contact forms use costs more here than + // the lead quality it buys. + email: emailField({ requireBusiness: false }), + phone: optionalPhoneField(), company: z.string().min(1).max(200), website: z.string().max(500).optional(), partnerReason: z.string().max(5000).optional(), diff --git a/apps/web/package.json b/apps/web/package.json index 91055db5d..78df5585d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@base-ui/react": "^1.4.1", + "@cleanstart/forms": "workspace:*", "@cleanstart/schema": "workspace:*", "@cleanstart/types": "workspace:*", "@cleanstart/ui": "workspace:*", @@ -28,6 +29,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lenis": "^1.3.23", + "libphonenumber-js": "^1.13.12", "lottie-react": "^2.4.1", "lucide-react": "^1.14.0", "mapbox-gl": "^3.25.0", diff --git a/apps/web/src/app/api/geo/route.ts b/apps/web/src/app/api/geo/route.ts new file mode 100644 index 000000000..a20b42e0d --- /dev/null +++ b/apps/web/src/app/api/geo/route.ts @@ -0,0 +1,33 @@ +import { NextResponse, type NextRequest } from "next/server"; + +export const runtime = "edge"; +export const dynamic = "force-dynamic"; + +/** + * Returns the visitor's country so the phone field can preselect their dial + * code. Vercel injects `x-vercel-ip-country` into every Vercel Function, so + * this needs no third-party IP lookup, no API key and no per-request cost. + * + * It exists as a route rather than being read during render because every page + * carrying a form is statically rendered and ISR-cached. Reading a request + * header in the page would opt the whole route into dynamic rendering and + * throw away that cache for a two-letter hint the visitor can override anyway. + * + * Returns `{ country: null }` off Vercel (local dev) and for the requests the + * edge cannot place. Callers must treat null as "no guess" and fall back. + */ +export function GET(request: NextRequest): NextResponse { + const header = request.headers.get("x-vercel-ip-country"); + const country = header && /^[A-Za-z]{2}$/.test(header) ? header.toUpperCase() : null; + + return NextResponse.json( + { country }, + { + headers: { + // Per-visitor, so it must never land in a shared cache. The client + // caches the answer in sessionStorage instead. + "Cache-Control": "no-store, private", + }, + }, + ); +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 17ed02ce9..16a8cf4f9 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -6310,3 +6310,28 @@ body { .scrollbar-premium-dark:hover::-webkit-scrollbar-thumb { background-color: rgba(255, 255, 255, 0.36); } + +/* --------------------------------------------------------------------------- + Form field focus indicator + --------------------------------------------------------------------------- + `:focus-visible` rather than `:focus`, so tabbing rings the field but a mouse + click on the country selector or the "how did you hear" trigger does not. + Browsers still match :focus-visible on text inputs when clicked, which is + wanted: you need to see where the caret landed. + + Blue rather than the cyan the `.cs-btn-*` rules use. That cyan (#33BAEC) is + tuned for the dark hero surfaces those buttons sit on, where it measures + 8.3:1; on the white form cards it drops to 2.2:1, under the 3:1 that WCAG + 1.4.11 asks of a non-text indicator. #3960F9 is the form accent already and + clears it on every surface a field appears on (4.8:1 light, 3.7:1 dark). + + An outline, not a border: the field border is set through an inline style and + inline always beats a class, so a border-based ring would silently not + render. An outline also sits outside the box, so nothing reflows. +--------------------------------------------------------------------------- */ + +.cs-field:focus-visible, +.cs-field-group:has(:focus-visible) { + outline: 2px solid #3960f9; + outline-offset: 2px; +} diff --git a/apps/web/src/components/forms/FieldShell.tsx b/apps/web/src/components/forms/FieldShell.tsx new file mode 100644 index 000000000..46f2bb144 --- /dev/null +++ b/apps/web/src/components/forms/FieldShell.tsx @@ -0,0 +1,91 @@ +import type { ReactNode } from "react"; + +import { FIELD_ERROR_TEXT, fieldLabelStyle, type FieldVariant } from "./field-surface"; + +interface FieldShellProps { + /** Must match the control's id so clicking the label focuses it. */ + htmlFor: string; + label: string; + required?: boolean | undefined; + /** Inline validation message. Its presence is what puts the field in error. */ + error?: string | undefined; + hint?: string | undefined; + children: ReactNode; + className?: string | undefined; + variant?: FieldVariant | undefined; +} + +/** + * Label, required marker, control, and the inline error underneath it. + * + * The error is the point. The forms previously relied on the browser's native + * validation bubble, which shows one message at a time, disappears on the next + * keystroke and cannot say "use your company email". This renders the message + * in the layout, next to the field it belongs to, and keeps it there. + */ +export function FieldShell({ + htmlFor, + label, + required = false, + error, + hint, + children, + className, + variant = "marketing", +}: FieldShellProps): React.ReactElement { + return ( +
+ + + {children} + + {error ? ( + + ) : hint ? ( +

+ {hint} +

+ ) : null} +
+ ); +} + +/** Wire-up every control inside a FieldShell needs to announce its own error. */ +export const fieldAria = ( + id: string, + error: string | undefined, + hint?: string, +): { "aria-invalid": boolean; "aria-describedby": string | undefined } => ({ + "aria-invalid": Boolean(error), + "aria-describedby": error ? `${id}-error` : hint ? `${id}-hint` : undefined, +}); diff --git a/apps/web/src/components/forms/FormRenderer.tsx b/apps/web/src/components/forms/FormRenderer.tsx index dace980e4..dea872622 100644 --- a/apps/web/src/components/forms/FormRenderer.tsx +++ b/apps/web/src/components/forms/FormRenderer.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { Form, FormField, @@ -10,6 +10,15 @@ import { StatusBanner, useFormStatus } from "@/components/forms/StatusBanner"; import { TurnstileWidget } from "@/components/TurnstileWidget"; import { useAttribution } from "@/components/attribution/AttributionProvider"; import { trackEvent } from "@/lib/analytics/track"; +import { emailError } from "@/lib/forms/validate"; +import { useDetectedCountry } from "@/lib/forms/useDetectedCountry"; +import { + emptyPhoneValue, + toE164, + validatePhone, + type PhoneValue, +} from "@/lib/forms/phone-value"; +import { PhoneField } from "@/components/forms/PhoneField"; export interface FormRendererSubmitResult { duplicate?: boolean; @@ -28,7 +37,10 @@ interface FormRendererProps { className?: string; } -type FieldValue = string | boolean | undefined; +type FieldValue = string | boolean | PhoneValue | undefined; + +const isPhoneValue = (value: FieldValue): value is PhoneValue => + typeof value === "object" && value !== null && "country" in value; const CMS_URL = process.env.NEXT_PUBLIC_CMS_URL ?? "http://localhost:3000"; @@ -41,7 +53,11 @@ const evaluateConditions = ( const mode = field.conditions?.mode ?? "all"; const check = (rule: FormFieldConditionRule): boolean => { const actual = values[rule.fieldName]; - const actualStr = actual == null ? "" : String(actual); + const actualStr = isPhoneValue(actual) + ? (toE164(actual) ?? "") + : actual == null + ? "" + : String(actual); switch (rule.operator) { case "equals": return actualStr === rule.value; @@ -60,6 +76,11 @@ const validateField = ( field: FormField, value: FieldValue, ): string | null => { + if (field.type === "tel") { + const phone = isPhoneValue(value) ? value : emptyPhoneValue(); + return validatePhone(phone, { required: Boolean(field.required) }); + } + const isConsentOrCheckbox = field.type === "consent" || field.type === "checkbox"; if (field.required) { if (isConsentOrCheckbox) { @@ -74,8 +95,12 @@ const validateField = ( } if (typeof value === "string" && value.length > 0) { if (field.type === "email") { - const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u; - if (!emailRe.test(value)) return field.errorMessage ?? "Enter a valid email address."; + // Shape always; company-only when the form definition asks for it, which + // the newsletter and gated-download forms deliberately do not. + const invalid = emailError(value, { + requireBusiness: field.requireBusinessEmail === true, + }); + if (invalid) return field.errorMessage ?? invalid; } const v = field.validation; if (v?.minLength != null && value.length < v.minLength) { @@ -102,7 +127,6 @@ const fieldInputStyle: React.CSSProperties = { paddingLeft: "16px", paddingRight: "16px", color: "#111", - outline: "none", background: "white", height: "44px", width: "100%", @@ -121,6 +145,7 @@ export function FormRenderer({ const out: Record = {}; for (const f of form.fields) { if (f.type === "checkbox" || f.type === "consent") out[f.name] = false; + else if (f.type === "tel") out[f.name] = emptyPhoneValue(); else out[f.name] = f.defaultValue ?? ""; } return out; @@ -138,6 +163,22 @@ export function FormRenderer({ // for those. The gate modal renders any gateForm through this component, so // the widget lives here rather than in each caller. const [turnstileToken, setTurnstileToken] = useState(""); + const { country: detectedCountry, detected } = useDetectedCountry(); + const touchedCountryRef = useRef(false); + + useEffect(() => { + if (!detected || touchedCountryRef.current) return; + setValues((prev) => { + const next = { ...prev }; + for (const field of form.fields) { + const current = next[field.name]; + if (field.type === "tel" && isPhoneValue(current)) { + next[field.name] = { ...current, country: detectedCountry }; + } + } + return next; + }); + }, [detected, detectedCountry, form.fields]); const visibleFields = useMemo( () => form.fields.filter((f) => evaluateConditions(f, values)), @@ -171,7 +212,10 @@ export function FormRenderer({ const out: Record = {}; for (const f of visibleFields) { if (f.type === "consent") continue; - out[f.name] = values[f.name] ?? ""; + const value = values[f.name]; + // Phone fields go over the wire as E.164, never as the country/digits + // pair the field holds internally. + out[f.name] = isPhoneValue(value) ? (toE164(value) ?? "") : (value ?? ""); } return out; }; @@ -247,6 +291,7 @@ export function FormRenderer({ } trackEvent("generate_lead", { form_id: form.id, + form_name: form.slug ?? String(form.id), gated: Boolean(json.download), }); onSuccess?.(successPayload); @@ -324,6 +369,7 @@ export function FormRenderer({ placeholder={f.placeholder ?? undefined} value={String(values[f.name] ?? "")} onChange={(e) => setValue(f.name, e.target.value)} + className="cs-field outline-none" style={{ ...fieldInputStyle, height: "96px", paddingTop: "10px" }} /> {helpEl} @@ -332,6 +378,28 @@ export function FormRenderer({ ); } + if (f.type === "tel") { + const phone = isPhoneValue(values[f.name]) ? values[f.name] : emptyPhoneValue(); + return ( + { + if (next.country.code !== (phone as PhoneValue).country.code) { + touchedCountryRef.current = true; + } + setValue(f.name, next); + }} + size="md" + error={err} + hint={f.helpText ?? undefined} + /> + ); + } + if (f.type === "select") { return (
@@ -341,6 +409,7 @@ export function FormRenderer({ required={!!f.required} value={String(values[f.name] ?? "")} onChange={(e) => setValue(f.name, e.target.value)} + className="cs-field outline-none" style={fieldInputStyle} > @@ -394,6 +463,7 @@ export function FormRenderer({ placeholder={f.placeholder ?? undefined} value={String(values[f.name] ?? "")} onChange={(e) => setValue(f.name, e.target.value)} + className="cs-field outline-none" style={fieldInputStyle} /> {helpEl} diff --git a/apps/web/src/components/forms/PhoneField.tsx b/apps/web/src/components/forms/PhoneField.tsx new file mode 100644 index 000000000..b29141faa --- /dev/null +++ b/apps/web/src/components/forms/PhoneField.tsx @@ -0,0 +1,374 @@ +"use client"; + +import { useEffect, useId, useMemo, useRef, useState } from "react"; + +import { searchCountries, type PhoneCountry } from "@/lib/forms/countries"; +import { + digitsOnly, + formatNational, + maxNationalDigits, + parseInternational, + type PhoneValue, +} from "@/lib/forms/phone-value"; + +import { + FIELD_BORDER_ERROR, + fieldBorderColor, + fieldSurfaceStyle, + type FieldHeight, + type FieldVariant, +} from "./field-surface"; +import { FieldShell } from "./FieldShell"; + +interface PhoneFieldProps { + id: string; + label: string; + value: PhoneValue; + onChange: (next: PhoneValue) => void; + required?: boolean | undefined; + error?: string | undefined; + hint?: string | undefined; + size?: FieldHeight | undefined; + variant?: FieldVariant | undefined; + className?: string | undefined; + /** Fires on blur so the caller can validate a field the visitor has left. */ + onBlur?: (() => void) | undefined; +} + +/** + * Phone entry as one control: a country selector carrying the dial code, and a + * digits-only number input beside it. + * + * The country is preselected from the visitor's IP by whoever owns the value + * (see useDetectedCountry) and stays editable. It doubles as the answer to + * "which country is this lead in", which is why the forms no longer ask + * separately. + * + * The number input takes digits and nothing else: letters, spaces and + * punctuation are dropped on the way in rather than rejected afterwards, so + * pasting "+1 (415) 555-2671" into a US field leaves the right digits behind. + */ +export function PhoneField({ + id, + label, + value, + onChange, + required = false, + error, + hint, + size = "sm", + variant = "marketing", + className, + onBlur, +}: PhoneFieldProps): React.ReactElement { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); + + const wrapRef = useRef(null); + const searchRef = useRef(null); + const listRef = useRef(null); + const triggerRef = useRef(null); + + const listId = useId(); + const results = useMemo(() => searchCountries(query), [query]); + + const invalid = Boolean(error); + const surface = fieldSurfaceStyle({ variant, size, invalid }); + const restingBorder = fieldBorderColor(variant); + + const closeMenu = (refocusTrigger: boolean): void => { + setOpen(false); + setQuery(""); + // preventScroll: focusing normally scrolls the element into view, which + // would jolt the page every time the menu opens or closes. + if (refocusTrigger) triggerRef.current?.focus({ preventScroll: true }); + }; + + const selectCountry = (country: PhoneCountry): void => { + // Re-clamp the digits: moving from a 10-digit plan to one with a longer + // dial code can push an existing number past the E.164 ceiling. + const capped = value.national.slice(0, maxNationalDigits(country)); + onChange({ country, national: capped }); + closeMenu(true); + }; + + useEffect(() => { + if (!open) return; + const onPointerDown = (event: PointerEvent): void => { + if (wrapRef.current?.contains(event.target as Node)) return; + setOpen(false); + setQuery(""); + }; + document.addEventListener("pointerdown", onPointerDown); + return () => document.removeEventListener("pointerdown", onPointerDown); + }, [open]); + + // Seeds the active row from the current selection when the menu opens. + // Re-running it on `results` or `value` would fight the arrow keys and snap + // the highlight back on every keystroke, so `open` is the only trigger. + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally open-only, see above + useEffect(() => { + if (!open) return; + searchRef.current?.focus({ preventScroll: true }); + const selected = results.findIndex((country) => country.code === value.country.code); + setActiveIndex(selected >= 0 ? selected : 0); + }, [open]); + + // Keeps the active row visible by moving the list's own scrollTop and + // nothing else. `scrollIntoView` would walk up and scroll every ancestor + // scroll container, including the document, so arrowing through the list + // would drag the page along behind the popover. + useEffect(() => { + if (!open) return; + const list = listRef.current; + const row = list?.querySelector(`[data-index="${activeIndex}"]`); + if (!list || !row) return; + const listBox = list.getBoundingClientRect(); + const rowBox = row.getBoundingClientRect(); + if (rowBox.top < listBox.top) { + list.scrollTop -= listBox.top - rowBox.top; + } else if (rowBox.bottom > listBox.bottom) { + list.scrollTop += rowBox.bottom - listBox.bottom; + } + }, [activeIndex, open]); + + const onSearchKeyDown = (event: React.KeyboardEvent): void => { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + if (results.length === 0) return; + const delta = event.key === "ArrowDown" ? 1 : -1; + setActiveIndex((prev) => (prev + delta + results.length) % results.length); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + const picked = results[activeIndex]; + if (picked) selectCountry(picked); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + closeMenu(true); + } + }; + + const onDigitsChange = (raw: string): void => { + // Pasting a full international number should move the country selector to + // match, not fold the dial code into the local digits. + const pasted = parseInternational(raw); + if (pasted) { + onChange(pasted); + return; + } + onChange({ + country: value.country, + national: digitsOnly(raw).slice(0, maxNationalDigits(value.country)), + }); + }; + + return ( + +
{ + // Moving between the country trigger and the number input is still + // "inside" the field, so it must not count as leaving it. + if (event.currentTarget.contains(event.relatedTarget as Node | null)) return; + onBlur?.(); + }} + > + + + onDigitsChange(event.target.value)} + aria-invalid={invalid} + aria-describedby={error ? `${id}-error` : hint ? `${id}-hint` : undefined} + className="w-full min-w-0 rounded-r-[8px] bg-transparent px-3 outline-none placeholder:text-[#A3A3A3]" + style={{ + fontFamily: "inherit", + fontWeight: "inherit", + fontSize: "inherit", + color: "#111111", + }} + /> + + {open ? ( +
+ setQuery(event.target.value)} + onKeyDown={onSearchKeyDown} + placeholder="Search country or code" + aria-label="Search country or dial code" + aria-controls={listId} + aria-activedescendant={ + results[activeIndex] ? `${listId}-${results[activeIndex].code}` : undefined + } + className="w-full border-b bg-white px-3 py-2.5 outline-none placeholder:text-[#A3A3A3]" + style={{ + borderColor: restingBorder, + fontFamily: "inherit", + fontSize: "var(--fs-input)", + color: "#111111", + }} + /> +
cannot render the flag, name and dial code as separate styled columns + role="listbox" + tabIndex={-1} + aria-label="Country" + className="max-h-[240px] overflow-y-auto py-1" + // Lenis (root layout) intercepts wheel events and animates + // window.scrollY itself, which leaves every nested scroll + // container inert — the wheel scrolls the page instead of this + // list. data-lenis-prevent hands wheel events over the list back + // to the browser. Lenis is off for touch and reduced-motion, so + // overscroll-behavior still does the containing work there. + data-lenis-prevent + style={{ overscrollBehavior: "contain" }} + > + {results.length === 0 ? ( +

+ No country matches that. +

+ ) : ( + results.map((country, index) => { + const selected = country.code === value.country.code; + return ( +