@evinvest/kitstart 0.10.0 → 0.12.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 +210 -17
- package/dist/core/accept.d.ts +19 -1
- package/dist/core/accept.js +25 -2
- package/dist/core/analytics.d.ts +13 -3
- package/dist/core/analytics.js +17 -2
- 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 +4 -3
- package/dist/index.js +4 -3
- 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/AnalyticsBoundary.js +8 -4
- 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/analytics-context.d.ts +7 -0
- package/dist/react/analytics-context.js +7 -0
- package/dist/react/booking-adapters.d.ts +58 -0
- package/dist/react/booking-adapters.js +104 -0
- package/dist/react/index.d.ts +5 -1
- package/dist/react/index.js +4 -1
- package/dist/react/use-booking.d.ts +20 -0
- package/dist/react/use-booking.js +32 -0
- package/dist/react/use-lead-error.d.ts +3 -7
- package/dist/react/use-lead-error.js +9 -6
- package/dist/react/use-lead-submit.d.ts +7 -1
- package/dist/react/use-lead-submit.js +13 -3
- package/dist/react/use-repriced.d.ts +18 -0
- package/dist/react/use-repriced.js +27 -0
- package/dist/server/experiments-declared.d.ts +106 -0
- package/dist/server/experiments-declared.js +124 -0
- package/dist/server/experiments-source.d.ts +49 -0
- package/dist/server/experiments-source.js +88 -0
- package/dist/server/index.d.ts +4 -2
- package/dist/server/index.js +2 -0
- package/dist/server/lead-webhook.d.ts +63 -0
- package/dist/server/lead-webhook.js +24 -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,3 +1,4 @@
|
|
|
1
|
+
import type { BookingConfig, BookingRules } from "../booking/model.js";
|
|
1
2
|
import type { Geo, OpeningHours, Place, PostalAddress, Rating, ServiceArea } from "./types.js";
|
|
2
3
|
/**
|
|
3
4
|
* The live half of a place, as `GET <source>/locations/<slug>` answers it.
|
|
@@ -16,12 +17,15 @@ export interface PlaceLive<L extends string> {
|
|
|
16
17
|
serviceArea?: readonly ServiceArea[];
|
|
17
18
|
hours?: readonly OpeningHours[];
|
|
18
19
|
rating?: Rating;
|
|
20
|
+
/** Validated with the site's Cal.com hosts; one that does not validate is dropped, the baked one kept. */
|
|
21
|
+
booking?: BookingConfig;
|
|
19
22
|
}
|
|
20
23
|
/**
|
|
21
24
|
* Drops what does not validate instead of failing the page on one bad field.
|
|
22
|
-
* A landmark counts only when it is given in every one of `locales
|
|
25
|
+
* A landmark counts only when it is given in every one of `locales`; a
|
|
26
|
+
* `cal_com` booking only on one of `rules.calComHosts` (the site's).
|
|
23
27
|
*/
|
|
24
|
-
export declare function parsePlaceLive<L extends string>(body: unknown, locales: readonly L[]): PlaceLive<L>;
|
|
28
|
+
export declare function parsePlaceLive<L extends string>(body: unknown, locales: readonly L[], rules?: BookingRules): PlaceLive<L>;
|
|
25
29
|
/**
|
|
26
30
|
* The live fields over the baked ones. A service-area place takes none of the
|
|
27
31
|
* storefront's: an address, a pin or a photo from the source would give it a
|
package/dist/core/place/live.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BookingConfigError, parseBookingConfig } from "../booking/validate.js";
|
|
1
2
|
import { parseInstant } from "./rating.js";
|
|
2
3
|
const DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
|
3
4
|
const TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
@@ -60,11 +61,24 @@ function landmark(v, locales) {
|
|
|
60
61
|
function isComplete(record, locales) {
|
|
61
62
|
return locales.every(locale => typeof record[locale] === "string");
|
|
62
63
|
}
|
|
64
|
+
function booking(v, rules) {
|
|
65
|
+
if (v === undefined || v === null)
|
|
66
|
+
return undefined;
|
|
67
|
+
try {
|
|
68
|
+
return parseBookingConfig(v, rules);
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error instanceof BookingConfigError)
|
|
72
|
+
return undefined;
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
63
76
|
/**
|
|
64
77
|
* Drops what does not validate instead of failing the page on one bad field.
|
|
65
|
-
* A landmark counts only when it is given in every one of `locales
|
|
78
|
+
* A landmark counts only when it is given in every one of `locales`; a
|
|
79
|
+
* `cal_com` booking only on one of `rules.calComHosts` (the site's).
|
|
66
80
|
*/
|
|
67
|
-
export function parsePlaceLive(body, locales) {
|
|
81
|
+
export function parsePlaceLive(body, locales, rules = {}) {
|
|
68
82
|
if (!isObject(body))
|
|
69
83
|
throw new Error("live location: body is not an object");
|
|
70
84
|
const live = {};
|
|
@@ -104,6 +118,9 @@ export function parsePlaceLive(body, locales) {
|
|
|
104
118
|
const r = rating(body.rating);
|
|
105
119
|
if (r)
|
|
106
120
|
live.rating = r;
|
|
121
|
+
const b = booking(body.booking, rules);
|
|
122
|
+
if (b)
|
|
123
|
+
live.booking = b;
|
|
107
124
|
return live;
|
|
108
125
|
}
|
|
109
126
|
/**
|
|
@@ -113,6 +130,7 @@ export function parsePlaceLive(body, locales) {
|
|
|
113
130
|
*/
|
|
114
131
|
export function mergeLive(baked, live) {
|
|
115
132
|
const front = baked.presence;
|
|
133
|
+
const booking = live.booking ?? baked.booking;
|
|
116
134
|
return {
|
|
117
135
|
...baked,
|
|
118
136
|
presence: front.kind === "storefront"
|
|
@@ -128,5 +146,6 @@ export function mergeLive(baked, live) {
|
|
|
128
146
|
serviceArea: live.serviceArea ?? baked.serviceArea,
|
|
129
147
|
hours: live.hours ?? baked.hours,
|
|
130
148
|
rating: live.rating ?? baked.rating,
|
|
149
|
+
...(booking ? { booking } : {}),
|
|
131
150
|
};
|
|
132
151
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { BookingConfig } from "../booking/model.js";
|
|
1
2
|
/**
|
|
2
3
|
* The place model. Three words, one each:
|
|
3
4
|
*
|
|
@@ -79,6 +80,11 @@ export interface Place<L extends string> {
|
|
|
79
80
|
hours: readonly OpeningHours[] | null;
|
|
80
81
|
/** Live only. */
|
|
81
82
|
rating: Rating | null;
|
|
83
|
+
/**
|
|
84
|
+
* How a priced lead's slot is set (`PlaceLive.booking`, the panel's place
|
|
85
|
+
* settings); absent → `DEFAULT_BOOKING`, a call (`bookingOf`).
|
|
86
|
+
*/
|
|
87
|
+
booking?: BookingConfig;
|
|
82
88
|
}
|
|
83
89
|
/** The storefront half of a place, or `null` for a service-area business. */
|
|
84
90
|
export declare function storefrontOf<L extends string>(place: Place<L>): Extract<Presence<L>, {
|
package/dist/core/place/types.js
CHANGED
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The place model. Three words, one each:
|
|
3
|
-
*
|
|
4
|
-
* - **Place** — the aggregate: one point of the business, baked config merged
|
|
5
|
-
* with the live source;
|
|
6
|
-
* - **Presence** — how the place exists for a visitor: a storefront with an
|
|
7
|
-
* address, or a service area with none;
|
|
8
|
-
* - **ServiceArea** — where the van goes.
|
|
9
|
-
*
|
|
10
|
-
* A service-area business has no address *in the type*, so no builder can
|
|
11
|
-
* render one for it by accident — schema.org, the map and the footer all have
|
|
12
|
-
* to ask `presence.kind` first.
|
|
13
|
-
*/
|
|
14
1
|
/** The storefront half of a place, or `null` for a service-area business. */
|
|
15
2
|
export function storefrontOf(place) {
|
|
16
3
|
return place.presence.kind === "storefront" ? place.presence : null;
|
package/dist/core/routing.d.ts
CHANGED
|
@@ -66,8 +66,9 @@ export declare function parseGoneHeader(value: string | null): {
|
|
|
66
66
|
};
|
|
67
67
|
/**
|
|
68
68
|
* The routes every landing has outside `[locale]`, which pass untouched: the
|
|
69
|
-
* form target,
|
|
70
|
-
*
|
|
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`.
|
|
71
72
|
*/
|
|
72
73
|
export declare const NON_PAGE_ROUTES: readonly string[];
|
|
73
74
|
/** @deprecated The same list as {@link NON_PAGE_ROUTES}, under its old name. */
|
package/dist/core/routing.js
CHANGED
|
@@ -70,10 +70,11 @@ export function parseGoneHeader(value) {
|
|
|
70
70
|
}
|
|
71
71
|
/**
|
|
72
72
|
* The routes every landing has outside `[locale]`, which pass untouched: the
|
|
73
|
-
* form target,
|
|
74
|
-
*
|
|
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`.
|
|
75
76
|
*/
|
|
76
|
-
export const NON_PAGE_ROUTES = ["/quote", "/og", "/health", "/sitemap.xml", "/robots.txt"];
|
|
77
|
+
export const NON_PAGE_ROUTES = ["/quote", "/quote/booking", "/quote/confirm", "/og", "/health", "/sitemap.xml", "/robots.txt"];
|
|
77
78
|
/** @deprecated The same list as {@link NON_PAGE_ROUTES}, under its old name. */
|
|
78
79
|
export const PASS_PATHS = NON_PAGE_ROUTES;
|
|
79
80
|
function isInfrastructure(pathname, publicFiles) {
|
package/dist/core/site.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { LocaleRegistry } from "@evinvest/i18n";
|
|
2
|
+
import type { BookingRules } from "./booking/model.js";
|
|
2
3
|
import type { LeadSchema } from "./lead.js";
|
|
3
4
|
import type { PublicationPolicy } from "./place/publication.js";
|
|
4
5
|
import type { Place } from "./place/types.js";
|
|
@@ -69,6 +70,13 @@ export interface SiteConfig<L extends string, P extends string> {
|
|
|
69
70
|
* down, or serves nothing valid. Absent → every need is a `quote` then.
|
|
70
71
|
*/
|
|
71
72
|
pricing?: PricingModel;
|
|
73
|
+
/**
|
|
74
|
+
* The booking rules a place's `booking` is held to, baked or live:
|
|
75
|
+
* `calComHosts` are the Cal.com hosts a `cal_com` page may be on — the
|
|
76
|
+
* hosted cloud by default (`DEFAULT_CAL_COM_HOSTS`); a brand on its own
|
|
77
|
+
* Cal.com lists both, `["cal.com", "cal.brand.fr"]`.
|
|
78
|
+
*/
|
|
79
|
+
booking?: BookingRules;
|
|
72
80
|
legacyRedirects?: readonly LegacyRedirect<L>[];
|
|
73
81
|
/**
|
|
74
82
|
* Paths the brand serves as files besides the routes every landing has
|
package/dist/core/site.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bookingConfigProblems, calComHostsProblems } from "./booking/validate.js";
|
|
1
2
|
import { pricingProblemsFor } from "./pricing/validate.js";
|
|
2
3
|
export function defineSite(config) {
|
|
3
4
|
const pageKeys = Object.keys(config.pages).filter((k) => Object.hasOwn(config.pages, k));
|
|
@@ -35,6 +36,20 @@ export function defineSite(config) {
|
|
|
35
36
|
if (problems.length > 0)
|
|
36
37
|
throw new Error(`defineSite: pricing: ${problems.join("; ")}`);
|
|
37
38
|
}
|
|
39
|
+
const calComHosts = config.booking?.calComHosts;
|
|
40
|
+
if (calComHosts) {
|
|
41
|
+
const problems = calComHostsProblems(calComHosts);
|
|
42
|
+
if (problems.length > 0)
|
|
43
|
+
throw new Error(`defineSite: booking.${problems.join("; ")}`);
|
|
44
|
+
}
|
|
45
|
+
for (const place of config.places) {
|
|
46
|
+
// Baked into the build: a booking the live source would refuse must not ship.
|
|
47
|
+
if (place.booking === undefined)
|
|
48
|
+
continue;
|
|
49
|
+
const problems = bookingConfigProblems(place.booking, config.booking ?? {});
|
|
50
|
+
if (problems.length > 0)
|
|
51
|
+
throw new Error(`defineSite: place "${place.slug}": ${problems.join("; ")}`);
|
|
52
|
+
}
|
|
38
53
|
for (const need of Object.keys(config.lead.flows ?? {})) {
|
|
39
54
|
if (!config.lead.subjects.includes(need))
|
|
40
55
|
throw new Error(`defineSite: lead.flows names "${need}", which is not one of lead.subjects`);
|
package/dist/index.d.ts
CHANGED
|
@@ -6,18 +6,19 @@
|
|
|
6
6
|
export { assertLaunchable, bakedPlace, cardFact, contactOf, defineSite, ogLocaleOf, openLaunchBlockers, perLocale, type BrandFacts, type LegacyRedirect, type OwnerTodo, type PageSuffix, type Site, type SiteConfig, type Topology, } from "./core/site.js";
|
|
7
7
|
export { createPlaceView, DEFAULT_TIME_ZONE, freshRating, isOpenAt, nextOpening, parseInstant, isPublished, mergeLive, parsePlaceLive, placeHref, placeOrigin, placeUrl, publicationGaps, RATING_MAX_AGE_DAYS, servedLocalities, SERVICE_AREA_GATE, siteOrigin, STOREFRONT_GATE, storefrontOf, type DayOfWeek, type Geo, type LinkBase, type LinkMode, type Opening, type OpeningHours, type OriginFacts, type Place, type PlaceLive, type PlaceView, type PostalAddress, type Presence, type PublicationField, type PublicationPolicy, type Rating, type ServiceArea, } from "./core/place/index.js";
|
|
8
8
|
export { createRouting, GONE, GONE_HEADER, gonePath, goneHeader, HOST_MARK, LANG_COOKIE, LANG_COOKIE_MAX_AGE, NON_PAGE_ROUTES, parseGoneHeader, parsePlaceParam, PASS_PATHS, placeParam, pointSuffixes, THANKS, type Decision, type RequestFacts, type Routing, } from "./core/routing.js";
|
|
9
|
-
export { CARD_FIELD, CARD_ID, CHANNEL_FIELD, channelOf, CONSENT_FIELD, LEAD_CHANNELS, LEAD_ERROR_PARAM, LEAD_SCHEMA_VERSION, MAX_CONSENT, MAX_FIELD, readCandidate, rejectionOf, suspectOf, SUBMISSION_FIELD, SUBMISSION_ID, validateCallbackLead, validateCandidate, validateLead, type Lead, type LeadCandidate, type LeadChannel, type LeadConsent, type LeadExtra, type LeadPrice, type LeadRejection, type LeadSchema, type LeadStore, type LeadSuspect, type LeadVerdict, type LeadWire, type SpamVerdict, } from "./core/lead.js";
|
|
9
|
+
export { CARD_FIELD, CARD_ID, CHANNEL_FIELD, channelOf, CONSENT_FIELD, LEAD_CHANNELS, LEAD_ERROR_PARAM, LEAD_CARD_PARAM, leadErrorFor, leadErrorOf, LEAD_SCHEMA_VERSION, MAX_CONSENT, MAX_FIELD, readCandidate, rejectionOf, suspectOf, SUBMISSION_FIELD, SUBMISSION_ID, validateCallbackLead, validateCandidate, validateLead, type Lead, type LeadCandidate, type LeadChannel, type LeadError, type PageLeadError, type LeadConsent, type LeadExtra, type LeadPrice, type LeadRejection, type LeadSchema, type LeadStore, type LeadSuspect, type LeadVerdict, type LeadWire, type SpamVerdict, } from "./core/lead.js";
|
|
10
10
|
export { isMobilePhone, isPlausiblePhone, normalizePhone, phoneProblem } from "./core/phone.js";
|
|
11
11
|
export { CENTS_BUCKETS, centsBucket, ESTIMATE_FIELD_PREFIX, estimateField, flowOf, labelOf, LEAD_FLOWS, mulBp, parsePricingModel, priceOf, PRICING_CURRENCY, PRICING_FORMAT, PRICING_LIMITS, PricingModelError, pricingProblems, pricingProblemsFor, readEstimateInputs, roundTo, type AddOption, type DiscountOption, type LeadFlow, type LeadFlows, type MultiplyOption, type NeedPricing, type Price, type PriceLine, type PricingInput, type PricingInputKind, type PricingInputs, type PricingLabels, type PricingModel, } from "./core/pricing/index.js";
|
|
12
12
|
export { flowTextOf, LEAD_CAPTURE_TEXT, type LeadCaptureFlowText, type LeadCaptureText } from "./core/lead-capture-text.js";
|
|
13
13
|
export { fillText, formatCents, openingText } from "./core/lead-capture-format.js";
|
|
14
14
|
export { channelHref, resolveChannels, smsHref, type CaptureChannel, type ChannelFacts, type ChannelOptions, type ResolvedChannels, } from "./core/channels.js";
|
|
15
15
|
export { checkTiming, HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, MIN_FILL_MS, RATE_LIMIT_MAX_KEYS, RateLimiter, RENDERED_AT_FIELD, screen, type Screening, } from "./core/antispam.js";
|
|
16
|
-
export { createAcceptLead, EXPERIMENT_FIELD, FORM_ID_FIELD, leadRef, LOCALE_FIELD, LOCATION_FIELD, VARIANT_FIELD, type AcceptDeps, type Outcome, type SubmitTags, } from "./core/accept.js";
|
|
16
|
+
export { createAcceptLead, ANALYTICS_ID_FIELD, EXPERIMENT_FIELD, FORM_ID_FIELD, leadRef, LOCALE_FIELD, LOCATION_FIELD, PRICE_CHANGED, SHOWN_CENTS_FIELD, VARIANT_FIELD, type AcceptDeps, type Outcome, type SubmitTags, } from "./core/accept.js";
|
|
17
17
|
export { areaServedNodes, breadcrumbNode, businessId, businessNode, faqPageNode, offerNodes, organizationId, organizationNode, placeGraph, websiteId, type OfferInput, type PageGraphCopy, type QuestionAnswer, } from "./core/seo/ld.js";
|
|
18
18
|
export { AI_CRAWLERS, ogImageUrl, robotsFor, sitemapFor, type RobotsFile, type RobotsRule, type SitemapEntry, } from "./core/seo/sitemap.js";
|
|
19
|
-
export { ALLOWED_PROPS, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, type AnalyticsTarget, type IntentChannel, type LeadField, type LeadStep, } from "./core/analytics.js";
|
|
19
|
+
export { ALLOWED_PROPS, ANALYTICS_ID, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, type AnalyticsTarget, type IntentChannel, type LeadField, type LeadStep, } from "./core/analytics.js";
|
|
20
20
|
export type { CallBarText, CopySlice, CoreText, PageMetaCopy, QuoteFormCopy, Said, StatusAction, StatusCopy, StatusScreenText, } from "./core/content.js";
|
|
21
|
+
export { BOOKING_LIMITS, BOOKING_PROVIDERS, BOOKING_STATUSES, BookingConfigError, bookingConfigProblems, BOOKING_EXPERIMENT, bookingHref, bookingOf, bookingRequestedProperties, bookingRequestProblems, calComHostsProblems, DEFAULT_BOOKING, DEFAULT_CAL_COM_HOSTS, isBookingProvider, isLeadRef, isPageProvider, isPreferredPart, parseBookingConfig, parseBookingRequest, PREFERRED_PARTS, type BookingChoice, type BookingConfig, type BookingPrefill, type BookingProvider, type BookingRequest, type BookingRequestedProperties, type BookingRules, type BookingStatus, type OpenBookingConfig, type PageProvider, type PreferredPart, } from "./core/booking/index.js";
|
|
21
22
|
export { brandStatusTarget, statusTarget, thanksChannel, thanksSuffix, type StatusTarget } from "./core/status.js";
|
|
22
23
|
export { createLocaleRegistry, type LocaleRegistry } from "@evinvest/i18n";
|
|
23
24
|
export { contactChannel, ldCompact, localBusiness, telHref, whatsappHref, type ContactChannel, type JsonLdNode, } from "@evinvest/marketing";
|
package/dist/index.js
CHANGED
|
@@ -6,17 +6,18 @@
|
|
|
6
6
|
export { assertLaunchable, bakedPlace, cardFact, contactOf, defineSite, ogLocaleOf, openLaunchBlockers, perLocale, } from "./core/site.js";
|
|
7
7
|
export { createPlaceView, DEFAULT_TIME_ZONE, freshRating, isOpenAt, nextOpening, parseInstant, isPublished, mergeLive, parsePlaceLive, placeHref, placeOrigin, placeUrl, publicationGaps, RATING_MAX_AGE_DAYS, servedLocalities, SERVICE_AREA_GATE, siteOrigin, STOREFRONT_GATE, storefrontOf, } from "./core/place/index.js";
|
|
8
8
|
export { createRouting, GONE, GONE_HEADER, gonePath, goneHeader, HOST_MARK, LANG_COOKIE, LANG_COOKIE_MAX_AGE, NON_PAGE_ROUTES, parseGoneHeader, parsePlaceParam, PASS_PATHS, placeParam, pointSuffixes, THANKS, } from "./core/routing.js";
|
|
9
|
-
export { CARD_FIELD, CARD_ID, CHANNEL_FIELD, channelOf, CONSENT_FIELD, LEAD_CHANNELS, LEAD_ERROR_PARAM, LEAD_SCHEMA_VERSION, MAX_CONSENT, MAX_FIELD, readCandidate, rejectionOf, suspectOf, SUBMISSION_FIELD, SUBMISSION_ID, validateCallbackLead, validateCandidate, validateLead, } from "./core/lead.js";
|
|
9
|
+
export { CARD_FIELD, CARD_ID, CHANNEL_FIELD, channelOf, CONSENT_FIELD, LEAD_CHANNELS, LEAD_ERROR_PARAM, LEAD_CARD_PARAM, leadErrorFor, leadErrorOf, LEAD_SCHEMA_VERSION, MAX_CONSENT, MAX_FIELD, readCandidate, rejectionOf, suspectOf, SUBMISSION_FIELD, SUBMISSION_ID, validateCallbackLead, validateCandidate, validateLead, } from "./core/lead.js";
|
|
10
10
|
export { isMobilePhone, isPlausiblePhone, normalizePhone, phoneProblem } from "./core/phone.js";
|
|
11
11
|
export { CENTS_BUCKETS, centsBucket, ESTIMATE_FIELD_PREFIX, estimateField, flowOf, labelOf, LEAD_FLOWS, mulBp, parsePricingModel, priceOf, PRICING_CURRENCY, PRICING_FORMAT, PRICING_LIMITS, PricingModelError, pricingProblems, pricingProblemsFor, readEstimateInputs, roundTo, } from "./core/pricing/index.js";
|
|
12
12
|
export { flowTextOf, LEAD_CAPTURE_TEXT } from "./core/lead-capture-text.js";
|
|
13
13
|
export { fillText, formatCents, openingText } from "./core/lead-capture-format.js";
|
|
14
14
|
export { channelHref, resolveChannels, smsHref, } from "./core/channels.js";
|
|
15
15
|
export { checkTiming, HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, MIN_FILL_MS, RATE_LIMIT_MAX_KEYS, RateLimiter, RENDERED_AT_FIELD, screen, } from "./core/antispam.js";
|
|
16
|
-
export { createAcceptLead, EXPERIMENT_FIELD, FORM_ID_FIELD, leadRef, LOCALE_FIELD, LOCATION_FIELD, VARIANT_FIELD, } from "./core/accept.js";
|
|
16
|
+
export { createAcceptLead, ANALYTICS_ID_FIELD, EXPERIMENT_FIELD, FORM_ID_FIELD, leadRef, LOCALE_FIELD, LOCATION_FIELD, PRICE_CHANGED, SHOWN_CENTS_FIELD, VARIANT_FIELD, } from "./core/accept.js";
|
|
17
17
|
export { areaServedNodes, breadcrumbNode, businessId, businessNode, faqPageNode, offerNodes, organizationId, organizationNode, placeGraph, websiteId, } from "./core/seo/ld.js";
|
|
18
18
|
export { AI_CRAWLERS, ogImageUrl, robotsFor, sitemapFor, } from "./core/seo/sitemap.js";
|
|
19
|
-
export { ALLOWED_PROPS, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, } from "./core/analytics.js";
|
|
19
|
+
export { ALLOWED_PROPS, ANALYTICS_ID, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, } from "./core/analytics.js";
|
|
20
|
+
export { BOOKING_LIMITS, BOOKING_PROVIDERS, BOOKING_STATUSES, BookingConfigError, bookingConfigProblems, BOOKING_EXPERIMENT, bookingHref, bookingOf, bookingRequestedProperties, bookingRequestProblems, calComHostsProblems, DEFAULT_BOOKING, DEFAULT_CAL_COM_HOSTS, isBookingProvider, isLeadRef, isPageProvider, isPreferredPart, parseBookingConfig, parseBookingRequest, PREFERRED_PARTS, } from "./core/booking/index.js";
|
|
20
21
|
export { brandStatusTarget, statusTarget, thanksChannel, thanksSuffix } from "./core/status.js";
|
|
21
22
|
// A landing needs these beside the machinery; re-exported by name so a brand
|
|
22
23
|
// imports one package and the list here is the whole surface.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { LeadStore } from "../core/lead.js";
|
|
2
|
+
import type { ServerEnv } from "../server/env.js";
|
|
3
|
+
import type { LeadWebhook } from "../server/lead-webhook.js";
|
|
4
|
+
/**
|
|
5
|
+
* `app/quote/booking/route.ts` — the page's word that a priced lead asked for
|
|
6
|
+
* a slot: `booking.requested@1` to the panel, after the lead's `lead.created`,
|
|
7
|
+
* through the same outbox. `LeadCapture` posts it when the visitor opens a
|
|
8
|
+
* booking page (`link`, `cal_com`) or sends a `manual` preference.
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* export const dynamic = "force-dynamic";
|
|
12
|
+
* export const POST = bookingRoute({ env: serverEnv, webhook });
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* The body is JSON: `booking.requested@1`'s properties (`lead_ref`,
|
|
16
|
+
* `provider`, `preferred_date?`, `preferred_part?`) plus `submission`, the
|
|
17
|
+
* form's submission id. The id is what proves the post comes from the page
|
|
18
|
+
* that sent the lead: it is a random UUID only that page knew, and the lead's
|
|
19
|
+
* reference is derived from it — a guessed `lead_ref` alone books nothing.
|
|
20
|
+
* Answers `200 { ok: true, queued }`; `queued` is `false` when the switch is
|
|
21
|
+
* off (`panelBooking`) or the lead already asked.
|
|
22
|
+
*/
|
|
23
|
+
export interface BookingRouteDeps {
|
|
24
|
+
env: () => Pick<ServerEnv, "leadsDb">;
|
|
25
|
+
/** The same webhook the quote route queues `lead.created` on. */
|
|
26
|
+
webhook?: () => LeadWebhook | null;
|
|
27
|
+
store?: () => LeadStore;
|
|
28
|
+
now?: () => number;
|
|
29
|
+
log?: Pick<Console, "warn" | "error">;
|
|
30
|
+
}
|
|
31
|
+
export declare function bookingRoute(deps: BookingRouteDeps): (request: Request) => Promise<Response>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { leadRef } from "../core/accept.js";
|
|
2
|
+
import { bookingRequestProblems, parseBookingRequest } from "../core/booking/validate.js";
|
|
3
|
+
import { openLeadStore } from "../server/lead-store.js";
|
|
4
|
+
const MAX_BODY = 4 * 1024;
|
|
5
|
+
const DAY_MS = 86_400_000;
|
|
6
|
+
/** How far ahead a preferred day may be; a day already past (a day's slack for time zones) is refused. */
|
|
7
|
+
const MAX_AHEAD_DAYS = 366;
|
|
8
|
+
const SUBMISSION = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
9
|
+
function json(status, body) {
|
|
10
|
+
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
|
11
|
+
}
|
|
12
|
+
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
13
|
+
function inWindow(day, now) {
|
|
14
|
+
const at = Date.parse(`${day}T00:00:00Z`);
|
|
15
|
+
return at >= now - 2 * DAY_MS && at <= now + MAX_AHEAD_DAYS * DAY_MS;
|
|
16
|
+
}
|
|
17
|
+
/** The body as text, or `null` past `MAX_BODY` — counted as it arrives, since a chunked body has no length. */
|
|
18
|
+
async function readCapped(request) {
|
|
19
|
+
if (Number(request.headers.get("content-length") ?? 0) > MAX_BODY)
|
|
20
|
+
return null;
|
|
21
|
+
if (!request.body)
|
|
22
|
+
return "";
|
|
23
|
+
const reader = request.body.getReader();
|
|
24
|
+
const chunks = [];
|
|
25
|
+
let size = 0;
|
|
26
|
+
for (;;) {
|
|
27
|
+
const { done, value } = await reader.read();
|
|
28
|
+
if (done)
|
|
29
|
+
break;
|
|
30
|
+
size += value.byteLength;
|
|
31
|
+
if (size > MAX_BODY) {
|
|
32
|
+
await reader.cancel();
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
chunks.push(value);
|
|
36
|
+
}
|
|
37
|
+
const decoder = new TextDecoder();
|
|
38
|
+
return chunks.map(c => decoder.decode(c, { stream: true })).join("") + decoder.decode();
|
|
39
|
+
}
|
|
40
|
+
export function bookingRoute(deps) {
|
|
41
|
+
const log = deps.log ?? console;
|
|
42
|
+
const now = deps.now ?? Date.now;
|
|
43
|
+
let store;
|
|
44
|
+
let webhook;
|
|
45
|
+
return async (request) => {
|
|
46
|
+
// A cross-origin page cannot send this type without a preflight, which
|
|
47
|
+
// this route never answers: the JSON type is the CSRF check.
|
|
48
|
+
if (!(request.headers.get("content-type") ?? "").startsWith("application/json"))
|
|
49
|
+
return json(415, { ok: false });
|
|
50
|
+
const text = await readCapped(request);
|
|
51
|
+
if (text === null)
|
|
52
|
+
return json(413, { ok: false });
|
|
53
|
+
let body;
|
|
54
|
+
try {
|
|
55
|
+
body = JSON.parse(text);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return json(400, { ok: false });
|
|
59
|
+
}
|
|
60
|
+
if (!isObject(body))
|
|
61
|
+
return json(400, { ok: false });
|
|
62
|
+
const { submission, ...properties } = body;
|
|
63
|
+
if (typeof submission !== "string" || !SUBMISSION.test(submission))
|
|
64
|
+
return json(422, { ok: false });
|
|
65
|
+
if (bookingRequestProblems(properties).length > 0)
|
|
66
|
+
return json(422, { ok: false });
|
|
67
|
+
const booking = parseBookingRequest(properties);
|
|
68
|
+
if (booking.preferredDate !== undefined && !inWindow(booking.preferredDate, now()))
|
|
69
|
+
return json(422, { ok: false });
|
|
70
|
+
let found;
|
|
71
|
+
try {
|
|
72
|
+
store ??= deps.store ? deps.store() : openLeadStore(deps.env().leadsDb);
|
|
73
|
+
if (!store.findSubmission)
|
|
74
|
+
return json(501, { ok: false });
|
|
75
|
+
found = await store.findSubmission(submission);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
log.error("booking: the lead store could not be read", error);
|
|
79
|
+
return json(503, { ok: false });
|
|
80
|
+
}
|
|
81
|
+
if (!found || leadRef(found.id, submission) !== booking.leadRef)
|
|
82
|
+
return json(404, { ok: false });
|
|
83
|
+
// Booking follows a priced lead; a quote's slot is set on the call.
|
|
84
|
+
if (found.lead.flow !== "estimate" && found.lead.flow !== "fixed")
|
|
85
|
+
return json(409, { ok: false });
|
|
86
|
+
if (webhook === undefined) {
|
|
87
|
+
webhook = deps.webhook?.() ?? null;
|
|
88
|
+
webhook?.start();
|
|
89
|
+
}
|
|
90
|
+
// Sent by the outbox's own timer, like a lead's retry: the visitor waits for nothing.
|
|
91
|
+
const queued = webhook?.requestBooking?.(booking) ?? { kind: "off" };
|
|
92
|
+
// The lead stays in the leads file for a person; the panel never heard of it.
|
|
93
|
+
if (queued.kind === "unqueued")
|
|
94
|
+
log.warn(`booking: lead ${found.id} asked for a ${booking.provider} slot but was never sent to the panel; not queued`);
|
|
95
|
+
return json(200, { ok: true, queued: queued.kind === "queued" });
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type LeadCaptureText } from "../core/lead-capture-text.js";
|
|
2
|
+
import { type Site } from "../core/site.js";
|
|
3
|
+
import type { PricingSource } from "../server/pricing-source.js";
|
|
4
|
+
/**
|
|
5
|
+
* `app/quote/confirm/route.ts` — where a form posted without a script lands
|
|
6
|
+
* when the price it showed has changed (`price_changed`). Its page may be a
|
|
7
|
+
* cached render (ISR) that still shows the old price, so sending it back there
|
|
8
|
+
* would refuse every resubmit; this page is never cached. It prices the
|
|
9
|
+
* answers afresh, says the price changed, and asks to confirm: a form posting
|
|
10
|
+
* to `/quote` with the fresh price as `shown_cents`.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* export const dynamic = "force-dynamic";
|
|
14
|
+
* export const GET = confirmRoute(site, { pricing });
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* The URL carries only what the visitor chose — the need, the answers, the
|
|
18
|
+
* page's ids and the price they saw — never what they typed: the phone and
|
|
19
|
+
* the postcode are asked again. A brand's extra fields are not carried.
|
|
20
|
+
*/
|
|
21
|
+
export interface ConfirmRouteDeps<L extends string> {
|
|
22
|
+
/** The price list the quote route prices with; absent → `site.pricing`. */
|
|
23
|
+
pricing?: PricingSource;
|
|
24
|
+
/** The form's words; the kit's in the page's language by default. */
|
|
25
|
+
text?: (locale: L) => LeadCaptureText;
|
|
26
|
+
/** The quote route's path; `/quote`. */
|
|
27
|
+
action?: string;
|
|
28
|
+
now?: () => number;
|
|
29
|
+
}
|
|
30
|
+
export declare const CONFIRM_PATH = "/quote/confirm";
|
|
31
|
+
/** The price the visitor saw, in the confirmation's query. */
|
|
32
|
+
export declare const SHOWN_PARAM = "shown";
|
|
33
|
+
/**
|
|
34
|
+
* The confirmation's query for a refused form: the fields the visitor chose
|
|
35
|
+
* and the page's own ids, slugs only, and the price shown. Used by the quote
|
|
36
|
+
* route; nothing typed goes in it.
|
|
37
|
+
*/
|
|
38
|
+
export declare function confirmQuery(site: Pick<Site<string, string>, "lead">, form: FormData): URLSearchParams;
|
|
39
|
+
export declare function confirmRoute<L extends string, P extends string>(site: Site<L, P>, deps?: ConfirmRouteDeps<L>): (request: Request) => Promise<Response>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { EXPERIMENT_FIELD, FORM_ID_FIELD, LOCALE_FIELD, LOCATION_FIELD, SHOWN_CENTS_FIELD, VARIANT_FIELD } from "../core/accept.js";
|
|
2
|
+
import { HONEYPOT_FIELD, RENDERED_AT_FIELD } from "../core/antispam.js";
|
|
3
|
+
import { CARD_FIELD, CARD_ID, SUBMISSION_FIELD, SUBMISSION_ID } from "../core/lead.js";
|
|
4
|
+
import { fillText, formatCents } from "../core/lead-capture-format.js";
|
|
5
|
+
import { flowTextOf, LEAD_CAPTURE_TEXT } from "../core/lead-capture-text.js";
|
|
6
|
+
import { createPlaceView } from "../core/place/view.js";
|
|
7
|
+
import { estimateField, flowOf, readEstimateInputs } from "../core/pricing/flow.js";
|
|
8
|
+
import { PRICING_LIMITS } from "../core/pricing/model.js";
|
|
9
|
+
import { priceOf } from "../core/pricing/price.js";
|
|
10
|
+
import { createRouting } from "../core/routing.js";
|
|
11
|
+
import { bakedPlace } from "../core/site.js";
|
|
12
|
+
export const CONFIRM_PATH = "/quote/confirm";
|
|
13
|
+
/** The price the visitor saw, in the confirmation's query. */
|
|
14
|
+
export const SHOWN_PARAM = "shown";
|
|
15
|
+
const escape = (s) => s.replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c);
|
|
16
|
+
const SLUG = /^[a-z0-9_-]{1,40}$/;
|
|
17
|
+
const CENTS = /^\d{1,12}$/;
|
|
18
|
+
/**
|
|
19
|
+
* The confirmation's query for a refused form: the fields the visitor chose
|
|
20
|
+
* and the page's own ids, slugs only, and the price shown. Used by the quote
|
|
21
|
+
* route; nothing typed goes in it.
|
|
22
|
+
*/
|
|
23
|
+
export function confirmQuery(site, form) {
|
|
24
|
+
const query = new URLSearchParams();
|
|
25
|
+
const keep = (name, test) => {
|
|
26
|
+
const value = form.get(name);
|
|
27
|
+
if (typeof value === "string" && test.test(value))
|
|
28
|
+
query.set(name, value);
|
|
29
|
+
};
|
|
30
|
+
for (const name of [LOCALE_FIELD, LOCATION_FIELD, FORM_ID_FIELD, EXPERIMENT_FIELD, VARIANT_FIELD])
|
|
31
|
+
keep(name, SLUG);
|
|
32
|
+
keep(CARD_FIELD, CARD_ID);
|
|
33
|
+
keep(SUBMISSION_FIELD, SUBMISSION_ID);
|
|
34
|
+
const subject = form.get(site.lead.wire.subject);
|
|
35
|
+
if (typeof subject === "string" && site.lead.subjects.includes(subject))
|
|
36
|
+
query.set(site.lead.wire.subject, subject);
|
|
37
|
+
for (const [name, value] of form) {
|
|
38
|
+
if (name.startsWith("estimate_") && typeof value === "string" && PRICING_LIMITS.slug.test(value) && SLUG.test(name.slice("estimate_".length)))
|
|
39
|
+
query.set(name, value);
|
|
40
|
+
}
|
|
41
|
+
const shown = form.get(SHOWN_CENTS_FIELD);
|
|
42
|
+
if (typeof shown === "string" && CENTS.test(shown))
|
|
43
|
+
query.set(SHOWN_PARAM, shown);
|
|
44
|
+
return query;
|
|
45
|
+
}
|
|
46
|
+
function page(lang, title, body) {
|
|
47
|
+
const html = `<!doctype html><html lang="${escape(lang)}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex"><title>${escape(title)}</title></head><body style="font-family:system-ui,sans-serif;max-width:32rem;margin:2rem auto;padding:0 1rem;line-height:1.5">${body}</body></html>`;
|
|
48
|
+
return new Response(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "X-Robots-Tag": "noindex" } });
|
|
49
|
+
}
|
|
50
|
+
export function confirmRoute(site, deps = {}) {
|
|
51
|
+
const routing = createRouting(site);
|
|
52
|
+
const action = deps.action ?? "/quote";
|
|
53
|
+
const now = deps.now ?? Date.now;
|
|
54
|
+
return async (request) => {
|
|
55
|
+
const url = new URL(request.url);
|
|
56
|
+
const q = (name) => url.searchParams.get(name);
|
|
57
|
+
const rawLocale = q(LOCALE_FIELD);
|
|
58
|
+
const locale = site.i18n.isLocale(rawLocale) ? rawLocale : site.i18n.defaultLocale;
|
|
59
|
+
const rawSlug = q(LOCATION_FIELD);
|
|
60
|
+
const place = rawSlug ? bakedPlace(site, rawSlug) : undefined;
|
|
61
|
+
const card = q(CARD_FIELD);
|
|
62
|
+
const anchor = card !== null && CARD_ID.test(card) ? card : "quote";
|
|
63
|
+
const host = request.headers.get("host") ?? url.host;
|
|
64
|
+
const back = () => {
|
|
65
|
+
const mode = place && routing.hostSlug(host) === place.slug ? "host" : "path";
|
|
66
|
+
const location = place ? createPlaceView(site, place, locale, mode).href(`#${anchor}`) : `/${locale}#${anchor}`;
|
|
67
|
+
return new Response(null, { status: 303, headers: { Location: location, "Cache-Control": "no-store" } });
|
|
68
|
+
};
|
|
69
|
+
const need = q(site.lead.wire.subject);
|
|
70
|
+
if (need === null)
|
|
71
|
+
return back();
|
|
72
|
+
let model;
|
|
73
|
+
try {
|
|
74
|
+
model = deps.pricing ? await deps.pricing.model() : (site.pricing ?? null);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
model = null;
|
|
78
|
+
}
|
|
79
|
+
const flow = flowOf(site.lead.flows, model, need);
|
|
80
|
+
if (flow === "quote" || !model)
|
|
81
|
+
return back();
|
|
82
|
+
const inputs = flow === "estimate" ? readEstimateInputs(model, need, name => q(name)) : {};
|
|
83
|
+
const price = priceOf(model, need, inputs);
|
|
84
|
+
if (!price)
|
|
85
|
+
return back();
|
|
86
|
+
const kit = locale.startsWith("en") ? LEAD_CAPTURE_TEXT.en : LEAD_CAPTURE_TEXT.fr;
|
|
87
|
+
const base = deps.text?.(locale) ?? kit;
|
|
88
|
+
const flowText = flowTextOf(base, locale);
|
|
89
|
+
const fresh = formatCents(price.cents, locale);
|
|
90
|
+
const shown = q(SHOWN_PARAM);
|
|
91
|
+
const said = shown !== null && CENTS.test(shown) ? fillText(flowText.priceChanged, { price: fresh, shown: formatCents(Number(shown), locale) }) : flowText.priceChangedGeneric;
|
|
92
|
+
const hidden = [
|
|
93
|
+
[site.lead.wire.subject, need],
|
|
94
|
+
...Object.entries(inputs).map(([input, option]) => [estimateField(input), option]),
|
|
95
|
+
[SHOWN_CENTS_FIELD, String(price.cents)],
|
|
96
|
+
[RENDERED_AT_FIELD, String(now())],
|
|
97
|
+
];
|
|
98
|
+
for (const name of [LOCALE_FIELD, LOCATION_FIELD, FORM_ID_FIELD, EXPERIMENT_FIELD, VARIANT_FIELD, CARD_FIELD, SUBMISSION_FIELD]) {
|
|
99
|
+
const value = q(name);
|
|
100
|
+
if (value !== null)
|
|
101
|
+
hidden.push([name, value]);
|
|
102
|
+
}
|
|
103
|
+
const field = (name, label, type, autocomplete) => `<p><label for="${escape(name)}">${escape(label)}</label><br><input id="${escape(name)}" name="${escape(name)}" type="${type}" autocomplete="${autocomplete}" required style="font-size:1rem;padding:.5rem;width:100%"></p>`;
|
|
104
|
+
const body = [
|
|
105
|
+
`<h1>${escape(flowText.priceTitle)} : ${escape(fresh)}</h1>`,
|
|
106
|
+
`<p role="alert">${escape(said)}</p>`,
|
|
107
|
+
`<p>${escape(flowText.priceNote)}</p>`,
|
|
108
|
+
`<form method="post" action="${escape(action)}">`,
|
|
109
|
+
...hidden.map(([name, value]) => `<input type="hidden" name="${escape(name)}" value="${escape(value)}">`),
|
|
110
|
+
`<input type="text" name="${HONEYPOT_FIELD}" tabindex="-1" autocomplete="off" value="" aria-hidden="true" style="position:absolute;left:-9999px">`,
|
|
111
|
+
field(site.lead.wire.locality, base.localityLabel, "text", "postal-code"),
|
|
112
|
+
field(site.lead.wire.mobile, base.phoneLabel, "tel", "tel"),
|
|
113
|
+
`<p><button type="submit" style="font-size:1rem;padding:.75rem 1.5rem">${escape(flowText.bookSubmit)}</button></p>`,
|
|
114
|
+
"</form>",
|
|
115
|
+
].join("");
|
|
116
|
+
return page(site.i18n.hreflangOf(locale), flowText.priceTitle, body);
|
|
117
|
+
};
|
|
118
|
+
}
|
package/dist/next/index.d.ts
CHANGED
|
@@ -10,3 +10,5 @@ export { healthRoute, robotsRoute, sitemapRoute } from "./routes.js";
|
|
|
10
10
|
export { ogRoute, type OgCard, type OgFont, type OgOptions } from "./og.js";
|
|
11
11
|
export { ogPalette, type OgColours } from "./og-palette.js";
|
|
12
12
|
export { quoteRoute, type QuoteRouteDeps, type UnavailableCopy } from "./quote-route.js";
|
|
13
|
+
export { bookingRoute, type BookingRouteDeps } from "./booking-route.js";
|
|
14
|
+
export { CONFIRM_PATH, confirmQuery, confirmRoute, SHOWN_PARAM, type ConfirmRouteDeps } from "./confirm-route.js";
|
package/dist/next/index.js
CHANGED
|
@@ -10,3 +10,5 @@ export { healthRoute, robotsRoute, sitemapRoute } from "./routes.js";
|
|
|
10
10
|
export { ogRoute } from "./og.js";
|
|
11
11
|
export { ogPalette } from "./og-palette.js";
|
|
12
12
|
export { quoteRoute } from "./quote-route.js";
|
|
13
|
+
export { bookingRoute } from "./booking-route.js";
|
|
14
|
+
export { CONFIRM_PATH, confirmQuery, confirmRoute, SHOWN_PARAM } from "./confirm-route.js";
|
package/dist/next/quote-route.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { after } from "next/server.js";
|
|
2
|
-
import {
|
|
2
|
+
import { CONFIRM_PATH, confirmQuery } from "./confirm-route.js";
|
|
3
|
+
import { createAcceptLead, EXPERIMENT_FIELD, PRICE_CHANGED, VARIANT_FIELD } from "../core/accept.js";
|
|
3
4
|
import { analyticsSink, EVENTS, experimentProps } from "../core/analytics.js";
|
|
4
5
|
import { RateLimiter } from "../core/antispam.js";
|
|
5
|
-
import { CARD_FIELD, CARD_ID, channelOf, LEAD_ERROR_PARAM } from "../core/lead.js";
|
|
6
|
+
import { CARD_FIELD, CARD_ID, channelOf, LEAD_CARD_PARAM, LEAD_ERROR_PARAM } from "../core/lead.js";
|
|
6
7
|
import { createPlaceView } from "../core/place/view.js";
|
|
7
8
|
import { createRouting } from "../core/routing.js";
|
|
8
9
|
import { bakedPlace, contactOf } from "../core/site.js";
|
|
@@ -115,7 +116,10 @@ export function quoteRoute(site, deps) {
|
|
|
115
116
|
query.set("need", subject);
|
|
116
117
|
const posted = form.get(CARD_FIELD);
|
|
117
118
|
const card = typeof posted === "string" && CARD_ID.test(posted) ? posted : anchor;
|
|
118
|
-
|
|
119
|
+
const target = `${card}${channel === "callback" ? "-callback" : ""}`;
|
|
120
|
+
// The fragment never reaches the server: the query names the card too.
|
|
121
|
+
query.set(LEAD_CARD_PARAM, target);
|
|
122
|
+
return `?${query}#${target}`;
|
|
119
123
|
}
|
|
120
124
|
/** Self-contained: the thing that failed may be the thing that renders pages. */
|
|
121
125
|
function unavailable(slug, locale) {
|
|
@@ -189,13 +193,18 @@ export function quoteRoute(site, deps) {
|
|
|
189
193
|
case "invalid": {
|
|
190
194
|
const tags = experimentProps(text(form, EXPERIMENT_FIELD), text(form, VARIANT_FIELD));
|
|
191
195
|
// The field's role and why — never what was typed.
|
|
192
|
-
const
|
|
196
|
+
const repriced = outcome.field === PRICE_CHANGED;
|
|
197
|
+
const props = { form_id: outcome.formId, channel: outcome.channel, field: outcome.field, reason: repriced ? PRICE_CHANGED : "invalid", ...tags };
|
|
193
198
|
if (env.posthogKey) {
|
|
194
199
|
const sink = analyticsSink({ key: env.posthogKey, host: env.posthogHost, brandId: site.brand.id }, outcome.slug);
|
|
195
200
|
defer(() => sink.capture(EVENTS.formReject, props));
|
|
196
201
|
}
|
|
197
202
|
if (scripted)
|
|
198
|
-
return json(422, { ok: false, field: outcome.field });
|
|
203
|
+
return json(422, { ok: false, field: outcome.field, ...(repriced && outcome.cents !== undefined ? { reason: PRICE_CHANGED, cents: outcome.cents } : {}) });
|
|
204
|
+
// The card's page may be a cached render still showing the old price:
|
|
205
|
+
// a plain post confirms on a page that is never cached instead.
|
|
206
|
+
if (repriced)
|
|
207
|
+
return seeOther(`${CONFIRM_PATH}?${confirmQuery(site, form)}`);
|
|
199
208
|
return seeOther(href(request, outcome.slug, outcome.locale, refused(form, outcome.field, outcome.channel)));
|
|
200
209
|
}
|
|
201
210
|
case "failed":
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { ContactLinkTracker } from "@evinvest/marketing/tracker";
|
|
4
4
|
import { usePathname } from "next/navigation.js";
|
|
5
|
-
import { useEffect, useMemo } from "react";
|
|
5
|
+
import { useEffect, useMemo, useState } from "react";
|
|
6
6
|
import { analyticsSink, countsAsPageView, EVENTS, experimentProps } from "../core/analytics.js";
|
|
7
|
-
import { AnalyticsSinkContext, experimentOf } from "./analytics-context.js";
|
|
7
|
+
import { AnalyticsIdContext, AnalyticsSinkContext, experimentOf } from "./analytics-context.js";
|
|
8
|
+
import { newSubmissionId } from "./use-lead-submit.js";
|
|
8
9
|
/**
|
|
9
10
|
* `data-intent` on a link or button marks a conversion intent that is not a
|
|
10
11
|
* contact link — or one the tracker cannot classify (`sms:`).
|
|
@@ -35,7 +36,10 @@ function source() {
|
|
|
35
36
|
export function AnalyticsBoundary({ target, placeSlug, children }) {
|
|
36
37
|
// On the target's values: a server layout hands a fresh object every render.
|
|
37
38
|
const { key, host, brandId } = target;
|
|
38
|
-
|
|
39
|
+
// One visitor for the boundary's life, in memory only: a sink rebuilt for
|
|
40
|
+
// another place keeps it, and a lead posted from the page can name it.
|
|
41
|
+
const [distinctId] = useState(newSubmissionId);
|
|
42
|
+
const sink = useMemo(() => analyticsSink({ key, host, brandId }, placeSlug, distinctId), [key, host, brandId, placeSlug, distinctId]);
|
|
39
43
|
const pathname = usePathname();
|
|
40
44
|
useEffect(() => {
|
|
41
45
|
if (!countsAsPageView(pathname))
|
|
@@ -57,5 +61,5 @@ export function AnalyticsBoundary({ target, placeSlug, children }) {
|
|
|
57
61
|
}, [sink]);
|
|
58
62
|
// A link inside an experiment's island (`LeadCapture`) carries the
|
|
59
63
|
// assignment on itself, which is what the tracker reads.
|
|
60
|
-
return (_jsx(AnalyticsSinkContext.Provider, { value: sink, children: _jsx(ContactLinkTracker, { channels: ["phone", "whatsapp"], onContact: ({ channel, data }) => sink.capture(EVENTS.intent, { channel, ...experimentProps(data["experiment"], data["variant"]) }, { transport: "beacon" }), children: children }) }));
|
|
64
|
+
return (_jsx(AnalyticsIdContext.Provider, { value: key ? distinctId : null, children: _jsx(AnalyticsSinkContext.Provider, { value: sink, children: _jsx(ContactLinkTracker, { channels: ["phone", "whatsapp"], onContact: ({ channel, data }) => sink.capture(EVENTS.intent, { channel, ...experimentProps(data["experiment"], data["variant"]) }, { transport: "beacon" }), children: children }) }) }));
|
|
61
65
|
}
|