@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.
Files changed (42) hide show
  1. package/README.md +9 -0
  2. package/package.json +274 -261
  3. package/src/api/checkout.ts +15 -0
  4. package/src/api/http.ts +9 -0
  5. package/src/api/integrations.ts +126 -0
  6. package/src/checkout/card-offer.ts +68 -0
  7. package/src/checkout/carrier-marks.ts +53 -0
  8. package/src/checkout/checkout-client.tsx +71 -13
  9. package/src/checkout/checkout-error-screen.tsx +113 -0
  10. package/src/checkout/discount-section.tsx +88 -56
  11. package/src/checkout/fulfillment-option.ts +30 -0
  12. package/src/checkout/index.ts +41 -6
  13. package/src/checkout/labels.ts +98 -0
  14. package/src/checkout/line-item-card.tsx +35 -83
  15. package/src/checkout/mobile-checkout-bottom-bar.tsx +132 -0
  16. package/src/checkout/mobile-checkout-top-bar.tsx +94 -0
  17. package/src/checkout/mobile-order-summary-body.tsx +172 -0
  18. package/src/checkout/order-summary.tsx +85 -179
  19. package/src/checkout/payment-button.tsx +25 -8
  20. package/src/checkout/payment-method-list.tsx +51 -13
  21. package/src/checkout/payment-wrapper.tsx +57 -13
  22. package/src/checkout/pickup-option.ts +35 -0
  23. package/src/checkout/pickup-point-selector.tsx +328 -0
  24. package/src/checkout/pickup-points.ts +65 -0
  25. package/src/checkout/pigeon-office-selector.tsx +379 -0
  26. package/src/checkout/shipping-method-list.tsx +102 -10
  27. package/src/checkout/summary-math.ts +152 -0
  28. package/src/checkout/use-checkout-funnel.ts +303 -0
  29. package/src/checkout/use-checkout-orchestration.ts +384 -30
  30. package/src/lib/stripe-env.ts +25 -0
  31. package/src/locales/bg.ts +37 -1
  32. package/src/locales/es.ts +35 -1
  33. package/src/tracking/attribution.ts +83 -0
  34. package/src/tracking/consent.ts +53 -0
  35. package/src/tracking/events.ts +113 -0
  36. package/src/tracking/fbq.ts +48 -0
  37. package/src/tracking/gtag.ts +32 -0
  38. package/src/tracking/index.ts +32 -1
  39. package/src/tracking/once.ts +137 -0
  40. package/src/tracking/rybbit-events.ts +42 -0
  41. package/src/tracking/ttq.ts +24 -0
  42. 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
- poweredBy: "Създаден с",
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
- poweredBy: "Con la tecnología de",
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
  *
@@ -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',{
@@ -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
  *
@@ -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.
@@ -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
  }
@@ -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
- * trackOrderPurchase
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"