@cartbase/storefront 0.20.0 → 0.21.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 (109) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +4 -1
  3. package/src/api/auth.ts +108 -108
  4. package/src/api/carts.ts +523 -523
  5. package/src/api/categories.ts +184 -184
  6. package/src/api/checkout.ts +526 -526
  7. package/src/api/collections.ts +130 -130
  8. package/src/api/consent.ts +75 -75
  9. package/src/api/content.ts +125 -125
  10. package/src/api/customers.ts +303 -303
  11. package/src/api/gift-cards.ts +112 -112
  12. package/src/api/http.ts +181 -180
  13. package/src/api/index.ts +30 -30
  14. package/src/api/menus.ts +77 -77
  15. package/src/api/metaobjects.ts +136 -136
  16. package/src/api/orders.ts +290 -290
  17. package/src/api/redirects.ts +37 -37
  18. package/src/api/regions.ts +200 -200
  19. package/src/api/search.ts +163 -163
  20. package/src/api/store.ts +56 -35
  21. package/src/api/types.ts +91 -91
  22. package/src/cart-drawer/context.tsx +778 -778
  23. package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
  24. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
  25. package/src/cart-drawer/gift-wrap.tsx +82 -82
  26. package/src/cart-drawer/item/index.tsx +162 -162
  27. package/src/cart-drawer/item/upsell.tsx +110 -110
  28. package/src/cart-drawer/labels.ts +123 -123
  29. package/src/cart-drawer/mutation-queue.ts +78 -78
  30. package/src/cart-drawer/notes.tsx +131 -131
  31. package/src/cart-drawer/payment-badges.tsx +36 -96
  32. package/src/cart-drawer/sticky-footer.tsx +73 -73
  33. package/src/cart-drawer/summary-breakdown.tsx +197 -197
  34. package/src/checkout/address-error-copy.ts +117 -117
  35. package/src/checkout/boxnow-locker-selector.tsx +410 -410
  36. package/src/checkout/compare-addresses.ts +40 -40
  37. package/src/checkout/discount-section.tsx +218 -218
  38. package/src/checkout/error-copy-codes.ts +63 -63
  39. package/src/checkout/geocode.ts +154 -154
  40. package/src/checkout/gift-card-section.tsx +224 -224
  41. package/src/checkout/index.ts +72 -72
  42. package/src/checkout/labels.ts +495 -495
  43. package/src/checkout/payment-button.tsx +372 -372
  44. package/src/checkout/payment-error-copy.ts +154 -154
  45. package/src/checkout/promotion-error-copy.ts +91 -91
  46. package/src/common/country-flag.tsx +52 -52
  47. package/src/common/country-select.tsx +11 -11
  48. package/src/common/icons/cartbase-mark.ts +9 -0
  49. package/src/common/icons/payment-marks.ts +73 -0
  50. package/src/common/icons/social-marks.ts +61 -0
  51. package/src/common/index.ts +35 -20
  52. package/src/common/market-select.tsx +57 -57
  53. package/src/common/payment-icons.tsx +54 -0
  54. package/src/common/powered-by-cartbase.tsx +47 -0
  55. package/src/common/social-links.tsx +65 -0
  56. package/src/index.ts +12 -12
  57. package/src/lib/country-name.ts +59 -59
  58. package/src/lib/get-product-price.ts +133 -133
  59. package/src/lib/media-image.tsx +39 -39
  60. package/src/lib/payment-constants.ts +53 -53
  61. package/src/lib/platform.ts +13 -13
  62. package/src/lib/price.tsx +39 -39
  63. package/src/lib/store-api-error.ts +36 -36
  64. package/src/lib/variant-caption.ts +32 -32
  65. package/src/locales/bg.ts +3 -0
  66. package/src/locales/context.ts +37 -37
  67. package/src/locales/en.ts +26 -26
  68. package/src/locales/es.ts +3 -0
  69. package/src/locales/index.ts +19 -19
  70. package/src/locales/provider.tsx +59 -59
  71. package/src/locales/types.ts +77 -77
  72. package/src/order/index.ts +62 -62
  73. package/src/order/labels.ts +79 -79
  74. package/src/order/order-totals.tsx +250 -250
  75. package/src/primitives/select-field.tsx +93 -93
  76. package/src/products/image-gallery.tsx +43 -43
  77. package/src/products/option-select.tsx +67 -67
  78. package/src/products/product-info.tsx +5 -5
  79. package/src/products/product-price.tsx +69 -69
  80. package/src/products/product-promises.tsx +61 -61
  81. package/src/products/product-specs.tsx +65 -65
  82. package/src/products/product-tabs.tsx +123 -123
  83. package/src/products/purchase-options.tsx +130 -130
  84. package/src/products/sets.ts +110 -110
  85. package/src/products/variant-matching.ts +71 -71
  86. package/src/products/variant-url.ts +74 -74
  87. package/src/reviews-ui/lightbox-state.ts +46 -46
  88. package/src/reviews-ui/review-header.tsx +166 -166
  89. package/src/reviews-ui/review-lightbox.tsx +271 -271
  90. package/src/reviews-ui/review-list.tsx +193 -193
  91. package/src/reviews-ui/review-widget.tsx +219 -218
  92. package/src/reviews-ui/widget-options.ts +55 -55
  93. package/src/store/category-template.tsx +136 -136
  94. package/src/store/index.ts +40 -40
  95. package/src/store/labels.ts +10 -0
  96. package/src/tracking/chatgpt-pixel.tsx +99 -99
  97. package/src/tracking/consent-init.tsx +62 -62
  98. package/src/tracking/events.ts +348 -348
  99. package/src/tracking/ga4.tsx +93 -93
  100. package/src/tracking/google-ads.ts +84 -84
  101. package/src/tracking/gtm.tsx +60 -60
  102. package/src/tracking/inline-script.ts +49 -49
  103. package/src/tracking/oaiq.ts +206 -206
  104. package/src/tracking/tiktok-pixel.tsx +91 -91
  105. package/src/tracking/track-init.tsx +56 -56
  106. package/src/tracking/track-order-purchase.tsx +122 -122
  107. package/src/tracking/ttq.ts +180 -180
  108. package/src/tracking/use-tracking-config.ts +54 -54
  109. package/theme/index.css +25 -25
@@ -1,348 +1,348 @@
1
- "use client"
2
-
3
- import {
4
- trackAddToCart,
5
- trackInitiateCheckout,
6
- trackPurchase,
7
- trackViewContent,
8
- } from "./fbq"
9
- import {
10
- trackGAAddToCart,
11
- trackGABeginCheckout,
12
- trackGAPurchase,
13
- trackGAViewItem,
14
- } from "./gtag"
15
- import {
16
- trackRybbitAddToCart,
17
- trackRybbitBeginCheckout,
18
- trackRybbitPurchase,
19
- trackRybbitViewItem,
20
- } from "./rybbit-events"
21
- import {
22
- trackTikTokAddToCart,
23
- trackTikTokInitiateCheckout,
24
- trackTikTokPurchase,
25
- trackTikTokViewContent,
26
- } from "./ttq"
27
- import {
28
- minorUnits,
29
- trackOpenAiCheckoutStarted,
30
- trackOpenAiContentsViewed,
31
- trackOpenAiItemsAdded,
32
- trackOpenAiOrderCreated,
33
- type OpenAiContentItem,
34
- } from "./oaiq"
35
- import { googleAdsPurchaseSendTo, trackGoogleAdsPurchase } from "./google-ads"
36
- import type { TrackingConfig } from "./types"
37
-
38
- /**
39
- * ONE call per commerce moment, every vendor at once.
40
- *
41
- * The per-vendor helpers beside this file stay exported and stay usable.
42
- * This is the door a storefront should reach for anyway, because the
43
- * failure these functions prevent is the most common one in tracking and
44
- * it is invisible: a store adds a fifth vendor, updates four call sites,
45
- * misses the fifth, and nobody notices until an ad account has been
46
- * optimising on partial data for a month. We have watched it happen to a
47
- * product page whose buy box predated a shared helper: ViewContent and
48
- * InitiateCheckout were present, AddToCart was missing from the single
49
- * most-used add path in the store.
50
- *
51
- * With one function per moment, a vendor is added HERE, once, and every
52
- * surface that already calls it gains the vendor for free.
53
- *
54
- * Every helper underneath no-ops when its tag is absent, so passing a
55
- * config with only two vendors configured fires exactly those two.
56
- *
57
- * Identifier discipline, and it is not cosmetic:
58
- * - `productId` is the catalogue key. Meta's content_ids, TikTok's
59
- * content_id and the feed's <g:id> must be the SAME value or the
60
- * event matches no catalogue entry, which costs dynamic ads and
61
- * product-level reporting on both platforms.
62
- * - GA4 is the exception on purpose: its items are keyed by VARIANT,
63
- * because GA4 reports on what was actually bought rather than
64
- * matching an ads catalogue.
65
- * - Purchase dedup keys are derived from `displayId` inside the
66
- * helpers, never passed in, so the browser and the server cannot
67
- * drift apart.
68
- */
69
-
70
- /** One line, in the shape every vendor is derived from. */
71
- export type TrackedLine = {
72
- /** Catalogue key. Meta + TikTok content ids, and the feed's <g:id>. */
73
- productId: string
74
- /** What was actually bought. GA4 item_id. */
75
- variantId?: string
76
- title: string
77
- quantity: number
78
- /** Unit price, in `currency`. */
79
- price: number
80
- }
81
-
82
- export type TrackedOrder = {
83
- /** THE dedup key across every vendor and both sides of the wire. */
84
- displayId: string | number
85
- currency: string
86
- /** Order total actually collected. */
87
- value: number
88
- tax?: number
89
- shipping?: number
90
- coupon?: string
91
- lines: TrackedLine[]
92
- /**
93
- * First order for this buyer. Computed server-side by the order.placed
94
- * forwarder and persisted on the order as `metadata.customer_type`;
95
- * read it from there rather than deriving it in the browser, so the two
96
- * sides tell the ad platforms the same thing. Omit when unknown.
97
- */
98
- customerType?: "new" | "returning"
99
- }
100
-
101
- const gaItems = (lines: TrackedLine[], currency: string) =>
102
- lines.map((line, index) => ({
103
- item_id: line.variantId || line.productId,
104
- item_name: line.title,
105
- quantity: line.quantity,
106
- price: line.price,
107
- currency,
108
- index,
109
- }))
110
-
111
- const metaContents = (lines: TrackedLine[]) =>
112
- lines.map((line) => ({
113
- id: line.productId,
114
- quantity: line.quantity,
115
- item_price: line.price,
116
- }))
117
-
118
- const tiktokContents = (lines: TrackedLine[]) =>
119
- lines.map((line) => ({
120
- content_id: line.productId,
121
- content_name: line.title,
122
- quantity: line.quantity,
123
- price: line.price,
124
- }))
125
-
126
- const unitCount = (lines: TrackedLine[]) =>
127
- lines.reduce((sum, line) => sum + (Number(line.quantity) || 0), 0)
128
-
129
- /**
130
- * ChatGPT Ads contents. The shape is close to TikTok's, but every amount
131
- * is an INTEGER IN MINOR UNITS on this vendor, which is why the mapping
132
- * cannot be shared with `tiktokContents` above: the same numbers in the
133
- * same field names would be a hundredfold under-report.
134
- */
135
- const openAiContents = (
136
- lines: TrackedLine[],
137
- currency: string
138
- ): OpenAiContentItem[] =>
139
- lines.map((line) => ({
140
- id: line.productId,
141
- name: line.title,
142
- content_type: "product",
143
- quantity: line.quantity,
144
- amount: minorUnits(line.price),
145
- currency,
146
- }))
147
-
148
- /** Product page view. */
149
- export function trackProductView(input: {
150
- line: TrackedLine
151
- currency: string
152
- value: number
153
- }): void {
154
- const { line, currency, value } = input
155
- trackViewContent({
156
- content_ids: [line.productId],
157
- content_type: "product",
158
- currency,
159
- value,
160
- })
161
- trackTikTokViewContent({
162
- contentId: line.productId,
163
- contentName: line.title,
164
- currency,
165
- value,
166
- })
167
- trackOpenAiContentsViewed({
168
- contentId: line.productId,
169
- contentName: line.title,
170
- currency,
171
- value,
172
- })
173
- trackGAViewItem({ currency, value, items: gaItems([line], currency) })
174
- trackRybbitViewItem({
175
- item_id: line.variantId || line.productId,
176
- item_name: line.title,
177
- currency,
178
- value,
179
- })
180
- }
181
-
182
- /** Add to cart, from ANY surface — listing card, buy box, upsell rail. */
183
- export function trackCartAdd(input: {
184
- line: TrackedLine
185
- currency: string
186
- value: number
187
- }): void {
188
- const { line, currency, value } = input
189
- trackAddToCart({
190
- content_ids: [line.productId],
191
- content_type: "product",
192
- currency,
193
- value,
194
- contents: metaContents([line]),
195
- })
196
- trackTikTokAddToCart({
197
- contentId: line.productId,
198
- contentName: line.title,
199
- quantity: line.quantity,
200
- price: line.price,
201
- currency,
202
- value,
203
- })
204
- trackOpenAiItemsAdded({
205
- contentId: line.productId,
206
- contentName: line.title,
207
- quantity: line.quantity,
208
- price: line.price,
209
- currency,
210
- value,
211
- })
212
- trackGAAddToCart({ currency, value, items: gaItems([line], currency) })
213
- trackRybbitAddToCart({
214
- item_id: line.variantId || line.productId,
215
- item_name: line.title,
216
- quantity: line.quantity,
217
- currency,
218
- value,
219
- })
220
- }
221
-
222
- /** Checkout started. */
223
- export function trackCheckoutStart(input: {
224
- lines: TrackedLine[]
225
- currency: string
226
- value: number
227
- coupon?: string
228
- }): void {
229
- const { lines, currency, value, coupon } = input
230
- trackInitiateCheckout({
231
- content_ids: lines.map((line) => line.productId),
232
- content_type: "product",
233
- currency,
234
- value,
235
- num_items: unitCount(lines),
236
- contents: metaContents(lines),
237
- })
238
- trackTikTokInitiateCheckout({
239
- contents: tiktokContents(lines),
240
- currency,
241
- value,
242
- })
243
- trackOpenAiCheckoutStarted({
244
- contents: openAiContents(lines, currency),
245
- currency,
246
- value,
247
- })
248
- trackGABeginCheckout({
249
- currency,
250
- value,
251
- items: gaItems(lines, currency),
252
- ...(coupon ? { coupon } : {}),
253
- })
254
- trackRybbitBeginCheckout({
255
- item_ids: lines.map((line) => line.variantId || line.productId),
256
- num_items: unitCount(lines),
257
- currency,
258
- value,
259
- })
260
- }
261
-
262
- /**
263
- * Order confirmed — the money event.
264
- *
265
- * Google Ads only fires when the store configured BOTH the account id
266
- * and the purchase conversion label, which is why the config is a
267
- * parameter here: `googleAdsPurchaseSendTo` returns null otherwise, and a
268
- * malformed `send_to` is accepted by Google and silently dropped.
269
- *
270
- * Everything here is deduped against the server: Meta on
271
- * `purchase_<displayId>`, TikTok on `tt_purchase_<displayId>`, ChatGPT Ads
272
- * on `oai_order_<displayId>`, GA4 and Google Ads on the transaction id,
273
- * which is `displayId` itself.
274
- */
275
- export function trackOrderPurchase(
276
- order: TrackedOrder,
277
- config?: TrackingConfig
278
- ): void {
279
- const {
280
- displayId,
281
- currency,
282
- value,
283
- lines,
284
- tax,
285
- shipping,
286
- coupon,
287
- customerType,
288
- } = order
289
- const transactionId = String(displayId)
290
-
291
- trackPurchase(
292
- {
293
- content_ids: lines.map((line) => line.productId),
294
- content_type: "product",
295
- currency,
296
- value,
297
- num_items: unitCount(lines),
298
- contents: metaContents(lines),
299
- },
300
- displayId
301
- )
302
-
303
- trackTikTokPurchase({
304
- contents: tiktokContents(lines),
305
- currency,
306
- value,
307
- displayId,
308
- customerType,
309
- })
310
-
311
- trackOpenAiOrderCreated({
312
- contents: openAiContents(lines, currency),
313
- currency,
314
- value,
315
- displayId,
316
- })
317
-
318
- trackGAPurchase({
319
- transaction_id: transactionId,
320
- currency,
321
- value,
322
- items: gaItems(lines, currency),
323
- ...(typeof tax === "number" ? { tax } : {}),
324
- ...(typeof shipping === "number" ? { shipping } : {}),
325
- ...(coupon ? { coupon } : {}),
326
- })
327
-
328
- trackRybbitPurchase({
329
- transaction_id: transactionId,
330
- item_ids: lines.map((line) => line.variantId || line.productId),
331
- num_items: unitCount(lines),
332
- currency,
333
- value,
334
- })
335
-
336
- const sendTo = config ? googleAdsPurchaseSendTo(config) : null
337
- if (sendTo) {
338
- trackGoogleAdsPurchase({
339
- sendTo,
340
- value,
341
- currency,
342
- transactionId,
343
- ...(customerType
344
- ? { newCustomer: customerType === "new" }
345
- : {}),
346
- })
347
- }
348
- }
1
+ "use client"
2
+
3
+ import {
4
+ trackAddToCart,
5
+ trackInitiateCheckout,
6
+ trackPurchase,
7
+ trackViewContent,
8
+ } from "./fbq"
9
+ import {
10
+ trackGAAddToCart,
11
+ trackGABeginCheckout,
12
+ trackGAPurchase,
13
+ trackGAViewItem,
14
+ } from "./gtag"
15
+ import {
16
+ trackRybbitAddToCart,
17
+ trackRybbitBeginCheckout,
18
+ trackRybbitPurchase,
19
+ trackRybbitViewItem,
20
+ } from "./rybbit-events"
21
+ import {
22
+ trackTikTokAddToCart,
23
+ trackTikTokInitiateCheckout,
24
+ trackTikTokPurchase,
25
+ trackTikTokViewContent,
26
+ } from "./ttq"
27
+ import {
28
+ minorUnits,
29
+ trackOpenAiCheckoutStarted,
30
+ trackOpenAiContentsViewed,
31
+ trackOpenAiItemsAdded,
32
+ trackOpenAiOrderCreated,
33
+ type OpenAiContentItem,
34
+ } from "./oaiq"
35
+ import { googleAdsPurchaseSendTo, trackGoogleAdsPurchase } from "./google-ads"
36
+ import type { TrackingConfig } from "./types"
37
+
38
+ /**
39
+ * ONE call per commerce moment, every vendor at once.
40
+ *
41
+ * The per-vendor helpers beside this file stay exported and stay usable.
42
+ * This is the door a storefront should reach for anyway, because the
43
+ * failure these functions prevent is the most common one in tracking and
44
+ * it is invisible: a store adds a fifth vendor, updates four call sites,
45
+ * misses the fifth, and nobody notices until an ad account has been
46
+ * optimising on partial data for a month. We have watched it happen to a
47
+ * product page whose buy box predated a shared helper: ViewContent and
48
+ * InitiateCheckout were present, AddToCart was missing from the single
49
+ * most-used add path in the store.
50
+ *
51
+ * With one function per moment, a vendor is added HERE, once, and every
52
+ * surface that already calls it gains the vendor for free.
53
+ *
54
+ * Every helper underneath no-ops when its tag is absent, so passing a
55
+ * config with only two vendors configured fires exactly those two.
56
+ *
57
+ * Identifier discipline, and it is not cosmetic:
58
+ * - `productId` is the catalogue key. Meta's content_ids, TikTok's
59
+ * content_id and the feed's <g:id> must be the SAME value or the
60
+ * event matches no catalogue entry, which costs dynamic ads and
61
+ * product-level reporting on both platforms.
62
+ * - GA4 is the exception on purpose: its items are keyed by VARIANT,
63
+ * because GA4 reports on what was actually bought rather than
64
+ * matching an ads catalogue.
65
+ * - Purchase dedup keys are derived from `displayId` inside the
66
+ * helpers, never passed in, so the browser and the server cannot
67
+ * drift apart.
68
+ */
69
+
70
+ /** One line, in the shape every vendor is derived from. */
71
+ export type TrackedLine = {
72
+ /** Catalogue key. Meta + TikTok content ids, and the feed's <g:id>. */
73
+ productId: string
74
+ /** What was actually bought. GA4 item_id. */
75
+ variantId?: string
76
+ title: string
77
+ quantity: number
78
+ /** Unit price, in `currency`. */
79
+ price: number
80
+ }
81
+
82
+ export type TrackedOrder = {
83
+ /** THE dedup key across every vendor and both sides of the wire. */
84
+ displayId: string | number
85
+ currency: string
86
+ /** Order total actually collected. */
87
+ value: number
88
+ tax?: number
89
+ shipping?: number
90
+ coupon?: string
91
+ lines: TrackedLine[]
92
+ /**
93
+ * First order for this buyer. Computed server-side by the order.placed
94
+ * forwarder and persisted on the order as `metadata.customer_type`;
95
+ * read it from there rather than deriving it in the browser, so the two
96
+ * sides tell the ad platforms the same thing. Omit when unknown.
97
+ */
98
+ customerType?: "new" | "returning"
99
+ }
100
+
101
+ const gaItems = (lines: TrackedLine[], currency: string) =>
102
+ lines.map((line, index) => ({
103
+ item_id: line.variantId || line.productId,
104
+ item_name: line.title,
105
+ quantity: line.quantity,
106
+ price: line.price,
107
+ currency,
108
+ index,
109
+ }))
110
+
111
+ const metaContents = (lines: TrackedLine[]) =>
112
+ lines.map((line) => ({
113
+ id: line.productId,
114
+ quantity: line.quantity,
115
+ item_price: line.price,
116
+ }))
117
+
118
+ const tiktokContents = (lines: TrackedLine[]) =>
119
+ lines.map((line) => ({
120
+ content_id: line.productId,
121
+ content_name: line.title,
122
+ quantity: line.quantity,
123
+ price: line.price,
124
+ }))
125
+
126
+ const unitCount = (lines: TrackedLine[]) =>
127
+ lines.reduce((sum, line) => sum + (Number(line.quantity) || 0), 0)
128
+
129
+ /**
130
+ * ChatGPT Ads contents. The shape is close to TikTok's, but every amount
131
+ * is an INTEGER IN MINOR UNITS on this vendor, which is why the mapping
132
+ * cannot be shared with `tiktokContents` above: the same numbers in the
133
+ * same field names would be a hundredfold under-report.
134
+ */
135
+ const openAiContents = (
136
+ lines: TrackedLine[],
137
+ currency: string
138
+ ): OpenAiContentItem[] =>
139
+ lines.map((line) => ({
140
+ id: line.productId,
141
+ name: line.title,
142
+ content_type: "product",
143
+ quantity: line.quantity,
144
+ amount: minorUnits(line.price),
145
+ currency,
146
+ }))
147
+
148
+ /** Product page view. */
149
+ export function trackProductView(input: {
150
+ line: TrackedLine
151
+ currency: string
152
+ value: number
153
+ }): void {
154
+ const { line, currency, value } = input
155
+ trackViewContent({
156
+ content_ids: [line.productId],
157
+ content_type: "product",
158
+ currency,
159
+ value,
160
+ })
161
+ trackTikTokViewContent({
162
+ contentId: line.productId,
163
+ contentName: line.title,
164
+ currency,
165
+ value,
166
+ })
167
+ trackOpenAiContentsViewed({
168
+ contentId: line.productId,
169
+ contentName: line.title,
170
+ currency,
171
+ value,
172
+ })
173
+ trackGAViewItem({ currency, value, items: gaItems([line], currency) })
174
+ trackRybbitViewItem({
175
+ item_id: line.variantId || line.productId,
176
+ item_name: line.title,
177
+ currency,
178
+ value,
179
+ })
180
+ }
181
+
182
+ /** Add to cart, from ANY surface — listing card, buy box, upsell rail. */
183
+ export function trackCartAdd(input: {
184
+ line: TrackedLine
185
+ currency: string
186
+ value: number
187
+ }): void {
188
+ const { line, currency, value } = input
189
+ trackAddToCart({
190
+ content_ids: [line.productId],
191
+ content_type: "product",
192
+ currency,
193
+ value,
194
+ contents: metaContents([line]),
195
+ })
196
+ trackTikTokAddToCart({
197
+ contentId: line.productId,
198
+ contentName: line.title,
199
+ quantity: line.quantity,
200
+ price: line.price,
201
+ currency,
202
+ value,
203
+ })
204
+ trackOpenAiItemsAdded({
205
+ contentId: line.productId,
206
+ contentName: line.title,
207
+ quantity: line.quantity,
208
+ price: line.price,
209
+ currency,
210
+ value,
211
+ })
212
+ trackGAAddToCart({ currency, value, items: gaItems([line], currency) })
213
+ trackRybbitAddToCart({
214
+ item_id: line.variantId || line.productId,
215
+ item_name: line.title,
216
+ quantity: line.quantity,
217
+ currency,
218
+ value,
219
+ })
220
+ }
221
+
222
+ /** Checkout started. */
223
+ export function trackCheckoutStart(input: {
224
+ lines: TrackedLine[]
225
+ currency: string
226
+ value: number
227
+ coupon?: string
228
+ }): void {
229
+ const { lines, currency, value, coupon } = input
230
+ trackInitiateCheckout({
231
+ content_ids: lines.map((line) => line.productId),
232
+ content_type: "product",
233
+ currency,
234
+ value,
235
+ num_items: unitCount(lines),
236
+ contents: metaContents(lines),
237
+ })
238
+ trackTikTokInitiateCheckout({
239
+ contents: tiktokContents(lines),
240
+ currency,
241
+ value,
242
+ })
243
+ trackOpenAiCheckoutStarted({
244
+ contents: openAiContents(lines, currency),
245
+ currency,
246
+ value,
247
+ })
248
+ trackGABeginCheckout({
249
+ currency,
250
+ value,
251
+ items: gaItems(lines, currency),
252
+ ...(coupon ? { coupon } : {}),
253
+ })
254
+ trackRybbitBeginCheckout({
255
+ item_ids: lines.map((line) => line.variantId || line.productId),
256
+ num_items: unitCount(lines),
257
+ currency,
258
+ value,
259
+ })
260
+ }
261
+
262
+ /**
263
+ * Order confirmed — the money event.
264
+ *
265
+ * Google Ads only fires when the store configured BOTH the account id
266
+ * and the purchase conversion label, which is why the config is a
267
+ * parameter here: `googleAdsPurchaseSendTo` returns null otherwise, and a
268
+ * malformed `send_to` is accepted by Google and silently dropped.
269
+ *
270
+ * Everything here is deduped against the server: Meta on
271
+ * `purchase_<displayId>`, TikTok on `tt_purchase_<displayId>`, ChatGPT Ads
272
+ * on `oai_order_<displayId>`, GA4 and Google Ads on the transaction id,
273
+ * which is `displayId` itself.
274
+ */
275
+ export function trackOrderPurchase(
276
+ order: TrackedOrder,
277
+ config?: TrackingConfig
278
+ ): void {
279
+ const {
280
+ displayId,
281
+ currency,
282
+ value,
283
+ lines,
284
+ tax,
285
+ shipping,
286
+ coupon,
287
+ customerType,
288
+ } = order
289
+ const transactionId = String(displayId)
290
+
291
+ trackPurchase(
292
+ {
293
+ content_ids: lines.map((line) => line.productId),
294
+ content_type: "product",
295
+ currency,
296
+ value,
297
+ num_items: unitCount(lines),
298
+ contents: metaContents(lines),
299
+ },
300
+ displayId
301
+ )
302
+
303
+ trackTikTokPurchase({
304
+ contents: tiktokContents(lines),
305
+ currency,
306
+ value,
307
+ displayId,
308
+ customerType,
309
+ })
310
+
311
+ trackOpenAiOrderCreated({
312
+ contents: openAiContents(lines, currency),
313
+ currency,
314
+ value,
315
+ displayId,
316
+ })
317
+
318
+ trackGAPurchase({
319
+ transaction_id: transactionId,
320
+ currency,
321
+ value,
322
+ items: gaItems(lines, currency),
323
+ ...(typeof tax === "number" ? { tax } : {}),
324
+ ...(typeof shipping === "number" ? { shipping } : {}),
325
+ ...(coupon ? { coupon } : {}),
326
+ })
327
+
328
+ trackRybbitPurchase({
329
+ transaction_id: transactionId,
330
+ item_ids: lines.map((line) => line.variantId || line.productId),
331
+ num_items: unitCount(lines),
332
+ currency,
333
+ value,
334
+ })
335
+
336
+ const sendTo = config ? googleAdsPurchaseSendTo(config) : null
337
+ if (sendTo) {
338
+ trackGoogleAdsPurchase({
339
+ sendTo,
340
+ value,
341
+ currency,
342
+ transactionId,
343
+ ...(customerType
344
+ ? { newCustomer: customerType === "new" }
345
+ : {}),
346
+ })
347
+ }
348
+ }