@cartbase/storefront 0.7.0 → 0.9.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/README.md +4 -5
- package/package.json +248 -243
- package/src/api/carts.ts +16 -1
- package/src/api/http.ts +17 -9
- package/src/api/index.ts +1 -0
- package/src/api/store.ts +35 -0
- package/src/cart-drawer/cross-sell-carousel.tsx +2 -3
- package/src/cart-drawer/gift-wrap.tsx +82 -83
- package/src/cart-drawer/item/index.tsx +4 -6
- package/src/cart-drawer/sticky-footer.tsx +73 -73
- package/src/checkout/geocode.ts +1 -1
- package/src/checkout/gift-card-section.tsx +2 -2
- package/src/checkout/line-item-card.tsx +3 -3
- package/src/checkout/order-summary.tsx +11 -12
- package/src/checkout/payment-button.tsx +2 -3
- package/src/checkout/shipping-method-list.tsx +2 -2
- package/src/lib/cookie-names.ts +45 -0
- package/src/lib/money.ts +1 -1
- package/src/lib/platform.ts +13 -0
- package/src/lib/price.tsx +39 -0
- package/src/lib/visitor.ts +74 -0
- 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/tracking/attribution.ts +134 -105
- package/src/tracking/chatgpt-pixel.tsx +92 -0
- package/src/tracking/consent.ts +12 -0
- package/src/tracking/events.ts +56 -2
- package/src/tracking/get-tracking-attribution.ts +16 -3
- package/src/tracking/index.ts +23 -0
- package/src/tracking/live-heartbeat.tsx +63 -0
- package/src/tracking/oaiq.ts +206 -0
- package/src/tracking/page-views.tsx +96 -0
- package/src/tracking/storefront-tags.tsx +77 -67
- package/src/tracking/types.ts +4 -0
- package/src/lib/dual-price.tsx +0 -73
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,
|
|
@@ -4,6 +4,14 @@ import { getTrackingConfig } from "./get-tracking-config"
|
|
|
4
4
|
import type { StorefrontClient } from "../api/http"
|
|
5
5
|
import type { TrackingAttribution, TrackingClientHints } from "./types"
|
|
6
6
|
import { CLICK_ID_METADATA_KEYS } from "./attribution"
|
|
7
|
+
import {
|
|
8
|
+
LEGACY_UTM_FIRST_COOKIE,
|
|
9
|
+
LEGACY_UTM_LAST_COOKIE,
|
|
10
|
+
LEGACY_VISITOR_COOKIE,
|
|
11
|
+
UTM_FIRST_COOKIE,
|
|
12
|
+
UTM_LAST_COOKIE,
|
|
13
|
+
VISITOR_COOKIE,
|
|
14
|
+
} from "../lib/cookie-names"
|
|
7
15
|
|
|
8
16
|
/**
|
|
9
17
|
* Reads Meta + GA4 attribution signals from the current Next.js server
|
|
@@ -73,14 +81,19 @@ export async function getTrackingAttribution(
|
|
|
73
81
|
// attribution.ts on first tracking call. Surfaces server-side here
|
|
74
82
|
// so the order.placed CAPI Purchase can include it as external_id
|
|
75
83
|
// (alongside customer_id when both exist — Meta accepts an array).
|
|
76
|
-
anonId =
|
|
84
|
+
anonId =
|
|
85
|
+
cookieStore.get(VISITOR_COOKIE)?.value ?? cookieStore.get(LEGACY_VISITOR_COOKIE)?.value
|
|
77
86
|
gaCookieRaw = cookieStore.get("_ga")?.value
|
|
78
87
|
// _1c_utm_first / _1c_utm_last — JSON-encoded UTM tuples written
|
|
79
88
|
// by browser-side captureUtmsFromUrl(). First-touch records the
|
|
80
89
|
// acquisition campaign (365-day TTL); last-touch records the
|
|
81
90
|
// closer (90-day TTL, refreshed on each UTM-bearing visit).
|
|
82
|
-
utmFirstRaw =
|
|
83
|
-
|
|
91
|
+
utmFirstRaw =
|
|
92
|
+
cookieStore.get(UTM_FIRST_COOKIE)?.value ??
|
|
93
|
+
cookieStore.get(LEGACY_UTM_FIRST_COOKIE)?.value
|
|
94
|
+
utmLastRaw =
|
|
95
|
+
cookieStore.get(UTM_LAST_COOKIE)?.value ??
|
|
96
|
+
cookieStore.get(LEGACY_UTM_LAST_COOKIE)?.value
|
|
84
97
|
|
|
85
98
|
// _ga_<MEASUREMENT_ID> uses the GA4 measurementId (e.g., G-ABCDEF1234)
|
|
86
99
|
// with the "G-" prefix stripped: cookie name = `_ga_ABCDEF1234`.
|
package/src/tracking/index.ts
CHANGED
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
* from its own config. This is the one a storefront should mount;
|
|
19
19
|
* the individual tags below are for a layout that needs control.
|
|
20
20
|
* - <TrackInit /> — captures UTMs and ad-click ids on the landing page
|
|
21
|
+
* - <PageViews /> — one pageview per navigation, client-side ones
|
|
22
|
+
* included; the platform derives page type + entity id from the path
|
|
23
|
+
* - <LiveHeartbeat /> — pings while a tab is open, so the merchant's live
|
|
24
|
+
* visitor count means "right now" rather than "recently active"
|
|
21
25
|
* (they exist only there) and starts the engagement clock.
|
|
22
26
|
* - <MetaPixel pixelId> / <GoogleTag measurementId adsConversionId> /
|
|
23
27
|
* <TikTokPixel pixelId> / <Gtm containerId> / <Rybbit siteId> —
|
|
@@ -53,8 +57,16 @@ export { MetaPixel, updatePixelAdvancedMatching } from "./meta-pixel"
|
|
|
53
57
|
export { GA4, GoogleTag } from "./ga4"
|
|
54
58
|
export { Gtm } from "./gtm"
|
|
55
59
|
export { TikTokPixel } from "./tiktok-pixel"
|
|
60
|
+
export { ChatGptPixel } from "./chatgpt-pixel"
|
|
56
61
|
export { StorefrontTags } from "./storefront-tags"
|
|
57
62
|
export { TrackInit } from "./track-init"
|
|
63
|
+
// The live-visitor heartbeat. Mounted by <StorefrontTags> so a storefront
|
|
64
|
+
// gets it without knowing it exists.
|
|
65
|
+
export { LiveHeartbeat } from "./live-heartbeat"
|
|
66
|
+
// Every navigation, automatically. Mounted by <StorefrontTags>; the page
|
|
67
|
+
// type and the record it was about are derived by the platform, never
|
|
68
|
+
// declared here.
|
|
69
|
+
export { PageViews } from "./page-views"
|
|
58
70
|
export { TrackOrderPurchase } from "./track-order-purchase"
|
|
59
71
|
export { Rybbit } from "./rybbit"
|
|
60
72
|
export { ConsentInit } from "./consent-init"
|
|
@@ -123,6 +135,17 @@ export {
|
|
|
123
135
|
tiktokPurchaseEventId,
|
|
124
136
|
type TikTokContentItem,
|
|
125
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"
|
|
126
149
|
export {
|
|
127
150
|
trackGoogleAdsPurchase,
|
|
128
151
|
googleAdsPurchaseSendTo,
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react"
|
|
4
|
+
|
|
5
|
+
import { getTabSessionId, platformCollectorPresent, readVisitorId } from "../lib/visitor"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The live-visitor heartbeat.
|
|
9
|
+
*
|
|
10
|
+
* While this tab is open it pings the store's own domain every 15 seconds.
|
|
11
|
+
* The platform counts a visitor as present while their last ping is inside
|
|
12
|
+
* a 30 second window, so a closed tab, a killed browser, a dropped network
|
|
13
|
+
* or a locked phone all disappear within half a minute with nothing to
|
|
14
|
+
* detect them — no unload handler, which browsers do not guarantee anyway,
|
|
15
|
+
* especially on mobile.
|
|
16
|
+
*
|
|
17
|
+
* Why a heartbeat instead of counting recent events: a shopper reading one
|
|
18
|
+
* product page for four minutes sends nothing in between, so an
|
|
19
|
+
* event-window count either forgets them or keeps counting people who left.
|
|
20
|
+
* A ping answers "who is on the store right now" literally.
|
|
21
|
+
*
|
|
22
|
+
* The path is first-party (`/_cb/heartbeat` on the merchant's own domain),
|
|
23
|
+
* which is what keeps it out of ad blockers and away from third-party
|
|
24
|
+
* cookie rules. The platform's injected proxy forwards it.
|
|
25
|
+
*
|
|
26
|
+
* Costs nothing when it fails: every call is fire-and-forget with keepalive,
|
|
27
|
+
* a dropped ping is one missing dot on a chart.
|
|
28
|
+
*/
|
|
29
|
+
const HEARTBEAT_MS = 15_000
|
|
30
|
+
const ENDPOINT = "/_cb/heartbeat"
|
|
31
|
+
|
|
32
|
+
export function LiveHeartbeat() {
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
// The deploy-injected collector already does this on every page.
|
|
35
|
+
if (platformCollectorPresent()) return
|
|
36
|
+
const session = getTabSessionId()
|
|
37
|
+
|
|
38
|
+
const ping = () => {
|
|
39
|
+
const body = JSON.stringify({
|
|
40
|
+
session_id: session,
|
|
41
|
+
device_id: readVisitorId(),
|
|
42
|
+
// Path only. The platform drops query strings as well, because
|
|
43
|
+
// signed document links and login codes travel in them.
|
|
44
|
+
path: window.location.pathname,
|
|
45
|
+
})
|
|
46
|
+
void fetch(ENDPOINT, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: { "content-type": "application/json" },
|
|
49
|
+
body,
|
|
50
|
+
keepalive: true,
|
|
51
|
+
}).catch(() => undefined)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
ping()
|
|
55
|
+
// No visibility handling on purpose (the live-polling law): browsers
|
|
56
|
+
// already throttle timers in a hidden tab, and anything that guesses at
|
|
57
|
+
// attention will eventually guess wrong.
|
|
58
|
+
const timer = setInterval(ping, HEARTBEAT_MS)
|
|
59
|
+
return () => clearInterval(timer)
|
|
60
|
+
}, [])
|
|
61
|
+
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { Suspense, useEffect, useRef } from "react"
|
|
4
|
+
import { usePathname, useSearchParams } from "next/navigation"
|
|
5
|
+
|
|
6
|
+
import { getTabSessionId, platformCollectorPresent, readVisitorId } from "../lib/visitor"
|
|
7
|
+
import { readConsentCookie } from "./consent"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* PAGEVIEWS — every route, automatically.
|
|
11
|
+
*
|
|
12
|
+
* Mounted once by <StorefrontTags>, this fires on every navigation the
|
|
13
|
+
* router performs, client-side ones included. That is the whole design:
|
|
14
|
+
* NOT a list of instrumented pages. Home, collections, blog posts, content
|
|
15
|
+
* pages, search, account and 404s are all captured without a single
|
|
16
|
+
* per-page call, which is the only shape that survives an agent inventing
|
|
17
|
+
* routes the platform has never seen.
|
|
18
|
+
*
|
|
19
|
+
* WHAT IT DELIBERATELY DOES NOT SEND: what the page IS. No `pageType` prop,
|
|
20
|
+
* no per-page declaration. The platform derives the page type and the id of
|
|
21
|
+
* the record the page was about from the path and its own catalog, because
|
|
22
|
+
* a declaration is a guarantee living in merchant code, and merchant code
|
|
23
|
+
* forgets. A route nobody anticipated still resolves, because the last path
|
|
24
|
+
* segment is looked up as a handle in the store's own catalog.
|
|
25
|
+
*
|
|
26
|
+
* The path is first-party (`/_cb/pageview` on the merchant's own domain),
|
|
27
|
+
* which keeps it out of ad blockers and away from third-party cookie rules.
|
|
28
|
+
*
|
|
29
|
+
* Query VALUES never leave the browser: only the keys travel, because a
|
|
30
|
+
* search term is shopper input and signed links live in query strings.
|
|
31
|
+
*/
|
|
32
|
+
const ENDPOINT = "/_cb/pageview"
|
|
33
|
+
|
|
34
|
+
function PageViewsInner() {
|
|
35
|
+
const pathname = usePathname()
|
|
36
|
+
const searchParams = useSearchParams()
|
|
37
|
+
// The referrer for a client-side navigation is the page you came FROM,
|
|
38
|
+
// which the browser cannot tell us — document.referrer only ever holds
|
|
39
|
+
// the page that loaded the app.
|
|
40
|
+
const lastPath = useRef<string | null>(null)
|
|
41
|
+
|
|
42
|
+
const search = searchParams?.toString() ?? ""
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!pathname) return
|
|
46
|
+
// The deploy-injected collector already reports every navigation.
|
|
47
|
+
if (platformCollectorPresent()) return
|
|
48
|
+
|
|
49
|
+
const previous = lastPath.current
|
|
50
|
+
lastPath.current = pathname
|
|
51
|
+
|
|
52
|
+
const consent = readConsentCookie()
|
|
53
|
+
const referrer = previous
|
|
54
|
+
? `${window.location.origin}${previous}`
|
|
55
|
+
: typeof document !== "undefined" && document.referrer
|
|
56
|
+
? document.referrer
|
|
57
|
+
: undefined
|
|
58
|
+
|
|
59
|
+
const body = JSON.stringify({
|
|
60
|
+
path: pathname,
|
|
61
|
+
query_keys: search ? Array.from(new URLSearchParams(search).keys()).slice(0, 20) : undefined,
|
|
62
|
+
referrer,
|
|
63
|
+
title: typeof document !== "undefined" ? document.title : undefined,
|
|
64
|
+
session_id: getTabSessionId(),
|
|
65
|
+
device_id: readVisitorId(),
|
|
66
|
+
// undefined when the store runs no banner, which the platform reads
|
|
67
|
+
// as "nothing to honour" rather than as a refusal.
|
|
68
|
+
consent: consent ? consent.analytics : undefined,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
void fetch(ENDPOINT, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { "content-type": "application/json" },
|
|
74
|
+
body,
|
|
75
|
+
keepalive: true,
|
|
76
|
+
}).catch(() => undefined)
|
|
77
|
+
// `search` is in the deps on purpose: a filter or a search query change
|
|
78
|
+
// is a new pageview, and a merchant reading a search report needs it.
|
|
79
|
+
}, [pathname, search])
|
|
80
|
+
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The Suspense boundary is INSIDE the component, not left to the consumer.
|
|
86
|
+
* `useSearchParams` opts a page out of static rendering unless it sits
|
|
87
|
+
* under one, and a storefront must not be able to lose its own static
|
|
88
|
+
* pages by mounting analytics. Nobody can wire this wrong.
|
|
89
|
+
*/
|
|
90
|
+
export function PageViews() {
|
|
91
|
+
return (
|
|
92
|
+
<Suspense fallback={null}>
|
|
93
|
+
<PageViewsInner />
|
|
94
|
+
</Suspense>
|
|
95
|
+
)
|
|
96
|
+
}
|