@evinvest/kitstart 0.12.0 → 0.13.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 +13 -6
- package/dist/react/LeadCapture.js +4 -2
- package/dist/react/LeadCaptureEstimate.d.ts +1 -1
- package/dist/react/LeadCaptureEstimate.js +1 -1
- package/dist/react/use-lead-context.d.ts +7 -0
- package/dist/react/use-lead-context.js +15 -0
- package/dist/server/lead-webhook.d.ts +6 -9
- package/dist/server/lead-webhook.js +6 -8
- package/package.json +1 -1
- package/template/flake.nix +1 -1
- package/template/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ brand's `flake.nix` is its own config plus one call — `template/flake.nix` is
|
|
|
45
45
|
the whole of one:
|
|
46
46
|
|
|
47
47
|
```nix
|
|
48
|
-
inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.
|
|
48
|
+
inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.13.0"; # the version in package-lock.json
|
|
49
49
|
inputs.ev.inputs.v_flakes.follows = "v_flakes";
|
|
50
50
|
|
|
51
51
|
landing = ev.lib.mkLanding {
|
|
@@ -236,7 +236,7 @@ it is the plain POST to `/quote` it always was.
|
|
|
236
236
|
| `bookingVariant` · `bookingEmbed` · `bookingAdapters` | the `booking_provider` variant that picks the place's provider · the providers' embeds (after cookie consent) rather than a new tab · the brand's adapters over the built-ins (from a client component) — see [Booking](#booking-manual-link-google_calendar-cal_com) |
|
|
237
237
|
| `initialError` | `leadErrorOf(searchParams)` on a page that reads its query: the refusal a 303 brought back, drawn on the server by the card it names (`lead_card`) so the card says why without a script (see *Refusals*) |
|
|
238
238
|
| `head`, `trust` | the brand's heading instead of the title; a slot beside the submit. Like `extras`, `done` and `booking`, any node, built on the server or not, and never asked for a `key`: each slot sits alone in a keyed fragment |
|
|
239
|
-
| `className` · `classNames` | the root · its parts: `root`, `head`, `title`, `lede`, `form`, `contact`, `field`, `label`, `control` (every input, the need's select in both states, the callback's phone), `hint`, `error` (a refusal: under the field, or above the submit), `chips`, `chip`, `needs`, `need`, `summary`, `submit`, `trust`, `privacy`, `opening`, `others`, `channel`, `primary`, `callback`, `callbackSummary`, `callbackForm`, `callbackLede`, `callbackSubmit`, `consent`, `done`, and for the flows `estimate`, `estimateInput`, `estimateLegend`, `estimateOption
|
|
239
|
+
| `className` · `classNames` | the root · its parts: `root`, `head`, `title`, `lede`, `form`, `contact`, `field`, `label`, `control` (every input, the need's select in both states, the callback's phone), `hint`, `error` (a refusal: under the field, or above the submit), `chips`, `chip`, `needs`, `need`, `summary`, `submit`, `trust`, `privacy`, `opening`, `others`, `channel`, `primary`, `callback`, `callbackSummary`, `callbackForm`, `callbackLede`, `callbackSubmit`, `consent`, `done`, and for the flows `estimate`, `estimateInput`, `estimateLegend`, `estimateGrid` (an input's answers: 2 columns, 3 from `sm`), `estimateOption` (an answer's tile), `price`, `priceTotal`, `breakdown`, `priceNote`, `photos`, `priced`, `pricedPrice`, `pricedNote`, and for the booking `booking`, `bookingCta`, `bookingNote`, `prefer`, `preferOption`, `preferSubmit` |
|
|
240
240
|
|
|
241
241
|
- **Taps.** A need the page knows is not asked again, and a place serving one
|
|
242
242
|
commune fills it: focus the phone, type, send — two taps. `qualify-first`
|
|
@@ -315,7 +315,10 @@ it is the plain POST to `/quote` it always was.
|
|
|
315
315
|
never makes a second lead. While it runs the form's submit is disabled, `aria-busy` and
|
|
316
316
|
says `sending`. No answer — no network, or none in 15 s
|
|
317
317
|
(`SUBMIT_TIMEOUT_MS`) — is said in place with a retry that sends the same
|
|
318
|
-
lead; the page is never left for the browser's error page.
|
|
318
|
+
lead; the page is never left for the browser's error page. While a post
|
|
319
|
+
runs or waits for its retry, the channels keep their order: the hours
|
|
320
|
+
turning (the place closing) would move the callback, and a moved form is a
|
|
321
|
+
new one — what was typed gone, the retry sent nowhere.
|
|
319
322
|
- **Events** (through `AnalyticsBoundary`'s sink; none outside one):
|
|
320
323
|
`lead_form_view` (half in view, once), `lead_form_start` (first focus),
|
|
321
324
|
`lead_form_field_error {field, blocking}` (`blocking: true` the browser
|
|
@@ -374,7 +377,11 @@ export const POST = quoteRoute(site, { env: serverEnv, notifier, pricing, unavai
|
|
|
374
377
|
need — is a `quote`. So a flow the brand has not switched on is never run
|
|
375
378
|
by a posted field.
|
|
376
379
|
- **The form.** An estimate's questions are a tile per answer (touch-sized
|
|
377
|
-
radios posted as `estimate_<input>`), required once the script runs
|
|
380
|
+
radios posted as `estimate_<input>`), required once the script runs —
|
|
381
|
+
two columns, three from `sm`; a brand sets its own count with
|
|
382
|
+
`classNames={{ estimateGrid: "grid-cols-3 sm:grid-cols-4" }}` (a denser
|
|
383
|
+
grid, a shorter card), and a tile keeps its 44 px touch height in any
|
|
384
|
+
(`estimateOption` styles the tile itself); the
|
|
378
385
|
price box (`aria-live`) shows the total and the lines that made it, or
|
|
379
386
|
what is missing — never "from". A fixed need shows its price. Either
|
|
380
387
|
submits as `bookSubmit` ("Réserver"). Without a script the answers still
|
|
@@ -650,8 +657,8 @@ export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavai
|
|
|
650
657
|
loopback — the body carries PII. All checked at boot.
|
|
651
658
|
- **The channel.** `channelOf(lead)` is `form` or `callback`. For the
|
|
652
659
|
Service-Arb panel, map it through `panelChannel`: its `properties.channel`
|
|
653
|
-
is a closed set
|
|
654
|
-
|
|
660
|
+
is a closed set (`form` | `phone_inbound` | `callback`) that refuses the
|
|
661
|
+
whole event outside it, and a callback goes as `callback` (panel v0.3.0 on).
|
|
655
662
|
- **Queued before the 303, sent after it.** The body is built once, from the
|
|
656
663
|
lead and `ctx` (`leadId`, `brandId`, `locale`, `formId`, `at`, and a fresh
|
|
657
664
|
`idempotencyKey` for the receiver to deduplicate by), and written to the
|
|
@@ -17,7 +17,7 @@ import { EstimateInputs, PriceBox, useEstimate } from "./LeadCaptureEstimate.js"
|
|
|
17
17
|
import { FormMessage, LocalityField, NameField, PhoneField } from "./LeadCaptureFields.js";
|
|
18
18
|
import { NeedField } from "./LeadCaptureNeed.js";
|
|
19
19
|
import { QuoteFormShell } from "./QuoteFormShell.js";
|
|
20
|
-
import { useHydrated, useNeed, useNow, useOpenOnHash } from "./use-lead-context.js";
|
|
20
|
+
import { useHeld, useHydrated, useNeed, useNow, useOpenOnHash } from "./use-lead-context.js";
|
|
21
21
|
import { errorText, formMessageId, OWN_FIELDS, useLeadError } from "./use-lead-error.js";
|
|
22
22
|
import { useLeadEvents } from "./use-lead-events.js";
|
|
23
23
|
import { FailureMessage, SubmitButton } from "./LeadCaptureSubmit.js";
|
|
@@ -74,7 +74,9 @@ export function LeadCapture(props) {
|
|
|
74
74
|
const doneRef = useRef(null);
|
|
75
75
|
// The form the focus was in is gone: the news takes it, and is read out.
|
|
76
76
|
useEffect(() => doneRef.current?.focus(), [sent]);
|
|
77
|
-
|
|
77
|
+
// Held while a lead is posting or failed: its form stays where it is, for the retry.
|
|
78
|
+
const now = useHeld(useNow(renderedAt), busy !== null || failure !== null);
|
|
79
|
+
const resolved = resolveChannels({ ...contact, hours: place.hours }, { now: new Date(now), timeZone: props.timeZone, prefer: props.prefer });
|
|
78
80
|
const opening = openingText(resolved.nextOpening, text, locale);
|
|
79
81
|
const needLabel = needs.find(n => n.value === need)?.label;
|
|
80
82
|
const message = needLabel ? fillText(text.message, { need: needLabel }) : text.messageGeneric;
|
|
@@ -2,7 +2,7 @@ import type { LeadCaptureFlowText } from "../core/lead-capture-text.js";
|
|
|
2
2
|
import { type LeadFlow } from "../core/pricing/flow.js";
|
|
3
3
|
import type { Price, PricingModel } from "../core/pricing/model.js";
|
|
4
4
|
import type { PartClassNames } from "./parts.js";
|
|
5
|
-
export type EstimatePart = "estimate" | "estimateInput" | "estimateLegend" | "estimateOption" | "price" | "priceTotal" | "breakdown" | "priceNote";
|
|
5
|
+
export type EstimatePart = "estimate" | "estimateInput" | "estimateLegend" | "estimateGrid" | "estimateOption" | "price" | "priceTotal" | "breakdown" | "priceNote";
|
|
6
6
|
/**
|
|
7
7
|
* The live price of the need on screen: its answers, as the visitor taps
|
|
8
8
|
* them, and the price `priceOf` makes of them — the same function the server
|
|
@@ -36,7 +36,7 @@ export function EstimateInputs(props) {
|
|
|
36
36
|
if (pricing?.kind !== "estimate")
|
|
37
37
|
return null;
|
|
38
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))) }));
|
|
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: cn("grid grid-cols-2 gap-2 sm:grid-cols-3", c?.estimateGrid), 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
40
|
}
|
|
41
41
|
function lineLabel(line, model, locale, text) {
|
|
42
42
|
switch (line.kind) {
|
|
@@ -6,6 +6,13 @@ export declare const NEED_ATTR = "data-need";
|
|
|
6
6
|
* A page cached for ten minutes is put right once it runs.
|
|
7
7
|
*/
|
|
8
8
|
export declare function useNow(renderedAt: number): number;
|
|
9
|
+
/**
|
|
10
|
+
* `value`, held at what it was when `hold` turned on, until it turns off. The
|
|
11
|
+
* channel order runs on the clock, and a channel that moves is a form React
|
|
12
|
+
* draws anew: what was typed is gone, and the retry of a post still waiting
|
|
13
|
+
* on the server would go to a form no longer on the page.
|
|
14
|
+
*/
|
|
15
|
+
export declare function useHeld<T>(value: T, hold: boolean): T;
|
|
9
16
|
/** `false` on the server and while hydrating, `true` from the render after. */
|
|
10
17
|
export declare const useHydrated: () => boolean;
|
|
11
18
|
/**
|
|
@@ -17,6 +17,21 @@ const minuteNow = () => Math.floor(Date.now() / MINUTE) * MINUTE;
|
|
|
17
17
|
export function useNow(renderedAt) {
|
|
18
18
|
return useSyncExternalStore(subscribeMinute, minuteNow, () => renderedAt);
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* `value`, held at what it was when `hold` turned on, until it turns off. The
|
|
22
|
+
* channel order runs on the clock, and a channel that moves is a form React
|
|
23
|
+
* draws anew: what was typed is gone, and the retry of a post still waiting
|
|
24
|
+
* on the server would go to a form no longer on the page.
|
|
25
|
+
*/
|
|
26
|
+
export function useHeld(value, hold) {
|
|
27
|
+
const [held, setHeld] = useState(null);
|
|
28
|
+
// Adjusted during render, React's own pattern for state derived from a prop.
|
|
29
|
+
if (hold && held === null)
|
|
30
|
+
setHeld({ value });
|
|
31
|
+
if (!hold && held !== null)
|
|
32
|
+
setHeld(null);
|
|
33
|
+
return held ? held.value : value;
|
|
34
|
+
}
|
|
20
35
|
const subscribeNothing = () => () => { };
|
|
21
36
|
/** `false` on the server and while hydrating, `true` from the render after. */
|
|
22
37
|
export const useHydrated = () => useSyncExternalStore(subscribeNothing, () => true, () => false);
|
|
@@ -70,15 +70,13 @@ export interface PanelFlowProperties {
|
|
|
70
70
|
}
|
|
71
71
|
export declare function panelFlowProperties(flow: PanelFlow | undefined): PanelFlowProperties | Record<string, never>;
|
|
72
72
|
/**
|
|
73
|
-
*
|
|
74
|
-
* `properties.channel`
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
73
|
+
* A lead's channel as the Service-Arb panel's `lead.created` carries it in
|
|
74
|
+
* `properties.channel` — a closed set (`form` | `phone_inbound` | `callback`)
|
|
75
|
+
* that refuses the whole event outside it, so the outbox would park the lead.
|
|
76
|
+
* A site's lead is a form or a callback, each its own: `phone_inbound` is the
|
|
77
|
+
* panel's, for the calls it records itself. `panelChannel(channelOf(lead))`.
|
|
78
78
|
*/
|
|
79
|
-
declare
|
|
80
|
-
/** A lead's channel as the panel's `properties.channel` may carry it: `panelChannel(channelOf(lead))`. */
|
|
81
|
-
export declare function panelChannel(channel: LeadChannel): "form" | typeof PANEL_CALLBACK;
|
|
79
|
+
export declare function panelChannel(channel: LeadChannel): "form" | "callback";
|
|
82
80
|
/**
|
|
83
81
|
* The receiver's body, built by the brand: the kit serialises and signs
|
|
84
82
|
* whatever this returns and knows nothing of its shape. Called once, when the
|
|
@@ -188,4 +186,3 @@ export declare function leadWebhook(site: {
|
|
|
188
186
|
id: string;
|
|
189
187
|
};
|
|
190
188
|
}, env: Pick<ServerEnv, "leadsDb" | "leadWebhook">, options: LeadWebhookOptions): LeadWebhook | null;
|
|
191
|
-
export {};
|
|
@@ -22,16 +22,14 @@ export function panelFlowProperties(flow) {
|
|
|
22
22
|
};
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
* `properties.channel`
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
25
|
+
* A lead's channel as the Service-Arb panel's `lead.created` carries it in
|
|
26
|
+
* `properties.channel` — a closed set (`form` | `phone_inbound` | `callback`)
|
|
27
|
+
* that refuses the whole event outside it, so the outbox would park the lead.
|
|
28
|
+
* A site's lead is a form or a callback, each its own: `phone_inbound` is the
|
|
29
|
+
* panel's, for the calls it records itself. `panelChannel(channelOf(lead))`.
|
|
30
30
|
*/
|
|
31
|
-
const PANEL_CALLBACK = "form";
|
|
32
|
-
/** A lead's channel as the panel's `properties.channel` may carry it: `panelChannel(channelOf(lead))`. */
|
|
33
31
|
export function panelChannel(channel) {
|
|
34
|
-
return channel
|
|
32
|
+
return channel;
|
|
35
33
|
}
|
|
36
34
|
/**
|
|
37
35
|
* The lead webhook, or `null` when it is off: no `LEAD_WEBHOOK_URL`, or no
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evinvest/kitstart",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Landing-site machinery for local-service brands: a site composition root, the Place/Presence/ServiceArea model with a publication gate, host/locale routing, schema.org and SEO builders, the lead funnel with its antispam, and analytics events. Pure core at '.', more subpaths per runtime.",
|
|
5
5
|
"license": "BlueOak-1.0.0",
|
|
6
6
|
"publishConfig": {
|
package/template/flake.nix
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
v_flakes.url = "github:valeratrades/v_flakes?ref=v1.6";
|
|
4
4
|
# The lib flake at the tag of the @evinvest/kitstart version in
|
|
5
5
|
# package-lock.json — mkLanding refuses a mismatch.
|
|
6
|
-
ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.
|
|
6
|
+
ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.13.0";
|
|
7
7
|
ev.inputs.v_flakes.follows = "v_flakes";
|
|
8
8
|
};
|
|
9
9
|
|
package/template/package.json
CHANGED