@evinvest/kitstart 0.13.0 → 0.14.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 (50) hide show
  1. package/README.md +114 -8
  2. package/dist/core/accept.js +6 -2
  3. package/dist/core/analytics.d.ts +7 -2
  4. package/dist/core/lead-capture-text.d.ts +21 -1
  5. package/dist/core/lead-capture-text.js +16 -0
  6. package/dist/core/pricing/flow.d.ts +9 -0
  7. package/dist/core/pricing/flow.js +14 -0
  8. package/dist/core/pricing/index.d.ts +1 -1
  9. package/dist/core/pricing/index.js +1 -1
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.js +1 -1
  12. package/dist/react/LeadCapture.d.ts +42 -8
  13. package/dist/react/LeadCapture.js +171 -26
  14. package/dist/react/LeadCaptureChannels.d.ts +8 -2
  15. package/dist/react/LeadCaptureChannels.js +8 -2
  16. package/dist/react/LeadCaptureEstimate.d.ts +59 -26
  17. package/dist/react/LeadCaptureEstimate.js +81 -41
  18. package/dist/react/LeadCaptureFields.d.ts +2 -0
  19. package/dist/react/LeadCaptureFields.js +1 -1
  20. package/dist/react/LeadCaptureNeed.d.ts +27 -6
  21. package/dist/react/LeadCaptureNeed.js +10 -41
  22. package/dist/react/LeadCapturePrice.d.ts +36 -0
  23. package/dist/react/LeadCapturePrice.js +47 -0
  24. package/dist/react/LeadCaptureSteps.d.ts +47 -0
  25. package/dist/react/LeadCaptureSteps.js +39 -0
  26. package/dist/react/LeadCaptureTiles.d.ts +53 -0
  27. package/dist/react/LeadCaptureTiles.js +50 -0
  28. package/dist/react/QuoteFormShell.d.ts +5 -1
  29. package/dist/react/QuoteFormShell.js +2 -2
  30. package/dist/react/focus-next.d.ts +11 -0
  31. package/dist/react/focus-next.js +58 -0
  32. package/dist/react/index.d.ts +4 -1
  33. package/dist/react/lead-steps.d.ts +53 -0
  34. package/dist/react/lead-steps.js +58 -0
  35. package/dist/react/use-lead-context.d.ts +6 -0
  36. package/dist/react/use-lead-context.js +16 -0
  37. package/dist/react/use-lead-focus.d.ts +19 -0
  38. package/dist/react/use-lead-focus.js +70 -0
  39. package/package.json +1 -1
  40. package/template/app/[locale]/[location]/prices/page.tsx +6 -1
  41. package/template/flake.nix +1 -1
  42. package/template/package.json +2 -2
  43. package/template/src/entities/content/model/en.ts +7 -0
  44. package/template/src/entities/content/model/fr.ts +7 -0
  45. package/template/src/entities/content/model/types.ts +8 -0
  46. package/template/src/shared/config/lead.ts +5 -1
  47. package/template/src/views/prices/index.ts +1 -0
  48. package/template/src/views/prices/ui/EstimateCard.tsx +66 -0
  49. package/template/tests/bundle_budget.txt +2 -1
  50. package/template/tests/e2e/steps.spec.ts +138 -0
@@ -6,6 +6,6 @@ import { CARD_FIELD, SUBMISSION_FIELD } from "../core/lead.js";
6
6
  /** The mobile field's attributes: the numeric keypad and the phone's autofill. */
7
7
  export const PHONE_INPUT_PROPS = { type: "tel", inputMode: "tel", autoComplete: "tel" };
8
8
  export function QuoteFormShell(props) {
9
- const { placeSlug, locale, renderedAt, honeypotLabel, formId = "quote", card, action = "/quote", id = "quote", className, onSubmit, children } = props;
10
- return (_jsxs("form", { id: id, method: "post", action: action, onSubmit: onSubmit, className: cn("relative flex w-full flex-col gap-5", className), children: [placeSlug && _jsx("input", { type: "hidden", name: LOCATION_FIELD, value: placeSlug }), _jsx("input", { type: "hidden", name: LOCALE_FIELD, value: locale }), _jsx("input", { type: "hidden", name: FORM_ID_FIELD, value: formId }), _jsx("input", { type: "hidden", name: RENDERED_AT_FIELD, value: String(renderedAt) }), card && _jsx("input", { type: "hidden", name: CARD_FIELD, value: card }), _jsx("input", { type: "hidden", name: SUBMISSION_FIELD }), children, _jsx("div", { "aria-hidden": "true", style: { position: "absolute", left: "-10000px", width: 1, height: 1, overflow: "hidden" }, children: _jsxs("label", { children: [honeypotLabel, _jsx("input", { type: "text", name: HONEYPOT_FIELD, tabIndex: -1, autoComplete: "off", defaultValue: "" })] }) })] }));
9
+ const { placeSlug, locale, renderedAt, honeypotLabel, formId = "quote", card, action = "/quote", id = "quote", className, onSubmit, onKeyDown, children } = props;
10
+ return (_jsxs("form", { id: id, method: "post", action: action, onSubmit: onSubmit, onKeyDown: onKeyDown, ...props.data, className: cn("relative flex w-full flex-col gap-5", className), children: [placeSlug && _jsx("input", { type: "hidden", name: LOCATION_FIELD, value: placeSlug }), _jsx("input", { type: "hidden", name: LOCALE_FIELD, value: locale }), _jsx("input", { type: "hidden", name: FORM_ID_FIELD, value: formId }), _jsx("input", { type: "hidden", name: RENDERED_AT_FIELD, value: String(renderedAt) }), card && _jsx("input", { type: "hidden", name: CARD_FIELD, value: card }), _jsx("input", { type: "hidden", name: SUBMISSION_FIELD }), children, _jsx("div", { "aria-hidden": "true", style: { position: "absolute", left: "-10000px", width: 1, height: 1, overflow: "hidden" }, children: _jsxs("label", { children: [honeypotLabel, _jsx("input", { type: "text", name: HONEYPOT_FIELD, tabIndex: -1, autoComplete: "off", defaultValue: "" })] }) })] }));
11
11
  }
@@ -0,0 +1,11 @@
1
+ /** `LeadSteps`' mark of a screen that is off (`STEP_OFF`), and the attribute. */
2
+ export declare const OFF_SELECTOR = "[data-lead-off], [hidden]";
3
+ /**
4
+ * The next empty field of `form` after `from` (from the top without one), or
5
+ * `null`. `hiddenToo`: on a screen not shown yet, too — the next screen's.
6
+ */
7
+ export declare function nextEmpty(form: HTMLFormElement, from: Element | null, hiddenToo?: boolean): HTMLInputElement | null;
8
+ /** Focuses the next empty field after `from`; `true` when there was one. */
9
+ export declare function focusNext(form: HTMLFormElement | null | undefined, from: Element | null): boolean;
10
+ /** Focuses a step's first empty field, else its first field: what the screen asks. */
11
+ export declare function focusStep(step: Element | null | undefined): void;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Where the focus goes after an answer: the next field the visitor has not
3
+ * filled — a radio group with nothing checked takes it at its first radio, a
4
+ * typed field when empty. Read from the DOM, in document order, so a brand's
5
+ * extras take their turn too; a field on a screen that is off (another step) is
6
+ * not there to be filled.
7
+ */
8
+ const FIELDS = "[data-lead-field]";
9
+ function empty(el, form) {
10
+ if (el.type === "radio")
11
+ return !form.querySelector(`input[type=radio][name="${CSS.escape(el.name)}"]:checked`);
12
+ if (el.type === "checkbox")
13
+ return !el.checked;
14
+ return el.value.trim() === "";
15
+ }
16
+ /** `LeadSteps`' mark of a screen that is off (`STEP_OFF`), and the attribute. */
17
+ export const OFF_SELECTOR = "[data-lead-off], [hidden]";
18
+ const shown = (el) => el.closest(OFF_SELECTOR) === null;
19
+ /** The fields of `scope` in document order, a radio group once — at its checked radio, else its first. */
20
+ function fields(scope, hiddenToo = false) {
21
+ const seen = new Set();
22
+ const out = [];
23
+ for (const el of scope.querySelectorAll(FIELDS)) {
24
+ if (!(el instanceof HTMLInputElement) || el.disabled || el.type === "hidden" || (!hiddenToo && !shown(el)))
25
+ continue;
26
+ if (el.type === "radio") {
27
+ if (seen.has(el.name))
28
+ continue;
29
+ seen.add(el.name);
30
+ out.push(el.form?.querySelector(`input[type=radio][name="${CSS.escape(el.name)}"]:checked`) ?? el);
31
+ continue;
32
+ }
33
+ out.push(el);
34
+ }
35
+ return out;
36
+ }
37
+ /**
38
+ * The next empty field of `form` after `from` (from the top without one), or
39
+ * `null`. `hiddenToo`: on a screen not shown yet, too — the next screen's.
40
+ */
41
+ export function nextEmpty(form, from, hiddenToo = false) {
42
+ const after = (el) => from === null || (from.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0;
43
+ return fields(form, hiddenToo).find(el => after(el) && !from?.contains(el) && empty(el, form)) ?? null;
44
+ }
45
+ /** Focuses the next empty field after `from`; `true` when there was one. */
46
+ export function focusNext(form, from) {
47
+ const next = form ? nextEmpty(form, from) : null;
48
+ next?.focus();
49
+ return next !== null;
50
+ }
51
+ /** Focuses a step's first empty field, else its first field: what the screen asks. */
52
+ export function focusStep(step) {
53
+ const form = step?.closest("form");
54
+ if (!step || !form)
55
+ return;
56
+ const all = fields(step);
57
+ (all.find(el => empty(el, form)) ?? all[0])?.focus();
58
+ }
@@ -23,7 +23,10 @@ export type { BookingPart } from "./LeadBookingManual.js";
23
23
  export { BOOKING_ADAPTERS, BOOKING_EMBEDS, bookingAdapters, calComAdapter, calComEmbedAdapter, calComEmbedOrigin, googleCalendarAdapter, linkAdapter, manualAdapter, type BookedSlot, type BookingAdapter, type BookingAdapters, type BookingContext, } from "./booking-adapters.js";
24
24
  export { BOOKING_ACTION } from "./use-booking.js";
25
25
  export type { LeadSent } from "./use-lead-submit.js";
26
- export type { LeadCaptureLayout } from "./LeadCaptureNeed.js";
26
+ export type { LeadCaptureLayout, LeadNeedDisplay, LeadNeedOption } from "./LeadCaptureNeed.js";
27
+ export type { EstimateQuestion, EstimateQuestions } from "./LeadCaptureEstimate.js";
28
+ export type { LeadIntro, LeadIntroOption } from "./lead-steps.js";
29
+ export type { ChannelIconKey } from "./LeadCaptureChannels.js";
27
30
  export { MapFacade, type MapFacadePart, type MapFacadeProps } from "./MapFacade.js";
28
31
  export { PlaceDirectory, type PlaceDirectoryPart, type PlaceDirectoryProps } from "./PlaceDirectory.js";
29
32
  export { PHONE_INPUT_PROPS, QuoteFormShell, type QuoteFormShellProps } from "./QuoteFormShell.js";
@@ -0,0 +1,53 @@
1
+ import { type ReactNode } from "react";
2
+ import type { LeadStep } from "../core/analytics.js";
3
+ /** One answer to the intro question. */
4
+ export interface LeadIntroOption {
5
+ /** Posted under the intro's `field`: a slug. */
6
+ value: string;
7
+ label: string;
8
+ /** Drawn on its tile, the brand's own. */
9
+ icon?: ReactNode;
10
+ /** `callback`: the rest of the form is the phone and its consent, posted as a callback. `form` (default): the form. */
11
+ channel?: "form" | "callback" | undefined;
12
+ }
13
+ /**
14
+ * A question asked before the need (`steps`) — how urgent, who is asking —
15
+ * whose answer goes with the lead as one of the brand's extras: declare
16
+ * `field` in `site.lead.extras`, or the server drops it.
17
+ */
18
+ export interface LeadIntro {
19
+ label: string;
20
+ field: string;
21
+ options: readonly LeadIntroOption[];
22
+ }
23
+ /** A screen of `layout="steps"`, named as `lead_form_step` reports it. */
24
+ export type StepId = Exclude<LeadStep, "contact">;
25
+ /**
26
+ * The screens, in order: the intro question when there is one; then — unless
27
+ * its answer asked for a call back, which needs the phone alone — the need,
28
+ * the estimate's screens asked now (a question each, or a few sharing one,
29
+ * named by the first), the postcode on its own screen (`localityStep="own"`),
30
+ * and the phone, always last.
31
+ */
32
+ export declare function stepsOf(o: {
33
+ intro: boolean;
34
+ callback: boolean;
35
+ estimate: readonly (readonly string[])[];
36
+ localityOwn: boolean;
37
+ }): StepId[];
38
+ /** The screen a refused field is shown on: its own (an estimate's, the screen it shares), else the last. */
39
+ export declare function stepOfField(steps: readonly StepId[], field: string, estimate?: readonly (readonly string[])[]): StepId | null;
40
+ /**
41
+ * Which screen is on: the one a refusal points at, else the one the visitor
42
+ * went back to (`back`, `edit`), else the first not yet answered. An answer
43
+ * calls `next`, and the first open screen comes back on. Each move is
44
+ * reported once (`onStep`) — never the first screen, which `lead_form_view`
45
+ * already counts.
46
+ */
47
+ export declare function useLeadSteps(steps: readonly StepId[], answered: (id: StepId) => boolean, refused: StepId | null, onStep: (id: StepId) => void): {
48
+ current: StepId;
49
+ index: number;
50
+ back: () => void;
51
+ edit: (id: StepId) => void;
52
+ next: () => void;
53
+ };
@@ -0,0 +1,58 @@
1
+ "use client";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { estimateField } from "../core/pricing/flow.js";
4
+ /**
5
+ * The screens, in order: the intro question when there is one; then — unless
6
+ * its answer asked for a call back, which needs the phone alone — the need,
7
+ * the estimate's screens asked now (a question each, or a few sharing one,
8
+ * named by the first), the postcode on its own screen (`localityStep="own"`),
9
+ * and the phone, always last.
10
+ */
11
+ export function stepsOf(o) {
12
+ const out = o.intro ? ["intro"] : [];
13
+ if (o.callback)
14
+ return [...out, "phone"];
15
+ out.push("need", ...o.estimate.flatMap(screen => (screen[0] === undefined ? [] : [estimateField(screen[0])])));
16
+ if (o.localityOwn)
17
+ out.push("locality");
18
+ out.push("phone");
19
+ return out;
20
+ }
21
+ /** The screen a refused field is shown on: its own (an estimate's, the screen it shares), else the last. */
22
+ export function stepOfField(steps, field, estimate = []) {
23
+ if (steps.length === 0)
24
+ return null;
25
+ const shared = estimate.find(screen => screen.some(id => estimateField(id) === field))?.[0];
26
+ const own = field === "need" || field === "locality" ? field : shared !== undefined ? estimateField(shared) : null;
27
+ const step = own !== null ? steps.find(s => s === own) : undefined;
28
+ return step ?? steps[steps.length - 1] ?? null;
29
+ }
30
+ /**
31
+ * Which screen is on: the one a refusal points at, else the one the visitor
32
+ * went back to (`back`, `edit`), else the first not yet answered. An answer
33
+ * calls `next`, and the first open screen comes back on. Each move is
34
+ * reported once (`onStep`) — never the first screen, which `lead_form_view`
35
+ * already counts.
36
+ */
37
+ export function useLeadSteps(steps, answered, refused, onStep) {
38
+ const [at, setAt] = useState(null);
39
+ const open = steps.find(s => !answered(s)) ?? steps[steps.length - 1] ?? "phone";
40
+ const current = refused !== null && steps.includes(refused) ? refused : at !== null && steps.includes(at) ? at : open;
41
+ const index = steps.indexOf(current);
42
+ const shown = useRef(current);
43
+ const report = useRef(onStep);
44
+ report.current = onStep;
45
+ useEffect(() => {
46
+ if (shown.current === current)
47
+ return;
48
+ shown.current = current;
49
+ report.current(current);
50
+ }, [current]);
51
+ return {
52
+ current,
53
+ index,
54
+ back: () => setAt(steps[index - 1] ?? null),
55
+ edit: (id) => setAt(id),
56
+ next: () => setAt(null),
57
+ };
58
+ }
@@ -29,3 +29,9 @@ onPreset: () => void): [string | undefined, (need: string) => void];
29
29
  * callback by id, and not every browser opens a closed one it scrolls to.
30
30
  */
31
31
  export declare function useOpenOnHash(id: string): void;
32
+ /**
33
+ * The postcode the page already knows: `?postcode=` once the script runs (a
34
+ * cached page cannot read the query) — the link from an ad or a card that
35
+ * asked it already. Anything that does not read as a postcode is ignored.
36
+ */
37
+ export declare function usePostcode(): string | undefined;
@@ -84,3 +84,19 @@ export function useOpenOnHash(id) {
84
84
  return () => window.removeEventListener("hashchange", open);
85
85
  }, [id]);
86
86
  }
87
+ /** What `?postcode=` may carry into the form: a short postcode, letters for the few countries that use them, never more. */
88
+ const POSTCODE_PARAM = /^[0-9A-Za-z][0-9A-Za-z -]{1,9}$/;
89
+ /**
90
+ * The postcode the page already knows: `?postcode=` once the script runs (a
91
+ * cached page cannot read the query) — the link from an ad or a card that
92
+ * asked it already. Anything that does not read as a postcode is ignored.
93
+ */
94
+ export function usePostcode() {
95
+ const [postcode, setPostcode] = useState(undefined);
96
+ useEffect(() => {
97
+ const value = new URLSearchParams(window.location.search).get("postcode")?.trim();
98
+ if (value && POSTCODE_PARAM.test(value))
99
+ setPostcode(value);
100
+ }, []);
101
+ return postcode;
102
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Where the focus goes once the visitor has answered: to the next screen in
3
+ * `steps`, else — with `focusNext` — to the next empty field.
4
+ *
5
+ * `moveOn(change, from, stable)` applies the answer. When it changes no
6
+ * screen (`stable`) and what comes next is a typed field, that field is
7
+ * revealed and focused inside the tap: iOS opens the keyboard only for a
8
+ * focus a gesture made. Otherwise the focus moves once React has drawn the
9
+ * change. `focusAfterSelect()`: the same after the kit's select, whose list
10
+ * hands the focus back to its trigger as it closes — so it waits for that.
11
+ */
12
+ export declare function useLeadFocus(form: () => HTMLFormElement | null, o: {
13
+ steps: boolean;
14
+ focusNext: boolean;
15
+ current: string;
16
+ }): {
17
+ moveOn(change: () => void, from: Element | null, stable: boolean): void;
18
+ focusAfterSelect(): void;
19
+ };
@@ -0,0 +1,70 @@
1
+ "use client";
2
+ import { useEffect, useLayoutEffect, useRef } from "react";
3
+ import { focusNext, focusStep, nextEmpty, OFF_SELECTOR } from "./focus-next.js";
4
+ /** A screen on or off as `LeadSteps` draws it: the mark and the inline style. */
5
+ function setOff(step, off) {
6
+ step.toggleAttribute("data-lead-off", off);
7
+ step.style.display = off ? "none" : "";
8
+ }
9
+ /**
10
+ * Where the focus goes once the visitor has answered: to the next screen in
11
+ * `steps`, else — with `focusNext` — to the next empty field.
12
+ *
13
+ * `moveOn(change, from, stable)` applies the answer. When it changes no
14
+ * screen (`stable`) and what comes next is a typed field, that field is
15
+ * revealed and focused inside the tap: iOS opens the keyboard only for a
16
+ * focus a gesture made. Otherwise the focus moves once React has drawn the
17
+ * change. `focusAfterSelect()`: the same after the kit's select, whose list
18
+ * hands the focus back to its trigger as it closes — so it waits for that.
19
+ */
20
+ export function useLeadFocus(form, o) {
21
+ const { steps, current } = o;
22
+ const pending = useRef(null);
23
+ const revealed = useRef(null);
24
+ const afterSelect = useRef(false);
25
+ useLayoutEffect(() => {
26
+ const el = steps ? form() : null;
27
+ // A screen revealed ahead of React is React's again: only the current one shows.
28
+ for (const step of el?.querySelectorAll("[data-lead-step]") ?? [])
29
+ setOff(step, step.dataset["leadStep"] !== current);
30
+ // Revealed ahead, but not the screen React put on (a postcode typed and never confirmed): that screen's field instead.
31
+ const lost = revealed.current?.closest(OFF_SELECTOR) != null;
32
+ revealed.current = null;
33
+ const move = pending.current ?? (lost ? { from: null } : null);
34
+ if (!move)
35
+ return;
36
+ pending.current = null;
37
+ if (steps)
38
+ focusStep(el?.querySelector("[data-lead-step]:not([data-lead-off])"));
39
+ else
40
+ focusNext(form(), move.from);
41
+ });
42
+ // Passive, so after the select's own effect has given its trigger the focus back.
43
+ useEffect(() => {
44
+ if (!afterSelect.current)
45
+ return;
46
+ afterSelect.current = false;
47
+ focusNext(form(), null);
48
+ });
49
+ return {
50
+ moveOn(change, from, stable) {
51
+ if (!steps && !o.focusNext)
52
+ return change();
53
+ change();
54
+ const el = form();
55
+ const next = stable && el ? nextEmpty(el, from, steps) : null;
56
+ if (next && next.type !== "radio") {
57
+ const step = next.closest("[data-lead-step]");
58
+ if (step)
59
+ setOff(step, false);
60
+ next.focus();
61
+ revealed.current = next;
62
+ }
63
+ else
64
+ pending.current = { from };
65
+ },
66
+ focusAfterSelect() {
67
+ afterSelect.current = o.focusNext;
68
+ },
69
+ };
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evinvest/kitstart",
3
- "version": "0.13.0",
3
+ "version": "0.14.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,6 +3,8 @@ import { placeMetadata } from "@evinvest/kitstart/next";
3
3
  import { Faq, JsonLd, Section } from "@evinvest/kitstart/react";
4
4
  import type { Metadata } from "next";
5
5
  import { loadPlace } from "@/views/place/server";
6
+ import { EstimateCard } from "@/views/prices";
7
+ import { pricing } from "@/shared/config/env";
6
8
  import { site } from "@/shared/config/site";
7
9
 
8
10
  type Props = { params: Promise<{ locale: string; location: string }> };
@@ -14,10 +16,13 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
14
16
  }
15
17
 
16
18
  export default async function PricesPage({ params }: Props) {
17
- const { copy } = await loadPlace(params);
19
+ const [{ view, copy, renderedAt }, model] = await Promise.all([loadPlace(params), pricing.model()]);
18
20
  return (
19
21
  <main>
20
22
  <JsonLd data={faqPageNode(copy.t.faqs)} />
23
+ <Section surface="card">
24
+ <EstimateCard view={view} copy={copy} renderedAt={renderedAt} model={model} />
25
+ </Section>
21
26
  <Section>
22
27
  <Faq items={copy.t.faqs} />
23
28
  </Section>
@@ -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.13.0";
6
+ ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.14.0";
7
7
  ev.inputs.v_flakes.follows = "v_flakes";
8
8
  };
9
9
 
@@ -18,9 +18,9 @@
18
18
  "lint": "eslint . && steiger ./src"
19
19
  },
20
20
  "dependencies": {
21
- "@evinvest/analytics": "^0.3.0",
21
+ "@evinvest/analytics": "^0.4.0",
22
22
  "@evinvest/i18n": "^0.9.0",
23
- "@evinvest/kitstart": "^0.13.0",
23
+ "@evinvest/kitstart": "^0.14.0",
24
24
  "@evinvest/marketing": "^0.3.0",
25
25
  "@evinvest/uikit": "^0.26.0",
26
26
  "next": "16.3.6",
@@ -43,5 +43,12 @@ export const EN = {
43
43
  contactLabel: "Contact us",
44
44
  subjects: { standard: "Regular cleaning", deep: "Deep cleaning", other: "Other", recurring: "Recurring cleaning" },
45
45
  leadCapture: { ...LEAD_CAPTURE_TEXT.en, needLabel: "Service" },
46
+ estimateForm: {
47
+ title: "Your price in four questions",
48
+ lede: "One question at a time, the price at the end.",
49
+ when: { label: "When do you need it?", today: "Urgent — today", week: "This week", compare: "Just comparing" },
50
+ afterPhone: "Your number stays with us — a call back within 15 min.",
51
+ badge: "Best value",
52
+ },
46
53
  faqs: [{ q: "Can the price change?", a: "No: the price quoted is the price you pay." }],
47
54
  } satisfies Text;
@@ -43,5 +43,12 @@ export const FR = {
43
43
  contactLabel: "Nous contacter",
44
44
  subjects: { standard: "Ménage courant", deep: "Grand ménage", other: "Autre", recurring: "Ménage régulier" },
45
45
  leadCapture: { ...LEAD_CAPTURE_TEXT.fr, needLabel: "Prestation" },
46
+ estimateForm: {
47
+ title: "Votre prix en quatre questions",
48
+ lede: "Une question à la fois, le prix à la fin.",
49
+ when: { label: "C’est pour quand ?", today: "Urgent — aujourd’hui", week: "Cette semaine", compare: "Je compare" },
50
+ afterPhone: "Votre numéro reste entre nous — rappel sous 15 min.",
51
+ badge: "Le plus avantageux",
52
+ },
46
53
  faqs: [{ q: "Le prix peut-il changer ?", a: "Non : le prix annoncé est celui que vous payez." }],
47
54
  } satisfies Text;
@@ -32,6 +32,14 @@ export interface Text extends CoreText<PageKey, Facts> {
32
32
  subjects: Record<Subject, string>;
33
33
  /** The lead form's words: the kit's, with the brand's overrides spread over them. */
34
34
  leadCapture: LeadCaptureText;
35
+ /** The prices page's estimate, one question a screen: its heading, the intro question, the line under the phone. */
36
+ estimateForm: {
37
+ title: string;
38
+ lede: string;
39
+ when: { label: string; today: string; week: string; compare: string };
40
+ afterPhone: string;
41
+ badge: string;
42
+ };
35
43
  faqs: readonly { q: string; a: string }[];
36
44
  }
37
45
 
@@ -22,7 +22,11 @@ export const PHOTO_NEEDS: readonly Subject[] = ["deep"];
22
22
  export const LEAD: LeadSchema<Subject> = {
23
23
  subjects: SUBJECTS,
24
24
  wire: { subject: "subject", locality: "locality", mobile: "mobile" },
25
- extras: [{ name: "surface_m2", max: 6 }],
25
+ // `urgency`: the prices page's intro question (`today` | `week` | `compare`), kept with the lead.
26
+ extras: [
27
+ { name: "surface_m2", max: 6 },
28
+ { name: "urgency", max: 16 },
29
+ ],
26
30
  // One shape for the mail, the webhook and a dialler: `+33612345678`.
27
31
  mobileFormat: "e164",
28
32
  validate: validateLead,
@@ -0,0 +1 @@
1
+ export { EstimateCard } from "./ui/EstimateCard";
@@ -0,0 +1,66 @@
1
+ import { contactOf, type PlaceView, type PricingModel } from "@evinvest/kitstart";
2
+ import { LeadCapture } from "@evinvest/kitstart/react";
3
+ import type { ReactNode } from "react";
4
+ import type { Copy } from "@/entities/content";
5
+ import type { Locale } from "@/shared/config/i18n";
6
+ import { LEAD, PHOTO_NEEDS, type Subject } from "@/shared/config/lead";
7
+ import { site } from "@/shared/config/site";
8
+
9
+ /** A placeholder mark per need: a brand draws its own. Hidden from assistive technology by the card. */
10
+ const icon = (d: string): ReactNode => (
11
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
12
+ <path d={d} />
13
+ </svg>
14
+ );
15
+ const ICONS: Record<Subject, ReactNode> = {
16
+ standard: icon("M4 20h16M6 20V9l6-5 6 5v11"),
17
+ deep: icon("M12 3v18M3 12h18"),
18
+ other: icon("M12 8v4m0 4h.01M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18"),
19
+ recurring: icon("M4 12a8 8 0 0 1 14-5.3L20 9M20 12a8 8 0 0 1-14 5.3L4 15"),
20
+ };
21
+
22
+ /**
23
+ * The prices page's estimate: the kit's `LeadCapture` one question a screen
24
+ * (`steps`) — how urgent first (an urgent job is a call back, the phone
25
+ * alone), the need as cards, the regular clean's answers with "I don't know"
26
+ * for the surface and the frequency as cards that carry their price, and the
27
+ * price on one line with the tax credit. A card of its own (`#estimate`), so
28
+ * the home page's form and its call bar are untouched.
29
+ */
30
+ export function EstimateCard({ view, copy, renderedAt, model }: { view: PlaceView<Locale>; copy: Copy; renderedAt: number; model: PricingModel | null }) {
31
+ const { t } = copy;
32
+ const e = t.estimateForm;
33
+ return (
34
+ <LeadCapture
35
+ id="estimate"
36
+ formId="estimate"
37
+ layout="steps"
38
+ place={view.place}
39
+ contact={contactOf(site, view.place)}
40
+ locale={view.locale}
41
+ renderedAt={renderedAt}
42
+ wire={LEAD.wire}
43
+ needs={LEAD.subjects.map(s => ({ value: s, label: t.subjects[s], icon: ICONS[s] }))}
44
+ needDisplay="cards"
45
+ intro={{
46
+ label: e.when.label,
47
+ field: "urgency",
48
+ options: [
49
+ { value: "today", label: e.when.today, channel: "callback" },
50
+ { value: "week", label: e.when.week },
51
+ { value: "compare", label: e.when.compare },
52
+ ],
53
+ }}
54
+ flows={LEAD.flows}
55
+ pricing={model}
56
+ photos={PHOTO_NEEDS}
57
+ questions={{ surface: { unknown: true }, frequency: { display: "cards", badges: { biweekly: e.badge } } }}
58
+ price="compact"
59
+ taxCredit={0.5}
60
+ afterPhone={<p className="text-sm text-ink-soft">{e.afterPhone}</p>}
61
+ channelsDisplay="row"
62
+ text={{ ...t.leadCapture, title: e.title, lede: e.lede, privacy: "" }}
63
+ className="max-w-md"
64
+ />
65
+ );
66
+ }
@@ -10,6 +10,7 @@
10
10
  # Over it now, on purpose: the form became `LeadCapture`, a client island
11
11
  # (162,985 B gz, +3.2 %) — a warning to pay back, not a new target. The form
12
12
  # variants (an estimate's tiles and live price, the priced success) add
13
- # about 3.9 KB gz more (170,375 B gz, +7.8 %).
13
+ # about 3.9 KB gz more (170,375 B gz, +7.8 %); one question a screen and the
14
+ # compact form's pieces about 9 KB more (179,430 B gz, +13.6 %).
14
15
  158000
15
16
  tolerance 20%