@evinvest/kitstart 0.17.0 → 0.18.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 +147 -10
- package/dist/core/accept.d.ts +14 -0
- package/dist/core/accept.js +21 -3
- package/dist/core/analytics.d.ts +11 -2
- package/dist/core/analytics.js +22 -0
- package/dist/core/channels.d.ts +12 -4
- package/dist/core/channels.js +11 -1
- package/dist/core/lead-capture-text.d.ts +6 -2
- package/dist/core/lead.d.ts +33 -10
- package/dist/core/lead.js +21 -8
- package/dist/core/messenger-message.d.ts +31 -0
- package/dist/core/messenger-message.js +33 -0
- package/dist/core/messenger-text.d.ts +104 -0
- package/dist/core/messenger-text.js +150 -0
- package/dist/core/messenger.d.ts +92 -0
- package/dist/core/messenger.js +55 -0
- package/dist/core/place/index.d.ts +1 -1
- package/dist/core/place/live.d.ts +5 -1
- package/dist/core/place/live.js +22 -1
- package/dist/core/place/types.d.ts +17 -1
- package/dist/core/site.d.ts +8 -1
- package/dist/core/site.js +22 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.js +7 -4
- package/dist/next/quote-route.js +4 -7
- package/dist/react/AnalyticsBoundary.js +3 -3
- package/dist/react/LeadCapture.d.ts +24 -1
- package/dist/react/LeadCapture.js +96 -11
- package/dist/react/LeadCaptureChannels.d.ts +7 -2
- package/dist/react/LeadCaptureChannels.js +2 -2
- package/dist/react/LeadCaptureEstimate.d.ts +6 -0
- package/dist/react/LeadCaptureFields.d.ts +17 -0
- package/dist/react/LeadCaptureFields.js +42 -33
- package/dist/react/LeadCaptureMessenger.d.ts +41 -0
- package/dist/react/LeadCaptureMessenger.js +133 -0
- package/dist/react/LeadCapturePrice.d.ts +9 -3
- package/dist/react/LeadCapturePrice.js +24 -4
- package/dist/react/LeadCaptureSubmit.d.ts +3 -0
- package/dist/react/LeadCaptureSubmit.js +2 -2
- package/dist/react/LeadCaptureTiles.js +9 -4
- package/dist/react/analytics-context.d.ts +9 -3
- package/dist/react/analytics-context.js +7 -3
- package/dist/react/index.d.ts +1 -0
- package/dist/react/messenger/action.d.ts +144 -0
- package/dist/react/messenger/action.js +301 -0
- package/dist/react/messenger/chip.d.ts +8 -0
- package/dist/react/messenger/chip.js +22 -0
- package/dist/react/messenger/fallback.d.ts +8 -0
- package/dist/react/messenger/fallback.js +11 -0
- package/dist/react/messenger/saga.d.ts +10 -0
- package/dist/react/messenger/saga.js +33 -0
- package/dist/react/messenger/segment.d.ts +8 -0
- package/dist/react/messenger/segment.js +15 -0
- package/dist/react/messenger/select.d.ts +10 -0
- package/dist/react/messenger/select.js +26 -0
- package/dist/react/messenger/sheet.d.ts +10 -0
- package/dist/react/messenger/sheet.js +57 -0
- package/dist/react/messenger/split.d.ts +9 -0
- package/dist/react/messenger/split.js +17 -0
- package/dist/react/messenger/swap.d.ts +10 -0
- package/dist/react/messenger/swap.js +18 -0
- package/dist/react/messenger/thanks.d.ts +10 -0
- package/dist/react/messenger/thanks.js +18 -0
- package/dist/react/messenger/tiles.d.ts +8 -0
- package/dist/react/messenger/tiles.js +20 -0
- package/dist/react/messenger/types.d.ts +98 -0
- package/dist/react/messenger/types.js +1 -0
- package/dist/react/messenger/urgency.d.ts +10 -0
- package/dist/react/messenger/urgency.js +29 -0
- package/dist/react/use-lead-error.d.ts +3 -3
- package/dist/react/use-lead-events.d.ts +21 -1
- package/dist/react/use-lead-events.js +9 -3
- package/dist/react/use-lead-submit.d.ts +20 -6
- package/dist/react/use-lead-submit.js +23 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/lead-store-sqlite.js +11 -5
- package/dist/server/lead-webhook.d.ts +30 -5
- package/dist/server/lead-webhook.js +13 -7
- package/dist/server/notify.js +11 -5
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +1 -1
- package/dist/testing/landing-contract.js +6 -1
- package/dist/testing/lead-rules.d.ts +8 -0
- package/dist/testing/lead-rules.js +30 -1
- package/package.json +5 -2
- package/template/flake.nix +1 -1
- package/template/package.json +2 -2
package/dist/core/site.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { LocaleRegistry } from "@evinvest/i18n";
|
|
2
2
|
import type { BookingRules } from "./booking/model.js";
|
|
3
3
|
import type { LeadSchema } from "./lead.js";
|
|
4
|
+
import { type MessengerFacts } from "./messenger.js";
|
|
4
5
|
import type { PublicationPolicy } from "./place/publication.js";
|
|
5
6
|
import type { Place } from "./place/types.js";
|
|
6
7
|
import type { PricingModel } from "./pricing/model.js";
|
|
@@ -126,8 +127,14 @@ export declare function assertLaunchable(site: {
|
|
|
126
127
|
}, todos: readonly OwnerTodo[]): void;
|
|
127
128
|
/** The baked place for a slug, if the site has it. */
|
|
128
129
|
export declare function bakedPlace<L extends string, P extends string>(site: Site<L, P>, slug: string): Place<L> | undefined;
|
|
129
|
-
/**
|
|
130
|
+
/**
|
|
131
|
+
* The numbers a place answers on: its own, or the brand's until it has one.
|
|
132
|
+
* The panel's messenger switches (`place.messengers`) do not reach here — they
|
|
133
|
+
* are the lead form's (`messengerFacts`); the header, the call bar and the
|
|
134
|
+
* control's channels keep the number.
|
|
135
|
+
*/
|
|
130
136
|
export declare function contactOf(site: Pick<SiteConfig<string, string>, "brand">, place: Place<string>): {
|
|
131
137
|
phone: string | null;
|
|
132
138
|
whatsapp: string | null;
|
|
133
139
|
};
|
|
140
|
+
export declare function messengerFacts(_site: Pick<SiteConfig<string, string>, "brand">, place: Place<string>): MessengerFacts;
|
package/dist/core/site.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { bookingConfigProblems, calComHostsProblems } from "./booking/validate.js";
|
|
2
|
+
import { TELEGRAM_BOT } from "./messenger.js";
|
|
2
3
|
import { pricingProblemsFor } from "./pricing/validate.js";
|
|
3
4
|
export function defineSite(config) {
|
|
4
5
|
const pageKeys = Object.keys(config.pages).filter((k) => Object.hasOwn(config.pages, k));
|
|
@@ -101,10 +102,30 @@ export function assertLaunchable(site, todos) {
|
|
|
101
102
|
export function bakedPlace(site, slug) {
|
|
102
103
|
return site.places.find(p => p.slug === slug);
|
|
103
104
|
}
|
|
104
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* The numbers a place answers on: its own, or the brand's until it has one.
|
|
107
|
+
* The panel's messenger switches (`place.messengers`) do not reach here — they
|
|
108
|
+
* are the lead form's (`messengerFacts`); the header, the call bar and the
|
|
109
|
+
* control's channels keep the number.
|
|
110
|
+
*/
|
|
105
111
|
export function contactOf(site, place) {
|
|
106
112
|
return {
|
|
107
113
|
phone: place.channels.phone ?? site.brand.phone,
|
|
108
114
|
whatsapp: place.channels.whatsapp ?? site.brand.phone,
|
|
109
115
|
};
|
|
110
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* What a `LeadCapture` `messenger` variant may offer (`messengers`): the
|
|
119
|
+
* place's own WhatsApp number — never the brand's phone, the chat is answered
|
|
120
|
+
* by hand at the place — and its bot, each unless the panel switched it off.
|
|
121
|
+
* A baked bot whose name is not one (`TELEGRAM_BOT`) is none, as a live one is.
|
|
122
|
+
* Not a fallback for `contactOf`: the control's WhatsApp link keeps the brand's.
|
|
123
|
+
*/
|
|
124
|
+
const validBot = (bot) => (bot && TELEGRAM_BOT.test(bot) ? bot : null);
|
|
125
|
+
export function messengerFacts(_site, place) {
|
|
126
|
+
const on = place.messengers ?? {};
|
|
127
|
+
return {
|
|
128
|
+
whatsapp: on.whatsapp === false ? null : place.channels.whatsapp,
|
|
129
|
+
telegram: on.telegram === false ? null : validBot(place.channels.telegram),
|
|
130
|
+
};
|
|
131
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,20 +3,23 @@
|
|
|
3
3
|
* the proxy (edge), a client island and a server module can all import it.
|
|
4
4
|
* Runtime-bound halves live on their own subpaths.
|
|
5
5
|
*/
|
|
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
|
-
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";
|
|
6
|
+
export { assertLaunchable, bakedPlace, cardFact, contactOf, defineSite, messengerFacts, ogLocaleOf, openLaunchBlockers, perLocale, type BrandFacts, type LegacyRedirect, type OwnerTodo, type PageSuffix, type Site, type SiteConfig, type Topology, } from "./core/site.js";
|
|
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 MessengerSwitches, 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_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";
|
|
9
|
+
export { CARD_FIELD, CARD_ID, CHANNEL_FIELD, channelOf, CONSENT_FIELD, isMessengerChannel, 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 MessengerChannel, 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 { answeredUnknown, CENTS_BUCKETS, centsBucket, ESTIMATE_FIELD_PREFIX, ESTIMATE_UNKNOWN, 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
|
+
export { LEAD_CAPTURE_MESSENGER_TEXT, messengerTextOf, type LeadCaptureMessengerText } from "./core/messenger-text.js";
|
|
14
|
+
export { channelsAvailable, MESSAGE_REF, MESSAGE_REF_FIELD, MESSAGE_REF_PREFIX, messageRefOf, newMessageRef, TELEGRAM_BOT, telegramHref, type ChannelsAvailable, type MessengerFacts, type MessengerKind, type MessengerMode, type MessengerVariant, type RandomBytes, } from "./core/messenger.js";
|
|
15
|
+
export { MAX_MESSENGER_MESSAGE, messengerMessage, type MessengerMessageInput, type MessengerMessageText } from "./core/messenger-message.js";
|
|
13
16
|
export { fillText, formatCents, openingText } from "./core/lead-capture-format.js";
|
|
14
17
|
export { channelHref, resolveChannels, smsHref, type CaptureChannel, type ChannelFacts, type ChannelOptions, type ResolvedChannels, } from "./core/channels.js";
|
|
15
18
|
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, 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";
|
|
19
|
+
export { createAcceptLead, ANALYTICS_ID_FIELD, CHANNELS_FIELD, EXPERIMENT_FIELD, FORM_ID_FIELD, leadRef, LOCALE_FIELD, LOCATION_FIELD, PRICE_CHANGED, SHOWN_CENTS_FIELD, submitTagsOf, VARIANT_FIELD, type AcceptDeps, type Outcome, type SubmitTags, } from "./core/accept.js";
|
|
17
20
|
export { areaServedNodes, breadcrumbNode, businessId, businessNode, faqPageNode, offerNodes, organizationId, organizationNode, placeGraph, websiteId, type OfferInput, type PageGraphCopy, type QuestionAnswer, } from "./core/seo/ld.js";
|
|
18
21
|
export { AI_CRAWLERS, ogImageUrl, robotsFor, sitemapFor, type RobotsFile, type RobotsRule, type SitemapEntry, } from "./core/seo/sitemap.js";
|
|
19
|
-
export { ALLOWED_PROPS, ANALYTICS_ID, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, qaVisit, type AnalyticsTarget, type IntentChannel, type LeadField, type LeadStep, } from "./core/analytics.js";
|
|
22
|
+
export { ALLOWED_PROPS, ANALYTICS_ID, analyticsSink, channelsProps, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, qaVisit, type AnalyticsTarget, type IntentChannel, type LeadField, type LeadStep, } from "./core/analytics.js";
|
|
20
23
|
export { AB_COOKIE_PREFIX, AB_FORCE_PARAM, abAssignments, abRunning, abReset, abVariantUrl, cookieValue, type AbReset, } from "./core/ab-switcher.js";
|
|
21
24
|
export { abSwitcherVisible } from "./core/ab-gate.js";
|
|
22
25
|
export type { CallBarText, CopySlice, CoreText, PageMetaCopy, QuoteFormCopy, Said, StatusAction, StatusCopy, StatusScreenText, } from "./core/content.js";
|
package/dist/index.js
CHANGED
|
@@ -3,20 +3,23 @@
|
|
|
3
3
|
* the proxy (edge), a client island and a server module can all import it.
|
|
4
4
|
* Runtime-bound halves live on their own subpaths.
|
|
5
5
|
*/
|
|
6
|
-
export { assertLaunchable, bakedPlace, cardFact, contactOf, defineSite, ogLocaleOf, openLaunchBlockers, perLocale, } from "./core/site.js";
|
|
6
|
+
export { assertLaunchable, bakedPlace, cardFact, contactOf, defineSite, messengerFacts, 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_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";
|
|
9
|
+
export { CARD_FIELD, CARD_ID, CHANNEL_FIELD, channelOf, CONSENT_FIELD, isMessengerChannel, 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 { answeredUnknown, CENTS_BUCKETS, centsBucket, ESTIMATE_FIELD_PREFIX, ESTIMATE_UNKNOWN, 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
|
+
export { LEAD_CAPTURE_MESSENGER_TEXT, messengerTextOf } from "./core/messenger-text.js";
|
|
14
|
+
export { channelsAvailable, MESSAGE_REF, MESSAGE_REF_FIELD, MESSAGE_REF_PREFIX, messageRefOf, newMessageRef, TELEGRAM_BOT, telegramHref, } from "./core/messenger.js";
|
|
15
|
+
export { MAX_MESSENGER_MESSAGE, messengerMessage } from "./core/messenger-message.js";
|
|
13
16
|
export { fillText, formatCents, openingText } from "./core/lead-capture-format.js";
|
|
14
17
|
export { channelHref, resolveChannels, smsHref, } from "./core/channels.js";
|
|
15
18
|
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, ANALYTICS_ID_FIELD, EXPERIMENT_FIELD, FORM_ID_FIELD, leadRef, LOCALE_FIELD, LOCATION_FIELD, PRICE_CHANGED, SHOWN_CENTS_FIELD, VARIANT_FIELD, } from "./core/accept.js";
|
|
19
|
+
export { createAcceptLead, ANALYTICS_ID_FIELD, CHANNELS_FIELD, EXPERIMENT_FIELD, FORM_ID_FIELD, leadRef, LOCALE_FIELD, LOCATION_FIELD, PRICE_CHANGED, SHOWN_CENTS_FIELD, submitTagsOf, VARIANT_FIELD, } from "./core/accept.js";
|
|
17
20
|
export { areaServedNodes, breadcrumbNode, businessId, businessNode, faqPageNode, offerNodes, organizationId, organizationNode, placeGraph, websiteId, } from "./core/seo/ld.js";
|
|
18
21
|
export { AI_CRAWLERS, ogImageUrl, robotsFor, sitemapFor, } from "./core/seo/sitemap.js";
|
|
19
|
-
export { ALLOWED_PROPS, ANALYTICS_ID, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, qaVisit, } from "./core/analytics.js";
|
|
22
|
+
export { ALLOWED_PROPS, ANALYTICS_ID, analyticsSink, channelsProps, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, qaVisit, } from "./core/analytics.js";
|
|
20
23
|
export { AB_COOKIE_PREFIX, AB_FORCE_PARAM, abAssignments, abRunning, abReset, abVariantUrl, cookieValue, } from "./core/ab-switcher.js";
|
|
21
24
|
export { abSwitcherVisible } from "./core/ab-gate.js";
|
|
22
25
|
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";
|
package/dist/next/quote-route.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { after } from "next/server.js";
|
|
2
2
|
import { CONFIRM_PATH, confirmQuery } from "./confirm-route.js";
|
|
3
|
-
import { createAcceptLead,
|
|
4
|
-
import { analyticsSink, EVENTS
|
|
3
|
+
import { createAcceptLead, PRICE_CHANGED, submitTagsOf } from "../core/accept.js";
|
|
4
|
+
import { analyticsSink, EVENTS } from "../core/analytics.js";
|
|
5
5
|
import { RateLimiter } from "../core/antispam.js";
|
|
6
6
|
import { CARD_FIELD, CARD_ID, channelOf, LEAD_CARD_PARAM, LEAD_ERROR_PARAM } from "../core/lead.js";
|
|
7
7
|
import { createPlaceView } from "../core/place/view.js";
|
|
@@ -54,10 +54,6 @@ function seeOther(location) {
|
|
|
54
54
|
function json(status, body) {
|
|
55
55
|
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
|
56
56
|
}
|
|
57
|
-
const text = (form, name) => {
|
|
58
|
-
const value = form.get(name);
|
|
59
|
-
return typeof value === "string" ? value : null;
|
|
60
|
-
};
|
|
61
57
|
/** `LeadCapture`'s own post asks for JSON; a plain form post never does. */
|
|
62
58
|
const wantsJson = (request) => (request.headers.get("accept") ?? "").includes("application/json");
|
|
63
59
|
export function quoteRoute(site, deps) {
|
|
@@ -171,6 +167,7 @@ export function quoteRoute(site, deps) {
|
|
|
171
167
|
capture: (lead, formId, tags) => sinkFor(lead.placeSlug).capture(EVENTS.leadSubmit, {
|
|
172
168
|
form_id: formId,
|
|
173
169
|
channel: channelOf(lead),
|
|
170
|
+
...(lead.messageRef ? { message_ref: lead.messageRef } : {}),
|
|
174
171
|
...tags,
|
|
175
172
|
}),
|
|
176
173
|
limiter: (limiter ??= new RateLimiter(env.leadRateLimit?.limit ?? 5, env.leadRateLimit?.windowMs ?? 600_000)),
|
|
@@ -194,7 +191,7 @@ export function quoteRoute(site, deps) {
|
|
|
194
191
|
return scripted ? json(200, { ok: true, location, lead: outcome.ref, ...price }) : seeOther(location);
|
|
195
192
|
}
|
|
196
193
|
case "invalid": {
|
|
197
|
-
const tags =
|
|
194
|
+
const tags = submitTagsOf(form);
|
|
198
195
|
// The field's role and why — never what was typed.
|
|
199
196
|
const repriced = outcome.field === PRICE_CHANGED;
|
|
200
197
|
const props = { form_id: outcome.formId, channel: outcome.channel, field: outcome.field, reason: repriced ? PRICE_CHANGED : "invalid", ...tags };
|
|
@@ -3,7 +3,7 @@ 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
5
|
import { useEffect, useMemo, useState } from "react";
|
|
6
|
-
import { analyticsSink, countsAsPageView, EVENTS, experimentProps } from "../core/analytics.js";
|
|
6
|
+
import { analyticsSink, channelsProps, countsAsPageView, EVENTS, experimentProps } from "../core/analytics.js";
|
|
7
7
|
import { AnalyticsIdContext, AnalyticsSinkContext, experimentOf } from "./analytics-context.js";
|
|
8
8
|
import { newSubmissionId } from "./use-lead-submit.js";
|
|
9
9
|
/**
|
|
@@ -11,7 +11,7 @@ import { newSubmissionId } from "./use-lead-submit.js";
|
|
|
11
11
|
* contact link — or one the tracker cannot classify (`sms:`).
|
|
12
12
|
*/
|
|
13
13
|
const INTENT_ATTR = "data-intent";
|
|
14
|
-
const INTENTS = ["form_open", "booking", "sms", "callback"];
|
|
14
|
+
const INTENTS = ["form_open", "booking", "sms", "callback", "telegram", "whatsapp_qr"];
|
|
15
15
|
function source() {
|
|
16
16
|
const utm = new URLSearchParams(window.location.search).get("utm_source");
|
|
17
17
|
if (utm)
|
|
@@ -54,5 +54,5 @@ export function AnalyticsBoundary({ target, placeSlug, qaCookie, children }) {
|
|
|
54
54
|
}, [sink]);
|
|
55
55
|
// A link inside an experiment's island (`LeadCapture`) carries the
|
|
56
56
|
// assignment on itself, which is what the tracker reads.
|
|
57
|
-
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 }) }) }));
|
|
57
|
+
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"]), ...channelsProps(data["channelsAvailable"]) }, { transport: "beacon" }), children: children }) }) }));
|
|
58
58
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
2
|
import { type CaptureChannel } from "../core/channels.js";
|
|
3
3
|
import { type LeadError, type LeadWire, type PageLeadError } from "../core/lead.js";
|
|
4
|
+
import { type MessengerFacts, type MessengerVariant } from "../core/messenger.js";
|
|
4
5
|
import { type LeadCaptureText } from "../core/lead-capture-text.js";
|
|
5
6
|
import { type Place } from "../core/place/types.js";
|
|
6
7
|
import { type LeadFlows } from "../core/pricing/flow.js";
|
|
@@ -14,10 +15,11 @@ import { type FieldPart } from "./LeadCaptureFields.js";
|
|
|
14
15
|
import { type LeadCaptureLayout, type LeadNeedDisplay, type LeadNeedOption } from "./LeadCaptureNeed.js";
|
|
15
16
|
import { type PricePart } from "./LeadCapturePrice.js";
|
|
16
17
|
import { type StepsPart } from "./LeadCaptureSteps.js";
|
|
18
|
+
import type { MessengerPart } from "./messenger/types.js";
|
|
17
19
|
import { type LeadIntro } from "./lead-steps.js";
|
|
18
20
|
import type { PartClassNames } from "./parts.js";
|
|
19
21
|
import { type LeadSent } from "./use-lead-submit.js";
|
|
20
|
-
export type LeadCapturePart = "root" | "head" | "title" | "lede" | "form" | "contact" | "submit" | "trust" | "privacy" | "opening" | "others" | "done" | "needs" | "need" | "summary" | "icon" | "intro" | "introOption" | "stepNext" | FieldPart | ChannelPart | EstimatePart | PricePart | StepsPart | PricedPart | BookingPart;
|
|
22
|
+
export type LeadCapturePart = "root" | "head" | "title" | "lede" | "form" | "contact" | "submit" | "trust" | "privacy" | "opening" | "others" | "done" | "needs" | "need" | "summary" | "icon" | "intro" | "introOption" | "stepNext" | FieldPart | ChannelPart | EstimatePart | PricePart | StepsPart | PricedPart | BookingPart | MessengerPart;
|
|
21
23
|
export interface LeadCaptureProps {
|
|
22
24
|
/** Its hours order the channels; its service area suggests the commune. */
|
|
23
25
|
place: Place<string>;
|
|
@@ -117,6 +119,27 @@ export interface LeadCaptureProps {
|
|
|
117
119
|
channelIcons?: Partial<Record<ChannelIconKey, ReactNode>> | undefined;
|
|
118
120
|
/** Moved first when available — a `default_channel` experiment's arm. */
|
|
119
121
|
prefer?: CaptureChannel | undefined;
|
|
122
|
+
/**
|
|
123
|
+
* The `lead_channel` experiment's arm: how the card offers WhatsApp and the
|
|
124
|
+
* brand's bot (`MessengerVariant`); absent → the control. A place without
|
|
125
|
+
* WhatsApp falls back to the control (`messengerShownOf`), and every event
|
|
126
|
+
* says which messengers were there (`channels_available`).
|
|
127
|
+
*/
|
|
128
|
+
messenger?: MessengerVariant | undefined;
|
|
129
|
+
/** `messengerFacts(site, place)`: the place's own WhatsApp number and bot, each `null` when off. */
|
|
130
|
+
messengers?: MessengerFacts | undefined;
|
|
131
|
+
/** The brand's prefix of the lead's chat reference (`AQ` → `AQ-7K3F`, `message_ref`); without it no reference. */
|
|
132
|
+
refPrefix?: string | undefined;
|
|
133
|
+
/** The brand's name: the prefilled message's greeting («Bonjour Aquafix»). */
|
|
134
|
+
brand?: string | undefined;
|
|
135
|
+
/**
|
|
136
|
+
* The estimate's question whose answer is the message's timing line and
|
|
137
|
+
* in its preview (`{ input: "frequency" }` → «Délai souhaité : 2 sem.»);
|
|
138
|
+
* nothing until it is answered, nor for «I don't know».
|
|
139
|
+
*/
|
|
140
|
+
messengerTiming?: {
|
|
141
|
+
input: string;
|
|
142
|
+
} | undefined;
|
|
120
143
|
/** The site's assignment: on every event, and posted with the form. Slugs only. */
|
|
121
144
|
experiment?: Experiment | undefined;
|
|
122
145
|
timeZone?: string | undefined;
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { Button, cn } from "@evinvest/uikit";
|
|
4
4
|
import { Fragment, useEffect, useRef, useState } from "react";
|
|
5
|
-
import { PRICE_CHANGED, SHOWN_CENTS_FIELD } from "../core/accept.js";
|
|
5
|
+
import { CHANNELS_FIELD, PRICE_CHANGED, SHOWN_CENTS_FIELD } from "../core/accept.js";
|
|
6
6
|
import { resolveChannels } from "../core/channels.js";
|
|
7
7
|
import { CHANNEL_FIELD, leadErrorFor } from "../core/lead.js";
|
|
8
|
-
import { fillText, openingText } from "../core/lead-capture-format.js";
|
|
8
|
+
import { fillText, formatCents, openingText } from "../core/lead-capture-format.js";
|
|
9
|
+
import { channelsAvailable, MESSAGE_REF_FIELD } from "../core/messenger.js";
|
|
9
10
|
import { flowTextOf } from "../core/lead-capture-text.js";
|
|
10
11
|
import { bookingOf } from "../core/booking/model.js";
|
|
11
12
|
import { servedLocalities, storefrontOf } from "../core/place/types.js";
|
|
@@ -20,6 +21,7 @@ import { ConsentField, FormMessage, LocalityField, NameField, PhoneField } from
|
|
|
20
21
|
import { NeedField } from "./LeadCaptureNeed.js";
|
|
21
22
|
import { PriceBox, PriceCompact } from "./LeadCapturePrice.js";
|
|
22
23
|
import { IntroField, LeadSteps } from "./LeadCaptureSteps.js";
|
|
24
|
+
import { drawsAt, initialMode, MessengerSlot, messengerShownOf, NO_MESSENGERS, useMessageRef } from "./LeadCaptureMessenger.js";
|
|
23
25
|
import { stepOfField, stepsOf, useLeadSteps } from "./lead-steps.js";
|
|
24
26
|
import { QuoteFormShell } from "./QuoteFormShell.js";
|
|
25
27
|
import { useHeld, useHydrated, useNeed, useNow, useOpenOnHash, usePostcode } from "./use-lead-context.js";
|
|
@@ -64,7 +66,12 @@ export function LeadCapture(props) {
|
|
|
64
66
|
const postcode = usePostcode();
|
|
65
67
|
const [locality, setLocality] = useState(undefined);
|
|
66
68
|
const localityShown = locality ?? postcode ?? (served.length === 1 ? served[0] : undefined);
|
|
67
|
-
const
|
|
69
|
+
const facts = props.messengers ?? NO_MESSENGERS;
|
|
70
|
+
const shown = messengerShownOf(props.messenger, facts);
|
|
71
|
+
const channels = channelsAvailable(props.messengers);
|
|
72
|
+
const [mode, setMode] = useState(() => initialMode(props.messenger));
|
|
73
|
+
const { ref: messageRef, renew: renewRef, minted } = useMessageRef(props.refPrefix, shown !== null);
|
|
74
|
+
const events = useLeadEvents(root, { formId, layout, experiment, channels, messenger: shown ?? undefined });
|
|
68
75
|
useOpenOnHash(`${id}-callback`);
|
|
69
76
|
// Only this card's: a page may draw several, and the query names one.
|
|
70
77
|
const initial = leadErrorFor(props.initialError ?? null, id);
|
|
@@ -78,7 +85,8 @@ export function LeadCapture(props) {
|
|
|
78
85
|
const flow = estimate.flow;
|
|
79
86
|
const repriced = useRepriced(estimate.price, shownNeed, flowText, locale);
|
|
80
87
|
// A priced lead stays in the card: its price is confirmed there, and the slot promised.
|
|
81
|
-
|
|
88
|
+
// `thanks` offers its messengers in the success: the card stays.
|
|
89
|
+
const staysInCard = (ch) => props.done !== undefined || (ch === "form" && (flow !== "quote" || shown === "thanks"));
|
|
82
90
|
const { sent, onSubmit, busy, failure, retry } = useLeadSubmit(staysInCard, { mobile: wire.mobile, name: props.name?.field }, {
|
|
83
91
|
onRefused: (channel, field, cents) => {
|
|
84
92
|
repriced.refused(field, cents);
|
|
@@ -102,7 +110,9 @@ export function LeadCapture(props) {
|
|
|
102
110
|
const needLabel = needs.find(n => n.value === need)?.label;
|
|
103
111
|
const message = needLabel ? fillText(text.message, { need: needLabel }) : text.messageGeneric;
|
|
104
112
|
const [lead = "form", ...rest] = resolved.order;
|
|
105
|
-
|
|
113
|
+
// A variant owns WhatsApp — its link posts the lead first — and the owner's model has no text message.
|
|
114
|
+
const owned = (ch) => shown !== null && shown !== "fallback" && (ch === "whatsapp" || ch === "sms");
|
|
115
|
+
const links = rest.filter((ch) => ch !== "form" && ch !== "callback" && ch !== "telegram" && !owned(ch));
|
|
106
116
|
// The intro asked for a call back: the folded callback would be a second one.
|
|
107
117
|
const callbackElsewhere = !callbackPreset;
|
|
108
118
|
const asked = model && shownNeed !== undefined && wanted === "estimate" ? askedInputs(model, shownNeed, estimate.answers) : [];
|
|
@@ -179,11 +189,66 @@ export function LeadCapture(props) {
|
|
|
179
189
|
continueLocality();
|
|
180
190
|
}
|
|
181
191
|
};
|
|
182
|
-
const channel = (ch, primary) => ch === "callback" ? (_jsx(CallbackForm, { id: `${id}-callback`, card: id, primary: primary, open: initial?.channel === "callback" || (props.callbackOpen ?? primary), formId: formId, placeSlug: place.slug, locale: locale, renderedAt: renderedAt, mobileName: wire.mobile, subject: need ? { name: wire.subject, value: need } : null, opening: opening, text: text, experiment: experiment, onSubmit: onSubmit, error: error?.channel === "callback" ? { field: error.field, text: errorText(error.field, text) } : null, busy: busy === "callback", failure: failure?.channel === "callback" ? failure.failure : null, onRetry: retry, onSoftError: () => events.fieldError("phone"), row: !primary && props.channelsDisplay === "row", icon: props.channelIcons?.callback, classNames: c }, ch)) : ch === "form" ? null : (_jsx(ChannelLink, { channel: ch, contact: contact, message: message, text: text, primary: primary, experiment: experiment, icon: props.channelIcons?.[ch], classNames: c }, ch));
|
|
183
|
-
|
|
192
|
+
const channel = (ch, primary) => ch === "callback" ? (_jsx(CallbackForm, { id: `${id}-callback`, card: id, primary: primary, open: initial?.channel === "callback" || (props.callbackOpen ?? primary), formId: formId, placeSlug: place.slug, locale: locale, renderedAt: renderedAt, mobileName: wire.mobile, subject: need ? { name: wire.subject, value: need } : null, opening: opening, text: text, experiment: experiment, channels: channels, onSubmit: onSubmit, error: error?.channel === "callback" ? { field: error.field, text: errorText(error.field, text) } : null, busy: busy === "callback", failure: failure?.channel === "callback" ? failure.failure : null, onRetry: retry, onSoftError: () => events.fieldError("phone"), row: !primary && props.channelsDisplay === "row", icon: props.channelIcons?.callback, classNames: c }, ch)) : ch === "form" || ch === "telegram" ? null : (_jsx(ChannelLink, { channel: ch, contact: contact, message: message, text: text, primary: primary, experiment: experiment, icon: props.channelIcons?.[ch], classNames: c }, ch));
|
|
193
|
+
// The estimate's answers as the message says them: the whole label, and the
|
|
194
|
+
// brand's words for the preview and the need's line (`previewLabels`, else
|
|
195
|
+
// `shortLabels`). «I don't know» is no answer; the timing question has a line of its own.
|
|
196
|
+
const answerOf = (input) => {
|
|
197
|
+
const option = input.options.find(o => o.id === estimate.answers[input.id]);
|
|
198
|
+
if (!option)
|
|
199
|
+
return null;
|
|
200
|
+
const label = labelOf(option.labels, locale);
|
|
201
|
+
const question = props.questions?.[input.id];
|
|
202
|
+
return { label, short: question?.previewLabels?.[option.id] ?? question?.shortLabels?.[option.id] ?? label };
|
|
203
|
+
};
|
|
204
|
+
const timingInput = asked.find(i => i.id === props.messengerTiming?.input);
|
|
205
|
+
const answerShorts = asked
|
|
206
|
+
.filter(i => i !== timingInput)
|
|
207
|
+
.map(i => answerOf(i)?.short)
|
|
208
|
+
.filter((short) => short !== undefined);
|
|
209
|
+
// A variant arranges the card's own phone field and submit; it never draws its own.
|
|
210
|
+
const kitOf = (at, variant, drawn) => ({
|
|
211
|
+
at,
|
|
212
|
+
variant,
|
|
213
|
+
shown: drawn,
|
|
214
|
+
facts,
|
|
215
|
+
id,
|
|
216
|
+
locale,
|
|
217
|
+
brand: props.brand ?? "",
|
|
218
|
+
text,
|
|
219
|
+
wire,
|
|
220
|
+
needs,
|
|
221
|
+
needLabel: needs.find(n => n.value === shownNeed)?.label ?? null,
|
|
222
|
+
priceText: flow !== "quote" && repriced.price ? formatCents(repriced.price.cents, locale) : null,
|
|
223
|
+
answers: answerShorts,
|
|
224
|
+
timing: timingInput ? answerOf(timingInput) : null,
|
|
225
|
+
mode,
|
|
226
|
+
setMode,
|
|
227
|
+
messageRef,
|
|
228
|
+
renewRef,
|
|
229
|
+
ready: minted,
|
|
230
|
+
phone: options => phoneOf(options),
|
|
231
|
+
// Inside a variant the submit is its main button, dressed as its messenger's.
|
|
232
|
+
// …with the phone's icon before its words: it is the call.
|
|
233
|
+
submit: (label, options) => submitOf(label, { ...options, className: cn(c?.messengerCta, options?.className), icon: props.channelIcons?.phone ?? props.channelIcons?.callback }),
|
|
234
|
+
title: text.title,
|
|
235
|
+
lede: text.lede,
|
|
236
|
+
events,
|
|
237
|
+
icons: props.channelIcons,
|
|
238
|
+
experiment,
|
|
239
|
+
classNames: c,
|
|
240
|
+
});
|
|
241
|
+
const slot = (at) => shown !== null && props.messenger && drawsAt(shown, at) && _jsx(MessengerSlot, { kit: kitOf(at, props.messenger, shown) }, `messenger-${at}`);
|
|
242
|
+
const rootProps = {
|
|
243
|
+
id,
|
|
244
|
+
className: cn("flex w-full flex-col gap-6", props.className, c?.root),
|
|
245
|
+
"data-experiment": experiment?.name,
|
|
246
|
+
"data-variant": experiment?.variant,
|
|
247
|
+
"data-channels-available": channels,
|
|
248
|
+
};
|
|
184
249
|
if (sent && staysInCard(sent.channel)) {
|
|
185
250
|
const done = typeof props.done === "function" ? props.done(sent) : props.done;
|
|
186
|
-
return (_jsx("div", { ref: root, ...rootProps, children: _jsxs("div", { ref: doneRef, role: "status", tabIndex: -1, className: cn("flex flex-col gap-4 outline-none", c?.done), children: [_jsx(Fragment, { children: done }, "done"), sent.channel === "form" && flow !== "quote" && (_jsx(LeadCapturePriced, { sent: sent, booking: props.booking === undefined ? (_jsx(LeadBooking, { booking: bookingOf(place, props.bookingVariant), sent: sent, locale: locale, text: flowText, adapters: props.bookingAdapters, embed: props.bookingEmbed, formId: formId, classNames: c })) : typeof props.booking === "function" ? (props.booking(sent)) : (props.booking), locale: locale, text: flowText, classNames: c }))] }) }));
|
|
251
|
+
return (_jsx("div", { ref: root, ...rootProps, children: _jsxs("div", { ref: doneRef, role: "status", tabIndex: -1, className: cn("flex flex-col gap-4 outline-none", c?.done), children: [_jsx(Fragment, { children: done }, "done"), sent.channel === "form" && flow !== "quote" && (_jsx(LeadCapturePriced, { sent: sent, booking: props.booking === undefined ? (_jsx(LeadBooking, { booking: bookingOf(place, props.bookingVariant), sent: sent, locale: locale, text: flowText, adapters: props.bookingAdapters, embed: props.bookingEmbed, formId: formId, classNames: c })) : typeof props.booking === "function" ? (props.booking(sent)) : (props.booking), locale: locale, text: flowText, classNames: c })), sent.channel === "form" && slot("done")] }) }));
|
|
187
252
|
}
|
|
188
253
|
// An arrow key in `steps`: chosen, and the screen stays on until the answer is given.
|
|
189
254
|
const stay = (choose) => (...args) => {
|
|
@@ -197,11 +262,28 @@ export function LeadCapture(props) {
|
|
|
197
262
|
const localityField = (_jsx(LocalityField
|
|
198
263
|
// Remounted with what the query knew: an untouched field takes it as typed.
|
|
199
264
|
, { name: wire.locality, label: text.localityLabel, servedLabel: text.servedLabel, served: served, placeholder: text.localityPlaceholder, required: props.locality !== "optional", optional: text.optional, requiredText: text.required, error: at("locality"), hydrated: hydrated, preset: postcode, classNames: c }, postcode ?? ""));
|
|
200
|
-
|
|
265
|
+
function phoneOf(o = {}) {
|
|
266
|
+
return (_jsxs(_Fragment, { children: [_jsx(PhoneField, { name: wire.mobile, text: text, error: at("phone"), onSoftError: () => events.fieldError("phone"), optional: o.optional, disabled: o.disabled, placeholder: o.placeholder, frame: o.frame, form: o.form, classNames: c }), _jsx(Fragment, { children: "after" in o ? o.after : props.afterPhone }, "afterPhone")] }));
|
|
267
|
+
}
|
|
268
|
+
// A variant's `contact` takes the phone's place, and brings its own button.
|
|
269
|
+
const contactSlot = slot("contact");
|
|
270
|
+
const phone = contactSlot || phoneOf();
|
|
201
271
|
const nameField = props.name && (_jsx(NameField, { name: props.name.field, label: text.nameLabel, placeholder: text.namePlaceholder, required: props.name.required ?? false, optional: text.optional, requiredText: text.required, error: at("name"), classNames: c }));
|
|
202
272
|
const mainBusy = busy === "form" || (callbackPreset && busy === "callback");
|
|
203
273
|
const mainFailure = failure && (failure.channel === "form" || callbackPreset) ? failure.failure : null;
|
|
204
|
-
|
|
274
|
+
function submitOf(label, o = {}) {
|
|
275
|
+
return (_jsx(SubmitButton, { busy: mainBusy, label: label ?? (callbackPreset ? text.callbackSubmit : flow === "quote" ? text.submit : flowText.bookSubmit), sending: text.sending, form: o.form, className: cn(callbackPreset ? cn(c?.submit, c?.callbackSubmit) : c?.submit, o.className), children: o.icon !== undefined && o.icon !== null && (_jsx("span", { "aria-hidden": "true", className: cn("flex shrink-0 items-center [&_svg]:size-5", c?.channelIcon), children: o.icon })) }));
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Without a script a variant leads nowhere — its messenger links are inert
|
|
279
|
+
* until the reference is minted — so a browser that runs none gets the
|
|
280
|
+
* control's phone and submit (`select` draws the phone already). Never
|
|
281
|
+
* parsed where scripts run.
|
|
282
|
+
*/
|
|
283
|
+
function noScript() {
|
|
284
|
+
return (_jsxs("noscript", { children: [shown !== "select" && phoneOf({ after: null }), submitOf()] }));
|
|
285
|
+
}
|
|
286
|
+
const tail = (_jsxs(_Fragment, { children: [_jsx(FormMessage, { id: formMessageId(id, "form"), error: above, className: c?.error }), _jsx(FailureMessage, { failure: mainFailure, text: text, onRetry: retry, className: c?.error }), _jsxs("div", { className: cn("flex flex-col gap-3", c?.trust), children: [!contactSlot && submitOf(), contactSlot && noScript(), slot("afterSubmit"), _jsx(Fragment, { children: props.trust }, "trust")] }), opening && lead !== "callback" && _jsx("p", { className: cn("text-sm text-ink-soft", c?.opening), children: opening }), text.privacy !== "" && _jsx("p", { className: cn("text-sm text-ink-soft", c?.privacy), children: text.privacy })] }));
|
|
205
287
|
// `qualify-first` shows the contact step as soon as a need's radio is
|
|
206
288
|
// checked (`:has`) — without a script, and inside the tap with one.
|
|
207
289
|
const contactShown = layout === "single" || (need !== undefined && !editing);
|
|
@@ -258,7 +340,10 @@ export function LeadCapture(props) {
|
|
|
258
340
|
}
|
|
259
341
|
}
|
|
260
342
|
};
|
|
261
|
-
return (_jsxs("div", { ref: root, ...rootProps, children: [_jsx(Fragment, { children:
|
|
343
|
+
return (_jsxs("div", { ref: root, ...rootProps, children: [_jsx(Fragment, { children: slot("head") ||
|
|
344
|
+
(props.head ?? (_jsxs("div", { className: cn("flex flex-col gap-1", c?.head), children: [_jsx("p", { className: cn("font-display text-2xl font-bold text-ink", c?.title), children: text.title }), !drawsAt(shown, "afterHead") && _jsx("p", { className: cn("text-ink-soft", c?.lede), children: text.lede })] }))) }, "head"), slot("afterHead"), lead !== "form" && !owned(lead) && (lead !== "callback" || callbackElsewhere) && channel(lead, true), _jsxs(QuoteFormShell, { id: `${id}-form`, placeSlug: place.slug, locale: locale, renderedAt: renderedAt, honeypotLabel: text.honeypotLabel, formId: formId, card: id, onSubmit: onSubmit, onKeyDown: onKeyDown, data: steps ? { "data-lead-steps": "" } : undefined, className: cn("group/lead", c?.form), children: [_jsx(ExperimentFields, { experiment: experiment }), _jsx("input", { type: "hidden", name: CHANNELS_FIELD, value: channels }), messageRef && _jsx("input", { type: "hidden", name: MESSAGE_REF_FIELD, value: messageRef }), flow !== "quote" && repriced.shownCents !== undefined && _jsx("input", { type: "hidden", name: SHOWN_CENTS_FIELD, value: repriced.shownCents }), steps ? (_jsx(LeadSteps, { steps: stepIds.map(stepView), current: stepper.current, total: total, onBack: () => moveOn(stepper.back, null, false), onEdit: step => moveOn(() => stepper.edit(step), null, false), text: { stepProgress: flowText.stepProgress, stepProgressOpen: flowText.stepProgressOpen, stepBack: flowText.stepBack, change: text.needChange }, classNames: c })) : drawsAt(shown, "body") && mode === null ? (
|
|
345
|
+
// `saga`'s first screen: the channel, before anything else.
|
|
346
|
+
_jsxs(_Fragment, { children: [slot("body"), noScript()] })) : (_jsxs(_Fragment, { children: [slot("beforeNeed"), _jsx(NeedField, { display: needDisplay, name: wire.subject, needs: needs, need: need, editing: editing, hydrated: hydrated, label: text.needLabel, changeLabel: text.needChange, requiredText: text.needRequired, enterPicks: enterPicks, onPick: pick, onSelect: select, onEdit: () => {
|
|
262
347
|
setEditing(true);
|
|
263
348
|
events.step("need");
|
|
264
349
|
}, classNames: c }), _jsxs("div", { className: cn("flex-col gap-5", contactClass, c?.contact), children: [model && shownNeed !== undefined && wanted === "estimate" && _jsx(EstimateInputs, { model: model, need: shownNeed, questions: props.questions, ...estimateProps }), priceShown, photos, localityField, phone, nameField, _jsx(Fragment, { children: props.extras }, "extras"), tail] })] }))] }), (links.length > 0 || (rest.includes("callback") && callbackElsewhere)) &&
|
|
@@ -10,7 +10,7 @@ import { type PartClassNames } from "./parts.js";
|
|
|
10
10
|
*/
|
|
11
11
|
export type ChannelPart = "channel" | "primary" | "callback" | "callbackSummary" | "callbackForm" | "callbackLede" | "callbackSubmit" | "consent" | "photos" | "channelIcon";
|
|
12
12
|
/** A way out the brand may draw an icon for (`channelIcons`). */
|
|
13
|
-
export type ChannelIconKey = "phone" | "whatsapp" | "sms" | "callback";
|
|
13
|
+
export type ChannelIconKey = "phone" | "whatsapp" | "sms" | "callback" | "telegram";
|
|
14
14
|
export interface Experiment {
|
|
15
15
|
name: string;
|
|
16
16
|
variant: string;
|
|
@@ -19,6 +19,8 @@ export interface Experiment {
|
|
|
19
19
|
export declare function ExperimentFields({ experiment }: {
|
|
20
20
|
experiment: Experiment | undefined;
|
|
21
21
|
}): import("react").JSX.Element | null;
|
|
22
|
+
/** The links of the channel list; a bot is a `messenger` variant's. */
|
|
23
|
+
type LinkChannel = Exclude<CaptureChannel, "form" | "callback" | "telegram">;
|
|
22
24
|
/**
|
|
23
25
|
* A channel that leaves the page — `tel:`, `wa.me`, `sms:` — as a link the
|
|
24
26
|
* boundary counts: the tracker reads `tel:` and WhatsApp itself, `data-intent`
|
|
@@ -26,7 +28,7 @@ export declare function ExperimentFields({ experiment }: {
|
|
|
26
28
|
* because the tracker reads the link's own `data-*`.
|
|
27
29
|
*/
|
|
28
30
|
export declare function ChannelLink(props: {
|
|
29
|
-
channel:
|
|
31
|
+
channel: LinkChannel;
|
|
30
32
|
contact: {
|
|
31
33
|
phone: string | null;
|
|
32
34
|
whatsapp: string | null;
|
|
@@ -65,6 +67,8 @@ export declare function CallbackForm(props: {
|
|
|
65
67
|
opening: string | null;
|
|
66
68
|
text: LeadCaptureText;
|
|
67
69
|
experiment: Experiment | undefined;
|
|
70
|
+
/** The messengers the card offered (`channels_available`), for the submit's event. */
|
|
71
|
+
channels?: string | undefined;
|
|
68
72
|
onSubmit: FormEventHandler<HTMLFormElement> | undefined;
|
|
69
73
|
/** The server's refusal: the field and its words. */
|
|
70
74
|
error: {
|
|
@@ -93,3 +97,4 @@ export declare function PhotosAsk(props: {
|
|
|
93
97
|
experiment: Experiment | undefined;
|
|
94
98
|
className?: string | undefined;
|
|
95
99
|
}): import("react").JSX.Element | null;
|
|
100
|
+
export {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Button, buttonVariants, cn } from "@evinvest/uikit";
|
|
3
|
-
import { EXPERIMENT_FIELD, VARIANT_FIELD } from "../core/accept.js";
|
|
3
|
+
import { CHANNELS_FIELD, EXPERIMENT_FIELD, VARIANT_FIELD } from "../core/accept.js";
|
|
4
4
|
import { channelHref } from "../core/channels.js";
|
|
5
5
|
import { CHANNEL_FIELD } from "../core/lead.js";
|
|
6
6
|
import { fillText } from "../core/lead-capture-format.js";
|
|
@@ -51,7 +51,7 @@ export function ChannelLink(props) {
|
|
|
51
51
|
export function CallbackForm(props) {
|
|
52
52
|
const { id, primary, open, formId, placeSlug, locale, renderedAt, mobileName, subject, opening, text, experiment, error, classNames: c } = props;
|
|
53
53
|
const at = (field) => (error?.field === field ? error.text : null);
|
|
54
|
-
return (_jsxs("details", { id: id, open: open, className: cn(props.row ? "min-w-0 flex-1 open:basis-full" : "w-full", c?.callback), children: [_jsxs("summary", { "data-intent": "callback", className: partWithLeading(buttonVariants({ variant: primary ? "primary" : "outline", size: "touch", className: "flex w-full list-none [&::-webkit-details-marker]:hidden" }), CHANNEL_LEADING, cn(c?.channel, primary && c?.primary, c?.callbackSummary) || undefined), children: [_jsx(ChannelIcon, { icon: props.icon, className: c?.channelIcon }), text.callback] }), _jsxs(QuoteFormShell, { id: `${id}-form`, placeSlug: placeSlug, locale: locale, renderedAt: renderedAt, honeypotLabel: text.honeypotLabel, formId: formId, card: props.card, onSubmit: props.onSubmit, className: cn("mt-4 gap-4", c?.callbackForm), children: [_jsx("input", { type: "hidden", name: CHANNEL_FIELD, value: "callback" }), subject && _jsx("input", { type: "hidden", name: subject.name, value: subject.value }), _jsx(ExperimentFields, { experiment: experiment }), _jsx("p", { className: cn("text-ink-soft", c?.callbackLede), children: opening ?? text.callbackLede }), _jsx(PhoneField, { id: `${id}-phone`, name: mobileName, text: text, error: at("phone"), onSoftError: props.onSoftError, classNames: c }), _jsx(ConsentField, { sentence: text.callbackConsent, requiredText: text.consentRequired, error: at("consent"), className: c?.consent, classNames: c }), _jsx(FormMessage, { id: `${id}-error`, error: error && error.field !== "phone" && error.field !== "consent" ? error.text : null, className: c?.error }), _jsx(FailureMessage, { failure: props.failure, text: text, onRetry: props.onRetry, className: c?.error }), _jsx(SubmitButton, { busy: props.busy, label: text.callbackSubmit, sending: text.sending, className: c?.callbackSubmit })] })] }));
|
|
54
|
+
return (_jsxs("details", { id: id, open: open, className: cn(props.row ? "min-w-0 flex-1 open:basis-full" : "w-full", c?.callback), children: [_jsxs("summary", { "data-intent": "callback", className: partWithLeading(buttonVariants({ variant: primary ? "primary" : "outline", size: "touch", className: "flex w-full list-none [&::-webkit-details-marker]:hidden" }), CHANNEL_LEADING, cn(c?.channel, primary && c?.primary, c?.callbackSummary) || undefined), children: [_jsx(ChannelIcon, { icon: props.icon, className: c?.channelIcon }), text.callback] }), _jsxs(QuoteFormShell, { id: `${id}-form`, placeSlug: placeSlug, locale: locale, renderedAt: renderedAt, honeypotLabel: text.honeypotLabel, formId: formId, card: props.card, onSubmit: props.onSubmit, className: cn("mt-4 gap-4", c?.callbackForm), children: [_jsx("input", { type: "hidden", name: CHANNEL_FIELD, value: "callback" }), subject && _jsx("input", { type: "hidden", name: subject.name, value: subject.value }), _jsx(ExperimentFields, { experiment: experiment }), props.channels && _jsx("input", { type: "hidden", name: CHANNELS_FIELD, value: props.channels }), _jsx("p", { className: cn("text-ink-soft", c?.callbackLede), children: opening ?? text.callbackLede }), _jsx(PhoneField, { id: `${id}-phone`, name: mobileName, text: text, error: at("phone"), onSoftError: props.onSoftError, classNames: c }), _jsx(ConsentField, { sentence: text.callbackConsent, requiredText: text.consentRequired, error: at("consent"), className: c?.consent, classNames: c }), _jsx(FormMessage, { id: `${id}-error`, error: error && error.field !== "phone" && error.field !== "consent" ? error.text : null, className: c?.error }), _jsx(FailureMessage, { failure: props.failure, text: text, onRetry: props.onRetry, className: c?.error }), _jsx(SubmitButton, { busy: props.busy, label: text.callbackSubmit, sending: text.sending, className: c?.callbackSubmit })] })] }));
|
|
55
55
|
}
|
|
56
56
|
/**
|
|
57
57
|
* A `quote` need priced from photos (a deep clean, after-works): the photos
|
|
@@ -17,6 +17,12 @@ export interface EstimateQuestion {
|
|
|
17
17
|
badges?: Readonly<Record<string, string>> | undefined;
|
|
18
18
|
/** A shorter label per option id, drawn on a phone (under `sm`); the radio keeps the whole one. */
|
|
19
19
|
shortLabels?: Readonly<Record<string, string>> | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* The answer as a `messenger` variant's message says it, per option id — in
|
|
22
|
+
* the need's line and the preview («2 ch.», «40–70 m²»), where a tile's own
|
|
23
|
+
* «2» says nothing. Over `shortLabels`, then the label.
|
|
24
|
+
*/
|
|
25
|
+
previewLabels?: Readonly<Record<string, string>> | undefined;
|
|
20
26
|
/**
|
|
21
27
|
* `steps` only: questions with the same number share a screen, in the
|
|
22
28
|
* need's order. It moves on by itself once every one is answered; until
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
1
2
|
import type { LeadCaptureText } from "../core/lead-capture-text.js";
|
|
2
3
|
import type { PartClassNames } from "./parts.js";
|
|
3
4
|
export type FieldPart = "field" | "label" | "control" | "hint" | "error" | "chips" | "chip";
|
|
@@ -24,6 +25,11 @@ export type PhoneText = Pick<LeadCaptureText, "phoneLabel" | "phoneHint" | "phon
|
|
|
24
25
|
* (`phoneProblem`): one it would refuse blocks the submit, in the page's
|
|
25
26
|
* words. Leaving the field with such a number shows the hint already — a
|
|
26
27
|
* hint, read out, not yet an error.
|
|
28
|
+
*
|
|
29
|
+
* A `messenger` variant asks it differently: `optional` (the chat answers;
|
|
30
|
+
* a number typed is still held to the rule), `disabled` (a bot needs none),
|
|
31
|
+
* in a box of the variant's with a channel picker beside it (`frame`), or in
|
|
32
|
+
* a drawer outside the form's element (`form`, the form's id).
|
|
27
33
|
*/
|
|
28
34
|
export declare function PhoneField(props: {
|
|
29
35
|
name: string;
|
|
@@ -33,6 +39,17 @@ export declare function PhoneField(props: {
|
|
|
33
39
|
/** The server's refusal of this field, in the page's words. */
|
|
34
40
|
error: string | null;
|
|
35
41
|
onSoftError: () => void;
|
|
42
|
+
optional?: boolean | undefined;
|
|
43
|
+
disabled?: boolean | undefined;
|
|
44
|
+
/** Over `text.phonePlaceholder`. */
|
|
45
|
+
placeholder?: string | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* The variant's box around the input (the kit's `InputGroup` with a picker):
|
|
48
|
+
* the input then draws no box of its own. The variant's, so the control's
|
|
49
|
+
* page never loads the group.
|
|
50
|
+
*/
|
|
51
|
+
frame?: ((input: ReactNode) => ReactNode) | undefined;
|
|
52
|
+
form?: string | undefined;
|
|
36
53
|
classNames?: PartClassNames<FieldPart> | undefined;
|
|
37
54
|
}): import("react").JSX.Element;
|
|
38
55
|
export declare function NameField(props: {
|