@evinvest/kitstart 0.10.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 +155 -17
- package/dist/core/accept.d.ts +12 -1
- package/dist/core/accept.js +15 -0
- package/dist/core/analytics.d.ts +3 -1
- package/dist/core/analytics.js +6 -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-text.d.ts +26 -2
- package/dist/core/lead-capture-text.js +32 -2
- package/dist/core/lead.d.ts +33 -0
- package/dist/core/lead.js +43 -0
- 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/routing.d.ts +3 -2
- package/dist/core/routing.js +4 -3
- package/dist/core/site.d.ts +8 -0
- package/dist/core/site.js +15 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- 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.js +14 -5
- 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 +28 -5
- package/dist/react/LeadCapture.js +22 -8
- package/dist/react/LeadCaptureFields.js +60 -21
- package/dist/react/LeadCapturePriced.d.ts +3 -4
- package/dist/react/LeadCapturePriced.js +4 -4
- 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-submit.d.ts +7 -1
- package/dist/react/use-lead-submit.js +6 -3
- package/dist/react/use-repriced.d.ts +18 -0
- package/dist/react/use-repriced.js +27 -0
- package/dist/server/index.d.ts +2 -2
- package/dist/server/lead-webhook.d.ts +46 -0
- package/dist/server/lead-webhook.js +17 -1
- 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/webhook-outbox.d.ts +11 -1
- package/dist/server/webhook-outbox.js +32 -5
- package/dist/testing/lead-rules.js +15 -1
- package/package.json +1 -1
- package/template/app/quote/booking/route.ts +11 -0
- package/template/app/quote/confirm/route.ts +12 -0
- package/template/flake.nix +1 -1
- package/template/package.json +2 -2
- package/template/src/shared/config/places.ts +4 -0
- package/template/src/shared/config/site.ts +3 -0
- package/template/tests/e2e/flows.spec.ts +86 -9
- package/template/tests/e2e/mock-panel.mjs +18 -4
- package/template/tests/e2e/quote-form.spec.ts +2 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { useEffect, useState } from "react";
|
|
3
|
-
import {
|
|
4
|
-
|
|
3
|
+
import { PRICE_CHANGED } from "../core/accept.js";
|
|
4
|
+
import { LEAD_CARD_PARAM, LEAD_ERROR_FIELD, LEAD_ERROR_PARAM } from "../core/lead.js";
|
|
5
5
|
/** The fields `LeadCapture` draws itself, which show a refusal under themselves. */
|
|
6
6
|
export const OWN_FIELDS = { form: ["phone", "locality", "name"], callback: ["phone", "consent"] };
|
|
7
7
|
/** The id of the message a refusal of a field the card does not draw is shown in, above the submit. */
|
|
@@ -31,8 +31,9 @@ function controlOf(form, field) {
|
|
|
31
31
|
* `aria-invalid` and pointed at the message above the submit. Fixing that
|
|
32
32
|
* control clears it.
|
|
33
33
|
*/
|
|
34
|
-
export function useLeadError(root, id) {
|
|
35
|
-
|
|
34
|
+
export function useLeadError(root, id, initial = null) {
|
|
35
|
+
// Drawn on the server too when the page read it from its query (`leadErrorOf`).
|
|
36
|
+
const [error, setError] = useState(initial);
|
|
36
37
|
useEffect(() => {
|
|
37
38
|
const url = new URL(window.location.href);
|
|
38
39
|
const field = url.searchParams.get(LEAD_ERROR_PARAM);
|
|
@@ -40,8 +41,9 @@ export function useLeadError(root, id) {
|
|
|
40
41
|
if (field === null || channel === null)
|
|
41
42
|
return;
|
|
42
43
|
url.searchParams.delete(LEAD_ERROR_PARAM);
|
|
44
|
+
url.searchParams.delete(LEAD_CARD_PARAM);
|
|
43
45
|
window.history.replaceState(window.history.state, "", url);
|
|
44
|
-
if (
|
|
46
|
+
if (LEAD_ERROR_FIELD.test(field))
|
|
45
47
|
setError({ channel, field });
|
|
46
48
|
}, [id]);
|
|
47
49
|
useEffect(() => {
|
|
@@ -57,7 +59,8 @@ export function useLeadError(root, id) {
|
|
|
57
59
|
foreign?.setAttribute("aria-describedby", formMessageId(id, error.channel));
|
|
58
60
|
(control ?? document.getElementById(formMessageId(id, error.channel)))?.focus();
|
|
59
61
|
const fixed = (event) => {
|
|
60
|
-
|
|
62
|
+
// A refusal of the whole form, or of its price, is about every answer: any change answers it.
|
|
63
|
+
if (error.field === "form" || error.field === PRICE_CHANGED || (event.target instanceof Element && control?.contains(event.target)))
|
|
61
64
|
setError(null);
|
|
62
65
|
};
|
|
63
66
|
form.addEventListener("input", fixed);
|
|
@@ -11,6 +11,11 @@ export interface LeadSent {
|
|
|
11
11
|
lead?: string;
|
|
12
12
|
/** The price the server took the lead at, for an `estimate` or `fixed` need. */
|
|
13
13
|
cents?: number;
|
|
14
|
+
/**
|
|
15
|
+
* The form's submission id, with `lead` only: the secret a booking request
|
|
16
|
+
* (`bookingRoute`) proves the lead is this page's with.
|
|
17
|
+
*/
|
|
18
|
+
submission?: string;
|
|
14
19
|
}
|
|
15
20
|
/** Leaving the page, behind a seam a test can stand in for (jsdom cannot navigate). */
|
|
16
21
|
export declare const navigation: {
|
|
@@ -27,7 +32,8 @@ export type SendFailure = "network" | "timeout";
|
|
|
27
32
|
export declare const SUBMIT_TIMEOUT_MS = 15000;
|
|
28
33
|
export interface LeadSubmitHandlers {
|
|
29
34
|
/** The route refused the lead: the field to fix. */
|
|
30
|
-
|
|
35
|
+
/** `cents`: with `price_changed`, the server's fresh price. */
|
|
36
|
+
onRefused: (channel: LeadChannel, field: string, cents?: number) => void;
|
|
31
37
|
/** No answer: the form stays as typed, with a retry. */
|
|
32
38
|
onFailed: (channel: LeadChannel, failure: SendFailure) => void;
|
|
33
39
|
}
|
|
@@ -68,8 +68,9 @@ async function readAnswer(res) {
|
|
|
68
68
|
...(typeof cents === "number" && Number.isSafeInteger(cents) && cents >= 0 ? { cents } : {}),
|
|
69
69
|
};
|
|
70
70
|
}
|
|
71
|
+
// `cents`: with `price_changed`, the price the lead would be taken at now.
|
|
71
72
|
if (ok === false && typeof field === "string")
|
|
72
|
-
return { ok, field };
|
|
73
|
+
return { ok, field, ...(typeof cents === "number" && Number.isSafeInteger(cents) && cents >= 0 ? { cents } : {}) };
|
|
73
74
|
return null;
|
|
74
75
|
}
|
|
75
76
|
if (res.ok && res.redirected) {
|
|
@@ -136,13 +137,15 @@ export function useLeadSubmit(done, fields, on) {
|
|
|
136
137
|
return fallBack();
|
|
137
138
|
if (!answer.ok) {
|
|
138
139
|
settle();
|
|
139
|
-
return on.onRefused(lead.channel, answer.field);
|
|
140
|
+
return on.onRefused(lead.channel, answer.field, answer.cents);
|
|
140
141
|
}
|
|
141
142
|
// Still pending: the page is leaving, or the form is gone. The next
|
|
142
143
|
// lead from this form (Back, another need) gets an id of its own.
|
|
143
144
|
minted.delete(form);
|
|
145
|
+
const submission = data.get(SUBMISSION_FIELD);
|
|
146
|
+
const ref = answer.lead && typeof submission === "string" && submission !== "" ? { lead: answer.lead, submission } : answer.lead ? { lead: answer.lead } : {};
|
|
144
147
|
if (typeof done === "function" ? done(lead.channel) : done)
|
|
145
|
-
setSent({ ...lead, ...
|
|
148
|
+
setSent({ ...lead, ...ref, ...(answer.cents !== undefined ? { cents: answer.cents } : {}) });
|
|
146
149
|
else
|
|
147
150
|
navigation.assign(answer.location);
|
|
148
151
|
}, () => {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { LeadCaptureFlowText } from "../core/lead-capture-text.js";
|
|
2
|
+
import type { Price } from "../core/pricing/model.js";
|
|
3
|
+
/**
|
|
4
|
+
* The price the server answered when the one on screen was stale
|
|
5
|
+
* (`price_changed`): shown in its place, and posted back as `shown_cents` —
|
|
6
|
+
* a resubmit is the visitor confirming it. Only while the answers are the
|
|
7
|
+
* ones it was refused for; a change and the page prices on its own again.
|
|
8
|
+
*/
|
|
9
|
+
export declare function useRepriced(price: Price | null, need: string | undefined, text: LeadCaptureFlowText, locale: string): {
|
|
10
|
+
/** The price to show: the server's fresh one, else the page's own. */
|
|
11
|
+
price: Price | null;
|
|
12
|
+
/** What the form posts as `shown_cents`. */
|
|
13
|
+
shownCents: number | undefined;
|
|
14
|
+
/** The words for a `price_changed` refusal, with both prices when the page has them. */
|
|
15
|
+
message: () => string;
|
|
16
|
+
/** A refusal: remembers the fresh price when it is `price_changed`. */
|
|
17
|
+
refused(field: string, cents: number | undefined): void;
|
|
18
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { PRICE_CHANGED } from "../core/accept.js";
|
|
4
|
+
import { fillText, formatCents } from "../core/lead-capture-format.js";
|
|
5
|
+
/**
|
|
6
|
+
* The price the server answered when the one on screen was stale
|
|
7
|
+
* (`price_changed`): shown in its place, and posted back as `shown_cents` —
|
|
8
|
+
* a resubmit is the visitor confirming it. Only while the answers are the
|
|
9
|
+
* ones it was refused for; a change and the page prices on its own again.
|
|
10
|
+
*/
|
|
11
|
+
export function useRepriced(price, need, text, locale) {
|
|
12
|
+
const [repriced, setRepriced] = useState(null);
|
|
13
|
+
const current = repriced && price && repriced.shown === price.cents && repriced.need === need ? repriced : null;
|
|
14
|
+
return {
|
|
15
|
+
/** The price to show: the server's fresh one, else the page's own. */
|
|
16
|
+
price: current ? { cents: current.cents, breakdown: [] } : price,
|
|
17
|
+
/** What the form posts as `shown_cents`. */
|
|
18
|
+
shownCents: current?.cents ?? price?.cents,
|
|
19
|
+
/** The words for a `price_changed` refusal, with both prices when the page has them. */
|
|
20
|
+
message: () => current ? fillText(text.priceChanged, { price: formatCents(current.cents, locale), shown: formatCents(current.shown, locale) }) : text.priceChangedGeneric,
|
|
21
|
+
/** A refusal: remembers the fresh price when it is `price_changed`. */
|
|
22
|
+
refused(field, cents) {
|
|
23
|
+
if (field === PRICE_CHANGED && cents !== undefined && price)
|
|
24
|
+
setRepriced({ cents, shown: price.cents, need });
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -11,9 +11,9 @@ export { describeLeadDb, openLeadStore, parseLeadDb, type LeadDb } from "./lead-
|
|
|
11
11
|
export { openSqliteLeadStore, type SqliteLeadStore } from "./lead-store-sqlite.js";
|
|
12
12
|
export { LeadStoreNotImplemented, openPostgresLeadStore } from "./lead-store-postgres.js";
|
|
13
13
|
export { parseSmtpUrl, sendMail, type Mail, type SendOptions } from "./smtp.js";
|
|
14
|
-
export { defaultLeadMail, leadNotifier, MAIL_PER_MINUTE, type LeadMail, type LeadNotifier, type NotifyEnv } from "./notify.js";
|
|
14
|
+
export { defaultLeadMail, leadNotifier, MAIL_PER_MINUTE, type LeadMail, type LeadMailShown, type LeadNotifier, type NeedLabel, type NotifyEnv } from "./notify.js";
|
|
15
15
|
export { clientKey, NO_CLIENT_ADDRESS, parseProxyTrust, type ProxyTrust } from "./client-key.js";
|
|
16
16
|
export { checkLeadStore } from "./boot.js";
|
|
17
|
-
export { leadWebhook, panelChannel, panelFlowOf, panelFlowProperties, type BuildWebhookBody, type LeadWebhook, type LeadWebhookContext, type LeadWebhookOptions, type PanelFlow, type PanelFlowProperties, } from "./lead-webhook.js";
|
|
17
|
+
export { leadWebhook, panelChannel, panelFlowOf, panelFlowProperties, type BookingQueued, type BookingWebhookContext, type BuildBookingBody, type BuildWebhookBody, type LeadWebhook, type LeadWebhookContext, type LeadWebhookOptions, type PanelFlow, type PanelFlowProperties, } from "./lead-webhook.js";
|
|
18
18
|
export { checkWebhookUrl, openWebhookOutbox, WEBHOOK_HORIZON_MS, WEBHOOK_MAX_ATTEMPTS, WEBHOOK_MAX_DELAY_MS, WEBHOOK_TICK_MS, type DeadRow, type OutboxRow, type OutboxState, type TickReport, type WebhookOutbox, type WebhookOutboxOptions, type WebhookTarget, } from "./webhook-outbox.js";
|
|
19
19
|
export { signatureHeaders, signWebhook, type WebhookSignatureHeaders, type WebhookSigning } from "./webhook-signature.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import "server-only";
|
|
2
|
+
import type { BookingRequest } from "../core/booking/model.js";
|
|
2
3
|
import { type Lead, type LeadChannel, type LeadSuspect } from "../core/lead.js";
|
|
3
4
|
import type { LeadFlow } from "../core/pricing/flow.js";
|
|
4
5
|
import type { ServerEnv } from "./env.js";
|
|
@@ -75,6 +76,32 @@ export declare function panelChannel(channel: LeadChannel): "form" | typeof PANE
|
|
|
75
76
|
* lead is queued — the stored body is what every attempt sends.
|
|
76
77
|
*/
|
|
77
78
|
export type BuildWebhookBody = (lead: Lead, ctx: LeadWebhookContext) => unknown;
|
|
79
|
+
/** What the brand's booking body builder is told besides the request. */
|
|
80
|
+
export interface BookingWebhookContext {
|
|
81
|
+
brandId: string;
|
|
82
|
+
/** When the site took the request. */
|
|
83
|
+
at: Date;
|
|
84
|
+
/** Fresh per request and stored with the body, like a lead's. */
|
|
85
|
+
idempotencyKey: string;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* `booking.requested@1`'s body, built by the brand like `BuildWebhookBody`:
|
|
89
|
+
* `bookingRequestedProperties(request)` writes the properties as the panel
|
|
90
|
+
* reads them, and `request.leadRef` is the lead id the brand's
|
|
91
|
+
* `lead.created` must have carried for the two to join.
|
|
92
|
+
*/
|
|
93
|
+
export type BuildBookingBody = (request: BookingRequest, ctx: BookingWebhookContext) => unknown;
|
|
94
|
+
/** Why `requestBooking` queued nothing. */
|
|
95
|
+
export type BookingQueued = {
|
|
96
|
+
kind: "queued";
|
|
97
|
+
row: number;
|
|
98
|
+
} | {
|
|
99
|
+
kind: "off";
|
|
100
|
+
} | {
|
|
101
|
+
kind: "duplicate";
|
|
102
|
+
} | {
|
|
103
|
+
kind: "unqueued";
|
|
104
|
+
};
|
|
78
105
|
export interface LeadWebhook {
|
|
79
106
|
/** Builds, serialises and queues the lead's body; returns the outbox row. */
|
|
80
107
|
enqueue(lead: Lead, id: number, meta: {
|
|
@@ -92,6 +119,16 @@ export interface LeadWebhook {
|
|
|
92
119
|
readonly panelSuspect: boolean;
|
|
93
120
|
/** Whether `ctx.flow` is filled in (`LeadWebhookOptions.panelFlow`). */
|
|
94
121
|
readonly panelFlow: boolean;
|
|
122
|
+
/** Whether `requestBooking` queues anything (`LeadWebhookOptions.panelBooking`). */
|
|
123
|
+
readonly panelBooking?: boolean;
|
|
124
|
+
/**
|
|
125
|
+
* Queues `booking.requested@1` behind the lead's `lead.created`, through
|
|
126
|
+
* the same outbox: not sent until that row is delivered, and a `409` / `425`
|
|
127
|
+
* for it retried. Once per lead: a second request is a `duplicate`,
|
|
128
|
+
* whatever it asks. `unqueued` when the lead's own row was never queued (a
|
|
129
|
+
* suspect held back); `off` without `panelBooking` or a `buildBookingBody`.
|
|
130
|
+
*/
|
|
131
|
+
requestBooking?(request: BookingRequest): BookingQueued;
|
|
95
132
|
readonly outbox: WebhookOutbox;
|
|
96
133
|
}
|
|
97
134
|
export interface LeadWebhookOptions extends WebhookOutboxOptions {
|
|
@@ -114,6 +151,15 @@ export interface LeadWebhookOptions extends WebhookOutboxOptions {
|
|
|
114
151
|
* the outbox would park the lead. The leads table keeps them either way.
|
|
115
152
|
*/
|
|
116
153
|
panelFlow?: boolean;
|
|
154
|
+
/**
|
|
155
|
+
* The switch for `booking.requested@1` (`requestBooking`). Off (the
|
|
156
|
+
* default) until the panel accepts the event type — it refuses an unknown
|
|
157
|
+
* one, and the outbox would park it. Off, a booking request is answered
|
|
158
|
+
* and dropped; the lead is untouched either way.
|
|
159
|
+
*/
|
|
160
|
+
panelBooking?: boolean;
|
|
161
|
+
/** The body of `booking.requested@1`; absent → booking requests are dropped whatever `panelBooking` says. */
|
|
162
|
+
buildBookingBody?: BuildBookingBody | undefined;
|
|
117
163
|
}
|
|
118
164
|
/**
|
|
119
165
|
* The lead webhook, or `null` when it is off: no `LEAD_WEBHOOK_URL`, or no
|
|
@@ -38,7 +38,7 @@ export function panelChannel(channel) {
|
|
|
38
38
|
* so a broken setting fails startup rather than a lead.
|
|
39
39
|
*/
|
|
40
40
|
export function leadWebhook(site, env, options) {
|
|
41
|
-
const { buildBody, signing, panelSuspect = false, panelFlow = false, ...outboxOptions } = options;
|
|
41
|
+
const { buildBody, buildBookingBody, signing, panelSuspect = false, panelFlow = false, panelBooking = false, ...outboxOptions } = options;
|
|
42
42
|
if (!env.leadWebhook || !buildBody)
|
|
43
43
|
return null;
|
|
44
44
|
if (env.leadsDb.kind !== "sqlite")
|
|
@@ -49,6 +49,22 @@ export function leadWebhook(site, env, options) {
|
|
|
49
49
|
outbox,
|
|
50
50
|
panelSuspect,
|
|
51
51
|
panelFlow,
|
|
52
|
+
panelBooking,
|
|
53
|
+
requestBooking(request) {
|
|
54
|
+
if (!panelBooking || !buildBookingBody)
|
|
55
|
+
return { kind: "off" };
|
|
56
|
+
// The lead's own row, as `enqueue` refs it: the booking follows it, and
|
|
57
|
+
// a lead never queued (held back as suspect) is never booked to the panel.
|
|
58
|
+
const lead = `lead:${/^lead-(\d+)-/.exec(request.leadRef)?.[1] ?? ""}`;
|
|
59
|
+
if (!outbox.hasRef(lead))
|
|
60
|
+
return { kind: "unqueued" };
|
|
61
|
+
const ctx = { brandId: site.brand.id, at: new Date(now()), idempotencyKey: randomUUID() };
|
|
62
|
+
const body = JSON.stringify(buildBookingBody(request, ctx));
|
|
63
|
+
if (typeof body !== "string")
|
|
64
|
+
throw new Error("buildBookingBody returned nothing JSON can carry");
|
|
65
|
+
const row = outbox.enqueueOnce(body, `booking:${request.leadRef}`, lead);
|
|
66
|
+
return row === null ? { kind: "duplicate" } : { kind: "queued", row };
|
|
67
|
+
},
|
|
52
68
|
enqueue(lead, id, meta) {
|
|
53
69
|
const suspect = panelSuspect ? suspectOf(lead) : undefined;
|
|
54
70
|
const flow = panelFlow ? panelFlowOf(lead) : undefined;
|
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.
|
|
@@ -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
|
/**
|
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": {
|
|
@@ -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 });
|
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",
|
|
@@ -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
|
];
|
|
@@ -31,6 +31,9 @@ export const site = defineSite({
|
|
|
31
31
|
publication: SERVICE_AREA_GATE,
|
|
32
32
|
lead: LEAD,
|
|
33
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"] },
|
|
34
37
|
});
|
|
35
38
|
|
|
36
39
|
export type PageKey = (typeof site.pageKeys)[number];
|