@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 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.12.0"; # the version in package-lock.json
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`, `price`, `priceTotal`, `breakdown`, `priceNote`, `photos`, `priced`, `pricedPrice`, `pricedNote`, and for the booking `booking`, `bookingCta`, `bookingNote`, `prefer`, `preferOption`, `preferSubmit` |
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; the
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 and refuses the whole event outside it, so a callback goes
654
- as `form` until the panel accepts `callback` (one constant to flip).
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
- const resolved = resolveChannels({ ...contact, hours: place.hours }, { now: new Date(useNow(renderedAt)), timeZone: props.timeZone, prefer: props.prefer });
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
- * What the Service-Arb panel's `lead.created` calls a callback request. Its
74
- * `properties.channel` is a closed set (`form` | `phone_inbound`) and an event
75
- * outside it is refused whole — the outbox would park the lead — so until the
76
- * panel's `sa.funnel.v1` accepts `callback`, a callback travels as `form`.
77
- * TODO(panel): set to "callback" once the panel accepts it; nothing else changes.
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 const PANEL_CALLBACK = "form";
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
- * What the Service-Arb panel's `lead.created` calls a callback request. Its
26
- * `properties.channel` is a closed set (`form` | `phone_inbound`) and an event
27
- * outside it is refused whole — the outbox would park the lead — so until the
28
- * panel's `sa.funnel.v1` accepts `callback`, a callback travels as `form`.
29
- * TODO(panel): set to "callback" once the panel accepts it; nothing else changes.
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 === "callback" ? PANEL_CALLBACK : "form";
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.12.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": {
@@ -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.12.0";
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
 
@@ -20,7 +20,7 @@
20
20
  "dependencies": {
21
21
  "@evinvest/analytics": "^0.3.0",
22
22
  "@evinvest/i18n": "^0.9.0",
23
- "@evinvest/kitstart": "^0.12.0",
23
+ "@evinvest/kitstart": "^0.13.0",
24
24
  "@evinvest/marketing": "^0.3.0",
25
25
  "@evinvest/uikit": "^0.26.0",
26
26
  "next": "16.3.6",