@cartbase/storefront 0.5.0 → 0.7.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 +75 -58
- package/package.json +11 -1
- package/src/api/integrations.ts +118 -117
- package/src/api/products.ts +7 -0
- package/src/products/product-actions.tsx +15 -9
- package/src/tracking/attribution.ts +126 -0
- 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 +30 -0
- 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 +176 -133
- package/src/tracking/inline-script.ts +49 -0
- package/src/tracking/storefront-tags.tsx +67 -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
package/src/tracking/index.ts
CHANGED
|
@@ -1,133 +1,176 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @cartbase/storefront/tracking — Meta Pixel + GA4 + Rybbit + Consent Mode v2.
|
|
3
|
-
*
|
|
4
|
-
* Ported from `@1click/ui/src/tracking` (v2.3.1) with the Cartbase seams:
|
|
5
|
-
* - config comes from `GET /api/store/integrations` → `tracking` block
|
|
6
|
-
* via `getTrackingConfig(client)` (never env vars)
|
|
7
|
-
* - server-side CAPI / GA4-MP sending is Cartbase-backend-owned (the
|
|
8
|
-
* `order.placed` forwarder) — this package fires CLIENT events only
|
|
9
|
-
* and writes attribution for the server to inherit
|
|
10
|
-
* - Purchase dedupe: Pixel `eventID = "purchase_" + order.display_id`
|
|
11
|
-
* (trackPurchase builds it); GA4 `transaction_id =
|
|
12
|
-
* String(order.display_id)`
|
|
13
|
-
*
|
|
14
|
-
* Public surface:
|
|
15
|
-
* - <ConsentInit> / <ConsentBanner> / <ConsentSettingsLink> — Consent
|
|
16
|
-
* Mode v2 (config from GET /api/store/consent; `_1c_consent` seam)
|
|
17
|
-
* - <
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* -
|
|
21
|
-
*
|
|
22
|
-
* -
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* -
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* -
|
|
30
|
-
*
|
|
31
|
-
* -
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* -
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
type
|
|
88
|
-
} from "./
|
|
89
|
-
export {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
export {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
} from "./
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @cartbase/storefront/tracking — Meta Pixel + GA4 + Rybbit + Consent Mode v2.
|
|
3
|
+
*
|
|
4
|
+
* Ported from `@1click/ui/src/tracking` (v2.3.1) with the Cartbase seams:
|
|
5
|
+
* - config comes from `GET /api/store/integrations` → `tracking` block
|
|
6
|
+
* via `getTrackingConfig(client)` (never env vars)
|
|
7
|
+
* - server-side CAPI / GA4-MP sending is Cartbase-backend-owned (the
|
|
8
|
+
* `order.placed` forwarder) — this package fires CLIENT events only
|
|
9
|
+
* and writes attribution for the server to inherit
|
|
10
|
+
* - Purchase dedupe: Pixel `eventID = "purchase_" + order.display_id`
|
|
11
|
+
* (trackPurchase builds it); GA4 `transaction_id =
|
|
12
|
+
* String(order.display_id)`
|
|
13
|
+
*
|
|
14
|
+
* Public surface:
|
|
15
|
+
* - <ConsentInit> / <ConsentBanner> / <ConsentSettingsLink> — Consent
|
|
16
|
+
* Mode v2 (config from GET /api/store/consent; `_1c_consent` seam)
|
|
17
|
+
* - <StorefrontTags client> — EVERY tag the store configured, mounted
|
|
18
|
+
* from its own config. This is the one a storefront should mount;
|
|
19
|
+
* the individual tags below are for a layout that needs control.
|
|
20
|
+
* - <TrackInit /> — captures UTMs and ad-click ids on the landing page
|
|
21
|
+
* (they exist only there) and starts the engagement clock.
|
|
22
|
+
* - <MetaPixel pixelId> / <GoogleTag measurementId adsConversionId> /
|
|
23
|
+
* <TikTokPixel pixelId> / <Gtm containerId> / <Rybbit siteId> —
|
|
24
|
+
* individual layout tags (<GA4> is <GoogleTag> with only the
|
|
25
|
+
* analytics destination)
|
|
26
|
+
* - ONE call per commerce moment, every vendor at once (prefer these):
|
|
27
|
+
* trackProductView / trackCartAdd / trackCheckoutStart /
|
|
28
|
+
* trackOrderPurchase
|
|
29
|
+
* - Meta Pixel client helpers: trackViewContent / trackAddToCart /
|
|
30
|
+
* trackInitiateCheckout / trackPurchase / trackLead
|
|
31
|
+
* - GA4 client helpers: trackGAViewItem / trackGAAddToCart /
|
|
32
|
+
* trackGABeginCheckout / trackGAPurchase / setEnhancedConversions
|
|
33
|
+
* - Rybbit client helpers: trackRybbit* (queue-buffered)
|
|
34
|
+
* - getTrackingConfig(client) — the public tag config
|
|
35
|
+
* - getTrackingAttribution(clientHints?, opts?) — server-side signal
|
|
36
|
+
* gathering for the cart.metadata writeback (consent-gated)
|
|
37
|
+
* - getEngagementTimeMsec() / initEngagementTime() — time-on-session
|
|
38
|
+
*
|
|
39
|
+
* Server vs client split (per Next.js App Router rules):
|
|
40
|
+
* - get-tracking-attribution.ts imports `next/headers` (server-only by
|
|
41
|
+
* construction); get-tracking-config.ts is isomorphic
|
|
42
|
+
* - meta-pixel.tsx, ga4.tsx, rybbit.tsx, fbq.ts, gtag.ts,
|
|
43
|
+
* rybbit-events.ts, attribution.ts, use-engagement-time.ts,
|
|
44
|
+
* consent-banner.tsx are `"use client"`
|
|
45
|
+
* - types.ts, consent.ts, consent-init.tsx are universal
|
|
46
|
+
*
|
|
47
|
+
* Importers should usually pull from the subpath that matches their
|
|
48
|
+
* environment. The barrel re-exports everything, but tree-shaking and
|
|
49
|
+
* Next.js's RSC boundary detection both work better with subpaths.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
export { MetaPixel, updatePixelAdvancedMatching } from "./meta-pixel"
|
|
53
|
+
export { GA4, GoogleTag } from "./ga4"
|
|
54
|
+
export { Gtm } from "./gtm"
|
|
55
|
+
export { TikTokPixel } from "./tiktok-pixel"
|
|
56
|
+
export { StorefrontTags } from "./storefront-tags"
|
|
57
|
+
export { TrackInit } from "./track-init"
|
|
58
|
+
export { TrackOrderPurchase } from "./track-order-purchase"
|
|
59
|
+
export { Rybbit } from "./rybbit"
|
|
60
|
+
export { ConsentInit } from "./consent-init"
|
|
61
|
+
export { ConsentBanner, ConsentBannerCard, ConsentSettingsLink } from "./consent-banner"
|
|
62
|
+
export {
|
|
63
|
+
applyConsent,
|
|
64
|
+
setConsent,
|
|
65
|
+
openConsentSettings,
|
|
66
|
+
readConsentCookie,
|
|
67
|
+
writeConsentCookie,
|
|
68
|
+
shouldRenderBanner,
|
|
69
|
+
pickConsentCopy,
|
|
70
|
+
CONSENT_COOKIE,
|
|
71
|
+
CONSENT_MAX_AGE_SECONDS,
|
|
72
|
+
CONSENT_OPEN_EVENT,
|
|
73
|
+
CONSENT_INIT_SNIPPET,
|
|
74
|
+
type ConsentChoices,
|
|
75
|
+
type ConsentCopy,
|
|
76
|
+
type ConsentLayout,
|
|
77
|
+
type ConsentMode,
|
|
78
|
+
type ConsentSettings,
|
|
79
|
+
} from "./consent"
|
|
80
|
+
export {
|
|
81
|
+
trackViewContent,
|
|
82
|
+
trackAddToCart,
|
|
83
|
+
trackInitiateCheckout,
|
|
84
|
+
trackPurchase,
|
|
85
|
+
trackLead,
|
|
86
|
+
generateEventId,
|
|
87
|
+
type LeadContext,
|
|
88
|
+
} from "./fbq"
|
|
89
|
+
export {
|
|
90
|
+
setTrackingDefaults,
|
|
91
|
+
getOrCreateAnonId,
|
|
92
|
+
getOrCreateFbp,
|
|
93
|
+
getOrCreateFbc,
|
|
94
|
+
getKnownVisitor,
|
|
95
|
+
rememberKnownVisitor,
|
|
96
|
+
captureUtmsFromUrl,
|
|
97
|
+
captureClickIdsFromUrl,
|
|
98
|
+
getCapturedFirstTouchUtms,
|
|
99
|
+
getCapturedLastTouchUtms,
|
|
100
|
+
sha256Hex,
|
|
101
|
+
normaliseEmailForHash,
|
|
102
|
+
normalisePhoneForHash,
|
|
103
|
+
normalisePhoneForGoogleHash,
|
|
104
|
+
CLICK_ID_COOKIES,
|
|
105
|
+
CLICK_ID_METADATA_KEYS,
|
|
106
|
+
type KnownVisitor,
|
|
107
|
+
type CapturedUtms,
|
|
108
|
+
} from "./attribution"
|
|
109
|
+
export {
|
|
110
|
+
trackProductView,
|
|
111
|
+
trackCartAdd,
|
|
112
|
+
trackCheckoutStart,
|
|
113
|
+
trackOrderPurchase,
|
|
114
|
+
type TrackedLine,
|
|
115
|
+
type TrackedOrder,
|
|
116
|
+
} from "./events"
|
|
117
|
+
export {
|
|
118
|
+
trackTikTokViewContent,
|
|
119
|
+
trackTikTokAddToCart,
|
|
120
|
+
trackTikTokInitiateCheckout,
|
|
121
|
+
trackTikTokPurchase,
|
|
122
|
+
applyTikTokConsent,
|
|
123
|
+
tiktokPurchaseEventId,
|
|
124
|
+
type TikTokContentItem,
|
|
125
|
+
} from "./ttq"
|
|
126
|
+
export {
|
|
127
|
+
trackGoogleAdsPurchase,
|
|
128
|
+
googleAdsPurchaseSendTo,
|
|
129
|
+
type GoogleAdsPurchaseInput,
|
|
130
|
+
} from "./google-ads"
|
|
131
|
+
export {
|
|
132
|
+
trackGAViewItem,
|
|
133
|
+
trackGAAddToCart,
|
|
134
|
+
trackGABeginCheckout,
|
|
135
|
+
trackGAPurchase,
|
|
136
|
+
setEnhancedConversions,
|
|
137
|
+
type EnhancedConversionsInput,
|
|
138
|
+
} from "./gtag"
|
|
139
|
+
export {
|
|
140
|
+
trackRybbitViewItem,
|
|
141
|
+
trackRybbitAddToCart,
|
|
142
|
+
trackRybbitBeginCheckout,
|
|
143
|
+
trackRybbitPurchase,
|
|
144
|
+
type RybbitViewItemData,
|
|
145
|
+
type RybbitAddToCartData,
|
|
146
|
+
type RybbitBeginCheckoutData,
|
|
147
|
+
type RybbitPurchaseData,
|
|
148
|
+
} from "./rybbit-events"
|
|
149
|
+
export { getTrackingConfig } from "./get-tracking-config"
|
|
150
|
+
export { useTrackingConfig } from "./use-tracking-config"
|
|
151
|
+
export { getTrackingAttribution } from "./get-tracking-attribution"
|
|
152
|
+
export {
|
|
153
|
+
getEngagementTimeMsec,
|
|
154
|
+
initEngagementTime,
|
|
155
|
+
} from "./use-engagement-time"
|
|
156
|
+
|
|
157
|
+
export type {
|
|
158
|
+
TrackingConfig,
|
|
159
|
+
TrackingConfigResponse,
|
|
160
|
+
TrackingAttribution,
|
|
161
|
+
TrackingClientHints,
|
|
162
|
+
MetaContentItem,
|
|
163
|
+
ViewContentData,
|
|
164
|
+
AddToCartData,
|
|
165
|
+
InitiateCheckoutData,
|
|
166
|
+
PurchaseData,
|
|
167
|
+
LeadData,
|
|
168
|
+
} from "./types"
|
|
169
|
+
|
|
170
|
+
export type {
|
|
171
|
+
GA4Item,
|
|
172
|
+
GA4ViewItemData,
|
|
173
|
+
GA4AddToCartData,
|
|
174
|
+
GA4BeginCheckoutData,
|
|
175
|
+
GA4PurchaseData,
|
|
176
|
+
} from "./gtag"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe interpolation of a configured id into a vendor's inline snippet.
|
|
3
|
+
*
|
|
4
|
+
* Every tag component here injects the vendor's own bootstrap through
|
|
5
|
+
* `dangerouslySetInnerHTML`, with the merchant's pixel / measurement /
|
|
6
|
+
* conversion id written into it. Those ids arrive from the store's
|
|
7
|
+
* integration settings, which means they are typed by a person in the
|
|
8
|
+
* admin, and a value carrying a quote would close the string literal and
|
|
9
|
+
* run whatever follows it on every page of that storefront.
|
|
10
|
+
*
|
|
11
|
+
* On a single-merchant site that is only self-inflicted. On a platform it
|
|
12
|
+
* is one tenant's admin user writing script into that tenant's public
|
|
13
|
+
* storefront, so the escape is not optional.
|
|
14
|
+
*
|
|
15
|
+
* Returns the value as a COMPLETE single-quoted JavaScript literal,
|
|
16
|
+
* quotes included, so callers cannot forget to quote it themselves:
|
|
17
|
+
*
|
|
18
|
+
* `fbq('init', ${jsStringLiteral(pixelId)});`
|
|
19
|
+
*/
|
|
20
|
+
const LINE_SEPARATORS = new RegExp("[\u2028\u2029]", "g")
|
|
21
|
+
|
|
22
|
+
export function jsStringLiteral(value: string): string {
|
|
23
|
+
const escaped = String(value)
|
|
24
|
+
.replace(/\\/g, "\\\\")
|
|
25
|
+
.replace(/'/g, "\\'")
|
|
26
|
+
.replace(/\r/g, "\\r")
|
|
27
|
+
.replace(/\n/g, "\\n")
|
|
28
|
+
// `</script` inside an inline script closes the element wherever it
|
|
29
|
+
// appears, quoted or not.
|
|
30
|
+
.replace(/<\//g, "<\\/")
|
|
31
|
+
// U+2028 and U+2029 are line terminators to a JavaScript parser, so
|
|
32
|
+
// they break a string literal exactly like a newline does. The class
|
|
33
|
+
// is built from escapes rather than written literally, because a
|
|
34
|
+
// source file carrying those characters raw is its own version of
|
|
35
|
+
// this problem (it breaks the file that fixes it).
|
|
36
|
+
.replace(LINE_SEPARATORS, (ch) =>
|
|
37
|
+
ch.charCodeAt(0) === 0x2028 ? "\\u2028" : "\\u2029"
|
|
38
|
+
)
|
|
39
|
+
return `'${escaped}'`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The same value for a URL query parameter (the loader `src`), where the
|
|
44
|
+
* hazard is a crafted id breaking out of the parameter rather than out of
|
|
45
|
+
* a string literal.
|
|
46
|
+
*/
|
|
47
|
+
export function urlParam(value: string): string {
|
|
48
|
+
return encodeURIComponent(String(value))
|
|
49
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { GoogleTag } from "./ga4"
|
|
2
|
+
import { Gtm } from "./gtm"
|
|
3
|
+
import { MetaPixel } from "./meta-pixel"
|
|
4
|
+
import { TikTokPixel } from "./tiktok-pixel"
|
|
5
|
+
import { getTrackingConfig } from "./get-tracking-config"
|
|
6
|
+
import type { StorefrontClient } from "../api/http"
|
|
7
|
+
import type { TrackingConfig } from "./types"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* StorefrontTags — every marketing tag the store has configured, mounted
|
|
11
|
+
* from the store's own config.
|
|
12
|
+
*
|
|
13
|
+
* THE POINT: a merchant who saves their pixel ids in the admin gets those
|
|
14
|
+
* tags on their storefront, with nothing else to do and no code to write.
|
|
15
|
+
* Before this existed the package EXPORTED tags and no storefront mounted
|
|
16
|
+
* them, so the admin could serve a Meta pixel id, a GA4 id and a Google
|
|
17
|
+
* Ads conversion id that reached the browser and did nothing. Adding a
|
|
18
|
+
* vendor is one line HERE now, and every store built on the package gains
|
|
19
|
+
* it at the next deploy.
|
|
20
|
+
*
|
|
21
|
+
* Consent: nothing extra to wire. <ConsentInit> sets the Consent Mode v2
|
|
22
|
+
* defaults synchronously ahead of these, the Meta and TikTok snippets read
|
|
23
|
+
* the shared consent cookie before they are allowed to write cookies, and
|
|
24
|
+
* `applyConsent()` relays a live decision to all three vendors. Mount this
|
|
25
|
+
* AFTER <ConsentInit> and the gate holds.
|
|
26
|
+
*
|
|
27
|
+
* Google is deliberately ONE tag with two destinations rather than two
|
|
28
|
+
* loaders — see the comment block in ./ga4.tsx. It is also why the Ads
|
|
29
|
+
* conversion inherits the consent gate for free.
|
|
30
|
+
*
|
|
31
|
+
* Server component: pass a `client` and it fetches the config (cached),
|
|
32
|
+
* or pass an already-fetched `config` when the layout has one in hand.
|
|
33
|
+
* Renders nothing for a vendor the store has not configured, so a store
|
|
34
|
+
* running only GA4 ships exactly one tag.
|
|
35
|
+
*/
|
|
36
|
+
export async function StorefrontTags({
|
|
37
|
+
client,
|
|
38
|
+
config,
|
|
39
|
+
}: {
|
|
40
|
+
/** Used to fetch the tracking config when `config` is not supplied. */
|
|
41
|
+
client?: StorefrontClient
|
|
42
|
+
/** Pre-fetched tracking block, e.g. from a layout that already has it. */
|
|
43
|
+
config?: TrackingConfig
|
|
44
|
+
}) {
|
|
45
|
+
let tracking: TrackingConfig | undefined = config
|
|
46
|
+
if (!tracking) {
|
|
47
|
+
if (!client) return null
|
|
48
|
+
try {
|
|
49
|
+
tracking = await getTrackingConfig(client)
|
|
50
|
+
} catch {
|
|
51
|
+
// A tracking-config failure must never take a storefront down.
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<>
|
|
58
|
+
<MetaPixel pixelId={tracking.facebookPixel?.pixelId} />
|
|
59
|
+
<TikTokPixel pixelId={tracking.tiktok?.pixelId} />
|
|
60
|
+
<GoogleTag
|
|
61
|
+
measurementId={tracking.ga4?.measurementId}
|
|
62
|
+
adsConversionId={tracking.googleAds?.conversionId}
|
|
63
|
+
/>
|
|
64
|
+
<Gtm containerId={tracking.gtm?.containerId} />
|
|
65
|
+
</>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
@@ -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
|
+
}
|