@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.
- package/LICENSE +21 -21
- package/package.json +3 -1
- package/src/api/auth.ts +108 -108
- package/src/api/carts.ts +523 -523
- package/src/api/categories.ts +184 -184
- package/src/api/checkout.ts +526 -526
- package/src/api/collections.ts +130 -130
- package/src/api/consent.ts +75 -75
- package/src/api/content.ts +125 -125
- package/src/api/customers.ts +303 -303
- package/src/api/gift-cards.ts +112 -112
- package/src/api/http.ts +180 -180
- package/src/api/index.ts +30 -30
- package/src/api/menus.ts +77 -77
- package/src/api/metaobjects.ts +136 -136
- package/src/api/orders.ts +290 -290
- package/src/api/products.ts +43 -1
- package/src/api/redirects.ts +37 -37
- package/src/api/regions.ts +200 -200
- package/src/api/reviews.ts +259 -259
- package/src/api/search.ts +163 -163
- package/src/api/store.ts +35 -35
- package/src/api/types.ts +91 -91
- package/src/cart-drawer/context.tsx +367 -168
- package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
- package/src/cart-drawer/gift-wrap.tsx +82 -82
- package/src/cart-drawer/index.ts +73 -68
- package/src/cart-drawer/item/index.tsx +162 -162
- package/src/cart-drawer/item/quantity.tsx +8 -14
- package/src/cart-drawer/item/upsell.tsx +110 -110
- package/src/cart-drawer/labels.ts +123 -123
- package/src/cart-drawer/mutation-queue.ts +78 -0
- package/src/cart-drawer/notes.tsx +131 -131
- package/src/cart-drawer/sticky-footer.tsx +73 -73
- package/src/cart-drawer/summary-breakdown.tsx +197 -197
- package/src/checkout/address-error-copy.ts +117 -117
- package/src/checkout/boxnow-locker-selector.tsx +410 -410
- package/src/checkout/compare-addresses.ts +40 -40
- package/src/checkout/discount-section.tsx +218 -218
- package/src/checkout/error-copy-codes.ts +63 -63
- package/src/checkout/geocode.ts +154 -154
- package/src/checkout/gift-card-section.tsx +224 -224
- package/src/checkout/index.ts +72 -72
- package/src/checkout/labels.ts +495 -495
- package/src/checkout/payment-button.tsx +372 -372
- package/src/checkout/payment-error-copy.ts +154 -154
- package/src/checkout/promotion-error-copy.ts +91 -91
- package/src/checkout/use-checkout-orchestration.ts +1604 -1592
- package/src/common/country-flag.tsx +52 -52
- package/src/common/country-select.tsx +11 -11
- package/src/common/index.ts +20 -20
- package/src/common/market-select.tsx +57 -57
- package/src/index.ts +12 -12
- package/src/lib/cookie-names.ts +14 -0
- package/src/lib/country-name.ts +59 -59
- package/src/lib/get-product-price.ts +133 -133
- package/src/lib/media-image.tsx +39 -39
- package/src/lib/payment-constants.ts +53 -53
- package/src/lib/platform.ts +13 -13
- package/src/lib/price.tsx +39 -39
- package/src/lib/store-api-error.ts +36 -36
- package/src/lib/variant-caption.ts +32 -32
- package/src/locales/bg.ts +467 -467
- package/src/locales/context.ts +37 -37
- package/src/locales/en.ts +26 -26
- package/src/locales/es.ts +466 -466
- package/src/locales/index.ts +19 -19
- package/src/locales/provider.tsx +59 -59
- package/src/locales/types.ts +77 -77
- package/src/order/index.ts +62 -62
- package/src/order/labels.ts +79 -79
- package/src/order/order-totals.tsx +250 -250
- package/src/primitives/select-field.tsx +93 -93
- package/src/products/image-gallery.tsx +43 -43
- package/src/products/index.ts +68 -59
- package/src/products/option-select.tsx +67 -67
- package/src/products/product-actions-wrapper.tsx +63 -62
- package/src/products/product-actions.tsx +151 -150
- package/src/products/product-price.tsx +69 -69
- package/src/products/product-promises.tsx +61 -61
- package/src/products/product-specs.tsx +65 -65
- package/src/products/product-tabs.tsx +123 -123
- package/src/products/product-template.tsx +163 -162
- package/src/products/purchase-options.tsx +130 -130
- package/src/products/sets.ts +110 -0
- package/src/products/use-product-actions.ts +199 -164
- package/src/products/variant-matching.ts +71 -71
- package/src/products/variant-url.ts +74 -74
- package/src/reviews-ui/index.ts +73 -73
- package/src/store/category-template.tsx +136 -136
- package/src/store/index.ts +40 -40
- package/src/tracking/chatgpt-pixel.tsx +99 -99
- package/src/tracking/consent-init.tsx +62 -62
- package/src/tracking/events.ts +348 -348
- package/src/tracking/ga4.tsx +93 -93
- package/src/tracking/google-ads.ts +84 -84
- package/src/tracking/gtm.tsx +60 -60
- package/src/tracking/inline-script.ts +49 -49
- package/src/tracking/oaiq.ts +206 -206
- package/src/tracking/tiktok-pixel.tsx +91 -91
- package/src/tracking/track-init.tsx +56 -56
- package/src/tracking/track-order-purchase.tsx +122 -122
- package/src/tracking/ttq.ts +180 -180
- package/src/tracking/use-tracking-config.ts +54 -54
- package/theme/index.css +25 -25
- package/theme/theme.css +150 -150
- package/theme/tokens.css +106 -106
package/src/tracking/events.ts
CHANGED
|
@@ -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
|
+
}
|