@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
|
@@ -3,6 +3,15 @@ import { cookies, headers } from "next/headers"
|
|
|
3
3
|
import { getTrackingConfig } from "./get-tracking-config"
|
|
4
4
|
import type { StorefrontClient } from "../api/http"
|
|
5
5
|
import type { TrackingAttribution, TrackingClientHints } from "./types"
|
|
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"
|
|
6
15
|
|
|
7
16
|
/**
|
|
8
17
|
* Reads Meta + GA4 attribution signals from the current Next.js server
|
|
@@ -72,14 +81,19 @@ export async function getTrackingAttribution(
|
|
|
72
81
|
// attribution.ts on first tracking call. Surfaces server-side here
|
|
73
82
|
// so the order.placed CAPI Purchase can include it as external_id
|
|
74
83
|
// (alongside customer_id when both exist — Meta accepts an array).
|
|
75
|
-
anonId =
|
|
84
|
+
anonId =
|
|
85
|
+
cookieStore.get(VISITOR_COOKIE)?.value ?? cookieStore.get(LEGACY_VISITOR_COOKIE)?.value
|
|
76
86
|
gaCookieRaw = cookieStore.get("_ga")?.value
|
|
77
87
|
// _1c_utm_first / _1c_utm_last — JSON-encoded UTM tuples written
|
|
78
88
|
// by browser-side captureUtmsFromUrl(). First-touch records the
|
|
79
89
|
// acquisition campaign (365-day TTL); last-touch records the
|
|
80
90
|
// closer (90-day TTL, refreshed on each UTM-bearing visit).
|
|
81
|
-
utmFirstRaw =
|
|
82
|
-
|
|
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
|
|
83
97
|
|
|
84
98
|
// _ga_<MEASUREMENT_ID> uses the GA4 measurementId (e.g., G-ABCDEF1234)
|
|
85
99
|
// with the "G-" prefix stripped: cookie name = `_ga_ABCDEF1234`.
|
|
@@ -110,6 +124,35 @@ export async function getTrackingAttribution(
|
|
|
110
124
|
applyUtmCookie(utmFirstRaw, "utm_first", result)
|
|
111
125
|
applyUtmCookie(utmLastRaw, "utm_last", result)
|
|
112
126
|
|
|
127
|
+
// Ad-click identifiers — TikTok's ttclid / _ttp and Google's gclid /
|
|
128
|
+
// gbraid / wbraid, captured into cookies by captureClickIdsFromUrl().
|
|
129
|
+
// They travel the same road as the UTMs: cart.metadata at checkout,
|
|
130
|
+
// order.metadata at completion, and from there into the TikTok Events
|
|
131
|
+
// API `user` block. Without ttclid TikTok cannot tie a conversion back
|
|
132
|
+
// to the click that produced it, which is the number the advertiser
|
|
133
|
+
// looks at first.
|
|
134
|
+
try {
|
|
135
|
+
const cookieStore = await cookies()
|
|
136
|
+
// The key map is a plain tuple list (attribution.ts owns the cookie
|
|
137
|
+
// names), so the write goes through one narrow cast rather than a
|
|
138
|
+
// per-key branch that would have to be edited for every new platform.
|
|
139
|
+
const sink = result as Record<string, unknown>
|
|
140
|
+
for (const [cookieName, metadataKey] of CLICK_ID_METADATA_KEYS) {
|
|
141
|
+
const value = cookieStore.get(cookieName)?.value
|
|
142
|
+
if (value) {
|
|
143
|
+
// Written encoded by the capture; decoded once here so the wire
|
|
144
|
+
// carries the id the platform actually issued.
|
|
145
|
+
try {
|
|
146
|
+
sink[metadataKey] = decodeURIComponent(value)
|
|
147
|
+
} catch {
|
|
148
|
+
sink[metadataKey] = value
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
// best-effort — attribution never blocks a checkout
|
|
154
|
+
}
|
|
155
|
+
|
|
113
156
|
// _ga cookie format: "GA1.1.<client_id>.<timestamp>" — backend wants
|
|
114
157
|
// the full <client_id>.<timestamp> portion (the canonical GA client_id).
|
|
115
158
|
if (gaCookieRaw) {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { TrackingConfig } from "./types"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Google Ads conversion tracking — browser side.
|
|
7
|
+
*
|
|
8
|
+
* Rides the SAME gtag instance <GoogleTag> loads for GA4; there is
|
|
9
|
+
* deliberately no loader in this file (see the comment block in ./ga4.tsx
|
|
10
|
+
* and https://support.google.com/google-ads/answer/2476688).
|
|
11
|
+
*
|
|
12
|
+
* DEDUP, and it is the whole reason this file is careful. Google Ads
|
|
13
|
+
* collapses two conversions that share a conversion action AND a
|
|
14
|
+
* `transaction_id` (https://support.google.com/google-ads/answer/6386790).
|
|
15
|
+
* Every vendor snippet ships `'transaction_id': ''`, and an empty value
|
|
16
|
+
* dedupes nothing — so every re-open of the confirmation page counts
|
|
17
|
+
* another purchase. That is not a hypothetical: order emails link back to
|
|
18
|
+
* the confirmation page, so the page is re-opened days after the sale, by
|
|
19
|
+
* the same buyer, routinely. We always send `String(order.display_id)`,
|
|
20
|
+
* the same key GA4's Measurement Protocol already uses.
|
|
21
|
+
*
|
|
22
|
+
* No-ops when gtag is absent (Ads not configured, server render, blocked
|
|
23
|
+
* script), so callers need no guards.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
type GtagFn = (...args: unknown[]) => void
|
|
27
|
+
|
|
28
|
+
function safeGtag(): GtagFn | null {
|
|
29
|
+
if (typeof window === "undefined") return null
|
|
30
|
+
const fn = (window as unknown as { gtag?: GtagFn }).gtag
|
|
31
|
+
return typeof fn === "function" ? fn : null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build the `send_to` Google Ads expects for the purchase conversion:
|
|
36
|
+
* "AW-XXXXXXXXXX/Label".
|
|
37
|
+
*
|
|
38
|
+
* Returns null when either half is missing, so callers skip the
|
|
39
|
+
* conversion entirely instead of firing a malformed `send_to` that Google
|
|
40
|
+
* accepts and silently drops. The label is genuinely optional in the
|
|
41
|
+
* admin: a merchant can save the account id before creating the
|
|
42
|
+
* conversion action.
|
|
43
|
+
*/
|
|
44
|
+
export function googleAdsPurchaseSendTo(config: TrackingConfig): string | null {
|
|
45
|
+
const conversionId = config.googleAds?.conversionId?.trim()
|
|
46
|
+
const label = config.googleAds?.conversionLabel?.trim()
|
|
47
|
+
if (!conversionId || !label) return null
|
|
48
|
+
return `${conversionId}/${label}`
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type GoogleAdsPurchaseInput = {
|
|
52
|
+
/** Full send_to value from `googleAdsPurchaseSendTo`. */
|
|
53
|
+
sendTo: string
|
|
54
|
+
/** Order total actually collected, in `currency`. */
|
|
55
|
+
value: number
|
|
56
|
+
/** ISO 4217, e.g. "EUR". */
|
|
57
|
+
currency: string
|
|
58
|
+
/** THE dedup key. Always `String(order.display_id)`. */
|
|
59
|
+
transactionId: string
|
|
60
|
+
/**
|
|
61
|
+
* Whether this order is the buyer's first. Computed once server-side by
|
|
62
|
+
* the order.placed forwarder and persisted on the order, so the browser
|
|
63
|
+
* and the server cannot disagree. Omitted entirely when unknown: a
|
|
64
|
+
* wrong value is worse than an absent one for new-customer bidding.
|
|
65
|
+
*/
|
|
66
|
+
newCustomer?: boolean
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function trackGoogleAdsPurchase(input: GoogleAdsPurchaseInput): void {
|
|
70
|
+
const gtag = safeGtag()
|
|
71
|
+
if (!gtag || !input.sendTo) return
|
|
72
|
+
|
|
73
|
+
const payload: Record<string, unknown> = {
|
|
74
|
+
send_to: input.sendTo,
|
|
75
|
+
value: input.value,
|
|
76
|
+
currency: input.currency,
|
|
77
|
+
transaction_id: input.transactionId,
|
|
78
|
+
}
|
|
79
|
+
if (typeof input.newCustomer === "boolean") {
|
|
80
|
+
payload.new_customer = input.newCustomer
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
gtag("event", "conversion", payload)
|
|
84
|
+
}
|
package/src/tracking/gtag.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
sha256Hex,
|
|
5
|
+
normaliseEmailForHash,
|
|
6
|
+
normalisePhoneForGoogleHash,
|
|
7
|
+
} from "./attribution"
|
|
4
8
|
|
|
5
9
|
/**
|
|
6
10
|
* Typed wrappers around the global `gtag()` from GA4 (gtag.js).
|
|
@@ -114,16 +118,23 @@ export function trackGAPurchase(data: GA4PurchaseData): void {
|
|
|
114
118
|
* these signals to recover conversions that browser cookies miss
|
|
115
119
|
* (Safari ITP, iOS, ad-blockers).
|
|
116
120
|
*
|
|
117
|
-
* Hashing
|
|
118
|
-
* - email /
|
|
119
|
-
*
|
|
120
|
-
* -
|
|
121
|
-
*
|
|
122
|
-
*
|
|
121
|
+
* Hashing:
|
|
122
|
+
* - email / first_name / last_name: SHA-256 hex of the trimmed,
|
|
123
|
+
* lowercased value, which is what both vendors want
|
|
124
|
+
* - phone: SHA-256 hex of E.164 WITH the leading plus, which is what
|
|
125
|
+
* GOOGLE wants and is NOT what Meta wants
|
|
126
|
+
* - city / region / postal_code / country / street: plain text
|
|
127
|
+
* (Google documents sha256 variants only for the name fields inside
|
|
128
|
+
* the address block; geo fields are accepted as plain)
|
|
123
129
|
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
130
|
+
* The phone line is the one to read twice. This function used to reuse
|
|
131
|
+
* Meta's digits-only normaliser on the reasoning that both vendors should
|
|
132
|
+
* see identical digests for the same person. That was the bug: Google
|
|
133
|
+
* requires the plus, so the digest matched nobody, and nothing failed
|
|
134
|
+
* loudly enough to notice. Two vendors, two normalisers, on purpose.
|
|
135
|
+
*
|
|
136
|
+
* Storefront callers pass RAW values; this function normalises and hashes
|
|
137
|
+
* inline.
|
|
127
138
|
*
|
|
128
139
|
* Idempotent — safe to call repeatedly as new fields become known
|
|
129
140
|
* (gtag.set merges user_data per Google's spec).
|
|
@@ -160,9 +171,11 @@ export async function setEnhancedConversions(
|
|
|
160
171
|
)
|
|
161
172
|
}
|
|
162
173
|
if (input.phone) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
174
|
+
// Undefined for anything that could not be a real E.164 number: a
|
|
175
|
+
// hash of a malformed phone cannot match, and it costs the field
|
|
176
|
+
// Google would otherwise fall back from.
|
|
177
|
+
const e164 = normalisePhoneForGoogleHash(input.phone)
|
|
178
|
+
if (e164) userData.sha256_phone_number = await sha256Hex(e164)
|
|
166
179
|
}
|
|
167
180
|
|
|
168
181
|
const address: Record<string, unknown> = {}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import Script from "next/script"
|
|
4
|
+
|
|
5
|
+
import { jsStringLiteral, urlParam } from "./inline-script"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Gtm — Google Tag Manager container.
|
|
9
|
+
*
|
|
10
|
+
* The hub has served `tracking.gtm.containerId` to storefronts since the
|
|
11
|
+
* tracking card shipped, and until now nothing in this package mounted
|
|
12
|
+
* it, so a merchant who configured GTM got a container id in their page
|
|
13
|
+
* data and no container. Same shape of defect as Google Ads was.
|
|
14
|
+
*
|
|
15
|
+
* GTM is the merchant's own escape hatch: whatever tag we have not built
|
|
16
|
+
* a first-class integration for, they can deploy through their container
|
|
17
|
+
* without waiting for us. That makes it worth mounting properly rather
|
|
18
|
+
* than treating it as a lesser vendor.
|
|
19
|
+
*
|
|
20
|
+
* Consent: <ConsentInit> sets the Consent Mode v2 defaults synchronously
|
|
21
|
+
* before this loads, so tags inside the container inherit the gate, and
|
|
22
|
+
* `applyConsent()` pushes the visitor's decision to the same dataLayer.
|
|
23
|
+
* Nothing extra to wire here.
|
|
24
|
+
*
|
|
25
|
+
* The <noscript> iframe is part of Google's documented snippet and is
|
|
26
|
+
* what makes the container work for a scriptless visitor. Next renders
|
|
27
|
+
* it in <body>, which is where Google puts it.
|
|
28
|
+
*
|
|
29
|
+
* Renders nothing when `containerId` is falsy.
|
|
30
|
+
*/
|
|
31
|
+
export function Gtm({ containerId }: { containerId?: string }) {
|
|
32
|
+
if (!containerId) return null
|
|
33
|
+
|
|
34
|
+
const initSnippet = `
|
|
35
|
+
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
|
36
|
+
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
|
37
|
+
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
|
38
|
+
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
|
39
|
+
})(window,document,'script','dataLayer',${jsStringLiteral(containerId)});
|
|
40
|
+
`.trim()
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<>
|
|
44
|
+
<Script
|
|
45
|
+
id="gtm-init"
|
|
46
|
+
strategy="afterInteractive"
|
|
47
|
+
dangerouslySetInnerHTML={{ __html: initSnippet }}
|
|
48
|
+
/>
|
|
49
|
+
<noscript>
|
|
50
|
+
<iframe
|
|
51
|
+
src={`https://www.googletagmanager.com/ns.html?id=${urlParam(containerId)}`}
|
|
52
|
+
height="0"
|
|
53
|
+
width="0"
|
|
54
|
+
style={{ display: "none", visibility: "hidden" }}
|
|
55
|
+
title="Google Tag Manager"
|
|
56
|
+
/>
|
|
57
|
+
</noscript>
|
|
58
|
+
</>
|
|
59
|
+
)
|
|
60
|
+
}
|
package/src/tracking/index.ts
CHANGED
|
@@ -1,133 +1,187 @@
|
|
|
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
|
-
export {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
} from "./
|
|
73
|
-
export {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
type
|
|
87
|
-
type
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
+
* - <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"
|
|
25
|
+
* (they exist only there) and starts the engagement clock.
|
|
26
|
+
* - <MetaPixel pixelId> / <GoogleTag measurementId adsConversionId> /
|
|
27
|
+
* <TikTokPixel pixelId> / <Gtm containerId> / <Rybbit siteId> —
|
|
28
|
+
* individual layout tags (<GA4> is <GoogleTag> with only the
|
|
29
|
+
* analytics destination)
|
|
30
|
+
* - ONE call per commerce moment, every vendor at once (prefer these):
|
|
31
|
+
* trackProductView / trackCartAdd / trackCheckoutStart /
|
|
32
|
+
* trackOrderPurchase
|
|
33
|
+
* - Meta Pixel client helpers: trackViewContent / trackAddToCart /
|
|
34
|
+
* trackInitiateCheckout / trackPurchase / trackLead
|
|
35
|
+
* - GA4 client helpers: trackGAViewItem / trackGAAddToCart /
|
|
36
|
+
* trackGABeginCheckout / trackGAPurchase / setEnhancedConversions
|
|
37
|
+
* - Rybbit client helpers: trackRybbit* (queue-buffered)
|
|
38
|
+
* - getTrackingConfig(client) — the public tag config
|
|
39
|
+
* - getTrackingAttribution(clientHints?, opts?) — server-side signal
|
|
40
|
+
* gathering for the cart.metadata writeback (consent-gated)
|
|
41
|
+
* - getEngagementTimeMsec() / initEngagementTime() — time-on-session
|
|
42
|
+
*
|
|
43
|
+
* Server vs client split (per Next.js App Router rules):
|
|
44
|
+
* - get-tracking-attribution.ts imports `next/headers` (server-only by
|
|
45
|
+
* construction); get-tracking-config.ts is isomorphic
|
|
46
|
+
* - meta-pixel.tsx, ga4.tsx, rybbit.tsx, fbq.ts, gtag.ts,
|
|
47
|
+
* rybbit-events.ts, attribution.ts, use-engagement-time.ts,
|
|
48
|
+
* consent-banner.tsx are `"use client"`
|
|
49
|
+
* - types.ts, consent.ts, consent-init.tsx are universal
|
|
50
|
+
*
|
|
51
|
+
* Importers should usually pull from the subpath that matches their
|
|
52
|
+
* environment. The barrel re-exports everything, but tree-shaking and
|
|
53
|
+
* Next.js's RSC boundary detection both work better with subpaths.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
export { MetaPixel, updatePixelAdvancedMatching } from "./meta-pixel"
|
|
57
|
+
export { GA4, GoogleTag } from "./ga4"
|
|
58
|
+
export { Gtm } from "./gtm"
|
|
59
|
+
export { TikTokPixel } from "./tiktok-pixel"
|
|
60
|
+
export { StorefrontTags } from "./storefront-tags"
|
|
61
|
+
export { TrackInit } from "./track-init"
|
|
62
|
+
// The live-visitor heartbeat. Mounted by <StorefrontTags> so a storefront
|
|
63
|
+
// gets it without knowing it exists.
|
|
64
|
+
export { LiveHeartbeat } from "./live-heartbeat"
|
|
65
|
+
// Every navigation, automatically. Mounted by <StorefrontTags>; the page
|
|
66
|
+
// type and the record it was about are derived by the platform, never
|
|
67
|
+
// declared here.
|
|
68
|
+
export { PageViews } from "./page-views"
|
|
69
|
+
export { TrackOrderPurchase } from "./track-order-purchase"
|
|
70
|
+
export { Rybbit } from "./rybbit"
|
|
71
|
+
export { ConsentInit } from "./consent-init"
|
|
72
|
+
export { ConsentBanner, ConsentBannerCard, ConsentSettingsLink } from "./consent-banner"
|
|
73
|
+
export {
|
|
74
|
+
applyConsent,
|
|
75
|
+
setConsent,
|
|
76
|
+
openConsentSettings,
|
|
77
|
+
readConsentCookie,
|
|
78
|
+
writeConsentCookie,
|
|
79
|
+
shouldRenderBanner,
|
|
80
|
+
pickConsentCopy,
|
|
81
|
+
CONSENT_COOKIE,
|
|
82
|
+
CONSENT_MAX_AGE_SECONDS,
|
|
83
|
+
CONSENT_OPEN_EVENT,
|
|
84
|
+
CONSENT_INIT_SNIPPET,
|
|
85
|
+
type ConsentChoices,
|
|
86
|
+
type ConsentCopy,
|
|
87
|
+
type ConsentLayout,
|
|
88
|
+
type ConsentMode,
|
|
89
|
+
type ConsentSettings,
|
|
90
|
+
} from "./consent"
|
|
91
|
+
export {
|
|
92
|
+
trackViewContent,
|
|
93
|
+
trackAddToCart,
|
|
94
|
+
trackInitiateCheckout,
|
|
95
|
+
trackPurchase,
|
|
96
|
+
trackLead,
|
|
97
|
+
generateEventId,
|
|
98
|
+
type LeadContext,
|
|
99
|
+
} from "./fbq"
|
|
100
|
+
export {
|
|
101
|
+
setTrackingDefaults,
|
|
102
|
+
getOrCreateAnonId,
|
|
103
|
+
getOrCreateFbp,
|
|
104
|
+
getOrCreateFbc,
|
|
105
|
+
getKnownVisitor,
|
|
106
|
+
rememberKnownVisitor,
|
|
107
|
+
captureUtmsFromUrl,
|
|
108
|
+
captureClickIdsFromUrl,
|
|
109
|
+
getCapturedFirstTouchUtms,
|
|
110
|
+
getCapturedLastTouchUtms,
|
|
111
|
+
sha256Hex,
|
|
112
|
+
normaliseEmailForHash,
|
|
113
|
+
normalisePhoneForHash,
|
|
114
|
+
normalisePhoneForGoogleHash,
|
|
115
|
+
CLICK_ID_COOKIES,
|
|
116
|
+
CLICK_ID_METADATA_KEYS,
|
|
117
|
+
type KnownVisitor,
|
|
118
|
+
type CapturedUtms,
|
|
119
|
+
} from "./attribution"
|
|
120
|
+
export {
|
|
121
|
+
trackProductView,
|
|
122
|
+
trackCartAdd,
|
|
123
|
+
trackCheckoutStart,
|
|
124
|
+
trackOrderPurchase,
|
|
125
|
+
type TrackedLine,
|
|
126
|
+
type TrackedOrder,
|
|
127
|
+
} from "./events"
|
|
128
|
+
export {
|
|
129
|
+
trackTikTokViewContent,
|
|
130
|
+
trackTikTokAddToCart,
|
|
131
|
+
trackTikTokInitiateCheckout,
|
|
132
|
+
trackTikTokPurchase,
|
|
133
|
+
applyTikTokConsent,
|
|
134
|
+
tiktokPurchaseEventId,
|
|
135
|
+
type TikTokContentItem,
|
|
136
|
+
} from "./ttq"
|
|
137
|
+
export {
|
|
138
|
+
trackGoogleAdsPurchase,
|
|
139
|
+
googleAdsPurchaseSendTo,
|
|
140
|
+
type GoogleAdsPurchaseInput,
|
|
141
|
+
} from "./google-ads"
|
|
142
|
+
export {
|
|
143
|
+
trackGAViewItem,
|
|
144
|
+
trackGAAddToCart,
|
|
145
|
+
trackGABeginCheckout,
|
|
146
|
+
trackGAPurchase,
|
|
147
|
+
setEnhancedConversions,
|
|
148
|
+
type EnhancedConversionsInput,
|
|
149
|
+
} from "./gtag"
|
|
150
|
+
export {
|
|
151
|
+
trackRybbitViewItem,
|
|
152
|
+
trackRybbitAddToCart,
|
|
153
|
+
trackRybbitBeginCheckout,
|
|
154
|
+
trackRybbitPurchase,
|
|
155
|
+
type RybbitViewItemData,
|
|
156
|
+
type RybbitAddToCartData,
|
|
157
|
+
type RybbitBeginCheckoutData,
|
|
158
|
+
type RybbitPurchaseData,
|
|
159
|
+
} from "./rybbit-events"
|
|
160
|
+
export { getTrackingConfig } from "./get-tracking-config"
|
|
161
|
+
export { useTrackingConfig } from "./use-tracking-config"
|
|
162
|
+
export { getTrackingAttribution } from "./get-tracking-attribution"
|
|
163
|
+
export {
|
|
164
|
+
getEngagementTimeMsec,
|
|
165
|
+
initEngagementTime,
|
|
166
|
+
} from "./use-engagement-time"
|
|
167
|
+
|
|
168
|
+
export type {
|
|
169
|
+
TrackingConfig,
|
|
170
|
+
TrackingConfigResponse,
|
|
171
|
+
TrackingAttribution,
|
|
172
|
+
TrackingClientHints,
|
|
173
|
+
MetaContentItem,
|
|
174
|
+
ViewContentData,
|
|
175
|
+
AddToCartData,
|
|
176
|
+
InitiateCheckoutData,
|
|
177
|
+
PurchaseData,
|
|
178
|
+
LeadData,
|
|
179
|
+
} from "./types"
|
|
180
|
+
|
|
181
|
+
export type {
|
|
182
|
+
GA4Item,
|
|
183
|
+
GA4ViewItemData,
|
|
184
|
+
GA4AddToCartData,
|
|
185
|
+
GA4BeginCheckoutData,
|
|
186
|
+
GA4PurchaseData,
|
|
187
|
+
} 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
|
+
}
|