@evinvest/kitstart 0.9.0 → 0.11.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 +237 -12
- package/dist/core/accept.d.ts +35 -2
- package/dist/core/accept.js +74 -5
- package/dist/core/analytics.d.ts +4 -1
- package/dist/core/analytics.js +13 -0
- package/dist/core/booking/index.d.ts +3 -0
- package/dist/core/booking/index.js +3 -0
- package/dist/core/booking/model.d.ts +106 -0
- package/dist/core/booking/model.js +66 -0
- package/dist/core/booking/url.d.ts +17 -0
- package/dist/core/booking/url.js +53 -0
- package/dist/core/booking/validate.d.ts +27 -0
- package/dist/core/booking/validate.js +219 -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 +57 -2
- package/dist/core/lead-capture-text.js +75 -2
- package/dist/core/lead.d.ts +65 -3
- package/dist/core/lead.js +46 -2
- package/dist/core/phone.d.ts +3 -1
- package/dist/core/phone.js +28 -2
- package/dist/core/place/live.d.ts +6 -2
- package/dist/core/place/live.js +21 -2
- package/dist/core/place/types.d.ts +6 -0
- package/dist/core/place/types.js +0 -13
- 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/routing.d.ts +3 -2
- package/dist/core/routing.js +4 -3
- package/dist/core/site.d.ts +15 -0
- package/dist/core/site.js +26 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +6 -4
- package/dist/next/booking-route.d.ts +31 -0
- package/dist/next/booking-route.js +97 -0
- package/dist/next/confirm-route.d.ts +39 -0
- package/dist/next/confirm-route.js +118 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +2 -0
- package/dist/next/quote-route.d.ts +8 -1
- package/dist/next/quote-route.js +17 -6
- package/dist/react/LeadBooking.d.ts +35 -0
- package/dist/react/LeadBooking.js +47 -0
- package/dist/react/LeadBookingManual.d.ts +17 -0
- package/dist/react/LeadBookingManual.js +50 -0
- package/dist/react/LeadCapture.d.ts +54 -3
- package/dist/react/LeadCapture.js +37 -10
- 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/LeadCaptureFields.js +60 -21
- package/dist/react/LeadCapturePriced.d.ts +18 -0
- package/dist/react/LeadCapturePriced.js +14 -0
- package/dist/react/booking-adapters.d.ts +58 -0
- package/dist/react/booking-adapters.js +104 -0
- package/dist/react/index.d.ts +5 -1
- package/dist/react/index.js +4 -1
- package/dist/react/use-booking.d.ts +20 -0
- package/dist/react/use-booking.js +32 -0
- package/dist/react/use-lead-error.d.ts +3 -7
- package/dist/react/use-lead-error.js +9 -6
- 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 +14 -4
- package/dist/react/use-lead-submit.js +19 -9
- package/dist/react/use-repriced.d.ts +18 -0
- package/dist/react/use-repriced.js +27 -0
- package/dist/server/index.d.ts +3 -2
- package/dist/server/index.js +2 -1
- package/dist/server/lead-store-sqlite.js +31 -5
- package/dist/server/lead-webhook.d.ts +94 -0
- package/dist/server/lead-webhook.js +46 -2
- package/dist/server/notify.d.ts +20 -2
- package/dist/server/notify.js +6 -4
- package/dist/server/place-source.js +1 -1
- package/dist/server/pricing-source.d.ts +25 -0
- package/dist/server/pricing-source.js +48 -0
- package/dist/server/webhook-outbox.d.ts +11 -1
- package/dist/server/webhook-outbox.js +32 -5
- package/dist/testing/lead-rules.js +15 -1
- 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/booking/route.ts +11 -0
- package/template/app/quote/confirm/route.ts +12 -0
- package/template/app/quote/route.ts +2 -1
- package/template/flake.nix +1 -1
- package/template/package.json +2 -2
- 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/places.ts +4 -0
- package/template/src/shared/config/pricing.ts +49 -0
- package/template/src/shared/config/site.ts +6 -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 +183 -0
- package/template/tests/e2e/mock-panel.mjs +34 -0
- package/template/tests/e2e/playwright.config.ts +25 -9
- package/template/tests/e2e/quote-form.spec.ts +2 -2
package/dist/server/notify.d.ts
CHANGED
|
@@ -15,8 +15,19 @@ export interface LeadMail {
|
|
|
15
15
|
text: string;
|
|
16
16
|
}
|
|
17
17
|
export type NotifyEnv = Pick<ServerEnv, "smtpUrl" | "notifyTo" | "notifyFrom" | "smsToken">;
|
|
18
|
+
/**
|
|
19
|
+
* The need's label in the mail's language — the business's, whatever the
|
|
20
|
+
* visitor's — or `undefined` for one it does not know (a stale form's).
|
|
21
|
+
*/
|
|
22
|
+
export type NeedLabel = (need: string) => string | undefined;
|
|
23
|
+
/** What a mail shows in place of the raw lead: the need as `needLabel` names it, else its id. */
|
|
24
|
+
export interface LeadMailShown {
|
|
25
|
+
need: string;
|
|
26
|
+
}
|
|
18
27
|
/** A plain default; a brand passes `format` to write it in its own language. */
|
|
19
|
-
export declare function defaultLeadMail(brand: Pick<BrandFacts, "name">, lead: Lead, id: number
|
|
28
|
+
export declare function defaultLeadMail(brand: Pick<BrandFacts, "name">, lead: Lead, id: number, options?: {
|
|
29
|
+
needLabel?: NeedLabel | undefined;
|
|
30
|
+
}): LeadMail;
|
|
20
31
|
/**
|
|
21
32
|
* Checked when it is built — at boot, when a brand builds it there — not on
|
|
22
33
|
* the first lead: with SMTP configured, a sender needs a domain to be `leads@`
|
|
@@ -29,7 +40,14 @@ export declare const MAIL_PER_MINUTE = 20;
|
|
|
29
40
|
export declare function leadNotifier(site: {
|
|
30
41
|
brand: BrandFacts;
|
|
31
42
|
}, env: NotifyEnv, options?: {
|
|
32
|
-
|
|
43
|
+
/** The brand's mail; `shown.need` is the need's label (`needLabel`), else its id. */
|
|
44
|
+
format?: (lead: Lead, id: number, shown: LeadMailShown) => LeadMail;
|
|
45
|
+
/**
|
|
46
|
+
* Names the need in the mail — `lead.subject` is the id the form posted
|
|
47
|
+
* (`hot_water`), which the business should not have to decode. The labels
|
|
48
|
+
* the brand hands `LeadCapture` as `needs`, in the mail's language.
|
|
49
|
+
*/
|
|
50
|
+
needLabel?: NeedLabel;
|
|
33
51
|
log?: Pick<Console, "warn">;
|
|
34
52
|
/** `MAIL_PER_MINUTE` by default: a flood that passes the antispam must not become a mail flood. */
|
|
35
53
|
mailPerMinute?: number;
|
package/dist/server/notify.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import "server-only";
|
|
2
2
|
import { channelOf } from "../core/lead.js";
|
|
3
3
|
import { parseSmtpUrl, sendMail } from "./smtp.js";
|
|
4
|
+
const shownOf = (lead, needLabel) => ({ need: needLabel?.(lead.subject) ?? lead.subject });
|
|
4
5
|
/** A plain default; a brand passes `format` to write it in its own language. */
|
|
5
|
-
export function defaultLeadMail(brand, lead, id) {
|
|
6
|
+
export function defaultLeadMail(brand, lead, id, options = {}) {
|
|
6
7
|
const place = lead.placeSlug ?? "unknown";
|
|
7
8
|
// A callback request is a promise to ring the customer: it says so first.
|
|
8
9
|
const callback = channelOf(lead) === "callback";
|
|
@@ -11,7 +12,7 @@ export function defaultLeadMail(brand, lead, id) {
|
|
|
11
12
|
text: [
|
|
12
13
|
`Lead #${id} — place ${place}${callback ? " — CALL BACK" : ""}${lead.spamVerdict ? ` — suspect (${lead.spamVerdict})` : ""}`,
|
|
13
14
|
"",
|
|
14
|
-
`Subject : ${lead.
|
|
15
|
+
`Subject : ${shownOf(lead, options.needLabel).need}`,
|
|
15
16
|
`Locality : ${lead.locality}`,
|
|
16
17
|
`Mobile : ${lead.mobile}`,
|
|
17
18
|
...Object.entries(lead.extras).map(([name, value]) => `${name} : ${value}`),
|
|
@@ -32,7 +33,8 @@ export function leadNotifier(site, env, options = {}) {
|
|
|
32
33
|
const log = options.log ?? console;
|
|
33
34
|
const now = options.now ?? Date.now;
|
|
34
35
|
const cap = options.mailPerMinute ?? MAIL_PER_MINUTE;
|
|
35
|
-
const
|
|
36
|
+
const { needLabel } = options;
|
|
37
|
+
const format = options.format ?? ((lead, id) => defaultLeadMail(brand, lead, id, { needLabel }));
|
|
36
38
|
let window = { start: Number.NEGATIVE_INFINITY, sent: 0 };
|
|
37
39
|
const spend = () => {
|
|
38
40
|
const t = now();
|
|
@@ -59,7 +61,7 @@ export function leadNotifier(site, env, options = {}) {
|
|
|
59
61
|
log.warn(`lead ${id}: over ${cap} notification mails a minute; stored, not mailed`);
|
|
60
62
|
}
|
|
61
63
|
else if (mail) {
|
|
62
|
-
const { subject, text } = format(lead, id);
|
|
64
|
+
const { subject, text } = format(lead, id, shownOf(lead, needLabel));
|
|
63
65
|
channels.push(sendMail(mail.url, { from: mail.from, to: mail.to, subject, text }, { helo: mail.helo }));
|
|
64
66
|
}
|
|
65
67
|
if (env.smsToken) {
|
|
@@ -29,7 +29,7 @@ export function createPlaceSource(site, options) {
|
|
|
29
29
|
if (!response.ok)
|
|
30
30
|
return { kind: "failed", status: response.status };
|
|
31
31
|
try {
|
|
32
|
-
return { kind: "live", place: mergeLive(baked, parsePlaceLive(await response.json(), site.i18n.locales)) };
|
|
32
|
+
return { kind: "live", place: mergeLive(baked, parsePlaceLive(await response.json(), site.i18n.locales, site.booking ?? {})) };
|
|
33
33
|
}
|
|
34
34
|
catch (cause) {
|
|
35
35
|
// A body that is not the source's shape is the source failing, not the place.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import type { PricingModel } from "../core/pricing/model.js";
|
|
3
|
+
import type { Site } from "../core/site.js";
|
|
4
|
+
/**
|
|
5
|
+
* The brand's live price list: `GET <base>/pricing`, the whole model with a
|
|
6
|
+
* label in every locale, over the baked one (`site.pricing`). The same
|
|
7
|
+
* failure semantics as the place source, for the same reason — a page that
|
|
8
|
+
* throws under ISR is a bare 500 with no phone: an unreachable source, a
|
|
9
|
+
* non-200, a body that is not JSON, and a model that does not validate all
|
|
10
|
+
* serve the baked model. Taken whole or not at all: a page never prices from
|
|
11
|
+
* half a model. `{}` is the source's "nothing set": the baked model, quietly.
|
|
12
|
+
*/
|
|
13
|
+
export interface PricingSourceOptions {
|
|
14
|
+
/** The source's base URL, read when asked; `null` → the baked model only. */
|
|
15
|
+
baseUrl: () => string | null;
|
|
16
|
+
/** TTL on the fetch itself (see `PlaceSourceOptions`); 600 s. */
|
|
17
|
+
revalidateSeconds?: number;
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
log?: Pick<Console, "error">;
|
|
20
|
+
}
|
|
21
|
+
export interface PricingSource {
|
|
22
|
+
/** The model to price with now: live, else baked, else `null` (every need is a `quote`). Never throws. */
|
|
23
|
+
model(): Promise<PricingModel | null>;
|
|
24
|
+
}
|
|
25
|
+
export declare function createPricingSource<L extends string, P extends string>(site: Site<L, P>, options: PricingSourceOptions): PricingSource;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
import { parsePricingModel, pricingProblemsFor } from "../core/pricing/validate.js";
|
|
3
|
+
import { PLACE_REVALIDATE_SECONDS } from "./place-source.js";
|
|
4
|
+
const TIMEOUT_MS = 3_000;
|
|
5
|
+
const isEmptyObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v) && Object.keys(v).length === 0;
|
|
6
|
+
export function createPricingSource(site, options) {
|
|
7
|
+
const revalidate = options.revalidateSeconds ?? PLACE_REVALIDATE_SECONDS;
|
|
8
|
+
const timeoutMs = options.timeoutMs ?? TIMEOUT_MS;
|
|
9
|
+
const log = options.log ?? console;
|
|
10
|
+
const baked = site.pricing ?? null;
|
|
11
|
+
async function live(base) {
|
|
12
|
+
const init = { cache: "force-cache", next: { revalidate }, signal: AbortSignal.timeout(timeoutMs) };
|
|
13
|
+
let response;
|
|
14
|
+
try {
|
|
15
|
+
response = await fetch(`${base}/pricing`, init);
|
|
16
|
+
}
|
|
17
|
+
catch (cause) {
|
|
18
|
+
log.error("pricing: source unreachable, pricing from the baked model", cause);
|
|
19
|
+
return baked;
|
|
20
|
+
}
|
|
21
|
+
if (!response.ok) {
|
|
22
|
+
log.error(`pricing: source answered ${response.status}, pricing from the baked model`);
|
|
23
|
+
return baked;
|
|
24
|
+
}
|
|
25
|
+
let body;
|
|
26
|
+
try {
|
|
27
|
+
body = await response.json();
|
|
28
|
+
}
|
|
29
|
+
catch (cause) {
|
|
30
|
+
log.error("pricing: the source's body is not JSON, pricing from the baked model", cause);
|
|
31
|
+
return baked;
|
|
32
|
+
}
|
|
33
|
+
if (isEmptyObject(body))
|
|
34
|
+
return baked;
|
|
35
|
+
const problems = pricingProblemsFor(body, site.i18n.locales);
|
|
36
|
+
if (problems.length > 0) {
|
|
37
|
+
log.error(`pricing: the source's model is refused whole (${problems.slice(0, 5).join("; ")}), pricing from the baked model`);
|
|
38
|
+
return baked;
|
|
39
|
+
}
|
|
40
|
+
return parsePricingModel(body);
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
async model() {
|
|
44
|
+
const base = options.baseUrl();
|
|
45
|
+
return base ? live(base) : baked;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -67,6 +67,16 @@ export interface TickReport {
|
|
|
67
67
|
export interface WebhookOutbox {
|
|
68
68
|
/** Queues a body; synchronous and local, so it can sit before the response. */
|
|
69
69
|
enqueue(body: string, ref?: string | null): number;
|
|
70
|
+
/**
|
|
71
|
+
* Queues a body unless a row with the same `ref` was ever queued, in one
|
|
72
|
+
* statement — two pods racing on one ref queue it once. Answers the new
|
|
73
|
+
* row, or `null` when the ref was there. With `after`, the row is not sent
|
|
74
|
+
* until the row of that ref is delivered, and a `409` or `425` for it is
|
|
75
|
+
* retried: the receiver has not seen what it depends on yet.
|
|
76
|
+
*/
|
|
77
|
+
enqueueOnce?(body: string, ref: string, after?: string): number | null;
|
|
78
|
+
/** Whether a row with this `ref` was ever queued, whatever its state. */
|
|
79
|
+
hasRef?(ref: string): boolean;
|
|
70
80
|
/** One pass over the rows that are due; concurrent calls share the running one. */
|
|
71
81
|
tick(): Promise<TickReport>;
|
|
72
82
|
/** Ticks every `intervalMs` until `stop`; a second call is a no-op. */
|
|
@@ -100,4 +110,4 @@ export declare function checkWebhookUrl(value: string, name?: string): string;
|
|
|
100
110
|
* rows stay, are counted at open, and are not sent to a receiver that never
|
|
101
111
|
* agreed to take them.
|
|
102
112
|
*/
|
|
103
|
-
export declare function openWebhookOutbox(path: string, target: WebhookTarget, options?: WebhookOutboxOptions): WebhookOutbox
|
|
113
|
+
export declare function openWebhookOutbox(path: string, target: WebhookTarget, options?: WebhookOutboxOptions): WebhookOutbox & Required<Pick<WebhookOutbox, "enqueueOnce" | "hasRef">>;
|
|
@@ -21,7 +21,17 @@ CREATE TABLE IF NOT EXISTS webhook_outbox (
|
|
|
21
21
|
done_at INTEGER
|
|
22
22
|
);
|
|
23
23
|
CREATE INDEX IF NOT EXISTS webhook_outbox_due ON webhook_outbox (state, target, next_attempt_at);
|
|
24
|
+
CREATE INDEX IF NOT EXISTS webhook_outbox_ref ON webhook_outbox (ref);
|
|
24
25
|
`;
|
|
26
|
+
/**
|
|
27
|
+
* Added after the table shipped: a file from before gets it on open. A row's
|
|
28
|
+
* `after_ref` holds it until the row of that ref is delivered.
|
|
29
|
+
*/
|
|
30
|
+
function addAfterRef(db) {
|
|
31
|
+
const columns = db.prepare("PRAGMA table_info(webhook_outbox)").all();
|
|
32
|
+
if (!columns.some(c => column(c, "name") === "after_ref"))
|
|
33
|
+
db.exec("ALTER TABLE webhook_outbox ADD COLUMN after_ref TEXT");
|
|
34
|
+
}
|
|
25
35
|
/** How much of a receiver's answer a row keeps. */
|
|
26
36
|
const ERROR_CAP = 500;
|
|
27
37
|
const IN_CLUSTER = /(^|\.)svc(\.cluster\.local)?$/;
|
|
@@ -103,7 +113,7 @@ async function multiStatus(response, rowId, log) {
|
|
|
103
113
|
const reasons = rejected.map(r => `#${String(column(r, "index"))}: ${String(column(r, "reason") ?? "rejected")}`);
|
|
104
114
|
return { kind: "delivered", note: `rejected ${reasons.join("; ")}` };
|
|
105
115
|
}
|
|
106
|
-
async function judge(response, rowId, log) {
|
|
116
|
+
async function judge(response, rowId, log, dependent) {
|
|
107
117
|
const status = response.status;
|
|
108
118
|
if (status === 207)
|
|
109
119
|
return multiStatus(response, rowId, log);
|
|
@@ -116,6 +126,9 @@ async function judge(response, rowId, log) {
|
|
|
116
126
|
const why = said ? `${tag}: ${said}` : tag;
|
|
117
127
|
if (status === 408 || status === 429 || status >= 500)
|
|
118
128
|
return { kind: "retry", tag, why, afterMs: retryAfter(response) };
|
|
129
|
+
// A row that follows another: the receiver may not have taken that one yet.
|
|
130
|
+
if (dependent && (status === 409 || status === 425))
|
|
131
|
+
return { kind: "retry", tag, why, afterMs: retryAfter(response) };
|
|
119
132
|
// A rotated key (401, 403) or a receiver moved or not yet deployed (404):
|
|
120
133
|
// fixed by a person, after which the lead must still arrive.
|
|
121
134
|
if (status === 401 || status === 403)
|
|
@@ -150,13 +163,19 @@ export function openWebhookOutbox(path, target, options = {}) {
|
|
|
150
163
|
db.exec("PRAGMA busy_timeout = 5000");
|
|
151
164
|
db.exec("PRAGMA journal_mode = WAL");
|
|
152
165
|
db.exec(SCHEMA);
|
|
166
|
+
addAfterRef(db);
|
|
153
167
|
}
|
|
154
168
|
catch (error) {
|
|
155
169
|
db.close();
|
|
156
170
|
throw error;
|
|
157
171
|
}
|
|
158
172
|
const insert = db.prepare("INSERT INTO webhook_outbox (target, ref, body, created_at, next_attempt_at) VALUES (?, ?, ?, ?, ?) RETURNING id");
|
|
159
|
-
const
|
|
173
|
+
const insertOnce = db.prepare("INSERT INTO webhook_outbox (target, ref, body, created_at, next_attempt_at, after_ref) SELECT ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM webhook_outbox WHERE ref = ?) RETURNING id");
|
|
174
|
+
const byRef = db.prepare("SELECT 1 FROM webhook_outbox WHERE ref = ? LIMIT 1");
|
|
175
|
+
// A row waits for the row it follows to be delivered; held, it is not due.
|
|
176
|
+
const due = db.prepare(`SELECT id, ref, body, attempts, created_at, after_ref FROM webhook_outbox AS o WHERE state = 'pending' AND target = ? AND next_attempt_at <= ?
|
|
177
|
+
AND (after_ref IS NULL OR EXISTS (SELECT 1 FROM webhook_outbox AS d WHERE d.ref = o.after_ref AND d.state = 'delivered'))
|
|
178
|
+
ORDER BY next_attempt_at, id LIMIT ?`);
|
|
160
179
|
// A lease: another process ticking the same file skips a row while it is in flight.
|
|
161
180
|
const claim = db.prepare("UPDATE webhook_outbox SET next_attempt_at = ? WHERE id = ? AND state = 'pending' AND next_attempt_at <= ?");
|
|
162
181
|
const finish = db.prepare("UPDATE webhook_outbox SET state = ?, attempts = ?, last_error = ?, done_at = ? WHERE id = ?");
|
|
@@ -173,7 +192,7 @@ export function openWebhookOutbox(path, target, options = {}) {
|
|
|
173
192
|
const plain = Math.min(maxDelay, baseDelay * 2 ** Math.max(0, attempts - 1));
|
|
174
193
|
return Math.min(maxDelay, Math.max(plain + Math.floor(plain * 0.2 * random()), hinted ?? 0));
|
|
175
194
|
}
|
|
176
|
-
async function deliver(id, body) {
|
|
195
|
+
async function deliver(id, body, dependent) {
|
|
177
196
|
try {
|
|
178
197
|
const response = await send(target.url, {
|
|
179
198
|
method: "POST",
|
|
@@ -182,7 +201,7 @@ export function openWebhookOutbox(path, target, options = {}) {
|
|
|
182
201
|
redirect: "manual",
|
|
183
202
|
signal: AbortSignal.timeout(timeoutMs),
|
|
184
203
|
});
|
|
185
|
-
return await judge(response, id, log);
|
|
204
|
+
return await judge(response, id, log, dependent);
|
|
186
205
|
}
|
|
187
206
|
catch (error) {
|
|
188
207
|
// The name and code only: a fetch error's message is safe, but its cause chain is the runtime's to word.
|
|
@@ -202,7 +221,7 @@ export function openWebhookOutbox(path, target, options = {}) {
|
|
|
202
221
|
const queuedAt = int(row, "created_at");
|
|
203
222
|
if (Number(claim.run(t + timeoutMs + 5_000, id, t).changes) === 0)
|
|
204
223
|
continue;
|
|
205
|
-
const verdict = await deliver(id, body);
|
|
224
|
+
const verdict = await deliver(id, body, text(row, "after_ref") !== null);
|
|
206
225
|
if (verdict.kind === "delivered") {
|
|
207
226
|
finish.run("delivered", attempts, verdict.note?.slice(0, ERROR_CAP) ?? null, now(), id);
|
|
208
227
|
report.delivered += 1;
|
|
@@ -249,6 +268,14 @@ export function openWebhookOutbox(path, target, options = {}) {
|
|
|
249
268
|
const t = now();
|
|
250
269
|
return int(insert.get(target.url, ref, body, t, t), "id");
|
|
251
270
|
},
|
|
271
|
+
enqueueOnce(body, ref, after) {
|
|
272
|
+
const t = now();
|
|
273
|
+
const row = insertOnce.get(target.url, ref, body, t, t, after ?? null, ref);
|
|
274
|
+
return row === undefined ? null : int(row, "id");
|
|
275
|
+
},
|
|
276
|
+
hasRef(ref) {
|
|
277
|
+
return byRef.get(ref) !== undefined;
|
|
278
|
+
},
|
|
252
279
|
tick,
|
|
253
280
|
start(intervalMs = WEBHOOK_TICK_MS) {
|
|
254
281
|
if (timer)
|
|
@@ -16,7 +16,13 @@ export const PHONE_MATRIX = [
|
|
|
16
16
|
{ mobile: "01 23 45 67 89", valid: true },
|
|
17
17
|
{ mobile: "+1 415 555 0123", valid: true },
|
|
18
18
|
{ mobile: "+44 7911 123456", valid: true },
|
|
19
|
-
{ mobile: "+
|
|
19
|
+
{ mobile: "+32 470 12 34 56", valid: true },
|
|
20
|
+
{ mobile: "+41 44 668 18 00", valid: true },
|
|
21
|
+
{ mobile: "+49 30 1234567", valid: true },
|
|
22
|
+
{ mobile: "+34 612 345 678", valid: true },
|
|
23
|
+
{ mobile: "+39 06 1234 5678", valid: true },
|
|
24
|
+
{ mobile: "+44 20 7946 0958", valid: true },
|
|
25
|
+
{ mobile: "+352 621 123 456", valid: true },
|
|
20
26
|
{ mobile: "0612345678", valid: true },
|
|
21
27
|
{ mobile: "(415) 555-0123", valid: false },
|
|
22
28
|
{ mobile: "+3361234567", valid: false },
|
|
@@ -25,6 +31,14 @@ export const PHONE_MATRIX = [
|
|
|
25
31
|
{ mobile: "06 12 34 56 7", valid: false },
|
|
26
32
|
{ mobile: "12 34 56 7", valid: false },
|
|
27
33
|
{ mobile: "+1234567", valid: false },
|
|
34
|
+
// A known plan's number short of its national length (LEAD-FORMS-RETEST-2026-10-03).
|
|
35
|
+
{ mobile: "+12345678", valid: false },
|
|
36
|
+
{ mobile: "+1 415 555 012", valid: false },
|
|
37
|
+
{ mobile: "+44 7911 1234", valid: false },
|
|
38
|
+
{ mobile: "+34 612 345 67", valid: false },
|
|
39
|
+
{ mobile: "+41 44 668 18 0", valid: false },
|
|
40
|
+
{ mobile: "+32 470 123", valid: false },
|
|
41
|
+
{ mobile: "+49 30 1234", valid: false },
|
|
28
42
|
{ mobile: "06 12", valid: false },
|
|
29
43
|
];
|
|
30
44
|
/**
|
|
@@ -29,10 +29,13 @@ export function describeLeadStoreContract(name, harness) {
|
|
|
29
29
|
lead({ subject: "x".repeat(200), locality: "Clermont-Ferrand — 63000" }),
|
|
30
30
|
lead({ channel: "callback", subject: "", locality: "", consent: { text: "J’accepte d’être rappelé·e.", at: "2026-10-03T10:00:00.000Z" } }),
|
|
31
31
|
lead({ channel: "callback", consent: { text: "x".repeat(500), at: "2026-10-03T10:00:00.000Z" } }),
|
|
32
|
+
lead({ flow: "quote" }),
|
|
33
|
+
lead({ flow: "estimate", price: { cents: 8400, validFrom: "2026-10-01", inputs: { zone: "proche", frequency: "biweekly" } } }),
|
|
34
|
+
lead({ flow: "fixed", price: { cents: 0, validFrom: "2026-10-01" } }),
|
|
32
35
|
]) {
|
|
33
36
|
expect(await store.insert(variant)).toBeGreaterThan(0);
|
|
34
37
|
}
|
|
35
|
-
expect(await store.count()).toBe(
|
|
38
|
+
expect(await store.count()).toBe(9);
|
|
36
39
|
await store.close();
|
|
37
40
|
});
|
|
38
41
|
it("stands at the current schema version and reports itself healthy", async () => {
|
|
@@ -55,10 +58,14 @@ export function describeLeadStoreContract(name, harness) {
|
|
|
55
58
|
lead: lead({ submissionId: sid, channel: "callback", consent: { text: "Oui.", at: "2026-10-03T10:00:00.000Z" }, extras: { name: "Ana" } }),
|
|
56
59
|
});
|
|
57
60
|
expect(await store.findSubmission("00000000-0000-4000-8000-000000000000")).toBeNull();
|
|
61
|
+
// A priced lead reads back at the price it was taken at.
|
|
62
|
+
const priced = lead({ submissionId: "7c9e6679-7425-40de-944b-e07fc1f90ae7", flow: "estimate", price: { cents: 8400, validFrom: "2026-10-01", inputs: { zone: "proche" } } });
|
|
63
|
+
const pricedId = await store.insert(priced);
|
|
64
|
+
expect(await store.findSubmission("7c9e6679-7425-40de-944b-e07fc1f90ae7")).toEqual({ id: pricedId, lead: { ...priced, channel: "form" } });
|
|
58
65
|
// Leads without one (the no-JS post) never collide.
|
|
59
66
|
await store.insert(lead());
|
|
60
67
|
await store.insert(lead());
|
|
61
|
-
expect(await store.count()).toBe(
|
|
68
|
+
expect(await store.count()).toBe(4);
|
|
62
69
|
await store.close();
|
|
63
70
|
});
|
|
64
71
|
it("keeps what it acknowledged across a close and a reopen", async () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evinvest/kitstart",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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": {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { placeMetadata } from "@evinvest/kitstart/next";
|
|
2
2
|
import type { Metadata } from "next";
|
|
3
3
|
import { loadPlace } from "@/views/place/server";
|
|
4
|
+
import { pricing } from "@/shared/config/env";
|
|
4
5
|
import { site } from "@/shared/config/site";
|
|
5
6
|
import { PlaceHome } from "@/views/home";
|
|
6
7
|
|
|
@@ -13,6 +14,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export default async function PlaceHomePage({ params }: Props) {
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
// The price list only where the form is: the other pages of a place need none.
|
|
18
|
+
const [{ view, copy, renderedAt }, model] = await Promise.all([loadPlace(params), pricing.model()]);
|
|
19
|
+
return <PlaceHome view={view} copy={copy} renderedAt={renderedAt} model={model} />;
|
|
18
20
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { bookingRoute } from "@evinvest/kitstart/next";
|
|
2
|
+
import { serverEnv } from "@/shared/config/env";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A priced lead asking for a slot: `booking.requested@1` to the panel through
|
|
6
|
+
* the lead webhook's outbox, once its `panelBooking` is on. This template has
|
|
7
|
+
* no webhook, so the request is answered and dropped.
|
|
8
|
+
*/
|
|
9
|
+
export const dynamic = "force-dynamic";
|
|
10
|
+
|
|
11
|
+
export const POST = bookingRoute({ env: serverEnv });
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { confirmRoute } from "@evinvest/kitstart/next";
|
|
2
|
+
import { TEXT } from "@/entities/content";
|
|
3
|
+
import { pricing } from "@/shared/config/env";
|
|
4
|
+
import { site } from "@/shared/config/site";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A form posted without a script whose price changed lands here: a page that
|
|
8
|
+
* is never cached, with the fresh price to confirm.
|
|
9
|
+
*/
|
|
10
|
+
export const dynamic = "force-dynamic";
|
|
11
|
+
|
|
12
|
+
export const GET = confirmRoute(site, { pricing, text: locale => TEXT[locale].leadCapture });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { quoteRoute } from "@evinvest/kitstart/next";
|
|
2
2
|
import { TEXT } from "@/entities/content";
|
|
3
|
-
import { notifier, serverEnv } from "@/shared/config/env";
|
|
3
|
+
import { notifier, pricing, serverEnv } from "@/shared/config/env";
|
|
4
4
|
import { site } from "@/shared/config/site";
|
|
5
5
|
|
|
6
6
|
/** The no-JS path: a plain form POST answered with a 303. */
|
|
@@ -9,6 +9,7 @@ export const dynamic = "force-dynamic";
|
|
|
9
9
|
export const POST = quoteRoute(site, {
|
|
10
10
|
env: serverEnv,
|
|
11
11
|
notifier,
|
|
12
|
+
pricing,
|
|
12
13
|
unavailable: locale => {
|
|
13
14
|
const t = TEXT[locale];
|
|
14
15
|
const f = { place: site.brand.name, phone: site.brand.phone };
|
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.11.0";
|
|
7
7
|
ev.inputs.v_flakes.follows = "v_flakes";
|
|
8
8
|
};
|
|
9
9
|
|
package/template/package.json
CHANGED
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@evinvest/analytics": "^0.3.0",
|
|
22
22
|
"@evinvest/i18n": "^0.9.0",
|
|
23
|
-
"@evinvest/kitstart": "^0.
|
|
23
|
+
"@evinvest/kitstart": "^0.11.0",
|
|
24
24
|
"@evinvest/marketing": "^0.3.0",
|
|
25
|
-
"@evinvest/uikit": "^0.
|
|
25
|
+
"@evinvest/uikit": "^0.26.0",
|
|
26
26
|
"next": "16.3.6",
|
|
27
27
|
"react": "19.3.0",
|
|
28
28
|
"react-dom": "19.3.0",
|
|
@@ -41,7 +41,7 @@ export const EN = {
|
|
|
41
41
|
coverageTitle: "Where we work",
|
|
42
42
|
brandPage: { title: "Brand", description: "Cleaning at a fixed price.", open: "Open" },
|
|
43
43
|
contactLabel: "Contact us",
|
|
44
|
-
subjects: { standard: "Regular cleaning", deep: "Deep cleaning", other: "Other" },
|
|
44
|
+
subjects: { standard: "Regular cleaning", deep: "Deep cleaning", other: "Other", recurring: "Recurring cleaning" },
|
|
45
45
|
leadCapture: { ...LEAD_CAPTURE_TEXT.en, needLabel: "Service" },
|
|
46
46
|
faqs: [{ q: "Can the price change?", a: "No: the price quoted is the price you pay." }],
|
|
47
47
|
} satisfies Text;
|
|
@@ -41,7 +41,7 @@ export const FR = {
|
|
|
41
41
|
coverageTitle: "Où nous intervenons",
|
|
42
42
|
brandPage: { title: "Brand", description: "Le ménage à prix fixe.", open: "Ouvrir" },
|
|
43
43
|
contactLabel: "Nous contacter",
|
|
44
|
-
subjects: { standard: "Ménage courant", deep: "Grand ménage", other: "Autre" },
|
|
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
46
|
faqs: [{ q: "Le prix peut-il changer ?", a: "Non : le prix annoncé est celui que vous payez." }],
|
|
47
47
|
} satisfies Text;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import "server-only";
|
|
2
|
-
import { createPlaceSource, createServerEnv, leadNotifier, type LeadNotifier } from "@evinvest/kitstart/server";
|
|
2
|
+
import { createPlaceSource, createPricingSource, createServerEnv, leadNotifier, type LeadNotifier } from "@evinvest/kitstart/server";
|
|
3
3
|
import { site } from "./site";
|
|
4
4
|
|
|
5
5
|
/** Parsed once, lazily: `next build` imports this and must need no secrets. */
|
|
@@ -8,6 +8,9 @@ export const serverEnv = createServerEnv(site);
|
|
|
8
8
|
/** The live place data, when `LOCATIONS_API_URL` is set; baked otherwise. */
|
|
9
9
|
export const places = createPlaceSource(site, { baseUrl: () => serverEnv().locationsApiUrl });
|
|
10
10
|
|
|
11
|
+
/** The live price list from the same source (`<LOCATIONS_API_URL>/pricing`); `site.pricing` otherwise. */
|
|
12
|
+
export const pricing = createPricingSource(site, { baseUrl: () => serverEnv().locationsApiUrl });
|
|
13
|
+
|
|
11
14
|
let built: LeadNotifier | undefined;
|
|
12
15
|
|
|
13
16
|
/** Built at boot (`instrumentation.ts`), so SMTP with no sender fails startup. */
|
|
@@ -1,8 +1,17 @@
|
|
|
1
|
-
import { validateLead, type LeadSchema } from "@evinvest/kitstart";
|
|
1
|
+
import { validateLead, type LeadFlows, type LeadSchema } from "@evinvest/kitstart";
|
|
2
2
|
|
|
3
|
-
export const SUBJECTS = ["standard", "deep", "other"] as const;
|
|
3
|
+
export const SUBJECTS = ["standard", "deep", "other", "recurring"] as const;
|
|
4
4
|
export type Subject = (typeof SUBJECTS)[number];
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* How each need is sold. `recurring` is priced live from a few answers
|
|
8
|
+
* (`pricing.ts`) and booked; the rest ask for a quote, and `deep` — priced
|
|
9
|
+
* from photos — offers to send them on WhatsApp once the place has a number.
|
|
10
|
+
* The form and the quote route read the same map.
|
|
11
|
+
*/
|
|
12
|
+
export const FLOWS = { recurring: "estimate", standard: "quote", deep: "quote", other: "quote" } as const satisfies LeadFlows;
|
|
13
|
+
export const PHOTO_NEEDS: readonly Subject[] = ["deep"];
|
|
14
|
+
|
|
6
15
|
/**
|
|
7
16
|
* What the quote form asks, and the one rule worth enforcing: a lead with no
|
|
8
17
|
* way to reach the customer is not a lead. `validateLead` is the number rule
|
|
@@ -17,4 +26,5 @@ export const LEAD: LeadSchema<Subject> = {
|
|
|
17
26
|
// One shape for the mail, the webhook and a dialler: `+33612345678`.
|
|
18
27
|
mobileFormat: "e164",
|
|
19
28
|
validate: validateLead,
|
|
29
|
+
flows: FLOWS,
|
|
20
30
|
};
|
|
@@ -16,5 +16,9 @@ export const PLACES: readonly Place<Locale>[] = [
|
|
|
16
16
|
channels: { phone: null, whatsapp: null },
|
|
17
17
|
hours: [{ days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], opens: "08:00", closes: "19:00" }],
|
|
18
18
|
rating: null,
|
|
19
|
+
// How a priced lead's slot is set. Left out, the card promises a call
|
|
20
|
+
// (`manual`); the panel's place settings set it live (`PlaceLive.booking`),
|
|
21
|
+
// e.g. `{ default: "manual", providers: { google_calendar: { url: "https://calendar.app.google/…" } } }`,
|
|
22
|
+
// and the `booking_provider` experiment picks among them (`bookingOf`).
|
|
19
23
|
},
|
|
20
24
|
];
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { PricingModel } from "@evinvest/kitstart";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The baked price list: what `recurring` is priced from until the panel
|
|
5
|
+
* serves one (`GET <LOCATIONS_API_URL>/pricing`), and whenever it is down.
|
|
6
|
+
* OWNER_TODO: placeholder amounts — the owner sets the real ones in the panel.
|
|
7
|
+
*/
|
|
8
|
+
export const PRICING: PricingModel = {
|
|
9
|
+
format: 1,
|
|
10
|
+
currency: "EUR",
|
|
11
|
+
validFrom: "2026-10-01",
|
|
12
|
+
roundToCents: 100,
|
|
13
|
+
minimumCents: 4900,
|
|
14
|
+
inputs: [
|
|
15
|
+
{
|
|
16
|
+
id: "bedrooms",
|
|
17
|
+
kind: "add",
|
|
18
|
+
labels: { fr: "Chambres", en: "Bedrooms" },
|
|
19
|
+
options: [
|
|
20
|
+
{ id: "studio", labels: { fr: "Studio", en: "Studio" }, addCents: 0 },
|
|
21
|
+
{ id: "t2", labels: { fr: "1 chambre", en: "1 bedroom" }, addCents: 1500 },
|
|
22
|
+
{ id: "t3", labels: { fr: "2 chambres", en: "2 bedrooms" }, addCents: 3000 },
|
|
23
|
+
{ id: "t4", labels: { fr: "3 chambres et plus", en: "3 bedrooms or more" }, addCents: 4500 },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: "surface",
|
|
28
|
+
kind: "add",
|
|
29
|
+
labels: { fr: "Surface", en: "Floor area" },
|
|
30
|
+
options: [
|
|
31
|
+
{ id: "s40", labels: { fr: "Moins de 40 m²", en: "Under 40 m²" }, addCents: 0 },
|
|
32
|
+
{ id: "s70", labels: { fr: "40 à 70 m²", en: "40 to 70 m²" }, addCents: 1000 },
|
|
33
|
+
{ id: "s70p", labels: { fr: "Plus de 70 m²", en: "Over 70 m²" }, addCents: 2500 },
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: "frequency",
|
|
38
|
+
kind: "discount",
|
|
39
|
+
labels: { fr: "Fréquence", en: "How often" },
|
|
40
|
+
options: [
|
|
41
|
+
{ id: "weekly", labels: { fr: "Chaque semaine", en: "Every week" }, discountBp: 1500 },
|
|
42
|
+
{ id: "biweekly", labels: { fr: "Toutes les 2 semaines", en: "Every 2 weeks" }, discountBp: 1000 },
|
|
43
|
+
{ id: "monthly", labels: { fr: "Chaque mois", en: "Every month" }, discountBp: 500 },
|
|
44
|
+
{ id: "once", labels: { fr: "Une fois", en: "Once" }, discountBp: 0 },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
needs: { recurring: { kind: "estimate", baseCents: 4500, inputs: ["bedrooms", "surface", "frequency"] } },
|
|
49
|
+
};
|
|
@@ -2,6 +2,7 @@ import { cardFact, defineSite, SERVICE_AREA_GATE, type OwnerTodo } from "@evinve
|
|
|
2
2
|
import { i18n } from "./i18n";
|
|
3
3
|
import { LEAD } from "./lead";
|
|
4
4
|
import { PLACES } from "./places";
|
|
5
|
+
import { PRICING } from "./pricing";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* The brand as the machinery sees it — routing, the lead funnel, schema.org,
|
|
@@ -29,6 +30,10 @@ export const site = defineSite({
|
|
|
29
30
|
places: PLACES,
|
|
30
31
|
publication: SERVICE_AREA_GATE,
|
|
31
32
|
lead: LEAD,
|
|
33
|
+
pricing: PRICING,
|
|
34
|
+
// The Cal.com hosts a place's `cal_com` booking may be on. A brand on its
|
|
35
|
+
// own Cal.com adds its domain: ["cal.com", "cal.brand.fr"].
|
|
36
|
+
booking: { calComHosts: ["cal.com"] },
|
|
32
37
|
});
|
|
33
38
|
|
|
34
39
|
export type PageKey = (typeof site.pageKeys)[number];
|
|
@@ -36,4 +41,5 @@ export type PageKey = (typeof site.pageKeys)[number];
|
|
|
36
41
|
export const OWNER_TODO: readonly OwnerTodo[] = [
|
|
37
42
|
{ field: "site.brand.legalName", why: "raison sociale as registered", blocksLaunch: true },
|
|
38
43
|
{ field: "assets/card.toml phone", why: "the public number", blocksLaunch: false },
|
|
44
|
+
{ field: "shared/config/pricing.ts", why: "placeholder prices until the panel serves the real ones", blocksLaunch: false },
|
|
39
45
|
];
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { contactOf, placeGraph, type PlaceView } from "@evinvest/kitstart";
|
|
1
|
+
import { contactOf, placeGraph, type PlaceView, type PricingModel } from "@evinvest/kitstart";
|
|
2
2
|
import { CallBar, Coverage, Display, Faq, JsonLd, LeadCapture, Section } from "@evinvest/kitstart/react";
|
|
3
3
|
import type { Copy } from "@/entities/content";
|
|
4
4
|
import type { Locale } from "@/shared/config/i18n";
|
|
5
|
-
import { LEAD } from "@/shared/config/lead";
|
|
5
|
+
import { LEAD, PHOTO_NEEDS } from "@/shared/config/lead";
|
|
6
6
|
import { site } from "@/shared/config/site";
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -12,7 +12,7 @@ import { site } from "@/shared/config/site";
|
|
|
12
12
|
* `site.lead.wire` — restyle it through `classNames`, drive `layout` and
|
|
13
13
|
* `prefer` from an experiment.
|
|
14
14
|
*/
|
|
15
|
-
export function PlaceHome({ view, copy, renderedAt }: { view: PlaceView<Locale>; copy: Copy; renderedAt: number }) {
|
|
15
|
+
export function PlaceHome({ view, copy, renderedAt, model }: { view: PlaceView<Locale>; copy: Copy; renderedAt: number; model: PricingModel | null }) {
|
|
16
16
|
const { t, f } = copy;
|
|
17
17
|
const contact = contactOf(site, view.place);
|
|
18
18
|
const graph = placeGraph(site, view, "home", { placeName: f.place, title: t.pages.home.title(f), description: t.pages.home.description(f) }, new Date(renderedAt));
|
|
@@ -32,6 +32,9 @@ export function PlaceHome({ view, copy, renderedAt }: { view: PlaceView<Locale>;
|
|
|
32
32
|
renderedAt={renderedAt}
|
|
33
33
|
wire={LEAD.wire}
|
|
34
34
|
needs={LEAD.subjects.map(s => ({ value: s, label: t.subjects[s] }))}
|
|
35
|
+
flows={LEAD.flows}
|
|
36
|
+
pricing={model}
|
|
37
|
+
photos={PHOTO_NEEDS}
|
|
35
38
|
text={t.leadCapture}
|
|
36
39
|
className="max-w-md"
|
|
37
40
|
/>
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
# first measurement plus a few percent; change it only in a commit that says why.
|
|
9
9
|
#
|
|
10
10
|
# Over it now, on purpose: the form became `LeadCapture`, a client island
|
|
11
|
-
# (162,985 B gz, +3.2 %) — a warning to pay back, not a new target.
|
|
11
|
+
# (162,985 B gz, +3.2 %) — a warning to pay back, not a new target. The form
|
|
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 %).
|
|
12
14
|
158000
|
|
13
15
|
tolerance 20%
|