@evinvest/kitstart
v0.21.1
Published
Landing-site machinery for local-service brands: a site composition root, the Place/Presence/ServiceArea model with a publication gate, host/locale routing, schema.org and SEO builders, the lead funnel with its antispam, and analytics events. Pure core at
Readme
@evinvest/kitstart
The machinery of a local-service landing site — the part every brand of the
vertical shares, lifted out of the aquafix reference site so a new brand is a
site.ts, its copy and its own sections.
What is here is behaviour, not look: routing between subdomains and locales, the place model and its publication gate, the lead funnel and its antispam, schema.org and sitemaps, analytics events. What a brand looks like stays in the brand.
Subpaths
| Import | Runtime | What |
|---|---|---|
| @evinvest/kitstart | anywhere (edge, client, server) | defineSite, places, routing (createRouting), the lead schema and funnel (createAcceptLead), the price list (PricingModel, priceOf, flowOf), antispam, JSON-LD / sitemap / robots builders, analytics events, the copy contract |
| @evinvest/kitstart/server | Node, server-only | createServerEnv, createPlaceSource, createPricingSource, the lead store (openLeadStore by LEADS_DB_URL: sqlite: today, postgres:// a stub that refuses at boot), checkLeadStore, leadNotifier, leadWebhook (signed, outboxed), sendMail, clientKey |
| @evinvest/kitstart/proxy | edge | createProxy(site), PROXY_MATCHER |
| @evinvest/kitstart/next | Next server (routes, RSC) | quoteRoute, bookingRoute, confirmRoute, sitemapRoute, robotsRoute, ogRoute, healthRoute, createPlaceLoader, loadLocale, placeMetadata / brandMetadata / statusMetadata, metadataBase |
| @evinvest/kitstart/next/config | next.config.ts, vitest.config.ts | withLanding, buildEnv and the assets/ readers |
| @evinvest/kitstart/react | either side | LangSwitch, CallBar, StatusScreen, PlaceDirectory, AreaChips, Coverage, MapFacade (client), QuoteFormShell, FormSelect (client) with loadLazyParts for tests, LeadCapture (client), LeadBooking (client) and its adapters, Faq, AnalyticsBoundary (client), plus the kit and marketing pieces a landing composes with |
| @evinvest/kitstart/testing | a brand's vitest | describeLandingContract(site, { globalsCss, proxySource, text }), describeLeadStoreContract(name, harness), storefrontPlace, serviceAreaPlace, testLead |
| @evinvest/kitstart/testing/e2e | a brand's Playwright | defineSectionSuite(sections), settle(page, selector), BREAKPOINTS |
| bin kitstart-size | plain node | kitstart-size [<build root>] [--route …] [--budget …]: first-load JS of a place page against the target in tests/bundle_budget.txt (passes with a warning up to its tolerance, 20 % by default — see The bundle budget); fails closed |
vitest and @playwright/test are not peers at all: they are the brand's
own test runners, which it installs in its devDependencies. As peers —
even optional ones — npm counted them among a brand's production
dependencies, and a runner's advisory turned npm audit --omit=dev red.
A new brand
template/ (shipped in the tarball too) is the skeleton: a single-topology,
service-area site before launch (no site in assets/card.toml → noindex,
robots disallow), with every route file a few literal lines over a factory.
Copy it, then follow its README. CI builds it on every PR exactly as a brand
would — packed tarballs, next build, its own tests, kitstart-size, and a
live standalone server answering /, /fr, a page, a 404, the form POST, the
OG card and robots (npm run check:template).
Nix: lib.mkLanding
The lib flake exports the flake a landing shares (nix/mk-landing.nix), so a
brand's flake.nix is its own config plus one call — template/flake.nix is
the whole of one:
inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.21.1"; # the version in package-lock.json
inputs.ev.inputs.v_flakes.follows = "v_flakes";
landing = ev.lib.mkLanding {
pkgs, root, pname, sitePort, buildFiles, # required
prodEnv ? { }, # baked into the image (deploy/config.nix)
v_flakes ? null, # without it: no `container`
nodejs ? pkgs.nodejs_22, nodeRuntime ? pkgs.nodejs-slim_22,
budgetFile ? "tests/bundle_budget.txt", gatedRoute ? "/[locale]/[location]",
requiredFiles ? [ ], # must be in the standalone output (OG fonts)
mounts ? [ "/data" ], criticality ? "high",
smoke ? { page = "/fr"; }, # + host, og, quote = { field = value; }
containerAttr ? ".#container",
checkKitstartVersion ? true,
e2eConfig ? "tests/e2e",
packageSourceOverrides ? { }, # lock key → `file:`/vendored tarball
maxOldSpaceMiB ? 56, # V8 old-space cap; the OG render dies at 48
};
# → { site, bundleBudget, packages.{default,site,container}, containers,
# checks.{site,bundle-budget}, apps.{default,help,dev,test,accept-test,size,container-smoke},
# devShell }site: the hermeticnext buildfrompackage-lock.jsonthroughimportNpmLock(dependencies keyed on the manifest and the lock only; foreign optional native binaries never fetched — the self-check's lock carries a Windows-only package with a bogus integrity to prove it).packageSourceOverridesserves lock entries the registry cannot yet —{ "node_modules/@evinvest/kitstart" = ./vendor/kitstart.tgz; }builds a brand against an unpublished kitstart; the self-check vendors one package whoseresolved404s.container: the OCI image on node-slim,prodEnvbaked in.checks.bundle-budget:kitstart-sizeon the Nix build.
The bundle budget
tests/bundle_budget.txt holds the target — the gzip bytes of
first-load JS the place page should weigh — and, optionally, how far over it
a build may go:
# Why the number is what it is (comments are free).
158000
tolerance 20%| Measured | kitstart-size |
|---|---|
| ≤ target | passes |
| ≤ target × (1 + tolerance) | passes with a warning: the overshoot in bytes and %, and a ::warning:: annotation on GitHub Actions |
| more | fails |
Without a tolerance line the tolerance is 20 % — a file of one number, as
every budget was before, is a target. The ceiling rounds down to a whole
byte. The overshoot is meant to be paid back; changing the target or the
tolerance is a deliberate commit, with a reason, as raising the budget was.
checks.bundle-budget applies the same rule (no annotation in the sandbox).
apps.test(tsc — the app, thene2eConfigas its own project with its owntsconfig.json— lint, vitest, build, size, Playwright on the flake's pinned browsers),accept-test(Linux only),size,dev,help.apps.container-smoke: boots the image with a host port docker picks (-p 127.0.0.1::<port>: landing ports sit in Linux's ephemeral range), checks/health, the 302, the page, the OG card and the form POST landing in the mount, then boots it again with everyprodEnvkey blanked and wants 500 — and once more with only the lead store's key (LEADS_DB_PATH/LEADS_DB_URL) blanked, wanting 500 from the store's refusal alone.
Versions move together. mkLanding refuses a lock whose
@evinvest/kitstart differs from the lib revision's
ts/kitstart/package.json: the size gate runs the lib's copy of the bin, and
the two must be one release. Pin the flake to the matching
@evinvest/kitstart-vX.Y.Z tag.
Widgets
Structural only — where behaviour matters more than look. Marketing sections
(hero, prices, reviews…) stay in the brand until two brands hold the same one.
Each widget is a Server Component unless it needs the browser (MapFacade,
AnalyticsBoundary, FormSelect, AbSwitcher), styled with the kit's token roles only, restyled through
className — and, where a brand needs geometry inside one (Faq, CallBar,
StatusScreen, PlaceDirectory, Coverage), through its named parts:
classNames={{ list: "rounded-none", answer: "px-2" }}, merged after the
kit's classes so the brand's utility wins. No descendant selectors: they
break silently when a widget's markup moves. A size in a part keeps the
kit's line height: tailwind-merge drops an earlier leading-* for any later
font size, so the widgets put their leading-* after the brand's classes —
code: "text-[88px]" stays leading-none. To change the line height, name
it ("text-[88px] leading-tight", or "text-lg/7"). The header's language
switch is StatusScreen's lang: below md it takes a row of its own
(order-last w-full); lang: "order-none w-auto" keeps one row.
QuoteFormShell is headless: it owns the hidden fields and the
honeypot the funnel reads; the visible fields are the brand's children.
StatusScreen takes the brand's name and marks as props, so the client error
boundary never imports the site config.
FormSelect: a choice that posts without JavaScript
A form's select, for the fields inside QuoteFormShell. On the server and
without JavaScript it is the kit's NativeSelect — a real <select name>
the form posts as is. Hydrated, it stays that select, live — a choice there
reaches onValueChange — until the kit's Select is here: a chunk of its
own, asked for once the page is idle, or sooner when a mouse moves over the
field or it takes the focus. Then it is the kit's Select: the list drawn in
the palette, never the platform's menu, and the value in an input of the
same name. A press before that opens the platform's list at once, never a
wait on the chunk; a select holding the focus (its picker may be open) is
swapped only once the focus leaves. Both states are one box — height, width,
border, radius and inset — so the swap moves nothing (checked by the
template's e2e, which CI runs: npm run check:template -- --e2e).
<Field className="flex flex-col gap-2">
<FieldLabel>{t.quoteLabels.subject}</FieldLabel>
<FormSelect
name={LEAD.wire.subject}
size="lg"
defaultValue={LEAD.subjects[0]}
options={LEAD.subjects.map(s => ({ value: s, label: t.subjects[s] }))}
/>
</Field>| Prop | |
|---|---|
| name, options: { value, label }[] | the posted field and its choices (labels are text) |
| defaultValue | without it: empty under a placeholder, else the first option — the native rule |
| value | controlled: shown and posted until the parent changes it from onValueChange; "" is the placeholder, undefined uncontrolled |
| placeholder | an empty, unpickable choice shown until one is made |
| required, disabled | as on a <select>: required refuses the submit, disabled posts nothing |
| size | sm · md · lg (the landing's) — the kit's form scale |
| id, aria-describedby, aria-invalid | inside a Field the id comes from it: its FieldLabel names the <select> before hydration and the trigger after |
| className · classNames={{ trigger, content, item }} | the shared box · the control in both states, the list, its rows |
| onValueChange | the chosen value, after hydration |
- The value survives the swap. A choice made in the native select before
the script arrived is read in the hydration commit; a form
resetputs the default back in both states (uncontrolled; undervalue, see below). With nothing chosen (under aplaceholder) it posts nothing, as a native select on its disabled placeholder does. requiredstill refuses the submit. Atype="hidden"input is never validated, so underrequiredthe value rides in a transparent input under the trigger, out of the tab order, the accessibility tree and autofill. An invalid field's trigger takesaria-invalid(the error border). On a submit — and only then — the form's first invalid field also takes focus with its list open, where the browser's bubble would have pointed at nothing; a script'scheckValidity()marks it and moves nothing.- Keyboard and screen readers are the kit's
Select(its README has the focus pattern): the arrows open it, opening lands on the chosen option, letters jump, Enter chooses, Escape and Tab close with focus back on the trigger; the trigger is acomboboxnamed by theFieldLabel, and so is its list, with the kit's focus ring. - Weight. The kit's
Select— the list's placing, dismissal and keys — is out of the first load, inFormSelectKit's chunk; the native select and the value's plumbing stay. With@evinvest/uikitbefore 0.28 a page whose form uses the kit'sFieldstill loadsSelectup front: thatFieldimportedSelectTriggerto recognise the control it labels. - Controlled, as an
<input>. Undervaluethe prop is the value: a pick only callsonValueChange, and the parent's state (or a reset of its own,setSubject("")back to the placeholder) is what moves it. A formresetleaves it onvalue— clear the parent's state from the form'sonResetif it should follow. Without JavaScript it is still the native select, starting onvalue; a pick made there before hydration is reported throughonValueChange. Switching betweenvalueandundefinedwarns once in development, as React does for an input, and keeps the last value.
const [subject, setSubject] = useState<string>(LEAD.subjects[0]);
<FormSelect name={LEAD.wire.subject} size="lg" value={subject} onValueChange={setSubject} options={subjects} />LeadCapture: the lead form every brand shares
One form for every brand, so an experiment's results pool across sites (the
site is the stratum). A client island over QuoteFormShell: without a script
it is the plain POST to /quote it always was.
<LeadCapture
place={view.place}
contact={contactOf(site, view.place)}
locale={view.locale}
renderedAt={renderedAt}
wire={LEAD.wire}
needs={LEAD.subjects.map(s => ({ value: s, label: t.subjects[s] }))}
text={{ ...LEAD_CAPTURE_TEXT[view.locale], submit: "…" }}
layout={arm === "b" ? "steps" : "single"}
experiment={{ name: "lead_form", variant: arm }}
/>| Prop | |
|---|---|
| place, contact | the hours order the channels, the service area suggests the commune; contact is contactOf(site, place) — a null number is a channel not offered |
| locale, renderedAt | the page's language; the render stamp (time trap, and "now" until the script runs) |
| wire, needs | site.lead.wire; the subjects with their labels, an icon each (any node, the brand's — the kit ships none) for needDisplay="cards", and an optional shortLabel drawn on tiles under sm |
| need | the need the page already knows — also set by ?need= and by a tap on any [data-need="…"] element. ?postcode= fills the postcode the same way |
| layout | single (default) · steps: one question per screen — see One question per screen · qualify-first: a tile per need, then the contact step (the two screens steps grew from, kept as they were) |
| needDisplay | how the need is asked: select (default on single) · tiles (default otherwise) · cards, a grid with each need's icon |
| intro, localityStep | steps only: a question before the need, posted as a brand's extra, whose answer may turn the form into a callback · the postcode on its own screen (own, default) or on the phone's (with-phone) |
| focusNext | on one screen: after a choice the focus moves to the next empty field, and Enter in a field moves to the next empty one before it submits. Off by default; always so in steps |
| questions | by estimate input id: { display?: "tiles" \| "cards", unknown?: true, unknownSpan?: 2, badges?: { [option]: text }, shortLabels?: { [option]: text }, previewLabels?: { [option]: text }, step?: number, next?: string } — see The compact form and steps |
| price, taxCredit | box (default) · compact: the price on one line, what is left after a tax credit of taxCredit (a ratio, 0.5), the breakdown behind "Détail" |
| afterPhone | a node right under the phone field: one line of reassurance |
| channelsDisplay, channelIcons | the other ways out: stack (default, under otherChannels) · row, one row of compact buttons named by otherChannels · the brand's icon per channel (phone, whatsapp, sms, callback, telegram) |
| locality | required (default) · optional |
| name | { field, required? }: a name field posted as the brand's extra; off by default |
| extras | the brand's own fields, after the phone |
| prefer | a channel moved first when available (a default_channel arm) |
| experiment | { name, variant }, slugs: on every event and posted with the form |
| timeZone | Europe/Paris by default |
| formId, id | quote · quote: the id is the card's (root), so #quote scrolls to the whole card, head included; the form is <id>-form, the callback <id>-callback (its form <id>-callback-form). Both forms post it as card, so a refused lead comes back to #<id> |
| text | LeadCaptureText: LEAD_CAPTURE_TEXT.fr / .en, plain strings ({need}, {day}, {time} filled in); optional localityPlaceholder, phonePlaceholder, namePlaceholder (none by default). The errors are words of the page too: phoneInvalid, required, needRequired, consentRequired, fieldInvalid, formInvalid, and the post's sending, networkError, timeoutError, retry — a brand spreading LEAD_CAPTURE_TEXT has them |
| labels | visible (default) · hidden: every field's label sr-only — still the field's accessible name — for a design that draws placeholders |
| callbackOpen | whether the callback starts open; by default only when it leads (the place is closed). #<id>-callback opens it either way |
| done | the card after a lead is taken — a node, or (sent: LeadSent) => node ({ channel, phone, name }), shown in place. Without it, a lead taken goes to the thanks page the route names. Either way a script posts the form itself (asking /quote for JSON), so a refusal keeps what was typed; an answer that is not the route's, or no network, submits the form for real; without a script nothing changes (303) |
| flows, pricing, photos | how each need is sold (site.lead.flows), the page's price list (createPricingSource(site).model()), and the quote needs priced from photos — see Form variants |
| booking | after a priced lead, in place of the place's booking (LeadBooking, from place.booking): a node or (sent) => node |
| bookingVariant · bookingEmbed · bookingAdapters | the booking_provider variant that picks the place's provider · the providers' embeds (after cookie consent) rather than a new tab · the brand's adapters over the built-ins (from a client component) — see Booking |
| initialError | leadErrorOf(searchParams) on a page that reads its query: the refusal a 303 brought back, drawn on the server by the card it names (lead_card) so the card says why without a script (see Refusals) |
| head, trust | the brand's heading instead of the title; a slot beside the submit. Like extras, done and booking, any node, built on the server or not, and never asked for a key: each slot sits alone in a keyed fragment |
| className · classNames | the root · its parts: root, head, title, lede, form, contact, field, label, control (every input, the need's select in both states, the callback's phone), hint, error (a refusal: under the field, or above the submit), chips, chip, needs, need, summary, icon (a need's or an intro answer's icon), submit, trust, privacy (not drawn when text.privacy is ""), opening, others, channel, channelIcon, primary, callback, callbackSummary, callbackForm, callbackLede, callbackSubmit, consent, done, for steps progress, stepBack, step (each screen), answers, answer (the chips of the screens answered), stepNext, intro, introOption, and for the flows estimate, estimateInput, estimateLegend, estimateGrid (an input's answers: 2 columns, 3 from sm), estimateOption (an answer's tile), estimateUnknown (the "I don't know" tile, after estimateOption), badge, optionPrice (a card's total), price, priceTotal, breakdown, priceNote, priceLine, priceTaxCredit, priceDetail (price="compact": the «Détail» trigger), priceDetailContent (its popover), photos, priced, pricedPrice, pricedNote, and for the booking booking, bookingCta, bookingNote, prefer, preferOption, preferSubmit, and for a messenger variant messenger, messengerSlot, messengerCta, messengerSecondary, messengerSquare, messengerSegment, messengerTile, messengerTrigger, messengerPreview, messengerPicker, messengerOption, messengerQr, messengerReturn, messengerHint |
| messenger, messengers, refPrefix, brand, messengerTiming | the lead_channel arm (MessengerVariant, absent → the control) · messengerFacts(site, place) · the chat reference's prefix (AQ) · the message's greeting · the estimate's question whose answer is the message's timing line ({ input: "frequency" }) — see Messengers below |
- Taps. A need the page knows is not asked again, and a place serving one
commune fills it: focus the phone, type, send — two taps.
qualify-firstwith no need: the tile, which moves the focus to the first empty field (the phone when the commune is filled), then send.singlewith no need takes the first one unless changed (two more taps). Enter sends. From the keyboard the tiles are a radio group: the arrows move the choice and stay in it; Enter or Space answers and moves on. - Channels (
resolveChannels, shared withCallBar): call (tel:), WhatsApp and SMS (the need in the message; SMS only to a mobile), "call me back" and the form. Open, the call leads; closed, the callback (open) and WhatsApp lead and the call goes last, and the form says when the next opening is — from the place's hours, never invented. No hours: no promise, the open order. No number: no call, text or WhatsApp.CallBarorders its buttons the same way when givenhours(andnow={renderedAt}, andcallback={{ href: view.href("#quote-callback"), label }}); withouthoursit is unchanged. - Callback. A
<details>with its own small form: the phone and a native, required consent, posted to/quotewithchannel=callback. The consent's value is the sentence it shows (text.callbackConsent, in the page's language); the server refuses a callback without it — back to the callback, open, naming the consent, whatever the brand's rule — and stores it with the lead:consent_textword for word,consent_atwhen the server accepted it (Lead.consent). Then the lead is held tolead.validateCallback(the phone rule, by default), not to the form's rule. Its phone is the form's field: the same hint, the same block. The consent goes into no event; a webhook body built by the brand should leavelead.consentout. - Locality. A postcode field (
autoComplete="postal-code"). A storefront with no named zone fills in its own postcode; a named zone fills in its one commune or offers its few as chips, by name — and then the field takes letters (inputMode="text"), since iOS's numeric keypad could not edit a name. Otherwise it is the numeric keypad. - Callback, closed. Its summary is a block (
flex, not the button'sinline-flex) withleading-6after the brand'schannel/callbackSummaryclasses unless they set a line height of their own, so a brand's type size cannot leave it on a half pixel. - Phone.
type="tel", required, never masked, held to one rule on both sides (phoneProblem,validateLead): a French number is ten digits from0[1-9](or+33and nine), any other a+and 8 to 15 digits — and for the plans visitors most often dial from, that plan's national length after the code:+1ten,+44nine or ten,+34and+41nine,+32eight or nine,+497 to 13,+396 to 11 (so+12345678is no number); full-width digits read as digits; a run of one digit is no number. One that fails gets a hint when the field is left (aria-live, read out; when a tap left it, only once the tap is over, so nothing moves under the finger) and blocks the submit in the page's words — the browser's bubble would speak its own language. Withlead.mobileFormat: "e164"a number it can read is stored as+33612345678(normalizePhone); the default keeps it as typed, which is what a brand's tests and tooling look rows up by. - Field messages. Under each field, a live region (
aria-live) always in the DOM, so a message appearing in it is read out. Empty, it is out of the field's flex flow (empty:absolute, neverhidden): it takes no gap, so a brand'sclassNames.fieldmay set any gap with no override for it. - Refusals are never silent. Every required field blocks with the page's
words and is marked
aria-invalidonce refused. What the server still refuses (a brand's own rule, a stale page) comes back to the card: the script's post gets a422 { field }and the form, as typed, shows the error at that field (role="alert", focused); the plain post gets a 303 to?lead_error=<field>&need=<need>&lead_card=<id>#<id>(#<id>-callbackfor a callback, opened), which the card reads once the script runs and then drops from the URL. Only slugs ride in that URL — never a phone or a name. Without a script that text is the page's to draw: a page that reads its query passesinitialError={leadErrorOf(await searchParams)}and the server renders it in the card the 303 named (lead_card=<id>or<id>-callback, beside the fragment the server never sees): only that card draws it, at its form or its callback (opened). A URL from beforelead_cardis drawn by every card as it reads — the consent at the callback, anything else at the form. A page built for ISR reads no query, so without a script it lands on the card with the field marked by nothing — the limit of a cached page. A field the card does not draw (a brand'sextras) shows its error above the submit and is marked itself. - Sending. The script's post (asking
/quotefor JSON) carries asubmission_idit mints once per lead — a new one as soon as anything posted changes, and never one the browser restored; the store keeps one row per id, so a resend after a lost answer is answered as the first was and never makes a second lead. While it runs the form's submit is disabled,aria-busyand sayssending. No answer — no network, or none in 15 s (SUBMIT_TIMEOUT_MS) — is said in place with a retry that sends the same lead; the page is never left for the browser's error page. While a post runs or waits for its retry, the channels keep their order: the hours turning (the place closing) would move the callback, and a moved form is a new one — what was typed gone, the retry sent nowhere. - Events (through
AnalyticsBoundary's sink; none outside one):lead_form_view(half in view, once),lead_form_start(first focus),lead_form_field_error {field, blocking}(blocking: truethe browser refused it,falsethe phone hint showed — the field's role, never its value),lead_form_submit_error {reason, channel}(network/timeout),lead_form_step {step}(contact/needinqualify-first; instepseach screen moved to —intro,need,estimate_<input>,locality,phone), each withform_id,layout,experiment,variant;contact_intent_click {channel}forphone,whatsapp,sms,callback, with the experiment; and on the serverlead_form_submit {form_id, channel}andlead_form_reject {form_id, channel, field, reason}with the posted experiment. On a test visit every one of them — page view, intents, funnel, booking, and the server's two whenquoteRoutehasqaCookie— carriesforced: true(seeAbSwitcher). - Weight. 8.9 KB gz of first-load JS on the template's place page (157,287 → 166,361 B against its 158,000 B target: +5.3 %, a warning within the 20 % tolerance); the refusals and the shared phone rule are 2.0 KB of it, sending (the id, the busy state, the retry) 0.6 KB.
Messengers: messenger, WhatsApp and the bot
The lead_channel experiment's arms. WhatsApp: the customer sends the brand's
prefilled message, answered by hand in WhatsApp Business. Telegram: the
brand's bot, opened as t.me/<bot>?start=<ref>. The phone is asked only for
a call; no text messages. Either way the lead is posted first, in the
background, so the panel has it before the chat starts.
import { messengerFacts } from "@evinvest/kitstart";
<LeadCapture
{...controlProps}
messenger={arm === "c" ? { kind: "segment" } : undefined} // undefined → the control
messengers={messengerFacts(site, place)} // the place's own WhatsApp, its bot — each `null` when off
// (a bot's name: `TELEGRAM_BOT`, 5–32 chars ending in `bot`)
refPrefix="AQ" // the lead's chat reference: AQ-7K3F
brand="Aquafix" // the message's greeting
channelIcons={{ whatsapp: <WhatsAppIcon />, telegram: <TelegramIcon />, callback: <PhoneIcon /> }}
classNames={{ messengerSlot: "h-[92px]" }} // every state of a variant one height
/>| kind | Figma | the card |
|---|---|---|
| select {side: "prefix" \| "suffix"} | AQ-1 / VF-1 | the channel picked in the phone field (the kit's Select, an overlay): WhatsApp — the number optional, the button opens the chat; Appel — the control; Telegram — no number, the button opens the bot |
| segment | AQ-2 | «WhatsApp | Appel» over the slot (the message ready / the phone); «ou via Telegram» under the button |
| tiles | VF-2 | «Recevoir mon devis par» and a tile per channel over the slot (the message / what the bot does / the phone) |
| thanks | AQ-3 | the control's form; the in-card success adds a photo on WhatsApp (a QR code on a computer) and the bot. The card stays (done) |
| swap | AQ-4 / VF-3 | no phone: the WhatsApp button, then [Telegram][Être rappelé]; «Être rappelé» swaps in the phone and [WhatsApp square][submit] |
| saga | AQ-5 | the channel first, on a screen of its own (cards), then the job and the channel's slot; «Changer de canal» back |
| urgency {field} | AQ-6 | «C’est urgent ?» — posted as the brand's extra field (today / later; list it in site.lead.extras) — today a call, «je compare» the message |
| sheet | VF-4 | the estimate, the postcode and one button; the kit's Drawer: WhatsApp / Telegram / a call, the phone asked in the drawer |
| chip | VF-5 | the lede becomes a chip «Réponse sur WhatsApp ▾» (a Select overlay); the slot follows it |
| split | VF-6 | the slot over one row: «Devis sur WhatsApp» with [Telegram][📞] squares; 📞 turns it into «Être rappelé» with [WhatsApp][Telegram] |
- Fallback (Figma «Выключено в панели»). No WhatsApp for the place — no
number of its own, or
messengers.whatsapp: falsefrom the panel — and every kind is the control, with «ou via Telegram» under the submit when the bot is on;urgencykeeps its question (both answers ask the phone) andthankskeeps the bot. No bot — every Telegram piece is left out. The arm stays assigned; every event says what the card had:channels_available(wa,tg|wa|tg|none). The switches are the form's only:contactOf— the header, the call bar, the control's own WhatsApp link — keeps the number. - The reference
message_ref:<refPrefix>-<4 Crockford base32>, minted in the browser once the script runs, and again only once the visitor is back from the chat and has answered «Message envoyé ?» — never in the tap: the reference posted is the one in the link, the QR code and the return screen, and a second tap on the same lead is the same lead; the WhatsApp message ends withRéf. AQ-7K3F, the bot'sstartis the reference itself. Posted with every lead of the card (athankssuccess sends the form lead's own), stored (message_ref, schema 8), mailed (Réf.), and sent to the panel underpanelMessenger. - The message (
messengerMessage, ≤ 400 characters, nothing typed about the person):Bonjour {brand} 👋, the need with the estimate's answers («Ménage standard · 2 ch. · 40–70 m²»: each answer asquestions[id].previewLabelssays it — a tile's «2» is «2 ch.» here — else itsshortLabels, else its label; «Je ne sais pas» left out), the postcode, the estimate shown (a priced need), the timing,Réf. {ref}— a line without its value left out. The slot shows it as a preview, so the visitor sees there is nothing to write: «Bonjour Vifnet 👋 · Ménage standard · 2 ch. · 40–70 m² · 2 sem. · env. 77 € · 63130 · Réf. VF-7K3F» (the timing by itspreviewLabels/shortLabels, the price throughmessengerPreviewPrice,"env. {price}"). The timing isurgency's answer («aujourd’hui» / «pas pressé»), else the answer to the estimate's questionmessengerTimingnames —messengerTiming={{ input: "frequency" }}, its whole label on a line of its own, out of the need's list; none until it is answered, nor for «Je ne sais pas». Each line is a key a brand rewords:{ ...LEAD_CAPTURE_TEXT.fr, messageTiming: "Fréquence : {timing}", messagePrice: "Estimation vue sur le site : env. {price} / passage" }. - Icons (
channelIcons):whatsappandtelegrambefore their buttons, items, tiles and segments;phone(elsecallback) before every call inside a variant — «Être rappelé», its submit, the squares. - The tap. A real
<a href>— the OS opens the app from the tap. A number typed must be one (the browser says so, nothing leaves). The lead is posted (keepalive,channel=whatsapp|telegram, the reference, what was filled; the same submission id for a second tap). On a computer (hover: hover,pointer: fine, ≥ 768 px) WhatsApp becomes a QR code in the slot (qrcode-generator, loaded on that tap only) with «ou ouvrir WhatsApp Web» and «Être rappelé»; the bot opens in a new tab. In an app's own browser (Instagram, Facebook, TikTok, Line, Snapchat) a hint says how to leave it, with «Copier le message». - Back on the page (
visibilitychange), the card asks «Message envoyé ?» over the form: reopen the chat, «Je n’ai pas pu envoyer → être rappelé» (the variant's call state, the phone focused), or «C’est envoyé». - The server. A
whatsapp/telegramlead needs no phone (one typed is held to the rule) and no consent, and is never sent back for a changed price;validateLeadknows the channel. A brand's own rule must not refuse one for the postcode or the phone it was not asked — the testing subpath'smessengerRuleDisagreements(site.lead)lists where it does. - Words:
LeadCaptureMessengerText— optional keys of the card'stext, a key left out the kit's in the page's language (messengerTextOf; the defaults areLEAD_CAPTURE_MESSENGER_TEXT, kept out ofLEAD_CAPTURE_TEXTso the control's island never carries them). A brand overrides by spreading:{ ...LEAD_CAPTURE_TEXT.fr, messengerWhatsappCta: "Envoyer sur WhatsApp →" }. Parts:messenger(the variant's column),messengerSlot(the fixed box),messengerCta(the main button: a messenger's, or the call's submit inside the variant — its height and type, aschannel/primaryare the control's),messengerSecondary(Telegram and «Être rappelé» beside it, «ou via Telegram», «Rouvrir WhatsApp», the QR's «Être rappelé»),messengerSquare(the icon squares ofsplit/swap),messengerSegment(asegmentitem),messengerTile(atilestile, anurgencyanswer) — both aftermessengerOption,messengerTrigger(the channel picker's button:select's in the field — its tint — andchip's),messengerPreview(the message, the bot's card),messengerPicker(the segment, the tiles,select's field box, the chip, the drawer's body),messengerOption(a picker's item, asaga/sheetcard, a segment's item, a tile),messengerQr,messengerReturn,messengerHint. A part with a size keeps its line height (leading-6) unless it sets one. - Events: every
LeadCaptureevent carrieschannels_availableand, with a variant,messenger_variant;lead_messenger_open {channel, device, inapp, message_ref},lead_messenger_return {channel, answer: sent | failed, message_ref},contact_intent_click {channel: telegram | whatsapp_qr};lead_form_submithasmessage_refand the postedchannels_available. - Weight. Each kind is its own chunk, fetched only by the arm that draws
it; the control pays for the dispatch alone, and the QR encoder for nobody
until a desktop tap. The server writes the variant into the page whole (no
boundary of its own: React would reveal a large one late, the contact block
missing for a frame); hydrating, the card waits for its variant's chunk,
the server's markup kept as it is. Until the chunk and the reference are
there, its messenger links lead nowhere (
aria-disabled, nohref); a browser without scripts gets the control's phone and submit (<noscript>).
One question per screen: layout="steps"
<LeadCapture … layout="steps" needDisplay="cards" price="compact" taxCredit={0.5}
questions={{ surface: { unknown: true }, frequency: { display: "cards", badges: { biweekly: "Le plus avantageux" } } }}
intro={{ label: "C’est pour quand ?", field: "urgency", options: [
{ value: "today", label: "Urgent — aujourd’hui", channel: "callback" },
{ value: "week", label: "Cette semaine" },
] }}
afterPhone={<p>Votre numéro reste entre nous — rappel sous 15 min.</p>} channelsDisplay="row" />- Screens. The intro question when there is one; the need; each of the
estimate's questions; the postcode (
localityStep="own", the default, or with the phone,with-phone); the phone, always last, with the price, the name and the brand's extras and the submit. A tap answers and moves on (from the keyboard the tiles are a radio group: arrows choose, Enter or Space answers); the postcode moves on with "Continuer" or Enter. The focus goes to the new screen's field — inside the tap when that is a typed field, so iOS opens the keyboard. - Over the screens a thin bar (
progress; the screen is read out asstepProgress, "Étape 2/4", never drawn), "Retour" (stepBack), and the screens answered as chips (answers,answer), each a tap back to its screen with "Modifier" (needChange). The total counts the most screens an answer to come could add (the longest estimate while the need is open), so the bar only moves forward as the visitor answers; until the intro is answered — its branches differ in length — no total is said (stepProgressOpen, "Étape 1"). - Questions sharing a screen (
questions: { bedrooms: { step: 1, next: "Voir les prix" }, surface: { step: 1 } }): questions with the samestepnumber are one screen. It moves on by itself once all are answered; until then the focus goes to the next question, and the button (nextof the first, elsestepNext) says which one is missing. - Never asked twice. A need the page knows (
need,?need=, a[data-need]card) and a postcode it knows (?postcode=, a place serving one commune) are answered screens: a chip, not a question. - The intro (
intro: { label, field, options }) is posted underfield— declare it insite.lead.extras({ name: "urgency", max: 16 }) and it is stored and mailed with the lead like any extra. An answer withchannel: "callback"cuts the form to the phone and the callback's consent, posted as a callback lead (channel=callback,callbackSubmit); the folded callback is then not offered a second time. - A refusal opens the screen of the field it is about (the phone's for anything the form cannot place).
- Without a script every screen is in the form, hidden but posted; a
<noscript>style shows them all at once and hides the bar, the back link, the chips and "Continuer": the form posts as one, asqualify-firstdoes. The server draws the same screen the script starts on — no shift at hydration.
The compact form
Pieces any layout takes, for a card that fits a phone's screen:
- The price on one line (
price="compact"):priceLine("Votre prix : {price}" — a brand may write "≈ {price}"), then, withtaxCredit(a ratio of the price a tax credit gives back,0.5for the French crédit d'impôt), what is left to pay (priceTaxCredit,data-credit-cents), and the breakdown andpriceNotebehind «Détail» (text.priceDetail): the kit'sPopover, over the card — opening it moves nothing — closed by a tap outside or Escape and kept 8 px inside the screen; without a script (and until hydration) a<details>opening in place. Parts:priceDetailthe trigger in both (the button, or the summary — style it directly, not through[&>summary]),priceDetailContentthe popover's box (or the details' body). Read out as it changes, the line only. - A question as cards (
questions: { frequency: { display: "cards" } }): one answer a line, each with the total it would make given the other answers (once they are given), and an optionalbadgestag per option. - Short labels on a phone (
needs[].shortLabel,questions[id].shortLabels): undersmthe tile draws the short one; the radio stays named by the whole label (aria-label, with the badge and the price). - Badges are the brand's words (
badges): the kit marks nothing as popular or cheapest by itself. - "Je ne sais pas" (
questions: { surface: { unknown: true, unknownSpan: 2 } }) adds the answerestimateUnknownto that question, posted asESTIMATE_UNKNOWN(?— never a slug, so never an option a model prices). The questions after it are not asked, the price goes, and the lead is aquote: the submit sayssubmit, a quote need's photos ask shows, and the server stores it as a quote too (answeredUnknown), without the warning a stale answer logs. - Need cards (
needDisplay="cards"): a grid of cards with each need'sicon;tilesa list without. Required either way: nothing is chosen for the visitor, unlike the select's first need. - One line under the phone (
afterPhone) — andtext.privacy: ""drops the kit's privacy line, so the reassurance is said once. - The other channels in a row (
channelsDisplay="row"): one row of compact buttons (call, WhatsApp, text, "Rappelez-moi" — its form on a line of its own once open), named byotherChannelsas a group, each with the brand'schannelIcons; a channel the place lacks is not drawn. Hiding the row where a sticky call bar already offers the same (classNames.others) is the brand's. focusNexton one screen: after the need's select, a tile, or an estimate's answer, the focus moves to the next empty field; Enter in a typed field moves to the next empty one, and submits from the last.- Words.
estimateUnknown,priceLine,priceTaxCredit,priceDetail,stepProgress,stepProgressOpen,stepBack,stepNextare inLEAD_CAPTURE_TEXTand optional inLeadCaptureText, falling back to the kit's in the page's language (flowTextOf) — a brand's text from 0.13 still type-checks. - Weight. The steps, the tiles, the compact price and the focus ride
with
LeadCapturewhichever layout a page uses: about 9 KB gz of first-load JS on the template's place page (170,375 → 179,430 B against its 158,000 B target: +13.6 %, a warning within the 20 % tolerance).
Form variants: quote, estimate, fixed
A need is sold one of three ways, chosen per need — one brand can mix them:
quote— the price is uncertain; the lead asks for one. The form as it was.estimate— the price follows from a few enum answers (zone, bedrooms, surface band, frequency), shown live as the visitor taps; then the slot.fixed— one price for a well-defined job, shown as is; then the slot.
// shared/config/lead.ts — the form and the route read the same map
export const LEAD: LeadSchema<Subject> = { …, flows: { standard: "estimate", deep: "quote" } };
// shared/config/site.ts — the baked price list
export const site = defineSite({ …, lead: LEAD, pricing: PRICING });
// shared/config/env.ts — the live one, from the place source's base URL
export const pricing = createPricingSource(site, { baseUrl: () => serverEnv().locationsApiUrl });
// app/quote/route.ts
export const POST = quoteRoute(site, { env: serverEnv, notifier, pricing, unavailable });
// the page: <LeadCapture … flows={LEAD.flows} pricing={await pricing.model()} photos={["deep"]} />- The price list (
PricingModel) is JSON:format1,currencyEUR(TTC),validFrom,roundToCents,minimumCents,inputs(addin cents,multiplyanddiscountin basis points, labels per locale) andneeds({ kind: "estimate", baseCents, inputs }or{ kind: "fixed", cents }).priceOf(model, need, answers) → { cents, breakdown } | nullis integer cents throughout, rounding half up at every product, then toroundToCents, then the minimum. The rules and the shared fixtures the panel holds its own port to are intest/fixtures/pricing/.defineSiterefuses a baked model that does not validate or lacks a label in one of the site's locales. - The live list (
createPricingSource):GET <base>/pricing, TTL 600 s, 3 s timeout; an unreachable source, a non-200, a body that is not JSON or a model that does not validate (for every locale of the site) all serve the baked model, logged — whole or not at all, never half a model.{}is "nothing set": the baked model, quietly. - A need runs its flow only when the list prices it so (
flowOf): anestimatethe model cannot price — no model, a remote model without the need — is aquote. So a flow the brand has not switched on is never run by a posted field. - The form. An estimate's questions are a tile per answer (touch-sized
radios posted as
estimate_<input>), required once the script runs — two columns, three fromsm; a brand sets its own count withclassNames={{ estimateGrid: "grid-cols-3 sm:grid-cols-4" }}(a denser grid, a shorter card), and a tile keeps its 44 px touch height in any (estimateOptionstyles the tile itself); the price box (aria-live) shows the total and the lines that made it, or what is missing — never "from". A fixed need shows its price. Either submits asbookSubmit("Réserver"). Without a script the answers still post and the server prices them; the live price needs the script. - The server prices the lead itself.
/quotereads the posted answers for the need's inputs only and runs the samepriceOf; a posted amount is never read. The lead is stored withflow,price.cents,price.validFromand an estimate'sprice.inputs(schema 7:flow,quoted_cents,pricing_valid_from,estimate_inputs). An estimate whose answers do not price (a stale page, a forged answer) is kept as aquote— never refused. A callback has no flow. - The price shown is the price recorded. The form also posts the price
it showed,
shown_cents(SHOWN_CENTS_FIELD) — compared, never stored. When the server's price differs (the panel changed the list under an open page), the lead is not taken: a script gets422 { ok: false, field: "price_changed", reason: "price_changed", cents }, and the card shows the fresh price in place of the old one withpriceChanged("Le prix a changé : 86 € au lieu de 77 €"); the next submit posts it and is taken. A plain post goes to/quote/confirm(confirmRoute) — never back to its card, whose page may be a cached render still showing the old price, so every resubmit would be refused again. The confirmation is never cached: it prices the answers afresh, says the price changed, and asks again for the phone and the postcode — the URL carries only the need, the answers, the page's ids and the price seen, nothing typed (a brand's extra fields are not carried). Its form posts the freshshown_centsand is taken. A page cached before the field posts none and is taken as before; so does an estimate posted without a script, whose page could not show a price. Counted aslead_form_reject {reason: "price_changed"}. - After a priced lead the card stays: the script's answer carries the
server's
centsand the lead's reference (lead,leadRef:lead-<row>-<8 hex>), and the card confirms the price (sentPrice) and offers the place's booking (below) — or shows the brand's ownbooking. The lead is stored first, so an abandoned booking is still a lead to call. Aquoteneed, and any callback, behave as before. - Photos. A
quoteneed listed inphotosoffers "Envoyez des photos" — a WhatsApp link with the need in the message — when the place has WhatsApp; the callback stays where it is. - Words.
LEAD_CAPTURE_TEXTcarries them (priceTitle,pricePending,priceNote,priceBase,priceRounding,priceMinimum,bookSubmit,sentPrice,priceChanged*,slotCallback,book*(bookPhoneHinttoo),booked,prefer*,part*,photos*); optional inLeadCaptureText, so a brand's own text from before them falls back to the kit's in the page's language (flowTextOf). - Events.
lead_estimate_shown {need, cents_bucket}once per need and price band (centsBucket:"7500-10000"), never the price;lead_booking_open {provider}andlead_booking_done {provider}(below). - Weight. About 3.9 KB gz of first-load JS on the template's place page (166,459 → 170,375 B against its 158,000 B target: +7.8 %, within the 20 % tolerance).
Booking: manual, link, google_calendar, cal_com
How a priced lead's slot is set, per place, provider-agnostic. The default is
manual — a call. A place may also have pages on link, google_calendar
and cal_com, and the booking_provider experiment picks among them; the
first A/B is google_calendar against manual. calendly is the next
adapter, on the same seams.
// shared/config/places.ts — baked, optional; the panel's place settings override it (`PlaceLive.booking`)
{ slug: "paris", …, booking: { default: "manual", providers: { google_calendar: { url: "https://calendar.app.google/…" } } } }
// shared/config/site.ts — the Cal.com hosts a place may book on (default ["cal.evinvest.ltd", "cal.com"])
export const site = defineSite({ …, booking: { calComHosts: ["cal.com", "cal.brand.fr"] } });
// the page: the experiment's variant picks the provider among the place's
<LeadCapture … bookingVariant={assignment(BOOKING_EXPERIMENT)} />
// app/quote/booking/route.ts
export const dynamic = "force-dynamic";
export const POST = bookingRoute({ env: serverEnv, webhook });- The config (
BookingConfig):{ default, providers: { <provider>: { url } } }.manualhas no page and is always available: the card promises a call and offers an optional preference — a day of the coming week and a part of the day (morning | afternoon | evening), no free text.link— any booking page, opened withref=<leadRef>.google_calendar— a schedule oncalendar.app.googleorcalendar.google.com/calendar/appointments/…, opened as is: it takes no parameter, so the card asks the visitor to type the same phone there (bookPhoneHint) and the panel matches by contact and time.cal_com—https://<allowed host>/<user>/<event>, opened withname,attendeePhoneNumber(E.164) andmetadata[ref]=<leadRef>.defaultismanualor one ofproviders; a place with no booking ismanual(DEFAULT_BOOKING). A ref always rides in the query, never the fragment.bookingConfigProblemsholds the strict URL rules; the panel mirrors them fromtest/fixtures/booking/.defineSiterefuses a baked booking or host list that does not validate; a live one that does not is dropped and the baked one kept. - The choice: one entry point.
bookingOf(place, variant)answers the provider thebooking_providervariant (BOOKING_EXPERIMENT, the same key on every brand) names, when the place has it (manualalways), else the place'sdefault.LeadCapturecalls it withbookingVariant. The chosen provider is whatbooking.requested@1andlead_booking_open/lead_booking_done {provider}carry, so the arms compare. - Nothing third-party before the click. Every provider with a page is a
plain link the browser opens in a new tab.
bookingEmbedonLeadCapture(BOOKING_EMBEDS: Cal.com's modal,calComEmbedAdapter) opens it on the page instead — only once the visitor accepted the provider's cookies: its script loads on the click, and itsbookingSuccessfulisonBooked(once per lead, however often the modal opens), the one way the page learns a slot was taken (lead_booking_done). In an A/B test both arms must open the same way, or it measures the opening. - Adapters (
BookingAdapter = { provider, href?, open?, prefillsPhone? }), picked by the chosen provider:manualAdapter,linkAdapter,googleCalendarAdapter,calComAdapter;bookingAdaptersonLeadCaptureregisters a brand's own by provider name (from a client component — a server one cannot pass functions). A provider no adapter knows promises the call. booking.requested@1. Opening a provider's page, or sending amanualpreference, posts{ submission, lead_ref, provider, preferred_date?, preferred_part? }to/quote/booking(bookingRoute). The submission id proves the lead is this page's (alead_refalone books nothing); only a priced lead (estimate,fixed) books; one request per lead. It is queued on the lead webhook's outbox behindpanelBooking(off) — see the lead webhook.ctx.leadRefis the panel's lead id. A booking comes back to the panel carryingleadRef(metadata[ref],ref,lead_ref), so a brand'slead.createdmust sendctx.leadRefas its lead id — not an id of its own.- Weight. The booking and the price confirmation add about 3.1 KB gz of first-load JS to the template's place page (170,853 → 173,979 B against its 158,000 B target: +10.1 %, within the 20 % tolerance).
Tailwind v4 does not scan node_modules; the brand's globals.css names the
package:
/* app/globals.css; from src/app/ it is ../../node_modules/… */
@source "../node_modules/@evinvest/kitstart/dist";Visual baselines: test/visual/ renders each widget to a static page
(react-dom/server over the kit's tokens.css), and nix run
.#kitstart-visual captures and compares them byte for byte on Linux. PNGs
come from the kitstart-visual CI artifact only.
Thin app/ routes
Segment config, config.matcher and next/font/local must be literals in the
route file — Next reads them statically — so the literal stays in the brand's
file and the handler comes from a factory:
// proxy.ts
export const proxy = createProxy(site);
// Files too: `decide` passes the routes outside `[locale]` and
// `site.publicFiles` (`/icon.svg`), and 404s every other path, `/wp-login.php`
// included — let past, it would be a cached bare 404 without the phone.
export const config = { matcher: ["/((?!_next/).*)"] };
// app/quote/route.ts — `anchor`: the card's id, when it is not `quote`
export const dynamic = "force-dynamic";
export const POST = quoteRoute(site, { env: serverEnv, notifier, unavailable, anchor: "devis" });
// app/quote/confirm/route.ts — a plain post whose price changed confirms here, uncached
export const dynamic = "force-dynamic";
export const GET = confirmRoute(site, { pricing, text: locale => TEXT[locale].leadCapture });
// app/quote/booking/route.ts — a priced lead's booking request (`booking.requested@1`)
export const dynamic = "force-dynamic";
export const POST = bookingRoute({ env: serverEnv, webhook });
// app/sitemap.ts — reads the Host header, so it is dynamic; pages are not
export const dynamic = "force-dynamic";
export default sitemapRoute(site, places);
// app/[locale]/[location]/layout.tsx — ISR: no page reads the request
export function generateStaticParams() { return []; }
export const revalidate = 600;The lead rule. site.lead.validate defaults to validateLead: the
number the form itself blocks on. A brand's own rule composes with it and
names the field it refuses, which the form shows the error at:
validate: lead => validateLead(lead) ?? (lead.locality === "" ? { field: "locality", why: "a postcode" } : null),A bare string still works — a refusal of the whole form. The landing
contract (describeLandingContract) holds the rule to the form's phone check
over PHONE_MATRIX (leadRuleDisagreements), so the server never refuses a
number the form let through.
The thanks page. A callback lands on /thanks?channel=callback; read it
with thanksChannel(searchParams) to promise a call instead of a quote, and
pass { thanks: true, channel } to statusTarget so the language switch
keeps it.
createPlaceLoader(site, places)(params) returns the PlaceView for a page,
reading the link mode from the location param (_royat = host mode).
Dead paths. Next 16 answers a notFound() with an empty shell it fills in
after hydration — a blank page without JavaScript. So the proxy knows the dead
paths itself (decide → gone) and rewrites each to /<locale>/404/404, a
path no route matches, with the language and place in GONE_HEADER. The
brand's app/global-not-found.tsx (experimental.globalNotFound, set by
withLanding) reads that header — the only thing that does — and renders the
404 on the server with statusTarget(site, parseGoneHeader(…)). The
notFound() boundary under [locale] stays for what the proxy cannot foresee
(a place the live source withdrew); it and error.tsx are client modules and
take brandStatusTarget({ locales, phone }, locale), never the site config,
which would carry every place into every page's bundle. Redirects the proxy
chooses per visitor answer Cache-Control: private, no-store.
Server invariants
- A page's place loader never throws on the live source. Pages are cached
(ISR); a cold render that throws is Next's bare
Internal Server Errorwith no phone on it. A 5xx or an unreachable source serves the baked place (noindexuntil the gate fields are back); only a 410, or a 404 in the source's own JSON, withdraws a place — a bare 404 is an ingress, not the source. The sitemap is the one strict reader (getPlaceStrictthrows). - The lead is durable before anything else happens. Every
LeadStoreadapter passesdescribeLeadStoreContractand migrates itself toLEAD_SCHEMA_VERSIONon open. SQLite keeps the Rust server's columns (job,zip,mobile) and recognises every earlier table in place. Schema 6 addssubmission_idunder a unique index (where present): one row per script submission, read back byfindSubmission. Schema 7 adds the flow and the price a lead was taken at (flow,quoted_cents,pricing_valid_from,estimate_inputs), all nullable. - Mail is checked at boot. With
SMTP_URLset,leadNotifierthrows when it is built if the URL is malformed (the error never repeats it), there is no sender (LEAD_NOTIFY_FROM, orleads@<domain>) or no recipient (LEAD_NOTIFY_TO, or the brand's email). Both are bare addresses ([email protected]), notName <…>. Off loopback, a server without TLS gets nothing; pastMAIL_PER_MINUTEa minute the lead is stored and only logged. - The mail names the need.
lead.subjectis the id the form posted (hot_water);leadNotifier(site, env, { needLabel })names it instead —needLabel(need)answers the label in the mail's language (the business's, not the visitor's),undefinedfor a need it does not know, which the mail prints as the id. The default mail uses it,defaultLeadMail(brand, lead, id, { needLabel })too, and a brand'sformatgets it as its third argument:(lead, id, { need }) => LeadMail. - The rate limit knows whose address it counts.
TRUSTED_PROXYiscloudflare(onlyCF-Connecting-IP) orxff:<n>(the n-thX-Forwarded-Forhop from the right); production refuses to boot without it. - SQLite wants one writer node. The file lives on a ReadWriteOnce volume
mounted by one node; pods on it take turns through
busy_timeout. A shared network filesystem is not a place for it — that is what the Postgres port is for.
Lead webhook
A signed POST of each lead to a receiver the brand names, delivered through an outbox in the leads file. The kit knows no receiver: the brand builds the body and passes the signing scheme; the kit serialises, signs, stores and retries.
// shared/config/env.ts
import { createServerEnv, leadWebhook, type LeadWebhook } from "@evinvest/kitstart/server";
export const serverEnv = createServerEnv(site);
let hook: LeadWebhook | null | undefined;
export const webhook = (): LeadWebhook | null =>
(hook ??= leadWebhook(site, serverEnv(), {
// The receiver's scheme, all of it: the kit has no defaults.
signing: {
prefix: "sa-ingest/v1.",
headers: { keyId: "x-sa-key-id", timestamp: "x-sa-timestamp", signature: "x-sa-signature" },
},
buildBody: (lead, ctx) => ({ events: [/* the receiver's shape, from lead + ctx */] }),
}));
// instrumentation.ts, in register(): resume the queue after a restart
webhook()?.start();
// app/quote/route.ts
export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavailable });- Off unless both halves are there. No
LEAD_WEBHOOK_URL, or nobuildBody→leadWebhookreturnsnull. With the URL,LEAD_WEBHOOK_KEY_IDandLEAD_WEBHOOK_SECRETare required, and the URL must behttps:, orhttp:to a*.svc/*.svc.cluster.localhost or loopback — the body carries PII. All checked at boot. - The channel.
channelOf(lead)isform,callback,whatsapportelegram. For the Service-Arb panel usectx.channel(orpanelChannel(channel, panelMessenger)): itsproperties.channelis a closed set that refuses the whole event outside it; a callback goes ascallback(panel v0.3.0 on), a messenger lead asformuntilpanelMessengeris on. - Messenger leads:
panelMessenger, off. On:ctx.channelsayswhatsapp/telegram, andctx.messageRefcarries the lead's chat reference forproperties.message_ref. Off (the default, until the panel takes them): a messenger lead goes as aform, without its reference — the leads table and the mail keep both either way. - Queued before the 303, sent after it. The body is built once, from the
lead and
ctx(leadId,brandId,locale,formId,at, and a freshidempotencyKeyfor the receiver to deduplicate by), and written to thewebhook_outboxtable before the visitor is thanked; the send runs inafter. A lead the notifier skips (honeypot, rate limit) is not queued — unlesspanelSuspect(below) queues the rate-limited one. - Suspect leads:
panelSuspect, off.suspectOf(lead)israte_limited,too_fastor nothing — neverhoneypot, whose lead goes nowhere. WithleadWebhook(…, { panelSuspect: true })a rate-limited lead is queued too (still never mailed) andctx.suspectnames why a lead is suspect, for the body'ssuspectproperty. Off by default, and to stay off until the panel'slead.createdaccepts the property: it refuses an unknown one, and the outbox would park the lead. Off, nothing changes. - The visitor's language:
panelLocale, off. WithleadWebhook(…, { panelLocale: true })ctx.panelLocaleisfroren(the site locale's language,panelLocaleOf; a locale the panel does not take leaves it absent), andpanelLocaleProperties(ctx.panelLocale)writes it aslead.created'sproperties.locale. Off by default, and to stay off until the panel accepts the property — it refuses an unknown one, and the outbox would park the lead. Off, the context and the body are as before. - The sale:
panelFlow, off. WithleadWebhook(…, { panelFlow: true })ctx.flowsays how the need was sold (panelFlowOf(lead)), andpanelFlowProperties(ctx.flow)writes it aslead.created's properties:flow;quoted_centsandpricing_valid_fromtogether, forestimateandfixedonly;estimate_inputs(input → answer, slugs, at most 12) for anestimatethat asked anything. Off by default, and to stay off until the panel accepts them — it refuses unknown properties, and the outbox would park the lead. ctx.leadRef— send it as the panel's lead id. The lead's refere
