@cartbase/storefront 0.17.0 → 0.18.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 (108) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +3 -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 +180 -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/products.ts +43 -1
  18. package/src/api/redirects.ts +37 -37
  19. package/src/api/regions.ts +200 -200
  20. package/src/api/reviews.ts +259 -259
  21. package/src/api/search.ts +163 -163
  22. package/src/api/store.ts +35 -35
  23. package/src/api/types.ts +91 -91
  24. package/src/cart-drawer/context.tsx +367 -168
  25. package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
  26. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
  27. package/src/cart-drawer/gift-wrap.tsx +82 -82
  28. package/src/cart-drawer/index.ts +73 -68
  29. package/src/cart-drawer/item/index.tsx +162 -162
  30. package/src/cart-drawer/item/quantity.tsx +8 -14
  31. package/src/cart-drawer/item/upsell.tsx +110 -110
  32. package/src/cart-drawer/labels.ts +123 -123
  33. package/src/cart-drawer/mutation-queue.ts +78 -0
  34. package/src/cart-drawer/notes.tsx +131 -131
  35. package/src/cart-drawer/sticky-footer.tsx +73 -73
  36. package/src/cart-drawer/summary-breakdown.tsx +197 -197
  37. package/src/checkout/address-error-copy.ts +117 -117
  38. package/src/checkout/boxnow-locker-selector.tsx +410 -410
  39. package/src/checkout/compare-addresses.ts +40 -40
  40. package/src/checkout/discount-section.tsx +218 -218
  41. package/src/checkout/error-copy-codes.ts +63 -63
  42. package/src/checkout/geocode.ts +154 -154
  43. package/src/checkout/gift-card-section.tsx +224 -224
  44. package/src/checkout/index.ts +72 -72
  45. package/src/checkout/labels.ts +495 -495
  46. package/src/checkout/payment-button.tsx +372 -372
  47. package/src/checkout/payment-error-copy.ts +154 -154
  48. package/src/checkout/promotion-error-copy.ts +91 -91
  49. package/src/checkout/use-checkout-orchestration.ts +1604 -1592
  50. package/src/common/country-flag.tsx +52 -52
  51. package/src/common/country-select.tsx +11 -11
  52. package/src/common/index.ts +20 -20
  53. package/src/common/market-select.tsx +57 -57
  54. package/src/index.ts +12 -12
  55. package/src/lib/cookie-names.ts +14 -0
  56. package/src/lib/country-name.ts +59 -59
  57. package/src/lib/get-product-price.ts +133 -133
  58. package/src/lib/media-image.tsx +39 -39
  59. package/src/lib/payment-constants.ts +53 -53
  60. package/src/lib/platform.ts +13 -13
  61. package/src/lib/price.tsx +39 -39
  62. package/src/lib/store-api-error.ts +36 -36
  63. package/src/lib/variant-caption.ts +32 -32
  64. package/src/locales/bg.ts +467 -467
  65. package/src/locales/context.ts +37 -37
  66. package/src/locales/en.ts +26 -26
  67. package/src/locales/es.ts +466 -466
  68. package/src/locales/index.ts +19 -19
  69. package/src/locales/provider.tsx +59 -59
  70. package/src/locales/types.ts +77 -77
  71. package/src/order/index.ts +62 -62
  72. package/src/order/labels.ts +79 -79
  73. package/src/order/order-totals.tsx +250 -250
  74. package/src/primitives/select-field.tsx +93 -93
  75. package/src/products/image-gallery.tsx +43 -43
  76. package/src/products/index.ts +68 -59
  77. package/src/products/option-select.tsx +67 -67
  78. package/src/products/product-actions-wrapper.tsx +63 -62
  79. package/src/products/product-actions.tsx +151 -150
  80. package/src/products/product-price.tsx +69 -69
  81. package/src/products/product-promises.tsx +61 -61
  82. package/src/products/product-specs.tsx +65 -65
  83. package/src/products/product-tabs.tsx +123 -123
  84. package/src/products/product-template.tsx +163 -162
  85. package/src/products/purchase-options.tsx +130 -130
  86. package/src/products/sets.ts +110 -0
  87. package/src/products/use-product-actions.ts +199 -164
  88. package/src/products/variant-matching.ts +71 -71
  89. package/src/products/variant-url.ts +74 -74
  90. package/src/reviews-ui/index.ts +73 -73
  91. package/src/store/category-template.tsx +136 -136
  92. package/src/store/index.ts +40 -40
  93. package/src/tracking/chatgpt-pixel.tsx +99 -99
  94. package/src/tracking/consent-init.tsx +62 -62
  95. package/src/tracking/events.ts +348 -348
  96. package/src/tracking/ga4.tsx +93 -93
  97. package/src/tracking/google-ads.ts +84 -84
  98. package/src/tracking/gtm.tsx +60 -60
  99. package/src/tracking/inline-script.ts +49 -49
  100. package/src/tracking/oaiq.ts +206 -206
  101. package/src/tracking/tiktok-pixel.tsx +91 -91
  102. package/src/tracking/track-init.tsx +56 -56
  103. package/src/tracking/track-order-purchase.tsx +122 -122
  104. package/src/tracking/ttq.ts +180 -180
  105. package/src/tracking/use-tracking-config.ts +54 -54
  106. package/theme/index.css +25 -25
  107. package/theme/theme.css +150 -150
  108. package/theme/tokens.css +106 -106
@@ -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
+ }