@cartbase/storefront 0.20.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/package.json +274 -258
  2. package/src/api/checkout.ts +15 -0
  3. package/src/api/http.ts +10 -0
  4. package/src/api/integrations.ts +121 -0
  5. package/src/api/store.ts +21 -0
  6. package/src/cart-drawer/payment-badges.tsx +36 -96
  7. package/src/checkout/card-offer.ts +68 -0
  8. package/src/checkout/carrier-marks.ts +53 -0
  9. package/src/checkout/checkout-client.tsx +71 -13
  10. package/src/checkout/checkout-error-screen.tsx +113 -0
  11. package/src/checkout/discount-section.tsx +88 -56
  12. package/src/checkout/fulfillment-option.ts +30 -0
  13. package/src/checkout/index.ts +41 -6
  14. package/src/checkout/labels.ts +98 -0
  15. package/src/checkout/line-item-card.tsx +35 -83
  16. package/src/checkout/mobile-checkout-bottom-bar.tsx +132 -0
  17. package/src/checkout/mobile-checkout-top-bar.tsx +94 -0
  18. package/src/checkout/mobile-order-summary-body.tsx +172 -0
  19. package/src/checkout/order-summary.tsx +85 -179
  20. package/src/checkout/payment-button.tsx +25 -8
  21. package/src/checkout/payment-method-list.tsx +51 -13
  22. package/src/checkout/payment-wrapper.tsx +57 -13
  23. package/src/checkout/pickup-option.ts +35 -0
  24. package/src/checkout/pickup-point-selector.tsx +372 -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/common/icons/cartbase-mark.ts +9 -0
  31. package/src/common/icons/payment-marks.ts +73 -0
  32. package/src/common/icons/social-marks.ts +61 -0
  33. package/src/common/index.ts +15 -0
  34. package/src/common/payment-icons.tsx +54 -0
  35. package/src/common/powered-by-cartbase.tsx +47 -0
  36. package/src/common/social-links.tsx +65 -0
  37. package/src/lib/stripe-env.ts +25 -0
  38. package/src/locales/bg.ts +39 -0
  39. package/src/locales/es.ts +37 -0
  40. package/src/store/labels.ts +10 -0
  41. package/src/tracking/attribution.ts +83 -0
  42. package/src/tracking/consent.ts +53 -0
  43. package/src/tracking/events.ts +113 -0
  44. package/src/tracking/fbq.ts +48 -0
  45. package/src/tracking/gtag.ts +32 -0
  46. package/src/tracking/index.ts +32 -1
  47. package/src/tracking/once.ts +137 -0
  48. package/src/tracking/rybbit-events.ts +42 -0
  49. package/src/tracking/ttq.ts +24 -0
  50. package/src/tracking/types.ts +34 -0
@@ -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"
@@ -0,0 +1,137 @@
1
+ "use client"
2
+
3
+ /**
4
+ * once — "fire this tracking event at most once per cart" guard.
5
+ *
6
+ * Why this exists:
7
+ *
8
+ * The mid-funnel checkout events (add_shipping_info, add_payment_info)
9
+ * are driven by FORM STATE, not by a one-shot user action. A shopper
10
+ * can switch courier, toggle card and cash on delivery, correct a typo
11
+ * in their address or refresh the page, and each of those re-runs the
12
+ * effect that would fire the event. Without a guard one shopper emits a
13
+ * dozen add_payment_info events, which wrecks the funnel ratios in GA4
14
+ * and Rybbit and teaches Meta's optimizer that the event is worthless.
15
+ *
16
+ * A `useRef` guard is NOT enough: it lives in React memory and resets on
17
+ * every remount, so a refresh or a walk back into the checkout re-fires.
18
+ * The guard has to outlive the component.
19
+ *
20
+ * Design:
21
+ *
22
+ * - Keyed by `${event}:${cartId}` so a genuinely NEW cart (the shopper
23
+ * bought, then started a second order) fires its own events. The same
24
+ * cart never fires twice.
25
+ * - `sessionStorage`, not `localStorage`: the guard should expire when
26
+ * the browsing session does. A shopper returning tomorrow to the same
27
+ * abandoned cart is a new session and legitimately re-enters the
28
+ * funnel.
29
+ * - An in-memory Set mirrors the store, so repeated calls within one
30
+ * page never touch sessionStorage (Safari throws on quota and in
31
+ * private mode; we degrade to memory-only rather than fire twice).
32
+ *
33
+ * Belt and braces: callers pair this with a DETERMINISTIC `event_id` (see
34
+ * `checkoutStepEventId`) so that even if the guard is defeated, by two
35
+ * tabs on one cart or by storage cleared mid-session, Meta still collapses
36
+ * the duplicates server-side by event_id, exactly as it does for
37
+ * Purchase's `purchase_${display_id}`.
38
+ */
39
+
40
+ const STORAGE_KEY = "cartbase:fired-events"
41
+
42
+ /** Mirrors sessionStorage so repeat calls in one page skip storage I/O. */
43
+ const memory = new Set<string>()
44
+
45
+ function readStore(): Set<string> {
46
+ if (memory.size > 0) return memory
47
+ if (typeof window === "undefined") return memory
48
+ try {
49
+ const raw = window.sessionStorage.getItem(STORAGE_KEY)
50
+ if (raw) {
51
+ const parsed = JSON.parse(raw) as unknown
52
+ if (Array.isArray(parsed)) {
53
+ for (const k of parsed) if (typeof k === "string") memory.add(k)
54
+ }
55
+ }
56
+ } catch {
57
+ // Private mode, quota, corrupt JSON: memory-only from here.
58
+ }
59
+ return memory
60
+ }
61
+
62
+ function persist(): void {
63
+ if (typeof window === "undefined") return
64
+ try {
65
+ window.sessionStorage.setItem(
66
+ STORAGE_KEY,
67
+ JSON.stringify(Array.from(memory))
68
+ )
69
+ } catch {
70
+ // Storage unavailable: the in-memory Set still dedupes this page.
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Returns true the FIRST time it is called for a given (event, cartId)
76
+ * pair and false every time after, marking the pair as fired.
77
+ *
78
+ * Callers treat it as the gate itself:
79
+ *
80
+ * if (!markFiredOnce("add_payment_info", cart.id)) return
81
+ * trackCheckoutPaymentInfo(...)
82
+ *
83
+ * Returns false when `cartId` is missing: a checkout-step event with no
84
+ * cart to key on cannot be deduped, and firing an undedupable event is
85
+ * worse than dropping it.
86
+ */
87
+ export function markFiredOnce(
88
+ event: string,
89
+ cartId: string | undefined | null
90
+ ): boolean {
91
+ if (!cartId) return false
92
+ if (typeof window === "undefined") return false
93
+
94
+ const key = `${event}:${cartId}`
95
+ const store = readStore()
96
+ if (store.has(key)) return false
97
+
98
+ store.add(key)
99
+ persist()
100
+ return true
101
+ }
102
+
103
+ /** Read-only check, does not mark. For conditional UI and debugging. */
104
+ export function hasFired(
105
+ event: string,
106
+ cartId: string | undefined | null
107
+ ): boolean {
108
+ if (!cartId || typeof window === "undefined") return false
109
+ return readStore().has(`${event}:${cartId}`)
110
+ }
111
+
112
+ /**
113
+ * Deterministic Meta `event_id` for a checkout-step event.
114
+ *
115
+ * Unlike `generateEventId()` (random, for events with no natural key),
116
+ * this derives the id from the cart, so the SAME logical action always
117
+ * produces the SAME id. That gives Meta a server-side dedup key: two tabs,
118
+ * a restored session or a retried request all collapse into one event
119
+ * instead of inflating the count. Same principle as Purchase's
120
+ * `purchase_${display_id}`.
121
+ */
122
+ export function checkoutStepEventId(event: string, cartId: string): string {
123
+ return `${event}_${cartId}`
124
+ }
125
+
126
+ /**
127
+ * Forget every guard for one cart. Called when the order is placed, so a
128
+ * shopper who starts a second order in the same session is not silenced by
129
+ * the keys of the cart they just bought.
130
+ */
131
+ export function forgetFiredEvents(cartId: string | undefined | null): void {
132
+ if (!cartId) return
133
+ for (const key of Array.from(memory)) {
134
+ if (key.endsWith(`:${cartId}`)) memory.delete(key)
135
+ }
136
+ persist()
137
+ }