diff --git a/CLAUDE.md b/CLAUDE.md index 43c312e0d..6a7046865 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` @@ -242,6 +255,30 @@ These are hard rules. Do not work around them — flag and stop instead. --- +## Email + +Every email the site sends is built in code and delivered through +`sendBrevoEmail` as `subject` + `htmlContent`. The registry at +`apps/cms/src/payload/lib/email/registry.ts` lists all of them with the form +that triggers each and the file that sends it. + +- **One layout.** `lib/email/layout.ts` owns presentation; builders describe + content as blocks. Table-based, inline styles, Arial, preheader, plated logo, + postal address. Do not hand-write email HTML. +- **Never use a Brevo dashboard template for a form.** Brevo interpolates + `{{ params.* }}` unescaped, so visitor input reaches inboxes as live markup, + and a second design source means production sends two different-looking sets + of email. `BREVO_TEMPLATE_ID`, `PARTNER_ADMIN_TEMPLATE_ID` and + `PARTNER_USER_TEMPLATE_ID` are dead and can be unset. +- **Payload's own mail** (password reset) goes through the Brevo adapter at + `lib/email/payload-adapter.ts`. Without it Payload logs mail to stdout and + password resets silently never arrive. +- **`apps/cms/emails/` is generated.** Run `pnpm --filter @cleanstart/cms + emails:render` after changing a template and commit the result; + `emails:check` is the drift gate. + +--- + ## Background jobs Twelve cron tasks in `apps/cms/src/payload/jobs/`. All gated by `PAYLOAD_AUTO_RUN=true` — set this in `.env` to enable; omitting it (e.g. in test runs) prevents spurious fires. diff --git a/apps/cms/emails/careers-applicant-confirmation.html b/apps/cms/emails/careers-applicant-confirmation.html new file mode 100644 index 000000000..b3cf89cbe --- /dev/null +++ b/apps/cms/emails/careers-applicant-confirmation.html @@ -0,0 +1,86 @@ + + + + + + + + + +Thanks for applying, Priya + + + + + +
Your application for Senior Platform Engineer reached our team.͏‌ ͏‌ ͏‌ ͏‌ ͏‌ 
+ + + +
+ + + + + + + + + + + + + + +
+ + diff --git a/apps/cms/emails/careers-hr-notification.html b/apps/cms/emails/careers-hr-notification.html new file mode 100644 index 000000000..6085a14a4 --- /dev/null +++ b/apps/cms/emails/careers-hr-notification.html @@ -0,0 +1,113 @@ + + + + + + + + + +New job application + + + + + +
Priya Nair applied for Senior Platform Engineer.͏‌ ͏‌ ͏‌ ͏‌ ͏‌ 
+ + + +
+ + + + + + + + + + + + + + +
+ + diff --git a/apps/cms/emails/contact-confirmation.html b/apps/cms/emails/contact-confirmation.html new file mode 100644 index 000000000..60a632577 --- /dev/null +++ b/apps/cms/emails/contact-confirmation.html @@ -0,0 +1,83 @@ + + + + + + + + + +Thanks, Priya + + + + + +
Your message reached the CleanStart team. We reply within one business day.͏‌ ͏‌ ͏‌ ͏‌ ͏‌ 
+ + + +
+ + + + + + + + + + + + + + +
+ + diff --git a/apps/cms/emails/deal-registration-confirmation.html b/apps/cms/emails/deal-registration-confirmation.html new file mode 100644 index 000000000..1bce8e88a --- /dev/null +++ b/apps/cms/emails/deal-registration-confirmation.html @@ -0,0 +1,92 @@ + + + + + + + + + +Thanks, Marcus + + + + + +
Your registration for Elena Vasquez — Northgate Security was received.͏‌ ͏‌ ͏‌ ͏‌ ͏‌ 
+ + + +
+ + + + + + + + + + + + + + +
+ + diff --git a/apps/cms/emails/deal-registration-notification.html b/apps/cms/emails/deal-registration-notification.html new file mode 100644 index 000000000..895f255df --- /dev/null +++ b/apps/cms/emails/deal-registration-notification.html @@ -0,0 +1,115 @@ + + + + + + + + + +New partner deal registration + + + + + +
Marcus Reed at Northgate Security registered Elena Vasquez.͏‌ ͏‌ ͏‌ ͏‌ ͏‌ 
+ + + +
+ + + + + + + + + + + + + + +
+ + diff --git a/apps/cms/emails/demo-confirmation.html b/apps/cms/emails/demo-confirmation.html new file mode 100644 index 000000000..5a746f00e --- /dev/null +++ b/apps/cms/emails/demo-confirmation.html @@ -0,0 +1,83 @@ + + + + + + + + + +Thanks, Priya + + + + + +
We have your demo request. A specialist will reach out within one business day.͏‌ ͏‌ ͏‌ ͏‌ ͏‌ 
+ + + +
+ + + + + + + + + + + + + + +
+ + diff --git a/apps/cms/emails/index.html b/apps/cms/emails/index.html new file mode 100644 index 000000000..5200d532d --- /dev/null +++ b/apps/cms/emails/index.html @@ -0,0 +1,30 @@ + +CleanStart email templates + +
+

CleanStart email templates

+

Generated from the code builders. Do not edit these files by hand.

+ + + + + + + + + + + +
TemplateFormAudienceSubject
demo-confirmationbook-a-demovisitorYour CleanStart demo request
contact-confirmationcontactvisitorWe've received your message
newsletter-welcomenewslettervisitorYou're subscribed to CleanStart
resource-downloadresource-capture (gated resources)visitorYour download: The 2026 Container Hardening Playbook
careers-applicant-confirmationjob applicationvisitorWe've received your application for Senior Platform Engineer
careers-hr-notificationjob applicationinternalNew application: Senior Platform Engineer, Priya Nair
partner-confirmationbecome-a-partnervisitorThanks for your interest in partnering with CleanStart
partner-notificationbecome-a-partnerinternalNew partner inquiry: Northgate Security, Marcus Reed
deal-registration-confirmationdeal-registrationvisitorWe have your deal registration
deal-registration-notificationdeal-registrationinternalNew partner deal registration: Elena Vasquez — Northgate Security
+
\ No newline at end of file diff --git a/apps/cms/emails/newsletter-welcome.html b/apps/cms/emails/newsletter-welcome.html new file mode 100644 index 000000000..181a15864 --- /dev/null +++ b/apps/cms/emails/newsletter-welcome.html @@ -0,0 +1,87 @@ + + + + + + + + + +Welcome, Priya + + + + + +
Container security research and release notes, roughly monthly.͏‌ ͏‌ ͏‌ ͏‌ ͏‌ 
+ + + +
+ + + + + + + + + + + + + + +
+ + diff --git a/apps/cms/emails/partner-confirmation.html b/apps/cms/emails/partner-confirmation.html new file mode 100644 index 000000000..9460aa05e --- /dev/null +++ b/apps/cms/emails/partner-confirmation.html @@ -0,0 +1,82 @@ + + + + + + + + + +Thanks, Marcus + + + + + +
We have your partnership inquiry. Our team will be in touch shortly.͏‌ ͏‌ ͏‌ ͏‌ ͏‌ 
+ + + +
+ + + + + + + + + + + + + + +
+ + diff --git a/apps/cms/emails/partner-notification.html b/apps/cms/emails/partner-notification.html new file mode 100644 index 000000000..370c5144b --- /dev/null +++ b/apps/cms/emails/partner-notification.html @@ -0,0 +1,101 @@ + + + + + + + + + +New partner inquiry + + + + + +
Marcus Reed at Northgate Security submitted a partnership inquiry.͏‌ ͏‌ ͏‌ ͏‌ ͏‌ 
+ + + +
+ + + + + + + + + + + + + + +
+ + diff --git a/apps/cms/emails/resource-download.html b/apps/cms/emails/resource-download.html new file mode 100644 index 000000000..9087c584c --- /dev/null +++ b/apps/cms/emails/resource-download.html @@ -0,0 +1,90 @@ + + + + + + + + + +Thanks, Priya + + + + + +
The 2026 Container Hardening Playbook is ready to download.͏‌ ͏‌ ͏‌ ͏‌ ͏‌ 
+ + + +
+ + + + + + + + + + + + + + +
+ + diff --git a/apps/cms/package.json b/apps/cms/package.json index 6ae598350..7c4ea55b8 100644 --- a/apps/cms/package.json +++ b/apps/cms/package.json @@ -20,10 +20,13 @@ "verify:payload-ui": "node --experimental-strip-types --no-warnings scripts/check-payload-ui-allowlist.ts", "verify:bundle-budget": "node --experimental-strip-types --no-warnings scripts/check-bundle-budget.ts", "migrate": "cross-env NODE_OPTIONS=--no-deprecation payload migrate", - "migrate:create": "cross-env NODE_OPTIONS=--no-deprecation payload migrate:create" + "migrate:create": "cross-env NODE_OPTIONS=--no-deprecation payload migrate:create", + "emails:render": "tsx scripts/render-email-templates.ts", + "emails:check": "tsx scripts/render-email-templates.ts --check" }, "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/scripts/render-email-templates.ts b/apps/cms/scripts/render-email-templates.ts new file mode 100755 index 000000000..998f8087a --- /dev/null +++ b/apps/cms/scripts/render-email-templates.ts @@ -0,0 +1,105 @@ +#!/usr/bin/env -S node --no-warnings --experimental-strip-types +/** + * Renders every entry in the email registry to `apps/cms/emails/`. + * + * The HTML is generated, not authored: `lib/email/layout.ts` and the builders + * remain the source of truth. Committing the output makes an email design + * change visible as a reviewable diff instead of vanishing inside a template + * function, and gives anyone a file to open in a browser or paste into a + * client-rendering test without running the CMS. + * + * Run from apps/cms: + * pnpm exec tsx scripts/render-email-templates.ts + * pnpm exec tsx scripts/render-email-templates.ts --check # CI drift gate + */ +import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +import { EMAIL_TEMPLATES } from '../src/payload/lib/email/registry.ts'; + +const OUT_DIR = path.resolve(import.meta.dirname, '..', 'emails'); + +const indexPage = (rows: { key: string; subject: string; form: string; audience: string }[]): string => + ` +CleanStart email templates + +
+

CleanStart email templates

+

Generated from the code builders. Do not edit these files by hand.

+ +${rows + .map( + (r) => + ``, + ) + .join('\n')} +
TemplateFormAudienceSubject
${r.key}${r.form}${r.audience}${r.subject}
+
`; + +const run = (): void => { + const check = process.argv.includes('--check'); + const rendered = new Map(); + + for (const entry of EMAIL_TEMPLATES) { + if (entry.key === 'payload-password-reset') continue; // Rendered by Payload, not by us. + const { subject, htmlContent } = entry.sample(); + rendered.set( + `${entry.key}.html`, + `\n${htmlContent}\n`, + ); + } + rendered.set( + 'index.html', + indexPage( + EMAIL_TEMPLATES.filter((e) => e.key !== 'payload-password-reset').map((e) => ({ + key: e.key, + subject: e.sample().subject, + form: e.form, + audience: e.audience, + })), + ), + ); + + if (check) { + let drift = 0; + for (const [name, body] of rendered) { + let current = ''; + try { + current = readFileSync(path.join(OUT_DIR, name), 'utf8'); + } catch { + current = ''; + } + if (current !== body) { + console.error(`drift: ${name}`); + drift += 1; + } + } + if (drift > 0) { + console.error(`\n${drift} file(s) out of date. Run: pnpm exec tsx scripts/render-email-templates.ts`); + process.exit(1); + } + console.log(`emails/ is up to date (${rendered.size} files).`); + return; + } + + rmSync(OUT_DIR, { recursive: true, force: true }); + mkdirSync(OUT_DIR, { recursive: true }); + for (const [name, body] of rendered) writeFileSync(path.join(OUT_DIR, name), body); + console.log(`Wrote ${readdirSync(OUT_DIR).length} files to apps/cms/emails/`); +}; + +run(); diff --git a/apps/cms/scripts/upload-email-logo.ts b/apps/cms/scripts/upload-email-logo.ts new file mode 100755 index 000000000..9e70ab63e --- /dev/null +++ b/apps/cms/scripts/upload-email-logo.ts @@ -0,0 +1,93 @@ +#!/usr/bin/env -S node --no-warnings --experimental-strip-types +/** + * Uploads the dark-mode-safe email wordmark used by `lib/email/layout.ts`. + * + * The shipped wordmark is a dark mark on a 73% transparent background. That is + * fine on the white card in light mode, but Outlook and Outlook.com invert the + * card in dark mode and the mark then sits dark-on-dark and disappears. This + * composites it onto an opaque white plate with 10 px of padding: invisible + * against the white card in light mode, and self-backing when a client inverts + * around it. + * + * Writes under the CMS-managed prefix, never `emails/`: those objects are + * referenced by every signature already sent, and `EmailAssets.ts` throws if a + * prefix targets them. + * + * SAFETY: refuses to overwrite an existing key. `--dry-run` previews. + * + * Run from apps/cms with the env file loaded: + * pnpm exec tsx --env-file=.env scripts/upload-email-logo.ts --dry-run + * pnpm exec tsx --env-file=.env scripts/upload-email-logo.ts + */ +import { HeadObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; +import sharp from 'sharp'; + +import { getR2Client } from '../src/payload/lib/r2.ts'; + +const SOURCE_URL = 'https://cdn.cleanstart.com/emails/social-icons/cleanstart-logo.png'; +/** Opaque backing so the mark survives a client inverting the card. */ +const PLATE_PADDING = 10; +const KEY = 'web/emails/logo-email.png'; + +const run = async (): Promise => { + const dryRun = process.argv.includes('--dry-run'); + const r2 = getR2Client(); + if (!r2) { + throw new Error('R2 is not configured — check R2_ENDPOINT / R2_BUCKET / R2_ACCESS_KEY_ID.'); + } + + const res = await fetch(SOURCE_URL); + if (!res.ok) throw new Error(`Source logo fetch failed: ${res.status}`); + const original = Buffer.from(await res.arrayBuffer()); + + const meta0 = await sharp(original).metadata(); + const optimised = await sharp({ + create: { + width: (meta0.width ?? 220) + PLATE_PADDING * 2, + height: (meta0.height ?? 46) + PLATE_PADDING * 2, + channels: 4, + background: '#ffffff', + }, + }) + .composite([{ input: original, top: PLATE_PADDING, left: PLATE_PADDING }]) + .png({ palette: true, quality: 90, compressionLevel: 9, effort: 10 }) + .toBuffer(); + + const meta = await sharp(optimised).metadata(); + console.log( + `source ${(original.length / 1024).toFixed(1)} KB -> plated ${(optimised.length / 1024).toFixed(1)} KB ` + + `(${meta.width}x${meta.height}, opaque background)`, + ); + + try { + await r2.client.send(new HeadObjectCommand({ Bucket: r2.bucket, Key: KEY })); + console.log(`${KEY} already exists — refusing to overwrite. Nothing written.`); + return; + } catch { + // Not found is the expected path; fall through to the upload. + } + + if (dryRun) { + console.log(`Dry run. Would write ${KEY}.`); + return; + } + + await r2.client.send( + new PutObjectCommand({ + Bucket: r2.bucket, + Key: KEY, + Body: optimised, + ContentType: 'image/png', + CacheControl: 'public, max-age=31536000, immutable', + }), + ); + console.log(`Wrote ${KEY}.`); +}; + +void run().then( + () => process.exit(0), + (err: unknown) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + }, +); 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..67e53845e 100644 --- a/apps/cms/src/payload-types.ts +++ b/apps/cms/src/payload-types.ts @@ -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; @@ -12449,6 +12453,7 @@ export interface FormsSelect { type?: T; label?: T; required?: T; + requireBusinessEmail?: T; placeholder?: T; helpText?: T; defaultValue?: T; diff --git a/apps/cms/src/payload.config.ts b/apps/cms/src/payload.config.ts index b0ad12412..c720db37f 100644 --- a/apps/cms/src/payload.config.ts +++ b/apps/cms/src/payload.config.ts @@ -2,6 +2,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { postgresAdapter } from '@payloadcms/db-postgres'; + +import { brevoEmailAdapter } from './payload/lib/email/payload-adapter'; import { cleanstartLexicalEditor } from './payload/lib/lexical/editor-config'; import { s3Storage } from '@payloadcms/storage-s3'; import { buildConfig } from 'payload'; @@ -610,6 +612,7 @@ export default buildConfig({ typescript: { outputFile: path.resolve(dirname, 'payload-types.ts'), }, + email: brevoEmailAdapter, db: postgresAdapter({ pool: { connectionString: requireEnv('DATABASE_URI'), 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/endpoints/careers-apply.test.ts b/apps/cms/src/payload/endpoints/careers-apply.test.ts index bd267f7ee..f3b99a909 100644 --- a/apps/cms/src/payload/endpoints/careers-apply.test.ts +++ b/apps/cms/src/payload/endpoints/careers-apply.test.ts @@ -199,7 +199,8 @@ describe('careersApplyEndpoint', () => { expect(req.payload.create).toHaveBeenCalledWith( expect.objectContaining({ collection: 'resumes' }), ); - expect(sendBrevoEmail).toHaveBeenCalledTimes(1); + // HR notification + applicant confirmation. + expect(sendBrevoEmail).toHaveBeenCalledTimes(2); expect(req.payload.create).toHaveBeenCalledWith( expect.objectContaining({ collection: 'career-applications', diff --git a/apps/cms/src/payload/endpoints/careers-apply.ts b/apps/cms/src/payload/endpoints/careers-apply.ts index bf4ff13c2..1999008fd 100644 --- a/apps/cms/src/payload/endpoints/careers-apply.ts +++ b/apps/cms/src/payload/endpoints/careers-apply.ts @@ -2,7 +2,10 @@ import * as Sentry from '@sentry/nextjs'; import type { Endpoint } from 'payload'; import { applicationFieldsSchema } from '../lib/careers/application-schema'; -import { buildHrApplicationEmail } from '../lib/careers/hr-email'; +import { + buildApplicantConfirmationEmail, + buildHrApplicationEmail, +} from '../lib/careers/hr-email'; import { formatJobLocation } from '../lib/careers/job-location'; import { clientIpFromHeaders } from '../lib/client-ip'; import { type BrevoSendResult, sendBrevoEmail } from '../lib/email/brevo'; @@ -10,22 +13,6 @@ import { DEFAULT_RATE_LIMITS, checkAndRecord } from '../lib/rate-limit'; import { verifyTurnstileToken } from '../lib/turnstile'; import { RESUME_LIMIT, checkUploadSize } from '../lib/upload-limits'; -/** - * Human-readable submission timestamp for the HR email (Brevo templates can't - * format dates). UTC with an explicit "UTC" suffix so it's unambiguous - * regardless of where the server or recipient sits. e.g. "Jun 4, 2026, 12:56 PM UTC". - */ -const formatSubmittedAt = (date: Date): string => - new Intl.DateTimeFormat('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: '2-digit', - hour12: true, - timeZone: 'UTC', - timeZoneName: 'short', - }).format(date); const json = (data: unknown, init?: ResponseInit): Response => new Response(JSON.stringify(data), { @@ -330,12 +317,6 @@ export const careersApplyEndpoint: Endpoint = { attachments.push({ name: coverFile.name || 'cover-letter', content: coverBuffer.toString('base64') }); } const hrEmail = process.env.CAREERS_HR_EMAIL; - // Use the Brevo dashboard template when BREVO_TEMPLATE_ID is a positive - // integer; otherwise fall back to the code-built HTML (hr-email.ts). - const templateIdRaw = process.env.BREVO_TEMPLATE_ID; - const templateIdParsed = templateIdRaw ? Number.parseInt(templateIdRaw, 10) : Number.NaN; - const templateId = - Number.isInteger(templateIdParsed) && templateIdParsed > 0 ? templateIdParsed : undefined; // Careers emails are sent from the careers sender identity (falls back to // the global BREVO_SENDER_* env when CAREERS_SENDER_* is unset). @@ -344,32 +325,17 @@ export const careersApplyEndpoint: Endpoint = { ...(process.env.CAREERS_SENDER_NAME ? { senderName: process.env.CAREERS_SENDER_NAME } : {}), }; + // One send path, built from lib/email/hr-email.ts. + // + // The BREVO_TEMPLATE_ID branch that used to sit here is gone. It won in + // production, so this form sent the old dashboard design while the rest of + // the site sent the shared layout, and it passed applicant input straight + // through as Brevo `params`. Brevo interpolates merge tags raw, so a cover + // letter containing markup reached HR's inbox unescaped. The builder + // escapes every value. let delivery: BrevoSendResult; if (!hrEmail) { delivery = { status: 'skipped', reason: 'no-hr-recipient' }; - } else if (templateId != null) { - delivery = await sendBrevoEmail({ - ...careersSender, - to: [{ email: hrEmail }], - replyTo: { email: data.email, name: fullName }, - templateId, - params: { - jobTitle: job.title ?? data.jobSlug, - jobLocation: jobLocation ?? '', - fullName, - firstName: data.firstName, - lastName: data.lastName, - email: data.email, - phone: data.phone ?? '', - location: data.location ?? '', - howDidYouHear: data.howDidYouHear ?? '', - linkedinUrl: data.linkedinUrl ?? '', - coverLetter: data.coverLetter ?? '', - coverLetterAttached: coverLetterFileId != null ? 'Yes' : '', - submittedAt: formatSubmittedAt(new Date()), - }, - attachments, - }); } else { const { subject, htmlContent } = buildHrApplicationEmail({ jobTitle: job.title ?? data.jobSlug, @@ -394,6 +360,27 @@ export const careersApplyEndpoint: Endpoint = { }); } + // Applicant acknowledgement. Non-fatal and deliberately after the HR + // notification: the internal copy is the one the business cannot lose, so + // it must not be delayed or risked by a second send. + const confirmation = buildApplicantConfirmationEmail({ + firstName: data.firstName, + jobTitle: job.title ?? data.jobSlug, + }); + const applicantDelivery = await sendBrevoEmail({ + ...careersSender, + to: [{ email: data.email, name: fullName }], + ...(hrEmail ? { replyTo: { email: hrEmail } } : {}), + subject: confirmation.subject, + htmlContent: confirmation.htmlContent, + }); + if (applicantDelivery.status === 'failed') { + req.payload.logger.warn( + { email: data.email, error: applicantDelivery.error }, + 'Applicant confirmation email failed', + ); + } + // Roll back orphaned R2 uploads when the submission ultimately fails: the // resume/cover-letter files are only persisted to storage for a successfully // created application. Best-effort — a cleanup failure is logged, not raised. diff --git a/apps/cms/src/payload/endpoints/deal-registration-apply.ts b/apps/cms/src/payload/endpoints/deal-registration-apply.ts index 0f4b23fd9..fc6074649 100644 --- a/apps/cms/src/payload/endpoints/deal-registration-apply.ts +++ b/apps/cms/src/payload/endpoints/deal-registration-apply.ts @@ -3,7 +3,10 @@ import type { Endpoint } from 'payload'; import { clientIpFromHeaders } from '../lib/client-ip'; import { createHubspotDeal } from '../lib/deal-registrations/hubspot-deal'; -import { buildDealRegistrationNotificationEmail } from '../lib/deal-registrations/notification-email'; +import { + buildDealRegistrationConfirmationEmail, + buildDealRegistrationNotificationEmail, +} from '../lib/deal-registrations/notification-email'; import { dealRegistrationSchema } from '../lib/deal-registrations/schema'; import { sendBrevoEmail } from '../lib/email/brevo'; import { DEFAULT_RATE_LIMITS, checkAndRecord } from '../lib/rate-limit'; @@ -219,6 +222,43 @@ export const dealRegistrationApplyEndpoint: Endpoint = { // Best-effort internal notification to the team (both marketing + Anil get // the identical HTML email). Never blocks or fails the 200 — the durable // deal-registrations row is already persisted above. + const dealSender = { + senderName: process.env.DEAL_REG_SENDER_NAME?.trim() || 'CleanStart', + }; + + // Confirmation to the partner rep who submitted. Best-effort, like the + // internal notification: the durable row is already persisted above. + try { + const confirmation = buildDealRegistrationConfirmationEmail({ + partnerName: data.partnerName, + partnerRep: data.partnerRep, + prospect: data.prospect, + dealDetails: data.dealDetails, + }); + const confirmResult = await sendBrevoEmail({ + ...dealSender, + to: [ + { + email: data.partnerRep.email, + name: `${data.partnerRep.firstName} ${data.partnerRep.lastName}`.trim(), + }, + ], + ...(notifyEmails()[0] ? { replyTo: { email: notifyEmails()[0] as string } } : {}), + subject: confirmation.subject, + htmlContent: confirmation.htmlContent, + }); + if (confirmResult.status === 'failed') { + req.payload.logger.warn( + { error: confirmResult.error }, + 'Deal registration confirmation email failed', + ); + } + } catch (err) { + Sentry.captureException(err, { + tags: { form: 'deal-registration', stage: 'confirm-email' }, + }); + } + const recipients = notifyEmails(); if (recipients.length > 0) { try { diff --git a/apps/cms/src/payload/endpoints/partner-apply.ts b/apps/cms/src/payload/endpoints/partner-apply.ts index 1929e188c..ef052fa5e 100644 --- a/apps/cms/src/payload/endpoints/partner-apply.ts +++ b/apps/cms/src/payload/endpoints/partner-apply.ts @@ -34,11 +34,6 @@ const corsHeaders = (origin: string): Record => ({ vary: 'Origin', }); -const numericTemplateId = (raw: string | undefined): number | undefined => { - if (!raw) return undefined; - const n = Number.parseInt(raw, 10); - return Number.isInteger(n) && n > 0 ? n : undefined; -}; export const partnerApplyOptionsEndpoint: Endpoint = { path: '/apply', @@ -128,6 +123,14 @@ export const partnerApplyEndpoint: Endpoint = { const turnstile = await verifyTurnstileToken(data.turnstileToken, ip); if (!turnstile.ok) { + // Logged because this branch writes nothing and sends nothing: a real + // applicant failing the bot check is otherwise indistinguishable from + // nobody having applied at all, which is exactly the hole that left the + // partner table empty with no way to tell why. + req.payload.logger.warn( + { ip, reason: turnstile.reason, email: data.email }, + 'Partner submission rejected — Turnstile failed', + ); return json({ ok: false, error: 'turnstile_failed', reason: turnstile.reason }, { status: 403, headers: cors }); } @@ -148,14 +151,6 @@ export const partnerApplyEndpoint: Endpoint = { phone: data.phone, company: data.company, website: data.website, partnerReason: data.partnerReason, }; const fullName = `${data.firstName} ${data.lastName}`.trim(); - const submittedAt = new Date().toISOString(); - const params = { - firstName: data.firstName, lastName: data.lastName, fullName, - email: data.email, phone: data.phone ?? '', company: data.company, - website: data.website ?? '', partnerReason: data.partnerReason ?? '', submittedAt, - }; - const adminTemplate = numericTemplateId(process.env.PARTNER_ADMIN_TEMPLATE_ID); - const userTemplate = numericTemplateId(process.env.PARTNER_USER_TEMPLATE_ID); const adminEmail = process.env.PARTNERS_NOTIFY_EMAIL; // Partner emails are sent from the partner sender identity (falls back to @@ -165,38 +160,38 @@ export const partnerApplyEndpoint: Endpoint = { ...(process.env.PARTNERS_SENDER_NAME ? { senderName: process.env.PARTNERS_SENDER_NAME } : {}), }; - // Admin notification (non-fatal). + // One send path, built from lib/partners/partner-emails.ts. + // + // The PARTNER_*_TEMPLATE_ID branches that used to sit here are gone. They + // won in production, so this form 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; the + // builders escape every value. let adminDelivery: BrevoSendResult; if (!adminEmail) { adminDelivery = { status: 'skipped', reason: 'no-admin-recipient' }; - } else if (adminTemplate != null) { + } else { + const { subject, htmlContent } = buildPartnerAdminEmail(emailInput); adminDelivery = await sendBrevoEmail({ ...partnerSender, - to: [{ email: adminEmail }], replyTo: { email: data.email, name: fullName }, - templateId: adminTemplate, params, + to: [{ email: adminEmail }], + replyTo: { email: data.email, name: fullName }, + subject, + htmlContent, }); - } else { - const { subject, htmlContent } = buildPartnerAdminEmail(emailInput); - adminDelivery = await sendBrevoEmail({ ...partnerSender, to: [{ email: adminEmail }], replyTo: { email: data.email, name: fullName }, subject, htmlContent }); } // Applicant confirmation (non-fatal). replyTo = the partnerships inbox when set. let applicantDelivery: BrevoSendResult; const applicantReplyTo = adminEmail ? { email: adminEmail } : undefined; - if (userTemplate != null) { - applicantDelivery = await sendBrevoEmail({ - ...partnerSender, - to: [{ email: data.email, name: fullName }], - ...(applicantReplyTo ? { replyTo: applicantReplyTo } : {}), - templateId: userTemplate, params, - }); - } else { + { const { subject, htmlContent } = buildPartnerApplicantEmail(emailInput); applicantDelivery = await sendBrevoEmail({ ...partnerSender, to: [{ email: data.email, name: fullName }], ...(applicantReplyTo ? { replyTo: applicantReplyTo } : {}), - subject, htmlContent, + subject, + htmlContent, }); } diff --git a/apps/cms/src/payload/endpoints/submit-lead.ts b/apps/cms/src/payload/endpoints/submit-lead.ts index a8c9b75d9..6bfc963b6 100644 --- a/apps/cms/src/payload/endpoints/submit-lead.ts +++ b/apps/cms/src/payload/endpoints/submit-lead.ts @@ -9,6 +9,9 @@ import { submitLead } from '../lib/lead-handlers/registry'; import type { LeadSubmission } from '../lib/lead-handlers/types'; import { type FormFieldDef, validateFields } from '../lib/lead-handlers/validate-fields'; import { DEFAULT_RATE_LIMITS, checkAndRecord } from '../lib/rate-limit'; +import { sendBrevoEmail } from '../lib/email/brevo'; +import { buildResourceDownloadEmail } from '../lib/email/lead-emails'; +import { extractEmail, extractName } from '../lib/lead-handlers/extract-fields'; import { signDownloadToken } from '../lib/resources/download-token'; import { buildUnlockCookieHeader } from '../lib/resources/unlock-cookie'; import { verifyTurnstileToken } from '../lib/turnstile'; @@ -458,6 +461,7 @@ export const submitLeadEndpoint: Endpoint = { })) as { id: string | number; slug?: string | null; + title?: string | null; gated?: boolean | null; gateForm?: number | string | { id?: number | string } | null; } | null; @@ -488,6 +492,31 @@ export const submitLeadEndpoint: Endpoint = { secret, secure: process.env.NODE_ENV === 'production', }); + + // Email the link as well as returning it. Response-only + // delivery loses the asset the moment the tab closes, and + // removes the only reason to give a real address — which is + // the entire point of gating it. Sent from here rather than a + // lead handler because the signed token does not exist until + // this point. Best-effort: the visitor already has the link. + const recipient = extractEmail(fieldDefs, data.fields); + if (recipient) { + const publicBase = (process.env.PAYLOAD_PUBLIC_SERVER_URL ?? '').replace(/\/$/u, ''); + const firstName = extractName(fieldDefs, data.fields)?.trim().split(/\s+/u)[0]; + const { subject, htmlContent } = buildResourceDownloadEmail({ + resourceTitle: String(resourceDoc.title ?? 'your resource'), + downloadUrl: `${publicBase}${downloadPayload.url}`, + expiresAt, + ...(firstName ? { firstName } : {}), + }); + const sent = await sendBrevoEmail({ to: [{ email: recipient }], subject, htmlContent }); + if (sent.status === 'failed') { + req.payload.logger.warn( + { email: recipient, error: sent.error }, + 'Resource download email failed — visitor still has the in-page link', + ); + } + } } } } 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/careers/hr-email.test.ts b/apps/cms/src/payload/lib/careers/hr-email.test.ts index cbf898136..29defaa8f 100644 --- a/apps/cms/src/payload/lib/careers/hr-email.test.ts +++ b/apps/cms/src/payload/lib/careers/hr-email.test.ts @@ -27,7 +27,11 @@ describe('buildHrApplicationEmail', () => { it('omits optional rows when absent', () => { const { htmlContent } = buildHrApplicationEmail({ ...base, phone: undefined, linkedinUrl: undefined, coverLetter: undefined }); - expect(htmlContent).not.toContain('LinkedIn'); + // Scoped to the card: "LinkedIn" also appears as the footer social icon's + // alt text, which is present on every email regardless of the details. + const card = htmlContent.slice(htmlContent.indexOf('', htmlContent.indexOf(' { @@ -50,11 +54,11 @@ describe('buildHrApplicationEmail', () => { expect(htmlContent).toContain('Berlin, DE'); expect(htmlContent).toContain('Heard via'); expect(htmlContent).toContain('LinkedIn'); - expect(htmlContent).toContain('Cover letter file attached'); + expect(htmlContent).toContain('cover letter file is attached'); const without = buildHrApplicationEmail(base); expect(without.htmlContent).not.toContain('Based in'); expect(without.htmlContent).not.toContain('Heard via'); - expect(without.htmlContent).not.toContain('Cover letter file attached'); + expect(without.htmlContent).not.toContain('cover letter file is attached'); }); }); diff --git a/apps/cms/src/payload/lib/careers/hr-email.ts b/apps/cms/src/payload/lib/careers/hr-email.ts index 62f47bee9..0128ce727 100644 --- a/apps/cms/src/payload/lib/careers/hr-email.ts +++ b/apps/cms/src/payload/lib/careers/hr-email.ts @@ -1,3 +1,5 @@ +import { type EmailBlock, renderEmail } from '../email/layout'; + export type HrApplicationEmailInput = { jobTitle: string; jobLocation?: string | undefined; @@ -12,43 +14,80 @@ export type HrApplicationEmailInput = { linkedinUrl?: string | undefined; }; -const escapeHtml = (value: string): string => - value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - -const row = (label: string, value: string | undefined): string => - value && value.trim().length > 0 - ? `${label}${escapeHtml(value)}` - : ''; - -/** - * Builds the HR-notification subject + HTML for a new application. Self-contained - * (no Brevo dashboard template). All applicant-supplied values are HTML-escaped. - */ +/** Internal HR notification for a new application. Resume arrives as an attachment. */ export const buildHrApplicationEmail = ( input: HrApplicationEmailInput, ): { subject: string; htmlContent: string } => { const fullName = `${input.firstName} ${input.lastName}`.trim(); - const subject = `New application — ${input.jobTitle} — ${fullName}`; - const htmlContent = ` -

New job application

-

A candidate applied for ${escapeHtml(input.jobTitle)}. Resume attached.

- -${row('Position', input.jobTitle)} -${row('Role location', input.jobLocation)} -${row('Name', fullName)} -${row('Email', input.email)} -${row('Phone', input.phone)} -${row('Based in', input.location)} -${row('Heard via', input.howDidYouHear)} -${row('LinkedIn', input.linkedinUrl)} -
-${input.coverLetter && input.coverLetter.trim().length > 0 ? `

Cover letter

${escapeHtml(input.coverLetter)}

` : ''} -${input.coverLetterAttached ? `

📎 Cover letter file attached.

` : ''} -`; - return { subject, htmlContent }; + const blocks: EmailBlock[] = [ + { + kind: 'paragraph', + text: `${fullName} applied for ${input.jobTitle}. Their resume is attached to this email.`, + }, + { + kind: 'details', + rows: [ + ['Position', input.jobTitle], + ['Role location', input.jobLocation], + ['Name', fullName], + ['Email', input.email], + ['Phone', input.phone], + ['Based in', input.location], + ['Heard via', input.howDidYouHear], + ['LinkedIn', input.linkedinUrl], + ], + }, + ...(input.coverLetter?.trim() + ? [{ kind: 'quote' as const, label: 'Cover letter', text: input.coverLetter }] + : []), + ...(input.coverLetterAttached + ? [{ kind: 'note' as const, text: 'A cover letter file is attached alongside the resume.' }] + : []), + ]; + + return { + subject: `New application: ${input.jobTitle}, ${fullName}`, + htmlContent: renderEmail({ + preheader: `${fullName} applied for ${input.jobTitle}.`, + eyebrow: 'Careers', + heading: 'New job application', + blocks, + footerNote: 'Sent automatically from the CleanStart careers site.', + }), + }; }; + +/** + * Applicant acknowledgement. + * + * Until now an application was answered with silence: 1,482 candidates + * submitted and heard nothing back, with no way to tell a received application + * from a lost one. + */ +export const buildApplicantConfirmationEmail = (input: { + firstName: string; + jobTitle: string; +}): { subject: string; htmlContent: string } => ({ + subject: `We've received your application for ${input.jobTitle}`, + htmlContent: renderEmail({ + preheader: `Your application for ${input.jobTitle} reached our team.`, + eyebrow: 'Careers', + heading: `Thanks for applying, ${input.firstName}`, + blocks: [ + { + kind: 'paragraph', + text: `We've received your application for ${input.jobTitle}, along with your resume.`, + }, + { + kind: 'paragraph', + text: 'Our team reviews every application. If your experience lines up with what the role needs, we will be in touch to arrange a first conversation.', + }, + { + kind: 'note', + text: 'Please do not reply to this message. It is sent from an unmonitored address.', + }, + { kind: 'paragraph', text: 'The CleanStart hiring team', muted: true }, + ], + footerNote: 'You received this because you applied for a role on cleanstart.com.', + }), +}); diff --git a/apps/cms/src/payload/lib/deal-registrations/notification-email.test.ts b/apps/cms/src/payload/lib/deal-registrations/notification-email.test.ts index 8ac0cc58a..3eb2f764c 100644 --- a/apps/cms/src/payload/lib/deal-registrations/notification-email.test.ts +++ b/apps/cms/src/payload/lib/deal-registrations/notification-email.test.ts @@ -15,7 +15,7 @@ const base: DealRegistrationNotificationInput = { describe('buildDealRegistrationNotificationEmail', () => { it('builds the deal-name subject as {prospect} — {partner}', () => { expect(buildDealRegistrationNotificationEmail(base).subject).toBe( - 'New partner deal registration — Vinya Ramananda — Global Cybersecurity Innovation Pvt Ltd', + 'New partner deal registration: Vinya Ramananda — Global Cybersecurity Innovation Pvt Ltd', ); }); diff --git a/apps/cms/src/payload/lib/deal-registrations/notification-email.ts b/apps/cms/src/payload/lib/deal-registrations/notification-email.ts index a95b9306e..847ecdec9 100644 --- a/apps/cms/src/payload/lib/deal-registrations/notification-email.ts +++ b/apps/cms/src/payload/lib/deal-registrations/notification-email.ts @@ -1,62 +1,103 @@ +import { type EmailBlock, renderEmail } from '../email/layout'; + export type DealRegistrationNotificationInput = { partnerName: string; partnerRep: { firstName: string; lastName: string; email: string; phone?: string | undefined }; prospect: { firstName: string; lastName: string; email: string; phone?: string | undefined }; dealDetails?: string | undefined; - /** HubSpot deal id when the CRM sync succeeded — renders a link to the record. */ + /** HubSpot deal id when the CRM sync succeeded. Renders a link to the record. */ dealId?: string | undefined; /** HubSpot portal id, used only to build the deal link. */ portalId?: string | undefined; }; -const escapeHtml = (value: string): string => - value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - -const row = (label: string, value: string | undefined): string => - value && value.trim().length > 0 - ? `${label}${escapeHtml(value)}` - : ''; +/** The deal name mirrors `buildDealName` (`{prospect} — {partner}`). */ +const dealNameOf = (input: DealRegistrationNotificationInput): string => + `${`${input.prospect.firstName} ${input.prospect.lastName}`.trim()} — ${input.partnerName}`; -/** - * Builds the internal-notification subject + HTML for a new partner deal - * registration. Self-contained (no Brevo dashboard template) so the exact same - * markup can be reused for every recipient. All submitter-supplied values are - * HTML-escaped. The deal name mirrors `buildDealName` (`{prospect} — {partner}`). - */ +/** Internal notification for a new partner deal registration. */ export const buildDealRegistrationNotificationEmail = ( input: DealRegistrationNotificationInput, ): { subject: string; htmlContent: string } => { const prospectFull = `${input.prospect.firstName} ${input.prospect.lastName}`.trim(); const repFull = `${input.partnerRep.firstName} ${input.partnerRep.lastName}`.trim(); - const dealName = `${prospectFull} — ${input.partnerName}`; - const subject = `New partner deal registration — ${dealName}`; + const dealName = dealNameOf(input); const dealLink = input.dealId && input.portalId ? `https://app.hubspot.com/contacts/${encodeURIComponent(input.portalId)}/record/0-3/${encodeURIComponent(input.dealId)}` : undefined; - const htmlContent = ` -

New partner deal registration

-

A partner submitted a deal registration through the CleanStart website${dealLink ? ' and it was created as a deal in HubSpot' : ''}. Review the details below and assign an owner to follow up.

- -${row('Deal', dealName)} -${row('Partner', input.partnerName)} -${row('Partner rep', repFull)} -${row('Rep email', input.partnerRep.email)} -${row('Rep phone', input.partnerRep.phone)} -${row('Prospect', prospectFull)} -${row('Prospect email', input.prospect.email)} -${row('Prospect phone', input.prospect.phone)} -
-${input.dealDetails && input.dealDetails.trim().length > 0 ? `

Deal details

${escapeHtml(input.dealDetails)}

` : ''} -${dealLink ? `

Open the deal in HubSpot

` : ''} -`; + const blocks: EmailBlock[] = [ + { + kind: 'paragraph', + text: `A partner registered a deal through the CleanStart website${dealLink ? ' and it was created as a deal in HubSpot' : ''}. Review the details and assign an owner to follow up.`, + }, + { + kind: 'details', + rows: [ + ['Deal', dealName], + ['Partner', input.partnerName], + ['Partner rep', repFull], + ['Rep email', input.partnerRep.email], + ['Rep phone', input.partnerRep.phone], + ['Prospect', prospectFull], + ['Prospect email', input.prospect.email], + ['Prospect phone', input.prospect.phone], + ], + }, + ...(input.dealDetails?.trim() + ? [{ kind: 'quote' as const, label: 'Deal details', text: input.dealDetails }] + : []), + ...(dealLink + ? [{ kind: 'button' as const, label: 'Open the deal in HubSpot', url: dealLink }] + : []), + ]; + + return { + subject: `New partner deal registration: ${dealName}`, + htmlContent: renderEmail({ + preheader: `${repFull} at ${input.partnerName} registered ${prospectFull}.`, + eyebrow: 'Deal registration', + heading: 'New partner deal registration', + blocks, + footerNote: 'Sent automatically from the CleanStart website deal-registration form.', + }), + }; +}; - return { subject, htmlContent }; +/** Confirmation to the partner rep who submitted the registration. */ +export const buildDealRegistrationConfirmationEmail = ( + input: DealRegistrationNotificationInput, +): { subject: string; htmlContent: string } => { + const dealName = dealNameOf(input); + return { + subject: 'We have your deal registration', + htmlContent: renderEmail({ + preheader: `Your registration for ${dealName} was received.`, + eyebrow: 'Deal registration', + heading: `Thanks, ${input.partnerRep.firstName}`, + blocks: [ + { + kind: 'paragraph', + text: `We've received your deal registration for ${dealName}. Our partner team will review it and confirm next steps within one business day.`, + }, + { + kind: 'details', + rows: [ + ['Deal', dealName], + ['Partner', input.partnerName], + ['Prospect', `${input.prospect.firstName} ${input.prospect.lastName}`.trim()], + ], + }, + { + kind: 'paragraph', + text: 'If any of this looks wrong, reply to this email and we will correct it.', + muted: true, + }, + ], + footerNote: + 'You received this because you submitted a deal registration on cleanstart.com.', + }), + }; }; 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/email/layout.test.ts b/apps/cms/src/payload/lib/email/layout.test.ts new file mode 100644 index 000000000..492f53461 --- /dev/null +++ b/apps/cms/src/payload/lib/email/layout.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest'; + +import { escapeHtml, renderEmail } from './layout'; + +const base = { preheader: 'Preview line.', heading: 'Hello', blocks: [] } as const; + +describe('escapeHtml', () => { + it('neutralises markup in visitor-supplied text', () => { + expect(escapeHtml('')).toBe( + '<script>alert("x")</script>', + ); + }); +}); + +describe('renderEmail — client compatibility', () => { + const html = renderEmail({ + preheader: 'Your demo request reached us.', + heading: 'Thanks for getting in touch', + blocks: [ + { kind: 'paragraph', text: 'Body copy.' }, + { kind: 'details', rows: [['Email', 'jane@acme.com'], ['Phone', undefined]] }, + { kind: 'quote', label: 'Message', text: 'We run 300 containers.' }, + { kind: 'button', label: 'Open', url: 'https://www.cleanstart.com/x' }, + { kind: 'note', text: 'Expires in 24 hours.' }, + { kind: 'divider' }, + ], + footerNote: 'Why you got this.', + }); + + it('is a complete document with a light color-scheme lock', () => { + expect(html.startsWith('')).toBe(true); + expect(html).toContain('name="color-scheme" content="light"'); + }); + + it('carries the preheader before any visible content', () => { + expect(html).toContain('Your demo request reached us.'); + // Compared against the

, not the first match: the heading also appears + // in , which legitimately precedes the preheader. + expect(html.indexOf('Your demo request reached us.')).toBeLessThan(html.indexOf('<h1')); + }); + + it('hides the preheader from the opened email', () => { + const block = html.slice(html.indexOf('Your demo request reached us.') - 300, html.indexOf('Your demo request reached us.')); + expect(block).toContain('display:none'); + expect(block).toContain('mso-hide:all'); + }); + + it('lays out with tables, since Outlook supports neither flex nor grid', () => { + expect(html).toContain('role="presentation"'); + expect(html).not.toMatch(/display:\s*flex/u); + expect(html).not.toMatch(/display:\s*grid/u); + }); + + it('names the brand in alt text, so it reads when images are blocked', () => { + expect(html).toContain('alt="CleanStart"'); + expect(html).toContain('logo-email.png'); + }); + + it('carries the postal address CAN-SPAM requires, and no unsubscribe link', () => { + expect(html).toContain('16192 Coastal Highway'); + // Comments stripped first: the markup explains *why* there is no + // unsubscribe, and that explanation is not itself a link. + const rendered = html.replace(/<!--[\s\S]*?-->/gu, ''); + expect(rendered).not.toMatch(/unsubscribe/iu); + }); + + it('forces Arial, matching the templates already live in Brevo', () => { + expect(html).toContain('font-family: Arial,Helvetica,sans-serif !important'); + }); + + it('renders each block kind', () => { + expect(html).toContain('Body copy.'); + expect(html).toContain('jane@acme.com'); + expect(html).toContain('We run 300 containers.'); + expect(html).toContain('Expires in 24 hours.'); + expect(html).toContain('https://www.cleanstart.com/x'); + }); + + it('drops detail rows with no value rather than printing empty labels', () => { + expect(html).not.toContain('Phone'); + }); +}); + +describe('renderEmail — safety', () => { + it('escapes visitor text in every block that carries it', () => { + const html = renderEmail({ + ...base, + heading: '<b>h</b>', + blocks: [ + { kind: 'paragraph', text: '<img src=x onerror=1>' }, + { kind: 'quote', text: '</td><script>bad()</script>' }, + { kind: 'details', rows: [['<b>k</b>', '<b>v</b>']] }, + { kind: 'note', text: '<i>n</i>' }, + ], + }); + expect(html).not.toContain('<script>bad()'); + expect(html).not.toContain('<img src=x'); + expect(html).toContain('<script>bad()'); + }); + + it('refuses a non-http button URL, so a caller cannot emit javascript:', () => { + const html = renderEmail({ + ...base, + blocks: [{ kind: 'button', label: 'Click', url: 'javascript:alert(1)' }], + }); + expect(html).not.toContain('javascript:'); + expect(html).not.toContain('>Click<'); + }); + + it('drops a malformed button URL instead of rendering a broken link', () => { + const html = renderEmail({ + ...base, + blocks: [{ kind: 'button', label: 'Click', url: 'not a url' }], + }); + expect(html).not.toContain('>Click<'); + }); +}); + +describe('renderEmail — Outlook and dark mode', () => { + const html = renderEmail({ + preheader: 'p', + heading: 'h', + blocks: [ + { kind: 'button', label: 'Download', url: 'https://www.cleanstart.com/x' }, + { kind: 'paragraph', text: 'body' }, + ], + }); + + it('pads the button cell, not the anchor', () => { + // Word-based Outlook ignores display:inline-block and is unreliable with + // padding on inline elements, so an anchor-padded button collapses into + // coloured text there. + const cell = /<td class="cs-btn"[^>]*>/u.exec(html)?.[0] ?? ''; + expect(cell).toContain('padding:14px 28px'); + expect(cell).toContain('mso-padding-alt'); + expect(html).not.toMatch(/<a[^>]*display:inline-block[^>]*>Download/u); + }); + + it('sets the button colour as a bgcolor attribute, which Word honours', () => { + expect(html).toMatch(/<td class="cs-btn"[^>]*bgcolor="#3960f9"/u); + }); + + it('fixes the card width with a width attribute, since Word ignores max-width', () => { + expect(html).toMatch(/<table[^>]*width="600"[^>]*class="cs-wrap cs-card"/u); + }); + + it('declares a light colour scheme so Apple Mail does not invert', () => { + expect(html).toContain('color-scheme: light'); + expect(html).toContain('supported-color-schemes: light'); + }); + + it('pins the card, text and button against Outlook.com dark-mode rewriting', () => { + for (const rule of ['[data-ogsc] .cs-heading', '[data-ogsb] .cs-card', '[data-ogsc] .cs-btn a']) { + expect(html).toContain(rule); + } + expect(html).toMatch(/<table[^>]*class="cs-wrap cs-card"[^>]*bgcolor="#ffffff"/u); + }); + + it('uses the plated logo, which stays legible when a client inverts the card', () => { + expect(html).toContain('web/emails/logo-email.png'); + expect(html).not.toContain('social-icons/cleanstart-logo.png'); + }); +}); diff --git a/apps/cms/src/payload/lib/email/layout.ts b/apps/cms/src/payload/lib/email/layout.ts new file mode 100644 index 000000000..d018e8094 --- /dev/null +++ b/apps/cms/src/payload/lib/email/layout.ts @@ -0,0 +1,268 @@ +/** + * The one HTML shell every CleanStart email renders into. + * + * Before this, each builder hand-wrote its own `<body style="font-family:Arial">`, + * so the four emails the site sends looked like four different companies. A + * caller now describes *content* as blocks and this owns *presentation*. + * + * Email HTML is not web HTML, and the constraints below are deliberate: + * + * - **Tables, not flex or grid.** Outlook on Windows renders through Word, + * which supports neither, plus no `float` and no `max-width` on divs. + * - **Inline styles.** Gmail strips `<style>` blocks in several contexts, so + * the only rules in `<head>` are media queries, which are a progressive + * enhancement rather than load-bearing. + * - **A preheader.** The grey preview line after the subject in most inboxes. + * Left unset, clients scrape the first body text, which is usually the logo + * alt text. It measurably moves open rates, so every email sets one. + * - **Wordmark as text next to the logo.** Most clients block images by + * default on first receipt; the brand still reads when the image does not + * load, and the logo carries alt text for the same reason. + * - **Buttons are table cells, not styled anchors.** A padded, background + * coloured `<td>` is the only construct that renders as a button in every + * major client. Outlook squares off the corners, which is expected. + */ + +/** + * Taken from the three templates already live in Brevo, so code-built mail and + * dashboard-built mail are the same design rather than two houses. Slate scale + * plus the brand blue. + */ +const BRAND = { + blue: '#3960f9', + tint: '#eef2ff', + heading: '#0f172a', + text: '#334155', + muted: '#64748b', + faint: '#94a3b8', + border: '#e2e8f0', + canvas: '#f1f5f9', + panel: '#f8fafc', + white: '#ffffff', +} as const; + +/** + * Arial, not a system-ui stack: the live templates force it, and it is the one + * family that renders identically across Outlook, Gmail and Apple Mail. + */ +const FONT = 'Arial,Helvetica,sans-serif'; +/** + * The wordmark on an opaque white plate (see scripts/upload-email-logo.ts). + * The bare `emails/social-icons/cleanstart-logo.png` is a dark mark on a 73% + * transparent background, so it disappears in the clients that invert the card + * in dark mode. Plated, it backs itself. + */ +const LOGO_URL = 'https://cdn.cleanstart.com/web/emails/logo-email.png'; +const SITE_URL = 'https://www.cleanstart.com'; + +const SOCIAL: ReadonlyArray<{ label: string; href: string; icon: string }> = [ + { + label: 'LinkedIn', + href: 'https://www.linkedin.com/company/cleanstart-official/', + icon: 'https://cdn.cleanstart.com/emails/social-icons/icon-linkedin.png', + }, + { + label: 'GitHub', + href: 'https://github.com/cleanstart-dev', + icon: 'https://cdn.cleanstart.com/emails/social-icons/icon-github.png', + }, +]; + +/** Postal address in the footer is a CAN-SPAM requirement for commercial mail. */ +const COMPANY_ADDRESS = 'CleanStart Inc. \u00b7 16192 Coastal Highway, Lewes, Delaware 19958, US'; + +export const escapeHtml = (value: string): string => + value + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + +/** Only http(s) survives, so a caller can never emit a `javascript:` href. */ +const safeUrl = (url: string): string | null => { + try { + const parsed = new URL(url); + return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed.toString() : null; + } catch { + return null; + } +}; + +export type EmailBlock = + /** Body copy. `muted` drops it to secondary grey for sign-offs. */ + | { kind: 'paragraph'; text: string; muted?: boolean } + /** Label/value pairs for a submission summary. Blank values are dropped. */ + | { kind: 'details'; rows: ReadonlyArray<readonly [string, string | undefined | null]> } + /** Verbatim visitor input, kept visually distinct from our own words. */ + | { kind: 'quote'; label?: string; text: string } + /** The single primary action. */ + | { kind: 'button'; label: string; url: string } + /** Tinted aside for a caveat, e.g. a link expiry. */ + | { kind: 'note'; text: string } + | { kind: 'divider' }; + +const paragraph = (text: string, muted = false): string => + `<p class="${muted ? 'cs-muted' : 'cs-text'}" style="margin:0 0 16px;font-family:${FONT};font-size:16px;line-height:1.6;color:${muted ? BRAND.muted : BRAND.text};">${escapeHtml(text)}</p>`; + +const details = (rows: ReadonlyArray<readonly [string, string | undefined | null]>): string => { + const cells = rows + .filter(([, value]) => typeof value === 'string' && value.trim().length > 0) + .map( + ([label, value]) => + `<tr> + <td style="padding:8px 16px 8px 0;font-family:${FONT};font-size:14px;line-height:1.5;color:${BRAND.muted};white-space:nowrap;vertical-align:top;">${escapeHtml(label)}</td> + <td style="padding:8px 0;font-family:${FONT};font-size:15px;line-height:1.5;color:${BRAND.text};vertical-align:top;">${escapeHtml(String(value))}</td> + </tr>`, + ) + .join(''); + if (cells.length === 0) return ''; + return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;margin:0 0 20px;width:100%;">${cells}</table>`; +}; + +const quote = (text: string, label?: string): string => + `${label ? `<p style="margin:0 0 6px;font-family:${FONT};font-size:13px;font-weight:600;letter-spacing:0.02em;text-transform:uppercase;color:${BRAND.muted};">${escapeHtml(label)}</p>` : ''} + <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 20px;"> + <tr><td style="border-left:3px solid ${BRAND.blue};padding:4px 0 4px 14px;font-family:${FONT};font-size:15px;line-height:1.6;color:${BRAND.text};white-space:pre-wrap;">${escapeHtml(text)}</td></tr> + </table>`; + +const button = (label: string, url: string): string => { + const href = safeUrl(url); + if (!href) return ''; + // Padding sits on the cell, not the anchor: Word-based Outlook ignores + // display:inline-block and handles padding on inline elements unreliably, so + // an anchor-padded button collapses there into coloured text. + return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:4px 0 24px;border-collapse:separate;"> + <tr><td class="cs-btn" align="center" bgcolor="${BRAND.blue}" style="padding:14px 28px;border-radius:8px;mso-padding-alt:14px 28px;"> + <a href="${escapeHtml(href)}" target="_blank" rel="noopener" style="font-family:${FONT};font-size:16px;font-weight:bold;line-height:1;color:${BRAND.white};text-decoration:none;display:block;">${escapeHtml(label)}</a> + </td></tr> + </table>`; +}; + +const note = (text: string): string => + `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 20px;"> + <tr><td bgcolor="${BRAND.canvas}" style="padding:14px 16px;border-radius:8px;font-family:${FONT};font-size:14px;line-height:1.5;color:${BRAND.muted};">${escapeHtml(text)}</td></tr> + </table>`; + +const divider = (): string => + `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 24px;"><tr><td style="border-top:1px solid ${BRAND.border};font-size:0;line-height:0;"> </td></tr></table>`; + +const renderBlock = (block: EmailBlock): string => { + switch (block.kind) { + case 'paragraph': + return paragraph(block.text, block.muted ?? false); + case 'details': + return details(block.rows); + case 'quote': + return quote(block.text, block.label); + case 'button': + return button(block.label, block.url); + case 'note': + return note(block.text); + case 'divider': + return divider(); + } +}; + +export interface RenderEmailOptions { + /** Inbox preview line. Write it as a real sentence: it is read before the body. */ + preheader: string; + /** Small category label above the heading, e.g. "Partnerships", "Careers". */ + eyebrow?: string; + heading: string; + blocks: readonly EmailBlock[]; + /** Closing line above the address, e.g. why this email was received. */ + footerNote?: string; +} + +export const renderEmail = ({ + preheader, + eyebrow, + heading, + blocks, + footerNote, +}: RenderEmailOptions): string => { + const body = blocks.map(renderBlock).join('\n'); + const social = SOCIAL.map( + (item) => + `<td style="padding:0 8px;"><a href="${item.href}" target="_blank" rel="noopener" style="display:inline-block;text-decoration:none;"><img src="${item.icon}" width="24" height="24" alt="${item.label}" style="display:block;border:0;outline:none;text-decoration:none;width:24px;height:24px;-ms-interpolation-mode:bicubic;"></a></td>`, + ).join(''); + + return `<!doctype html> +<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width,initial-scale=1"> +<meta name="x-apple-disable-message-reformatting"> +<meta name="color-scheme" content="light"> +<meta name="supported-color-schemes" content="light"> +<title>${escapeHtml(heading)} + + + + + +
${escapeHtml(preheader)}͏‌ ͏‌ ͏‌ ͏‌ ͏‌ 
+ + + +
+ + + + + + + + + + + + + + +
+ +`; +}; diff --git a/apps/cms/src/payload/lib/email/lead-emails.ts b/apps/cms/src/payload/lib/email/lead-emails.ts new file mode 100644 index 000000000..311d6fbd3 --- /dev/null +++ b/apps/cms/src/payload/lib/email/lead-emails.ts @@ -0,0 +1,122 @@ +import { type EmailBlock, renderEmail } from './layout'; + +/** + * Visitor-facing confirmations for the forms that post to `/api/leads/submit`. + * + * These are transactional, not marketing, and are sent over Brevo rather than + * as a HubSpot form follow-up on purpose: a HubSpot follow-up is a marketing + * send gated on subscription status, so a visitor who declines marketing + * consent would receive no acknowledgement of a demo request they just made. + */ + +export type ConfirmationInput = { + /** Visitor's first name when the form collected one. */ + firstName?: string | undefined; +}; + +const greeting = (firstName?: string): string => + firstName?.trim() ? `Thanks, ${firstName.trim()}` : 'Thanks for getting in touch'; + +const signOff: EmailBlock = { kind: 'paragraph', text: 'The CleanStart team', muted: true }; + +export const buildDemoConfirmationEmail = ( + input: ConfirmationInput, +): { subject: string; htmlContent: string } => ({ + subject: 'Your CleanStart demo request', + htmlContent: renderEmail({ + preheader: 'We have your demo request. A specialist will reach out within one business day.', + eyebrow: 'Demo request', + heading: greeting(input.firstName), + blocks: [ + { + kind: 'paragraph', + text: "We've received your demo request. One of our solutions specialists will reach out within one business day to arrange a time that suits you.", + }, + { + kind: 'paragraph', + text: 'It helps to know what you would like to see: your current base images, the CVE burden you are carrying, or the audit you are preparing for. Reply to this email with anything you want covered.', + }, + signOff, + ], + footerNote: 'You received this because you requested a demo on cleanstart.com.', + }), +}); + +export const buildContactConfirmationEmail = ( + input: ConfirmationInput, +): { subject: string; htmlContent: string } => ({ + subject: "We've received your message", + htmlContent: renderEmail({ + preheader: 'Your message reached the CleanStart team. We reply within one business day.', + eyebrow: 'Contact', + heading: greeting(input.firstName), + blocks: [ + { + kind: 'paragraph', + text: "We've received your message and someone from the team will reply within one business day.", + }, + { kind: 'paragraph', text: 'If it is urgent, reply to this email and it will reach us directly.' }, + signOff, + ], + footerNote: 'You received this because you contacted us through cleanstart.com.', + }), +}); + +export const buildNewsletterWelcomeEmail = ( + input: ConfirmationInput, +): { subject: string; htmlContent: string } => ({ + subject: "You're subscribed to CleanStart", + htmlContent: renderEmail({ + preheader: 'Container security research and release notes, roughly monthly.', + eyebrow: 'Newsletter', + heading: input.firstName?.trim() ? `Welcome, ${input.firstName.trim()}` : 'Welcome to CleanStart', + blocks: [ + { + kind: 'paragraph', + text: "You're on the list. We send container-security research, hardened-image release notes and the occasional deep dive, roughly monthly.", + }, + { kind: 'paragraph', text: 'No noise, and you can unsubscribe from any email we send.' }, + { kind: 'button', label: 'Browse the latest research', url: 'https://www.cleanstart.com/blogs' }, + ], + footerNote: 'You received this because you subscribed on cleanstart.com.', + }), +}); + +export type ResourceDownloadInput = ConfirmationInput & { + resourceTitle: string; + /** Absolute, already-signed download URL. */ + downloadUrl: string; + /** Epoch millis the signed link stops working. */ + expiresAt: number; +}; + +/** + * Emails the gated-resource link. + * + * The link was previously returned only in the HTTP response, so closing the + * tab lost the asset with no way to recover it, and it removed the only + * reason for a visitor to give a real address, which is the point of gating. + */ +export const buildResourceDownloadEmail = ( + input: ResourceDownloadInput, +): { subject: string; htmlContent: string } => { + const hours = Math.max(1, Math.round((input.expiresAt - Date.now()) / 3_600_000)); + return { + subject: `Your download: ${input.resourceTitle}`, + htmlContent: renderEmail({ + preheader: `${input.resourceTitle} is ready to download.`, + eyebrow: 'Your download', + heading: greeting(input.firstName), + blocks: [ + { kind: 'paragraph', text: `Here is your copy of ${input.resourceTitle}.` }, + { kind: 'button', label: 'Download it now', url: input.downloadUrl }, + { + kind: 'note', + text: `This link is unique to you and stops working in about ${hours} ${hours === 1 ? 'hour' : 'hours'}. Request the resource again if it expires.`, + }, + signOff, + ], + footerNote: 'You received this because you requested a resource on cleanstart.com.', + }), + }; +}; diff --git a/apps/cms/src/payload/lib/email/payload-adapter.ts b/apps/cms/src/payload/lib/email/payload-adapter.ts new file mode 100644 index 000000000..cf615e8d5 --- /dev/null +++ b/apps/cms/src/payload/lib/email/payload-adapter.ts @@ -0,0 +1,89 @@ +import type { EmailAdapter, SendEmailOptions } from 'payload'; + +import { type BrevoRecipient, type BrevoSendResult, sendBrevoEmail } from './brevo'; + +/** + * Payload email adapter backed by the Brevo transactional API. + * + * Without an adapter Payload logs outgoing mail to stdout instead of sending + * it, which silently broke admin password resets: the reset link went to the + * container's console and the editor received nothing. This routes Payload's + * own mail (password reset, and any future verification or invite) through the + * same Brevo credentials the public forms already use, so there is no second + * transport and no second secret to manage. + * + * Payload types `SendEmailOptions` as nodemailer's shape, so addresses arrive + * in any of nodemailer's forms and are normalised here. + */ + +type NodemailerAddress = SendEmailOptions['to']; + +/** Turns nodemailer's several address shapes into Brevo's `{ email, name }`. */ +const toRecipients = (value: NodemailerAddress): BrevoRecipient[] => { + if (value == null) return []; + const items = Array.isArray(value) ? value : [value]; + const out: BrevoRecipient[] = []; + for (const item of items) { + if (typeof item === 'string') { + // Accepts both "jane@x.com" and "Jane ". + const match = /^\s*(.*?)\s*<([^>]+)>\s*$/u.exec(item); + if (match?.[2]) { + const name = match[1]?.replace(/^"|"$/gu, '').trim(); + out.push(name ? { email: match[2].trim(), name } : { email: match[2].trim() }); + continue; + } + out.push({ email: item.trim() }); + continue; + } + if (item && typeof item === 'object' && 'address' in item && item.address) { + out.push(item.name ? { email: item.address, name: item.name } : { email: item.address }); + } + } + return out; +}; + +const asString = (value: unknown): string | undefined => + typeof value === 'string' && value.trim().length > 0 ? value : undefined; + +export const brevoEmailAdapter: EmailAdapter = ({ payload }) => ({ + name: 'brevo', + defaultFromAddress: process.env.BREVO_SENDER_EMAIL ?? 'no-reply@cleanstart.com', + defaultFromName: process.env.BREVO_SENDER_NAME ?? 'CleanStart', + async sendEmail(message: SendEmailOptions): Promise { + const to = toRecipients(message.to); + if (to.length === 0) { + return { status: 'skipped', reason: 'no-recipient' }; + } + + // Payload sends `html` for its own templates and `text` for plain bodies; + // Brevo needs HTML, so a text-only message is wrapped rather than dropped. + const html = asString(message.html); + const text = asString(message.text); + const htmlContent = html ?? (text ? `
${text}
` : undefined); + if (!htmlContent) { + return { status: 'skipped', reason: 'no-body' }; + } + + const from = toRecipients(message.from)[0]; + const replyTo = toRecipients(message.replyTo)[0]; + + const result = await sendBrevoEmail({ + to, + subject: asString(message.subject) ?? '(no subject)', + htmlContent, + ...(from?.email ? { senderEmail: from.email } : {}), + ...(from?.name ? { senderName: from.name } : {}), + ...(replyTo ? { replyTo } : {}), + }); + + // Payload swallows the return value, so a failure would otherwise vanish — + // and a lost password-reset mail is exactly the failure that must be loud. + if (result.status !== 'synced') { + payload.logger.error( + { to: to.map((r) => r.email), subject: message.subject, result }, + 'Payload email send did not complete', + ); + } + return result; + }, +}); diff --git a/apps/cms/src/payload/lib/email/registry.test.ts b/apps/cms/src/payload/lib/email/registry.test.ts new file mode 100644 index 000000000..315c06ac8 --- /dev/null +++ b/apps/cms/src/payload/lib/email/registry.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { EMAIL_TEMPLATES } from './registry'; + +describe('EMAIL_TEMPLATES', () => { + it('has a unique key per template', () => { + const keys = EMAIL_TEMPLATES.map((t) => t.key); + expect(new Set(keys).size).toBe(keys.length); + }); + + it.each(EMAIL_TEMPLATES.map((t) => [t.key, t] as const))('%s renders', (_key, entry) => { + const { subject, htmlContent } = entry.sample(); + expect(subject.trim().length).toBeGreaterThan(0); + expect(htmlContent.trim().length).toBeGreaterThan(0); + }); + + it('renders every site email through the shared layout', () => { + // Payload builds its own password-reset body; everything else must come + // from layout.ts so one design change reaches all of them. + for (const entry of EMAIL_TEMPLATES) { + if (entry.key === 'payload-password-reset') continue; + const { htmlContent } = entry.sample(); + expect(htmlContent, entry.key).toContain('logo-email.png'); + expect(htmlContent, entry.key).toContain('16192 Coastal Highway'); + } + }); + + it('carries no em-dashes in subjects, except the HubSpot-synced deal name', () => { + for (const entry of EMAIL_TEMPLATES) { + const { subject } = entry.sample(); + if (entry.key.startsWith('deal-registration')) continue; + expect(subject, entry.key).not.toMatch(/[–—]/u); + } + }); + + it('covers every form that sends mail', () => { + const forms = new Set(EMAIL_TEMPLATES.map((t) => t.form)); + for (const expected of [ + 'book-a-demo', + 'contact', + 'newsletter', + 'become-a-partner', + 'deal-registration', + 'job application', + ]) { + expect([...forms].some((f) => f.includes(expected)), expected).toBe(true); + } + }); +}); diff --git a/apps/cms/src/payload/lib/email/registry.ts b/apps/cms/src/payload/lib/email/registry.ts new file mode 100644 index 000000000..8e06b63d8 --- /dev/null +++ b/apps/cms/src/payload/lib/email/registry.ts @@ -0,0 +1,177 @@ +import { buildApplicantConfirmationEmail, buildHrApplicationEmail } from '../careers/hr-email'; +import { + buildDealRegistrationConfirmationEmail, + buildDealRegistrationNotificationEmail, +} from '../deal-registrations/notification-email'; +import { buildPartnerAdminEmail, buildPartnerApplicantEmail } from '../partners/partner-emails'; +import { + buildContactConfirmationEmail, + buildDemoConfirmationEmail, + buildNewsletterWelcomeEmail, + buildResourceDownloadEmail, +} from './lead-emails'; + +/** + * Every email the site sends, in one place. + * + * This is documentation that cannot go stale: `render-email-templates.ts` + * writes `apps/cms/emails/` from it, and `registry.test.ts` asserts each entry + * still renders, so a template that loses its sender or its builder fails the + * build rather than quietly stopping. + * + * All of them are built in code and sent through `sendBrevoEmail` as + * `subject` + `htmlContent`. None uses a Brevo dashboard template: those + * interpolate `{{ params.* }}` without escaping, so visitor input reached + * inboxes as live markup, and having two design sources meant production sent + * two different-looking sets of email. + */ + +export type EmailAudience = 'visitor' | 'internal'; + +export interface EmailTemplateEntry { + /** Stable key. Also the filename under apps/cms/emails/. */ + key: string; + /** Which form triggers it. */ + form: string; + audience: EmailAudience; + /** Where the send lives, so the mapping can be followed back to code. */ + sentFrom: string; + /** Renders the template with representative sample values. */ + sample: () => { subject: string; htmlContent: string }; +} + +const PARTNER_SAMPLE = { + firstName: 'Marcus', + lastName: 'Reed', + email: 'marcus@northgate.io', + phone: '+442071838750', + company: 'Northgate Security', + website: 'https://northgate.io', + partnerReason: 'We resell container security into regulated finance across EMEA.', +}; + +const DEAL_SAMPLE = { + partnerName: 'Northgate Security', + partnerRep: { + firstName: 'Marcus', + lastName: 'Reed', + email: 'marcus@northgate.io', + phone: '+442071838750', + }, + prospect: { + firstName: 'Elena', + lastName: 'Vasquez', + email: 'elena@meridianbank.com', + phone: '+14155552671', + }, + dealDetails: '500-node OpenShift estate, decision expected next quarter.', +}; + +export const EMAIL_TEMPLATES: readonly EmailTemplateEntry[] = [ + { + key: 'demo-confirmation', + form: 'book-a-demo', + audience: 'visitor', + sentFrom: 'lead-handlers/confirmation.ts', + sample: () => buildDemoConfirmationEmail({ firstName: 'Priya' }), + }, + { + key: 'contact-confirmation', + form: 'contact', + audience: 'visitor', + sentFrom: 'lead-handlers/confirmation.ts', + sample: () => buildContactConfirmationEmail({ firstName: 'Priya' }), + }, + { + key: 'newsletter-welcome', + form: 'newsletter', + audience: 'visitor', + sentFrom: 'lead-handlers/confirmation.ts', + sample: () => buildNewsletterWelcomeEmail({ firstName: 'Priya' }), + }, + { + key: 'resource-download', + form: 'resource-capture (gated resources)', + audience: 'visitor', + sentFrom: 'endpoints/submit-lead.ts', + sample: () => + buildResourceDownloadEmail({ + firstName: 'Priya', + resourceTitle: 'The 2026 Container Hardening Playbook', + downloadUrl: 'https://cms.cleanstart.com/api/resources/example/download?token=sample', + expiresAt: Date.now() + 24 * 3_600_000, + }), + }, + { + key: 'careers-applicant-confirmation', + form: 'job application', + audience: 'visitor', + sentFrom: 'endpoints/careers-apply.ts', + sample: () => + buildApplicantConfirmationEmail({ firstName: 'Priya', jobTitle: 'Senior Platform Engineer' }), + }, + { + key: 'careers-hr-notification', + form: 'job application', + audience: 'internal', + sentFrom: 'endpoints/careers-apply.ts', + sample: () => + buildHrApplicationEmail({ + jobTitle: 'Senior Platform Engineer', + jobLocation: 'Bengaluru, India', + firstName: 'Priya', + lastName: 'Nair', + email: 'priya.nair@example.com', + phone: '+919876543210', + location: 'Bengaluru, India', + howDidYouHear: 'Referral', + linkedinUrl: 'https://linkedin.com/in/priyanair', + coverLetter: 'Six years hardening container images for regulated fintech workloads.', + coverLetterAttached: true, + }), + }, + { + key: 'partner-confirmation', + form: 'become-a-partner', + audience: 'visitor', + sentFrom: 'endpoints/partner-apply.ts', + sample: () => buildPartnerApplicantEmail(PARTNER_SAMPLE), + }, + { + key: 'partner-notification', + form: 'become-a-partner', + audience: 'internal', + sentFrom: 'endpoints/partner-apply.ts', + sample: () => buildPartnerAdminEmail(PARTNER_SAMPLE), + }, + { + key: 'deal-registration-confirmation', + form: 'deal-registration', + audience: 'visitor', + sentFrom: 'endpoints/deal-registration-apply.ts', + sample: () => buildDealRegistrationConfirmationEmail(DEAL_SAMPLE), + }, + { + key: 'deal-registration-notification', + form: 'deal-registration', + audience: 'internal', + sentFrom: 'endpoints/deal-registration-apply.ts', + sample: () => + buildDealRegistrationNotificationEmail({ + ...DEAL_SAMPLE, + dealId: '12345', + portalId: '245478611', + }), + }, + { + key: 'payload-password-reset', + form: 'CMS admin (Payload)', + audience: 'internal', + sentFrom: 'lib/email/payload-adapter.ts', + sample: () => ({ + subject: 'Reset your password', + htmlContent: + 'Rendered by Payload, delivered through the Brevo adapter. Not built from the shared layout.', + }), + }, +]; 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/confirmation.test.ts b/apps/cms/src/payload/lib/lead-handlers/confirmation.test.ts new file mode 100644 index 000000000..e138a45ee --- /dev/null +++ b/apps/cms/src/payload/lib/lead-handlers/confirmation.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { sendBrevoEmail } from '../email/brevo'; +import { confirmationHandler } from './confirmation'; +import type { LeadSubmission } from './types'; + +vi.mock('../email/brevo', () => ({ + sendBrevoEmail: vi.fn(async () => ({ status: 'synced' as const })), +})); + +const submission = (fields: Record = {}): LeadSubmission => ({ + formId: 7, + formSchemaVersion: 1, + fields: { email: 'jane@acme.com', firstname: 'Jane', ...fields }, + source: 'https://www.cleanstart.com/book-a-demo', + utm: undefined, + attribution: undefined, + ip: '1.2.3.4', + userAgent: 'curl', + consent: undefined, +}); + +const ctx = (slug: string | null, overrides: Record = {}) => + ({ + payload: { findByID: vi.fn(async () => (slug ? { id: 7, slug } : null)) }, + primarySucceeded: true, + leadId: 7, + duplicateOfLeadId: undefined, + formFieldDefs: [ + { name: 'email', type: 'email' }, + { name: 'firstname', type: 'text' }, + ], + ...overrides, + }) as unknown as Parameters[1]; + +beforeEach(() => vi.mocked(sendBrevoEmail).mockClear()); +afterEach(() => vi.mocked(sendBrevoEmail).mockResolvedValue({ status: 'synced' })); + +describe('confirmationHandler', () => { + it.each([ + ['book-a-demo', 'Your CleanStart demo request'], + ['contact', "We've received your message"], + ['newsletter', "You're subscribed to CleanStart"], + ])('sends the %s confirmation to the visitor', async (slug, subject) => { + const result = await confirmationHandler.run(submission(), ctx(slug)); + expect(result).toMatchObject({ status: 'synced' }); + expect(sendBrevoEmail).toHaveBeenCalledWith( + expect.objectContaining({ to: [{ email: 'jane@acme.com' }], subject }), + ); + }); + + it('greets by first name when the form collected one', async () => { + await confirmationHandler.run(submission(), ctx('book-a-demo')); + const html = vi.mocked(sendBrevoEmail).mock.calls[0]?.[0]?.htmlContent ?? ''; + expect(html).toContain('Thanks, Jane'); + }); + + it('falls back to a neutral greeting with no name', async () => { + const defs = [{ name: 'email', type: 'email' }]; + await confirmationHandler.run( + { ...submission(), fields: { email: 'jane@acme.com' } }, + ctx('book-a-demo', { formFieldDefs: defs }), + ); + const html = vi.mocked(sendBrevoEmail).mock.calls[0]?.[0]?.htmlContent ?? ''; + expect(html).toContain('Thanks for getting in touch'); + }); + + it('sends nothing for a form with no template, so a new form cannot start emailing by accident', async () => { + const result = await confirmationHandler.run(submission(), ctx('resource-capture')); + expect(result).toMatchObject({ status: 'skipped' }); + expect(sendBrevoEmail).not.toHaveBeenCalled(); + }); + + it('does not acknowledge a duplicate twice', async () => { + const result = await confirmationHandler.run( + submission(), + ctx('book-a-demo', { duplicateOfLeadId: 3 }), + ); + expect(result).toMatchObject({ status: 'skipped', reason: 'duplicate-submission' }); + expect(sendBrevoEmail).not.toHaveBeenCalled(); + }); + + it('skips when the submission carries no email', async () => { + const result = await confirmationHandler.run( + { ...submission(), fields: { firstname: 'Jane' } }, + ctx('book-a-demo'), + ); + expect(result).toMatchObject({ status: 'skipped', reason: 'no-email-field' }); + }); + + it('reports a send failure rather than swallowing it', async () => { + vi.mocked(sendBrevoEmail).mockResolvedValueOnce({ status: 'failed', error: 'brevo 500' }); + const result = await confirmationHandler.run(submission(), ctx('book-a-demo')); + expect(result).toMatchObject({ status: 'failed', error: 'brevo 500' }); + }); + + it('survives a form lookup failure without throwing into the chain', async () => { + const broken = ctx('book-a-demo', { + payload: { findByID: vi.fn(async () => { throw new Error('db down'); }) }, + }); + const result = await confirmationHandler.run(submission(), broken); + expect(result).toMatchObject({ status: 'skipped', reason: 'form-lookup-failed' }); + }); +}); diff --git a/apps/cms/src/payload/lib/lead-handlers/confirmation.ts b/apps/cms/src/payload/lib/lead-handlers/confirmation.ts new file mode 100644 index 000000000..d5f71c565 --- /dev/null +++ b/apps/cms/src/payload/lib/lead-handlers/confirmation.ts @@ -0,0 +1,87 @@ +import { sendBrevoEmail } from '../email/brevo'; +import { + buildContactConfirmationEmail, + buildDemoConfirmationEmail, + buildNewsletterWelcomeEmail, +} from '../email/lead-emails'; +import { extractEmail, extractName } from './extract-fields'; +import type { LeadHandler, LeadHandlerResult, LeadSubmission } from './types'; + +/** + * Visitor acknowledgement for the forms that post to `/api/leads/submit`. + * + * Every one of these forms previously answered a submission with silence: the + * lead was stored and relayed to the CRM, but the person who filled it in + * received nothing at all. + * + * Keyed by form slug rather than by field shape, so a form only ever gets a + * confirmation someone deliberately wrote for it. A slug with no entry is + * skipped, which is why adding a form cannot accidentally start emailing + * visitors. + * + * Gated-resource downloads are not here: the signed link is minted after the + * handler chain has run, so that email is sent by the endpoint itself. + */ +const BUILDERS = { + 'book-a-demo': buildDemoConfirmationEmail, + contact: buildContactConfirmationEmail, + newsletter: buildNewsletterWelcomeEmail, +} as const; + +type KnownSlug = keyof typeof BUILDERS; + +const isKnownSlug = (slug: string | null | undefined): slug is KnownSlug => + typeof slug === 'string' && slug in BUILDERS; + +/** First word of the collected name, which is what the greeting wants. */ +const firstNameOf = (name: string | null): string | undefined => { + const first = name?.trim().split(/\s+/u)[0]; + return first && first.length > 0 ? first : undefined; +}; + +export const confirmationHandler: LeadHandler = { + name: 'confirmation-email', + kind: 'secondary', + async run(submission: LeadSubmission, ctx): Promise { + // A duplicate submission is the same person pressing submit twice; one + // acknowledgement is enough. + if (ctx.duplicateOfLeadId != null) { + return { handler: 'confirmation-email', status: 'skipped', reason: 'duplicate-submission' }; + } + + let slug: string | null = null; + try { + const form = (await ctx.payload.findByID({ + collection: 'forms', + id: submission.formId, + depth: 0, + overrideAccess: true, + })) as { slug?: string | null } | null; + slug = form?.slug ?? null; + } catch { + return { handler: 'confirmation-email', status: 'skipped', reason: 'form-lookup-failed' }; + } + + if (!isKnownSlug(slug)) { + return { handler: 'confirmation-email', status: 'skipped', reason: `no-template:${slug}` }; + } + + const email = extractEmail(ctx.formFieldDefs, submission.fields); + if (!email) { + return { handler: 'confirmation-email', status: 'skipped', reason: 'no-email-field' }; + } + + const { subject, htmlContent } = BUILDERS[slug]({ + firstName: firstNameOf(extractName(ctx.formFieldDefs, submission.fields)), + }); + + const result = await sendBrevoEmail({ to: [{ email }], subject, htmlContent }); + if (result.status === 'failed') { + return { handler: 'confirmation-email', status: 'failed', error: result.error }; + } + if (result.status === 'skipped') { + return { handler: 'confirmation-email', status: 'skipped', reason: result.reason }; + } + return { handler: 'confirmation-email', status: 'synced' }; + }, +}; 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/index.ts b/apps/cms/src/payload/lib/lead-handlers/index.ts index a61c1f5c7..11098f162 100644 --- a/apps/cms/src/payload/lib/lead-handlers/index.ts +++ b/apps/cms/src/payload/lib/lead-handlers/index.ts @@ -1,4 +1,5 @@ import { companyFromDomainHandler } from './company-from-domain'; +import { confirmationHandler } from './confirmation'; import { hubspotHandler } from './hubspot'; import { registerSecondaryHandler } from './registry'; @@ -6,11 +7,14 @@ import { registerSecondaryHandler } from './registry'; // - company-from-domain (free enrichment, no env gate) // - hubspot (primary CRM — Phase J3; reads DB-backed // integration row, no env gate) +// - confirmation-email (visitor acknowledgement over Brevo) // -// Email is owned entirely by HubSpot (form follow-up + internal -// notifications). Brevo was removed once HubSpot became the single -// email channel. Slack/Discord/Teams connect from the Integrations -// admin surface, not env vars. +// The visitor acknowledgement is ours rather than a HubSpot form follow-up. +// A follow-up is a marketing send gated on subscription status, so anyone +// declining marketing consent would get no acknowledgement of a demo request +// they just made. These are transactional and must not depend on that. +// Internal notification remains HubSpot's. Slack/Discord/Teams connect from +// the Integrations admin surface, not env vars. let registered = false; @@ -24,9 +28,10 @@ export const registerLeadHandlers = (): void => { registered = true; registerSecondaryHandler(companyFromDomainHandler); registerSecondaryHandler(hubspotHandler); + registerSecondaryHandler(confirmationHandler); }; -export { companyFromDomainHandler, hubspotHandler }; +export { companyFromDomainHandler, confirmationHandler, hubspotHandler }; export { hubspotGdprDeleteByEmail } from './hubspot'; export { registerSecondaryHandler, 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-emails.ts b/apps/cms/src/payload/lib/partners/partner-emails.ts index f74fb3225..8dc45f28b 100644 --- a/apps/cms/src/payload/lib/partners/partner-emails.ts +++ b/apps/cms/src/payload/lib/partners/partner-emails.ts @@ -1,3 +1,5 @@ +import { type EmailBlock, renderEmail } from '../email/layout'; + export type PartnerEmailInput = { firstName: string; lastName: string; @@ -8,49 +10,60 @@ export type PartnerEmailInput = { partnerReason?: string | undefined; }; -const escapeHtml = (value: string): string => - value - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - -const row = (label: string, value: string | undefined): string => - value && value.trim().length > 0 - ? `${label}${escapeHtml(value)}` - : ''; - -/** Internal admin notification — all submitted details. */ +/** Internal admin notification with all submitted details. */ export const buildPartnerAdminEmail = ( input: PartnerEmailInput, ): { subject: string; htmlContent: string } => { const fullName = `${input.firstName} ${input.lastName}`.trim(); - const subject = `New partner inquiry — ${input.company} — ${fullName}`; - const htmlContent = ` -

New partner inquiry

-

${escapeHtml(fullName)} from ${escapeHtml(input.company)} wants to partner with CleanStart.

- -${row('Name', fullName)} -${row('Email', input.email)} -${row('Phone', input.phone)} -${row('Company', input.company)} -${row('Website', input.website)} -
-${input.partnerReason && input.partnerReason.trim().length > 0 ? `

Why partner

${escapeHtml(input.partnerReason)}

` : ''} -`; - return { subject, htmlContent }; + const blocks: EmailBlock[] = [ + { kind: 'paragraph', text: `${fullName} from ${input.company} wants to partner with CleanStart.` }, + { + kind: 'details', + rows: [ + ['Name', fullName], + ['Email', input.email], + ['Phone', input.phone], + ['Company', input.company], + ['Website', input.website], + ], + }, + ...(input.partnerReason?.trim() + ? [{ kind: 'quote' as const, label: 'Why partner', text: input.partnerReason }] + : []), + ]; + + return { + subject: `New partner inquiry: ${input.company}, ${fullName}`, + htmlContent: renderEmail({ + preheader: `${fullName} at ${input.company} submitted a partnership inquiry.`, + eyebrow: 'Partnerships', + heading: 'New partner inquiry', + blocks, + footerNote: 'Sent automatically from the CleanStart website partner form.', + }), + }; }; -/** Applicant confirmation — friendly acknowledgement. */ +/** Applicant confirmation: a friendly acknowledgement. */ export const buildPartnerApplicantEmail = ( input: PartnerEmailInput, -): { subject: string; htmlContent: string } => { - const subject = 'Thanks for your interest in partnering with CleanStart'; - const htmlContent = ` -

Thanks, ${escapeHtml(input.firstName)}!

-

We've received your partnership inquiry for ${escapeHtml(input.company)}. Our partnerships team will review it and get back to you shortly.

-

— The CleanStart team

-`; - return { subject, htmlContent }; -}; +): { subject: string; htmlContent: string } => ({ + subject: 'Thanks for your interest in partnering with CleanStart', + htmlContent: renderEmail({ + preheader: 'We have your partnership inquiry. Our team will be in touch shortly.', + eyebrow: 'Partnerships', + heading: `Thanks, ${input.firstName}`, + blocks: [ + { + kind: 'paragraph', + text: `We've received your partnership inquiry for ${input.company}. Our partnerships team will review it and get back to you shortly.`, + }, + { + kind: 'paragraph', + text: 'If anything changes in the meantime, just reply to this email.', + muted: true, + }, + ], + footerNote: 'You received this because you submitted the partner form on cleanstart.com.', + }), +}); 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..8ee1a16b9 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -624,7 +624,7 @@ body { CTA SYSTEM -------------------------------------------------------------------------- Two distinct CTA types: - 1. "Glass Button" — Book a Demo, Browse Images, Book a Demo. All share the + 1. "Glass Button" — Book a Demo, Pull Images, Book a Demo. All share the same multi-layer fill + stroke: • base: rgba(255,255,255,0.65) • LINEAR_DODGE radial #FFFFFF→#3D00FF @50% (blue tint top-left) @@ -719,7 +719,7 @@ body { /* --------------------------------------------------------------------------- GLASS BUTTON - Used by: "Book a Demo", "Browse Images", "Book a Demo" + Used by: "Book a Demo", "Pull Images", "Book a Demo" --------------------------------------------------------------------------- */ .cs-btn-glass { position: relative; @@ -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 ( +