Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`).
Expand Down Expand Up @@ -159,6 +162,16 @@ import { Section, Container } from "@/components/layout";
</Section>
```

### Form fields

Use the shared field components in `src/components/forms/`, never a per-form copy:

- `<TextField>` and `<PhoneField>` 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`.
- `<PhoneField>` 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`
Expand Down Expand Up @@ -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.
Expand Down
86 changes: 86 additions & 0 deletions apps/cms/emails/careers-applicant-confirmation.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<!-- Generated by scripts/render-email-templates.ts. Do not edit.
Source: endpoints/careers-apply.ts
Form: job application
Subject: We've received your application for Senior Platform Engineer -->
<!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>Thanks for applying, Priya</title>
<!--[if mso]><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml><![endif]-->
<style>
:root { color-scheme: light; supported-color-schemes: light; }
table, td, div, p, a { font-family: Arial,Helvetica,sans-serif !important; }

/* Outlook.com and the new Outlook rewrite colours in dark mode and stamp
[data-ogsc] (text) / [data-ogsb] (background) on what they changed. Pinning
the card, its text and the button back stops the half-inverted result where
a dark heading lands on a dark card. Outlook on Windows uses the Word
engine, ignores all of this, and inverts wholesale: the layout stays
legible there because the logo carries its own light plate and the button
keeps an explicit bgcolor. */
[data-ogsc] .cs-card, [data-ogsb] .cs-card { background-color: #ffffff !important; }
[data-ogsc] .cs-heading { color: #0f172a !important; }
[data-ogsc] .cs-text { color: #334155 !important; }
[data-ogsc] .cs-muted, [data-ogsc] .cs-faint { color: #64748b !important; }
[data-ogsc] .cs-btn, [data-ogsb] .cs-btn { background-color: #3960f9 !important; }
[data-ogsc] .cs-btn a { color: #ffffff !important; }

@media only screen and (max-width:620px){
.cs-wrap{width:100% !important;}
.cs-pad{padding-left:22px !important;padding-right:22px !important;}
}
</style>
</head>
<body style="margin:0;padding:0;width:100%;background-color:#f1f5f9;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
<!-- Preheader: shown in the inbox list, never in the open email. The trailing
entities stop clients padding the preview with body text. -->
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:#f1f5f9;opacity:0;">Your application for Senior Platform Engineer reached our team.&#847;&zwnj;&nbsp;&#847;&zwnj;&nbsp;&#847;&zwnj;&nbsp;&#847;&zwnj;&nbsp;&#847;&zwnj;&nbsp;</div>

<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#f1f5f9;">
<tr><td align="center" style="padding:32px 12px;">

<!-- Header, body and footer share one card. The footer is separated by a
hairline rule rather than floating on the page background, matching the
templates already live in Brevo. -->
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" class="cs-wrap cs-card" bgcolor="#ffffff" style="width:600px;max-width:600px;background-color:#ffffff;border:1px solid #e2e8f0;border-radius:12px;overflow:hidden;">

<tr><td class="cs-pad" style="padding:28px 32px 20px;">
<a href="https://www.cleanstart.com" target="_blank" rel="noopener" style="text-decoration:none;">
<img src="https://cdn.cleanstart.com/web/emails/logo-email.png" alt="CleanStart" width="140" style="display:inline-block;width:140px;height:auto;border:0;outline:none;text-decoration:none;-ms-interpolation-mode:bicubic;">
</a>
</td></tr>

<tr><td class="cs-pad" style="padding:0 32px 28px;">
<p style="margin:0 0 10px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#3960f9;">Careers</p>
<h1 class="cs-heading" style="margin:0 0 18px;font-family:Arial,Helvetica,sans-serif;font-size:22px;line-height:1.35;font-weight:700;color:#0f172a;">Thanks for applying, Priya</h1>
<p class="cs-text" style="margin:0 0 16px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.6;color:#334155;">We&#39;ve received your application for Senior Platform Engineer, along with your resume.</p>
<p class="cs-text" style="margin:0 0 16px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.6;color:#334155;">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.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:0 0 20px;">
<tr><td bgcolor="#f1f5f9" style="padding:14px 16px;border-radius:8px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.5;color:#64748b;">Please do not reply to this message. It is sent from an unmonitored address.</td></tr>
</table>
<p class="cs-muted" style="margin:0 0 16px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:1.6;color:#64748b;">The CleanStart hiring team</p>
</td></tr>

<tr><td style="padding:0 32px;">
<div style="height:1px;background-color:#e2e8f0;line-height:1px;font-size:1px;">&nbsp;</div>
</td></tr>

<tr><td align="center" class="cs-pad" style="padding:18px 32px 26px;">
<p style="margin:0 0 14px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:1.5;color:#94a3b8;">You received this because you applied for a role on cleanstart.com.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:0 auto 12px;border-collapse:collapse;"><tr><td style="padding:0 8px;"><a href="https://www.linkedin.com/company/cleanstart-official/" target="_blank" rel="noopener" style="display:inline-block;text-decoration:none;"><img src="https://cdn.cleanstart.com/emails/social-icons/icon-linkedin.png" width="24" height="24" alt="LinkedIn" style="display:block;border:0;outline:none;text-decoration:none;width:24px;height:24px;-ms-interpolation-mode:bicubic;"></a></td><td style="padding:0 8px;"><a href="https://github.com/cleanstart-dev" target="_blank" rel="noopener" style="display:inline-block;text-decoration:none;"><img src="https://cdn.cleanstart.com/emails/social-icons/icon-github.png" width="24" height="24" alt="GitHub" style="display:block;border:0;outline:none;text-decoration:none;width:24px;height:24px;-ms-interpolation-mode:bicubic;"></a></td></tr></table>
<!-- Postal address is a CAN-SPAM requirement. No unsubscribe link: these
are transactional confirmations, not marketing. -->
<p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:11px;line-height:1.5;color:#94a3b8;">CleanStart Inc. · 16192 Coastal Highway, Lewes, Delaware 19958, US</p>
</td></tr>

</table>

</td></tr>
</table>
</body>
</html>
Loading
Loading