@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.
- package/README.md +178 -15
- package/dist/core/ab-gate.d.ts +11 -0
- package/dist/core/ab-gate.js +19 -0
- package/dist/core/ab-switcher.d.ts +33 -0
- package/dist/core/ab-switcher.js +61 -0
- package/dist/core/accept.js +6 -2
- package/dist/core/analytics.d.ts +7 -2
- package/dist/core/lead-capture-text.d.ts +21 -1
- package/dist/core/lead-capture-text.js +16 -0
- package/dist/core/pricing/flow.d.ts +9 -0
- package/dist/core/pricing/flow.js +14 -0
- package/dist/core/pricing/index.d.ts +1 -1
- package/dist/core/pricing/index.js +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/react/AbSwitcher.d.ts +9 -0
- package/dist/react/AbSwitcher.js +22 -0
- package/dist/react/AbSwitcherPanel.d.ts +8 -0
- package/dist/react/AbSwitcherPanel.js +77 -0
- package/dist/react/FormSelect.d.ts +6 -0
- package/dist/react/FormSelect.js +5 -2
- package/dist/react/LeadCapture.d.ts +42 -8
- package/dist/react/LeadCapture.js +171 -26
- package/dist/react/LeadCaptureChannels.d.ts +8 -2
- package/dist/react/LeadCaptureChannels.js +8 -2
- package/dist/react/LeadCaptureEstimate.d.ts +59 -26
- package/dist/react/LeadCaptureEstimate.js +81 -41
- package/dist/react/LeadCaptureFields.d.ts +2 -0
- package/dist/react/LeadCaptureFields.js +1 -1
- package/dist/react/LeadCaptureNeed.d.ts +27 -6
- package/dist/react/LeadCaptureNeed.js +10 -41
- package/dist/react/LeadCapturePrice.d.ts +36 -0
- package/dist/react/LeadCapturePrice.js +47 -0
- package/dist/react/LeadCaptureSteps.d.ts +47 -0
- package/dist/react/LeadCaptureSteps.js +39 -0
- package/dist/react/LeadCaptureTiles.d.ts +53 -0
- package/dist/react/LeadCaptureTiles.js +50 -0
- package/dist/react/QuoteFormShell.d.ts +5 -1
- package/dist/react/QuoteFormShell.js +2 -2
- package/dist/react/ab-switcher-types.d.ts +39 -0
- package/dist/react/ab-switcher-types.js +1 -0
- package/dist/react/focus-next.d.ts +11 -0
- package/dist/react/focus-next.js +58 -0
- package/dist/react/form-select-value.d.ts +6 -0
- package/dist/react/form-select-value.js +54 -9
- package/dist/react/index.d.ts +11 -3
- package/dist/react/index.js +6 -2
- package/dist/react/lead-steps.d.ts +53 -0
- package/dist/react/lead-steps.js +58 -0
- package/dist/react/use-lead-context.d.ts +6 -0
- package/dist/react/use-lead-context.js +16 -0
- package/dist/react/use-lead-focus.d.ts +19 -0
- package/dist/react/use-lead-focus.js +70 -0
- package/dist/server/experiments-declared.d.ts +7 -3
- package/dist/server/experiments-declared.js +1 -7
- package/package.json +1 -1
- package/template/app/[locale]/[location]/layout.tsx +4 -1
- package/template/app/[locale]/[location]/prices/page.tsx +6 -1
- package/template/flake.nix +1 -1
- package/template/package.json +2 -2
- package/template/src/entities/content/model/en.ts +7 -0
- package/template/src/entities/content/model/fr.ts +7 -0
- package/template/src/entities/content/model/types.ts +8 -0
- package/template/src/shared/config/experiments.ts +21 -0
- package/template/src/shared/config/lead.ts +5 -1
- package/template/src/views/prices/index.ts +1 -0
- package/template/src/views/prices/ui/EstimateCard.tsx +66 -0
- package/template/tests/bundle_budget.txt +2 -1
- package/template/tests/e2e/ab-switcher.spec.ts +99 -0
- package/template/tests/e2e/screenshot-section.css +6 -0
- 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.
|
|
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" ? "
|
|
212
|
-
experiment={{ name: "
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
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,
|
|
777
|
-
|
|
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
|
+
}
|
package/dist/core/accept.js
CHANGED
|
@@ -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) {
|
package/dist/core/analytics.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
30
|
-
|
|
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
|
-
/**
|
|
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
|