@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.
Files changed (63) hide show
  1. package/README.md +92 -5
  2. package/dist/core/accept.d.ts +23 -1
  3. package/dist/core/accept.js +59 -5
  4. package/dist/core/analytics.d.ts +2 -1
  5. package/dist/core/analytics.js +7 -0
  6. package/dist/core/lead-capture-format.d.ts +6 -0
  7. package/dist/core/lead-capture-format.js +13 -0
  8. package/dist/core/lead-capture-text.d.ts +32 -1
  9. package/dist/core/lead-capture-text.js +43 -0
  10. package/dist/core/lead.d.ts +32 -3
  11. package/dist/core/lead.js +3 -2
  12. package/dist/core/pricing/flow.d.ts +27 -0
  13. package/dist/core/pricing/flow.js +37 -0
  14. package/dist/core/pricing/index.d.ts +4 -0
  15. package/dist/core/pricing/index.js +4 -0
  16. package/dist/core/pricing/model.d.ts +129 -0
  17. package/dist/core/pricing/model.js +34 -0
  18. package/dist/core/pricing/price.d.ts +34 -0
  19. package/dist/core/pricing/price.js +105 -0
  20. package/dist/core/pricing/validate.d.ts +27 -0
  21. package/dist/core/pricing/validate.js +244 -0
  22. package/dist/core/site.d.ts +7 -0
  23. package/dist/core/site.js +11 -0
  24. package/dist/index.d.ts +5 -4
  25. package/dist/index.js +4 -3
  26. package/dist/next/quote-route.d.ts +8 -1
  27. package/dist/next/quote-route.js +3 -1
  28. package/dist/react/LeadCapture.d.ts +30 -2
  29. package/dist/react/LeadCapture.js +18 -5
  30. package/dist/react/LeadCaptureChannels.d.ts +14 -2
  31. package/dist/react/LeadCaptureChannels.js +13 -0
  32. package/dist/react/LeadCaptureEstimate.d.ts +46 -0
  33. package/dist/react/LeadCaptureEstimate.js +69 -0
  34. package/dist/react/LeadCapturePriced.d.ts +19 -0
  35. package/dist/react/LeadCapturePriced.js +14 -0
  36. package/dist/react/use-lead-events.d.ts +2 -0
  37. package/dist/react/use-lead-events.js +10 -0
  38. package/dist/react/use-lead-submit.d.ts +7 -3
  39. package/dist/react/use-lead-submit.js +14 -7
  40. package/dist/server/index.d.ts +2 -1
  41. package/dist/server/index.js +2 -1
  42. package/dist/server/lead-store-sqlite.js +31 -5
  43. package/dist/server/lead-webhook.d.ts +48 -0
  44. package/dist/server/lead-webhook.js +30 -2
  45. package/dist/server/pricing-source.d.ts +25 -0
  46. package/dist/server/pricing-source.js +48 -0
  47. package/dist/testing/lead-store-contract.js +9 -2
  48. package/package.json +1 -1
  49. package/template/app/[locale]/[location]/page.tsx +4 -2
  50. package/template/app/quote/route.ts +2 -1
  51. package/template/flake.nix +1 -1
  52. package/template/package.json +1 -1
  53. package/template/src/entities/content/model/en.ts +1 -1
  54. package/template/src/entities/content/model/fr.ts +1 -1
  55. package/template/src/shared/config/env.ts +4 -1
  56. package/template/src/shared/config/lead.ts +12 -2
  57. package/template/src/shared/config/pricing.ts +49 -0
  58. package/template/src/shared/config/site.ts +3 -0
  59. package/template/src/views/home/ui/PlaceHome.tsx +6 -3
  60. package/template/tests/bundle_budget.txt +3 -1
  61. package/template/tests/e2e/flows.spec.ts +106 -0
  62. package/template/tests/e2e/mock-panel.mjs +20 -0
  63. package/template/tests/e2e/playwright.config.ts +25 -9
@@ -5,13 +5,15 @@ import { type Site } from "../core/site.js";
5
5
  import type { ServerEnv } from "../server/env.js";
6
6
  import type { LeadWebhook } from "../server/lead-webhook.js";
7
7
  import type { LeadNotifier } from "../server/notify.js";
8
+ import type { PricingSource } from "../server/pricing-source.js";
8
9
  /**
9
10
  * `app/quote/route.ts` — the no-JS path, and the one that has to keep working:
10
11
  * a plain form POST answered with a 303, so a refresh does not resubmit.
11
12
  * A refused lead goes back to its card with the field to fix
12
13
  * (`/fr?lead_error=phone#devis`); a script asking for JSON (`Accept:
13
14
  * application/json`) is answered `422 { ok: false, field }` instead, and
14
- * `200 { ok: true, location }` for a lead taken.
15
+ * `200 { ok: true, location, lead, cents? }` for a lead taken — `lead` its
16
+ * public reference (`leadRef`), `cents` the price the server took it at.
15
17
  *
16
18
  * ```ts
17
19
  * export const dynamic = "force-dynamic";
@@ -38,6 +40,11 @@ export interface QuoteRouteDeps<L extends string> {
38
40
  webhook?: () => LeadWebhook | null;
39
41
  /** The self-contained 500's words, when the store refused the lead. */
40
42
  unavailable: (locale: L, place: Place<L> | null) => UnavailableCopy;
43
+ /**
44
+ * The price list an `estimate` or `fixed` lead is priced from — the same
45
+ * source the page showed (`createPricingSource`); absent → `site.pricing`.
46
+ */
47
+ pricing?: PricingSource;
41
48
  /** Opened on first use (`next build` imports the route); defaults to `LEADS_DB_URL`'s. */
42
49
  store?: () => LeadStore;
43
50
  /** Defaults to `env().leadRateLimit`, built on the first lead. */
@@ -148,6 +148,7 @@ export function quoteRoute(site, deps) {
148
148
  insert: lead => leadStore().insert(lead),
149
149
  findSubmission: async (submissionId) => (await leadStore().findSubmission?.(submissionId)) ?? null,
150
150
  sendSuspect: suspectQueued(),
151
+ ...(deps.pricing ? { pricing: () => deps.pricing?.model() ?? Promise.resolve(null) } : {}),
151
152
  defer,
152
153
  notify: (lead, id) => {
153
154
  notifier ??= deps.notifier();
@@ -182,7 +183,8 @@ export function quoteRoute(site, deps) {
182
183
  // A suspected bot is answered exactly as a person is.
183
184
  case "stored": {
184
185
  const location = href(request, outcome.lead.placeSlug, outcome.locale, thanksSuffix(channelOf(outcome.lead)));
185
- return scripted ? json(200, { ok: true, location }) : seeOther(location);
186
+ const price = outcome.lead.price ? { cents: outcome.lead.price.cents } : {};
187
+ return scripted ? json(200, { ok: true, location, lead: outcome.ref, ...price }) : seeOther(location);
186
188
  }
187
189
  case "invalid": {
188
190
  const tags = experimentProps(text(form, EXPERIMENT_FIELD), text(form, VARIANT_FIELD));
@@ -1,15 +1,19 @@
1
1
  import { type ReactNode } from "react";
2
2
  import { type CaptureChannel } from "../core/channels.js";
3
3
  import type { LeadWire } from "../core/lead.js";
4
- import type { LeadCaptureText } from "../core/lead-capture-text.js";
4
+ import { type LeadCaptureText } from "../core/lead-capture-text.js";
5
5
  import { type Place } from "../core/place/types.js";
6
+ import { type LeadFlows } from "../core/pricing/flow.js";
7
+ import type { PricingModel } from "../core/pricing/model.js";
6
8
  import type { FormSelectOption } from "./FormSelect.js";
9
+ import { type PricedPart } from "./LeadCapturePriced.js";
7
10
  import { type ChannelPart, type Experiment } from "./LeadCaptureChannels.js";
11
+ import { type EstimatePart } from "./LeadCaptureEstimate.js";
8
12
  import { type FieldPart } from "./LeadCaptureFields.js";
9
13
  import { type LeadCaptureLayout } from "./LeadCaptureNeed.js";
10
14
  import type { PartClassNames } from "./parts.js";
11
15
  import { type LeadSent } from "./use-lead-submit.js";
12
- export type LeadCapturePart = "root" | "head" | "title" | "lede" | "form" | "contact" | "submit" | "trust" | "privacy" | "opening" | "others" | "done" | "needs" | "need" | "summary" | FieldPart | ChannelPart;
16
+ export type LeadCapturePart = "root" | "head" | "title" | "lede" | "form" | "contact" | "submit" | "trust" | "privacy" | "opening" | "others" | "done" | "needs" | "need" | "summary" | FieldPart | ChannelPart | EstimatePart | PricedPart;
13
17
  export interface LeadCaptureProps {
14
18
  /** Its hours order the channels; its service area suggests the commune. */
15
19
  place: Place<string>;
@@ -25,6 +29,30 @@ export interface LeadCaptureProps {
25
29
  wire: LeadWire;
26
30
  /** The brand's subjects with their labels, in order. */
27
31
  needs: readonly FormSelectOption[];
32
+ /**
33
+ * How each need is sold — `site.lead.flows`, the map the server prices
34
+ * with. A need runs `estimate` or `fixed` only when `pricing` prices it so
35
+ * (`flowOf`); otherwise, and without the two, every need is a `quote`.
36
+ */
37
+ flows?: LeadFlows | undefined;
38
+ /**
39
+ * The price list the page priced from — `createPricingSource(site).model()`
40
+ * on the server page, handed down as data. An estimate's price is computed
41
+ * here as the visitor answers, and again by the route, whose number is kept.
42
+ */
43
+ pricing?: PricingModel | null | undefined;
44
+ /**
45
+ * `quote` needs a price is given from photos (a deep clean, after-works):
46
+ * a WhatsApp link to send them, prefilled with the need, when the place
47
+ * has WhatsApp. The callback is offered as always.
48
+ */
49
+ photos?: readonly string[] | undefined;
50
+ /**
51
+ * After a priced lead, in place of "we call you to set the slot": a
52
+ * booking provider's widget, when the brand has one. None ships with the
53
+ * kit yet.
54
+ */
55
+ booking?: ReactNode | ((sent: LeadSent) => ReactNode);
28
56
  /** The need the page already knows; `?need=` and `[data-need]` triggers set it too. */
29
57
  need?: string | undefined;
30
58
  /** `single` (one screen) or `qualify-first` (the need, then the contact) — an experiment's switch. */
@@ -4,8 +4,12 @@ import { cn } from "@evinvest/uikit";
4
4
  import { useEffect, useRef, useState } from "react";
5
5
  import { resolveChannels } from "../core/channels.js";
6
6
  import { fillText, openingText } from "../core/lead-capture-format.js";
7
+ import { flowTextOf } from "../core/lead-capture-text.js";
7
8
  import { servedLocalities, storefrontOf } from "../core/place/types.js";
8
- import { CallbackForm, ChannelLink, ExperimentFields } from "./LeadCaptureChannels.js";
9
+ import { flowOf } from "../core/pricing/flow.js";
10
+ import { LeadCapturePriced } from "./LeadCapturePriced.js";
11
+ import { CallbackForm, ChannelLink, ExperimentFields, PhotosAsk } from "./LeadCaptureChannels.js";
12
+ import { EstimateInputs, PriceBox, useEstimate } from "./LeadCaptureEstimate.js";
9
13
  import { FormMessage, LocalityField, NameField, PhoneField } from "./LeadCaptureFields.js";
10
14
  import { NeedField } from "./LeadCaptureNeed.js";
11
15
  import { QuoteFormShell } from "./QuoteFormShell.js";
@@ -40,7 +44,15 @@ export function LeadCapture(props) {
40
44
  const events = useLeadEvents(root, { formId, layout, experiment });
41
45
  useOpenOnHash(`${id}-callback`);
42
46
  const [error, setError] = useLeadError(root, id);
43
- const { sent, onSubmit, busy, failure, retry } = useLeadSubmit(props.done !== undefined, { mobile: wire.mobile, name: props.name?.field }, { onRefused: (channel, field) => setError({ channel, field }), onFailed: (channel, why) => events.submitError(why, channel) });
47
+ const flowText = flowTextOf(text, locale);
48
+ // `single` shows its select at the first need until one is picked: that is the need on screen.
49
+ const shownNeed = need ?? (layout === "single" ? needs[0]?.value : undefined);
50
+ const model = props.pricing ?? null;
51
+ const flow = flowOf(props.flows, model, shownNeed);
52
+ const estimate = useEstimate(model, shownNeed, flow, events.estimateShown);
53
+ // A priced lead stays in the card: its price is confirmed there, and the slot promised.
54
+ const staysInCard = (ch) => props.done !== undefined || (ch === "form" && flow !== "quote");
55
+ const { sent, onSubmit, busy, failure, retry } = useLeadSubmit(staysInCard, { mobile: wire.mobile, name: props.name?.field }, { onRefused: (channel, field) => setError({ channel, field }), onFailed: (channel, why) => events.submitError(why, channel) });
44
56
  // The form's refusal by where it shows: under the field the card draws, else above the submit.
45
57
  const formError = error?.channel === "form" ? error.field : null;
46
58
  const at = (field) => (formError === field ? errorText(field, text) : null);
@@ -82,11 +94,12 @@ export function LeadCapture(props) {
82
94
  const contactShown = layout === "single" || (need !== undefined && !editing);
83
95
  const contactClass = contactShown ? "flex" : "hidden group-has-[[data-need-option]:checked]/lead:flex";
84
96
  const rootProps = { id, className: cn("flex w-full flex-col gap-6", props.className, c?.root), "data-experiment": experiment?.name, "data-variant": experiment?.variant };
85
- if (sent && props.done !== undefined) {
86
- return (_jsx("div", { ref: root, ...rootProps, children: _jsx("div", { ref: doneRef, role: "status", tabIndex: -1, className: cn("outline-none", c?.done), children: typeof props.done === "function" ? props.done(sent) : props.done }) }));
97
+ if (sent && staysInCard(sent.channel)) {
98
+ const done = typeof props.done === "function" ? props.done(sent) : props.done;
99
+ return (_jsx("div", { ref: root, ...rootProps, children: _jsxs("div", { ref: doneRef, role: "status", tabIndex: -1, className: cn("flex flex-col gap-4 outline-none", c?.done), children: [done, sent.channel === "form" && flow !== "quote" && (_jsx(LeadCapturePriced, { sent: sent, booking: typeof props.booking === "function" ? props.booking(sent) : props.booking, locale: locale, text: flowText, classNames: c }))] }) }));
87
100
  }
88
101
  return (_jsxs("div", { ref: root, ...rootProps, children: [props.head ?? (_jsxs("div", { className: cn("flex flex-col gap-1", c?.head), children: [_jsx("p", { className: cn("font-display text-2xl font-bold text-ink", c?.title), children: text.title }), _jsx("p", { className: cn("text-ink-soft", c?.lede), children: text.lede })] })), lead !== "form" && channel(lead, true), _jsxs(QuoteFormShell, { id: `${id}-form`, placeSlug: place.slug, locale: locale, renderedAt: renderedAt, honeypotLabel: text.honeypotLabel, formId: formId, card: id, onSubmit: onSubmit, className: cn("group/lead", c?.form), children: [_jsx(ExperimentFields, { experiment: experiment }), _jsx(NeedField, { layout: layout, name: wire.subject, needs: needs, need: need, editing: editing, hydrated: hydrated, label: text.needLabel, changeLabel: text.needChange, requiredText: text.needRequired, onPick: pick, onSelect: select, onEdit: () => {
89
102
  setEditing(true);
90
103
  events.step("need");
91
- }, classNames: c }), _jsxs("div", { className: cn("flex-col gap-5", contactClass, c?.contact), children: [_jsx(LocalityField, { name: wire.locality, label: text.localityLabel, servedLabel: text.servedLabel, served: localitySuggestions(place), placeholder: text.localityPlaceholder, required: props.locality !== "optional", optional: text.optional, requiredText: text.required, error: at("locality"), hydrated: hydrated, classNames: c }), _jsx(PhoneField, { name: wire.mobile, text: text, error: at("phone"), onSoftError: () => events.fieldError("phone"), classNames: c }), props.name && (_jsx(NameField, { name: props.name.field, label: text.nameLabel, placeholder: text.namePlaceholder, required: props.name.required ?? false, optional: text.optional, requiredText: text.required, error: at("name"), classNames: c })), props.extras, _jsx(FormMessage, { id: formMessageId(id, "form"), error: above, className: c?.error }), _jsx(FailureMessage, { failure: failure?.channel === "form" ? failure.failure : null, text: text, onRetry: retry, className: c?.error }), _jsxs("div", { className: cn("flex flex-col gap-3", c?.trust), children: [_jsx(SubmitButton, { busy: busy === "form", label: text.submit, sending: text.sending, className: c?.submit }), props.trust] }), opening && lead !== "callback" && _jsx("p", { className: cn("text-sm text-ink-soft", c?.opening), children: opening }), _jsx("p", { className: cn("text-sm text-ink-soft", c?.privacy), children: text.privacy })] })] }), (links.length > 0 || rest.includes("callback")) && (_jsxs("div", { className: cn("flex flex-col gap-3", c?.others), children: [_jsx("p", { className: "text-sm font-medium text-ink", children: text.otherChannels }), links.length > 0 && _jsx("div", { className: "flex flex-wrap gap-2", children: links.map(ch => channel(ch, false)) }), rest.includes("callback") && channel("callback", false)] }))] }));
104
+ }, classNames: c }), _jsxs("div", { className: cn("flex-col gap-5", contactClass, c?.contact), children: [model && shownNeed !== undefined && flow === "estimate" && (_jsx(EstimateInputs, { model: model, need: shownNeed, locale: locale, answers: estimate.answers, onAnswer: estimate.answer, required: hydrated, classNames: c })), model && flow !== "quote" && _jsx(PriceBox, { model: model, flow: flow, price: estimate.price, locale: locale, text: flowText, classNames: c }), flow === "quote" && shownNeed !== undefined && props.photos?.includes(shownNeed) && (_jsx(PhotosAsk, { whatsapp: contact.whatsapp, needLabel: needs.find(n => n.value === shownNeed)?.label ?? shownNeed, text: flowText, experiment: experiment, className: c?.photos })), _jsx(LocalityField, { name: wire.locality, label: text.localityLabel, servedLabel: text.servedLabel, served: localitySuggestions(place), placeholder: text.localityPlaceholder, required: props.locality !== "optional", optional: text.optional, requiredText: text.required, error: at("locality"), hydrated: hydrated, classNames: c }), _jsx(PhoneField, { name: wire.mobile, text: text, error: at("phone"), onSoftError: () => events.fieldError("phone"), classNames: c }), props.name && (_jsx(NameField, { name: props.name.field, label: text.nameLabel, placeholder: text.namePlaceholder, required: props.name.required ?? false, optional: text.optional, requiredText: text.required, error: at("name"), classNames: c })), props.extras, _jsx(FormMessage, { id: formMessageId(id, "form"), error: above, className: c?.error }), _jsx(FailureMessage, { failure: failure?.channel === "form" ? failure.failure : null, text: text, onRetry: retry, className: c?.error }), _jsxs("div", { className: cn("flex flex-col gap-3", c?.trust), children: [_jsx(SubmitButton, { busy: busy === "form", label: flow === "quote" ? text.submit : flowText.bookSubmit, sending: text.sending, className: c?.submit }), props.trust] }), opening && lead !== "callback" && _jsx("p", { className: cn("text-sm text-ink-soft", c?.opening), children: opening }), _jsx("p", { className: cn("text-sm text-ink-soft", c?.privacy), children: text.privacy })] })] }), (links.length > 0 || rest.includes("callback")) && (_jsxs("div", { className: cn("flex flex-col gap-3", c?.others), children: [_jsx("p", { className: "text-sm font-medium text-ink", children: text.otherChannels }), links.length > 0 && _jsx("div", { className: "flex flex-wrap gap-2", children: links.map(ch => channel(ch, false)) }), rest.includes("callback") && channel("callback", false)] }))] }));
92
105
  }
@@ -1,5 +1,5 @@
1
1
  import { type CaptureChannel } from "../core/channels.js";
2
- import type { LeadCaptureText } from "../core/lead-capture-text.js";
2
+ import type { LeadCaptureFlowText, LeadCaptureText } from "../core/lead-capture-text.js";
3
3
  import type { FormEventHandler } from "react";
4
4
  import { type FieldPart } from "./LeadCaptureFields.js";
5
5
  import type { SendFailure } from "./use-lead-submit.js";
@@ -8,7 +8,7 @@ import { type PartClassNames } from "./parts.js";
8
8
  * `channel` and `primary` dress every way out, the callback's summary
9
9
  * included; the `callback*` parts are the callback's own, after them.
10
10
  */
11
- export type ChannelPart = "channel" | "primary" | "callback" | "callbackSummary" | "callbackForm" | "callbackLede" | "callbackSubmit" | "consent";
11
+ export type ChannelPart = "channel" | "primary" | "callback" | "callbackSummary" | "callbackForm" | "callbackLede" | "callbackSubmit" | "consent" | "photos";
12
12
  export interface Experiment {
13
13
  name: string;
14
14
  variant: string;
@@ -75,3 +75,15 @@ export declare function CallbackForm(props: {
75
75
  onSoftError: () => void;
76
76
  classNames?: PartClassNames<ChannelPart | FieldPart> | undefined;
77
77
  }): import("react").JSX.Element;
78
+ /**
79
+ * A `quote` need priced from photos (a deep clean, after-works): the photos
80
+ * go by WhatsApp, with the need already said — only when the place has
81
+ * WhatsApp; the callback stays where it is.
82
+ */
83
+ export declare function PhotosAsk(props: {
84
+ whatsapp: string | null;
85
+ needLabel: string;
86
+ text: Pick<LeadCaptureFlowText, "photosTitle" | "photosLede" | "photosCta" | "photosMessage">;
87
+ experiment: Experiment | undefined;
88
+ className?: string | undefined;
89
+ }): import("react").JSX.Element | null;
@@ -3,6 +3,7 @@ import { Button, buttonVariants, cn } from "@evinvest/uikit";
3
3
  import { EXPERIMENT_FIELD, VARIANT_FIELD } from "../core/accept.js";
4
4
  import { channelHref } from "../core/channels.js";
5
5
  import { CHANNEL_FIELD } from "../core/lead.js";
6
+ import { fillText } from "../core/lead-capture-format.js";
6
7
  import { ConsentField, FormMessage, PhoneField } from "./LeadCaptureFields.js";
7
8
  import { FailureMessage, SubmitButton } from "./LeadCaptureSubmit.js";
8
9
  import { partWithLeading } from "./parts.js";
@@ -46,3 +47,15 @@ export function CallbackForm(props) {
46
47
  const at = (field) => (error?.field === field ? error.text : null);
47
48
  return (_jsxs("details", { id: id, open: open, className: cn("w-full", c?.callback), children: [_jsx("summary", { "data-intent": "callback", className: partWithLeading(buttonVariants({ variant: primary ? "primary" : "outline", size: "touch", className: "flex w-full list-none [&::-webkit-details-marker]:hidden" }), CHANNEL_LEADING, cn(c?.channel, primary && c?.primary, c?.callbackSummary) || undefined), children: text.callback }), _jsxs(QuoteFormShell, { id: `${id}-form`, placeSlug: placeSlug, locale: locale, renderedAt: renderedAt, honeypotLabel: text.honeypotLabel, formId: formId, card: props.card, onSubmit: props.onSubmit, className: cn("mt-4 gap-4", c?.callbackForm), children: [_jsx("input", { type: "hidden", name: CHANNEL_FIELD, value: "callback" }), subject && _jsx("input", { type: "hidden", name: subject.name, value: subject.value }), _jsx(ExperimentFields, { experiment: experiment }), _jsx("p", { className: cn("text-ink-soft", c?.callbackLede), children: opening ?? text.callbackLede }), _jsx(PhoneField, { id: `${id}-phone`, name: mobileName, text: text, error: at("phone"), onSoftError: props.onSoftError, classNames: c }), _jsx(ConsentField, { sentence: text.callbackConsent, requiredText: text.consentRequired, error: at("consent"), className: c?.consent, classNames: c }), _jsx(FormMessage, { id: `${id}-error`, error: error && error.field !== "phone" && error.field !== "consent" ? error.text : null, className: c?.error }), _jsx(FailureMessage, { failure: props.failure, text: text, onRetry: props.onRetry, className: c?.error }), _jsx(SubmitButton, { busy: props.busy, label: text.callbackSubmit, sending: text.sending, className: c?.callbackSubmit })] })] }));
48
49
  }
50
+ /**
51
+ * A `quote` need priced from photos (a deep clean, after-works): the photos
52
+ * go by WhatsApp, with the need already said — only when the place has
53
+ * WhatsApp; the callback stays where it is.
54
+ */
55
+ export function PhotosAsk(props) {
56
+ const { whatsapp, text, experiment } = props;
57
+ const href = whatsapp ? channelHref("whatsapp", { phone: null, whatsapp }, fillText(text.photosMessage, { need: props.needLabel })) : null;
58
+ if (!href)
59
+ return null;
60
+ return (_jsxs("div", { className: cn("flex flex-col gap-2 rounded-[var(--control-radius)] border border-border p-4", props.className), children: [_jsx("p", { className: "font-medium text-ink", children: text.photosTitle }), _jsx("p", { className: "text-sm text-ink-soft", children: text.photosLede }), _jsx(Button, { href: href, variant: "outline", size: "touch", className: "w-full", "data-experiment": experiment?.name, "data-variant": experiment?.variant, children: text.photosCta })] }));
61
+ }
@@ -0,0 +1,46 @@
1
+ import type { LeadCaptureFlowText } from "../core/lead-capture-text.js";
2
+ import { type LeadFlow } from "../core/pricing/flow.js";
3
+ import type { Price, PricingModel } from "../core/pricing/model.js";
4
+ import type { PartClassNames } from "./parts.js";
5
+ export type EstimatePart = "estimate" | "estimateInput" | "estimateLegend" | "estimateOption" | "price" | "priceTotal" | "breakdown" | "priceNote";
6
+ /**
7
+ * The live price of the need on screen: its answers, as the visitor taps
8
+ * them, and the price `priceOf` makes of them — the same function the server
9
+ * stores the lead's price with. A full set of answers is reported once per
10
+ * band (`onShown`), never the price itself.
11
+ */
12
+ export declare function useEstimate(model: PricingModel | null, need: string | undefined, flow: LeadFlow, onShown: (need: string, cents: number) => void): {
13
+ answers: Readonly<Record<string, string>>;
14
+ answer: (input: string, option: string) => void;
15
+ price: Price | null;
16
+ };
17
+ /**
18
+ * An estimate's questions, a tile per answer — touch-sized radios, so the
19
+ * answers post without a script too (the server prices them; the live price
20
+ * needs the script). Required once the script runs: a price needs every
21
+ * answer, and without the script an unanswered estimate is still a lead, a
22
+ * quote.
23
+ */
24
+ export declare function EstimateInputs(props: {
25
+ model: PricingModel;
26
+ need: string;
27
+ locale: string;
28
+ answers: Readonly<Record<string, string>>;
29
+ onAnswer: (input: string, option: string) => void;
30
+ required: boolean;
31
+ classNames?: PartClassNames<EstimatePart> | undefined;
32
+ }): import("react").JSX.Element | null;
33
+ /**
34
+ * The price, read out as it changes (`aria-live`): an estimate's total and
35
+ * how it was reached, or — until every question is answered — what is
36
+ * missing; a fixed need's price, as is. Never "from": it is the price for
37
+ * these answers.
38
+ */
39
+ export declare function PriceBox(props: {
40
+ model: PricingModel;
41
+ flow: Exclude<LeadFlow, "quote">;
42
+ price: Price | null;
43
+ locale: string;
44
+ text: LeadCaptureFlowText;
45
+ classNames?: PartClassNames<EstimatePart> | undefined;
46
+ }): import("react").JSX.Element;
@@ -0,0 +1,69 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { cn } from "@evinvest/uikit";
4
+ import { useEffect, useMemo, useState } from "react";
5
+ import { formatCents } from "../core/lead-capture-format.js";
6
+ import { estimateField } from "../core/pricing/flow.js";
7
+ import { priceOf } from "../core/pricing/price.js";
8
+ import { labelOf } from "../core/pricing/validate.js";
9
+ /**
10
+ * The live price of the need on screen: its answers, as the visitor taps
11
+ * them, and the price `priceOf` makes of them — the same function the server
12
+ * stores the lead's price with. A full set of answers is reported once per
13
+ * band (`onShown`), never the price itself.
14
+ */
15
+ export function useEstimate(model, need, flow, onShown) {
16
+ const [answers, setAnswers] = useState({});
17
+ const price = useMemo(() => (flow === "quote" || !model || need === undefined ? null : priceOf(model, need, flow === "estimate" ? answers : {})), [model, need, flow, answers]);
18
+ const cents = flow === "estimate" ? price?.cents : undefined;
19
+ useEffect(() => {
20
+ if (need !== undefined && cents !== undefined)
21
+ onShown(need, cents);
22
+ }, [need, cents, onShown]);
23
+ const answer = (input, option) => setAnswers(prev => ({ ...prev, [input]: option }));
24
+ return { answers, answer, price };
25
+ }
26
+ /**
27
+ * An estimate's questions, a tile per answer — touch-sized radios, so the
28
+ * answers post without a script too (the server prices them; the live price
29
+ * needs the script). Required once the script runs: a price needs every
30
+ * answer, and without the script an unanswered estimate is still a lead, a
31
+ * quote.
32
+ */
33
+ export function EstimateInputs(props) {
34
+ const { model, need, locale, answers, classNames: c } = props;
35
+ const pricing = model.needs[need];
36
+ if (pricing?.kind !== "estimate")
37
+ return null;
38
+ const inputs = pricing.inputs.map(id => model.inputs.find(i => i.id === id)).filter((i) => i !== undefined);
39
+ return (_jsx("div", { className: cn("flex flex-col gap-5", c?.estimate), children: inputs.map(input => (_jsxs("fieldset", { className: cn("flex flex-col gap-2", c?.estimateInput), children: [_jsx("legend", { className: cn("mb-2 text-sm font-medium text-ink", c?.estimateLegend), children: labelOf(input.labels, locale) }), _jsx("div", { className: "grid grid-cols-2 gap-2 sm:grid-cols-3", children: input.options.map(option => (_jsxs("label", { className: "relative block cursor-pointer", children: [_jsx("input", { type: "radio", name: estimateField(input.id), value: option.id, required: props.required, checked: answers[input.id] === option.id, onChange: () => props.onAnswer(input.id, option.id), className: "peer absolute inset-0 opacity-0", "data-lead-field": estimateField(input.id) }), _jsx("span", { className: cn("flex h-full min-h-11 items-center rounded-[var(--control-radius)] border border-input px-3 py-2 text-ink transition-colors", "peer-checked:border-primary peer-checked:bg-hover peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50", c?.estimateOption), children: labelOf(option.labels, locale) })] }, option.id))) })] }, input.id))) }));
40
+ }
41
+ function lineLabel(line, model, locale, text) {
42
+ switch (line.kind) {
43
+ case "base":
44
+ return text.priceBase;
45
+ case "rounding":
46
+ return text.priceRounding;
47
+ case "minimum":
48
+ return text.priceMinimum;
49
+ case "add":
50
+ case "multiply":
51
+ case "discount": {
52
+ // The answer names itself ("2 chambres", "Toutes les 2 semaines"); the question would only repeat it.
53
+ const option = model.inputs.find(i => i.id === line.input)?.options.find(o => o.id === line.option);
54
+ return option ? labelOf(option.labels, locale) : line.option;
55
+ }
56
+ }
57
+ }
58
+ /**
59
+ * The price, read out as it changes (`aria-live`): an estimate's total and
60
+ * how it was reached, or — until every question is answered — what is
61
+ * missing; a fixed need's price, as is. Never "from": it is the price for
62
+ * these answers.
63
+ */
64
+ export function PriceBox(props) {
65
+ const { model, flow, price, locale, text, classNames: c } = props;
66
+ // A line that changed nothing (an answer worth 0) is not worth reading out.
67
+ const lines = price?.breakdown.filter((line, i) => i === 0 || line.cents !== 0) ?? [];
68
+ return (_jsxs("div", { "aria-live": "polite", "aria-atomic": "true", className: cn("flex flex-col gap-2 rounded-[var(--control-radius)] border border-border bg-card p-4", c?.price), children: [_jsx("p", { className: "text-sm text-ink-soft", children: text.priceTitle }), price ? (_jsx("p", { className: cn("font-display text-3xl font-bold text-ink", c?.priceTotal), "data-price-cents": price.cents, children: formatCents(price.cents, locale) })) : (_jsx("p", { className: "text-ink", children: text.pricePending })), flow === "estimate" && lines.length > 1 && (_jsx("ul", { className: cn("flex flex-col gap-1 text-sm text-ink-soft", c?.breakdown), children: lines.map((line, i) => (_jsxs("li", { className: "flex justify-between gap-3", children: [_jsx("span", { children: lineLabel(line, model, locale, text) }), _jsx("span", { className: "tabular-nums", children: formatCents(line.cents, locale, i > 0) })] }, i))) })), _jsx("p", { className: cn("text-sm text-ink-soft", c?.priceNote), children: text.priceNote })] }));
69
+ }
@@ -0,0 +1,19 @@
1
+ import type { ReactNode } from "react";
2
+ import type { LeadCaptureFlowText } from "../core/lead-capture-text.js";
3
+ import type { LeadSent } from "./use-lead-submit.js";
4
+ import type { PartClassNames } from "./parts.js";
5
+ export type PricedPart = "priced" | "pricedPrice" | "pricedNote";
6
+ /**
7
+ * After a priced lead (`estimate`, `fixed`): the price the server took it at,
8
+ * then how the slot is set — by a call, until a booking provider is chosen;
9
+ * a brand's `booking` (its own scheduling widget, say) stands in for that
10
+ * promise. The lead is stored before any of this, so an abandoned booking is
11
+ * still a lead to call.
12
+ */
13
+ export declare function LeadCapturePriced(props: {
14
+ sent: LeadSent;
15
+ booking: ReactNode;
16
+ locale: string;
17
+ text: LeadCaptureFlowText;
18
+ classNames?: PartClassNames<PricedPart> | undefined;
19
+ }): import("react").JSX.Element;
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cn } from "@evinvest/uikit";
3
+ import { fillText, formatCents } from "../core/lead-capture-format.js";
4
+ /**
5
+ * After a priced lead (`estimate`, `fixed`): the price the server took it at,
6
+ * then how the slot is set — by a call, until a booking provider is chosen;
7
+ * a brand's `booking` (its own scheduling widget, say) stands in for that
8
+ * promise. The lead is stored before any of this, so an abandoned booking is
9
+ * still a lead to call.
10
+ */
11
+ export function LeadCapturePriced(props) {
12
+ const { sent, locale, text, classNames: c } = props;
13
+ return (_jsxs("div", { className: cn("flex flex-col gap-3", c?.priced), children: [sent.cents !== undefined && _jsx("p", { className: cn("font-medium text-ink", c?.pricedPrice), children: fillText(text.sentPrice, { price: formatCents(sent.cents, locale) }) }), props.booking ?? _jsx("p", { className: cn("text-ink", c?.pricedNote), children: text.slotCallback })] }));
14
+ }
@@ -8,6 +8,8 @@ export interface LeadEvents {
8
8
  fieldError(field: LeadField): void;
9
9
  /** The script's post got no answer: `network` or `timeout`, and which form. */
10
10
  submitError(reason: "network" | "timeout", channel: "form" | "callback"): void;
11
+ /** An estimate's price for a full set of answers: its band only, once per need and band. */
12
+ estimateShown(need: string, cents: number): void;
11
13
  }
12
14
  /**
13
15
  * The lead form's own funnel, reported through the boundary's sink: seen
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { useEffect, useMemo, useRef } from "react";
3
3
  import { EVENTS, experimentProps } from "../core/analytics.js";
4
+ import { centsBucket } from "../core/pricing/price.js";
4
5
  import { useAnalyticsSink } from "./analytics-context.js";
5
6
  /** The attribute a lead form's control names its role in, for `lead_form_field_error`. */
6
7
  export const FIELD_ATTR = "data-lead-field";
@@ -18,6 +19,7 @@ export function useLeadEvents(root, tags) {
18
19
  const variant = tags.experiment?.variant;
19
20
  const props = useMemo(() => ({ form_id: formId, layout, ...experimentProps(name, variant) }), [formId, layout, name, variant]);
20
21
  const started = useRef(false);
22
+ const shown = useRef(new Set());
21
23
  useEffect(() => {
22
24
  const el = root.current;
23
25
  if (!sink || !el)
@@ -59,5 +61,13 @@ export function useLeadEvents(root, tags) {
59
61
  step: step => sink?.capture(EVENTS.formStep, { ...props, step }),
60
62
  fieldError: field => sink?.capture(EVENTS.fieldError, { ...props, field, blocking: false }),
61
63
  submitError: (reason, channel) => sink?.capture(EVENTS.submitError, { ...props, reason, channel }),
64
+ estimateShown: (need, cents) => {
65
+ const cents_bucket = centsBucket(cents);
66
+ const key = `${need}\n${cents_bucket}`;
67
+ if (shown.current.has(key))
68
+ return;
69
+ shown.current.add(key);
70
+ sink?.capture(EVENTS.estimateShown, { ...props, need, cents_bucket });
71
+ },
62
72
  }), [sink, props]);
63
73
  }
@@ -7,6 +7,10 @@ export interface LeadSent {
7
7
  phone: string;
8
8
  /** The name field's value, when the form has one and it was filled. */
9
9
  name: string | null;
10
+ /** The lead's public reference, as the route answered it (`leadRef`), for a `booking` to name the lead. */
11
+ lead?: string;
12
+ /** The price the server took the lead at, for an `estimate` or `fixed` need. */
13
+ cents?: number;
10
14
  }
11
15
  /** Leaving the page, behind a seam a test can stand in for (jsdom cannot navigate). */
12
16
  export declare const navigation: {
@@ -42,15 +46,15 @@ export interface LeadSubmit {
42
46
  }
43
47
  /**
44
48
  * The lead posted by a script, so a refusal keeps what was typed: the body
45
- * and the endpoint are the form's own, asked for JSON. Taken → the brand's
46
- * in-card success when it has one (`done`), else the thanks page the route
49
+ * and the endpoint are the form's own, asked for JSON. Taken → the in-card
50
+ * success when the card has one for that channel (`done`), else the thanks page the route
47
51
  * names. Refused → `onRefused` with the field, the form as it was. No answer
48
52
  * — no network, or nothing in `SUBMIT_TIMEOUT_MS` — is said in place with a
49
53
  * retry, never by leaving the page: the browser's error page would lose the
50
54
  * form. Any other answer (the store's 500) submits the form for real, so the
51
55
  * server's own page says what went wrong; the submission id makes that safe.
52
56
  */
53
- export declare function useLeadSubmit(done: boolean, fields: {
57
+ export declare function useLeadSubmit(done: boolean | ((channel: LeadChannel) => boolean), fields: {
54
58
  mobile: string;
55
59
  name: string | undefined;
56
60
  }, on: LeadSubmitHandlers): LeadSubmit;
@@ -41,6 +41,7 @@ function stamp(form) {
41
41
  minted.set(form, { id, lead });
42
42
  field.value = id;
43
43
  }
44
+ const LEAD_REF = /^lead-\d+-[0-9a-f]{8}$/;
44
45
  /** A root-relative path on this origin — never `//elsewhere`. */
45
46
  const OWN_PATH = /^\/(?!\/)/;
46
47
  /**
@@ -58,9 +59,15 @@ async function readAnswer(res) {
58
59
  });
59
60
  if (typeof body !== "object" || body === null)
60
61
  return null;
61
- const { ok, location, field } = body;
62
- if (ok === true && typeof location === "string" && OWN_PATH.test(location))
63
- return { ok, location };
62
+ const { ok, location, field, lead, cents } = body;
63
+ if (ok === true && typeof location === "string" && OWN_PATH.test(location)) {
64
+ return {
65
+ ok,
66
+ location,
67
+ ...(typeof lead === "string" && LEAD_REF.test(lead) ? { lead } : {}),
68
+ ...(typeof cents === "number" && Number.isSafeInteger(cents) && cents >= 0 ? { cents } : {}),
69
+ };
70
+ }
64
71
  if (ok === false && typeof field === "string")
65
72
  return { ok, field };
66
73
  return null;
@@ -76,8 +83,8 @@ async function readAnswer(res) {
76
83
  export const SUBMIT_TIMEOUT_MS = 15_000;
77
84
  /**
78
85
  * The lead posted by a script, so a refusal keeps what was typed: the body
79
- * and the endpoint are the form's own, asked for JSON. Taken → the brand's
80
- * in-card success when it has one (`done`), else the thanks page the route
86
+ * and the endpoint are the form's own, asked for JSON. Taken → the in-card
87
+ * success when the card has one for that channel (`done`), else the thanks page the route
81
88
  * names. Refused → `onRefused` with the field, the form as it was. No answer
82
89
  * — no network, or nothing in `SUBMIT_TIMEOUT_MS` — is said in place with a
83
90
  * retry, never by leaving the page: the browser's error page would lose the
@@ -134,8 +141,8 @@ export function useLeadSubmit(done, fields, on) {
134
141
  // Still pending: the page is leaving, or the form is gone. The next
135
142
  // lead from this form (Back, another need) gets an id of its own.
136
143
  minted.delete(form);
137
- if (done)
138
- setSent(lead);
144
+ if (typeof done === "function" ? done(lead.channel) : done)
145
+ setSent({ ...lead, ...(answer.lead ? { lead: answer.lead } : {}), ...(answer.cents !== undefined ? { cents: answer.cents } : {}) });
139
146
  else
140
147
  navigation.assign(answer.location);
141
148
  }, () => {
@@ -6,6 +6,7 @@
6
6
  import "server-only";
7
7
  export { createServerEnv, parseServerEnv, type EnvSource, type ServerEnv } from "./env.js";
8
8
  export { createPlaceSource, PLACE_REVALIDATE_SECONDS, PlaceSourceError, type PlaceSource, type PlaceSourceOptions } from "./place-source.js";
9
+ export { createPricingSource, type PricingSource, type PricingSourceOptions } from "./pricing-source.js";
9
10
  export { describeLeadDb, openLeadStore, parseLeadDb, type LeadDb } from "./lead-store.js";
10
11
  export { openSqliteLeadStore, type SqliteLeadStore } from "./lead-store-sqlite.js";
11
12
  export { LeadStoreNotImplemented, openPostgresLeadStore } from "./lead-store-postgres.js";
@@ -13,6 +14,6 @@ export { parseSmtpUrl, sendMail, type Mail, type SendOptions } from "./smtp.js";
13
14
  export { defaultLeadMail, leadNotifier, MAIL_PER_MINUTE, type LeadMail, type LeadNotifier, type NotifyEnv } from "./notify.js";
14
15
  export { clientKey, NO_CLIENT_ADDRESS, parseProxyTrust, type ProxyTrust } from "./client-key.js";
15
16
  export { checkLeadStore } from "./boot.js";
16
- export { leadWebhook, panelChannel, type BuildWebhookBody, type LeadWebhook, type LeadWebhookContext, type LeadWebhookOptions } from "./lead-webhook.js";
17
+ export { leadWebhook, panelChannel, panelFlowOf, panelFlowProperties, type BuildWebhookBody, type LeadWebhook, type LeadWebhookContext, type LeadWebhookOptions, type PanelFlow, type PanelFlowProperties, } from "./lead-webhook.js";
17
18
  export { checkWebhookUrl, openWebhookOutbox, WEBHOOK_HORIZON_MS, WEBHOOK_MAX_ATTEMPTS, WEBHOOK_MAX_DELAY_MS, WEBHOOK_TICK_MS, type DeadRow, type OutboxRow, type OutboxState, type TickReport, type WebhookOutbox, type WebhookOutboxOptions, type WebhookTarget, } from "./webhook-outbox.js";
18
19
  export { signatureHeaders, signWebhook, type WebhookSignatureHeaders, type WebhookSigning } from "./webhook-signature.js";
@@ -6,6 +6,7 @@
6
6
  import "server-only";
7
7
  export { createServerEnv, parseServerEnv } from "./env.js";
8
8
  export { createPlaceSource, PLACE_REVALIDATE_SECONDS, PlaceSourceError } from "./place-source.js";
9
+ export { createPricingSource } from "./pricing-source.js";
9
10
  export { describeLeadDb, openLeadStore, parseLeadDb } from "./lead-store.js";
10
11
  export { openSqliteLeadStore } from "./lead-store-sqlite.js";
11
12
  export { LeadStoreNotImplemented, openPostgresLeadStore } from "./lead-store-postgres.js";
@@ -13,6 +14,6 @@ export { parseSmtpUrl, sendMail } from "./smtp.js";
13
14
  export { defaultLeadMail, leadNotifier, MAIL_PER_MINUTE } from "./notify.js";
14
15
  export { clientKey, NO_CLIENT_ADDRESS, parseProxyTrust } from "./client-key.js";
15
16
  export { checkLeadStore } from "./boot.js";
16
- export { leadWebhook, panelChannel } from "./lead-webhook.js";
17
+ export { leadWebhook, panelChannel, panelFlowOf, panelFlowProperties, } from "./lead-webhook.js";
17
18
  export { checkWebhookUrl, openWebhookOutbox, WEBHOOK_HORIZON_MS, WEBHOOK_MAX_ATTEMPTS, WEBHOOK_MAX_DELAY_MS, WEBHOOK_TICK_MS, } from "./webhook-outbox.js";
18
19
  export { signatureHeaders, signWebhook } from "./webhook-signature.js";
@@ -2,6 +2,7 @@ import "server-only";
2
2
  import { mkdirSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import { channelOf, LEAD_SCHEMA_VERSION } from "../core/lead.js";
5
+ import { LEAD_FLOWS } from "../core/pricing/flow.js";
5
6
  // `node:sqlite` is reached through `getBuiltinModule` rather than an import so
6
7
  // the bundler never has to resolve a builtin it may not know yet; the type is
7
8
  // the module's own.
@@ -57,6 +58,15 @@ const STEPS = [
57
58
  db.exec("ALTER TABLE leads ADD COLUMN submission_id TEXT");
58
59
  db.exec("CREATE UNIQUE INDEX leads_submission_id ON leads (submission_id) WHERE submission_id IS NOT NULL");
59
60
  },
61
+ // 7 — how the need was sold (`quote`, `estimate`, `fixed`), and for the two
62
+ // priced flows the server's price, the model's date and an estimate's
63
+ // answers (JSON). Nullable: a row from before, a callback, a quote.
64
+ db => {
65
+ db.exec("ALTER TABLE leads ADD COLUMN flow TEXT");
66
+ db.exec("ALTER TABLE leads ADD COLUMN quoted_cents INTEGER");
67
+ db.exec("ALTER TABLE leads ADD COLUMN pricing_valid_from TEXT");
68
+ db.exec("ALTER TABLE leads ADD COLUMN estimate_inputs TEXT");
69
+ },
60
70
  ];
61
71
  if (STEPS.length !== LEAD_SCHEMA_VERSION) {
62
72
  // The version belongs to the port, shared by every adapter; this table of
@@ -69,22 +79,38 @@ function str(row, key) {
69
79
  return typeof value === "string" ? value : null;
70
80
  }
71
81
  const VERDICTS = ["honeypot", "too-fast", "rate-limited"];
82
+ /** A JSON object of strings, or `{}`: what `extras` and `estimate_inputs` hold. */
83
+ function strings(text) {
84
+ const value = JSON.parse(text ?? "{}");
85
+ return Object.fromEntries(Object.entries(typeof value === "object" && value !== null ? value : {}).filter((e) => typeof e[1] === "string"));
86
+ }
87
+ function priceOf(row) {
88
+ const cents = column(row, "quoted_cents");
89
+ const validFrom = str(row, "pricing_valid_from");
90
+ if ((typeof cents !== "number" && typeof cents !== "bigint") || validFrom === null)
91
+ return null;
92
+ const inputs = str(row, "estimate_inputs");
93
+ return { cents: Number(cents), validFrom, ...(inputs !== null ? { inputs: strings(inputs) } : {}) };
94
+ }
72
95
  /** A stored row read back as the lead it was written from. */
73
96
  function leadOf(row) {
74
97
  const verdict = str(row, "spam_verdict");
75
- const extras = JSON.parse(str(row, "extras") ?? "{}");
76
98
  const consentAt = str(row, "consent_at");
77
99
  const submissionId = str(row, "submission_id");
100
+ const flow = LEAD_FLOWS.find(f => f === str(row, "flow"));
101
+ const price = priceOf(row);
78
102
  return {
79
103
  subject: str(row, "job") ?? "",
80
104
  locality: str(row, "zip") ?? "",
81
105
  mobile: str(row, "mobile") ?? "",
82
- extras: Object.fromEntries(Object.entries(typeof extras === "object" && extras !== null ? extras : {}).filter((e) => typeof e[1] === "string")),
106
+ extras: strings(str(row, "extras")),
83
107
  placeSlug: str(row, "location_id"),
84
108
  spamVerdict: verdict !== null && VERDICTS.includes(verdict) ? verdict : null,
85
109
  channel: str(row, "channel") === "callback" ? "callback" : "form",
86
110
  ...(consentAt !== null ? { consent: { at: consentAt, text: str(row, "consent_text") ?? "" } } : {}),
87
111
  ...(submissionId !== null ? { submissionId } : {}),
112
+ ...(flow !== undefined ? { flow } : {}),
113
+ ...(price !== null ? { price } : {}),
88
114
  };
89
115
  }
90
116
  function userVersion(db) {
@@ -168,13 +194,13 @@ export function openSqliteLeadStore(path) {
168
194
  db.close();
169
195
  throw error;
170
196
  }
171
- const insert = db.prepare("INSERT INTO leads (job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text, submission_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id");
172
- const bySubmission = db.prepare("SELECT id, job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text, submission_id FROM leads WHERE submission_id = ?");
197
+ const insert = db.prepare("INSERT INTO leads (job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text, submission_id, flow, quoted_cents, pricing_valid_from, estimate_inputs) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id");
198
+ const bySubmission = db.prepare("SELECT id, job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text, submission_id, flow, quoted_cents, pricing_valid_from, estimate_inputs FROM leads WHERE submission_id = ?");
173
199
  const count = db.prepare("SELECT COUNT(*) AS n FROM leads");
174
200
  return {
175
201
  async insert(lead) {
176
202
  const extras = Object.keys(lead.extras).length > 0 ? JSON.stringify(lead.extras) : null;
177
- return integer(insert.get(lead.subject, lead.locality, lead.mobile, lead.placeSlug, lead.spamVerdict, extras, channelOf(lead), lead.consent?.at ?? null, lead.consent?.text ?? null, lead.submissionId ?? null), "id");
203
+ return integer(insert.get(lead.subject, lead.locality, lead.mobile, lead.placeSlug, lead.spamVerdict, extras, channelOf(lead), lead.consent?.at ?? null, lead.consent?.text ?? null, lead.submissionId ?? null, lead.flow ?? null, lead.price?.cents ?? null, lead.price?.validFrom ?? null, lead.price?.inputs ? JSON.stringify(lead.price.inputs) : null), "id");
178
204
  },
179
205
  async findSubmission(submissionId) {
180
206
  const row = bySubmission.get(submissionId);