@evinvest/kitstart 0.13.0 → 0.14.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 (50) hide show
  1. package/README.md +114 -8
  2. package/dist/core/accept.js +6 -2
  3. package/dist/core/analytics.d.ts +7 -2
  4. package/dist/core/lead-capture-text.d.ts +21 -1
  5. package/dist/core/lead-capture-text.js +16 -0
  6. package/dist/core/pricing/flow.d.ts +9 -0
  7. package/dist/core/pricing/flow.js +14 -0
  8. package/dist/core/pricing/index.d.ts +1 -1
  9. package/dist/core/pricing/index.js +1 -1
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.js +1 -1
  12. package/dist/react/LeadCapture.d.ts +42 -8
  13. package/dist/react/LeadCapture.js +171 -26
  14. package/dist/react/LeadCaptureChannels.d.ts +8 -2
  15. package/dist/react/LeadCaptureChannels.js +8 -2
  16. package/dist/react/LeadCaptureEstimate.d.ts +59 -26
  17. package/dist/react/LeadCaptureEstimate.js +81 -41
  18. package/dist/react/LeadCaptureFields.d.ts +2 -0
  19. package/dist/react/LeadCaptureFields.js +1 -1
  20. package/dist/react/LeadCaptureNeed.d.ts +27 -6
  21. package/dist/react/LeadCaptureNeed.js +10 -41
  22. package/dist/react/LeadCapturePrice.d.ts +36 -0
  23. package/dist/react/LeadCapturePrice.js +47 -0
  24. package/dist/react/LeadCaptureSteps.d.ts +47 -0
  25. package/dist/react/LeadCaptureSteps.js +39 -0
  26. package/dist/react/LeadCaptureTiles.d.ts +53 -0
  27. package/dist/react/LeadCaptureTiles.js +50 -0
  28. package/dist/react/QuoteFormShell.d.ts +5 -1
  29. package/dist/react/QuoteFormShell.js +2 -2
  30. package/dist/react/focus-next.d.ts +11 -0
  31. package/dist/react/focus-next.js +58 -0
  32. package/dist/react/index.d.ts +4 -1
  33. package/dist/react/lead-steps.d.ts +53 -0
  34. package/dist/react/lead-steps.js +58 -0
  35. package/dist/react/use-lead-context.d.ts +6 -0
  36. package/dist/react/use-lead-context.js +16 -0
  37. package/dist/react/use-lead-focus.d.ts +19 -0
  38. package/dist/react/use-lead-focus.js +70 -0
  39. package/package.json +1 -1
  40. package/template/app/[locale]/[location]/prices/page.tsx +6 -1
  41. package/template/flake.nix +1 -1
  42. package/template/package.json +2 -2
  43. package/template/src/entities/content/model/en.ts +7 -0
  44. package/template/src/entities/content/model/fr.ts +7 -0
  45. package/template/src/entities/content/model/types.ts +8 -0
  46. package/template/src/shared/config/lead.ts +5 -1
  47. package/template/src/views/prices/index.ts +1 -0
  48. package/template/src/views/prices/ui/EstimateCard.tsx +66 -0
  49. package/template/tests/bundle_budget.txt +2 -1
  50. 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.14.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 {
@@ -208,8 +208,8 @@ it is the plain POST to `/quote` it always was.
208
208
  wire={LEAD.wire}
209
209
  needs={LEAD.subjects.map(s => ({ value: s, label: t.subjects[s] }))}
210
210
  text={{ ...LEAD_CAPTURE_TEXT[view.locale], submit: "…" }}
211
- layout={arm === "b" ? "qualify-first" : "single"}
212
- experiment={{ name: "lead_layout", variant: arm }}
211
+ layout={arm === "b" ? "steps" : "single"}
212
+ experiment={{ name: "lead_form", variant: arm }}
213
213
  />
214
214
  ```
215
215
 
@@ -217,9 +217,16 @@ it is the plain POST to `/quote` it always was.
217
217
  |---|---|
218
218
  | `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
219
  | `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 |
220
+ | `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` |
221
+ | `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 |
222
+ | `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) |
223
+ | `needDisplay` | how the need is asked: `select` (default on `single`) · `tiles` (default otherwise) · `cards`, a grid with each need's `icon` |
224
+ | `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`) |
225
+ | `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` |
226
+ | `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) |
227
+ | `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" |
228
+ | `afterPhone` | a node right under the phone field: one line of reassurance |
229
+ | `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
230
  | `locality` | `required` (default) · `optional` |
224
231
  | `name` | `{ field, required? }`: a name field posted as the brand's extra; off by default |
225
232
  | `extras` | the brand's own fields, after the phone |
@@ -236,7 +243,7 @@ it is the plain POST to `/quote` it always was.
236
243
  | `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
244
  | `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
245
  | `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` |
246
+ | `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
247
 
241
248
  - **Taps.** A need the page knows is not asked again, and a place serving one
242
249
  commune fills it: focus the phone, type, send — two taps. `qualify-first`
@@ -325,7 +332,8 @@ it is the plain POST to `/quote` it always was.
325
332
  refused it, `false` the phone hint showed — the field's role, never its
326
333
  value), `lead_form_submit_error {reason, channel}` (`network` /
327
334
  `timeout`), `lead_form_step {step}`
328
- (`contact` / `need`), each with `form_id`, `layout`, `experiment`,
335
+ (`contact` / `need` in `qualify-first`; in `steps` each screen moved to —
336
+ `intro`, `need`, `estimate_<input>`, `locality`, `phone`), each with `form_id`, `layout`, `experiment`,
329
337
  `variant`; `contact_intent_click {channel}` for `phone`, `whatsapp`, `sms`,
330
338
  `callback`, with the experiment; and on the server `lead_form_submit
331
339
  {form_id, channel}` and `lead_form_reject {form_id, channel, field,
@@ -335,6 +343,104 @@ it is the plain POST to `/quote` it always was.
335
343
  within the 20 % tolerance); the refusals and the shared phone rule are
336
344
  2.0 KB of it, sending (the id, the busy state, the retry) 0.6 KB.
337
345
 
346
+ ### One question per screen: `layout="steps"`
347
+
348
+ ```tsx
349
+ <LeadCapture … layout="steps" needDisplay="cards" price="compact" taxCredit={0.5}
350
+ questions={{ surface: { unknown: true }, frequency: { display: "cards", badges: { biweekly: "Le plus avantageux" } } }}
351
+ intro={{ label: "C’est pour quand ?", field: "urgency", options: [
352
+ { value: "today", label: "Urgent — aujourd’hui", channel: "callback" },
353
+ { value: "week", label: "Cette semaine" },
354
+ ] }}
355
+ afterPhone={<p>Votre numéro reste entre nous — rappel sous 15 min.</p>} channelsDisplay="row" />
356
+ ```
357
+
358
+ - **Screens.** The intro question when there is one; the need; each of the
359
+ estimate's questions; the postcode (`localityStep="own"`, the default, or
360
+ with the phone, `with-phone`); the phone, always last, with the price, the
361
+ name and the brand's extras and the submit. A tap answers and moves on
362
+ (from the keyboard the tiles are a radio group: arrows choose, Enter or
363
+ Space answers); the postcode moves on with "Continuer" or Enter. The
364
+ focus goes to the new screen's field — inside the tap when that is a typed
365
+ field, so iOS opens the keyboard.
366
+ - **Over the screens** a thin bar (`progress`; the screen is read out as
367
+ `stepProgress`, "Étape 2/4", never drawn), "Retour" (`stepBack`), and the
368
+ screens answered as chips (`answers`, `answer`), each a tap back to its
369
+ screen with "Modifier" (`needChange`). The total counts the most screens
370
+ an answer to come could add (the longest estimate while the need is
371
+ open), so the bar only moves forward as the visitor answers; until the
372
+ intro is answered — its branches differ in length — no total is said
373
+ (`stepProgressOpen`, "Étape 1").
374
+ - **Questions sharing a screen** (`questions: { bedrooms: { step: 1, next:
375
+ "Voir les prix" }, surface: { step: 1 } }`): questions with the same `step`
376
+ number are one screen. It moves on by itself once all are answered; until
377
+ then the focus goes to the next question, and the button (`next` of the
378
+ first, else `stepNext`) says which one is missing.
379
+ - **Never asked twice.** A need the page knows (`need`, `?need=`, a
380
+ `[data-need]` card) and a postcode it knows (`?postcode=`, a place serving
381
+ one commune) are answered screens: a chip, not a question.
382
+ - **The intro** (`intro: { label, field, options }`) is posted under `field`
383
+ — declare it in `site.lead.extras` (`{ name: "urgency", max: 16 }`) and it
384
+ is stored and mailed with the lead like any extra. An answer with
385
+ `channel: "callback"` cuts the form to the phone and the callback's
386
+ consent, posted as a callback lead (`channel=callback`, `callbackSubmit`);
387
+ the folded callback is then not offered a second time.
388
+ - **A refusal** opens the screen of the field it is about (the phone's for
389
+ anything the form cannot place).
390
+ - **Without a script** every screen is in the form, hidden but posted; a
391
+ `<noscript>` style shows them all at once and hides the bar, the back link,
392
+ the chips and "Continuer": the form posts as one, as `qualify-first` does.
393
+ The server draws the same screen the script starts on — no shift at
394
+ hydration.
395
+
396
+ ### The compact form
397
+
398
+ Pieces any layout takes, for a card that fits a phone's screen:
399
+
400
+ - **The price on one line** (`price="compact"`): `priceLine` ("Votre prix :
401
+ {price}" — a brand may write "≈ {price}"), then, with `taxCredit` (a ratio
402
+ of the price a tax credit gives back, `0.5` for the French *crédit
403
+ d'impôt*), what is left to pay (`priceTaxCredit`, `data-credit-cents`),
404
+ and the breakdown and `priceNote` behind a `<details>` ("Détail",
405
+ `priceDetail`). Read out as it changes, the line only.
406
+ - **A question as cards** (`questions: { frequency: { display: "cards" } }`):
407
+ one answer a line, each with the total it would make given the other
408
+ answers (once they are given), and an optional `badges` tag per option.
409
+ - **Short labels on a phone** (`needs[].shortLabel`, `questions[id].shortLabels`):
410
+ under `sm` the tile draws the short one; the radio stays named by the
411
+ whole label (`aria-label`, with the badge and the price).
412
+ - **Badges are the brand's words** (`badges`): the kit marks nothing as
413
+ popular or cheapest by itself.
414
+ - **"Je ne sais pas"** (`questions: { surface: { unknown: true, unknownSpan: 2 } }`) adds the
415
+ answer `estimateUnknown` to that question, posted as `ESTIMATE_UNKNOWN`
416
+ (`?` — never a slug, so never an option a model prices). The questions
417
+ after it are not asked, the price goes, and the lead is a `quote`: the
418
+ submit says `submit`, a quote need's photos ask shows, and the server
419
+ stores it as a quote too (`answeredUnknown`), without the warning a stale
420
+ answer logs.
421
+ - **Need cards** (`needDisplay="cards"`): a grid of cards with each need's
422
+ `icon`; `tiles` a list without. Required either way: nothing is chosen
423
+ for the visitor, unlike the select's first need.
424
+ - **One line under the phone** (`afterPhone`) — and `text.privacy: ""` drops
425
+ the kit's privacy line, so the reassurance is said once.
426
+ - **The other channels in a row** (`channelsDisplay="row"`): one row of
427
+ compact buttons (call, WhatsApp, text, "Rappelez-moi" — its form on a line
428
+ of its own once open), named by `otherChannels` as a group, each with the
429
+ brand's `channelIcons`; a channel the place lacks is not drawn. Hiding the
430
+ row where a sticky call bar already offers the same (`classNames.others`)
431
+ is the brand's.
432
+ - **`focusNext`** on one screen: after the need's select, a tile, or an
433
+ estimate's answer, the focus moves to the next empty field; Enter in a
434
+ typed field moves to the next empty one, and submits from the last.
435
+ - **Words.** `estimateUnknown`, `priceLine`, `priceTaxCredit`, `priceDetail`,
436
+ `stepProgress`, `stepProgressOpen`, `stepBack`, `stepNext` are in `LEAD_CAPTURE_TEXT` and
437
+ optional in `LeadCaptureText`, falling back to the kit's in the page's
438
+ language (`flowTextOf`) — a brand's text from 0.13 still type-checks.
439
+ - **Weight.** The steps, the tiles, the compact price and the focus ride
440
+ with `LeadCapture` whichever layout a page uses: about 9 KB gz of
441
+ first-load JS on the template's place page (170,375 → 179,430 B against
442
+ its 158,000 B target: +13.6 %, a warning within the 20 % tolerance).
443
+
338
444
  ### Form variants: quote, estimate, fixed
339
445
 
340
446
  A need is sold one of three ways, chosen per need — one brand can mix them:
@@ -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";
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";
@@ -5,17 +5,19 @@ import { type LeadCaptureText } from "../core/lead-capture-text.js";
5
5
  import { type Place } from "../core/place/types.js";
6
6
  import { type LeadFlows } from "../core/pricing/flow.js";
7
7
  import type { PricingModel } from "../core/pricing/model.js";
8
- import type { FormSelectOption } from "./FormSelect.js";
9
8
  import type { BookingAdapters } from "./booking-adapters.js";
10
9
  import type { BookingPart } from "./LeadBookingManual.js";
11
10
  import { type PricedPart } from "./LeadCapturePriced.js";
12
- import { type ChannelPart, type Experiment } from "./LeadCaptureChannels.js";
13
- import { type EstimatePart } from "./LeadCaptureEstimate.js";
11
+ import { type ChannelIconKey, type ChannelPart, type Experiment } from "./LeadCaptureChannels.js";
12
+ import { type EstimatePart, type EstimateQuestions } from "./LeadCaptureEstimate.js";
14
13
  import { type FieldPart } from "./LeadCaptureFields.js";
15
- import { type LeadCaptureLayout } from "./LeadCaptureNeed.js";
14
+ import { type LeadCaptureLayout, type LeadNeedDisplay, type LeadNeedOption } from "./LeadCaptureNeed.js";
15
+ import { type PricePart } from "./LeadCapturePrice.js";
16
+ import { type StepsPart } from "./LeadCaptureSteps.js";
17
+ import { type LeadIntro } from "./lead-steps.js";
16
18
  import type { PartClassNames } from "./parts.js";
17
19
  import { type LeadSent } from "./use-lead-submit.js";
18
- export type LeadCapturePart = "root" | "head" | "title" | "lede" | "form" | "contact" | "submit" | "trust" | "privacy" | "opening" | "others" | "done" | "needs" | "need" | "summary" | FieldPart | ChannelPart | EstimatePart | PricedPart | BookingPart;
20
+ export type LeadCapturePart = "root" | "head" | "title" | "lede" | "form" | "contact" | "submit" | "trust" | "privacy" | "opening" | "others" | "done" | "needs" | "need" | "summary" | "icon" | "intro" | "introOption" | "stepNext" | FieldPart | ChannelPart | EstimatePart | PricePart | StepsPart | PricedPart | BookingPart;
19
21
  export interface LeadCaptureProps {
20
22
  /** Its hours order the channels; its service area suggests the commune. */
21
23
  place: Place<string>;
@@ -29,8 +31,8 @@ export interface LeadCaptureProps {
29
31
  renderedAt: number;
30
32
  /** `site.lead.wire`: the names the funnel reads. */
31
33
  wire: LeadWire;
32
- /** The brand's subjects with their labels, in order. */
33
- needs: readonly FormSelectOption[];
34
+ /** The brand's subjects with their labels, in order — and an icon each for `needDisplay="cards"`. */
35
+ needs: readonly LeadNeedOption[];
34
36
  /**
35
37
  * How each need is sold — `site.lead.flows`, the map the server prices
36
38
  * with. A need runs `estimate` or `fixed` only when `pricing` prices it so
@@ -71,8 +73,34 @@ export interface LeadCaptureProps {
71
73
  bookingEmbed?: boolean | undefined;
72
74
  /** The need the page already knows; `?need=` and `[data-need]` triggers set it too. */
73
75
  need?: string | undefined;
74
- /** `single` (one screen) or `qualify-first` (the need, then the contact) — an experiment's switch. */
76
+ /**
77
+ * `single` (one screen, the default) · `steps` (one question per screen, a
78
+ * thin bar, a way back, the phone last) · `qualify-first` (the two screens
79
+ * `steps` grew from: the need, then the contact) — an experiment's switch.
80
+ */
75
81
  layout?: LeadCaptureLayout | undefined;
82
+ /** How the need is asked: `select` (default on one screen), `tiles` (default otherwise), `cards` with the `icon` of each need. */
83
+ needDisplay?: LeadNeedDisplay | undefined;
84
+ /**
85
+ * `steps` only: a question before the need whose answer is posted as one of
86
+ * the brand's extras (`field`) and may change the channel — `callback`:
87
+ * the rest is the phone and its consent, posted as a callback.
88
+ */
89
+ intro?: LeadIntro | undefined;
90
+ /** `steps` only: the postcode on a screen of its own (default), or on the phone's. */
91
+ localityStep?: "own" | "with-phone" | undefined;
92
+ /**
93
+ * After a choice (the need, an estimate's answer) the focus moves to the
94
+ * next empty field, and Enter in a field moves to the next empty one before
95
+ * it submits. Always so in `steps`; off by default on one screen.
96
+ */
97
+ focusNext?: boolean | undefined;
98
+ /** By estimate input id: how a question is asked — `cards` with the total each answer makes, an "I don't know", badges. */
99
+ questions?: EstimateQuestions | undefined;
100
+ /** `box` (default): the price box. `compact`: one line, a tax-credit line with `taxCredit`, the breakdown behind "Détail". */
101
+ price?: "box" | "compact" | undefined;
102
+ /** `price="compact"`: the share of the price a tax credit gives back (0.5 for half), shown as what is left to pay. */
103
+ taxCredit?: number | undefined;
76
104
  locality?: "required" | "optional" | undefined;
77
105
  /** A name field, posted as the brand's extra `field`. Off by default: every field costs leads. */
78
106
  name?: {
@@ -81,6 +109,12 @@ export interface LeadCaptureProps {
81
109
  } | undefined;
82
110
  /** The brand's extra fields, after the phone — never before it. */
83
111
  extras?: ReactNode;
112
+ /** Right under the phone field: one short line of reassurance ("Votre numéro reste entre nous"). */
113
+ afterPhone?: ReactNode;
114
+ /** The other ways out: `stack` (default), under a heading, or `row`, one row of compact buttons. */
115
+ channelsDisplay?: "stack" | "row" | undefined;
116
+ /** The brand's icon before a channel's label, by channel. */
117
+ channelIcons?: Partial<Record<ChannelIconKey, ReactNode>> | undefined;
84
118
  /** Moved first when available — a `default_channel` experiment's arm. */
85
119
  prefer?: CaptureChannel | undefined;
86
120
  /** The site's assignment: on every event, and posted with the form. Slugs only. */