@cartbase/storefront 0.8.0 → 0.10.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.
- package/package.json +7 -8
- package/src/cart-drawer/cross-sell-carousel.tsx +2 -3
- package/src/cart-drawer/gift-wrap.tsx +82 -83
- package/src/cart-drawer/index.ts +0 -1
- package/src/cart-drawer/item/index.tsx +4 -6
- package/src/cart-drawer/sticky-footer.tsx +73 -73
- package/src/checkout/address-error-copy.ts +117 -119
- package/src/checkout/discount-section.tsx +218 -218
- package/src/checkout/error-copy-codes.ts +63 -0
- package/src/checkout/gift-card-section.tsx +4 -4
- package/src/checkout/index.ts +4 -5
- package/src/checkout/labels.ts +218 -0
- package/src/checkout/line-item-card.tsx +3 -3
- package/src/checkout/order-summary.tsx +11 -12
- package/src/checkout/payment-button.tsx +372 -373
- package/src/checkout/payment-error-copy.ts +154 -269
- package/src/checkout/promotion-error-copy.ts +91 -124
- package/src/checkout/shipping-method-list.tsx +2 -2
- package/src/checkout/use-checkout-orchestration.ts +10 -8
- package/src/lib/money.ts +1 -1
- package/src/lib/price.tsx +39 -0
- package/src/locales/bg.ts +468 -0
- package/src/locales/en.ts +26 -0
- package/src/locales/es.ts +467 -0
- package/src/locales/index.ts +19 -0
- package/src/locales/provider.tsx +63 -0
- package/src/locales/types.ts +49 -0
- package/src/order/index.ts +0 -1
- package/src/order/order-delivery-card.tsx +2 -2
- package/src/order/order-item.tsx +3 -3
- package/src/order/order-totals.tsx +4 -4
- package/src/products/index.ts +0 -1
- package/src/reviews-ui/index.ts +0 -1
- package/src/store/index.ts +0 -1
- package/src/tracking/attribution.ts +13 -0
- package/src/tracking/chatgpt-pixel.tsx +92 -0
- package/src/tracking/consent-banner.tsx +5 -1
- package/src/tracking/consent.ts +12 -0
- package/src/tracking/events.ts +56 -2
- package/src/tracking/index.ts +12 -0
- package/src/tracking/oaiq.ts +206 -0
- package/src/tracking/storefront-tags.tsx +2 -0
- package/src/tracking/types.ts +4 -0
- package/src/cart-drawer/labels-bg.ts +0 -72
- package/src/checkout/labels-bg.ts +0 -128
- package/src/lib/dual-price.tsx +0 -73
- package/src/order/labels-bg.ts +0 -39
- package/src/products/labels-bg.ts +0 -35
- package/src/reviews-ui/labels-bg.ts +0 -91
- package/src/store/labels-bg.ts +0 -22
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Price } from "../lib/price"
|
|
2
2
|
import { findFeeLine, type LineLike } from "../lib/cart-helpers"
|
|
3
3
|
import { defaultOrderLabels, type OrderLabels } from "./labels"
|
|
4
4
|
|
|
@@ -217,14 +217,14 @@ export function OrderTotals({
|
|
|
217
217
|
) : row.negative ? (
|
|
218
218
|
<span className="text-sm text-success">
|
|
219
219
|
-{" "}
|
|
220
|
-
<
|
|
220
|
+
<Price
|
|
221
221
|
amount={row.amount}
|
|
222
222
|
currencyCode={currencyCode}
|
|
223
223
|
className="text-sm text-success"
|
|
224
224
|
/>
|
|
225
225
|
</span>
|
|
226
226
|
) : (
|
|
227
|
-
<
|
|
227
|
+
<Price
|
|
228
228
|
amount={row.amount}
|
|
229
229
|
currencyCode={currencyCode}
|
|
230
230
|
className="text-sm text-foreground"
|
|
@@ -237,7 +237,7 @@ export function OrderTotals({
|
|
|
237
237
|
<div className="h-px bg-border my-3" />
|
|
238
238
|
<div className="flex justify-between items-baseline">
|
|
239
239
|
<span className="text-[15px] font-bold text-foreground">{l.total}</span>
|
|
240
|
-
<
|
|
240
|
+
<Price
|
|
241
241
|
amount={total}
|
|
242
242
|
currencyCode={currencyCode}
|
|
243
243
|
className="text-xl font-bold text-foreground tracking-tight"
|
package/src/products/index.ts
CHANGED
package/src/reviews-ui/index.ts
CHANGED
package/src/store/index.ts
CHANGED
|
@@ -496,6 +496,16 @@ const TTCLID_COOKIE = "_1c_ttclid"
|
|
|
496
496
|
const GCLID_COOKIE = "_1c_gclid"
|
|
497
497
|
const GBRAID_COOKIE = "_1c_gbraid"
|
|
498
498
|
const WBRAID_COOKIE = "_1c_wbraid"
|
|
499
|
+
/**
|
|
500
|
+
* OpenAI's click id, from `oppref` on the landing URL.
|
|
501
|
+
*
|
|
502
|
+
* Their own SDK stores the same parameter in a first-party `__oppref`
|
|
503
|
+
* cookie at init, and we capture it here anyway: the Conversions API takes
|
|
504
|
+
* `oppref` on the server event explicitly, and this module runs on the
|
|
505
|
+
* landing page whether or not their script loaded or was consented to. Ours
|
|
506
|
+
* is the one the server reads.
|
|
507
|
+
*/
|
|
508
|
+
const OPPREF_COOKIE = "_1c_oppref"
|
|
499
509
|
|
|
500
510
|
/** TikTok's ttclid lifetime follows the CTA window in Attribution
|
|
501
511
|
* Manager; Google's conversion window tops out at 90 days. One TTL
|
|
@@ -509,6 +519,7 @@ const CLICK_ID_CAPTURE: ReadonlyArray<readonly [string, string]> = [
|
|
|
509
519
|
[GCLID_COOKIE, "gclid"],
|
|
510
520
|
[GBRAID_COOKIE, "gbraid"],
|
|
511
521
|
[WBRAID_COOKIE, "wbraid"],
|
|
522
|
+
[OPPREF_COOKIE, "oppref"],
|
|
512
523
|
]
|
|
513
524
|
|
|
514
525
|
/**
|
|
@@ -522,6 +533,7 @@ export const CLICK_ID_COOKIES = {
|
|
|
522
533
|
gbraid: GBRAID_COOKIE,
|
|
523
534
|
wbraid: WBRAID_COOKIE,
|
|
524
535
|
ttp: "_ttp",
|
|
536
|
+
oppref: OPPREF_COOKIE,
|
|
525
537
|
} as const
|
|
526
538
|
|
|
527
539
|
/** cookie name → order.metadata key (code truth for the server side:
|
|
@@ -532,6 +544,7 @@ export const CLICK_ID_METADATA_KEYS: ReadonlyArray<readonly [string, string]> =
|
|
|
532
544
|
[CLICK_ID_COOKIES.gclid, "google_gclid"],
|
|
533
545
|
[CLICK_ID_COOKIES.gbraid, "google_gbraid"],
|
|
534
546
|
[CLICK_ID_COOKIES.wbraid, "google_wbraid"],
|
|
547
|
+
[CLICK_ID_COOKIES.oppref, "oai_oppref"],
|
|
535
548
|
]
|
|
536
549
|
|
|
537
550
|
/**
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import Script from "next/script"
|
|
4
|
+
|
|
5
|
+
import { CONSENT_COOKIE } from "./consent"
|
|
6
|
+
import { jsStringLiteral } from "./inline-script"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* ChatGptPixel — the OpenAI measurement pixel, the ChatGPT Ads twin of
|
|
10
|
+
* <MetaPixel> and <TikTokPixel>.
|
|
11
|
+
*
|
|
12
|
+
* The loader is OpenAI's own, verbatim from
|
|
13
|
+
* https://developers.openai.com/ads/measurement-pixel: a command queue on
|
|
14
|
+
* `window.oaiq` that buffers calls until the SDK arrives from
|
|
15
|
+
* bzrcdn.openai.com, injected with `<Script strategy="afterInteractive">`.
|
|
16
|
+
*
|
|
17
|
+
* CONSENT IS CLOSED BEFORE INIT, and that order is the whole point. Their
|
|
18
|
+
* consent default is TRUE, so an unconsented visitor is measured unless we
|
|
19
|
+
* say otherwise first:
|
|
20
|
+
*
|
|
21
|
+
* oaiq("consent", false) // queued BEFORE init — no pings
|
|
22
|
+
* oaiq("init", { pixelId })
|
|
23
|
+
* oaiq("consent", true) // only once the visitor accepted
|
|
24
|
+
*
|
|
25
|
+
* The script is LOADED either way, like the Meta and TikTok gates beside
|
|
26
|
+
* it, so a later grant costs no round trip. But the page view is fired
|
|
27
|
+
* only AFTER a grant, and that is deliberate rather than cautious: their
|
|
28
|
+
* docs state that with consent false the pixel sends no measurement
|
|
29
|
+
* pings, and they do NOT document whether a call made while consent was
|
|
30
|
+
* false is released on a later grant. TikTok documents exactly that
|
|
31
|
+
* (`holdConsent` queues, `grantConsent` flushes) and our TikTok gate
|
|
32
|
+
* relies on it. Nothing here relies on behaviour OpenAI has not written
|
|
33
|
+
* down: an unconsented page view is not sent, and not counted on later.
|
|
34
|
+
*
|
|
35
|
+
* The decision is read from the shared `_1c_consent` cookie inside the
|
|
36
|
+
* snippet, before anything can be sent, and live changes arrive through
|
|
37
|
+
* `applyConsent()` in ./consent.ts — there is no cookie watcher.
|
|
38
|
+
*
|
|
39
|
+
* The page view is ours to fire at all: their pixel sends nothing at init,
|
|
40
|
+
* unlike Meta's and TikTok's base code.
|
|
41
|
+
*
|
|
42
|
+
* Their SDK also captures `oppref` off the landing URL into a first-party
|
|
43
|
+
* `__oppref` cookie by itself. Our attribution module captures the same
|
|
44
|
+
* parameter into `_1c_oppref` independently, so the server-side
|
|
45
|
+
* Conversions API event can carry the click even on a visit where this
|
|
46
|
+
* script never loaded at all.
|
|
47
|
+
*
|
|
48
|
+
* Renders nothing when `pixelId` is falsy, so a layout can mount it
|
|
49
|
+
* unconditionally.
|
|
50
|
+
*/
|
|
51
|
+
export function ChatGptPixel({ pixelId }: { pixelId?: string }) {
|
|
52
|
+
if (!pixelId) return null
|
|
53
|
+
|
|
54
|
+
const id = jsStringLiteral(pixelId)
|
|
55
|
+
|
|
56
|
+
const initSnippet = `
|
|
57
|
+
(function (w, d, s, u) {
|
|
58
|
+
if (w.oaiq) return;
|
|
59
|
+
var q = function () { q.q.push(arguments); };
|
|
60
|
+
q.q = [];
|
|
61
|
+
w.oaiq = q;
|
|
62
|
+
var js = d.createElement(s);
|
|
63
|
+
js.async = true;
|
|
64
|
+
js.src = u;
|
|
65
|
+
var f = d.getElementsByTagName(s)[0];
|
|
66
|
+
f.parentNode.insertBefore(js, f);
|
|
67
|
+
})(window, document, "script", "https://bzrcdn.openai.com/sdk/oaiq.min.js");
|
|
68
|
+
|
|
69
|
+
oaiq("consent", false);
|
|
70
|
+
oaiq("init", { pixelId: ${id} });
|
|
71
|
+
|
|
72
|
+
(function (d) {
|
|
73
|
+
var ads = false;
|
|
74
|
+
try {
|
|
75
|
+
var m = d.cookie.match(/(?:^|; )${CONSENT_COOKIE}=([^;]*)/);
|
|
76
|
+
if (m) { ads = !!JSON.parse(decodeURIComponent(m[1])).ads; }
|
|
77
|
+
} catch (e) {}
|
|
78
|
+
if (ads) {
|
|
79
|
+
oaiq("consent", true);
|
|
80
|
+
oaiq("measure", "page_viewed", { type: "contents" });
|
|
81
|
+
}
|
|
82
|
+
})(document);
|
|
83
|
+
`.trim()
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Script
|
|
87
|
+
id="chatgpt-pixel-init"
|
|
88
|
+
strategy="afterInteractive"
|
|
89
|
+
dangerouslySetInnerHTML={{ __html: initSnippet }}
|
|
90
|
+
/>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
@@ -341,7 +341,11 @@ function ConsentRow({
|
|
|
341
341
|
* its settings layer ("withdraw consent as easily as it was given").
|
|
342
342
|
*/
|
|
343
343
|
export function ConsentSettingsLink({
|
|
344
|
-
|
|
344
|
+
// English, like every other default in the library. It shipped as
|
|
345
|
+
// "Настройки на бисквитките" until 2026-09-13, which put Bulgarian in
|
|
346
|
+
// the footer of every store that mounted this link. Pass children to
|
|
347
|
+
// translate it; the consent banner has no label pack of its own.
|
|
348
|
+
children = "Cookie settings",
|
|
345
349
|
className,
|
|
346
350
|
}: {
|
|
347
351
|
children?: React.ReactNode
|
package/src/tracking/consent.ts
CHANGED
|
@@ -131,6 +131,18 @@ export function applyConsent(choices: ConsentChoices): void {
|
|
|
131
131
|
if (choices.ads) w.ttq.grantConsent()
|
|
132
132
|
else w.ttq.revokeConsent()
|
|
133
133
|
}
|
|
134
|
+
|
|
135
|
+
// ChatGPT Ads. One command on their own queue, `oaiq("consent", bool)`,
|
|
136
|
+
// which works before the SDK has arrived because the queue buffers it.
|
|
137
|
+
//
|
|
138
|
+
// A grant also fires the page view the loader withheld: their docs say
|
|
139
|
+
// an unconsented event sends no ping and do not promise that a held call
|
|
140
|
+
// is released later, so the visitor who accepts on their third page is
|
|
141
|
+
// counted from the grant onward rather than not at all.
|
|
142
|
+
if (typeof w.oaiq === "function") {
|
|
143
|
+
w.oaiq("consent", choices.ads)
|
|
144
|
+
if (choices.ads) w.oaiq("measure", "page_viewed", { type: "contents" })
|
|
145
|
+
}
|
|
134
146
|
}
|
|
135
147
|
|
|
136
148
|
/**
|
package/src/tracking/events.ts
CHANGED
|
@@ -24,6 +24,14 @@ import {
|
|
|
24
24
|
trackTikTokPurchase,
|
|
25
25
|
trackTikTokViewContent,
|
|
26
26
|
} from "./ttq"
|
|
27
|
+
import {
|
|
28
|
+
minorUnits,
|
|
29
|
+
trackOpenAiCheckoutStarted,
|
|
30
|
+
trackOpenAiContentsViewed,
|
|
31
|
+
trackOpenAiItemsAdded,
|
|
32
|
+
trackOpenAiOrderCreated,
|
|
33
|
+
type OpenAiContentItem,
|
|
34
|
+
} from "./oaiq"
|
|
27
35
|
import { googleAdsPurchaseSendTo, trackGoogleAdsPurchase } from "./google-ads"
|
|
28
36
|
import type { TrackingConfig } from "./types"
|
|
29
37
|
|
|
@@ -118,6 +126,25 @@ const tiktokContents = (lines: TrackedLine[]) =>
|
|
|
118
126
|
const unitCount = (lines: TrackedLine[]) =>
|
|
119
127
|
lines.reduce((sum, line) => sum + (Number(line.quantity) || 0), 0)
|
|
120
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
|
+
|
|
121
148
|
/** Product page view. */
|
|
122
149
|
export function trackProductView(input: {
|
|
123
150
|
line: TrackedLine
|
|
@@ -137,6 +164,12 @@ export function trackProductView(input: {
|
|
|
137
164
|
currency,
|
|
138
165
|
value,
|
|
139
166
|
})
|
|
167
|
+
trackOpenAiContentsViewed({
|
|
168
|
+
contentId: line.productId,
|
|
169
|
+
contentName: line.title,
|
|
170
|
+
currency,
|
|
171
|
+
value,
|
|
172
|
+
})
|
|
140
173
|
trackGAViewItem({ currency, value, items: gaItems([line], currency) })
|
|
141
174
|
trackRybbitViewItem({
|
|
142
175
|
item_id: line.variantId || line.productId,
|
|
@@ -168,6 +201,14 @@ export function trackCartAdd(input: {
|
|
|
168
201
|
currency,
|
|
169
202
|
value,
|
|
170
203
|
})
|
|
204
|
+
trackOpenAiItemsAdded({
|
|
205
|
+
contentId: line.productId,
|
|
206
|
+
contentName: line.title,
|
|
207
|
+
quantity: line.quantity,
|
|
208
|
+
price: line.price,
|
|
209
|
+
currency,
|
|
210
|
+
value,
|
|
211
|
+
})
|
|
171
212
|
trackGAAddToCart({ currency, value, items: gaItems([line], currency) })
|
|
172
213
|
trackRybbitAddToCart({
|
|
173
214
|
item_id: line.variantId || line.productId,
|
|
@@ -199,6 +240,11 @@ export function trackCheckoutStart(input: {
|
|
|
199
240
|
currency,
|
|
200
241
|
value,
|
|
201
242
|
})
|
|
243
|
+
trackOpenAiCheckoutStarted({
|
|
244
|
+
contents: openAiContents(lines, currency),
|
|
245
|
+
currency,
|
|
246
|
+
value,
|
|
247
|
+
})
|
|
202
248
|
trackGABeginCheckout({
|
|
203
249
|
currency,
|
|
204
250
|
value,
|
|
@@ -222,8 +268,9 @@ export function trackCheckoutStart(input: {
|
|
|
222
268
|
* malformed `send_to` is accepted by Google and silently dropped.
|
|
223
269
|
*
|
|
224
270
|
* Everything here is deduped against the server: Meta on
|
|
225
|
-
* `purchase_<displayId>`, TikTok on `tt_purchase_<displayId>`,
|
|
226
|
-
* Google Ads on the transaction id,
|
|
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.
|
|
227
274
|
*/
|
|
228
275
|
export function trackOrderPurchase(
|
|
229
276
|
order: TrackedOrder,
|
|
@@ -261,6 +308,13 @@ export function trackOrderPurchase(
|
|
|
261
308
|
customerType,
|
|
262
309
|
})
|
|
263
310
|
|
|
311
|
+
trackOpenAiOrderCreated({
|
|
312
|
+
contents: openAiContents(lines, currency),
|
|
313
|
+
currency,
|
|
314
|
+
value,
|
|
315
|
+
displayId,
|
|
316
|
+
})
|
|
317
|
+
|
|
264
318
|
trackGAPurchase({
|
|
265
319
|
transaction_id: transactionId,
|
|
266
320
|
currency,
|
package/src/tracking/index.ts
CHANGED
|
@@ -57,6 +57,7 @@ export { MetaPixel, updatePixelAdvancedMatching } from "./meta-pixel"
|
|
|
57
57
|
export { GA4, GoogleTag } from "./ga4"
|
|
58
58
|
export { Gtm } from "./gtm"
|
|
59
59
|
export { TikTokPixel } from "./tiktok-pixel"
|
|
60
|
+
export { ChatGptPixel } from "./chatgpt-pixel"
|
|
60
61
|
export { StorefrontTags } from "./storefront-tags"
|
|
61
62
|
export { TrackInit } from "./track-init"
|
|
62
63
|
// The live-visitor heartbeat. Mounted by <StorefrontTags> so a storefront
|
|
@@ -134,6 +135,17 @@ export {
|
|
|
134
135
|
tiktokPurchaseEventId,
|
|
135
136
|
type TikTokContentItem,
|
|
136
137
|
} from "./ttq"
|
|
138
|
+
export {
|
|
139
|
+
trackOpenAiPageView,
|
|
140
|
+
trackOpenAiContentsViewed,
|
|
141
|
+
trackOpenAiItemsAdded,
|
|
142
|
+
trackOpenAiCheckoutStarted,
|
|
143
|
+
trackOpenAiOrderCreated,
|
|
144
|
+
applyOpenAiConsent,
|
|
145
|
+
openAiOrderEventId,
|
|
146
|
+
minorUnits,
|
|
147
|
+
type OpenAiContentItem,
|
|
148
|
+
} from "./oaiq"
|
|
137
149
|
export {
|
|
138
150
|
trackGoogleAdsPurchase,
|
|
139
151
|
googleAdsPurchaseSendTo,
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Typed wrappers around the global `oaiq` command queue from the OpenAI
|
|
5
|
+
* measurement pixel — the ChatGPT Ads twin of ./ttq.ts and ./fbq.ts, and
|
|
6
|
+
* deliberately the same shape so a surface that fires one can fire the
|
|
7
|
+
* other on the line below.
|
|
8
|
+
*
|
|
9
|
+
* Every helper no-ops when `window.oaiq` is absent (pixel not configured,
|
|
10
|
+
* server render, script blocked), so callers need no guards.
|
|
11
|
+
*
|
|
12
|
+
* The call signature is theirs, from
|
|
13
|
+
* https://developers.openai.com/ads/measurement-pixel:
|
|
14
|
+
*
|
|
15
|
+
* oaiq("measure", eventName, eventData, { event_id })
|
|
16
|
+
*
|
|
17
|
+
* Two things here are NOT like the other vendors, and both are silent when
|
|
18
|
+
* wrong:
|
|
19
|
+
*
|
|
20
|
+
* 1. MONEY IS AN INTEGER IN MINOR UNITS. Meta, TikTok and GA4 all read
|
|
21
|
+
* `value: 25.99`; OpenAI reads `amount: 2599`. Passing a decimal
|
|
22
|
+
* under-reports revenue by a factor of a hundred with nothing to show
|
|
23
|
+
* for it, so every amount goes through `minorUnits` below and no
|
|
24
|
+
* caller passes `amount` itself.
|
|
25
|
+
* 2. EVENT DATA CARRIES ITS OWN `type`. Each event name is bound to one
|
|
26
|
+
* of four data shapes; the five commerce events we fire all take
|
|
27
|
+
* `contents`, so the wrappers set it and callers cannot get it wrong.
|
|
28
|
+
*
|
|
29
|
+
* Event names are theirs and are not interchangeable with Meta's or
|
|
30
|
+
* TikTok's: `contents_viewed`, `items_added`, `checkout_started`,
|
|
31
|
+
* `order_created`, `page_viewed`.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** One product line inside `contents`. */
|
|
35
|
+
export type OpenAiContentItem = {
|
|
36
|
+
id: string
|
|
37
|
+
name?: string
|
|
38
|
+
content_type?: string
|
|
39
|
+
quantity?: number
|
|
40
|
+
/** Integer, minor units. */
|
|
41
|
+
amount?: number
|
|
42
|
+
currency?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type OpenAiEventData = {
|
|
46
|
+
type: "contents"
|
|
47
|
+
/** Integer, minor units. */
|
|
48
|
+
amount?: number
|
|
49
|
+
currency?: string
|
|
50
|
+
contents?: OpenAiContentItem[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type OaiqFn = (
|
|
54
|
+
command: "init" | "measure" | "consent",
|
|
55
|
+
...args: unknown[]
|
|
56
|
+
) => void
|
|
57
|
+
|
|
58
|
+
function safeOaiq(): OaiqFn | null {
|
|
59
|
+
if (typeof window === "undefined") return null
|
|
60
|
+
const oaiq = (window as unknown as { oaiq?: OaiqFn }).oaiq
|
|
61
|
+
return typeof oaiq === "function" ? oaiq : null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Major units to their integer minor units: 25.99 becomes 2599.
|
|
66
|
+
*
|
|
67
|
+
* The twin of `openAiMinorUnits` in the platform's
|
|
68
|
+
* src/lib/tracking/constants.ts. Duplicated rather than imported because
|
|
69
|
+
* this package cannot reach into the platform, and rounded rather than
|
|
70
|
+
* truncated because 0.1 + 0.2 is 0.30000000000000004 in binary floating
|
|
71
|
+
* point and truncation would report 29 cents for 30.
|
|
72
|
+
*/
|
|
73
|
+
export function minorUnits(amountInMajorUnits: number): number {
|
|
74
|
+
return Math.round((Number(amountInMajorUnits) || 0) * 100)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* THE order dedup key — must equal what the backend forwarder sends
|
|
79
|
+
* (`openAiOrderEventId` in the platform's src/lib/tracking/constants.ts).
|
|
80
|
+
* Built from display_id here so the format cannot drift between the two
|
|
81
|
+
* sides, the same guarantee `trackPurchase` gives for Meta and
|
|
82
|
+
* `tiktokPurchaseEventId` for TikTok.
|
|
83
|
+
*
|
|
84
|
+
* OpenAI deduplicates on pixel id + event name + id, keeping the FIRST
|
|
85
|
+
* event it receives, so the browser and the server collapse into one
|
|
86
|
+
* conversion whichever arrives first.
|
|
87
|
+
*/
|
|
88
|
+
export function openAiOrderEventId(displayId: string | number): string {
|
|
89
|
+
return `oai_order_${displayId}`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A page view. Their pixel fires nothing on its own at init. */
|
|
93
|
+
export function trackOpenAiPageView(): void {
|
|
94
|
+
const oaiq = safeOaiq()
|
|
95
|
+
if (!oaiq) return
|
|
96
|
+
oaiq("measure", "page_viewed", { type: "contents" } satisfies OpenAiEventData)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function trackOpenAiContentsViewed(data: {
|
|
100
|
+
contentId: string
|
|
101
|
+
contentName?: string
|
|
102
|
+
currency: string
|
|
103
|
+
/** Major units — converted here. */
|
|
104
|
+
value: number
|
|
105
|
+
}): void {
|
|
106
|
+
const oaiq = safeOaiq()
|
|
107
|
+
if (!oaiq) return
|
|
108
|
+
oaiq("measure", "contents_viewed", {
|
|
109
|
+
type: "contents",
|
|
110
|
+
amount: minorUnits(data.value),
|
|
111
|
+
currency: data.currency,
|
|
112
|
+
contents: [
|
|
113
|
+
{
|
|
114
|
+
id: data.contentId,
|
|
115
|
+
name: data.contentName,
|
|
116
|
+
content_type: "product",
|
|
117
|
+
quantity: 1,
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
} satisfies OpenAiEventData)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function trackOpenAiItemsAdded(data: {
|
|
124
|
+
contentId: string
|
|
125
|
+
contentName?: string
|
|
126
|
+
quantity: number
|
|
127
|
+
/** Unit price, major units. */
|
|
128
|
+
price: number
|
|
129
|
+
currency: string
|
|
130
|
+
/** Line value, major units. */
|
|
131
|
+
value: number
|
|
132
|
+
}): void {
|
|
133
|
+
const oaiq = safeOaiq()
|
|
134
|
+
if (!oaiq) return
|
|
135
|
+
oaiq("measure", "items_added", {
|
|
136
|
+
type: "contents",
|
|
137
|
+
amount: minorUnits(data.value),
|
|
138
|
+
currency: data.currency,
|
|
139
|
+
contents: [
|
|
140
|
+
{
|
|
141
|
+
id: data.contentId,
|
|
142
|
+
name: data.contentName,
|
|
143
|
+
content_type: "product",
|
|
144
|
+
quantity: data.quantity,
|
|
145
|
+
amount: minorUnits(data.price),
|
|
146
|
+
currency: data.currency,
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
} satisfies OpenAiEventData)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function trackOpenAiCheckoutStarted(data: {
|
|
153
|
+
contents: OpenAiContentItem[]
|
|
154
|
+
currency: string
|
|
155
|
+
/** Cart value, major units. */
|
|
156
|
+
value: number
|
|
157
|
+
}): void {
|
|
158
|
+
const oaiq = safeOaiq()
|
|
159
|
+
if (!oaiq) return
|
|
160
|
+
oaiq("measure", "checkout_started", {
|
|
161
|
+
type: "contents",
|
|
162
|
+
amount: minorUnits(data.value),
|
|
163
|
+
currency: data.currency,
|
|
164
|
+
contents: data.contents,
|
|
165
|
+
} satisfies OpenAiEventData)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function trackOpenAiOrderCreated(data: {
|
|
169
|
+
contents: OpenAiContentItem[]
|
|
170
|
+
currency: string
|
|
171
|
+
/** Order total, major units. */
|
|
172
|
+
value: number
|
|
173
|
+
/** The order's display id — the dedup key shared with the server. */
|
|
174
|
+
displayId: string | number
|
|
175
|
+
}): void {
|
|
176
|
+
const oaiq = safeOaiq()
|
|
177
|
+
if (!oaiq) return
|
|
178
|
+
oaiq(
|
|
179
|
+
"measure",
|
|
180
|
+
"order_created",
|
|
181
|
+
{
|
|
182
|
+
type: "contents",
|
|
183
|
+
amount: minorUnits(data.value),
|
|
184
|
+
currency: data.currency,
|
|
185
|
+
contents: data.contents,
|
|
186
|
+
} satisfies OpenAiEventData,
|
|
187
|
+
{ event_id: openAiOrderEventId(data.displayId) }
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Push a live consent decision to the pixel.
|
|
193
|
+
*
|
|
194
|
+
* Normally you do NOT call this: `applyConsent()` in ./consent.ts relays
|
|
195
|
+
* every decision to gtag, fbq, ttq and oaiq together. Exported for a host
|
|
196
|
+
* app driving the pixel from a consent layer of its own.
|
|
197
|
+
*
|
|
198
|
+
* Their own gate, from the measurement-pixel docs: with consent false the
|
|
199
|
+
* pixel sends no measurement pings, and the default is TRUE, which is why
|
|
200
|
+
* the loader closes it explicitly before init rather than trusting it.
|
|
201
|
+
*/
|
|
202
|
+
export function applyOpenAiConsent(adsGranted: boolean): void {
|
|
203
|
+
const oaiq = safeOaiq()
|
|
204
|
+
if (!oaiq) return
|
|
205
|
+
oaiq("consent", adsGranted)
|
|
206
|
+
}
|
|
@@ -4,6 +4,7 @@ import { PageViews } from "./page-views"
|
|
|
4
4
|
import { Gtm } from "./gtm"
|
|
5
5
|
import { MetaPixel } from "./meta-pixel"
|
|
6
6
|
import { TikTokPixel } from "./tiktok-pixel"
|
|
7
|
+
import { ChatGptPixel } from "./chatgpt-pixel"
|
|
7
8
|
import { getTrackingConfig } from "./get-tracking-config"
|
|
8
9
|
import type { StorefrontClient } from "../api/http"
|
|
9
10
|
import type { TrackingConfig } from "./types"
|
|
@@ -65,6 +66,7 @@ export async function StorefrontTags({
|
|
|
65
66
|
<LiveHeartbeat />
|
|
66
67
|
<MetaPixel pixelId={tracking.facebookPixel?.pixelId} />
|
|
67
68
|
<TikTokPixel pixelId={tracking.tiktok?.pixelId} />
|
|
69
|
+
<ChatGptPixel pixelId={tracking.chatgptAds?.pixelId} />
|
|
68
70
|
<GoogleTag
|
|
69
71
|
measurementId={tracking.ga4?.measurementId}
|
|
70
72
|
adsConversionId={tracking.googleAds?.conversionId}
|
package/src/tracking/types.ts
CHANGED
|
@@ -44,6 +44,10 @@ export type TrackingConfig = {
|
|
|
44
44
|
* read by the order.placed forwarder, exactly like Meta's CAPI token,
|
|
45
45
|
* which has never traversed this surface. */
|
|
46
46
|
tiktok?: { pixelId: string }
|
|
47
|
+
/** ChatGPT Ads, pixel id ONLY — the Conversions API key is a bearer
|
|
48
|
+
* token and stays on the platform, read by the order.placed forwarder
|
|
49
|
+
* (chatgpt-ads-pixel card). */
|
|
50
|
+
chatgptAds?: { pixelId: string }
|
|
47
51
|
/** True when the store's consent CMP is enabled — client tags must
|
|
48
52
|
* mount through the consent gate (`_1c_consent` / Consent Mode v2). */
|
|
49
53
|
consent_required?: boolean
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import type { CartDrawerLabels } from "./labels"
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Bulgarian translations for the cart drawer.
|
|
5
|
-
*
|
|
6
|
-
* Ships alongside the English defaults so Bulgarian stores get the
|
|
7
|
-
* translated copy with a single import instead of hand-writing every
|
|
8
|
-
* string. Strings match the originals from mindpages-storefront, plus the
|
|
9
|
-
* Cartbase summary-breakdown row additions.
|
|
10
|
-
*
|
|
11
|
-
* Usage in a store's layout:
|
|
12
|
-
*
|
|
13
|
-
* import {
|
|
14
|
-
* CartDrawerProvider,
|
|
15
|
-
* bulgarianCartDrawerLabels,
|
|
16
|
-
* } from "@cartbase/storefront/cart-drawer"
|
|
17
|
-
*
|
|
18
|
-
* <CartDrawerProvider cart={cart} labels={bulgarianCartDrawerLabels}>
|
|
19
|
-
* ...
|
|
20
|
-
* </CartDrawerProvider>
|
|
21
|
-
*
|
|
22
|
-
* For multi-language stores, pick the matching labels object at render
|
|
23
|
-
* time based on the active locale (the store owns its locale cookie; see
|
|
24
|
-
* docs/storefront/regions.md for the locale catalog).
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
export const bulgarianCartDrawerLabels: CartDrawerLabels = {
|
|
28
|
-
yourCart: "Вашата кошница",
|
|
29
|
-
item: "артикул",
|
|
30
|
-
items: "артикула",
|
|
31
|
-
closeCart: "Затвори кошницата",
|
|
32
|
-
|
|
33
|
-
emptyCartTitle: "Кошницата е празна",
|
|
34
|
-
emptyCartMessage: "Все още нямате добавени продукти.",
|
|
35
|
-
browseProducts: "Разгледай продуктите",
|
|
36
|
-
|
|
37
|
-
awayFromFreeShipping: "до безплатна доставка",
|
|
38
|
-
allRewardsUnlocked: "Всички награди са отключени",
|
|
39
|
-
|
|
40
|
-
addGiftWrap: "Добави подаръчна опаковка",
|
|
41
|
-
giftNote: "Картичка",
|
|
42
|
-
orderNote: "Бележка",
|
|
43
|
-
giftNotePlaceholder: "Добави лично съобщение...",
|
|
44
|
-
orderNotePlaceholder: "Специални инструкции за поръчката...",
|
|
45
|
-
|
|
46
|
-
estimatedRewardPoints: "Очаквани точки за награда",
|
|
47
|
-
|
|
48
|
-
pairsWellWith: "Подходящи допълнения",
|
|
49
|
-
youllLoveThis: "Ще ви хареса",
|
|
50
|
-
youMightAlsoLike: "Може да ви хареса",
|
|
51
|
-
addToCart: "Добави",
|
|
52
|
-
|
|
53
|
-
subtotal: "Междинна сума",
|
|
54
|
-
taxAndShipping: "С включен ДДС. Доставката се изчислява при плащане.",
|
|
55
|
-
shipping: "Доставка",
|
|
56
|
-
free: "БЕЗПЛАТНА",
|
|
57
|
-
calculatedAtCheckout: "Изчислява се при плащане",
|
|
58
|
-
total: "Общо",
|
|
59
|
-
secureCheckout: "Към поръчка",
|
|
60
|
-
shippingAndTaxCalculatedAtCheckout:
|
|
61
|
-
"Доставката и данъкът се изчисляват при плащане",
|
|
62
|
-
|
|
63
|
-
discount: "Отстъпка",
|
|
64
|
-
tax: "ДДС",
|
|
65
|
-
paymentMethodFee: "Такса за плащане",
|
|
66
|
-
giftCard: "Подаръчна карта",
|
|
67
|
-
remainingToPay: "Остава за плащане",
|
|
68
|
-
|
|
69
|
-
continueShopping: "Продължи пазаруването",
|
|
70
|
-
|
|
71
|
-
remove: "Премахни",
|
|
72
|
-
}
|