@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
|
@@ -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. */
|
package/dist/next/quote-route.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
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 &&
|
|
86
|
-
|
|
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
|
|
46
|
-
*
|
|
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 {
|
|
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
|
|
80
|
-
*
|
|
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
|
}, () => {
|
package/dist/server/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/server/index.js
CHANGED
|
@@ -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:
|
|
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);
|