@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.
Files changed (84) hide show
  1. package/README.md +210 -17
  2. package/dist/core/accept.d.ts +19 -1
  3. package/dist/core/accept.js +25 -2
  4. package/dist/core/analytics.d.ts +13 -3
  5. package/dist/core/analytics.js +17 -2
  6. package/dist/core/booking/index.d.ts +3 -0
  7. package/dist/core/booking/index.js +3 -0
  8. package/dist/core/booking/model.d.ts +106 -0
  9. package/dist/core/booking/model.js +66 -0
  10. package/dist/core/booking/url.d.ts +17 -0
  11. package/dist/core/booking/url.js +53 -0
  12. package/dist/core/booking/validate.d.ts +27 -0
  13. package/dist/core/booking/validate.js +219 -0
  14. package/dist/core/lead-capture-text.d.ts +26 -2
  15. package/dist/core/lead-capture-text.js +32 -2
  16. package/dist/core/lead.d.ts +33 -0
  17. package/dist/core/lead.js +43 -0
  18. package/dist/core/phone.d.ts +3 -1
  19. package/dist/core/phone.js +28 -2
  20. package/dist/core/place/live.d.ts +6 -2
  21. package/dist/core/place/live.js +21 -2
  22. package/dist/core/place/types.d.ts +6 -0
  23. package/dist/core/place/types.js +0 -13
  24. package/dist/core/routing.d.ts +3 -2
  25. package/dist/core/routing.js +4 -3
  26. package/dist/core/site.d.ts +8 -0
  27. package/dist/core/site.js +15 -0
  28. package/dist/index.d.ts +4 -3
  29. package/dist/index.js +4 -3
  30. package/dist/next/booking-route.d.ts +31 -0
  31. package/dist/next/booking-route.js +97 -0
  32. package/dist/next/confirm-route.d.ts +39 -0
  33. package/dist/next/confirm-route.js +118 -0
  34. package/dist/next/index.d.ts +2 -0
  35. package/dist/next/index.js +2 -0
  36. package/dist/next/quote-route.js +14 -5
  37. package/dist/react/AnalyticsBoundary.js +8 -4
  38. package/dist/react/LeadBooking.d.ts +35 -0
  39. package/dist/react/LeadBooking.js +47 -0
  40. package/dist/react/LeadBookingManual.d.ts +17 -0
  41. package/dist/react/LeadBookingManual.js +50 -0
  42. package/dist/react/LeadCapture.d.ts +28 -5
  43. package/dist/react/LeadCapture.js +22 -8
  44. package/dist/react/LeadCaptureFields.js +60 -21
  45. package/dist/react/LeadCapturePriced.d.ts +3 -4
  46. package/dist/react/LeadCapturePriced.js +4 -4
  47. package/dist/react/analytics-context.d.ts +7 -0
  48. package/dist/react/analytics-context.js +7 -0
  49. package/dist/react/booking-adapters.d.ts +58 -0
  50. package/dist/react/booking-adapters.js +104 -0
  51. package/dist/react/index.d.ts +5 -1
  52. package/dist/react/index.js +4 -1
  53. package/dist/react/use-booking.d.ts +20 -0
  54. package/dist/react/use-booking.js +32 -0
  55. package/dist/react/use-lead-error.d.ts +3 -7
  56. package/dist/react/use-lead-error.js +9 -6
  57. package/dist/react/use-lead-submit.d.ts +7 -1
  58. package/dist/react/use-lead-submit.js +13 -3
  59. package/dist/react/use-repriced.d.ts +18 -0
  60. package/dist/react/use-repriced.js +27 -0
  61. package/dist/server/experiments-declared.d.ts +106 -0
  62. package/dist/server/experiments-declared.js +124 -0
  63. package/dist/server/experiments-source.d.ts +49 -0
  64. package/dist/server/experiments-source.js +88 -0
  65. package/dist/server/index.d.ts +4 -2
  66. package/dist/server/index.js +2 -0
  67. package/dist/server/lead-webhook.d.ts +63 -0
  68. package/dist/server/lead-webhook.js +24 -1
  69. package/dist/server/notify.d.ts +20 -2
  70. package/dist/server/notify.js +6 -4
  71. package/dist/server/place-source.js +1 -1
  72. package/dist/server/webhook-outbox.d.ts +11 -1
  73. package/dist/server/webhook-outbox.js +32 -5
  74. package/dist/testing/lead-rules.js +15 -1
  75. package/package.json +1 -1
  76. package/template/app/quote/booking/route.ts +11 -0
  77. package/template/app/quote/confirm/route.ts +12 -0
  78. package/template/flake.nix +1 -1
  79. package/template/package.json +2 -2
  80. package/template/src/shared/config/places.ts +4 -0
  81. package/template/src/shared/config/site.ts +3 -0
  82. package/template/tests/e2e/flows.spec.ts +86 -9
  83. package/template/tests/e2e/mock-panel.mjs +18 -4
  84. 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
@@ -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>, {
@@ -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;
@@ -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, the OG card, the probe, the sitemap and robots. Anything else
70
- * without a language but `/_next/…` and the site's `publicFiles` is `gone`.
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. */
@@ -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, the OG card, the probe, the sitemap and robots. Anything else
74
- * without a language but `/_next/…` and the site's `publicFiles` is `gone`.
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) {
@@ -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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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
+ }
@@ -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";
@@ -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";
@@ -1,8 +1,9 @@
1
1
  import { after } from "next/server.js";
2
- import { createAcceptLead, EXPERIMENT_FIELD, VARIANT_FIELD } from "../core/accept.js";
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
- return `?${query}#${card}${channel === "callback" ? "-callback" : ""}`;
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 props = { form_id: outcome.formId, channel: outcome.channel, field: outcome.field, reason: "invalid", ...tags };
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
- const sink = useMemo(() => analyticsSink({ key, host, brandId }, placeSlug), [key, host, brandId, placeSlug]);
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
  }