@evinvest/kitstart 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +178 -15
  2. package/dist/core/ab-gate.d.ts +11 -0
  3. package/dist/core/ab-gate.js +19 -0
  4. package/dist/core/ab-switcher.d.ts +33 -0
  5. package/dist/core/ab-switcher.js +61 -0
  6. package/dist/core/accept.js +6 -2
  7. package/dist/core/analytics.d.ts +7 -2
  8. package/dist/core/lead-capture-text.d.ts +21 -1
  9. package/dist/core/lead-capture-text.js +16 -0
  10. package/dist/core/pricing/flow.d.ts +9 -0
  11. package/dist/core/pricing/flow.js +14 -0
  12. package/dist/core/pricing/index.d.ts +1 -1
  13. package/dist/core/pricing/index.js +1 -1
  14. package/dist/index.d.ts +3 -1
  15. package/dist/index.js +3 -1
  16. package/dist/react/AbSwitcher.d.ts +9 -0
  17. package/dist/react/AbSwitcher.js +22 -0
  18. package/dist/react/AbSwitcherPanel.d.ts +8 -0
  19. package/dist/react/AbSwitcherPanel.js +77 -0
  20. package/dist/react/FormSelect.d.ts +6 -0
  21. package/dist/react/FormSelect.js +5 -2
  22. package/dist/react/LeadCapture.d.ts +42 -8
  23. package/dist/react/LeadCapture.js +171 -26
  24. package/dist/react/LeadCaptureChannels.d.ts +8 -2
  25. package/dist/react/LeadCaptureChannels.js +8 -2
  26. package/dist/react/LeadCaptureEstimate.d.ts +59 -26
  27. package/dist/react/LeadCaptureEstimate.js +81 -41
  28. package/dist/react/LeadCaptureFields.d.ts +2 -0
  29. package/dist/react/LeadCaptureFields.js +1 -1
  30. package/dist/react/LeadCaptureNeed.d.ts +27 -6
  31. package/dist/react/LeadCaptureNeed.js +10 -41
  32. package/dist/react/LeadCapturePrice.d.ts +36 -0
  33. package/dist/react/LeadCapturePrice.js +47 -0
  34. package/dist/react/LeadCaptureSteps.d.ts +47 -0
  35. package/dist/react/LeadCaptureSteps.js +39 -0
  36. package/dist/react/LeadCaptureTiles.d.ts +53 -0
  37. package/dist/react/LeadCaptureTiles.js +50 -0
  38. package/dist/react/QuoteFormShell.d.ts +5 -1
  39. package/dist/react/QuoteFormShell.js +2 -2
  40. package/dist/react/ab-switcher-types.d.ts +39 -0
  41. package/dist/react/ab-switcher-types.js +1 -0
  42. package/dist/react/focus-next.d.ts +11 -0
  43. package/dist/react/focus-next.js +58 -0
  44. package/dist/react/form-select-value.d.ts +6 -0
  45. package/dist/react/form-select-value.js +54 -9
  46. package/dist/react/index.d.ts +11 -3
  47. package/dist/react/index.js +6 -2
  48. package/dist/react/lead-steps.d.ts +53 -0
  49. package/dist/react/lead-steps.js +58 -0
  50. package/dist/react/use-lead-context.d.ts +6 -0
  51. package/dist/react/use-lead-context.js +16 -0
  52. package/dist/react/use-lead-focus.d.ts +19 -0
  53. package/dist/react/use-lead-focus.js +70 -0
  54. package/dist/server/experiments-declared.d.ts +7 -3
  55. package/dist/server/experiments-declared.js +1 -7
  56. package/package.json +1 -1
  57. package/template/app/[locale]/[location]/layout.tsx +4 -1
  58. package/template/app/[locale]/[location]/prices/page.tsx +6 -1
  59. package/template/flake.nix +1 -1
  60. package/template/package.json +2 -2
  61. package/template/src/entities/content/model/en.ts +7 -0
  62. package/template/src/entities/content/model/fr.ts +7 -0
  63. package/template/src/entities/content/model/types.ts +8 -0
  64. package/template/src/shared/config/experiments.ts +21 -0
  65. package/template/src/shared/config/lead.ts +5 -1
  66. package/template/src/views/prices/index.ts +1 -0
  67. package/template/src/views/prices/ui/EstimateCard.tsx +66 -0
  68. package/template/tests/bundle_budget.txt +2 -1
  69. package/template/tests/e2e/ab-switcher.spec.ts +99 -0
  70. package/template/tests/e2e/screenshot-section.css +6 -0
  71. package/template/tests/e2e/steps.spec.ts +138 -0
package/README.md CHANGED
@@ -45,7 +45,7 @@ brand's `flake.nix` is its own config plus one call — `template/flake.nix` is
45
45
  the whole of one:
46
46
 
47
47
  ```nix
48
- inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.13.0"; # the version in package-lock.json
48
+ inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.15.0"; # the version in package-lock.json
49
49
  inputs.ev.inputs.v_flakes.follows = "v_flakes";
50
50
 
51
51
  landing = ev.lib.mkLanding {
@@ -122,7 +122,7 @@ the two must be one release. Pin the flake to the matching
122
122
  Structural only — where behaviour matters more than look. Marketing sections
123
123
  (hero, prices, reviews…) stay in the brand until two brands hold the same one.
124
124
  Each widget is a Server Component unless it needs the browser (`MapFacade`,
125
- `AnalyticsBoundary`, `FormSelect`), styled with the kit's token roles only, restyled through
125
+ `AnalyticsBoundary`, `FormSelect`, `AbSwitcher`), styled with the kit's token roles only, restyled through
126
126
  `className` — and, where a brand needs geometry inside one (`Faq`, `CallBar`,
127
127
  `StatusScreen`, `PlaceDirectory`, `Coverage`), through its named parts:
128
128
  `classNames={{ list: "rounded-none", answer: "px-2" }}`, merged after the
@@ -166,6 +166,7 @@ which CI runs: `npm run check:template -- --e2e`).
166
166
  |---|---|
167
167
  | `name`, `options: { value, label }[]` | the posted field and its choices (labels are text) |
168
168
  | `defaultValue` | without it: empty under a `placeholder`, else the first option — the native rule |
169
+ | `value` | controlled: shown and posted until the parent changes it from `onValueChange`; `""` is the placeholder, `undefined` uncontrolled |
169
170
  | `placeholder` | an empty, unpickable choice shown until one is made |
170
171
  | `required`, `disabled` | as on a `<select>`: required refuses the submit, disabled posts nothing |
171
172
  | `size` | `sm` · `md` · `lg` (the landing's) — the kit's form scale |
@@ -175,7 +176,7 @@ which CI runs: `npm run check:template -- --e2e`).
175
176
 
176
177
  - **The value survives the swap.** A choice made in the native select before
177
178
  the script arrived is read in the hydration commit; a form `reset` puts the
178
- default back in both states. With nothing chosen (under a `placeholder`) it
179
+ default back in both states (uncontrolled; under `value`, see below). With nothing chosen (under a `placeholder`) it
179
180
  posts nothing, as a native select on its disabled placeholder does.
180
181
  - **`required` still refuses the submit.** A `type="hidden"` input is never
181
182
  validated, so under `required` the value rides in a transparent input under
@@ -192,6 +193,19 @@ which CI runs: `npm run check:template -- --e2e`).
192
193
  - **Weight.** It is a client module and pulls the kit's `Select` into the
193
194
  page: 3.8 KB gz of first-load JS on the template's place page (152,774 →
194
195
  156,565 B of its 158,000 B budget).
196
+ - **Controlled, as an `<input>`.** Under `value` the prop is the value: a
197
+ pick only calls `onValueChange`, and the parent's state (or a reset of its
198
+ own, `setSubject("")` back to the placeholder) is what moves it. A form
199
+ `reset` leaves it on `value` — clear the parent's state from the form's
200
+ `onReset` if it should follow. Without JavaScript it is still the native
201
+ select, starting on `value`; a pick made there before hydration is reported
202
+ through `onValueChange`. Switching between `value` and `undefined` warns
203
+ once in development, as React does for an input, and keeps the last value.
204
+
205
+ ```tsx
206
+ const [subject, setSubject] = useState<string>(LEAD.subjects[0]);
207
+ <FormSelect name={LEAD.wire.subject} size="lg" value={subject} onValueChange={setSubject} options={subjects} />
208
+ ```
195
209
 
196
210
  ### `LeadCapture`: the lead form every brand shares
197
211
 
@@ -208,8 +222,8 @@ it is the plain POST to `/quote` it always was.
208
222
  wire={LEAD.wire}
209
223
  needs={LEAD.subjects.map(s => ({ value: s, label: t.subjects[s] }))}
210
224
  text={{ ...LEAD_CAPTURE_TEXT[view.locale], submit: "…" }}
211
- layout={arm === "b" ? "qualify-first" : "single"}
212
- experiment={{ name: "lead_layout", variant: arm }}
225
+ layout={arm === "b" ? "steps" : "single"}
226
+ experiment={{ name: "lead_form", variant: arm }}
213
227
  />
214
228
  ```
215
229
 
@@ -217,9 +231,16 @@ it is the plain POST to `/quote` it always was.
217
231
  |---|---|
218
232
  | `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 |
219
233
  | `locale`, `renderedAt` | the page's language; the render stamp (time trap, and "now" until the script runs) |
220
- | `wire`, `needs` | `site.lead.wire`; the subjects with their labels |
221
- | `need` | the need the page already knows — also set by `?need=` and by a tap on any `[data-need="…"]` element |
222
- | `layout` | `single` (default) · `qualify-first`: a tile per need, then the contact step |
234
+ | `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` |
235
+ | `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 |
236
+ | `layout` | `single` (default) · `steps`: one question per screen — see [One question per screen](#one-question-per-screen-layoutsteps) · `qualify-first`: a tile per need, then the contact step (the two screens `steps` grew from, kept as they were) |
237
+ | `needDisplay` | how the need is asked: `select` (default on `single`) · `tiles` (default otherwise) · `cards`, a grid with each need's `icon` |
238
+ | `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`) |
239
+ | `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` |
240
+ | `questions` | by estimate input id: `{ display?: "tiles" \| "cards", unknown?: true, unknownSpan?: 2, badges?: { [option]: text }, shortLabels?: { [option]: text }, step?: number, next?: string }` — see [The compact form](#the-compact-form) and [steps](#one-question-per-screen-layoutsteps) |
241
+ | `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" |
242
+ | `afterPhone` | a node right under the phone field: one line of reassurance |
243
+ | `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`) |
223
244
  | `locality` | `required` (default) · `optional` |
224
245
  | `name` | `{ field, required? }`: a name field posted as the brand's extra; off by default |
225
246
  | `extras` | the brand's own fields, after the phone |
@@ -236,7 +257,7 @@ it is the plain POST to `/quote` it always was.
236
257
  | `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](#booking-manual-link-google_calendar-cal_com) |
237
258
  | `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*) |
238
259
  | `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 |
239
- | `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`, `submit`, `trust`, `privacy`, `opening`, `others`, `channel`, `primary`, `callback`, `callbackSummary`, `callbackForm`, `callbackLede`, `callbackSubmit`, `consent`, `done`, and for the flows `estimate`, `estimateInput`, `estimateLegend`, `estimateGrid` (an input's answers: 2 columns, 3 from `sm`), `estimateOption` (an answer's tile), `price`, `priceTotal`, `breakdown`, `priceNote`, `photos`, `priced`, `pricedPrice`, `pricedNote`, and for the booking `booking`, `bookingCta`, `bookingNote`, `prefer`, `preferOption`, `preferSubmit` |
260
+ | `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"`), `photos`, `priced`, `pricedPrice`, `pricedNote`, and for the booking `booking`, `bookingCta`, `bookingNote`, `prefer`, `preferOption`, `preferSubmit` |
240
261
 
241
262
  - **Taps.** A need the page knows is not asked again, and a place serving one
242
263
  commune fills it: focus the phone, type, send — two taps. `qualify-first`
@@ -325,7 +346,8 @@ it is the plain POST to `/quote` it always was.
325
346
  refused it, `false` the phone hint showed — the field's role, never its
326
347
  value), `lead_form_submit_error {reason, channel}` (`network` /
327
348
  `timeout`), `lead_form_step {step}`
328
- (`contact` / `need`), each with `form_id`, `layout`, `experiment`,
349
+ (`contact` / `need` in `qualify-first`; in `steps` each screen moved to —
350
+ `intro`, `need`, `estimate_<input>`, `locality`, `phone`), each with `form_id`, `layout`, `experiment`,
329
351
  `variant`; `contact_intent_click {channel}` for `phone`, `whatsapp`, `sms`,
330
352
  `callback`, with the experiment; and on the server `lead_form_submit
331
353
  {form_id, channel}` and `lead_form_reject {form_id, channel, field,
@@ -335,6 +357,104 @@ it is the plain POST to `/quote` it always was.
335
357
  within the 20 % tolerance); the refusals and the shared phone rule are
336
358
  2.0 KB of it, sending (the id, the busy state, the retry) 0.6 KB.
337
359
 
360
+ ### One question per screen: `layout="steps"`
361
+
362
+ ```tsx
363
+ <LeadCapture … layout="steps" needDisplay="cards" price="compact" taxCredit={0.5}
364
+ questions={{ surface: { unknown: true }, frequency: { display: "cards", badges: { biweekly: "Le plus avantageux" } } }}
365
+ intro={{ label: "C’est pour quand ?", field: "urgency", options: [
366
+ { value: "today", label: "Urgent — aujourd’hui", channel: "callback" },
367
+ { value: "week", label: "Cette semaine" },
368
+ ] }}
369
+ afterPhone={<p>Votre numéro reste entre nous — rappel sous 15 min.</p>} channelsDisplay="row" />
370
+ ```
371
+
372
+ - **Screens.** The intro question when there is one; the need; each of the
373
+ estimate's questions; the postcode (`localityStep="own"`, the default, or
374
+ with the phone, `with-phone`); the phone, always last, with the price, the
375
+ name and the brand's extras and the submit. A tap answers and moves on
376
+ (from the keyboard the tiles are a radio group: arrows choose, Enter or
377
+ Space answers); the postcode moves on with "Continuer" or Enter. The
378
+ focus goes to the new screen's field — inside the tap when that is a typed
379
+ field, so iOS opens the keyboard.
380
+ - **Over the screens** a thin bar (`progress`; the screen is read out as
381
+ `stepProgress`, "Étape 2/4", never drawn), "Retour" (`stepBack`), and the
382
+ screens answered as chips (`answers`, `answer`), each a tap back to its
383
+ screen with "Modifier" (`needChange`). The total counts the most screens
384
+ an answer to come could add (the longest estimate while the need is
385
+ open), so the bar only moves forward as the visitor answers; until the
386
+ intro is answered — its branches differ in length — no total is said
387
+ (`stepProgressOpen`, "Étape 1").
388
+ - **Questions sharing a screen** (`questions: { bedrooms: { step: 1, next:
389
+ "Voir les prix" }, surface: { step: 1 } }`): questions with the same `step`
390
+ number are one screen. It moves on by itself once all are answered; until
391
+ then the focus goes to the next question, and the button (`next` of the
392
+ first, else `stepNext`) says which one is missing.
393
+ - **Never asked twice.** A need the page knows (`need`, `?need=`, a
394
+ `[data-need]` card) and a postcode it knows (`?postcode=`, a place serving
395
+ one commune) are answered screens: a chip, not a question.
396
+ - **The intro** (`intro: { label, field, options }`) is posted under `field`
397
+ — declare it in `site.lead.extras` (`{ name: "urgency", max: 16 }`) and it
398
+ is stored and mailed with the lead like any extra. An answer with
399
+ `channel: "callback"` cuts the form to the phone and the callback's
400
+ consent, posted as a callback lead (`channel=callback`, `callbackSubmit`);
401
+ the folded callback is then not offered a second time.
402
+ - **A refusal** opens the screen of the field it is about (the phone's for
403
+ anything the form cannot place).
404
+ - **Without a script** every screen is in the form, hidden but posted; a
405
+ `<noscript>` style shows them all at once and hides the bar, the back link,
406
+ the chips and "Continuer": the form posts as one, as `qualify-first` does.
407
+ The server draws the same screen the script starts on — no shift at
408
+ hydration.
409
+
410
+ ### The compact form
411
+
412
+ Pieces any layout takes, for a card that fits a phone's screen:
413
+
414
+ - **The price on one line** (`price="compact"`): `priceLine` ("Votre prix :
415
+ {price}" — a brand may write "≈ {price}"), then, with `taxCredit` (a ratio
416
+ of the price a tax credit gives back, `0.5` for the French *crédit
417
+ d'impôt*), what is left to pay (`priceTaxCredit`, `data-credit-cents`),
418
+ and the breakdown and `priceNote` behind a `<details>` ("Détail",
419
+ `priceDetail`). Read out as it changes, the line only.
420
+ - **A question as cards** (`questions: { frequency: { display: "cards" } }`):
421
+ one answer a line, each with the total it would make given the other
422
+ answers (once they are given), and an optional `badges` tag per option.
423
+ - **Short labels on a phone** (`needs[].shortLabel`, `questions[id].shortLabels`):
424
+ under `sm` the tile draws the short one; the radio stays named by the
425
+ whole label (`aria-label`, with the badge and the price).
426
+ - **Badges are the brand's words** (`badges`): the kit marks nothing as
427
+ popular or cheapest by itself.
428
+ - **"Je ne sais pas"** (`questions: { surface: { unknown: true, unknownSpan: 2 } }`) adds the
429
+ answer `estimateUnknown` to that question, posted as `ESTIMATE_UNKNOWN`
430
+ (`?` — never a slug, so never an option a model prices). The questions
431
+ after it are not asked, the price goes, and the lead is a `quote`: the
432
+ submit says `submit`, a quote need's photos ask shows, and the server
433
+ stores it as a quote too (`answeredUnknown`), without the warning a stale
434
+ answer logs.
435
+ - **Need cards** (`needDisplay="cards"`): a grid of cards with each need's
436
+ `icon`; `tiles` a list without. Required either way: nothing is chosen
437
+ for the visitor, unlike the select's first need.
438
+ - **One line under the phone** (`afterPhone`) — and `text.privacy: ""` drops
439
+ the kit's privacy line, so the reassurance is said once.
440
+ - **The other channels in a row** (`channelsDisplay="row"`): one row of
441
+ compact buttons (call, WhatsApp, text, "Rappelez-moi" — its form on a line
442
+ of its own once open), named by `otherChannels` as a group, each with the
443
+ brand's `channelIcons`; a channel the place lacks is not drawn. Hiding the
444
+ row where a sticky call bar already offers the same (`classNames.others`)
445
+ is the brand's.
446
+ - **`focusNext`** on one screen: after the need's select, a tile, or an
447
+ estimate's answer, the focus moves to the next empty field; Enter in a
448
+ typed field moves to the next empty one, and submits from the last.
449
+ - **Words.** `estimateUnknown`, `priceLine`, `priceTaxCredit`, `priceDetail`,
450
+ `stepProgress`, `stepProgressOpen`, `stepBack`, `stepNext` are in `LEAD_CAPTURE_TEXT` and
451
+ optional in `LeadCaptureText`, falling back to the kit's in the page's
452
+ language (`flowTextOf`) — a brand's text from 0.13 still type-checks.
453
+ - **Weight.** The steps, the tiles, the compact price and the focus ride
454
+ with `LeadCapture` whichever layout a page uses: about 9 KB gz of
455
+ first-load JS on the template's place page (170,375 → 179,430 B against
456
+ its 158,000 B target: +13.6 %, a warning within the 20 % tolerance).
457
+
338
458
  ### Form variants: quote, estimate, fixed
339
459
 
340
460
  A need is sold one of three ways, chosen per need — one brand can mix them:
@@ -731,8 +851,9 @@ export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavai
731
851
 
732
852
  ## Experiments from the panel
733
853
 
734
- The experiments live in code (`@evinvest/experiments`, one `as const` config);
735
- the panel decides their weights and kill switch, and PostHog counts them.
854
+ The experiments live in code (`@evinvest/experiments`, one `as const` config
855
+ of variants, split equally); the panel decides any other weights and the kill
856
+ switch, and PostHog counts them.
736
857
 
737
858
  **Overrides** (`createExperimentsSource`): `GET <base>/experiments`, the same
738
859
  base as the place source, `{ "experiments": { "<key>": { enabled?, weights?,
@@ -756,7 +877,8 @@ declared length, holdout in `[0, 1)`) is `applyOverrides`' call.
756
877
 
757
878
  **Declaration** (`declareExperiments`): at every start the landing tells the
758
879
  panel which experiments this build runs — `experiments.declared@1`, one event
759
- with every experiment's key, variants, weights, `enabled`, `holdout` and an
880
+ with every experiment's key, variants, weights (always one each — the code's
881
+ equal split), `enabled`, `holdout` and an
760
882
  optional one-line `summary`, under a fresh UUIDv7. It goes through the lead
761
883
  webhook's outbox, signed and retried like a lead (so it needs the same
762
884
  `LEAD_WEBHOOK_URL` and sqlite lead store), and the panel keeps the latest by
@@ -773,10 +895,51 @@ export async function register() {
773
895
  It never throws and never holds the start: no webhook, one that cannot be
774
896
  built, or a queue that refuses the row is logged and skipped — the panel keeps
775
897
  the last declaration. An experiment the panel would refuse (key not
776
- `[a-z0-9_]{1,64}`, fewer than two unique slug variants, weights not one per
777
- variant, negative or summing to zero, holdout outside `[0, 1)`, summary over
898
+ `[a-z0-9_]{1,64}`, fewer than two unique slug variants, holdout outside
899
+ `[0, 1)`, summary over
778
900
  200 characters) is left out and logged, since the panel judges the event whole.
779
901
 
902
+ ### The QA switcher: `AbSwitcher`
903
+
904
+ A chip in a corner that switches an experiment's variant in one tap — for the
905
+ owner on a phone, in production. It drives the brand proxy's own mechanism and
906
+ adds no server path: a tap on a variant goes to the same URL with
907
+ `?ab_<key>=<value>`, which forces it and sets the QA cookie.
908
+
909
+ ```tsx
910
+ // app/[locale]/[location]/layout.tsx — a server layout; the props are plain data
911
+ <AbSwitcher
912
+ experiments={[{ key: "lead_form", label: "Lead form", variants: [{ value: "a", label: "Compact" }, { value: "b", label: "Steps" }] }]}
913
+ qaCookie="ab__qa" // the cookie the brand's force parameter sets
914
+ className="bottom-20 md:bottom-4" // over the brand's sticky bars; bottom-right by default
915
+ />
916
+ ```
917
+
918
+ - `current` — the assignments, if the caller knows them; left out, the panel
919
+ reads `ab_<key>` from `document.cookie` (the normal case: the page stays
920
+ static, the server reads no cookie). `forceParam` — `ab_` by default.
921
+ `text` — the menu's words, English by default.
922
+ - **Turning it on, on a phone, in production:** open any page with
923
+ `?ab_<key>=<value>`. The proxy sets the QA cookie and the chip appears;
924
+ every visit after is tagged as a test in analytics. Outside production
925
+ (`next dev`) the chip is always there.
926
+ - **What a visitor pays:** ~190 B gz of first load on the template's place
927
+ page. After hydration the gate checks `NODE_ENV` and the QA cookie
928
+ (non-empty) and only then imports the panel — a chunk of its own, ~2.1 KB
929
+ gz, that a visitor without the cookie never requests. The panel is the
930
+ kit's `Button` and `Badge` over its own small layer, not `Popover`: sharing
931
+ the overlay primitives with the page made Turbopack re-split the page's
932
+ chunks, ~400 B gz more for every visitor.
933
+ - **Reset** drops the experiments' assignment cookies (`ab_<key>`) and the
934
+ force parameters from the URL, then reloads: the variants are drawn again
935
+ and the visit stays a test, menu included. **Leave test** drops the QA cookie
936
+ too. **Minimize** and **Hide** last until the next load.
937
+ - Screenshots: the chip and its panel carry `data-ab-switcher`; hide it in the
938
+ section stylesheet, `[data-ab-switcher] { display: none !important; }`.
939
+
940
+ The decisions are plain functions in the core (`abSwitcherVisible`,
941
+ `abVariantUrl`, `abReset`, `abAssignments`), for a brand's own tests.
942
+
780
943
  ## The site
781
944
 
782
945
  ```ts
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The QA switcher's one decision a visitor pays for, apart from the rest
3
+ * (`ab-switcher.ts`) so the gate's bundle carries this function and nothing
4
+ * else: the panel's helpers arrive with the panel.
5
+ */
6
+ /**
7
+ * Whether the switcher shows: always outside production, and in production
8
+ * only to a visit carrying the QA cookie with a value — the cookie the brand's
9
+ * force parameter sets, so nobody else ever downloads the panel.
10
+ */
11
+ export declare function abSwitcherVisible(cookies: string, qaCookie: string, nodeEnv: string | undefined): boolean;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The QA switcher's one decision a visitor pays for, apart from the rest
3
+ * (`ab-switcher.ts`) so the gate's bundle carries this function and nothing
4
+ * else: the panel's helpers arrive with the panel.
5
+ */
6
+ /**
7
+ * Whether the switcher shows: always outside production, and in production
8
+ * only to a visit carrying the QA cookie with a value — the cookie the brand's
9
+ * force parameter sets, so nobody else ever downloads the panel.
10
+ */
11
+ export function abSwitcherVisible(cookies, qaCookie, nodeEnv) {
12
+ if (nodeEnv !== "production")
13
+ return true;
14
+ // Presence and non-empty only, no decoding: every visitor runs this.
15
+ return cookies.split(";").some(pair => {
16
+ const eq = pair.indexOf("=");
17
+ return eq > 0 && pair.slice(0, eq).trim() === qaCookie && pair.slice(eq + 1).trim() !== "";
18
+ });
19
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The QA switcher's decisions, as plain functions over strings: what the
3
+ * cookies assign, where a variant's tap leads, what a reset clears — whether
4
+ * the menu shows at all is `ab-gate.ts`. The lazy panel only applies them to
5
+ * `document.cookie` and `location`.
6
+ *
7
+ * The mechanism is the brand proxy's, unchanged: it keeps an assignment in a
8
+ * cookie `ab_<key>`, and `?ab_<key>=<value>` forces a variant and sets the QA
9
+ * cookie that marks the visit as a test.
10
+ */
11
+ /** The prefix of an assignment cookie: `ab_lead_form` holds `lead_form`'s variant. */
12
+ export declare const AB_COOKIE_PREFIX = "ab_";
13
+ /** The default prefix of the query parameter that forces a variant. */
14
+ export declare const AB_FORCE_PARAM = "ab_";
15
+ /** A cookie's value from a `document.cookie` string, by exact name, or `null`. */
16
+ export declare function cookieValue(cookies: string, name: string): string | null;
17
+ /** Each key's assigned variant read off the cookies; a key with none is left out. */
18
+ export declare function abAssignments(cookies: string, keys: readonly string[]): Record<string, string>;
19
+ /** The same page forcing `key` to `value`: the other parameters and the hash kept, an earlier force replaced. */
20
+ export declare function abVariantUrl(href: string, key: string, value: string, forceParam?: string): string;
21
+ export interface AbReset {
22
+ /** Each a `document.cookie` assignment that deletes one cookie. */
23
+ cookies: string[];
24
+ /** The page without the force parameters: left in, a reload would force the variant again. */
25
+ url: string;
26
+ }
27
+ /**
28
+ * What a reset clears. `"reassign"` drops the experiments' assignments only —
29
+ * the variant is drawn again and the visit stays a test, the menu with it;
30
+ * `"leave"` drops the QA cookie too. The QA cookie may itself start with the
31
+ * assignment prefix (`ab__qa`), so it is excluded by name, never by pattern.
32
+ */
33
+ export declare function abReset(href: string, keys: readonly string[], qaCookie: string, mode: "reassign" | "leave", forceParam?: string): AbReset;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The QA switcher's decisions, as plain functions over strings: what the
3
+ * cookies assign, where a variant's tap leads, what a reset clears — whether
4
+ * the menu shows at all is `ab-gate.ts`. The lazy panel only applies them to
5
+ * `document.cookie` and `location`.
6
+ *
7
+ * The mechanism is the brand proxy's, unchanged: it keeps an assignment in a
8
+ * cookie `ab_<key>`, and `?ab_<key>=<value>` forces a variant and sets the QA
9
+ * cookie that marks the visit as a test.
10
+ */
11
+ /** The prefix of an assignment cookie: `ab_lead_form` holds `lead_form`'s variant. */
12
+ export const AB_COOKIE_PREFIX = "ab_";
13
+ /** The default prefix of the query parameter that forces a variant. */
14
+ export const AB_FORCE_PARAM = "ab_";
15
+ /** A cookie's value from a `document.cookie` string, by exact name, or `null`. */
16
+ export function cookieValue(cookies, name) {
17
+ for (const pair of cookies.split(";")) {
18
+ const eq = pair.indexOf("=");
19
+ if (eq < 0 || pair.slice(0, eq).trim() !== name)
20
+ continue;
21
+ const raw = pair.slice(eq + 1).trim();
22
+ try {
23
+ return decodeURIComponent(raw);
24
+ }
25
+ catch {
26
+ return raw;
27
+ }
28
+ }
29
+ return null;
30
+ }
31
+ /** Each key's assigned variant read off the cookies; a key with none is left out. */
32
+ export function abAssignments(cookies, keys) {
33
+ const out = {};
34
+ for (const key of keys) {
35
+ const value = cookieValue(cookies, `${AB_COOKIE_PREFIX}${key}`);
36
+ if (value)
37
+ out[key] = value;
38
+ }
39
+ return out;
40
+ }
41
+ /** The same page forcing `key` to `value`: the other parameters and the hash kept, an earlier force replaced. */
42
+ export function abVariantUrl(href, key, value, forceParam = AB_FORCE_PARAM) {
43
+ const url = new URL(href);
44
+ url.searchParams.set(`${forceParam}${key}`, value);
45
+ return url.toString();
46
+ }
47
+ /**
48
+ * What a reset clears. `"reassign"` drops the experiments' assignments only —
49
+ * the variant is drawn again and the visit stays a test, the menu with it;
50
+ * `"leave"` drops the QA cookie too. The QA cookie may itself start with the
51
+ * assignment prefix (`ab__qa`), so it is excluded by name, never by pattern.
52
+ */
53
+ export function abReset(href, keys, qaCookie, mode, forceParam = AB_FORCE_PARAM) {
54
+ const names = keys.map(k => `${AB_COOKIE_PREFIX}${k}`).filter(n => n !== qaCookie);
55
+ if (mode === "leave")
56
+ names.push(qaCookie);
57
+ const url = new URL(href);
58
+ for (const key of keys)
59
+ url.searchParams.delete(`${forceParam}${key}`);
60
+ return { cookies: names.map(n => `${n}=; path=/; max-age=0`), url: url.toString() };
61
+ }
@@ -1,7 +1,7 @@
1
1
  import { ANALYTICS_ID, experimentProps } from "./analytics.js";
2
2
  import { HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, RENDERED_AT_FIELD, screen } from "./antispam.js";
3
3
  import { channelOf, MAX_FIELD, readCandidate, validateCandidate } from "./lead.js";
4
- import { flowOf, readEstimateInputs } from "./pricing/flow.js";
4
+ import { answeredUnknown, flowOf, readEstimateInputs } from "./pricing/flow.js";
5
5
  import { priceOf } from "./pricing/price.js";
6
6
  /** Hidden fields the form carries besides what the visitor types. */
7
7
  export const LOCATION_FIELD = "location";
@@ -52,7 +52,8 @@ export function leadRef(id, seed) {
52
52
  * from the posted answers through the same `priceOf` the form showed; a
53
53
  * posted amount is never read. A callback asks for a call, not a price: no
54
54
  * flow. An estimate whose answers do not price (a stale form, a tampered
55
- * field) is kept as a `quote`: the customer is still a customer.
55
+ * field) is kept as a `quote`: the customer is still a customer — and so is
56
+ * one answered "I don't know" (`ESTIMATE_UNKNOWN`), on purpose.
56
57
  */
57
58
  async function priced(site, form, lead, deps) {
58
59
  const flows = site.lead.flows;
@@ -69,6 +70,9 @@ async function priced(site, form, lead, deps) {
69
70
  const flow = flowOf(flows, model, lead.subject);
70
71
  if (flow === "quote" || !model)
71
72
  return { flow: "quote" };
73
+ // "I don't know" is an answer, not a stale form: a quote, said so, without a warning.
74
+ if (flow === "estimate" && answeredUnknown(model, lead.subject, name => field(form, name)))
75
+ return { flow: "quote" };
72
76
  const inputs = flow === "estimate" ? readEstimateInputs(model, lead.subject, name => field(form, name)) : {};
73
77
  const price = priceOf(model, lead.subject, inputs);
74
78
  if (!price) {
@@ -26,8 +26,13 @@ export type IntentChannel = "form_open" | "whatsapp" | "phone" | "sms" | "callba
26
26
  * one field reads the same on every site. A brand's extra is its own name.
27
27
  */
28
28
  export type LeadField = "need" | "locality" | "phone" | "name" | "consent" | (string & {});
29
- /** `qualify-first`'s two screens; `single` has one and never reports a step. */
30
- export type LeadStep = "need" | "contact";
29
+ /**
30
+ * A screen of the lead form, as `lead_form_step` names it. `qualify-first`'s
31
+ * two are `need` and `contact`; `steps` reports each screen it moves to —
32
+ * `intro`, `need`, `estimate_<input id>`, `locality`, `phone`. `single` has one
33
+ * screen and never reports a step. Slugs only: an input id, never an answer.
34
+ */
35
+ export type LeadStep = "need" | "contact" | "intro" | "locality" | "phone" | `estimate_${string}`;
31
36
  /**
32
37
  * Every property name an event may carry. A customer's phone or address comes
33
38
  * through the same pages, and once it reaches PostHog the only remedy is
@@ -82,7 +82,11 @@ export interface LeadCaptureText extends Partial<LeadCaptureFlowText> {
82
82
  /** The honeypot's label — read only by a bot filling every field. */
83
83
  honeypotLabel: string;
84
84
  }
85
- /** What the `estimate` and `fixed` flows, the photos ask and the booking print. */
85
+ /**
86
+ * What the `estimate` and `fixed` flows, the photos ask and the booking
87
+ * print — and every word added since (the compact price, the steps): optional
88
+ * in `LeadCaptureText`, so a brand's text from before them still type-checks.
89
+ */
86
90
  export interface LeadCaptureFlowText {
87
91
  /** Heads the live price. */
88
92
  priceTitle: string;
@@ -130,6 +134,22 @@ export interface LeadCaptureFlowText {
130
134
  photosCta: string;
131
135
  /** The WhatsApp message, `{need}` the need's label. */
132
136
  photosMessage: string;
137
+ /** The answer an estimate's question may offer (`EstimateQuestion.unknown`): the lead becomes a quote. */
138
+ estimateUnknown: string;
139
+ /** `price="compact"`: the one line, `{price}` the total. */
140
+ priceLine: string;
141
+ /** `price="compact"` with a `taxCredit`: after the line, `{price}` what is left to pay. */
142
+ priceTaxCredit: string;
143
+ /** `price="compact"`: opens how the price was reached. */
144
+ priceDetail: string;
145
+ /** `layout="steps"`: the screen read out, `{n}` of `{total}` — never drawn, the bar is. */
146
+ stepProgress: string;
147
+ /** The same before the intro is answered, when how many screens follow is not known yet: `{n}` only. */
148
+ stepProgressOpen: string;
149
+ /** `layout="steps"`: back one screen. */
150
+ stepBack: string;
151
+ /** `layout="steps"`: on to the next screen, from a field that is typed (the postcode). */
152
+ stepNext: string;
133
153
  }
134
154
  export declare const LEAD_CAPTURE_TEXT: Readonly<Record<"fr" | "en", LeadCaptureText>>;
135
155
  /** The flows' words for a page: the brand's, else the kit's in its language (English for `en*`, French otherwise). */
@@ -27,6 +27,14 @@ const FR_FLOW = {
27
27
  photosLede: "Pour ce besoin, quelques photos nous permettent de vous donner un prix juste.",
28
28
  photosCta: "Envoyer des photos sur WhatsApp",
29
29
  photosMessage: "Bonjour, voici des photos pour : {need}.",
30
+ estimateUnknown: "Je ne sais pas",
31
+ priceLine: "Votre prix : {price}",
32
+ priceTaxCredit: "{price} après crédit d’impôt",
33
+ priceDetail: "Détail",
34
+ stepProgress: "Étape {n}/{total}",
35
+ stepProgressOpen: "Étape {n}",
36
+ stepBack: "Retour",
37
+ stepNext: "Continuer",
30
38
  };
31
39
  const EN_FLOW = {
32
40
  priceTitle: "Your price",
@@ -57,6 +65,14 @@ const EN_FLOW = {
57
65
  photosLede: "For this job, a few photos let us give you a fair price.",
58
66
  photosCta: "Send photos on WhatsApp",
59
67
  photosMessage: "Hello, here are photos for: {need}.",
68
+ estimateUnknown: "I don’t know",
69
+ priceLine: "Your price: {price}",
70
+ priceTaxCredit: "{price} after tax credit",
71
+ priceDetail: "Details",
72
+ stepProgress: "Step {n} of {total}",
73
+ stepProgressOpen: "Step {n}",
74
+ stepBack: "Back",
75
+ stepNext: "Continue",
60
76
  };
61
77
  const FR = {
62
78
  ...FR_FLOW,
@@ -19,6 +19,15 @@ export declare function flowOf(flows: LeadFlows | undefined, model: PricingModel
19
19
  /** The form field an estimate's input is posted under: `estimate_<input id>`. */
20
20
  export declare const ESTIMATE_FIELD_PREFIX = "estimate_";
21
21
  export declare const estimateField: (input: string) => string;
22
+ /**
23
+ * The answer "I don't know" to an estimate's question (`EstimateQuestion.unknown`
24
+ * in `LeadCapture`): not a slug, so it can never be an option a model prices.
25
+ * A need answered with it is sold as a `quote` for that lead — on the form and
26
+ * on the server alike (`answeredUnknown`).
27
+ */
28
+ export declare const ESTIMATE_UNKNOWN = "?";
29
+ /** Whether any of the need's questions was answered "I don't know" — the lead is then a `quote`. */
30
+ export declare function answeredUnknown(model: PricingModel, need: string, get: (field: string) => string | null): boolean;
22
31
  /**
23
32
  * The answers a form posted for the need's inputs — only those, and only
24
33
  * slugs. Read by the server, which prices them itself: a posted amount, if
@@ -18,6 +18,20 @@ export function flowOf(flows, model, need) {
18
18
  /** The form field an estimate's input is posted under: `estimate_<input id>`. */
19
19
  export const ESTIMATE_FIELD_PREFIX = "estimate_";
20
20
  export const estimateField = (input) => `${ESTIMATE_FIELD_PREFIX}${input}`;
21
+ /**
22
+ * The answer "I don't know" to an estimate's question (`EstimateQuestion.unknown`
23
+ * in `LeadCapture`): not a slug, so it can never be an option a model prices.
24
+ * A need answered with it is sold as a `quote` for that lead — on the form and
25
+ * on the server alike (`answeredUnknown`).
26
+ */
27
+ export const ESTIMATE_UNKNOWN = "?";
28
+ /** Whether any of the need's questions was answered "I don't know" — the lead is then a `quote`. */
29
+ export function answeredUnknown(model, need, get) {
30
+ const pricing = Object.hasOwn(model.needs, need) ? model.needs[need] : undefined;
31
+ if (pricing?.kind !== "estimate")
32
+ return false;
33
+ return pricing.inputs.some(id => get(estimateField(id))?.trim() === ESTIMATE_UNKNOWN);
34
+ }
21
35
  /**
22
36
  * The answers a form posted for the need's inputs — only those, and only
23
37
  * slugs. Read by the server, which prices them itself: a posted amount, if
@@ -1,4 +1,4 @@
1
1
  export { PRICING_CURRENCY, PRICING_FORMAT, PRICING_LIMITS, type AddOption, type DiscountOption, type MultiplyOption, type NeedPricing, type Price, type PriceLine, type PricingInput, type PricingInputKind, type PricingInputs, type PricingLabels, type PricingModel, } from "./model.js";
2
2
  export { CENTS_BUCKETS, centsBucket, mulBp, priceOf, roundTo } from "./price.js";
3
3
  export { labelOf, parsePricingModel, PricingModelError, pricingProblems, pricingProblemsFor } from "./validate.js";
4
- export { ESTIMATE_FIELD_PREFIX, estimateField, flowOf, LEAD_FLOWS, readEstimateInputs, type LeadFlow, type LeadFlows } from "./flow.js";
4
+ export { answeredUnknown, ESTIMATE_FIELD_PREFIX, ESTIMATE_UNKNOWN, estimateField, flowOf, LEAD_FLOWS, readEstimateInputs, type LeadFlow, type LeadFlows } from "./flow.js";
@@ -1,4 +1,4 @@
1
1
  export { PRICING_CURRENCY, PRICING_FORMAT, PRICING_LIMITS, } from "./model.js";
2
2
  export { CENTS_BUCKETS, centsBucket, mulBp, priceOf, roundTo } from "./price.js";
3
3
  export { labelOf, parsePricingModel, PricingModelError, pricingProblems, pricingProblemsFor } from "./validate.js";
4
- export { ESTIMATE_FIELD_PREFIX, estimateField, flowOf, LEAD_FLOWS, readEstimateInputs } from "./flow.js";
4
+ export { answeredUnknown, ESTIMATE_FIELD_PREFIX, ESTIMATE_UNKNOWN, estimateField, flowOf, LEAD_FLOWS, readEstimateInputs } from "./flow.js";
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@ export { createPlaceView, DEFAULT_TIME_ZONE, freshRating, isOpenAt, nextOpening,
8
8
  export { createRouting, GONE, GONE_HEADER, gonePath, goneHeader, HOST_MARK, LANG_COOKIE, LANG_COOKIE_MAX_AGE, NON_PAGE_ROUTES, parseGoneHeader, parsePlaceParam, PASS_PATHS, placeParam, pointSuffixes, THANKS, type Decision, type RequestFacts, type Routing, } from "./core/routing.js";
9
9
  export { CARD_FIELD, CARD_ID, CHANNEL_FIELD, channelOf, CONSENT_FIELD, LEAD_CHANNELS, LEAD_ERROR_PARAM, LEAD_CARD_PARAM, leadErrorFor, leadErrorOf, LEAD_SCHEMA_VERSION, MAX_CONSENT, MAX_FIELD, readCandidate, rejectionOf, suspectOf, SUBMISSION_FIELD, SUBMISSION_ID, validateCallbackLead, validateCandidate, validateLead, type Lead, type LeadCandidate, type LeadChannel, type LeadError, type PageLeadError, type LeadConsent, type LeadExtra, type LeadPrice, type LeadRejection, type LeadSchema, type LeadStore, type LeadSuspect, type LeadVerdict, type LeadWire, type SpamVerdict, } from "./core/lead.js";
10
10
  export { isMobilePhone, isPlausiblePhone, normalizePhone, phoneProblem } from "./core/phone.js";
11
- export { CENTS_BUCKETS, centsBucket, ESTIMATE_FIELD_PREFIX, estimateField, flowOf, labelOf, LEAD_FLOWS, mulBp, parsePricingModel, priceOf, PRICING_CURRENCY, PRICING_FORMAT, PRICING_LIMITS, PricingModelError, pricingProblems, pricingProblemsFor, readEstimateInputs, roundTo, type AddOption, type DiscountOption, type LeadFlow, type LeadFlows, type MultiplyOption, type NeedPricing, type Price, type PriceLine, type PricingInput, type PricingInputKind, type PricingInputs, type PricingLabels, type PricingModel, } from "./core/pricing/index.js";
11
+ export { answeredUnknown, CENTS_BUCKETS, centsBucket, ESTIMATE_FIELD_PREFIX, ESTIMATE_UNKNOWN, estimateField, flowOf, labelOf, LEAD_FLOWS, mulBp, parsePricingModel, priceOf, PRICING_CURRENCY, PRICING_FORMAT, PRICING_LIMITS, PricingModelError, pricingProblems, pricingProblemsFor, readEstimateInputs, roundTo, type AddOption, type DiscountOption, type LeadFlow, type LeadFlows, type MultiplyOption, type NeedPricing, type Price, type PriceLine, type PricingInput, type PricingInputKind, type PricingInputs, type PricingLabels, type PricingModel, } from "./core/pricing/index.js";
12
12
  export { flowTextOf, LEAD_CAPTURE_TEXT, type LeadCaptureFlowText, type LeadCaptureText } from "./core/lead-capture-text.js";
13
13
  export { fillText, formatCents, openingText } from "./core/lead-capture-format.js";
14
14
  export { channelHref, resolveChannels, smsHref, type CaptureChannel, type ChannelFacts, type ChannelOptions, type ResolvedChannels, } from "./core/channels.js";
@@ -17,6 +17,8 @@ export { createAcceptLead, ANALYTICS_ID_FIELD, EXPERIMENT_FIELD, FORM_ID_FIELD,
17
17
  export { areaServedNodes, breadcrumbNode, businessId, businessNode, faqPageNode, offerNodes, organizationId, organizationNode, placeGraph, websiteId, type OfferInput, type PageGraphCopy, type QuestionAnswer, } from "./core/seo/ld.js";
18
18
  export { AI_CRAWLERS, ogImageUrl, robotsFor, sitemapFor, type RobotsFile, type RobotsRule, type SitemapEntry, } from "./core/seo/sitemap.js";
19
19
  export { ALLOWED_PROPS, ANALYTICS_ID, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, type AnalyticsTarget, type IntentChannel, type LeadField, type LeadStep, } from "./core/analytics.js";
20
+ export { AB_COOKIE_PREFIX, AB_FORCE_PARAM, abAssignments, abReset, abVariantUrl, cookieValue, type AbReset, } from "./core/ab-switcher.js";
21
+ export { abSwitcherVisible } from "./core/ab-gate.js";
20
22
  export type { CallBarText, CopySlice, CoreText, PageMetaCopy, QuoteFormCopy, Said, StatusAction, StatusCopy, StatusScreenText, } from "./core/content.js";
21
23
  export { BOOKING_LIMITS, BOOKING_PROVIDERS, BOOKING_STATUSES, BookingConfigError, bookingConfigProblems, BOOKING_EXPERIMENT, bookingHref, bookingOf, bookingRequestedProperties, bookingRequestProblems, calComHostsProblems, DEFAULT_BOOKING, DEFAULT_CAL_COM_HOSTS, isBookingProvider, isLeadRef, isPageProvider, isPreferredPart, parseBookingConfig, parseBookingRequest, PREFERRED_PARTS, type BookingChoice, type BookingConfig, type BookingPrefill, type BookingProvider, type BookingRequest, type BookingRequestedProperties, type BookingRules, type BookingStatus, type OpenBookingConfig, type PageProvider, type PreferredPart, } from "./core/booking/index.js";
22
24
  export { brandStatusTarget, statusTarget, thanksChannel, thanksSuffix, type StatusTarget } from "./core/status.js";
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ export { createPlaceView, DEFAULT_TIME_ZONE, freshRating, isOpenAt, nextOpening,
8
8
  export { createRouting, GONE, GONE_HEADER, gonePath, goneHeader, HOST_MARK, LANG_COOKIE, LANG_COOKIE_MAX_AGE, NON_PAGE_ROUTES, parseGoneHeader, parsePlaceParam, PASS_PATHS, placeParam, pointSuffixes, THANKS, } from "./core/routing.js";
9
9
  export { CARD_FIELD, CARD_ID, CHANNEL_FIELD, channelOf, CONSENT_FIELD, LEAD_CHANNELS, LEAD_ERROR_PARAM, LEAD_CARD_PARAM, leadErrorFor, leadErrorOf, LEAD_SCHEMA_VERSION, MAX_CONSENT, MAX_FIELD, readCandidate, rejectionOf, suspectOf, SUBMISSION_FIELD, SUBMISSION_ID, validateCallbackLead, validateCandidate, validateLead, } from "./core/lead.js";
10
10
  export { isMobilePhone, isPlausiblePhone, normalizePhone, phoneProblem } from "./core/phone.js";
11
- export { CENTS_BUCKETS, centsBucket, ESTIMATE_FIELD_PREFIX, estimateField, flowOf, labelOf, LEAD_FLOWS, mulBp, parsePricingModel, priceOf, PRICING_CURRENCY, PRICING_FORMAT, PRICING_LIMITS, PricingModelError, pricingProblems, pricingProblemsFor, readEstimateInputs, roundTo, } from "./core/pricing/index.js";
11
+ export { answeredUnknown, CENTS_BUCKETS, centsBucket, ESTIMATE_FIELD_PREFIX, ESTIMATE_UNKNOWN, estimateField, flowOf, labelOf, LEAD_FLOWS, mulBp, parsePricingModel, priceOf, PRICING_CURRENCY, PRICING_FORMAT, PRICING_LIMITS, PricingModelError, pricingProblems, pricingProblemsFor, readEstimateInputs, roundTo, } from "./core/pricing/index.js";
12
12
  export { flowTextOf, LEAD_CAPTURE_TEXT } from "./core/lead-capture-text.js";
13
13
  export { fillText, formatCents, openingText } from "./core/lead-capture-format.js";
14
14
  export { channelHref, resolveChannels, smsHref, } from "./core/channels.js";
@@ -17,6 +17,8 @@ export { createAcceptLead, ANALYTICS_ID_FIELD, EXPERIMENT_FIELD, FORM_ID_FIELD,
17
17
  export { areaServedNodes, breadcrumbNode, businessId, businessNode, faqPageNode, offerNodes, organizationId, organizationNode, placeGraph, websiteId, } from "./core/seo/ld.js";
18
18
  export { AI_CRAWLERS, ogImageUrl, robotsFor, sitemapFor, } from "./core/seo/sitemap.js";
19
19
  export { ALLOWED_PROPS, ANALYTICS_ID, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, } from "./core/analytics.js";
20
+ export { AB_COOKIE_PREFIX, AB_FORCE_PARAM, abAssignments, abReset, abVariantUrl, cookieValue, } from "./core/ab-switcher.js";
21
+ export { abSwitcherVisible } from "./core/ab-gate.js";
20
22
  export { BOOKING_LIMITS, BOOKING_PROVIDERS, BOOKING_STATUSES, BookingConfigError, bookingConfigProblems, BOOKING_EXPERIMENT, bookingHref, bookingOf, bookingRequestedProperties, bookingRequestProblems, calComHostsProblems, DEFAULT_BOOKING, DEFAULT_CAL_COM_HOSTS, isBookingProvider, isLeadRef, isPageProvider, isPreferredPart, parseBookingConfig, parseBookingRequest, PREFERRED_PARTS, } from "./core/booking/index.js";
21
23
  export { brandStatusTarget, statusTarget, thanksChannel, thanksSuffix } from "./core/status.js";
22
24
  // A landing needs these beside the machinery; re-exported by name so a brand