@evinvest/kitstart 0.9.0 → 0.10.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 +92 -5
- package/dist/core/accept.d.ts +23 -1
- package/dist/core/accept.js +59 -5
- package/dist/core/analytics.d.ts +2 -1
- package/dist/core/analytics.js +7 -0
- package/dist/core/lead-capture-format.d.ts +6 -0
- package/dist/core/lead-capture-format.js +13 -0
- package/dist/core/lead-capture-text.d.ts +32 -1
- package/dist/core/lead-capture-text.js +43 -0
- package/dist/core/lead.d.ts +32 -3
- package/dist/core/lead.js +3 -2
- package/dist/core/pricing/flow.d.ts +27 -0
- package/dist/core/pricing/flow.js +37 -0
- package/dist/core/pricing/index.d.ts +4 -0
- package/dist/core/pricing/index.js +4 -0
- package/dist/core/pricing/model.d.ts +129 -0
- package/dist/core/pricing/model.js +34 -0
- package/dist/core/pricing/price.d.ts +34 -0
- package/dist/core/pricing/price.js +105 -0
- package/dist/core/pricing/validate.d.ts +27 -0
- package/dist/core/pricing/validate.js +244 -0
- package/dist/core/site.d.ts +7 -0
- package/dist/core/site.js +11 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +4 -3
- package/dist/next/quote-route.d.ts +8 -1
- package/dist/next/quote-route.js +3 -1
- package/dist/react/LeadCapture.d.ts +30 -2
- package/dist/react/LeadCapture.js +18 -5
- package/dist/react/LeadCaptureChannels.d.ts +14 -2
- package/dist/react/LeadCaptureChannels.js +13 -0
- package/dist/react/LeadCaptureEstimate.d.ts +46 -0
- package/dist/react/LeadCaptureEstimate.js +69 -0
- package/dist/react/LeadCapturePriced.d.ts +19 -0
- package/dist/react/LeadCapturePriced.js +14 -0
- package/dist/react/use-lead-events.d.ts +2 -0
- package/dist/react/use-lead-events.js +10 -0
- package/dist/react/use-lead-submit.d.ts +7 -3
- package/dist/react/use-lead-submit.js +14 -7
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.js +2 -1
- package/dist/server/lead-store-sqlite.js +31 -5
- package/dist/server/lead-webhook.d.ts +48 -0
- package/dist/server/lead-webhook.js +30 -2
- package/dist/server/pricing-source.d.ts +25 -0
- package/dist/server/pricing-source.js +48 -0
- package/dist/testing/lead-store-contract.js +9 -2
- package/package.json +1 -1
- package/template/app/[locale]/[location]/page.tsx +4 -2
- package/template/app/quote/route.ts +2 -1
- package/template/flake.nix +1 -1
- package/template/package.json +1 -1
- package/template/src/entities/content/model/en.ts +1 -1
- package/template/src/entities/content/model/fr.ts +1 -1
- package/template/src/shared/config/env.ts +4 -1
- package/template/src/shared/config/lead.ts +12 -2
- package/template/src/shared/config/pricing.ts +49 -0
- package/template/src/shared/config/site.ts +3 -0
- package/template/src/views/home/ui/PlaceHome.tsx +6 -3
- package/template/tests/bundle_budget.txt +3 -1
- package/template/tests/e2e/flows.spec.ts +106 -0
- package/template/tests/e2e/mock-panel.mjs +20 -0
- package/template/tests/e2e/playwright.config.ts +25 -9
package/README.md
CHANGED
|
@@ -13,8 +13,8 @@ stays in the brand.
|
|
|
13
13
|
|
|
14
14
|
| Import | Runtime | What |
|
|
15
15
|
|---|---|---|
|
|
16
|
-
| `@evinvest/kitstart` | anywhere (edge, client, server) | `defineSite`, places, routing (`createRouting`), the lead schema and funnel (`createAcceptLead`), antispam, JSON-LD / sitemap / robots builders, analytics events, the copy contract |
|
|
17
|
-
| `@evinvest/kitstart/server` | Node, `server-only` | `createServerEnv`, `createPlaceSource`, the lead store (`openLeadStore` by `LEADS_DB_URL`: `sqlite:` today, `postgres://` a stub that refuses at boot), `checkLeadStore`, `leadNotifier`, `leadWebhook` (signed, outboxed), `sendMail`, `clientKey` |
|
|
16
|
+
| `@evinvest/kitstart` | anywhere (edge, client, server) | `defineSite`, places, routing (`createRouting`), the lead schema and funnel (`createAcceptLead`), the price list (`PricingModel`, `priceOf`, `flowOf`), antispam, JSON-LD / sitemap / robots builders, analytics events, the copy contract |
|
|
17
|
+
| `@evinvest/kitstart/server` | Node, `server-only` | `createServerEnv`, `createPlaceSource`, `createPricingSource`, the lead store (`openLeadStore` by `LEADS_DB_URL`: `sqlite:` today, `postgres://` a stub that refuses at boot), `checkLeadStore`, `leadNotifier`, `leadWebhook` (signed, outboxed), `sendMail`, `clientKey` |
|
|
18
18
|
| `@evinvest/kitstart/proxy` | edge | `createProxy(site)`, `PROXY_MATCHER` |
|
|
19
19
|
| `@evinvest/kitstart/next` | Next server (routes, RSC) | `quoteRoute`, `sitemapRoute`, `robotsRoute`, `ogRoute`, `healthRoute`, `createPlaceLoader`, `loadLocale`, `placeMetadata` / `brandMetadata` / `statusMetadata`, `metadataBase` |
|
|
20
20
|
| `@evinvest/kitstart/next/config` | `next.config.ts`, `vitest.config.ts` | `withLanding`, `buildEnv` and the `assets/` readers |
|
|
@@ -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.10.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 {
|
|
@@ -231,8 +231,10 @@ it is the plain POST to `/quote` it always was.
|
|
|
231
231
|
| `labels` | `visible` (default) · `hidden`: every field's label `sr-only` — still the field's accessible name — for a design that draws placeholders |
|
|
232
232
|
| `callbackOpen` | whether the callback starts open; by default only when it leads (the place is closed). `#<id>-callback` opens it either way |
|
|
233
233
|
| `done` | the card after a lead is taken — a node, or `(sent: LeadSent) => node` (`{ channel, phone, name }`), shown in place. Without it, a lead taken goes to the thanks page the route names. Either way a script posts the form itself (asking `/quote` for JSON), so a refusal keeps what was typed; an answer that is not the route's, or no network, submits the form for real; without a script nothing changes (303) |
|
|
234
|
+
| `flows`, `pricing`, `photos` | how each need is sold (`site.lead.flows`), the page's price list (`createPricingSource(site).model()`), and the `quote` needs priced from photos — see [Form variants](#form-variants-quote-estimate-fixed) |
|
|
235
|
+
| `booking` | after a priced lead, in place of "we call you to set the slot": a node or `(sent) => node` — a booking provider's widget, when the brand has one (none ships with the kit) |
|
|
234
236
|
| `head`, `trust` | the brand's heading instead of the title; a slot beside the submit |
|
|
235
|
-
| `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` |
|
|
237
|
+
| `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`, `estimateOption`, `price`, `priceTotal`, `breakdown`, `priceNote`, `photos`, `priced`, `pricedPrice`, `pricedNote` |
|
|
236
238
|
|
|
237
239
|
- **Taps.** A need the page knows is not asked again, and a place serving one
|
|
238
240
|
commune fills it: focus the phone, type, send — two taps. `qualify-first`
|
|
@@ -312,6 +314,80 @@ it is the plain POST to `/quote` it always was.
|
|
|
312
314
|
within the 20 % tolerance); the refusals and the shared phone rule are
|
|
313
315
|
2.0 KB of it, sending (the id, the busy state, the retry) 0.6 KB.
|
|
314
316
|
|
|
317
|
+
### Form variants: quote, estimate, fixed
|
|
318
|
+
|
|
319
|
+
A need is sold one of three ways, chosen per need — one brand can mix them:
|
|
320
|
+
|
|
321
|
+
- `quote` — the price is uncertain; the lead asks for one. The form as it was.
|
|
322
|
+
- `estimate` — the price follows from a few enum answers (zone, bedrooms,
|
|
323
|
+
surface band, frequency), shown live as the visitor taps; then the slot.
|
|
324
|
+
- `fixed` — one price for a well-defined job, shown as is; then the slot.
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
// shared/config/lead.ts — the form and the route read the same map
|
|
328
|
+
export const LEAD: LeadSchema<Subject> = { …, flows: { standard: "estimate", deep: "quote" } };
|
|
329
|
+
// shared/config/site.ts — the baked price list
|
|
330
|
+
export const site = defineSite({ …, lead: LEAD, pricing: PRICING });
|
|
331
|
+
// shared/config/env.ts — the live one, from the place source's base URL
|
|
332
|
+
export const pricing = createPricingSource(site, { baseUrl: () => serverEnv().locationsApiUrl });
|
|
333
|
+
// app/quote/route.ts
|
|
334
|
+
export const POST = quoteRoute(site, { env: serverEnv, notifier, pricing, unavailable });
|
|
335
|
+
// the page: <LeadCapture … flows={LEAD.flows} pricing={await pricing.model()} photos={["deep"]} />
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
- **The price list** (`PricingModel`) is JSON: `format` 1, `currency` `EUR`
|
|
339
|
+
(TTC), `validFrom`, `roundToCents`, `minimumCents`, `inputs` (`add` in
|
|
340
|
+
cents, `multiply` and `discount` in basis points, labels per locale) and
|
|
341
|
+
`needs` (`{ kind: "estimate", baseCents, inputs }` or `{ kind: "fixed",
|
|
342
|
+
cents }`). `priceOf(model, need, answers) → { cents, breakdown } | null`
|
|
343
|
+
is integer cents throughout, rounding half up at every product, then to
|
|
344
|
+
`roundToCents`, then the minimum. The rules and the shared fixtures the
|
|
345
|
+
panel holds its own port to are in
|
|
346
|
+
[`test/fixtures/pricing/`](./test/fixtures/pricing/README.md).
|
|
347
|
+
`defineSite` refuses a baked model that does not validate or lacks a label
|
|
348
|
+
in one of the site's locales.
|
|
349
|
+
- **The live list** (`createPricingSource`): `GET <base>/pricing`, TTL
|
|
350
|
+
600 s, 3 s timeout; an unreachable source, a non-200, a body that is not
|
|
351
|
+
JSON or a model that does not validate (for every locale of the site) all
|
|
352
|
+
serve the baked model, logged — whole or not at all, never half a model.
|
|
353
|
+
`{}` is "nothing set": the baked model, quietly.
|
|
354
|
+
- **A need runs its flow only when the list prices it so** (`flowOf`): an
|
|
355
|
+
`estimate` the model cannot price — no model, a remote model without the
|
|
356
|
+
need — is a `quote`. So a flow the brand has not switched on is never run
|
|
357
|
+
by a posted field.
|
|
358
|
+
- **The form.** An estimate's questions are a tile per answer (touch-sized
|
|
359
|
+
radios posted as `estimate_<input>`), required once the script runs; the
|
|
360
|
+
price box (`aria-live`) shows the total and the lines that made it, or
|
|
361
|
+
what is missing — never "from". A fixed need shows its price. Either
|
|
362
|
+
submits as `bookSubmit` ("Réserver"). Without a script the answers still
|
|
363
|
+
post and the server prices them; the live price needs the script.
|
|
364
|
+
- **The server prices the lead itself.** `/quote` reads the posted answers
|
|
365
|
+
for the need's inputs only and runs the same `priceOf`; a posted amount is
|
|
366
|
+
never read. The lead is stored with `flow`, `price.cents`,
|
|
367
|
+
`price.validFrom` and an estimate's `price.inputs` (schema 7: `flow`,
|
|
368
|
+
`quoted_cents`, `pricing_valid_from`, `estimate_inputs`). An estimate
|
|
369
|
+
whose answers do not price (a stale page, a forged answer) is kept as a
|
|
370
|
+
`quote` — never refused. A callback has no flow.
|
|
371
|
+
- **After a priced lead** the card stays: the script's answer carries the
|
|
372
|
+
server's `cents` and the lead's reference (`lead`, `leadRef`:
|
|
373
|
+
`lead-<row>-<8 hex>`), and the card confirms the price (`sentPrice`) and
|
|
374
|
+
promises a call to set the slot (`slotCallback`) — or shows the brand's
|
|
375
|
+
`booking`. The lead is stored first, so an abandoned booking is still a
|
|
376
|
+
lead to call. A `quote` need, and any callback, behave as before.
|
|
377
|
+
- **Photos.** A `quote` need listed in `photos` offers "Envoyez des photos"
|
|
378
|
+
— a WhatsApp link with the need in the message — when the place has
|
|
379
|
+
WhatsApp; the callback stays where it is.
|
|
380
|
+
- **Words.** `LEAD_CAPTURE_TEXT` carries them (`priceTitle`, `pricePending`,
|
|
381
|
+
`priceNote`, `priceBase`, `priceRounding`, `priceMinimum`, `bookSubmit`,
|
|
382
|
+
`sentPrice`, `slotCallback`, `photos*`); optional in `LeadCaptureText`, so
|
|
383
|
+
a brand's own text from before them falls back to the kit's in the page's
|
|
384
|
+
language (`flowTextOf`).
|
|
385
|
+
- **Events.** `lead_estimate_shown {need, cents_bucket}` once per need and
|
|
386
|
+
price band (`centsBucket`: `"7500-10000"`), never the price.
|
|
387
|
+
- **Weight.** About 3.9 KB gz of first-load JS on the template's place page
|
|
388
|
+
(166,459 → 170,375 B against its 158,000 B target: +7.8 %, within the 20 %
|
|
389
|
+
tolerance).
|
|
390
|
+
|
|
315
391
|
Tailwind v4 does not scan `node_modules`; the brand's `globals.css` names the
|
|
316
392
|
package:
|
|
317
393
|
|
|
@@ -399,7 +475,9 @@ chooses per visitor answer `Cache-Control: private, no-store`.
|
|
|
399
475
|
`LEAD_SCHEMA_VERSION` on open. SQLite keeps the Rust server's columns
|
|
400
476
|
(`job`, `zip`, `mobile`) and recognises every earlier table in place.
|
|
401
477
|
Schema 6 adds `submission_id` under a unique index (where present): one
|
|
402
|
-
row per script submission, read back by `findSubmission`.
|
|
478
|
+
row per script submission, read back by `findSubmission`. Schema 7 adds
|
|
479
|
+
the flow and the price a lead was taken at (`flow`, `quoted_cents`,
|
|
480
|
+
`pricing_valid_from`, `estimate_inputs`), all nullable.
|
|
403
481
|
- **Mail is checked at boot.** With `SMTP_URL` set, `leadNotifier` throws when
|
|
404
482
|
it is built if the URL is malformed (the error never repeats it), there is
|
|
405
483
|
no sender (`LEAD_NOTIFY_FROM`, or `leads@<domain>`) or no recipient
|
|
@@ -465,6 +543,15 @@ export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavai
|
|
|
465
543
|
suspect, for the body's `suspect` property. Off by default, and to stay off
|
|
466
544
|
until the panel's `lead.created` accepts the property: it refuses an
|
|
467
545
|
unknown one, and the outbox would park the lead. Off, nothing changes.
|
|
546
|
+
- **The sale: `panelFlow`, off.** With `leadWebhook(…, { panelFlow: true })`
|
|
547
|
+
`ctx.flow` says how the need was sold (`panelFlowOf(lead)`), and
|
|
548
|
+
`panelFlowProperties(ctx.flow)` writes it as `lead.created`'s properties:
|
|
549
|
+
`flow`; `quoted_cents` and `pricing_valid_from` together, for `estimate`
|
|
550
|
+
and `fixed` only; `estimate_inputs` (input → answer, slugs, at most 12)
|
|
551
|
+
for an `estimate` that asked anything. Off by default, and to stay off
|
|
552
|
+
until the panel accepts them — it refuses unknown properties, and the
|
|
553
|
+
outbox would park the lead. `ctx.leadRef` is the lead's reference the page
|
|
554
|
+
was answered with, whatever the switch.
|
|
468
555
|
- **The rate limit** is `LEAD_RATE_LIMIT` (`<count>/<seconds>`): 5 per 10
|
|
469
556
|
minutes per address in production, 100 outside it, where every request of
|
|
470
557
|
a local stack shares one address. Outside production a lead held back as
|
package/dist/core/accept.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type RateLimiter } from "./antispam.js";
|
|
2
2
|
import { type Lead, type LeadChannel } from "./lead.js";
|
|
3
|
+
import type { PricingModel } from "./pricing/model.js";
|
|
3
4
|
import type { Site } from "./site.js";
|
|
4
5
|
/** Hidden fields the form carries besides what the visitor types. */
|
|
5
6
|
export declare const LOCATION_FIELD = "location";
|
|
@@ -14,10 +15,14 @@ export interface SubmitTags {
|
|
|
14
15
|
variant?: string;
|
|
15
16
|
}
|
|
16
17
|
export type Outcome<L extends string> =
|
|
17
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* `ref`: the lead's public reference (`leadRef`). `duplicate`: a resend of
|
|
20
|
+
* a submission already stored — answered as the first was, nothing sent again.
|
|
21
|
+
*/
|
|
18
22
|
{
|
|
19
23
|
kind: "stored";
|
|
20
24
|
id: number;
|
|
25
|
+
ref: string;
|
|
21
26
|
lead: Lead;
|
|
22
27
|
locale: L;
|
|
23
28
|
formId: string;
|
|
@@ -56,7 +61,14 @@ export interface AcceptDeps {
|
|
|
56
61
|
enqueue?: (lead: Lead, id: number, meta: {
|
|
57
62
|
locale: string;
|
|
58
63
|
formId: string;
|
|
64
|
+
leadRef: string;
|
|
59
65
|
}) => void;
|
|
66
|
+
/**
|
|
67
|
+
* The price list to price an `estimate` or `fixed` lead with
|
|
68
|
+
* (`createPricingSource(…).model`); absent → `site.pricing`. A failure is
|
|
69
|
+
* no model: the lead is kept as a `quote`, never refused.
|
|
70
|
+
*/
|
|
71
|
+
pricing?: () => Promise<PricingModel | null>;
|
|
60
72
|
/**
|
|
61
73
|
* Queue a rate-limited lead too, for the panel to show as suspect
|
|
62
74
|
* (`LeadWebhookOptions.panelSuspect`). Off: it waits in the table, as before.
|
|
@@ -68,6 +80,16 @@ export interface AcceptDeps {
|
|
|
68
80
|
now: number;
|
|
69
81
|
log: Pick<Console, "warn" | "error">;
|
|
70
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* The lead's public reference, `lead-<row>-<8 hex>`: the one the script's
|
|
85
|
+
* answer gives the page (`LeadSent.lead`) and the webhook offers the panel
|
|
86
|
+
* (`LeadWebhookContext.leadRef`), so whatever the page does next — a booking,
|
|
87
|
+
* once there is a provider — can name its lead. The
|
|
88
|
+
* row id for a person matching it to the mail; the tag because row ids start
|
|
89
|
+
* over if the leads file is ever recreated. Seeded by the submission id when
|
|
90
|
+
* there is one, so a resend is answered with the same reference.
|
|
91
|
+
*/
|
|
92
|
+
export declare function leadRef(id: number, seed: string): string;
|
|
71
93
|
/**
|
|
72
94
|
* The funnel's commit point, independent of HTTP. Order is the invariant:
|
|
73
95
|
* validate → screen → **insert** → (deferred) notify and capture.
|
package/dist/core/accept.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { 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";
|
|
5
|
+
import { priceOf } from "./pricing/price.js";
|
|
4
6
|
/** Hidden fields the form carries besides what the visitor types. */
|
|
5
7
|
export const LOCATION_FIELD = "location";
|
|
6
8
|
export const LOCALE_FIELD = "locale";
|
|
@@ -13,6 +15,55 @@ function field(form, name) {
|
|
|
13
15
|
const value = form.get(name);
|
|
14
16
|
return typeof value === "string" ? value.trim().slice(0, MAX_FIELD) : null;
|
|
15
17
|
}
|
|
18
|
+
/** FNV-1a, 32 bits, as 8 hex: a tag, not a secret — the core has no `node:crypto`. */
|
|
19
|
+
function tag(seed) {
|
|
20
|
+
let h = 0x811c9dc5;
|
|
21
|
+
for (let i = 0; i < seed.length; i++)
|
|
22
|
+
h = Math.imul(h ^ seed.charCodeAt(i), 0x01000193);
|
|
23
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The lead's public reference, `lead-<row>-<8 hex>`: the one the script's
|
|
27
|
+
* answer gives the page (`LeadSent.lead`) and the webhook offers the panel
|
|
28
|
+
* (`LeadWebhookContext.leadRef`), so whatever the page does next — a booking,
|
|
29
|
+
* once there is a provider — can name its lead. The
|
|
30
|
+
* row id for a person matching it to the mail; the tag because row ids start
|
|
31
|
+
* over if the leads file is ever recreated. Seeded by the submission id when
|
|
32
|
+
* there is one, so a resend is answered with the same reference.
|
|
33
|
+
*/
|
|
34
|
+
export function leadRef(id, seed) {
|
|
35
|
+
return `lead-${id}-${tag(seed)}`;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* How the lead's need is sold and at what price — the server's own number,
|
|
39
|
+
* from the posted answers through the same `priceOf` the form showed; a
|
|
40
|
+
* posted amount is never read. A callback asks for a call, not a price: no
|
|
41
|
+
* flow. An estimate whose answers do not price (a stale form, a tampered
|
|
42
|
+
* field) is kept as a `quote`: the customer is still a customer.
|
|
43
|
+
*/
|
|
44
|
+
async function priced(site, form, lead, deps) {
|
|
45
|
+
const flows = site.lead.flows;
|
|
46
|
+
if (!flows || channelOf(lead) === "callback")
|
|
47
|
+
return {};
|
|
48
|
+
let model;
|
|
49
|
+
try {
|
|
50
|
+
model = deps.pricing ? await deps.pricing() : (site.pricing ?? null);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
deps.log.error("quote: the price list could not be read; the lead is kept as a quote", error);
|
|
54
|
+
model = null;
|
|
55
|
+
}
|
|
56
|
+
const flow = flowOf(flows, model, lead.subject);
|
|
57
|
+
if (flow === "quote" || !model)
|
|
58
|
+
return { flow: "quote" };
|
|
59
|
+
const inputs = flow === "estimate" ? readEstimateInputs(model, lead.subject, name => field(form, name)) : {};
|
|
60
|
+
const price = priceOf(model, lead.subject, inputs);
|
|
61
|
+
if (!price) {
|
|
62
|
+
deps.log.warn(`quote: an ${flow} for ${lead.subject} did not price from what was posted; kept as a quote`);
|
|
63
|
+
return { flow: "quote" };
|
|
64
|
+
}
|
|
65
|
+
return { flow, price: { cents: price.cents, validFrom: model.validFrom, ...(flow === "estimate" ? { inputs } : {}) } };
|
|
66
|
+
}
|
|
16
67
|
/**
|
|
17
68
|
* The funnel's commit point, independent of HTTP. Order is the invariant:
|
|
18
69
|
* validate → screen → **insert** → (deferred) notify and capture.
|
|
@@ -60,7 +111,8 @@ async function accept(site, form, clientKey, deps) {
|
|
|
60
111
|
// second row, no second mail, the limit not spent again.
|
|
61
112
|
const prior = await stored(candidate.submissionId, deps);
|
|
62
113
|
if (prior)
|
|
63
|
-
return { kind: "stored", id: prior.id, lead: prior.lead, locale, formId, duplicate: true };
|
|
114
|
+
return { kind: "stored", id: prior.id, ref: leadRef(prior.id, candidate.submissionId ?? ""), lead: prior.lead, locale, formId, duplicate: true };
|
|
115
|
+
const sale = await priced(site, form, candidate, deps);
|
|
64
116
|
// After validation, so a typo corrected and resent does not spend the limit.
|
|
65
117
|
const verdict = screen({
|
|
66
118
|
honeypot: [HONEYPOT_FIELD, ...LEGACY_HONEYPOT_FIELDS].map(name => field(form, name)).find(v => v) ?? null,
|
|
@@ -72,6 +124,7 @@ async function accept(site, form, clientKey, deps) {
|
|
|
72
124
|
const { consentText, ...rest } = candidate;
|
|
73
125
|
const lead = {
|
|
74
126
|
...rest,
|
|
127
|
+
...sale,
|
|
75
128
|
spamVerdict: verdict === "ok" ? null : verdict,
|
|
76
129
|
// Stamped by the server: the moment it accepted the consent with the lead.
|
|
77
130
|
...(consentText ? { consent: { text: consentText, at: new Date(deps.now).toISOString() } } : {}),
|
|
@@ -84,10 +137,11 @@ async function accept(site, form, clientKey, deps) {
|
|
|
84
137
|
// Two posts of one submission raced past the lookup; the unique index let one in.
|
|
85
138
|
const winner = await stored(candidate.submissionId, deps);
|
|
86
139
|
if (winner)
|
|
87
|
-
return { kind: "stored", id: winner.id, lead: winner.lead, locale, formId, duplicate: true };
|
|
140
|
+
return { kind: "stored", id: winner.id, ref: leadRef(winner.id, candidate.submissionId ?? ""), lead: winner.lead, locale, formId, duplicate: true };
|
|
88
141
|
deps.log.error(`quote: the lead store rejected a submission for ${slug ?? "no point"}`, error);
|
|
89
142
|
return { kind: "failed", locale, slug };
|
|
90
143
|
}
|
|
144
|
+
const ref = leadRef(id, candidate.submissionId ?? `${id}:${deps.now}`);
|
|
91
145
|
// The render stamp comes from the client, so it only marks: a `too-fast`
|
|
92
146
|
// lead (or one from a page cached before the stamp existed) is still sent
|
|
93
147
|
// on, flagged. The honeypot and the rate limit are the server's own
|
|
@@ -96,7 +150,7 @@ async function accept(site, form, clientKey, deps) {
|
|
|
96
150
|
if (!deps.enqueue)
|
|
97
151
|
return;
|
|
98
152
|
try {
|
|
99
|
-
deps.enqueue(lead, id, { locale, formId });
|
|
153
|
+
deps.enqueue(lead, id, { locale, formId, leadRef: ref });
|
|
100
154
|
}
|
|
101
155
|
catch (error) {
|
|
102
156
|
deps.log.error(`quote: lead ${id} is stored but could not be queued for its webhook`, error);
|
|
@@ -107,7 +161,7 @@ async function accept(site, form, clientKey, deps) {
|
|
|
107
161
|
deps.log.warn(`quote: lead ${id} stored as suspected spam (${lead.spamVerdict}); not notified${marked ? ", queued marked suspect" : ""}`);
|
|
108
162
|
if (marked)
|
|
109
163
|
queue();
|
|
110
|
-
return { kind: "stored", id, lead, locale, formId };
|
|
164
|
+
return { kind: "stored", id, ref, lead, locale, formId };
|
|
111
165
|
}
|
|
112
166
|
queue();
|
|
113
167
|
deps.defer(async () => {
|
|
@@ -120,5 +174,5 @@ async function accept(site, form, clientKey, deps) {
|
|
|
120
174
|
});
|
|
121
175
|
const tags = experimentProps(field(form, EXPERIMENT_FIELD), field(form, VARIANT_FIELD));
|
|
122
176
|
deps.defer(() => deps.capture(lead, formId, tags));
|
|
123
|
-
return { kind: "stored", id, lead, locale, formId };
|
|
177
|
+
return { kind: "stored", id, ref, lead, locale, formId };
|
|
124
178
|
}
|
package/dist/core/analytics.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export declare const EVENTS: {
|
|
|
16
16
|
readonly formStep: "lead_form_step";
|
|
17
17
|
readonly submitError: "lead_form_submit_error";
|
|
18
18
|
readonly formReject: "lead_form_reject";
|
|
19
|
+
readonly estimateShown: "lead_estimate_shown";
|
|
19
20
|
};
|
|
20
21
|
export type IntentChannel = "form_open" | "whatsapp" | "phone" | "sms" | "callback" | "booking";
|
|
21
22
|
/**
|
|
@@ -32,7 +33,7 @@ export type LeadStep = "need" | "contact";
|
|
|
32
33
|
* throws, prod drops) rather than by review. `experiment` and `variant` are
|
|
33
34
|
* the site's assignment, passed in; `field` is a field's role, never its value.
|
|
34
35
|
*/
|
|
35
|
-
export declare const ALLOWED_PROPS: readonly ["brand_id", "location_id", "source", "device", "channel", "form_id", "experiment", "variant", "field", "step", "layout", "reason", "blocking"];
|
|
36
|
+
export declare const ALLOWED_PROPS: readonly ["brand_id", "location_id", "source", "device", "channel", "form_id", "experiment", "variant", "field", "step", "layout", "reason", "blocking", "need", "cents_bucket"];
|
|
36
37
|
/** What an experiment's assignment looks like on the wire: short slugs, nothing a person typed. */
|
|
37
38
|
export declare const EXPERIMENT_SLUG: RegExp;
|
|
38
39
|
/** An experiment's assignment as event properties, or nothing when it is not a pair of slugs. */
|
package/dist/core/analytics.js
CHANGED
|
@@ -21,6 +21,9 @@ export const EVENTS = {
|
|
|
21
21
|
submitError: "lead_form_submit_error",
|
|
22
22
|
// The server's refusal (`reason`, the `field`): the visitor was sent back.
|
|
23
23
|
formReject: "lead_form_reject",
|
|
24
|
+
// An estimate's price, shown for a full set of answers: the need and the
|
|
25
|
+
// price's band (`centsBucket`), never the price — once per band and need.
|
|
26
|
+
estimateShown: "lead_estimate_shown",
|
|
24
27
|
};
|
|
25
28
|
/**
|
|
26
29
|
* Every property name an event may carry. A customer's phone or address comes
|
|
@@ -44,6 +47,10 @@ export const ALLOWED_PROPS = [
|
|
|
44
47
|
"reason",
|
|
45
48
|
// A field error that blocked the submit (`true`), or a hint (`false`).
|
|
46
49
|
"blocking",
|
|
50
|
+
// The brand's subject slug.
|
|
51
|
+
"need",
|
|
52
|
+
// A price's band, `"5000-7500"` (`centsBucket`) — never the price.
|
|
53
|
+
"cents_bucket",
|
|
47
54
|
];
|
|
48
55
|
/** What an experiment's assignment looks like on the wire: short slugs, nothing a person typed. */
|
|
49
56
|
export const EXPERIMENT_SLUG = /^[a-z0-9_-]{1,32}$/;
|
|
@@ -4,3 +4,9 @@ import type { Opening } from "./place/hours.js";
|
|
|
4
4
|
export declare function fillText(template: string, values: Readonly<Record<string, string>>): string;
|
|
5
5
|
/** The promise of the next opening, from real hours — or `null`, never an invented one. */
|
|
6
6
|
export declare function openingText(opening: Opening | null, text: Pick<LeadCaptureText, "openingToday" | "openingTomorrow" | "openingLater">, locale: string): string | null;
|
|
7
|
+
/**
|
|
8
|
+
* Cents as the page's language writes euros: `84 €`, `84,50 €`, `€84.50`.
|
|
9
|
+
* Whole euros drop the cents a rounded price never has; a signed line of a
|
|
10
|
+
* breakdown is `+8,50 €` / `−9,35 €`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function formatCents(cents: number, locale: string, signed?: boolean): string;
|
|
@@ -30,3 +30,16 @@ export function openingText(opening, text, locale) {
|
|
|
30
30
|
return fillText(text.openingTomorrow, { time });
|
|
31
31
|
return fillText(text.openingLater, { time, day: weekdayText(opening.day, locale) });
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Cents as the page's language writes euros: `84 €`, `84,50 €`, `€84.50`.
|
|
35
|
+
* Whole euros drop the cents a rounded price never has; a signed line of a
|
|
36
|
+
* breakdown is `+8,50 €` / `−9,35 €`.
|
|
37
|
+
*/
|
|
38
|
+
export function formatCents(cents, locale, signed = false) {
|
|
39
|
+
const digits = cents % 100 === 0 ? 0 : 2;
|
|
40
|
+
const format = new Intl.NumberFormat(locale, { style: "currency", currency: "EUR", minimumFractionDigits: digits, maximumFractionDigits: digits });
|
|
41
|
+
const text = format.format(Math.abs(cents) / 100);
|
|
42
|
+
if (!signed)
|
|
43
|
+
return cents < 0 ? `−${text}` : text;
|
|
44
|
+
return `${cents < 0 ? "−" : "+"}${text}`;
|
|
45
|
+
}
|
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
* The kit ships French and English (`LEAD_CAPTURE_TEXT`) so every brand asks
|
|
8
8
|
* in the same words and an experiment's arms differ only in what it tests; a
|
|
9
9
|
* brand overrides a key by spreading: `{ ...LEAD_CAPTURE_TEXT.fr, submit: "…" }`.
|
|
10
|
+
*
|
|
11
|
+
* The flows' words (`LeadCaptureFlowText`) are optional here so a brand's own
|
|
12
|
+
* text object written before them still type-checks; a key left out falls
|
|
13
|
+
* back to the kit's, in the page's language (`flowTextOf`).
|
|
10
14
|
*/
|
|
11
|
-
export interface LeadCaptureText {
|
|
15
|
+
export interface LeadCaptureText extends Partial<LeadCaptureFlowText> {
|
|
12
16
|
title: string;
|
|
13
17
|
lede: string;
|
|
14
18
|
needLabel: string;
|
|
@@ -78,4 +82,31 @@ export interface LeadCaptureText {
|
|
|
78
82
|
/** The honeypot's label — read only by a bot filling every field. */
|
|
79
83
|
honeypotLabel: string;
|
|
80
84
|
}
|
|
85
|
+
/** What the `estimate` and `fixed` flows, the photos ask and the booking print. */
|
|
86
|
+
export interface LeadCaptureFlowText {
|
|
87
|
+
/** Heads the live price. */
|
|
88
|
+
priceTitle: string;
|
|
89
|
+
/** In place of the price until every input is answered. */
|
|
90
|
+
pricePending: string;
|
|
91
|
+
/** Under the price: what it includes and when it is paid. */
|
|
92
|
+
priceNote: string;
|
|
93
|
+
/** The breakdown's first line: the need's base. */
|
|
94
|
+
priceBase: string;
|
|
95
|
+
priceRounding: string;
|
|
96
|
+
priceMinimum: string;
|
|
97
|
+
/** The submit of a priced need. */
|
|
98
|
+
bookSubmit: string;
|
|
99
|
+
/** The in-card success of a priced lead; `{price}` the server's. */
|
|
100
|
+
sentPrice: string;
|
|
101
|
+
/** After a priced lead: the slot is set by a call (no booking provider yet). */
|
|
102
|
+
slotCallback: string;
|
|
103
|
+
/** A `quote` need that is priced from photos: the ask, and its WhatsApp link. */
|
|
104
|
+
photosTitle: string;
|
|
105
|
+
photosLede: string;
|
|
106
|
+
photosCta: string;
|
|
107
|
+
/** The WhatsApp message, `{need}` the need's label. */
|
|
108
|
+
photosMessage: string;
|
|
109
|
+
}
|
|
81
110
|
export declare const LEAD_CAPTURE_TEXT: Readonly<Record<"fr" | "en", LeadCaptureText>>;
|
|
111
|
+
/** The flows' words for a page: the brand's, else the kit's in its language (English for `en*`, French otherwise). */
|
|
112
|
+
export declare function flowTextOf(text: LeadCaptureText, locale: string): LeadCaptureFlowText;
|
|
@@ -1,4 +1,35 @@
|
|
|
1
|
+
const FR_FLOW = {
|
|
2
|
+
priceTitle: "Votre prix",
|
|
3
|
+
pricePending: "Répondez aux questions pour voir le prix.",
|
|
4
|
+
priceNote: "Prix TTC pour ces réponses, payé après la prestation.",
|
|
5
|
+
priceBase: "Prix de base",
|
|
6
|
+
priceRounding: "Arrondi",
|
|
7
|
+
priceMinimum: "Minimum de prestation",
|
|
8
|
+
bookSubmit: "Réserver",
|
|
9
|
+
sentPrice: "Demande enregistrée au prix de {price}.",
|
|
10
|
+
slotCallback: "Nous vous rappelons pour fixer le créneau.",
|
|
11
|
+
photosTitle: "Envoyez des photos",
|
|
12
|
+
photosLede: "Pour ce besoin, quelques photos nous permettent de vous donner un prix juste.",
|
|
13
|
+
photosCta: "Envoyer des photos sur WhatsApp",
|
|
14
|
+
photosMessage: "Bonjour, voici des photos pour : {need}.",
|
|
15
|
+
};
|
|
16
|
+
const EN_FLOW = {
|
|
17
|
+
priceTitle: "Your price",
|
|
18
|
+
pricePending: "Answer the questions to see the price.",
|
|
19
|
+
priceNote: "Price incl. VAT for these answers, paid after the job.",
|
|
20
|
+
priceBase: "Base price",
|
|
21
|
+
priceRounding: "Rounding",
|
|
22
|
+
priceMinimum: "Minimum charge",
|
|
23
|
+
bookSubmit: "Book",
|
|
24
|
+
sentPrice: "Request saved at {price}.",
|
|
25
|
+
slotCallback: "We will call you to set the slot.",
|
|
26
|
+
photosTitle: "Send photos",
|
|
27
|
+
photosLede: "For this job, a few photos let us give you a fair price.",
|
|
28
|
+
photosCta: "Send photos on WhatsApp",
|
|
29
|
+
photosMessage: "Hello, here are photos for: {need}.",
|
|
30
|
+
};
|
|
1
31
|
const FR = {
|
|
32
|
+
...FR_FLOW,
|
|
2
33
|
title: "Recevoir un prix",
|
|
3
34
|
lede: "Réponse rapide, sans engagement.",
|
|
4
35
|
needLabel: "Votre besoin",
|
|
@@ -37,6 +68,7 @@ const FR = {
|
|
|
37
68
|
honeypotLabel: "Site web",
|
|
38
69
|
};
|
|
39
70
|
const EN = {
|
|
71
|
+
...EN_FLOW,
|
|
40
72
|
title: "Get a price",
|
|
41
73
|
lede: "A quick answer, no commitment.",
|
|
42
74
|
needLabel: "What you need",
|
|
@@ -75,3 +107,14 @@ const EN = {
|
|
|
75
107
|
honeypotLabel: "Website",
|
|
76
108
|
};
|
|
77
109
|
export const LEAD_CAPTURE_TEXT = { fr: FR, en: EN };
|
|
110
|
+
/** The flows' words for a page: the brand's, else the kit's in its language (English for `en*`, French otherwise). */
|
|
111
|
+
export function flowTextOf(text, locale) {
|
|
112
|
+
const kit = locale.startsWith("en") ? EN_FLOW : FR_FLOW;
|
|
113
|
+
const out = { ...kit };
|
|
114
|
+
for (const key of Object.keys(kit)) {
|
|
115
|
+
const own = text[key];
|
|
116
|
+
if (own !== undefined)
|
|
117
|
+
out[key] = own;
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
package/dist/core/lead.d.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* `LeadSchema.wire` names the posted fields (a page cached before a rename
|
|
10
10
|
* still delivers) — both deliberate departures from the first design.
|
|
11
11
|
*/
|
|
12
|
+
import type { LeadFlow, LeadFlows } from "./pricing/flow.js";
|
|
13
|
+
import type { PricingInputs } from "./pricing/model.js";
|
|
12
14
|
/** Why a submission was kept but not acted on. `null` is a clean lead. */
|
|
13
15
|
export type SpamVerdict = "honeypot" | "too-fast" | "rate-limited";
|
|
14
16
|
/**
|
|
@@ -73,6 +75,13 @@ export interface LeadSchema<S extends string> {
|
|
|
73
75
|
* number as typed.
|
|
74
76
|
*/
|
|
75
77
|
mobileFormat?: "typed" | "e164";
|
|
78
|
+
/**
|
|
79
|
+
* How each need is sold (`quote` | `estimate` | `fixed`); a need left out
|
|
80
|
+
* is a `quote`. The form and the server read the same map, and a need runs
|
|
81
|
+
* its flow only when the price list prices it that way (`flowOf`) — so a
|
|
82
|
+
* flow the brand has not switched on is never run by a posted field.
|
|
83
|
+
*/
|
|
84
|
+
flows?: LeadFlows;
|
|
76
85
|
}
|
|
77
86
|
/**
|
|
78
87
|
* How the lead was asked for: the quote form, or "call me back" — the form cut
|
|
@@ -139,6 +148,25 @@ export interface Lead {
|
|
|
139
148
|
* Absent on a plain post, which has no script to mint one.
|
|
140
149
|
*/
|
|
141
150
|
submissionId?: string;
|
|
151
|
+
/**
|
|
152
|
+
* How the need was sold, when the brand sells by flow (`LeadSchema.flows`)
|
|
153
|
+
* and the lead came through the form; absent otherwise — a callback, or a
|
|
154
|
+
* lead from before the field existed.
|
|
155
|
+
*/
|
|
156
|
+
flow?: LeadFlow;
|
|
157
|
+
/** Only on an `estimate` or `fixed` lead: the price the server computed. */
|
|
158
|
+
price?: LeadPrice;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The price a lead was taken at — the server's own number, from the posted
|
|
162
|
+
* answers, never a posted amount — and the model it came from.
|
|
163
|
+
*/
|
|
164
|
+
export interface LeadPrice {
|
|
165
|
+
cents: number;
|
|
166
|
+
/** The model's `validFrom`, `YYYY-MM-DD`. */
|
|
167
|
+
validFrom: string;
|
|
168
|
+
/** An estimate's answers, input id → option id; absent for a fixed price. */
|
|
169
|
+
inputs?: PricingInputs;
|
|
142
170
|
}
|
|
143
171
|
/** What the panel may be told of a suspected lead. */
|
|
144
172
|
export type LeadSuspect = "rate_limited" | "too_fast";
|
|
@@ -154,7 +182,7 @@ export declare function channelOf(lead: Pick<Lead, "channel">): LeadChannel;
|
|
|
154
182
|
* What the form posted, before the funnel judges it: the consent is the
|
|
155
183
|
* posted sentence, and the funnel stamps when it accepted it.
|
|
156
184
|
*/
|
|
157
|
-
export type LeadCandidate = Omit<Lead, "spamVerdict" | "consent"> & {
|
|
185
|
+
export type LeadCandidate = Omit<Lead, "spamVerdict" | "consent" | "flow" | "price"> & {
|
|
158
186
|
consentText?: string;
|
|
159
187
|
};
|
|
160
188
|
/**
|
|
@@ -189,9 +217,10 @@ export interface LeadStore {
|
|
|
189
217
|
* The lead schema's version, shared by every adapter: 1 the Rust server's
|
|
190
218
|
* table (`job`, `zip`, `mobile`, `at`), 2 + the place, 3 + the spam verdict,
|
|
191
219
|
* 4 + the brand's extras, 5 + the channel and the callback's consent, 6 + the
|
|
192
|
-
* script's submission id, unique
|
|
220
|
+
* script's submission id, unique, 7 + the flow and the price it was taken at.
|
|
221
|
+
* Append only.
|
|
193
222
|
*/
|
|
194
|
-
export declare const LEAD_SCHEMA_VERSION =
|
|
223
|
+
export declare const LEAD_SCHEMA_VERSION = 7;
|
|
195
224
|
/** A field is capped, not rejected: a long answer is still a customer. */
|
|
196
225
|
export declare const MAX_FIELD = 200;
|
|
197
226
|
/**
|
package/dist/core/lead.js
CHANGED
|
@@ -70,9 +70,10 @@ export function channelOf(lead) {
|
|
|
70
70
|
* The lead schema's version, shared by every adapter: 1 the Rust server's
|
|
71
71
|
* table (`job`, `zip`, `mobile`, `at`), 2 + the place, 3 + the spam verdict,
|
|
72
72
|
* 4 + the brand's extras, 5 + the channel and the callback's consent, 6 + the
|
|
73
|
-
* script's submission id, unique
|
|
73
|
+
* script's submission id, unique, 7 + the flow and the price it was taken at.
|
|
74
|
+
* Append only.
|
|
74
75
|
*/
|
|
75
|
-
export const LEAD_SCHEMA_VERSION =
|
|
76
|
+
export const LEAD_SCHEMA_VERSION = 7;
|
|
76
77
|
/** A field is capped, not rejected: a long answer is still a customer. */
|
|
77
78
|
export const MAX_FIELD = 200;
|
|
78
79
|
// C0 and C1 controls, DEL, and the Unicode line and paragraph separators.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { PricingInputs, PricingModel } from "./model.js";
|
|
2
|
+
/**
|
|
3
|
+
* How a need is sold. `quote`: the price is uncertain, the lead asks for one.
|
|
4
|
+
* `estimate`: the price follows from a few enum answers, shown live, then a
|
|
5
|
+
* slot is booked. `fixed`: one price for a well-defined job, then a slot.
|
|
6
|
+
* Chosen per need, not per site: one brand can mix them.
|
|
7
|
+
*/
|
|
8
|
+
export type LeadFlow = "quote" | "estimate" | "fixed";
|
|
9
|
+
export declare const LEAD_FLOWS: readonly LeadFlow[];
|
|
10
|
+
/** A brand's flows by subject slug; a need left out is a `quote`. */
|
|
11
|
+
export type LeadFlows = Readonly<Record<string, LeadFlow>>;
|
|
12
|
+
/**
|
|
13
|
+
* The flow a need actually runs: the brand's, when the model prices the need
|
|
14
|
+
* that way — an `estimate` the model cannot price (a remote model without
|
|
15
|
+
* it, no model at all) is a `quote`, never a form with no price. The form and
|
|
16
|
+
* the server both ask this, so they agree on what was shown.
|
|
17
|
+
*/
|
|
18
|
+
export declare function flowOf(flows: LeadFlows | undefined, model: PricingModel | null, need: string | undefined): LeadFlow;
|
|
19
|
+
/** The form field an estimate's input is posted under: `estimate_<input id>`. */
|
|
20
|
+
export declare const ESTIMATE_FIELD_PREFIX = "estimate_";
|
|
21
|
+
export declare const estimateField: (input: string) => string;
|
|
22
|
+
/**
|
|
23
|
+
* The answers a form posted for the need's inputs — only those, and only
|
|
24
|
+
* slugs. Read by the server, which prices them itself: a posted amount, if
|
|
25
|
+
* any, is never read.
|
|
26
|
+
*/
|
|
27
|
+
export declare function readEstimateInputs(model: PricingModel, need: string, get: (field: string) => string | null): PricingInputs;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { PRICING_LIMITS } from "./model.js";
|
|
2
|
+
export const LEAD_FLOWS = ["quote", "estimate", "fixed"];
|
|
3
|
+
/**
|
|
4
|
+
* The flow a need actually runs: the brand's, when the model prices the need
|
|
5
|
+
* that way — an `estimate` the model cannot price (a remote model without
|
|
6
|
+
* it, no model at all) is a `quote`, never a form with no price. The form and
|
|
7
|
+
* the server both ask this, so they agree on what was shown.
|
|
8
|
+
*/
|
|
9
|
+
export function flowOf(flows, model, need) {
|
|
10
|
+
if (!flows || !model || need === undefined || !Object.hasOwn(flows, need))
|
|
11
|
+
return "quote";
|
|
12
|
+
const wanted = flows[need];
|
|
13
|
+
if (wanted === undefined || wanted === "quote")
|
|
14
|
+
return "quote";
|
|
15
|
+
const pricing = Object.hasOwn(model.needs, need) ? model.needs[need] : undefined;
|
|
16
|
+
return pricing?.kind === wanted ? wanted : "quote";
|
|
17
|
+
}
|
|
18
|
+
/** The form field an estimate's input is posted under: `estimate_<input id>`. */
|
|
19
|
+
export const ESTIMATE_FIELD_PREFIX = "estimate_";
|
|
20
|
+
export const estimateField = (input) => `${ESTIMATE_FIELD_PREFIX}${input}`;
|
|
21
|
+
/**
|
|
22
|
+
* The answers a form posted for the need's inputs — only those, and only
|
|
23
|
+
* slugs. Read by the server, which prices them itself: a posted amount, if
|
|
24
|
+
* any, is never read.
|
|
25
|
+
*/
|
|
26
|
+
export function readEstimateInputs(model, need, get) {
|
|
27
|
+
const pricing = Object.hasOwn(model.needs, need) ? model.needs[need] : undefined;
|
|
28
|
+
if (pricing?.kind !== "estimate")
|
|
29
|
+
return {};
|
|
30
|
+
const out = {};
|
|
31
|
+
for (const id of pricing.inputs) {
|
|
32
|
+
const value = get(estimateField(id))?.trim();
|
|
33
|
+
if (value && PRICING_LIMITS.slug.test(value))
|
|
34
|
+
out[id] = value;
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
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
|
+
export { CENTS_BUCKETS, centsBucket, mulBp, priceOf, roundTo } from "./price.js";
|
|
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";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { PRICING_CURRENCY, PRICING_FORMAT, PRICING_LIMITS, } from "./model.js";
|
|
2
|
+
export { CENTS_BUCKETS, centsBucket, mulBp, priceOf, roundTo } from "./price.js";
|
|
3
|
+
export { labelOf, parsePricingModel, PricingModelError, pricingProblems, pricingProblemsFor } from "./validate.js";
|
|
4
|
+
export { ESTIMATE_FIELD_PREFIX, estimateField, flowOf, LEAD_FLOWS, readEstimateInputs } from "./flow.js";
|