@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.
- package/README.md +74 -58
- package/package.json +248 -233
- package/src/api/carts.ts +16 -1
- package/src/api/http.ts +17 -9
- package/src/api/index.ts +1 -0
- package/src/api/integrations.ts +118 -117
- package/src/api/store.ts +35 -0
- package/src/checkout/geocode.ts +1 -1
- package/src/lib/cookie-names.ts +45 -0
- package/src/lib/platform.ts +13 -0
- package/src/lib/visitor.ts +74 -0
- package/src/tracking/attribution.ts +153 -11
- package/src/tracking/consent.ts +14 -0
- package/src/tracking/events.ts +294 -0
- package/src/tracking/ga4.tsx +93 -49
- package/src/tracking/get-tracking-attribution.ts +46 -3
- package/src/tracking/google-ads.ts +84 -0
- package/src/tracking/gtag.ts +26 -13
- package/src/tracking/gtm.tsx +60 -0
- package/src/tracking/index.ts +187 -133
- package/src/tracking/inline-script.ts +49 -0
- package/src/tracking/live-heartbeat.tsx +63 -0
- package/src/tracking/page-views.tsx +96 -0
- package/src/tracking/storefront-tags.tsx +75 -0
- package/src/tracking/tiktok-pixel.tsx +83 -0
- package/src/tracking/track-init.tsx +56 -0
- package/src/tracking/track-order-purchase.tsx +122 -0
- package/src/tracking/ttq.ts +180 -0
- package/src/tracking/types.ts +23 -0
- package/src/tracking/use-tracking-config.ts +54 -0
|
@@ -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
|
+
}
|