@cartbase/storefront 0.21.0 → 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 (40) hide show
  1. package/package.json +14 -1
  2. package/src/api/checkout.ts +15 -0
  3. package/src/api/http.ts +9 -0
  4. package/src/api/integrations.ts +121 -0
  5. package/src/checkout/card-offer.ts +68 -0
  6. package/src/checkout/carrier-marks.ts +53 -0
  7. package/src/checkout/checkout-client.tsx +71 -13
  8. package/src/checkout/checkout-error-screen.tsx +113 -0
  9. package/src/checkout/discount-section.tsx +88 -56
  10. package/src/checkout/fulfillment-option.ts +30 -0
  11. package/src/checkout/index.ts +41 -6
  12. package/src/checkout/labels.ts +98 -0
  13. package/src/checkout/line-item-card.tsx +35 -83
  14. package/src/checkout/mobile-checkout-bottom-bar.tsx +132 -0
  15. package/src/checkout/mobile-checkout-top-bar.tsx +94 -0
  16. package/src/checkout/mobile-order-summary-body.tsx +172 -0
  17. package/src/checkout/order-summary.tsx +85 -179
  18. package/src/checkout/payment-button.tsx +25 -8
  19. package/src/checkout/payment-method-list.tsx +51 -13
  20. package/src/checkout/payment-wrapper.tsx +57 -13
  21. package/src/checkout/pickup-option.ts +35 -0
  22. package/src/checkout/pickup-point-selector.tsx +372 -0
  23. package/src/checkout/pigeon-office-selector.tsx +379 -0
  24. package/src/checkout/shipping-method-list.tsx +102 -10
  25. package/src/checkout/summary-math.ts +152 -0
  26. package/src/checkout/use-checkout-funnel.ts +303 -0
  27. package/src/checkout/use-checkout-orchestration.ts +384 -30
  28. package/src/lib/stripe-env.ts +25 -0
  29. package/src/locales/bg.ts +37 -1
  30. package/src/locales/es.ts +35 -1
  31. package/src/tracking/attribution.ts +83 -0
  32. package/src/tracking/consent.ts +53 -0
  33. package/src/tracking/events.ts +113 -0
  34. package/src/tracking/fbq.ts +48 -0
  35. package/src/tracking/gtag.ts +32 -0
  36. package/src/tracking/index.ts +32 -1
  37. package/src/tracking/once.ts +137 -0
  38. package/src/tracking/rybbit-events.ts +42 -0
  39. package/src/tracking/ttq.ts +24 -0
  40. package/src/tracking/types.ts +34 -0
@@ -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
+ }
@@ -179,6 +179,22 @@ export type RybbitBeginCheckoutData = {
179
179
  value: number
180
180
  }
181
181
 
182
+ export type RybbitAddShippingInfoData = {
183
+ item_ids: string[]
184
+ num_items: number
185
+ currency: string
186
+ value: number
187
+ shipping_tier?: string
188
+ }
189
+
190
+ export type RybbitAddPaymentInfoData = {
191
+ item_ids: string[]
192
+ num_items: number
193
+ currency: string
194
+ value: number
195
+ payment_type?: string
196
+ }
197
+
182
198
  export type RybbitPurchaseData = {
183
199
  transaction_id: string
184
200
  item_ids: string[]
@@ -217,6 +233,32 @@ export function trackRybbitBeginCheckout(
217
233
  })
218
234
  }
219
235
 
236
+ export function trackRybbitAddShippingInfo(
237
+ data: RybbitAddShippingInfoData
238
+ ): void {
239
+ fireEvent("add_shipping_info", {
240
+ item_ids: joinIds(data.item_ids),
241
+ num_items: data.num_items,
242
+ currency: data.currency,
243
+ value: data.value,
244
+ // Rybbit accepts string and number only, so an empty tier is omitted
245
+ // rather than sent as "".
246
+ ...(data.shipping_tier ? { shipping_tier: data.shipping_tier } : {}),
247
+ })
248
+ }
249
+
250
+ export function trackRybbitAddPaymentInfo(
251
+ data: RybbitAddPaymentInfoData
252
+ ): void {
253
+ fireEvent("add_payment_info", {
254
+ item_ids: joinIds(data.item_ids),
255
+ num_items: data.num_items,
256
+ currency: data.currency,
257
+ value: data.value,
258
+ ...(data.payment_type ? { payment_type: data.payment_type } : {}),
259
+ })
260
+ }
261
+
220
262
  /**
221
263
  * Purchase event — fired client-side on the order-confirmed page.
222
264
  *
@@ -141,6 +141,30 @@ export function trackTikTokInitiateCheckout(data: {
141
141
  })
142
142
  }
143
143
 
144
+ /**
145
+ * AddPaymentInfo — TikTok's own standard event, fired when the shopper has
146
+ * settled how they will pay. There is no TikTok equivalent of
147
+ * `add_shipping_info`, so the shipping step reaches Meta, GA4 and Rybbit
148
+ * and stops there; inventing a custom event on this vendor would optimise
149
+ * nothing and report nowhere.
150
+ */
151
+ export function trackTikTokAddPaymentInfo(data: {
152
+ contents: TikTokContentItem[]
153
+ currency: string
154
+ value: number
155
+ paymentType?: string
156
+ }): void {
157
+ const ttq = safeTtq()
158
+ if (!ttq) return
159
+ ttq.track("AddPaymentInfo", {
160
+ contents: data.contents,
161
+ content_type: "product",
162
+ currency: data.currency,
163
+ value: data.value,
164
+ ...(data.paymentType ? { payment_type: data.paymentType } : {}),
165
+ })
166
+ }
167
+
144
168
  export function trackTikTokPurchase(data: {
145
169
  contents: TikTokContentItem[]
146
170
  currency: string
@@ -93,6 +93,40 @@ export type InitiateCheckoutData = {
93
93
  contents: MetaContentItem[]
94
94
  }
95
95
 
96
+ /**
97
+ * AddShippingInfo. Not one of Meta's standard events, so the Pixel takes
98
+ * it through `trackCustom`; GA4 and Rybbit both name it
99
+ * `add_shipping_info`. `shipping_tier` is the courier the shopper chose,
100
+ * omitted while they have filled the address but picked nobody yet.
101
+ */
102
+ export type AddShippingInfoData = {
103
+ content_ids: string[]
104
+ content_type: "product" | "product_group"
105
+ currency: string
106
+ value: number
107
+ num_items: number
108
+ contents: MetaContentItem[]
109
+ shipping_tier?: string
110
+ }
111
+
112
+ /**
113
+ * AddPaymentInfo, a Meta standard event. Cash on delivery counts: Meta
114
+ * defines the event as "payment information is added in the checkout
115
+ * flow" and GA4 defines `payment_type` as "the chosen method of payment",
116
+ * so choosing to pay the courier completes the step, it just involves no
117
+ * card. Carrying `payment_type` is what keeps the two paths separable in
118
+ * reporting.
119
+ */
120
+ export type AddPaymentInfoData = {
121
+ content_ids: string[]
122
+ content_type: "product" | "product_group"
123
+ currency: string
124
+ value: number
125
+ num_items: number
126
+ contents: MetaContentItem[]
127
+ payment_type?: string
128
+ }
129
+
96
130
  export type PurchaseData = {
97
131
  content_ids: string[]
98
132
  content_type: "product" | "product_group"