@cartbase/storefront 0.21.0 → 0.22.1
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 +9 -0
- package/package.json +274 -261
- package/src/api/checkout.ts +15 -0
- package/src/api/http.ts +9 -0
- package/src/api/integrations.ts +126 -0
- package/src/checkout/card-offer.ts +68 -0
- package/src/checkout/carrier-marks.ts +53 -0
- package/src/checkout/checkout-client.tsx +71 -13
- package/src/checkout/checkout-error-screen.tsx +113 -0
- package/src/checkout/discount-section.tsx +88 -56
- package/src/checkout/fulfillment-option.ts +30 -0
- package/src/checkout/index.ts +41 -6
- package/src/checkout/labels.ts +98 -0
- package/src/checkout/line-item-card.tsx +35 -83
- package/src/checkout/mobile-checkout-bottom-bar.tsx +132 -0
- package/src/checkout/mobile-checkout-top-bar.tsx +94 -0
- package/src/checkout/mobile-order-summary-body.tsx +172 -0
- package/src/checkout/order-summary.tsx +85 -179
- package/src/checkout/payment-button.tsx +25 -8
- package/src/checkout/payment-method-list.tsx +51 -13
- package/src/checkout/payment-wrapper.tsx +57 -13
- package/src/checkout/pickup-option.ts +35 -0
- package/src/checkout/pickup-point-selector.tsx +328 -0
- package/src/checkout/pickup-points.ts +65 -0
- package/src/checkout/pigeon-office-selector.tsx +379 -0
- package/src/checkout/shipping-method-list.tsx +102 -10
- package/src/checkout/summary-math.ts +152 -0
- package/src/checkout/use-checkout-funnel.ts +303 -0
- package/src/checkout/use-checkout-orchestration.ts +384 -30
- package/src/lib/stripe-env.ts +25 -0
- package/src/locales/bg.ts +37 -1
- package/src/locales/es.ts +35 -1
- package/src/tracking/attribution.ts +83 -0
- package/src/tracking/consent.ts +53 -0
- package/src/tracking/events.ts +113 -0
- package/src/tracking/fbq.ts +48 -0
- package/src/tracking/gtag.ts +32 -0
- package/src/tracking/index.ts +32 -1
- package/src/tracking/once.ts +137 -0
- package/src/tracking/rybbit-events.ts +42 -0
- package/src/tracking/ttq.ts +24 -0
- package/src/tracking/types.ts +34 -0
package/src/locales/bg.ts
CHANGED
|
@@ -60,7 +60,8 @@ export const bg: StorefrontLocale = {
|
|
|
60
60
|
noResults: "Няма намерени продукти",
|
|
61
61
|
filters: "Филтри",
|
|
62
62
|
clearFilters: "Изчисти филтрите",
|
|
63
|
-
|
|
63
|
+
// The brand line stays in English in every language (Alexander, 2026-09-17).
|
|
64
|
+
poweredBy: "Powered by",
|
|
64
65
|
paymentMethods: "Начини на плащане",
|
|
65
66
|
socialProfiles: "Последвайте ни",
|
|
66
67
|
},
|
|
@@ -133,6 +134,9 @@ export const bg: StorefrontLocale = {
|
|
|
133
134
|
addToOrder: "+ Добави към поръчката",
|
|
134
135
|
addCode: "Приложи",
|
|
135
136
|
|
|
137
|
+
cartEmptyTitle: "Количката ви е празна",
|
|
138
|
+
cartEmptyText: "Добавете нещо в нея и поръчката започва оттук.",
|
|
139
|
+
cartEmptyBack: "Към магазина",
|
|
136
140
|
deliveryDisabled:
|
|
137
141
|
"Въведете адреса си за доставка, за да видите наличните варианти за доставяне.",
|
|
138
142
|
noShippingOptions: "Няма налични опции за доставка за вашия адрес.",
|
|
@@ -158,6 +162,9 @@ export const bg: StorefrontLocale = {
|
|
|
158
162
|
shipping: "Доставка",
|
|
159
163
|
shippingCalc: "Ще бъде изчислена",
|
|
160
164
|
shippingFree: "Безплатна",
|
|
165
|
+
// Neuter, agreeing with the line, where the shipping row above is
|
|
166
|
+
// feminine because it agrees with доставка.
|
|
167
|
+
lineFree: "Безплатно",
|
|
161
168
|
// The WORD is the pack's (a Bulgarian store's prices include ДДС). The
|
|
162
169
|
// RATE is not: it is the store's fact and it changes by law. Declared
|
|
163
170
|
// and deliberately empty, so the tooltip does not appear until a store
|
|
@@ -189,7 +196,14 @@ export const bg: StorefrontLocale = {
|
|
|
189
196
|
companyAddress: "Адрес на фирма",
|
|
190
197
|
|
|
191
198
|
placeOrder: "Поръчай",
|
|
199
|
+
completeOrder: "Завърши поръчката",
|
|
192
200
|
selectPaymentMethod: "Изберете начин на плащане",
|
|
201
|
+
nothingToPay: "Поръчката е покрита изцяло. Няма какво да доплащате.",
|
|
202
|
+
|
|
203
|
+
errorTitle: "Възникна проблем при зареждане на касата",
|
|
204
|
+
errorBody:
|
|
205
|
+
"Количката ви е запазена. Опитайте отново, а ако проблемът се повтори, свържете се с нас.",
|
|
206
|
+
errorRetry: "Опитай отново",
|
|
193
207
|
|
|
194
208
|
processingCard: [
|
|
195
209
|
"Свързваме се с банката...",
|
|
@@ -203,6 +217,18 @@ export const bg: StorefrontLocale = {
|
|
|
203
217
|
"Внимаваме за всеки детайл...",
|
|
204
218
|
],
|
|
205
219
|
|
|
220
|
+
pickupLoading: "Зареждане на офиси...",
|
|
221
|
+
pickupNearestOffices: "Най-близки офиси",
|
|
222
|
+
pickupNearestLockers: "Най-близки автомати",
|
|
223
|
+
pickupSearchOffice: "Търси друг офис",
|
|
224
|
+
pickupSearchLocker: "Търси друг автомат",
|
|
225
|
+
pickupSearchOfficePlaceholder: "Търси по име, град или адрес...",
|
|
226
|
+
pickupSearchLockerPlaceholder: "Търси по име, град или адрес...",
|
|
227
|
+
pickupNoResults: "Няма намерени резултати",
|
|
228
|
+
pickupChange: "Смени",
|
|
229
|
+
pickupUnavailable: "В момента няма достъпни офиси",
|
|
230
|
+
pickupRequired: "Избери офис за доставка",
|
|
231
|
+
|
|
206
232
|
econtLoadingOffices: "Зареждане на офиси...",
|
|
207
233
|
econtNearestOffices: "Най-близки офиси",
|
|
208
234
|
econtSearchAnother: "Търси друг офис",
|
|
@@ -220,6 +246,16 @@ export const bg: StorefrontLocale = {
|
|
|
220
246
|
"BoxNow временно е недостъпен, моля изберете друг метод за доставка.",
|
|
221
247
|
boxnowNoLockersInCity:
|
|
222
248
|
"Няма BoxNow автомати във вашия град. Моля изберете друг метод за доставка.",
|
|
249
|
+
pigeonLoadingOffices: "Зареждане на офиси...",
|
|
250
|
+
pigeonNearestOffices: "Най-близки офиси",
|
|
251
|
+
pigeonSearchAnother: "Търси друг офис",
|
|
252
|
+
pigeonSearchPlaceholder: "Търси по име, град или адрес...",
|
|
253
|
+
pigeonNoResults: "Няма намерени офиси за",
|
|
254
|
+
pigeonChange: "Промени",
|
|
255
|
+
pigeonUnavailable:
|
|
256
|
+
"Pigeon Express временно е недостъпен, моля изберете друг метод за доставка.",
|
|
257
|
+
pigeonNoOfficesInCity:
|
|
258
|
+
"Няма офиси на Pigeon Express във вашия град. Моля изберете друг метод за доставка.",
|
|
223
259
|
// Moved verbatim out of `checkout/address-error-copy.ts`, where these
|
|
224
260
|
// were the package's hardcoded copy for every merchant on earth.
|
|
225
261
|
addressErrors: {
|
package/src/locales/es.ts
CHANGED
|
@@ -65,7 +65,8 @@ export const es: StorefrontLocale = {
|
|
|
65
65
|
noResults: "No se han encontrado productos",
|
|
66
66
|
filters: "Filtros",
|
|
67
67
|
clearFilters: "Borrar filtros",
|
|
68
|
-
|
|
68
|
+
// The brand line stays in English in every language (Alexander, 2026-09-17).
|
|
69
|
+
poweredBy: "Powered by",
|
|
69
70
|
paymentMethods: "Métodos de pago",
|
|
70
71
|
socialProfiles: "Síguenos",
|
|
71
72
|
},
|
|
@@ -137,6 +138,9 @@ export const es: StorefrontLocale = {
|
|
|
137
138
|
addToOrder: "+ Añadir al pedido",
|
|
138
139
|
addCode: "Aplicar",
|
|
139
140
|
|
|
141
|
+
cartEmptyTitle: "Tu carrito está vacío",
|
|
142
|
+
cartEmptyText: "Añade algo y tu pedido empieza aquí.",
|
|
143
|
+
cartEmptyBack: "Volver a la tienda",
|
|
140
144
|
deliveryDisabled:
|
|
141
145
|
"Introduce tu dirección de entrega para ver las opciones de envío disponibles.",
|
|
142
146
|
noShippingOptions: "No hay opciones de envío disponibles para tu dirección.",
|
|
@@ -162,6 +166,7 @@ export const es: StorefrontLocale = {
|
|
|
162
166
|
shipping: "Envío",
|
|
163
167
|
shippingCalc: "Se calcula en el pago",
|
|
164
168
|
shippingFree: "Gratis",
|
|
169
|
+
lineFree: "Gratis",
|
|
165
170
|
// The WORD is the pack's. The RATE is not, and this one was WRONG: it
|
|
166
171
|
// said 20%, which is Bulgaria's rate, while Spain's IVA is 21%. It came
|
|
167
172
|
// from translating the old English default instead of asking whose fact
|
|
@@ -193,7 +198,14 @@ export const es: StorefrontLocale = {
|
|
|
193
198
|
companyAddress: "Dirección de la empresa",
|
|
194
199
|
|
|
195
200
|
placeOrder: "Realizar pedido",
|
|
201
|
+
completeOrder: "Completar pedido",
|
|
196
202
|
selectPaymentMethod: "Elige un método de pago",
|
|
203
|
+
nothingToPay: "Tu pedido está cubierto por completo. No queda nada por pagar.",
|
|
204
|
+
|
|
205
|
+
errorTitle: "Algo ha fallado en la caja",
|
|
206
|
+
errorBody:
|
|
207
|
+
"Tu carrito está a salvo. Inténtalo de nuevo y, si vuelve a pasar, escríbenos.",
|
|
208
|
+
errorRetry: "Intentar de nuevo",
|
|
197
209
|
|
|
198
210
|
processingCard: [
|
|
199
211
|
"Conectando con el banco...",
|
|
@@ -207,6 +219,18 @@ export const es: StorefrontLocale = {
|
|
|
207
219
|
"Cuidando cada detalle...",
|
|
208
220
|
],
|
|
209
221
|
|
|
222
|
+
pickupLoading: "Cargando puntos de recogida...",
|
|
223
|
+
pickupNearestOffices: "Oficinas más cercanas",
|
|
224
|
+
pickupNearestLockers: "Taquillas más cercanas",
|
|
225
|
+
pickupSearchOffice: "Buscar otra oficina",
|
|
226
|
+
pickupSearchLocker: "Buscar otra taquilla",
|
|
227
|
+
pickupSearchOfficePlaceholder: "Busca por nombre, ciudad o dirección...",
|
|
228
|
+
pickupSearchLockerPlaceholder: "Busca por nombre, ciudad o dirección...",
|
|
229
|
+
pickupNoResults: "Sin resultados",
|
|
230
|
+
pickupChange: "Cambiar",
|
|
231
|
+
pickupUnavailable: "Ahora mismo no hay puntos de recogida disponibles",
|
|
232
|
+
pickupRequired: "Elige un punto de recogida",
|
|
233
|
+
|
|
210
234
|
econtLoadingOffices: "Cargando oficinas...",
|
|
211
235
|
econtNearestOffices: "Oficinas más cercanas",
|
|
212
236
|
econtSearchAnother: "Buscar otra oficina",
|
|
@@ -224,6 +248,16 @@ export const es: StorefrontLocale = {
|
|
|
224
248
|
"BoxNow no está disponible temporalmente, elige otro método de envío.",
|
|
225
249
|
boxnowNoLockersInCity:
|
|
226
250
|
"No hay taquillas de BoxNow en tu ciudad. Elige otro método de envío.",
|
|
251
|
+
pigeonLoadingOffices: "Cargando oficinas...",
|
|
252
|
+
pigeonNearestOffices: "Oficinas más cercanas",
|
|
253
|
+
pigeonSearchAnother: "Buscar otra oficina",
|
|
254
|
+
pigeonSearchPlaceholder: "Busca por nombre, ciudad o dirección...",
|
|
255
|
+
pigeonNoResults: "No se encontraron oficinas para",
|
|
256
|
+
pigeonChange: "Cambiar",
|
|
257
|
+
pigeonUnavailable:
|
|
258
|
+
"Pigeon Express no está disponible temporalmente. Elige otro método de envío.",
|
|
259
|
+
pigeonNoOfficesInCity:
|
|
260
|
+
"No hay oficinas de Pigeon Express en tu ciudad. Elige otro método de envío.",
|
|
227
261
|
addressErrors: {
|
|
228
262
|
generic:
|
|
229
263
|
"No hemos podido guardar tu dirección. Revisa los datos e inténtalo de nuevo.",
|
|
@@ -547,6 +547,89 @@ export const CLICK_ID_METADATA_KEYS: ReadonlyArray<readonly [string, string]> =
|
|
|
547
547
|
[CLICK_ID_COOKIES.oppref, "oai_oppref"],
|
|
548
548
|
]
|
|
549
549
|
|
|
550
|
+
/**
|
|
551
|
+
* Everything the BROWSER can tell the cart about where this order came
|
|
552
|
+
* from, in the same flat keys the server-side `getTrackingAttribution()`
|
|
553
|
+
* produces: the Facebook pair and the visitor id, both UTM tuples, and
|
|
554
|
+
* every ad-click identifier.
|
|
555
|
+
*
|
|
556
|
+
* Why a browser-side twin exists at all. The click ids are captured on the
|
|
557
|
+
* landing page and have to reach the ORDER, and the one moment where a
|
|
558
|
+
* cart certainly exists and is not yet placed is the checkout the shopper
|
|
559
|
+
* is standing in. The server-side reader can only run in a server
|
|
560
|
+
* component or an action, so a checkout written as a client component (the
|
|
561
|
+
* kit's, and every store's) had no way to reach them; the ids sat in the
|
|
562
|
+
* jar until the order was gone. This reads the same cookies through
|
|
563
|
+
* `document.cookie` and hands back a payload for `cart.metadata`, which
|
|
564
|
+
* cart completion copies onto `order.metadata` and the `order.placed`
|
|
565
|
+
* forwarder reads.
|
|
566
|
+
*
|
|
567
|
+
* What it deliberately does NOT carry: the user agent, the client IP, the
|
|
568
|
+
* referer and the GA session id. Those are request facts, so the server
|
|
569
|
+
* reader stays the authority for them, and the two payloads merge by key
|
|
570
|
+
* without ever disagreeing.
|
|
571
|
+
*
|
|
572
|
+
* Consent is the CALLER's to check: this function only reads what is
|
|
573
|
+
* already on the device, but writing it onto an order is a choice the
|
|
574
|
+
* shopper's consent governs.
|
|
575
|
+
*/
|
|
576
|
+
export function readBrowserAttribution(): Record<string, string | number> {
|
|
577
|
+
if (!isBrowser()) return {}
|
|
578
|
+
const out: Record<string, string | number> = {}
|
|
579
|
+
|
|
580
|
+
// getOrCreate, not a plain read: the Pixel writes _fbp on init, and when
|
|
581
|
+
// an ad blocker or a slow script means it never did, ours is the value
|
|
582
|
+
// both sides will share (see the module header).
|
|
583
|
+
const fbp = getOrCreateFbp()
|
|
584
|
+
if (fbp) out.fb_fbp = fbp
|
|
585
|
+
const fbc = getOrCreateFbc()
|
|
586
|
+
if (fbc) out.fb_fbc = fbc
|
|
587
|
+
const anonId = getOrCreateAnonId()
|
|
588
|
+
if (anonId) out.fb_anon_id = anonId
|
|
589
|
+
|
|
590
|
+
const ga = getCookie("_ga")
|
|
591
|
+
if (ga) {
|
|
592
|
+
// "GA1.1.<client_id>.<timestamp>" — the canonical client id is the
|
|
593
|
+
// last two segments joined, exactly as the server reader takes it.
|
|
594
|
+
const parts = ga.split(".")
|
|
595
|
+
out.ga_client_id = parts.length >= 4 ? `${parts[2]}.${parts[3]}` : ga
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const first = getCapturedFirstTouchUtms()
|
|
599
|
+
if (first) applyUtmTuple(first, "utm_first", out)
|
|
600
|
+
const last = getCapturedLastTouchUtms()
|
|
601
|
+
if (last) applyUtmTuple(last, "utm_last", out)
|
|
602
|
+
|
|
603
|
+
for (const [cookieName, metadataKey] of CLICK_ID_METADATA_KEYS) {
|
|
604
|
+
const value = getCookie(cookieName)
|
|
605
|
+
if (!value) continue
|
|
606
|
+
// Written encoded by the capture; decoded once here so the wire
|
|
607
|
+
// carries the id the platform actually issued.
|
|
608
|
+
try {
|
|
609
|
+
out[metadataKey] = decodeURIComponent(value)
|
|
610
|
+
} catch {
|
|
611
|
+
out[metadataKey] = value
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return out
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Flatten one captured tuple onto the payload, dropping empty fields so a
|
|
619
|
+
* partial touch never writes null keys onto an order. */
|
|
620
|
+
function applyUtmTuple(
|
|
621
|
+
utms: CapturedUtms,
|
|
622
|
+
prefix: "utm_first" | "utm_last",
|
|
623
|
+
out: Record<string, string | number>
|
|
624
|
+
): void {
|
|
625
|
+
if (utms.utm_source) out[`${prefix}_source`] = utms.utm_source
|
|
626
|
+
if (utms.utm_medium) out[`${prefix}_medium`] = utms.utm_medium
|
|
627
|
+
if (utms.utm_campaign) out[`${prefix}_campaign`] = utms.utm_campaign
|
|
628
|
+
if (utms.utm_term) out[`${prefix}_term`] = utms.utm_term
|
|
629
|
+
if (utms.utm_content) out[`${prefix}_content`] = utms.utm_content
|
|
630
|
+
out[`${prefix}_captured_at`] = utms.captured_at
|
|
631
|
+
}
|
|
632
|
+
|
|
550
633
|
/**
|
|
551
634
|
* Capture every ad-click identifier present on the current URL.
|
|
552
635
|
*
|
package/src/tracking/consent.ts
CHANGED
|
@@ -51,6 +51,28 @@ export const CONSENT_MAX_AGE_SECONDS = 365 * 24 * 60 * 60
|
|
|
51
51
|
* (dispatched by `openConsentSettings()`, e.g. from a footer link). */
|
|
52
52
|
export const CONSENT_OPEN_EVENT = "1click:consent:open"
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Fired on `window` whenever a decision is applied, by the built-in banner
|
|
56
|
+
* or by an external CMP through `setConsent`. The detail is the choices.
|
|
57
|
+
*
|
|
58
|
+
* A component that must ACT on consent, rather than merely load a tag,
|
|
59
|
+
* needs to hear the answer as well as read it: the checkout writes where
|
|
60
|
+
* an order came from onto the cart, and a shopper who accepts while
|
|
61
|
+
* standing on the checkout page would otherwise be read once, before they
|
|
62
|
+
* chose, and never again. Same reasoning as `applyConsent` itself: the
|
|
63
|
+
* decision is relayed from the one door every choice already goes
|
|
64
|
+
* through, never polled for.
|
|
65
|
+
*/
|
|
66
|
+
export const CONSENT_CHANGED_EVENT = "cartbase:consent:changed"
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Where the store's consent switch is left for the browser by the
|
|
70
|
+
* `ConsentInit` snippet, which is the only component told whether the
|
|
71
|
+
* store collects consent at all (`consent.enabled`). A client component
|
|
72
|
+
* that has to apply the same rule reads it here instead of fetching.
|
|
73
|
+
*/
|
|
74
|
+
export const CONSENT_REQUIRED_FLAG = "__cartbase_consent_required"
|
|
75
|
+
|
|
54
76
|
export type ConsentChoices = {
|
|
55
77
|
/** analytics_storage */
|
|
56
78
|
analytics: boolean
|
|
@@ -143,6 +165,36 @@ export function applyConsent(choices: ConsentChoices): void {
|
|
|
143
165
|
w.oaiq("consent", choices.ads)
|
|
144
166
|
if (choices.ads) w.oaiq("measure", "page_viewed", { type: "contents" })
|
|
145
167
|
}
|
|
168
|
+
|
|
169
|
+
// And the same decision to our own side, for the surfaces that act on
|
|
170
|
+
// consent rather than load a tag (the checkout's attribution write).
|
|
171
|
+
try {
|
|
172
|
+
window.dispatchEvent(
|
|
173
|
+
new CustomEvent(CONSENT_CHANGED_EVENT, { detail: choices })
|
|
174
|
+
)
|
|
175
|
+
} catch {
|
|
176
|
+
// An environment with no CustomEvent: the vendors above were still told.
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* What applies RIGHT NOW: the visitor's stored choice, and before they
|
|
182
|
+
* make one, the store's own default.
|
|
183
|
+
*
|
|
184
|
+
* The rule is the snippet's rule, and there is only one of it (Alexander,
|
|
185
|
+
* 2026-09-14): a banner means consent is collected and nothing fires until
|
|
186
|
+
* it is given; no banner means the store collects none, so there is
|
|
187
|
+
* nothing to wait for. A caller that invented its own answer here would
|
|
188
|
+
* either write an EU shopper's identifiers before they agreed, or silence
|
|
189
|
+
* every store on earth that runs no banner.
|
|
190
|
+
*/
|
|
191
|
+
export function consentDecision(): ConsentChoices {
|
|
192
|
+
const stored = readConsentCookie()
|
|
193
|
+
if (stored) return stored
|
|
194
|
+
const required =
|
|
195
|
+
typeof window !== "undefined" &&
|
|
196
|
+
Boolean((window as unknown as Record<string, unknown>)[CONSENT_REQUIRED_FLAG])
|
|
197
|
+
return { analytics: !required, ads: !required, ts: 0 }
|
|
146
198
|
}
|
|
147
199
|
|
|
148
200
|
/**
|
|
@@ -206,6 +258,7 @@ export function consentInitSnippet(required: boolean): string {
|
|
|
206
258
|
if(m){c=JSON.parse(decodeURIComponent(m[1]));}
|
|
207
259
|
}catch(e){}
|
|
208
260
|
var ads=c?!!c.ads:${fallback}, an=c?!!c.analytics:${fallback};
|
|
261
|
+
window.${CONSENT_REQUIRED_FLAG}=${required};
|
|
209
262
|
window.dataLayer=window.dataLayer||[];
|
|
210
263
|
function gtag(){dataLayer.push(arguments);}
|
|
211
264
|
gtag('consent','default',{
|
package/src/tracking/events.ts
CHANGED
|
@@ -1,29 +1,37 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
trackAddPaymentInfo,
|
|
5
|
+
trackAddShippingInfo,
|
|
4
6
|
trackAddToCart,
|
|
5
7
|
trackInitiateCheckout,
|
|
6
8
|
trackPurchase,
|
|
7
9
|
trackViewContent,
|
|
8
10
|
} from "./fbq"
|
|
9
11
|
import {
|
|
12
|
+
trackGAAddPaymentInfo,
|
|
13
|
+
trackGAAddShippingInfo,
|
|
10
14
|
trackGAAddToCart,
|
|
11
15
|
trackGABeginCheckout,
|
|
12
16
|
trackGAPurchase,
|
|
13
17
|
trackGAViewItem,
|
|
14
18
|
} from "./gtag"
|
|
15
19
|
import {
|
|
20
|
+
trackRybbitAddPaymentInfo,
|
|
21
|
+
trackRybbitAddShippingInfo,
|
|
16
22
|
trackRybbitAddToCart,
|
|
17
23
|
trackRybbitBeginCheckout,
|
|
18
24
|
trackRybbitPurchase,
|
|
19
25
|
trackRybbitViewItem,
|
|
20
26
|
} from "./rybbit-events"
|
|
21
27
|
import {
|
|
28
|
+
trackTikTokAddPaymentInfo,
|
|
22
29
|
trackTikTokAddToCart,
|
|
23
30
|
trackTikTokInitiateCheckout,
|
|
24
31
|
trackTikTokPurchase,
|
|
25
32
|
trackTikTokViewContent,
|
|
26
33
|
} from "./ttq"
|
|
34
|
+
import { checkoutStepEventId } from "./once"
|
|
27
35
|
import {
|
|
28
36
|
minorUnits,
|
|
29
37
|
trackOpenAiCheckoutStarted,
|
|
@@ -259,6 +267,111 @@ export function trackCheckoutStart(input: {
|
|
|
259
267
|
})
|
|
260
268
|
}
|
|
261
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Contact and delivery details complete.
|
|
272
|
+
*
|
|
273
|
+
* The highest match-quality moment in the whole funnel: email, phone,
|
|
274
|
+
* name, city and postcode are all known here, which is why the checkout
|
|
275
|
+
* re-inits the Pixel's advanced matching at the same instant.
|
|
276
|
+
*
|
|
277
|
+
* The event id is DERIVED from the cart, never passed in, for the same
|
|
278
|
+
* reason Purchase derives its own from the order: two tabs on one cart, a
|
|
279
|
+
* restored session or a retried request then collapse into one event on
|
|
280
|
+
* Meta's side instead of counting three times.
|
|
281
|
+
*/
|
|
282
|
+
export function trackCheckoutShippingInfo(input: {
|
|
283
|
+
lines: TrackedLine[]
|
|
284
|
+
currency: string
|
|
285
|
+
value: number
|
|
286
|
+
/** The cart the step belongs to; the dedup key is built from it. */
|
|
287
|
+
cartId: string
|
|
288
|
+
/** The courier chosen, omitted while the shopper has picked none. */
|
|
289
|
+
shippingTier?: string
|
|
290
|
+
coupon?: string
|
|
291
|
+
}): void {
|
|
292
|
+
const { lines, currency, value, cartId, shippingTier, coupon } = input
|
|
293
|
+
trackAddShippingInfo(
|
|
294
|
+
{
|
|
295
|
+
content_ids: lines.map((line) => line.productId),
|
|
296
|
+
content_type: "product",
|
|
297
|
+
currency,
|
|
298
|
+
value,
|
|
299
|
+
num_items: unitCount(lines),
|
|
300
|
+
contents: metaContents(lines),
|
|
301
|
+
...(shippingTier ? { shipping_tier: shippingTier } : {}),
|
|
302
|
+
},
|
|
303
|
+
{ eventId: checkoutStepEventId("AddShippingInfo", cartId) }
|
|
304
|
+
)
|
|
305
|
+
trackGAAddShippingInfo({
|
|
306
|
+
currency,
|
|
307
|
+
value,
|
|
308
|
+
items: gaItems(lines, currency),
|
|
309
|
+
...(shippingTier ? { shipping_tier: shippingTier } : {}),
|
|
310
|
+
...(coupon ? { coupon } : {}),
|
|
311
|
+
})
|
|
312
|
+
trackRybbitAddShippingInfo({
|
|
313
|
+
item_ids: lines.map((line) => line.variantId || line.productId),
|
|
314
|
+
num_items: unitCount(lines),
|
|
315
|
+
currency,
|
|
316
|
+
value,
|
|
317
|
+
...(shippingTier ? { shipping_tier: shippingTier } : {}),
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The shopper has settled how they will pay.
|
|
323
|
+
*
|
|
324
|
+
* Cash on delivery counts. Meta defines the event as "payment information
|
|
325
|
+
* is added in the checkout flow" and GA4 defines `payment_type` as "the
|
|
326
|
+
* chosen method of payment", so choosing to pay the courier completes the
|
|
327
|
+
* step; it simply involves no card. Carrying `payment_type` is what keeps
|
|
328
|
+
* card and cash separable in reporting, which no funnel here could show
|
|
329
|
+
* before.
|
|
330
|
+
*/
|
|
331
|
+
export function trackCheckoutPaymentInfo(input: {
|
|
332
|
+
lines: TrackedLine[]
|
|
333
|
+
currency: string
|
|
334
|
+
value: number
|
|
335
|
+
cartId: string
|
|
336
|
+
/** "card" or "cod". */
|
|
337
|
+
paymentType?: string
|
|
338
|
+
coupon?: string
|
|
339
|
+
}): void {
|
|
340
|
+
const { lines, currency, value, cartId, paymentType, coupon } = input
|
|
341
|
+
trackAddPaymentInfo(
|
|
342
|
+
{
|
|
343
|
+
content_ids: lines.map((line) => line.productId),
|
|
344
|
+
content_type: "product",
|
|
345
|
+
currency,
|
|
346
|
+
value,
|
|
347
|
+
num_items: unitCount(lines),
|
|
348
|
+
contents: metaContents(lines),
|
|
349
|
+
...(paymentType ? { payment_type: paymentType } : {}),
|
|
350
|
+
},
|
|
351
|
+
{ eventId: checkoutStepEventId("AddPaymentInfo", cartId) }
|
|
352
|
+
)
|
|
353
|
+
trackTikTokAddPaymentInfo({
|
|
354
|
+
contents: tiktokContents(lines),
|
|
355
|
+
currency,
|
|
356
|
+
value,
|
|
357
|
+
...(paymentType ? { paymentType } : {}),
|
|
358
|
+
})
|
|
359
|
+
trackGAAddPaymentInfo({
|
|
360
|
+
currency,
|
|
361
|
+
value,
|
|
362
|
+
items: gaItems(lines, currency),
|
|
363
|
+
...(paymentType ? { payment_type: paymentType } : {}),
|
|
364
|
+
...(coupon ? { coupon } : {}),
|
|
365
|
+
})
|
|
366
|
+
trackRybbitAddPaymentInfo({
|
|
367
|
+
item_ids: lines.map((line) => line.variantId || line.productId),
|
|
368
|
+
num_items: unitCount(lines),
|
|
369
|
+
currency,
|
|
370
|
+
value,
|
|
371
|
+
...(paymentType ? { payment_type: paymentType } : {}),
|
|
372
|
+
})
|
|
373
|
+
}
|
|
374
|
+
|
|
262
375
|
/**
|
|
263
376
|
* Order confirmed — the money event.
|
|
264
377
|
*
|
package/src/tracking/fbq.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import type {
|
|
4
|
+
AddPaymentInfoData,
|
|
5
|
+
AddShippingInfoData,
|
|
4
6
|
AddToCartData,
|
|
5
7
|
InitiateCheckoutData,
|
|
6
8
|
LeadData,
|
|
@@ -122,6 +124,52 @@ export function trackInitiateCheckout(data: InitiateCheckoutData): void {
|
|
|
122
124
|
})
|
|
123
125
|
}
|
|
124
126
|
|
|
127
|
+
/**
|
|
128
|
+
* The event id a mid-funnel checkout step carries. Callers pass the
|
|
129
|
+
* deterministic one from `checkoutStepEventId(event, cartId)` so two tabs
|
|
130
|
+
* on the same cart collapse into one event on Meta's side instead of
|
|
131
|
+
* counting twice.
|
|
132
|
+
*/
|
|
133
|
+
export type CheckoutStepContext = {
|
|
134
|
+
eventId?: string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* AddShippingInfo — the shopper's contact and delivery details are
|
|
139
|
+
* complete. The highest match-quality moment in the funnel: email, phone,
|
|
140
|
+
* name, city and postcode are all known, which is why the advanced
|
|
141
|
+
* matching re-init runs beside it.
|
|
142
|
+
*
|
|
143
|
+
* `trackCustom`, not `track`: AddShippingInfo is not in Meta's standard
|
|
144
|
+
* event vocabulary, and calling `track()` with an unknown name makes the
|
|
145
|
+
* Pixel log a warning and treat it as custom anyway.
|
|
146
|
+
*/
|
|
147
|
+
export function trackAddShippingInfo(
|
|
148
|
+
data: AddShippingInfoData,
|
|
149
|
+
context: CheckoutStepContext = {}
|
|
150
|
+
): void {
|
|
151
|
+
const fbq = safeFbq()
|
|
152
|
+
if (!fbq) return
|
|
153
|
+
fbq(
|
|
154
|
+
"trackCustom",
|
|
155
|
+
"AddShippingInfo",
|
|
156
|
+
data as unknown as Record<string, unknown>,
|
|
157
|
+
{ eventID: context.eventId ?? generateEventId("AddShippingInfo") }
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** AddPaymentInfo — the shopper has settled how they will pay. */
|
|
162
|
+
export function trackAddPaymentInfo(
|
|
163
|
+
data: AddPaymentInfoData,
|
|
164
|
+
context: CheckoutStepContext = {}
|
|
165
|
+
): void {
|
|
166
|
+
const fbq = safeFbq()
|
|
167
|
+
if (!fbq) return
|
|
168
|
+
fbq("track", "AddPaymentInfo", data as unknown as Record<string, unknown>, {
|
|
169
|
+
eventID: context.eventId ?? generateEventId("AddPaymentInfo"),
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
125
173
|
/**
|
|
126
174
|
* Purchase event — eventID MUST equal `purchase_${order.display_id}`
|
|
127
175
|
* to dedupe with the Cartbase backend's order.placed CAPI Purchase.
|
package/src/tracking/gtag.ts
CHANGED
|
@@ -56,6 +56,24 @@ type GA4BeginCheckoutData = {
|
|
|
56
56
|
coupon?: string
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
type GA4AddShippingInfoData = {
|
|
60
|
+
currency: string
|
|
61
|
+
value: number
|
|
62
|
+
items: GA4Item[]
|
|
63
|
+
/** The delivery method the shopper chose, GA4's own field name. */
|
|
64
|
+
shipping_tier?: string
|
|
65
|
+
coupon?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type GA4AddPaymentInfoData = {
|
|
69
|
+
currency: string
|
|
70
|
+
value: number
|
|
71
|
+
items: GA4Item[]
|
|
72
|
+
/** "card" or "cod" here: GA4 defines it as the chosen method of payment. */
|
|
73
|
+
payment_type?: string
|
|
74
|
+
coupon?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
59
77
|
type GA4PurchaseData = {
|
|
60
78
|
transaction_id: string
|
|
61
79
|
currency: string
|
|
@@ -101,6 +119,18 @@ export function trackGABeginCheckout(data: GA4BeginCheckoutData): void {
|
|
|
101
119
|
gtag("event", "begin_checkout", data)
|
|
102
120
|
}
|
|
103
121
|
|
|
122
|
+
export function trackGAAddShippingInfo(data: GA4AddShippingInfoData): void {
|
|
123
|
+
const gtag = safeGtag()
|
|
124
|
+
if (!gtag) return
|
|
125
|
+
gtag("event", "add_shipping_info", data)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function trackGAAddPaymentInfo(data: GA4AddPaymentInfoData): void {
|
|
129
|
+
const gtag = safeGtag()
|
|
130
|
+
if (!gtag) return
|
|
131
|
+
gtag("event", "add_payment_info", data)
|
|
132
|
+
}
|
|
133
|
+
|
|
104
134
|
/**
|
|
105
135
|
* Purchase event — GA4 deduplicates by `transaction_id`. Pass the SAME
|
|
106
136
|
* `String(order.display_id)` here AND in the backend Measurement Protocol
|
|
@@ -209,5 +239,7 @@ export type {
|
|
|
209
239
|
GA4ViewItemData,
|
|
210
240
|
GA4AddToCartData,
|
|
211
241
|
GA4BeginCheckoutData,
|
|
242
|
+
GA4AddShippingInfoData,
|
|
243
|
+
GA4AddPaymentInfoData,
|
|
212
244
|
GA4PurchaseData,
|
|
213
245
|
}
|
package/src/tracking/index.ts
CHANGED
|
@@ -29,7 +29,10 @@
|
|
|
29
29
|
* analytics destination)
|
|
30
30
|
* - ONE call per commerce moment, every vendor at once (prefer these):
|
|
31
31
|
* trackProductView / trackCartAdd / trackCheckoutStart /
|
|
32
|
-
*
|
|
32
|
+
* trackCheckoutShippingInfo / trackCheckoutPaymentInfo /
|
|
33
|
+
* trackOrderPurchase. The three checkout moments are fired FOR a store
|
|
34
|
+
* by `useCheckoutOrchestration` (see `useCheckoutFunnel`), so a
|
|
35
|
+
* checkout built on the kit reports the whole funnel with no wiring
|
|
33
36
|
* - Meta Pixel client helpers: trackViewContent / trackAddToCart /
|
|
34
37
|
* trackInitiateCheckout / trackPurchase / trackLead
|
|
35
38
|
* - GA4 client helpers: trackGAViewItem / trackGAAddToCart /
|
|
@@ -76,6 +79,9 @@ export {
|
|
|
76
79
|
setConsent,
|
|
77
80
|
openConsentSettings,
|
|
78
81
|
readConsentCookie,
|
|
82
|
+
consentDecision,
|
|
83
|
+
CONSENT_CHANGED_EVENT,
|
|
84
|
+
CONSENT_REQUIRED_FLAG,
|
|
79
85
|
writeConsentCookie,
|
|
80
86
|
shouldRenderBanner,
|
|
81
87
|
pickConsentCopy,
|
|
@@ -95,11 +101,22 @@ export {
|
|
|
95
101
|
trackViewContent,
|
|
96
102
|
trackAddToCart,
|
|
97
103
|
trackInitiateCheckout,
|
|
104
|
+
trackAddShippingInfo,
|
|
105
|
+
trackAddPaymentInfo,
|
|
98
106
|
trackPurchase,
|
|
99
107
|
trackLead,
|
|
100
108
|
generateEventId,
|
|
109
|
+
type CheckoutStepContext,
|
|
101
110
|
type LeadContext,
|
|
102
111
|
} from "./fbq"
|
|
112
|
+
// The once-per-cart guard behind the checkout funnel's mid-funnel steps,
|
|
113
|
+
// and the deterministic event id that backs it up on Meta's side.
|
|
114
|
+
export {
|
|
115
|
+
markFiredOnce,
|
|
116
|
+
hasFired,
|
|
117
|
+
forgetFiredEvents,
|
|
118
|
+
checkoutStepEventId,
|
|
119
|
+
} from "./once"
|
|
103
120
|
export {
|
|
104
121
|
setTrackingDefaults,
|
|
105
122
|
getOrCreateAnonId,
|
|
@@ -109,6 +126,7 @@ export {
|
|
|
109
126
|
rememberKnownVisitor,
|
|
110
127
|
captureUtmsFromUrl,
|
|
111
128
|
captureClickIdsFromUrl,
|
|
129
|
+
readBrowserAttribution,
|
|
112
130
|
getCapturedFirstTouchUtms,
|
|
113
131
|
getCapturedLastTouchUtms,
|
|
114
132
|
sha256Hex,
|
|
@@ -124,6 +142,8 @@ export {
|
|
|
124
142
|
trackProductView,
|
|
125
143
|
trackCartAdd,
|
|
126
144
|
trackCheckoutStart,
|
|
145
|
+
trackCheckoutShippingInfo,
|
|
146
|
+
trackCheckoutPaymentInfo,
|
|
127
147
|
trackOrderPurchase,
|
|
128
148
|
type TrackedLine,
|
|
129
149
|
type TrackedOrder,
|
|
@@ -132,6 +152,7 @@ export {
|
|
|
132
152
|
trackTikTokViewContent,
|
|
133
153
|
trackTikTokAddToCart,
|
|
134
154
|
trackTikTokInitiateCheckout,
|
|
155
|
+
trackTikTokAddPaymentInfo,
|
|
135
156
|
trackTikTokPurchase,
|
|
136
157
|
applyTikTokConsent,
|
|
137
158
|
tiktokPurchaseEventId,
|
|
@@ -157,6 +178,8 @@ export {
|
|
|
157
178
|
trackGAViewItem,
|
|
158
179
|
trackGAAddToCart,
|
|
159
180
|
trackGABeginCheckout,
|
|
181
|
+
trackGAAddShippingInfo,
|
|
182
|
+
trackGAAddPaymentInfo,
|
|
160
183
|
trackGAPurchase,
|
|
161
184
|
setEnhancedConversions,
|
|
162
185
|
type EnhancedConversionsInput,
|
|
@@ -165,10 +188,14 @@ export {
|
|
|
165
188
|
trackRybbitViewItem,
|
|
166
189
|
trackRybbitAddToCart,
|
|
167
190
|
trackRybbitBeginCheckout,
|
|
191
|
+
trackRybbitAddShippingInfo,
|
|
192
|
+
trackRybbitAddPaymentInfo,
|
|
168
193
|
trackRybbitPurchase,
|
|
169
194
|
type RybbitViewItemData,
|
|
170
195
|
type RybbitAddToCartData,
|
|
171
196
|
type RybbitBeginCheckoutData,
|
|
197
|
+
type RybbitAddShippingInfoData,
|
|
198
|
+
type RybbitAddPaymentInfoData,
|
|
172
199
|
type RybbitPurchaseData,
|
|
173
200
|
} from "./rybbit-events"
|
|
174
201
|
export { getTrackingConfig } from "./get-tracking-config"
|
|
@@ -188,6 +215,8 @@ export type {
|
|
|
188
215
|
ViewContentData,
|
|
189
216
|
AddToCartData,
|
|
190
217
|
InitiateCheckoutData,
|
|
218
|
+
AddShippingInfoData,
|
|
219
|
+
AddPaymentInfoData,
|
|
191
220
|
PurchaseData,
|
|
192
221
|
LeadData,
|
|
193
222
|
} from "./types"
|
|
@@ -197,5 +226,7 @@ export type {
|
|
|
197
226
|
GA4ViewItemData,
|
|
198
227
|
GA4AddToCartData,
|
|
199
228
|
GA4BeginCheckoutData,
|
|
229
|
+
GA4AddShippingInfoData,
|
|
230
|
+
GA4AddPaymentInfoData,
|
|
200
231
|
GA4PurchaseData,
|
|
201
232
|
} from "./gtag"
|