@cartbase/storefront 0.6.0 → 0.8.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.
@@ -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,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
+ }
@@ -0,0 +1,75 @@
1
+ import { GoogleTag } from "./ga4"
2
+ import { LiveHeartbeat } from "./live-heartbeat"
3
+ import { PageViews } from "./page-views"
4
+ import { Gtm } from "./gtm"
5
+ import { MetaPixel } from "./meta-pixel"
6
+ import { TikTokPixel } from "./tiktok-pixel"
7
+ import { getTrackingConfig } from "./get-tracking-config"
8
+ import type { StorefrontClient } from "../api/http"
9
+ import type { TrackingConfig } from "./types"
10
+
11
+ /**
12
+ * StorefrontTags — every marketing tag the store has configured, mounted
13
+ * from the store's own config.
14
+ *
15
+ * THE POINT: a merchant who saves their pixel ids in the admin gets those
16
+ * tags on their storefront, with nothing else to do and no code to write.
17
+ * Before this existed the package EXPORTED tags and no storefront mounted
18
+ * them, so the admin could serve a Meta pixel id, a GA4 id and a Google
19
+ * Ads conversion id that reached the browser and did nothing. Adding a
20
+ * vendor is one line HERE now, and every store built on the package gains
21
+ * it at the next deploy.
22
+ *
23
+ * Consent: nothing extra to wire. <ConsentInit> sets the Consent Mode v2
24
+ * defaults synchronously ahead of these, the Meta and TikTok snippets read
25
+ * the shared consent cookie before they are allowed to write cookies, and
26
+ * `applyConsent()` relays a live decision to all three vendors. Mount this
27
+ * AFTER <ConsentInit> and the gate holds.
28
+ *
29
+ * Google is deliberately ONE tag with two destinations rather than two
30
+ * loaders — see the comment block in ./ga4.tsx. It is also why the Ads
31
+ * conversion inherits the consent gate for free.
32
+ *
33
+ * Server component: pass a `client` and it fetches the config (cached),
34
+ * or pass an already-fetched `config` when the layout has one in hand.
35
+ * Renders nothing for a vendor the store has not configured, so a store
36
+ * running only GA4 ships exactly one tag.
37
+ */
38
+ export async function StorefrontTags({
39
+ client,
40
+ config,
41
+ }: {
42
+ /** Used to fetch the tracking config when `config` is not supplied. */
43
+ client?: StorefrontClient
44
+ /** Pre-fetched tracking block, e.g. from a layout that already has it. */
45
+ config?: TrackingConfig
46
+ }) {
47
+ let tracking: TrackingConfig | undefined = config
48
+ if (!tracking) {
49
+ if (!client) return null
50
+ try {
51
+ tracking = await getTrackingConfig(client)
52
+ } catch {
53
+ // A tracking-config failure must never take a storefront down.
54
+ return null
55
+ }
56
+ }
57
+
58
+ return (
59
+ <>
60
+ {/* Cartbase's own analytics, always on and never a merchant setting:
61
+ it is what feeds the merchant's own dashboard, so it does not
62
+ depend on any vendor id being configured. The vendor tags below
63
+ only appear when the merchant has entered their ids. */}
64
+ <PageViews />
65
+ <LiveHeartbeat />
66
+ <MetaPixel pixelId={tracking.facebookPixel?.pixelId} />
67
+ <TikTokPixel pixelId={tracking.tiktok?.pixelId} />
68
+ <GoogleTag
69
+ measurementId={tracking.ga4?.measurementId}
70
+ adsConversionId={tracking.googleAds?.conversionId}
71
+ />
72
+ <Gtm containerId={tracking.gtm?.containerId} />
73
+ </>
74
+ )
75
+ }
@@ -0,0 +1,83 @@
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
+ * TikTokPixel — client-side base pixel injector, the TikTok twin of
10
+ * <MetaPixel>.
11
+ *
12
+ * Injects TikTok's own base snippet with `<Script
13
+ * strategy="afterInteractive">`, resolves consent from the `_1c_consent`
14
+ * cookie BEFORE the pixel is allowed to write cookies, then loads and
15
+ * fires the initial page view.
16
+ *
17
+ * Consent model. TikTok's base code registers `holdConsent`,
18
+ * `grantConsent` and `revokeConsent` on `ttq` alongside `page` and
19
+ * `track`, and `setAndDefer` queues every call onto the `ttq` array
20
+ * before the SDK is fetched. So the Meta pattern transfers exactly:
21
+ *
22
+ * ttq.holdConsent() // queued before load — cookies withheld
23
+ * ttq.load(pixelId)
24
+ * ttq.page()
25
+ * ttq.grantConsent() // visitor accepted marketing
26
+ * ttq.revokeConsent() // visitor declined, or withdrew later
27
+ *
28
+ * HOLDING rather than withholding the script is the point, and it is the
29
+ * same reasoning as the Meta gate above it: under load-on-consent, a
30
+ * visitor who browses three products and only then accepts gives TikTok
31
+ * nothing for those three pages. Holding queues them and the grant
32
+ * releases them.
33
+ *
34
+ * Live decisions arrive through `applyConsent()` in ./consent.ts, which
35
+ * relays to gtag, fbq and ttq together — there is no cookie watcher.
36
+ *
37
+ * One dependency outside the code: Cookie Consent Mode must be switched
38
+ * on for this pixel in TikTok Events Manager. With it off, `holdConsent`
39
+ * is an inert queued call and the pixel behaves like any unconsented
40
+ * pixel — no error, no crash, and no gate. That switch belongs to whoever
41
+ * owns the ad account.
42
+ *
43
+ * Renders nothing when `pixelId` is falsy, so a layout can mount it
44
+ * unconditionally.
45
+ */
46
+ export function TikTokPixel({ pixelId }: { pixelId?: string }) {
47
+ if (!pixelId) return null
48
+
49
+ const id = jsStringLiteral(pixelId)
50
+
51
+ const initSnippet = `
52
+ !function (w, d, t) {
53
+ w.TiktokAnalyticsObject=t;
54
+ var ttq=w[t]=w[t]||[];
55
+ ttq.methods=["page","track","identify","instances","debug","on","off","once","ready","alias","group","enableCookie","disableCookie","holdConsent","revokeConsent","grantConsent"];
56
+ ttq.setAndDefer=function(t,e){t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}};
57
+ for(var i=0;i<ttq.methods.length;i++)ttq.setAndDefer(ttq,ttq.methods[i]);
58
+ ttq.instance=function(t){var e=ttq._i[t]||[];for(var n=0;n<ttq.methods.length;n++)ttq.setAndDefer(e,ttq.methods[n]);return e};
59
+ ttq.load=function(e,n){var r="https://analytics.tiktok.com/i18n/pixel/events.js";ttq._i=ttq._i||{},ttq._i[e]=[],ttq._i[e]._u=r,ttq._t=ttq._t||{},ttq._t[e]=+new Date,ttq._o=ttq._o||{},ttq._o[e]=n||{};var o=d.createElement("script");o.type="text/javascript",o.async=!0,o.src=r+"?sdkid="+e+"&lib="+t;var a=d.getElementsByTagName("script")[0];a.parentNode.insertBefore(o,a)};
60
+
61
+ ttq.holdConsent();
62
+ ttq.load(${id});
63
+ ttq.page();
64
+
65
+ (function(){
66
+ var ads=false;
67
+ try{
68
+ var m=d.cookie.match(/(?:^|; )${CONSENT_COOKIE}=([^;]*)/);
69
+ if(m){ads=!!JSON.parse(decodeURIComponent(m[1])).ads;}
70
+ }catch(e){}
71
+ if(ads){ttq.grantConsent();}else{ttq.revokeConsent();}
72
+ })();
73
+ }(window, document, 'ttq');
74
+ `.trim()
75
+
76
+ return (
77
+ <Script
78
+ id="tiktok-pixel-init"
79
+ strategy="afterInteractive"
80
+ dangerouslySetInnerHTML={{ __html: initSnippet }}
81
+ />
82
+ )
83
+ }
@@ -0,0 +1,56 @@
1
+ "use client"
2
+
3
+ import { useEffect } from "react"
4
+
5
+ import {
6
+ captureClickIdsFromUrl,
7
+ captureUtmsFromUrl,
8
+ getOrCreateAnonId,
9
+ setTrackingDefaults,
10
+ } from "./attribution"
11
+ import { initEngagementTime } from "./use-engagement-time"
12
+
13
+ /**
14
+ * TrackInit — mounts once in the root layout and does the four things
15
+ * that must happen on the FIRST page of a visit, not at checkout:
16
+ *
17
+ * 1. Capture UTM parameters into the first-touch and last-touch
18
+ * cookies. First-touch is written once and survives a year, so the
19
+ * campaign that acquired the visitor is still attributable when they
20
+ * convert months later.
21
+ * 2. Capture the ad-click identifiers — TikTok's `ttclid`, Google's
22
+ * `gclid` and the two iOS variants `gbraid` / `wbraid`. These arrive
23
+ * ONLY on the landing URL of an ad click. Miss them there and they
24
+ * are gone: the visitor navigates, the parameter disappears, and
25
+ * neither platform can ever tie the eventual order back to the click
26
+ * that paid for it.
27
+ * 3. Create the anonymous visitor id, so events before signup can be
28
+ * joined to the customer afterwards.
29
+ * 4. Start the engagement clock, so time-on-session is measured from
30
+ * the landing page rather than lazily from the first checkout call.
31
+ *
32
+ * All four are idempotent and safe on every mount: a URL with no
33
+ * parameters leaves every existing cookie untouched, so an organic visit
34
+ * never clears the click that acquired the visitor.
35
+ *
36
+ * Cookies only — nothing is sent from here. The server reads them at
37
+ * checkout through `getTrackingAttribution()` and writes them onto the
38
+ * cart, which carries them to the order, which is where the platform's
39
+ * server-side Purchase events read them.
40
+ *
41
+ * `country` is the storefront's default market, used when an event has no
42
+ * address to derive one from (a product view by an anonymous visitor).
43
+ * Passing it lifts country coverage from the fraction of events that
44
+ * carry an address to effectively all of them.
45
+ */
46
+ export function TrackInit({ country }: { country?: string } = {}) {
47
+ useEffect(() => {
48
+ if (country) setTrackingDefaults({ country })
49
+ captureUtmsFromUrl()
50
+ captureClickIdsFromUrl()
51
+ getOrCreateAnonId()
52
+ initEngagementTime()
53
+ }, [country])
54
+
55
+ return null
56
+ }
@@ -0,0 +1,122 @@
1
+ "use client"
2
+
3
+ import { useEffect, useRef } from "react"
4
+
5
+ import type { CartLineItem, CompletedOrder } from "../api/carts"
6
+ import { trackOrderPurchase, type TrackedLine } from "./events"
7
+ import type { TrackingConfig } from "./types"
8
+
9
+ /**
10
+ * TrackOrderPurchase — fires the purchase event to every configured
11
+ * vendor from the order confirmation page.
12
+ *
13
+ * A component rather than four calls in the app, because purchase is the
14
+ * one event nobody may get wrong and it is the one with the most ways to
15
+ * go wrong: the dedup keys must match the server's exactly, the ids must
16
+ * be the CATALOGUE's, and every vendor must fire from the same place.
17
+ * A storefront mounts this and is done.
18
+ *
19
+ * FIRES ONCE PER ORDER, per browser. The guard is real, not defensive
20
+ * tidiness: this page is linked from every order email, so a buyer opens
21
+ * it again days later, and React strict mode double-mounts effects in
22
+ * development. The vendors dedupe too (Meta and TikTok on the shared
23
+ * event id, GA4 and Google Ads on the transaction id) and the platform's
24
+ * server side carries its own idempotency flag, so this is the third of
25
+ * three layers — but it is the only one that stops the request being made
26
+ * at all.
27
+ *
28
+ * `customerType` comes from `order.metadata.customer_type`, which the
29
+ * platform's order.placed forwarder computes ONCE and persists. Reading
30
+ * it from the order rather than deriving it in the browser is what keeps
31
+ * the browser and the server from telling Google and TikTok different
32
+ * things about the same buyer.
33
+ */
34
+
35
+ const FIRED_KEY_PREFIX = "_cb_purchase_tracked_"
36
+
37
+ function alreadyFired(key: string): boolean {
38
+ try {
39
+ return sessionStorage.getItem(key) === "1"
40
+ } catch {
41
+ // Private mode / blocked storage: fall back to the in-memory ref
42
+ // only. Worst case is a second event the vendors then dedupe.
43
+ return false
44
+ }
45
+ }
46
+
47
+ function markFired(key: string): void {
48
+ try {
49
+ sessionStorage.setItem(key, "1")
50
+ } catch {
51
+ // Non-fatal — see above.
52
+ }
53
+ }
54
+
55
+ export function TrackOrderPurchase({
56
+ order,
57
+ items,
58
+ config,
59
+ }: {
60
+ order: CompletedOrder
61
+ /** The cart lines as completed. Carry product_id — it is the catalogue
62
+ * key Meta and TikTok match on, and the flattened order items may not
63
+ * have it. */
64
+ items: CartLineItem[]
65
+ /** The store's tracking config. Google Ads fires only when it carries
66
+ * both the account id and the purchase label. */
67
+ config?: TrackingConfig
68
+ }) {
69
+ const firedRef = useRef(false)
70
+
71
+ useEffect(() => {
72
+ const displayId = order.display_id
73
+ // No display id means no dedup key, and an event that cannot dedupe
74
+ // against the server's is worse than no event: it double-counts.
75
+ if (displayId === undefined || displayId === null) return
76
+
77
+ const key = `${FIRED_KEY_PREFIX}${displayId}`
78
+ if (firedRef.current || alreadyFired(key)) return
79
+ firedRef.current = true
80
+ markFired(key)
81
+
82
+ const lines: TrackedLine[] = items.map((item) => ({
83
+ productId: item.product_id || item.variant_id || item.id,
84
+ variantId: item.variant_id ?? undefined,
85
+ title: item.product_title || item.title || "",
86
+ quantity: Number(item.quantity) || 1,
87
+ price: Number(item.unit_price) || 0,
88
+ }))
89
+
90
+ const summary = (Array.isArray(order.summary)
91
+ ? order.summary[0]
92
+ : order.summary) as Record<string, unknown> | null
93
+ const totals = (summary?.totals ?? summary ?? {}) as Record<string, unknown>
94
+ const num = (v: unknown): number | undefined =>
95
+ typeof v === "number" ? v : undefined
96
+
97
+ const value =
98
+ num(totals.total) ??
99
+ lines.reduce((sum, line) => sum + line.price * line.quantity, 0)
100
+
101
+ const metadata = (order.metadata ?? {}) as Record<string, unknown>
102
+ const customerType =
103
+ metadata.customer_type === "new" || metadata.customer_type === "returning"
104
+ ? metadata.customer_type
105
+ : undefined
106
+
107
+ trackOrderPurchase(
108
+ {
109
+ displayId,
110
+ currency: (order.currency_code || "eur").toUpperCase(),
111
+ value,
112
+ tax: num(totals.tax_total),
113
+ shipping: num(totals.shipping_total),
114
+ lines,
115
+ customerType,
116
+ },
117
+ config
118
+ )
119
+ }, [order, items, config])
120
+
121
+ return null
122
+ }
@@ -0,0 +1,180 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Typed wrappers around the global `ttq` from the TikTok Pixel — the
5
+ * TikTok twin of ./fbq.ts, and deliberately the same shape so a surface
6
+ * that fires one can fire the other on the line below.
7
+ *
8
+ * Every helper no-ops when `window.ttq` is absent (pixel not configured,
9
+ * server render, script blocked), so callers need no guards.
10
+ *
11
+ * Event names are case-sensitive and taken from TikTok's supported pixel
12
+ * events table:
13
+ * https://business-api.tiktok.com/portal/docs/supported-pixel-events/v1.3
14
+ * `Purchase` is the current name; `CompletePayment` and `PlaceAnOrder` do
15
+ * not appear in the current tables at all.
16
+ *
17
+ * `event_id` is the THIRD argument, a separate object after the
18
+ * properties, per TikTok's own implementation guide:
19
+ *
20
+ * ttq.track('Purchase', { ...properties }, { event_id: 'tt_purchase_1044' })
21
+ *
22
+ * TikTok deduplicates on event_source_id + event + event_id and discards
23
+ * duplicates for 48 hours from the first event, which is how the browser
24
+ * Purchase and the server-side Events API Purchase collapse into one.
25
+ *
26
+ * `content_id` must be the SAME identifier the product catalogue uses,
27
+ * which on Cartbase is the PRODUCT id — the value the feed emits as
28
+ * <g:id> and the value Meta already receives. A variant id here matches
29
+ * no catalogue entry at all.
30
+ */
31
+
32
+ /** One product line inside `contents`. */
33
+ export type TikTokContentItem = {
34
+ content_id: string
35
+ content_name?: string
36
+ content_category?: string
37
+ brand?: string
38
+ quantity?: number
39
+ price?: number
40
+ }
41
+
42
+ type TikTokProperties = {
43
+ contents?: TikTokContentItem[]
44
+ content_id?: string
45
+ content_name?: string
46
+ content_type?: "product" | "product_group"
47
+ currency?: string
48
+ value?: number
49
+ quantity?: number
50
+ description?: string
51
+ order_id?: string
52
+ /** TikTok's new-versus-returning signal. Same computed fact the backend
53
+ * persists on the order and sends to Google Ads as `new_customer`. */
54
+ customer_type?: "new" | "returning"
55
+ }
56
+
57
+ type TtqFn = {
58
+ track: (
59
+ event: string,
60
+ properties?: TikTokProperties,
61
+ options?: { event_id?: string }
62
+ ) => void
63
+ page: () => void
64
+ identify: (data: Record<string, unknown>) => void
65
+ grantConsent: () => void
66
+ revokeConsent: () => void
67
+ holdConsent: () => void
68
+ }
69
+
70
+ function safeTtq(): TtqFn | null {
71
+ if (typeof window === "undefined") return null
72
+ const ttq = (window as unknown as { ttq?: Partial<TtqFn> }).ttq
73
+ if (!ttq || typeof ttq.track !== "function") return null
74
+ return ttq as TtqFn
75
+ }
76
+
77
+ /**
78
+ * THE purchase dedup key — must equal what the backend forwarder sends
79
+ * (`tiktokPurchaseEventId` in src/lib/tracking/constants.ts). Built from
80
+ * display_id here so the format cannot drift between the two sides, the
81
+ * same guarantee `trackPurchase` gives for Meta.
82
+ */
83
+ export function tiktokPurchaseEventId(displayId: string | number): string {
84
+ return `tt_purchase_${displayId}`
85
+ }
86
+
87
+ export function trackTikTokViewContent(data: {
88
+ contentId: string
89
+ contentName?: string
90
+ currency: string
91
+ value: number
92
+ }): void {
93
+ const ttq = safeTtq()
94
+ if (!ttq) return
95
+ ttq.track("ViewContent", {
96
+ content_id: data.contentId,
97
+ content_type: "product",
98
+ content_name: data.contentName,
99
+ currency: data.currency,
100
+ value: data.value,
101
+ })
102
+ }
103
+
104
+ export function trackTikTokAddToCart(data: {
105
+ contentId: string
106
+ contentName?: string
107
+ quantity: number
108
+ price: number
109
+ currency: string
110
+ value: number
111
+ }): void {
112
+ const ttq = safeTtq()
113
+ if (!ttq) return
114
+ ttq.track("AddToCart", {
115
+ contents: [
116
+ {
117
+ content_id: data.contentId,
118
+ content_name: data.contentName,
119
+ quantity: data.quantity,
120
+ price: data.price,
121
+ },
122
+ ],
123
+ content_type: "product",
124
+ currency: data.currency,
125
+ value: data.value,
126
+ })
127
+ }
128
+
129
+ export function trackTikTokInitiateCheckout(data: {
130
+ contents: TikTokContentItem[]
131
+ currency: string
132
+ value: number
133
+ }): void {
134
+ const ttq = safeTtq()
135
+ if (!ttq) return
136
+ ttq.track("InitiateCheckout", {
137
+ contents: data.contents,
138
+ content_type: "product",
139
+ currency: data.currency,
140
+ value: data.value,
141
+ })
142
+ }
143
+
144
+ export function trackTikTokPurchase(data: {
145
+ contents: TikTokContentItem[]
146
+ currency: string
147
+ value: number
148
+ /** The order's display id — the dedup key and the order_id TikTok shows. */
149
+ displayId: string | number
150
+ customerType?: "new" | "returning"
151
+ }): void {
152
+ const ttq = safeTtq()
153
+ if (!ttq) return
154
+ ttq.track(
155
+ "Purchase",
156
+ {
157
+ contents: data.contents,
158
+ content_type: "product",
159
+ currency: data.currency,
160
+ value: data.value,
161
+ order_id: String(data.displayId),
162
+ ...(data.customerType ? { customer_type: data.customerType } : {}),
163
+ },
164
+ { event_id: tiktokPurchaseEventId(data.displayId) }
165
+ )
166
+ }
167
+
168
+ /**
169
+ * Push a live consent decision to the pixel.
170
+ *
171
+ * Normally you do NOT call this: `applyConsent()` in ./consent.ts already
172
+ * relays every decision to gtag, fbq and ttq together. It is exported for
173
+ * a host app driving the pixel from a consent layer of its own.
174
+ */
175
+ export function applyTikTokConsent(adsGranted: boolean): void {
176
+ const ttq = safeTtq()
177
+ if (!ttq) return
178
+ if (adsGranted) ttq.grantConsent()
179
+ else ttq.revokeConsent()
180
+ }