@evinvest/kitstart 0.18.0 → 0.20.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 +24 -11
- package/dist/core/antispam.d.ts +1 -20
- package/dist/core/antispam.js +2 -14
- package/dist/core/place/live.d.ts +2 -0
- package/dist/core/place/live.js +13 -0
- package/dist/core/place/rating.d.ts +1 -2
- package/dist/core/place/rating.js +2 -2
- package/dist/core/place/types.d.ts +2 -0
- package/dist/core/pricing/index.d.ts +2 -1
- package/dist/core/pricing/index.js +2 -1
- package/dist/core/pricing/label.d.ts +7 -0
- package/dist/core/pricing/label.js +8 -0
- package/dist/core/pricing/validate.d.ts +1 -3
- package/dist/core/pricing/validate.js +0 -4
- package/dist/core/routing.d.ts +5 -21
- package/dist/core/routing.js +6 -21
- package/dist/core/seo/sitemap.d.ts +1 -2
- package/dist/core/seo/sitemap.js +2 -2
- package/dist/generated/contract.d.ts +15 -0
- package/dist/generated/contract.js +16 -0
- package/dist/next/metadata.js +2 -2
- package/dist/next/og.js +3 -2
- package/dist/react/FormSelect.d.ts +5 -3
- package/dist/react/FormSelect.js +70 -24
- package/dist/react/FormSelectKit.d.ts +24 -0
- package/dist/react/FormSelectKit.js +28 -0
- package/dist/react/LeadCapture.js +1 -1
- package/dist/react/LeadCaptureEstimate.js +1 -1
- package/dist/react/LeadCapturePrice.js +1 -1
- package/dist/react/form-select-value.d.ts +3 -0
- package/dist/react/form-select-value.js +6 -6
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.js +1 -0
- package/dist/react/lazy-part.d.ts +33 -0
- package/dist/react/lazy-part.js +66 -0
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/lead-webhook.d.ts +24 -0
- package/dist/server/lead-webhook.js +13 -1
- package/package.json +2 -2
- package/template/flake.nix +1 -1
- package/template/package.json +5 -5
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ stays in the brand.
|
|
|
18
18
|
| `@evinvest/kitstart/proxy` | edge | `createProxy(site)`, `PROXY_MATCHER` |
|
|
19
19
|
| `@evinvest/kitstart/next` | Next server (routes, RSC) | `quoteRoute`, `bookingRoute`, `confirmRoute`, `sitemapRoute`, `robotsRoute`, `ogRoute`, `healthRoute`, `createPlaceLoader`, `loadLocale`, `placeMetadata` / `brandMetadata` / `statusMetadata`, `metadataBase` |
|
|
20
20
|
| `@evinvest/kitstart/next/config` | `next.config.ts`, `vitest.config.ts` | `withLanding`, `buildEnv` and the `assets/` readers |
|
|
21
|
-
| `@evinvest/kitstart/react` | either side | `LangSwitch`, `CallBar`, `StatusScreen`, `PlaceDirectory`, `AreaChips`, `Coverage`, `MapFacade` (client), `QuoteFormShell`, `FormSelect` (client), `LeadCapture` (client), `LeadBooking` (client) and its adapters, `Faq`, `AnalyticsBoundary` (client), plus the kit and marketing pieces a landing composes with |
|
|
21
|
+
| `@evinvest/kitstart/react` | either side | `LangSwitch`, `CallBar`, `StatusScreen`, `PlaceDirectory`, `AreaChips`, `Coverage`, `MapFacade` (client), `QuoteFormShell`, `FormSelect` (client) with `loadLazyParts` for tests, `LeadCapture` (client), `LeadBooking` (client) and its adapters, `Faq`, `AnalyticsBoundary` (client), plus the kit and marketing pieces a landing composes with |
|
|
22
22
|
| `@evinvest/kitstart/testing` | a brand's vitest | `describeLandingContract(site, { globalsCss, proxySource, text })`, `describeLeadStoreContract(name, harness)`, `storefrontPlace`, `serviceAreaPlace`, `testLead` |
|
|
23
23
|
| `@evinvest/kitstart/testing/e2e` | a brand's Playwright | `defineSectionSuite(sections)`, `settle(page, selector)`, `BREAKPOINTS` |
|
|
24
24
|
| bin `kitstart-size` | plain node | `kitstart-size [<build root>] [--route …] [--budget …]`: first-load JS of a place page against the target in `tests/bundle_budget.txt` (passes with a warning up to its tolerance, 20 % by default — see [The bundle budget](#the-bundle-budget)); fails closed |
|
|
@@ -45,7 +45,7 @@ brand's `flake.nix` is its own config plus one call — `template/flake.nix` is
|
|
|
45
45
|
the whole of one:
|
|
46
46
|
|
|
47
47
|
```nix
|
|
48
|
-
inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.
|
|
48
|
+
inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.20.0"; # the version in package-lock.json
|
|
49
49
|
inputs.ev.inputs.v_flakes.follows = "v_flakes";
|
|
50
50
|
|
|
51
51
|
landing = ev.lib.mkLanding {
|
|
@@ -143,12 +143,16 @@ boundary never imports the site config.
|
|
|
143
143
|
|
|
144
144
|
A form's select, for the fields inside `QuoteFormShell`. On the server and
|
|
145
145
|
without JavaScript it is the kit's `NativeSelect` — a real `<select name>`
|
|
146
|
-
the form posts as is.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
146
|
+
the form posts as is. Hydrated, it stays that select, live — a choice there
|
|
147
|
+
reaches `onValueChange` — until the kit's `Select` is here: a chunk of its
|
|
148
|
+
own, asked for once the page is idle, or sooner when a mouse moves over the
|
|
149
|
+
field or it takes the focus. Then it is the kit's `Select`: the list drawn in
|
|
150
|
+
the palette, never the platform's menu, and the value in an input of the
|
|
151
|
+
same `name`. A press before that opens the platform's list at once, never a
|
|
152
|
+
wait on the chunk; a select holding the focus (its picker may be open) is
|
|
153
|
+
swapped only once the focus leaves. Both states are one box — height, width,
|
|
154
|
+
border, radius and inset — so the swap moves nothing (checked by the
|
|
155
|
+
template's e2e, which CI runs: `npm run check:template -- --e2e`).
|
|
152
156
|
|
|
153
157
|
```tsx
|
|
154
158
|
<Field className="flex flex-col gap-2">
|
|
@@ -190,9 +194,11 @@ which CI runs: `npm run check:template -- --e2e`).
|
|
|
190
194
|
letters jump, Enter chooses, Escape and Tab close with focus back on the
|
|
191
195
|
trigger; the trigger is a `combobox` named by the `FieldLabel`, and so is
|
|
192
196
|
its list, with the kit's focus ring.
|
|
193
|
-
- **Weight.**
|
|
194
|
-
|
|
195
|
-
|
|
197
|
+
- **Weight.** The kit's `Select` — the list's placing, dismissal and keys —
|
|
198
|
+
is out of the first load, in `FormSelectKit`'s chunk; the native select and
|
|
199
|
+
the value's plumbing stay. With `@evinvest/uikit` before 0.28 a page whose
|
|
200
|
+
form uses the kit's `Field` still loads `Select` up front: that `Field`
|
|
201
|
+
imported `SelectTrigger` to recognise the control it labels.
|
|
196
202
|
- **Controlled, as an `<input>`.** Under `value` the prop is the value: a
|
|
197
203
|
pick only calls `onValueChange`, and the parent's state (or a reset of its
|
|
198
204
|
own, `setSubject("")` back to the placeholder) is what moves it. A form
|
|
@@ -931,6 +937,13 @@ export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavai
|
|
|
931
937
|
suspect, for the body's `suspect` property. Off by default, and to stay off
|
|
932
938
|
until the panel's `lead.created` accepts the property: it refuses an
|
|
933
939
|
unknown one, and the outbox would park the lead. Off, nothing changes.
|
|
940
|
+
- **The visitor's language: `panelLocale`, off.** With
|
|
941
|
+
`leadWebhook(…, { panelLocale: true })` `ctx.panelLocale` is `fr` or `en`
|
|
942
|
+
(the site locale's language, `panelLocaleOf`; a locale the panel does not
|
|
943
|
+
take leaves it absent), and `panelLocaleProperties(ctx.panelLocale)` writes
|
|
944
|
+
it as `lead.created`'s `properties.locale`. Off by default, and to stay off
|
|
945
|
+
until the panel accepts the property — it refuses an unknown one, and the
|
|
946
|
+
outbox would park the lead. Off, the context and the body are as before.
|
|
934
947
|
- **The sale: `panelFlow`, off.** With `leadWebhook(…, { panelFlow: true })`
|
|
935
948
|
`ctx.flow` says how the need was sold (`panelFlowOf(lead)`), and
|
|
936
949
|
`panelFlowProperties(ctx.flow)` writes it as `lead.created`'s properties:
|
package/dist/core/antispam.d.ts
CHANGED
|
@@ -1,28 +1,9 @@
|
|
|
1
1
|
import type { SpamVerdict } from "./lead.js";
|
|
2
|
-
|
|
3
|
-
* Three cheap barriers, none of which needs the visitor to run a script — the
|
|
4
|
-
* form has to work before any JavaScript arrives, so a challenge widget is not
|
|
5
|
-
* an option. A rejected submission is answered exactly like an accepted one,
|
|
6
|
-
* so a bot learns nothing from the response.
|
|
7
|
-
*/
|
|
8
|
-
/**
|
|
9
|
-
* The field a human never sees and a form-filling bot fills. A neutral name:
|
|
10
|
-
* a browser's autofill knows `website`, `url` or `company` and would fill the
|
|
11
|
-
* trap for a real person.
|
|
12
|
-
*/
|
|
13
|
-
export declare const HONEYPOT_FIELD = "hp_ref";
|
|
14
|
-
/** The trap's name on pages cached before it was renamed; still read. */
|
|
15
|
-
export declare const LEGACY_HONEYPOT_FIELDS: readonly string[];
|
|
16
|
-
/** When the form was rendered, in ms since the epoch. */
|
|
17
|
-
export declare const RENDERED_AT_FIELD = "t";
|
|
18
|
-
/** Faster than this from render to submit is a script, not a person. */
|
|
19
|
-
export declare const MIN_FILL_MS = 3000;
|
|
2
|
+
export { HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, MIN_FILL_MS, RATE_LIMIT_OVERFLOW_KEY, RENDERED_AT_FIELD } from "../generated/contract.js";
|
|
20
3
|
export type Screening = "ok" | SpamVerdict;
|
|
21
4
|
export declare function checkTiming(renderedAt: string | null, now: number): Screening;
|
|
22
5
|
/** How many distinct keys a limiter tracks before new ones share one bucket. */
|
|
23
6
|
export declare const RATE_LIMIT_MAX_KEYS = 10000;
|
|
24
|
-
/** The shared bucket keys past the cap count against. */
|
|
25
|
-
export declare const RATE_LIMIT_OVERFLOW_KEY = "\0overflow";
|
|
26
7
|
/**
|
|
27
8
|
* A fixed-window counter per client address, in memory. Per process by design:
|
|
28
9
|
* it is a speed bump for one bot hammering one pod, not an accounting system,
|
package/dist/core/antispam.js
CHANGED
|
@@ -1,21 +1,11 @@
|
|
|
1
|
+
import { MIN_FILL_MS, RATE_LIMIT_OVERFLOW_KEY } from "../generated/contract.js";
|
|
2
|
+
export { HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, MIN_FILL_MS, RATE_LIMIT_OVERFLOW_KEY, RENDERED_AT_FIELD } from "../generated/contract.js";
|
|
1
3
|
/**
|
|
2
4
|
* Three cheap barriers, none of which needs the visitor to run a script — the
|
|
3
5
|
* form has to work before any JavaScript arrives, so a challenge widget is not
|
|
4
6
|
* an option. A rejected submission is answered exactly like an accepted one,
|
|
5
7
|
* so a bot learns nothing from the response.
|
|
6
8
|
*/
|
|
7
|
-
/**
|
|
8
|
-
* The field a human never sees and a form-filling bot fills. A neutral name:
|
|
9
|
-
* a browser's autofill knows `website`, `url` or `company` and would fill the
|
|
10
|
-
* trap for a real person.
|
|
11
|
-
*/
|
|
12
|
-
export const HONEYPOT_FIELD = "hp_ref";
|
|
13
|
-
/** The trap's name on pages cached before it was renamed; still read. */
|
|
14
|
-
export const LEGACY_HONEYPOT_FIELDS = ["website"];
|
|
15
|
-
/** When the form was rendered, in ms since the epoch. */
|
|
16
|
-
export const RENDERED_AT_FIELD = "t";
|
|
17
|
-
/** Faster than this from render to submit is a script, not a person. */
|
|
18
|
-
export const MIN_FILL_MS = 3_000;
|
|
19
9
|
/** A render time this far ahead of the clock was forged. */
|
|
20
10
|
const MAX_SKEW_MS = 60_000;
|
|
21
11
|
export function checkTiming(renderedAt, now) {
|
|
@@ -26,8 +16,6 @@ export function checkTiming(renderedAt, now) {
|
|
|
26
16
|
}
|
|
27
17
|
/** How many distinct keys a limiter tracks before new ones share one bucket. */
|
|
28
18
|
export const RATE_LIMIT_MAX_KEYS = 10_000;
|
|
29
|
-
/** The shared bucket keys past the cap count against. */
|
|
30
|
-
export const RATE_LIMIT_OVERFLOW_KEY = "\u0000overflow";
|
|
31
19
|
const PRUNE_EVERY_MS = 1_000;
|
|
32
20
|
/**
|
|
33
21
|
* A fixed-window counter per client address, in memory. Per process by design:
|
|
@@ -23,6 +23,8 @@ export interface PlaceLive<L extends string> {
|
|
|
23
23
|
telegram?: string;
|
|
24
24
|
/** The panel's kill switches for the messengers; a key left out is on. */
|
|
25
25
|
messengers?: MessengerSwitches;
|
|
26
|
+
/** Where the place asks for a Google review (`REVIEW_HOSTS`, `https:` only); one that does not validate is dropped. */
|
|
27
|
+
reviewUrl?: string;
|
|
26
28
|
}
|
|
27
29
|
/**
|
|
28
30
|
* Drops what does not validate instead of failing the page on one bad field.
|
package/dist/core/place/live.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { BookingConfigError, parseBookingConfig } from "../booking/validate.js";
|
|
2
2
|
import { TELEGRAM_BOT } from "../messenger.js";
|
|
3
3
|
import { parseInstant } from "./rating.js";
|
|
4
|
+
/** The hosts Google's own review links live on: `g.page/r/…` and `search.google.com/local/writereview`. */
|
|
5
|
+
const REVIEW_HOSTS = ["g.page", "search.google.com"];
|
|
4
6
|
const DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
|
5
7
|
const TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
6
8
|
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
@@ -47,6 +49,13 @@ function httpsUrl(v) {
|
|
|
47
49
|
return undefined;
|
|
48
50
|
}
|
|
49
51
|
}
|
|
52
|
+
function reviewUrl(v) {
|
|
53
|
+
const s = httpsUrl(v);
|
|
54
|
+
if (!s)
|
|
55
|
+
return undefined;
|
|
56
|
+
// `httpsUrl` parsed it once already; a host check on the string would be fooled by userinfo (`https://g.page@evil.example`).
|
|
57
|
+
return REVIEW_HOSTS.includes(new URL(s).hostname) ? s : undefined;
|
|
58
|
+
}
|
|
50
59
|
function landmark(v, locales) {
|
|
51
60
|
if (!isObject(v))
|
|
52
61
|
return undefined;
|
|
@@ -139,6 +148,9 @@ export function parsePlaceLive(body, locales, rules = {}) {
|
|
|
139
148
|
const m = messengers(body.messengers);
|
|
140
149
|
if (m)
|
|
141
150
|
live.messengers = m;
|
|
151
|
+
const review = reviewUrl(body.reviewUrl);
|
|
152
|
+
if (review)
|
|
153
|
+
live.reviewUrl = review;
|
|
142
154
|
return live;
|
|
143
155
|
}
|
|
144
156
|
/**
|
|
@@ -168,5 +180,6 @@ export function mergeLive(baked, live) {
|
|
|
168
180
|
rating: live.rating ?? baked.rating,
|
|
169
181
|
...(booking ? { booking } : {}),
|
|
170
182
|
...(switches ? { messengers: switches } : {}),
|
|
183
|
+
...(live.reviewUrl ? { reviewUrl: live.reviewUrl } : {}),
|
|
171
184
|
};
|
|
172
185
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { Place, Rating } from "./types.js";
|
|
2
|
-
|
|
3
|
-
export declare const RATING_MAX_AGE_DAYS = 30;
|
|
2
|
+
export { RATING_MAX_AGE_DAYS } from "../../generated/contract.js";
|
|
4
3
|
/**
|
|
5
4
|
* An instant from the live source: RFC 3339 with an offset or `Z`, or a bare
|
|
6
5
|
* date (midnight UTC). A date-time without an offset is refused — `Date.parse`
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
export
|
|
1
|
+
import { RATING_MAX_AGE_DAYS } from "../../generated/contract.js";
|
|
2
|
+
export { RATING_MAX_AGE_DAYS } from "../../generated/contract.js";
|
|
3
3
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
4
4
|
const DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
5
5
|
/**
|
|
@@ -96,6 +96,8 @@ export interface Place<L extends string> {
|
|
|
96
96
|
* settings); absent → `DEFAULT_BOOKING`, a call (`bookingOf`).
|
|
97
97
|
*/
|
|
98
98
|
booking?: BookingConfig;
|
|
99
|
+
/** Where the place asks for a Google review (`PlaceLive.reviewUrl`); live only, absent until the panel sends it. */
|
|
100
|
+
reviewUrl?: string;
|
|
99
101
|
}
|
|
100
102
|
/** Which messengers the panel lets a place offer; a key left out is on. */
|
|
101
103
|
export interface MessengerSwitches {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { PRICING_CURRENCY, PRICING_FORMAT, PRICING_LIMITS, type AddOption, type DiscountOption, type MultiplyOption, type NeedPricing, type Price, type PriceLine, type PricingInput, type PricingInputKind, type PricingInputs, type PricingLabels, type PricingModel, } from "./model.js";
|
|
2
2
|
export { CENTS_BUCKETS, centsBucket, mulBp, priceOf, roundTo } from "./price.js";
|
|
3
|
-
export { labelOf
|
|
3
|
+
export { labelOf } from "./label.js";
|
|
4
|
+
export { parsePricingModel, PricingModelError, pricingProblems, pricingProblemsFor } from "./validate.js";
|
|
4
5
|
export { answeredUnknown, ESTIMATE_FIELD_PREFIX, ESTIMATE_UNKNOWN, estimateField, flowOf, LEAD_FLOWS, readEstimateInputs, type LeadFlow, type LeadFlows } from "./flow.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { PRICING_CURRENCY, PRICING_FORMAT, PRICING_LIMITS, } from "./model.js";
|
|
2
2
|
export { CENTS_BUCKETS, centsBucket, mulBp, priceOf, roundTo } from "./price.js";
|
|
3
|
-
export { labelOf
|
|
3
|
+
export { labelOf } from "./label.js";
|
|
4
|
+
export { parsePricingModel, PricingModelError, pricingProblems, pricingProblemsFor } from "./validate.js";
|
|
4
5
|
export { answeredUnknown, ESTIMATE_FIELD_PREFIX, ESTIMATE_UNKNOWN, estimateField, flowOf, LEAD_FLOWS, readEstimateInputs } from "./flow.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { PricingLabels } from "./model.js";
|
|
2
|
+
/**
|
|
3
|
+
* The label for `locale`, or the first one the model has. Apart from the
|
|
4
|
+
* validator: the form names its answers with it in the browser, where the
|
|
5
|
+
* validator's weight has no use.
|
|
6
|
+
*/
|
|
7
|
+
export declare function labelOf(labels: PricingLabels, locale: string): string;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The label for `locale`, or the first one the model has. Apart from the
|
|
3
|
+
* validator: the form names its answers with it in the browser, where the
|
|
4
|
+
* validator's weight has no use.
|
|
5
|
+
*/
|
|
6
|
+
export function labelOf(labels, locale) {
|
|
7
|
+
return (Object.hasOwn(labels, locale) ? labels[locale] : undefined) ?? Object.values(labels)[0] ?? "";
|
|
8
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type PricingModel } from "./model.js";
|
|
2
2
|
/**
|
|
3
3
|
* A model that does not validate, with every reason found. A model is taken
|
|
4
4
|
* whole or not at all: one bad field refuses it, so a page never prices from
|
|
@@ -23,5 +23,3 @@ export declare function parsePricingModel(value: unknown): PricingModel;
|
|
|
23
23
|
* must not show an option with no words.
|
|
24
24
|
*/
|
|
25
25
|
export declare function pricingProblemsFor(value: unknown, locales: readonly string[]): string[];
|
|
26
|
-
/** The label for `locale`, or the first one the model has. */
|
|
27
|
-
export declare function labelOf(labels: PricingLabels, locale: string): string;
|
|
@@ -238,7 +238,3 @@ export function pricingProblemsFor(value, locales) {
|
|
|
238
238
|
}
|
|
239
239
|
return out;
|
|
240
240
|
}
|
|
241
|
-
/** The label for `locale`, or the first one the model has. */
|
|
242
|
-
export function labelOf(labels, locale) {
|
|
243
|
-
return (Object.hasOwn(labels, locale) ? labels[locale] : undefined) ?? Object.values(labels)[0] ?? "";
|
|
244
|
-
}
|
package/dist/core/routing.d.ts
CHANGED
|
@@ -31,14 +31,9 @@ import type { Site } from "./site.js";
|
|
|
31
31
|
* must not be cached as permanent. Only page paths enter it — `/quote`,
|
|
32
32
|
* `/sitemap.xml`, `/og` and the brand's files pass straight through.
|
|
33
33
|
*/
|
|
34
|
-
|
|
35
|
-
export declare const THANKS = "/thanks";
|
|
36
|
-
export declare const LANG_COOKIE = "lang";
|
|
37
|
-
export declare const LANG_COOKIE_MAX_AGE = 31536000;
|
|
38
|
-
/** The prefix a `[location]` route param carries in host link mode. */
|
|
39
|
-
export declare const HOST_MARK = "_";
|
|
34
|
+
export { GONE, GONE_HEADER, HOST_MARK, LANG_COOKIE, LANG_COOKIE_MAX_AGE, NON_PAGE_ROUTES, THANKS } from "../generated/contract.js";
|
|
40
35
|
/**
|
|
41
|
-
*
|
|
36
|
+
* The path no route matches, in a language.
|
|
42
37
|
*
|
|
43
38
|
* Next 16 cannot put a `notFound()` boundary into the HTML: that response is
|
|
44
39
|
* an empty `<html id="__next_error__">` the client fills in after hydration.
|
|
@@ -48,29 +43,18 @@ export declare const HOST_MARK = "_";
|
|
|
48
43
|
* says which language and place the 404 speaks for in {@link GONE_HEADER}.
|
|
49
44
|
* Reserved: no place may take `404` as its slug.
|
|
50
45
|
*/
|
|
51
|
-
export declare const GONE = "404";
|
|
52
|
-
/** The path no route matches, in a language. */
|
|
53
46
|
export declare function gonePath(locale: string): string;
|
|
54
47
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
48
|
+
* `fr` or `fr/_royat`: the 404's language and, if it has one, its place. Read
|
|
49
|
+
* only by the global not-found page, a route of its own — a page that read it
|
|
50
|
+
* would render per request, and none does.
|
|
58
51
|
*/
|
|
59
|
-
export declare const GONE_HEADER = "x-landing-not-found";
|
|
60
|
-
/** `fr` or `fr/_royat`: the 404's language and, if it has one, its place. */
|
|
61
52
|
export declare function goneHeader(locale: string, location: string | null): string;
|
|
62
53
|
/** Inverse of {@link goneHeader}; anything absent or malformed reads as nothing. */
|
|
63
54
|
export declare function parseGoneHeader(value: string | null): {
|
|
64
55
|
locale?: string;
|
|
65
56
|
location?: string;
|
|
66
57
|
};
|
|
67
|
-
/**
|
|
68
|
-
* The routes every landing has outside `[locale]`, which pass untouched: the
|
|
69
|
-
* form target, its booking request (`bookingRoute`) and its price
|
|
70
|
-
* confirmation (`confirmRoute`), the OG card, the probe, the sitemap and robots. Anything else without a language but
|
|
71
|
-
* `/_next/…` and the site's `publicFiles` is `gone`.
|
|
72
|
-
*/
|
|
73
|
-
export declare const NON_PAGE_ROUTES: readonly string[];
|
|
74
58
|
/** @deprecated The same list as {@link NON_PAGE_ROUTES}, under its old name. */
|
|
75
59
|
export declare const PASS_PATHS: readonly string[];
|
|
76
60
|
/** The `[location]` param for a slug in a mode: `_royat` on its host, `royat` through the apex. */
|
package/dist/core/routing.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { GONE, HOST_MARK, NON_PAGE_ROUTES, THANKS } from "../generated/contract.js";
|
|
1
2
|
/**
|
|
2
3
|
* Which place and which language a request gets, decided before any route
|
|
3
4
|
* renders. Pure, so it is tested without a server — and mirrored by the Rust
|
|
@@ -29,14 +30,9 @@
|
|
|
29
30
|
* must not be cached as permanent. Only page paths enter it — `/quote`,
|
|
30
31
|
* `/sitemap.xml`, `/og` and the brand's files pass straight through.
|
|
31
32
|
*/
|
|
32
|
-
|
|
33
|
-
export const THANKS = "/thanks";
|
|
34
|
-
export const LANG_COOKIE = "lang";
|
|
35
|
-
export const LANG_COOKIE_MAX_AGE = 31_536_000;
|
|
36
|
-
/** The prefix a `[location]` route param carries in host link mode. */
|
|
37
|
-
export const HOST_MARK = "_";
|
|
33
|
+
export { GONE, GONE_HEADER, HOST_MARK, LANG_COOKIE, LANG_COOKIE_MAX_AGE, NON_PAGE_ROUTES, THANKS } from "../generated/contract.js";
|
|
38
34
|
/**
|
|
39
|
-
*
|
|
35
|
+
* The path no route matches, in a language.
|
|
40
36
|
*
|
|
41
37
|
* Next 16 cannot put a `notFound()` boundary into the HTML: that response is
|
|
42
38
|
* an empty `<html id="__next_error__">` the client fills in after hydration.
|
|
@@ -46,18 +42,14 @@ export const HOST_MARK = "_";
|
|
|
46
42
|
* says which language and place the 404 speaks for in {@link GONE_HEADER}.
|
|
47
43
|
* Reserved: no place may take `404` as its slug.
|
|
48
44
|
*/
|
|
49
|
-
export const GONE = "404";
|
|
50
|
-
/** The path no route matches, in a language. */
|
|
51
45
|
export function gonePath(locale) {
|
|
52
46
|
return `/${locale}/${GONE}/${GONE}`;
|
|
53
47
|
}
|
|
54
48
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
49
|
+
* `fr` or `fr/_royat`: the 404's language and, if it has one, its place. Read
|
|
50
|
+
* only by the global not-found page, a route of its own — a page that read it
|
|
51
|
+
* would render per request, and none does.
|
|
58
52
|
*/
|
|
59
|
-
export const GONE_HEADER = "x-landing-not-found";
|
|
60
|
-
/** `fr` or `fr/_royat`: the 404's language and, if it has one, its place. */
|
|
61
53
|
export function goneHeader(locale, location) {
|
|
62
54
|
return location ? `${locale}/${location}` : locale;
|
|
63
55
|
}
|
|
@@ -68,13 +60,6 @@ export function parseGoneHeader(value) {
|
|
|
68
60
|
return {};
|
|
69
61
|
return { ...(locale ? { locale } : {}), ...(location ? { location } : {}) };
|
|
70
62
|
}
|
|
71
|
-
/**
|
|
72
|
-
* The routes every landing has outside `[locale]`, which pass untouched: the
|
|
73
|
-
* form target, its booking request (`bookingRoute`) and its price
|
|
74
|
-
* confirmation (`confirmRoute`), the OG card, the probe, the sitemap and robots. Anything else without a language but
|
|
75
|
-
* `/_next/…` and the site's `publicFiles` is `gone`.
|
|
76
|
-
*/
|
|
77
|
-
export const NON_PAGE_ROUTES = ["/quote", "/quote/booking", "/quote/confirm", "/og", "/health", "/sitemap.xml", "/robots.txt"];
|
|
78
63
|
/** @deprecated The same list as {@link NON_PAGE_ROUTES}, under its old name. */
|
|
79
64
|
export const PASS_PATHS = NON_PAGE_ROUTES;
|
|
80
65
|
function isInfrastructure(pathname, publicFiles) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Place } from "../place/types.js";
|
|
2
2
|
import type { Site } from "../site.js";
|
|
3
|
+
export { AI_CRAWLERS } from "../../generated/contract.js";
|
|
3
4
|
/**
|
|
4
5
|
* One sitemap per host: a sitemap may only list URLs on its own host, and a
|
|
5
6
|
* place's canonical host is its subdomain (the apex on a `single` site). The
|
|
@@ -36,8 +37,6 @@ export interface RobotsFile {
|
|
|
36
37
|
* places a crawler drops).
|
|
37
38
|
*/
|
|
38
39
|
export declare function sitemapFor<L extends string, P extends string>(site: Site<L, P>, host: string, places: readonly Place<L>[]): SitemapEntry[];
|
|
39
|
-
/** Crawlers named explicitly: several treat a bare wildcard as ambiguous. */
|
|
40
|
-
export declare const AI_CRAWLERS: string[];
|
|
41
40
|
/**
|
|
42
41
|
* Allow everything, and point at the host's own sitemap. Unpublished places
|
|
43
42
|
* are held back by `noindex`, not here, so a crawler can still read the
|
package/dist/core/seo/sitemap.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { AI_CRAWLERS } from "../../generated/contract.js";
|
|
1
2
|
import { isPublished } from "../place/publication.js";
|
|
2
3
|
import { placeOrigin, siteOrigin } from "../place/view.js";
|
|
3
4
|
import { createRouting } from "../routing.js";
|
|
5
|
+
export { AI_CRAWLERS } from "../../generated/contract.js";
|
|
4
6
|
function cluster(site, origin, suffix, priority) {
|
|
5
7
|
const languages = site.i18n.languageAlternates(suffix || "/", origin);
|
|
6
8
|
return site.i18n.locales.map(locale => ({
|
|
@@ -28,8 +30,6 @@ export function sitemapFor(site, host, places) {
|
|
|
28
30
|
return [];
|
|
29
31
|
return site.pageKeys.flatMap(page => cluster(site, origin, site.pages[page], page === "home" ? 1 : 0.8));
|
|
30
32
|
}
|
|
31
|
-
/** Crawlers named explicitly: several treat a bare wildcard as ambiguous. */
|
|
32
|
-
export const AI_CRAWLERS = ["GPTBot", "ClaudeBot", "PerplexityBot", "Google-Extended"];
|
|
33
33
|
/**
|
|
34
34
|
* Allow everything, and point at the host's own sitemap. Unpublished places
|
|
35
35
|
* are held back by `noindex`, not here, so a crawler can still read the
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const HONEYPOT_FIELD = "hp_ref";
|
|
2
|
+
export declare const LEGACY_HONEYPOT_FIELDS: readonly ["website"];
|
|
3
|
+
export declare const RENDERED_AT_FIELD = "t";
|
|
4
|
+
export declare const MIN_FILL_MS = 3000;
|
|
5
|
+
export declare const RATE_LIMIT_OVERFLOW_KEY = "\0overflow";
|
|
6
|
+
export declare const THANKS = "/thanks";
|
|
7
|
+
export declare const LANG_COOKIE = "lang";
|
|
8
|
+
export declare const LANG_COOKIE_MAX_AGE = 31536000;
|
|
9
|
+
export declare const HOST_MARK = "_";
|
|
10
|
+
export declare const GONE = "404";
|
|
11
|
+
export declare const GONE_HEADER = "x-landing-not-found";
|
|
12
|
+
export declare const NON_PAGE_ROUTES: readonly ["/quote", "/quote/booking", "/quote/confirm", "/og", "/health", "/sitemap.xml", "/robots.txt"];
|
|
13
|
+
export declare const OG_IMAGE_SIZE: readonly [1200, 630];
|
|
14
|
+
export declare const RATING_MAX_AGE_DAYS = 30;
|
|
15
|
+
export declare const AI_CRAWLERS: readonly ["GPTBot", "ClaudeBot", "PerplexityBot", "Google-Extended"];
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// AUTO-GENERATED by `nix run .#gen` — do not edit.
|
|
2
|
+
export const HONEYPOT_FIELD = "hp_ref";
|
|
3
|
+
export const LEGACY_HONEYPOT_FIELDS = ["website"];
|
|
4
|
+
export const RENDERED_AT_FIELD = "t";
|
|
5
|
+
export const MIN_FILL_MS = 3000;
|
|
6
|
+
export const RATE_LIMIT_OVERFLOW_KEY = "\0overflow";
|
|
7
|
+
export const THANKS = "/thanks";
|
|
8
|
+
export const LANG_COOKIE = "lang";
|
|
9
|
+
export const LANG_COOKIE_MAX_AGE = 31536000;
|
|
10
|
+
export const HOST_MARK = "_";
|
|
11
|
+
export const GONE = "404";
|
|
12
|
+
export const GONE_HEADER = "x-landing-not-found";
|
|
13
|
+
export const NON_PAGE_ROUTES = ["/quote", "/quote/booking", "/quote/confirm", "/og", "/health", "/sitemap.xml", "/robots.txt"];
|
|
14
|
+
export const OG_IMAGE_SIZE = [1200, 630];
|
|
15
|
+
export const RATING_MAX_AGE_DAYS = 30;
|
|
16
|
+
export const AI_CRAWLERS = ["GPTBot", "ClaudeBot", "PerplexityBot", "Google-Extended"];
|
package/dist/next/metadata.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { isPublished } from "../core/place/publication.js";
|
|
2
|
+
import { OG_IMAGE_SIZE } from "../generated/contract.js";
|
|
2
3
|
import { placeOrigin, siteOrigin } from "../core/place/view.js";
|
|
3
4
|
import { ogImageUrl } from "../core/seo/sitemap.js";
|
|
4
5
|
import { ogLocaleOf } from "../core/site.js";
|
|
5
|
-
const OG_SIZE = { width: 1200, height: 630 };
|
|
6
6
|
const PRE_LAUNCH = { index: false, follow: false };
|
|
7
7
|
function others(site, locale) {
|
|
8
8
|
return site.i18n.locales.filter(l => l !== locale).map(l => ogLocaleOf(site, l));
|
|
@@ -22,7 +22,7 @@ function head(site, page) {
|
|
|
22
22
|
...(page.canonical !== null ? { url: page.canonical } : {}),
|
|
23
23
|
locale: ogLocaleOf(site, page.locale),
|
|
24
24
|
alternateLocale: others(site, page.locale),
|
|
25
|
-
images: [{ url: page.image,
|
|
25
|
+
images: [{ url: page.image, width: OG_IMAGE_SIZE[0], height: OG_IMAGE_SIZE[1] }],
|
|
26
26
|
},
|
|
27
27
|
twitter: { card: "summary_large_image" },
|
|
28
28
|
};
|
package/dist/next/og.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { ImageResponse } from "next/og.js";
|
|
2
2
|
import { memoByKey } from "../core/memo.js";
|
|
3
|
+
import { OG_IMAGE_SIZE } from "../generated/contract.js";
|
|
3
4
|
import { bakedPlace } from "../core/site.js";
|
|
4
5
|
export function ogRoute(site, options) {
|
|
5
|
-
const width = options.width ??
|
|
6
|
-
const height = options.height ??
|
|
6
|
+
const width = options.width ?? OG_IMAGE_SIZE[0];
|
|
7
|
+
const height = options.height ?? OG_IMAGE_SIZE[1];
|
|
7
8
|
// Read once per process, like the cards; a failed read is retried next time.
|
|
8
9
|
let fonts;
|
|
9
10
|
const loadFonts = () => {
|
|
@@ -4,9 +4,11 @@ import type { PartClassNames } from "./parts.js";
|
|
|
4
4
|
* A form's choice that works before any script and looks like the kit after
|
|
5
5
|
* it. The server, and a browser without JavaScript, get the kit's
|
|
6
6
|
* `NativeSelect` — a real `<select name>` the form posts as is. Once the page
|
|
7
|
-
* has hydrated
|
|
8
|
-
* the
|
|
9
|
-
*
|
|
7
|
+
* has hydrated and is idle, or as soon as the visitor reaches for the field,
|
|
8
|
+
* it becomes the kit's `Select`, drawn in the palette instead of the
|
|
9
|
+
* platform's menu, with the value in an input of the same `name`; until then
|
|
10
|
+
* the native select is live, its choices reported. Both are the same box
|
|
11
|
+
* (height, width, border, inset), so the swap moves nothing.
|
|
10
12
|
*
|
|
11
13
|
* Inside a `Field`, its label names whichever control is rendered: the
|
|
12
14
|
* `Field`'s id goes to the `<select>` first and to the trigger after.
|
package/dist/react/FormSelect.js
CHANGED
|
@@ -1,45 +1,91 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { jsx as _jsx
|
|
3
|
-
import { cn, NativeSelect, NativeSelectOption
|
|
4
|
-
import { useRef, useSyncExternalStore } from "react";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { cn, NativeSelect, NativeSelectOption } from "@evinvest/uikit";
|
|
4
|
+
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
5
5
|
import { useFormSelectValue } from "./form-select-value.js";
|
|
6
|
+
import { lazyPart } from "./lazy-part.js";
|
|
6
7
|
const BOX = "relative inline-flex w-full";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
// The chevron at full strength, as the native select draws it: the kit's
|
|
10
|
-
// trigger dims its own arrow.
|
|
11
|
-
const TRIGGER = "w-full min-w-0 text-ink [&>svg]:opacity-100";
|
|
12
|
-
const TRIGGER_TEXT = { sm: "text-base md:text-sm", md: "text-base md:text-sm", lg: "" };
|
|
8
|
+
/** The scripted state, in a chunk of its own (`FormSelectKit`). */
|
|
9
|
+
const KIT = lazyPart(() => import("./FormSelectKit.js").then(m => m.FormSelectKit));
|
|
13
10
|
const subscribe = () => () => { };
|
|
14
11
|
/** `false` on the server and while hydrating, `true` from the render after. */
|
|
15
12
|
const useHydrated = () => useSyncExternalStore(subscribe, () => true, () => false);
|
|
16
13
|
export function FormSelect(props) {
|
|
17
|
-
|
|
14
|
+
const hydrated = useHydrated();
|
|
15
|
+
const kit = useSyncExternalStore(KIT.subscribe, KIT.isLoaded, () => false);
|
|
16
|
+
return _jsx(FormSelectField, { ...props, hydrated: hydrated, kit: hydrated && kit });
|
|
18
17
|
}
|
|
19
18
|
/**
|
|
20
19
|
* Both states on demand — for the widget gallery, which renders on the server
|
|
21
20
|
* and would only ever see the native one. Not part of the package's surface.
|
|
22
21
|
*/
|
|
23
22
|
export function FormSelectView({ scripted, ...props }) {
|
|
23
|
+
return _jsx(FormSelectField, { ...props, hydrated: scripted, kit: scripted });
|
|
24
|
+
}
|
|
25
|
+
/** Asks for the scripted state when the page has nothing better to do. */
|
|
26
|
+
function whenIdle(run) {
|
|
27
|
+
if (typeof window.requestIdleCallback === "function") {
|
|
28
|
+
const handle = window.requestIdleCallback(run, { timeout: 2000 });
|
|
29
|
+
return () => window.cancelIdleCallback(handle);
|
|
30
|
+
}
|
|
31
|
+
const handle = window.setTimeout(run, 200);
|
|
32
|
+
return () => window.clearTimeout(handle);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The native select until the scripted one is here. `hydrated`: the page's
|
|
36
|
+
* script runs — the native select reports its choices and asks for the
|
|
37
|
+
* scripted state on idle, or sooner on the visitor's reach (a mouse over it,
|
|
38
|
+
* the focus). `kit`: the scripted state is loaded. A press before that opens
|
|
39
|
+
* the platform's list at once, as before the script: never a wait on a chunk.
|
|
40
|
+
*/
|
|
41
|
+
function FormSelectField({ hydrated, kit, ...props }) {
|
|
24
42
|
const { name, options, placeholder, required, disabled, size = "md", id, className, classNames } = props;
|
|
25
43
|
const initial = props.defaultValue ?? (placeholder !== undefined ? "" : (options[0]?.value ?? ""));
|
|
26
44
|
const native = useRef(null);
|
|
27
45
|
// The box, not the trigger: `SelectTrigger` keeps its own ref for placing the list.
|
|
28
46
|
const box = useRef(null);
|
|
29
|
-
|
|
47
|
+
// The native select has the focus — its own list may be open (a phone's
|
|
48
|
+
// picker): it stays until the focus leaves, rather than vanish under the finger.
|
|
49
|
+
const [focused, setFocused] = useState(false);
|
|
50
|
+
const scripted = kit && !focused;
|
|
51
|
+
const state = useFormSelectValue({ initial, controlled: props.value, native, box, live: hydrated, scripted, onValueChange: props.onValueChange });
|
|
30
52
|
const hint = placeholder !== undefined ? { placeholder } : {};
|
|
31
53
|
const aria = { "aria-describedby": props["aria-describedby"], "aria-invalid": props["aria-invalid"] || state.invalid || undefined };
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
54
|
+
useEffect(() => (hydrated && !kit ? whenIdle(KIT.preload) : undefined), [hydrated, kit]);
|
|
55
|
+
// The native select is uncontrolled (see below): a value moved by the
|
|
56
|
+
// parent, or put back by a reset, is written into it here.
|
|
57
|
+
const written = useRef(state.value);
|
|
58
|
+
useLayoutEffect(() => {
|
|
59
|
+
if (written.current === state.value)
|
|
60
|
+
return;
|
|
61
|
+
written.current = state.value;
|
|
62
|
+
if (native.current && native.current.value !== state.value)
|
|
63
|
+
native.current.value = state.value;
|
|
64
|
+
}, [state.value]);
|
|
65
|
+
if (scripted)
|
|
66
|
+
return _jsx(KIT.Part, { field: props, box: box, value: state.value, choose: state.choose, open: state.open, setOpen: state.setOpen, onInvalid: state.onInvalid, aria: aria });
|
|
67
|
+
const live = hydrated
|
|
68
|
+
? {
|
|
69
|
+
onChange: (e) => {
|
|
70
|
+
const picked = e.currentTarget.value;
|
|
71
|
+
state.choose(picked);
|
|
72
|
+
// Under a `value` the parent moves it, as on a controlled input.
|
|
73
|
+
if (props.value !== undefined)
|
|
74
|
+
e.currentTarget.value = props.value;
|
|
75
|
+
},
|
|
76
|
+
onPointerEnter: (e) => {
|
|
77
|
+
if (e.pointerType === "mouse")
|
|
78
|
+
KIT.preload();
|
|
79
|
+
},
|
|
80
|
+
onFocus: () => {
|
|
81
|
+
setFocused(true);
|
|
82
|
+
KIT.preload();
|
|
83
|
+
},
|
|
84
|
+
onBlur: () => setFocused(false),
|
|
85
|
+
}
|
|
86
|
+
: {};
|
|
87
|
+
return (_jsx(NativeSelect, { ref: native, name: name, id: id, size: size, ...hint, required: required, disabled: disabled,
|
|
88
|
+
// Uncontrolled even under `value`: before the script nothing could
|
|
89
|
+
// follow a change, and a pick made then must survive into the swap.
|
|
90
|
+
defaultValue: state.value, wrapperClassName: cn(BOX, className), className: classNames?.trigger, ...aria, ...live, children: options.map(o => (_jsx(NativeSelectOption, { value: o.value, children: o.label }, o.value))) }));
|
|
45
91
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { InvalidEvent, RefObject } from "react";
|
|
2
|
+
import type { FormSelectProps } from "./FormSelect.js";
|
|
3
|
+
/** What the scripted state is handed by `FormSelect`, which keeps the value across the swap. */
|
|
4
|
+
export interface FormSelectKitProps {
|
|
5
|
+
field: FormSelectProps;
|
|
6
|
+
box: RefObject<HTMLSpanElement | null>;
|
|
7
|
+
value: string;
|
|
8
|
+
choose: (value: string) => void;
|
|
9
|
+
open: boolean;
|
|
10
|
+
setOpen: (open: boolean) => void;
|
|
11
|
+
onInvalid: (e: InvalidEvent<HTMLInputElement>) => void;
|
|
12
|
+
aria: {
|
|
13
|
+
"aria-describedby": string | undefined;
|
|
14
|
+
"aria-invalid": true | undefined;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* `FormSelect` once scripted: the kit's `Select`, the list drawn in the
|
|
19
|
+
* palette, with the value in an input of the same `name`. A chunk of its own
|
|
20
|
+
* — the list's placing, dismissal and keys weigh more than the rest of the
|
|
21
|
+
* field — fetched once the page is idle, or as soon as the visitor reaches
|
|
22
|
+
* for the field.
|
|
23
|
+
*/
|
|
24
|
+
export declare function FormSelectKit({ field, box, value, choose, open, setOpen, onInvalid, aria }: FormSelectKitProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { cn, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@evinvest/uikit";
|
|
4
|
+
const BOX = "relative inline-flex w-full";
|
|
5
|
+
// The trigger's geometry matched to `NativeSelect`'s at each size: full width,
|
|
6
|
+
// the same text size (16 px on phones, which keeps iOS from zooming).
|
|
7
|
+
// The chevron at full strength, as the native select draws it: the kit's
|
|
8
|
+
// trigger dims its own arrow.
|
|
9
|
+
const TRIGGER = "w-full min-w-0 text-ink [&>svg]:opacity-100";
|
|
10
|
+
const TRIGGER_TEXT = { sm: "text-base md:text-sm", md: "text-base md:text-sm", lg: "" };
|
|
11
|
+
/**
|
|
12
|
+
* `FormSelect` once scripted: the kit's `Select`, the list drawn in the
|
|
13
|
+
* palette, with the value in an input of the same `name`. A chunk of its own
|
|
14
|
+
* — the list's placing, dismissal and keys weigh more than the rest of the
|
|
15
|
+
* field — fetched once the page is idle, or as soon as the visitor reaches
|
|
16
|
+
* for the field.
|
|
17
|
+
*/
|
|
18
|
+
export function FormSelectKit({ field, box, value, choose, open, setOpen, onInvalid, aria }) {
|
|
19
|
+
const { name, options, placeholder, required, disabled, size = "md", id, className, classNames } = field;
|
|
20
|
+
const hint = placeholder !== undefined ? { placeholder } : {};
|
|
21
|
+
return (_jsxs("span", { ref: box, "data-slot": "form-select", className: cn(BOX, className), children: [_jsxs(Select, { value: value, onValueChange: choose, open: open, onOpenChange: setOpen, children: [_jsx(SelectTrigger, { id: id, size: size, disabled: disabled, "aria-required": required || undefined, className: cn(TRIGGER, TRIGGER_TEXT[size], classNames?.trigger), ...aria, children: _jsx(SelectValue, { ...hint }) }), _jsx(SelectContent, { className: classNames?.content, children: options.map(o => (_jsx(SelectItem, { value: o.value, className: cn(size === "lg" && "py-2.5 text-base", classNames?.item), children: o.label }, o.value))) })] }), required ? (
|
|
22
|
+
// Validated, so not `type="hidden"` (which the browser never checks);
|
|
23
|
+
// under the trigger, out of the tab order, the accessibility tree and
|
|
24
|
+
// the browser's autofill.
|
|
25
|
+
_jsx("input", { name: name, value: value, required: true, disabled: disabled, tabIndex: -1, "aria-hidden": "true", autoComplete: "off", onChange: () => { }, onInvalid: onInvalid, className: "pointer-events-none absolute inset-0 opacity-0" })) : (
|
|
26
|
+
// Nothing chosen posts nothing, as a native select on its disabled placeholder.
|
|
27
|
+
value !== "" && _jsx("input", { type: "hidden", name: name, value: value, disabled: disabled }))] }));
|
|
28
|
+
}
|
|
@@ -11,7 +11,7 @@ import { flowTextOf } from "../core/lead-capture-text.js";
|
|
|
11
11
|
import { bookingOf } from "../core/booking/model.js";
|
|
12
12
|
import { servedLocalities, storefrontOf } from "../core/place/types.js";
|
|
13
13
|
import { ESTIMATE_UNKNOWN, estimateField, flowOf } from "../core/pricing/flow.js";
|
|
14
|
-
import { labelOf } from "../core/pricing/
|
|
14
|
+
import { labelOf } from "../core/pricing/label.js";
|
|
15
15
|
import { focusNext } from "./focus-next.js";
|
|
16
16
|
import { LeadBooking } from "./LeadBooking.js";
|
|
17
17
|
import { LeadCapturePriced } from "./LeadCapturePriced.js";
|
|
@@ -5,7 +5,7 @@ import { useEffect, useMemo, useState } from "react";
|
|
|
5
5
|
import { formatCents } from "../core/lead-capture-format.js";
|
|
6
6
|
import { ESTIMATE_UNKNOWN, estimateField } from "../core/pricing/flow.js";
|
|
7
7
|
import { priceOf } from "../core/pricing/price.js";
|
|
8
|
-
import { labelOf } from "../core/pricing/
|
|
8
|
+
import { labelOf } from "../core/pricing/label.js";
|
|
9
9
|
import { TileGroup } from "./LeadCaptureTiles.js";
|
|
10
10
|
/**
|
|
11
11
|
* The screens an estimate's questions take in `steps`, in order: one each,
|
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { cn, Popover, PopoverContent, PopoverTrigger } from "@evinvest/uikit";
|
|
3
3
|
import { useSyncExternalStore } from "react";
|
|
4
4
|
import { fillText, formatCents } from "../core/lead-capture-format.js";
|
|
5
|
-
import { labelOf } from "../core/pricing/
|
|
5
|
+
import { labelOf } from "../core/pricing/label.js";
|
|
6
6
|
function lineLabel(line, model, locale, text) {
|
|
7
7
|
switch (line.kind) {
|
|
8
8
|
case "base":
|
|
@@ -14,6 +14,9 @@ export declare function useFormSelectValue(opts: {
|
|
|
14
14
|
controlled: string | undefined;
|
|
15
15
|
native: RefObject<HTMLSelectElement | null>;
|
|
16
16
|
box: RefObject<HTMLElement | null>;
|
|
17
|
+
/** The page's script runs: the native select, if still drawn, is the field. */
|
|
18
|
+
live: boolean;
|
|
19
|
+
/** The kit's `Select` is drawn. */
|
|
17
20
|
scripted: boolean;
|
|
18
21
|
onValueChange: ((value: string) => void) | undefined;
|
|
19
22
|
}): {
|
|
@@ -12,7 +12,7 @@ const DEV = typeof process !== "undefined" && process.env.NODE_ENV !== "producti
|
|
|
12
12
|
* so a switch back to uncontrolled keeps the last value, as an `<input>` does.
|
|
13
13
|
*/
|
|
14
14
|
export function useFormSelectValue(opts) {
|
|
15
|
-
const { initial, controlled, native, box, scripted, onValueChange } = opts;
|
|
15
|
+
const { initial, controlled, native, box, live, scripted, onValueChange } = opts;
|
|
16
16
|
const [own, setOwn] = useState(controlled ?? initial);
|
|
17
17
|
const [invalid, setInvalid] = useState(false);
|
|
18
18
|
const [open, setOpen] = useState(false);
|
|
@@ -25,8 +25,8 @@ export function useFormSelectValue(opts) {
|
|
|
25
25
|
const latest = useRef({ initial, isControlled, value, onValueChange });
|
|
26
26
|
latest.current = { initial, isControlled, value, onValueChange };
|
|
27
27
|
// A choice made in the native select before the script arrived survives the
|
|
28
|
-
// swap: read in the hydration commit
|
|
29
|
-
//
|
|
28
|
+
// swap: read in the hydration commit. Once — from then on the native
|
|
29
|
+
// select, while it stays, reports its choices itself. Under a `value` it is the
|
|
30
30
|
// parent's to take, so it is reported instead of kept — and only a real
|
|
31
31
|
// pick: a `value` no option carries leaves the browser on its own default,
|
|
32
32
|
// which is no choice of the visitor's.
|
|
@@ -46,8 +46,8 @@ export function useFormSelectValue(opts) {
|
|
|
46
46
|
// from a script's `checkValidity()` by the submit that starts it.
|
|
47
47
|
const submitting = useRef(false);
|
|
48
48
|
useEffect(() => {
|
|
49
|
-
const form = box.current?.closest("form");
|
|
50
|
-
if (!
|
|
49
|
+
const form = (scripted ? box.current : native.current)?.closest("form");
|
|
50
|
+
if (!live || !form)
|
|
51
51
|
return;
|
|
52
52
|
const reset = () => {
|
|
53
53
|
if (!latest.current.isControlled)
|
|
@@ -71,7 +71,7 @@ export function useFormSelectValue(opts) {
|
|
|
71
71
|
form.removeEventListener("reset", reset);
|
|
72
72
|
document.removeEventListener("click", submit, true);
|
|
73
73
|
};
|
|
74
|
-
}, [box, scripted]);
|
|
74
|
+
}, [box, native, live, scripted]);
|
|
75
75
|
const choose = (next) => {
|
|
76
76
|
if (!isControlled)
|
|
77
77
|
setOwn(next);
|
package/dist/react/index.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export { Faq, type FaqPart, type FaqProps } from "./Faq.js";
|
|
|
23
23
|
export { FormSelect, type FormSelectOption, type FormSelectPart, type FormSelectProps } from "./FormSelect.js";
|
|
24
24
|
export { LangSwitch, withLang, type LangSwitchProps } from "./LangSwitch.js";
|
|
25
25
|
export { LeadCapture, type LeadCapturePart, type LeadCaptureProps } from "./LeadCapture.js";
|
|
26
|
+
export { loadLazyParts } from "./lazy-part.js";
|
|
26
27
|
export { LeadBooking, type LeadBookingProps } from "./LeadBooking.js";
|
|
27
28
|
export type { BookingPart } from "./LeadBookingManual.js";
|
|
28
29
|
export { BOOKING_ADAPTERS, BOOKING_EMBEDS, bookingAdapters, calComAdapter, calComEmbedAdapter, calComEmbedOrigin, googleCalendarAdapter, linkAdapter, manualAdapter, type BookedSlot, type BookingAdapter, type BookingAdapters, type BookingContext, } from "./booking-adapters.js";
|
package/dist/react/index.js
CHANGED
|
@@ -22,6 +22,7 @@ export { Faq } from "./Faq.js";
|
|
|
22
22
|
export { FormSelect } from "./FormSelect.js";
|
|
23
23
|
export { LangSwitch, withLang } from "./LangSwitch.js";
|
|
24
24
|
export { LeadCapture } from "./LeadCapture.js";
|
|
25
|
+
export { loadLazyParts } from "./lazy-part.js";
|
|
25
26
|
export { LeadBooking } from "./LeadBooking.js";
|
|
26
27
|
export { BOOKING_ADAPTERS, BOOKING_EMBEDS, bookingAdapters, calComAdapter, calComEmbedAdapter, calComEmbedOrigin, googleCalendarAdapter, linkAdapter, manualAdapter, } from "./booking-adapters.js";
|
|
27
28
|
export { BOOKING_ACTION } from "./use-booking.js";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type ComponentType } from "react";
|
|
2
|
+
/** A piece of a client island in a chunk of its own, and the way to ask for that chunk early. */
|
|
3
|
+
export interface LazyPart<P extends object> {
|
|
4
|
+
/** Drawn where the piece goes; see {@link lazyPart}. */
|
|
5
|
+
Part: ComponentType<P>;
|
|
6
|
+
/** Asks for the chunk — once a page; later calls share the first. */
|
|
7
|
+
preload: () => void;
|
|
8
|
+
/** Whether the chunk is here, for `useSyncExternalStore` with {@link LazyPart.subscribe}. */
|
|
9
|
+
isLoaded: () => boolean;
|
|
10
|
+
subscribe: (onLoad: () => void) => () => void;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Resolves once every lazily drawn part of the kit is loaded — today
|
|
14
|
+
* `FormSelect`'s scripted list — for a test that renders a form and reads it
|
|
15
|
+
* at once (`beforeAll(loadLazyParts)`). A page never needs it: the server
|
|
16
|
+
* loads them as their modules load, and a browser fetches them when idle.
|
|
17
|
+
*/
|
|
18
|
+
export declare function loadLazyParts(): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* A component a page pays for only when it draws it — the shape of the
|
|
21
|
+
* `messenger` variants (`LeadCaptureMessenger`), here for `FormSelect`'s
|
|
22
|
+
* scripted state.
|
|
23
|
+
*
|
|
24
|
+
* On the server every part is loaded as its module is, so a render finds it
|
|
25
|
+
* and writes it into the page whole: no boundary of its own, which React
|
|
26
|
+
* would reveal late. Hydrating, a part whose chunk is still on its way holds
|
|
27
|
+
* the island's hydration — the server's markup stays as it is, no fallback
|
|
28
|
+
* drawn — and `preload` from the island's render starts every chunk it will
|
|
29
|
+
* need at once, so none waits behind another. A part first drawn after
|
|
30
|
+
* hydration (a later step, the success) waits behind a boundary of its own,
|
|
31
|
+
* drawing nothing until its chunk is here.
|
|
32
|
+
*/
|
|
33
|
+
export declare function lazyPart<P extends object>(load: () => Promise<ComponentType<P>>): LazyPart<P>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { lazy, Suspense, useState, useSyncExternalStore } from "react";
|
|
4
|
+
const noop = () => () => undefined;
|
|
5
|
+
/** Every part's loader, for {@link loadLazyParts}. */
|
|
6
|
+
const LOADERS = [];
|
|
7
|
+
/**
|
|
8
|
+
* Resolves once every lazily drawn part of the kit is loaded — today
|
|
9
|
+
* `FormSelect`'s scripted list — for a test that renders a form and reads it
|
|
10
|
+
* at once (`beforeAll(loadLazyParts)`). A page never needs it: the server
|
|
11
|
+
* loads them as their modules load, and a browser fetches them when idle.
|
|
12
|
+
*/
|
|
13
|
+
export function loadLazyParts() {
|
|
14
|
+
return Promise.all(LOADERS.map(load => load())).then(() => undefined);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A component a page pays for only when it draws it — the shape of the
|
|
18
|
+
* `messenger` variants (`LeadCaptureMessenger`), here for `FormSelect`'s
|
|
19
|
+
* scripted state.
|
|
20
|
+
*
|
|
21
|
+
* On the server every part is loaded as its module is, so a render finds it
|
|
22
|
+
* and writes it into the page whole: no boundary of its own, which React
|
|
23
|
+
* would reveal late. Hydrating, a part whose chunk is still on its way holds
|
|
24
|
+
* the island's hydration — the server's markup stays as it is, no fallback
|
|
25
|
+
* drawn — and `preload` from the island's render starts every chunk it will
|
|
26
|
+
* need at once, so none waits behind another. A part first drawn after
|
|
27
|
+
* hydration (a later step, the success) waits behind a boundary of its own,
|
|
28
|
+
* drawing nothing until its chunk is here.
|
|
29
|
+
*/
|
|
30
|
+
export function lazyPart(load) {
|
|
31
|
+
let loaded = null;
|
|
32
|
+
let loading = null;
|
|
33
|
+
const listeners = new Set();
|
|
34
|
+
const fetchPart = () => {
|
|
35
|
+
loading ??= load().then(part => {
|
|
36
|
+
loaded = part;
|
|
37
|
+
for (const listener of listeners)
|
|
38
|
+
listener();
|
|
39
|
+
return part;
|
|
40
|
+
}, (error) => {
|
|
41
|
+
// A chunk that failed may be asked for again: the next draw retries.
|
|
42
|
+
loading = null;
|
|
43
|
+
throw error;
|
|
44
|
+
});
|
|
45
|
+
return loading;
|
|
46
|
+
};
|
|
47
|
+
const preload = () => void fetchPart().catch(() => undefined);
|
|
48
|
+
LOADERS.push(fetchPart);
|
|
49
|
+
if (typeof window === "undefined")
|
|
50
|
+
preload();
|
|
51
|
+
const Lazy = lazy(() => fetchPart().then(part => ({ default: part })));
|
|
52
|
+
function Part(props) {
|
|
53
|
+
const hydrating = useSyncExternalStore(noop, () => false, () => true);
|
|
54
|
+
// Chosen on the first render and kept: a structure swapped under the part would draw it anew.
|
|
55
|
+
const [Loaded] = useState(() => loaded);
|
|
56
|
+
const [wrapped] = useState(() => !hydrating);
|
|
57
|
+
if (Loaded)
|
|
58
|
+
return _jsx(Loaded, { ...props });
|
|
59
|
+
return wrapped ? (_jsx(Suspense, { fallback: null, children: _jsx(Lazy, { ...props }) })) : (_jsx(Lazy, { ...props }));
|
|
60
|
+
}
|
|
61
|
+
const subscribe = (onLoad) => {
|
|
62
|
+
listeners.add(onLoad);
|
|
63
|
+
return () => void listeners.delete(onLoad);
|
|
64
|
+
};
|
|
65
|
+
return { Part, preload, isLoaded: () => loaded !== null, subscribe };
|
|
66
|
+
}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -16,6 +16,6 @@ export { parseSmtpUrl, sendMail, type Mail, type SendOptions } from "./smtp.js";
|
|
|
16
16
|
export { defaultLeadMail, leadNotifier, MAIL_PER_MINUTE, type LeadMail, type LeadMailShown, type LeadNotifier, type NeedLabel, type NotifyEnv } from "./notify.js";
|
|
17
17
|
export { clientKey, NO_CLIENT_ADDRESS, parseProxyTrust, type ProxyTrust } from "./client-key.js";
|
|
18
18
|
export { checkLeadStore } from "./boot.js";
|
|
19
|
-
export { leadWebhook, panelChannel, panelFlowOf, panelFlowProperties, type BookingQueued, type BookingWebhookContext, type BuildBookingBody, type BuildWebhookBody, type LeadWebhook, type LeadWebhookContext, type LeadWebhookOptions, type PanelChannel, type PanelFlow, type PanelFlowProperties, } from "./lead-webhook.js";
|
|
19
|
+
export { leadWebhook, panelChannel, panelFlowOf, panelFlowProperties, panelLocaleOf, panelLocaleProperties, type BookingQueued, type BookingWebhookContext, type BuildBookingBody, type BuildWebhookBody, type LeadWebhook, type LeadWebhookContext, type LeadWebhookOptions, type PanelChannel, type PanelFlow, type PanelFlowProperties, type PanelLocale, } from "./lead-webhook.js";
|
|
20
20
|
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";
|
|
21
21
|
export { signatureHeaders, signWebhook, type WebhookSignatureHeaders, type WebhookSigning } from "./webhook-signature.js";
|
package/dist/server/index.js
CHANGED
|
@@ -16,6 +16,6 @@ export { parseSmtpUrl, sendMail } from "./smtp.js";
|
|
|
16
16
|
export { defaultLeadMail, leadNotifier, MAIL_PER_MINUTE } from "./notify.js";
|
|
17
17
|
export { clientKey, NO_CLIENT_ADDRESS, parseProxyTrust } from "./client-key.js";
|
|
18
18
|
export { checkLeadStore } from "./boot.js";
|
|
19
|
-
export { leadWebhook, panelChannel, panelFlowOf, panelFlowProperties, } from "./lead-webhook.js";
|
|
19
|
+
export { leadWebhook, panelChannel, panelFlowOf, panelFlowProperties, panelLocaleOf, panelLocaleProperties, } from "./lead-webhook.js";
|
|
20
20
|
export { checkWebhookUrl, openWebhookOutbox, WEBHOOK_HORIZON_MS, WEBHOOK_MAX_ATTEMPTS, WEBHOOK_MAX_DELAY_MS, WEBHOOK_TICK_MS, } from "./webhook-outbox.js";
|
|
21
21
|
export { signatureHeaders, signWebhook } from "./webhook-signature.js";
|
|
@@ -55,7 +55,22 @@ export interface LeadWebhookContext {
|
|
|
55
55
|
* on a lead that has one; otherwise absent, and the body must not carry it.
|
|
56
56
|
*/
|
|
57
57
|
messageRef?: string;
|
|
58
|
+
/**
|
|
59
|
+
* The language the visitor read the page in, as the panel's
|
|
60
|
+
* `properties.locale` takes it (`panelLocaleOf`): `fr` or `en` only, and
|
|
61
|
+
* only under `panelLocale`; otherwise absent, and the body must not carry
|
|
62
|
+
* the property. `panelLocaleProperties` writes it.
|
|
63
|
+
*/
|
|
64
|
+
panelLocale?: PanelLocale;
|
|
58
65
|
}
|
|
66
|
+
/** The languages the Service-Arb panel's `lead.created` takes in `properties.locale`. */
|
|
67
|
+
export type PanelLocale = "fr" | "en";
|
|
68
|
+
/** The panel's locale for a site locale (`fr`, `en-GB` → `en`), or nothing for one it does not take. */
|
|
69
|
+
export declare function panelLocaleOf(locale: string): PanelLocale | undefined;
|
|
70
|
+
/** `ctx.panelLocale` as `lead.created`'s properties, to spread into the body's `properties`; `{}` without one. */
|
|
71
|
+
export declare function panelLocaleProperties(locale: PanelLocale | undefined): {
|
|
72
|
+
locale?: PanelLocale;
|
|
73
|
+
};
|
|
59
74
|
/** A lead's sale as the panel's `lead.created` may carry it. */
|
|
60
75
|
export interface PanelFlow {
|
|
61
76
|
flow: LeadFlow;
|
|
@@ -146,6 +161,8 @@ export interface LeadWebhook {
|
|
|
146
161
|
readonly panelBooking?: boolean;
|
|
147
162
|
/** Whether `ctx.channel` says `whatsapp` / `telegram` and `ctx.messageRef` is filled in (`LeadWebhookOptions.panelMessenger`). */
|
|
148
163
|
readonly panelMessenger?: boolean;
|
|
164
|
+
/** Whether `ctx.panelLocale` is filled in (`LeadWebhookOptions.panelLocale`). */
|
|
165
|
+
readonly panelLocale?: boolean;
|
|
149
166
|
/**
|
|
150
167
|
* Queues `booking.requested@1` behind the lead's `lead.created`, through
|
|
151
168
|
* the same outbox: not sent until that row is delivered, and a `409` / `425`
|
|
@@ -198,6 +215,13 @@ export interface LeadWebhookOptions extends WebhookOutboxOptions {
|
|
|
198
215
|
* messenger lead goes as a `form`, without its reference.
|
|
199
216
|
*/
|
|
200
217
|
panelMessenger?: boolean;
|
|
218
|
+
/**
|
|
219
|
+
* The switch for `properties.locale` (`ctx.panelLocale`: `fr` or `en`). Off
|
|
220
|
+
* (the default) until the panel's `lead.created` accepts the property — it
|
|
221
|
+
* refuses an unknown one, and the outbox would park the lead. Off, the
|
|
222
|
+
* context carries nothing and the body is as it was.
|
|
223
|
+
*/
|
|
224
|
+
panelLocale?: boolean;
|
|
201
225
|
/** The body of `booking.requested@1`; absent → booking requests are dropped whatever `panelBooking` says. */
|
|
202
226
|
buildBookingBody?: BuildBookingBody | undefined;
|
|
203
227
|
}
|
|
@@ -3,6 +3,15 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { channelOf, suspectOf } from "../core/lead.js";
|
|
4
4
|
import { experimentsDeclaredBody } from "./experiments-declared.js";
|
|
5
5
|
import { openWebhookOutbox } from "./webhook-outbox.js";
|
|
6
|
+
/** The panel's locale for a site locale (`fr`, `en-GB` → `en`), or nothing for one it does not take. */
|
|
7
|
+
export function panelLocaleOf(locale) {
|
|
8
|
+
const primary = locale.toLowerCase().split(/[-_]/, 1)[0];
|
|
9
|
+
return primary === "fr" || primary === "en" ? primary : undefined;
|
|
10
|
+
}
|
|
11
|
+
/** `ctx.panelLocale` as `lead.created`'s properties, to spread into the body's `properties`; `{}` without one. */
|
|
12
|
+
export function panelLocaleProperties(locale) {
|
|
13
|
+
return locale ? { locale } : {};
|
|
14
|
+
}
|
|
6
15
|
/** The sale `ctx.flow` describes, or nothing for a lead without a flow. */
|
|
7
16
|
export function panelFlowOf(lead) {
|
|
8
17
|
if (!lead.flow)
|
|
@@ -40,7 +49,7 @@ export function panelChannel(channel, messenger = false) {
|
|
|
40
49
|
* so a broken setting fails startup rather than a lead.
|
|
41
50
|
*/
|
|
42
51
|
export function leadWebhook(site, env, options) {
|
|
43
|
-
const { buildBody, buildBookingBody, signing, panelSuspect = false, panelFlow = false, panelBooking = false, panelMessenger = false, ...outboxOptions } = options;
|
|
52
|
+
const { buildBody, buildBookingBody, signing, panelSuspect = false, panelFlow = false, panelBooking = false, panelMessenger = false, panelLocale = false, ...outboxOptions } = options;
|
|
44
53
|
if (!env.leadWebhook || !buildBody)
|
|
45
54
|
return null;
|
|
46
55
|
if (env.leadsDb.kind !== "sqlite")
|
|
@@ -54,6 +63,7 @@ export function leadWebhook(site, env, options) {
|
|
|
54
63
|
panelFlow,
|
|
55
64
|
panelBooking,
|
|
56
65
|
panelMessenger,
|
|
66
|
+
panelLocale,
|
|
57
67
|
requestBooking(request) {
|
|
58
68
|
if (!panelBooking || !buildBookingBody)
|
|
59
69
|
return { kind: "off" };
|
|
@@ -77,6 +87,7 @@ export function leadWebhook(site, env, options) {
|
|
|
77
87
|
enqueue(lead, id, meta) {
|
|
78
88
|
const suspect = panelSuspect ? suspectOf(lead) : undefined;
|
|
79
89
|
const flow = panelFlow ? panelFlowOf(lead) : undefined;
|
|
90
|
+
const locale = panelLocale ? panelLocaleOf(meta.locale) : undefined;
|
|
80
91
|
const ctx = {
|
|
81
92
|
leadId: id,
|
|
82
93
|
brandId: site.brand.id,
|
|
@@ -87,6 +98,7 @@ export function leadWebhook(site, env, options) {
|
|
|
87
98
|
...(flow ? { flow } : {}),
|
|
88
99
|
channel: panelChannel(channelOf(lead), panelMessenger),
|
|
89
100
|
...(panelMessenger && lead.messageRef ? { messageRef: lead.messageRef } : {}),
|
|
101
|
+
...(locale ? { panelLocale: locale } : {}),
|
|
90
102
|
};
|
|
91
103
|
const body = JSON.stringify(buildBody(lead, ctx));
|
|
92
104
|
if (typeof body !== "string")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evinvest/kitstart",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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": {
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
"@types/react-dom": "^19.3.0",
|
|
114
114
|
"eslint": "^9.39.5",
|
|
115
115
|
"jsdom": "^25.0.1",
|
|
116
|
-
"next": "^16.
|
|
116
|
+
"next": "^16.4.0",
|
|
117
117
|
"react": "^19.3.0",
|
|
118
118
|
"react-dom": "^19.3.0",
|
|
119
119
|
"server-only": "^0.0.1",
|
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.20.0";
|
|
7
7
|
ev.inputs.v_flakes.follows = "v_flakes";
|
|
8
8
|
};
|
|
9
9
|
|
package/template/package.json
CHANGED
|
@@ -19,11 +19,11 @@
|
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@evinvest/analytics": "^0.4.0",
|
|
22
|
-
"@evinvest/i18n": "^0.
|
|
23
|
-
"@evinvest/kitstart": "^0.
|
|
22
|
+
"@evinvest/i18n": "^0.11.0",
|
|
23
|
+
"@evinvest/kitstart": "^0.20.0",
|
|
24
24
|
"@evinvest/marketing": "^0.3.0",
|
|
25
|
-
"@evinvest/uikit": "^0.
|
|
26
|
-
"next": "16.
|
|
25
|
+
"@evinvest/uikit": "^0.29.0",
|
|
26
|
+
"next": "16.4.0",
|
|
27
27
|
"react": "19.3.0",
|
|
28
28
|
"react-dom": "19.3.0",
|
|
29
29
|
"server-only": "^0.0.1"
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"@types/react": "^19.3.0",
|
|
36
36
|
"@types/react-dom": "^19.3.0",
|
|
37
37
|
"eslint": "^9.39.5",
|
|
38
|
-
"eslint-config-next": "16.
|
|
38
|
+
"eslint-config-next": "16.4.0",
|
|
39
39
|
"steiger": "^0.6.0",
|
|
40
40
|
"tailwindcss": "^4.3.3",
|
|
41
41
|
"typescript": "5.9.3",
|