@cartbase/storefront 0.6.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/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
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
trackAddToCart,
|
|
5
|
+
trackInitiateCheckout,
|
|
6
|
+
trackPurchase,
|
|
7
|
+
trackViewContent,
|
|
8
|
+
} from "./fbq"
|
|
9
|
+
import {
|
|
10
|
+
trackGAAddToCart,
|
|
11
|
+
trackGABeginCheckout,
|
|
12
|
+
trackGAPurchase,
|
|
13
|
+
trackGAViewItem,
|
|
14
|
+
} from "./gtag"
|
|
15
|
+
import {
|
|
16
|
+
trackRybbitAddToCart,
|
|
17
|
+
trackRybbitBeginCheckout,
|
|
18
|
+
trackRybbitPurchase,
|
|
19
|
+
trackRybbitViewItem,
|
|
20
|
+
} from "./rybbit-events"
|
|
21
|
+
import {
|
|
22
|
+
trackTikTokAddToCart,
|
|
23
|
+
trackTikTokInitiateCheckout,
|
|
24
|
+
trackTikTokPurchase,
|
|
25
|
+
trackTikTokViewContent,
|
|
26
|
+
} from "./ttq"
|
|
27
|
+
import { googleAdsPurchaseSendTo, trackGoogleAdsPurchase } from "./google-ads"
|
|
28
|
+
import type { TrackingConfig } from "./types"
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* ONE call per commerce moment, every vendor at once.
|
|
32
|
+
*
|
|
33
|
+
* The per-vendor helpers beside this file stay exported and stay usable.
|
|
34
|
+
* This is the door a storefront should reach for anyway, because the
|
|
35
|
+
* failure these functions prevent is the most common one in tracking and
|
|
36
|
+
* it is invisible: a store adds a fifth vendor, updates four call sites,
|
|
37
|
+
* misses the fifth, and nobody notices until an ad account has been
|
|
38
|
+
* optimising on partial data for a month. We have watched it happen to a
|
|
39
|
+
* product page whose buy box predated a shared helper: ViewContent and
|
|
40
|
+
* InitiateCheckout were present, AddToCart was missing from the single
|
|
41
|
+
* most-used add path in the store.
|
|
42
|
+
*
|
|
43
|
+
* With one function per moment, a vendor is added HERE, once, and every
|
|
44
|
+
* surface that already calls it gains the vendor for free.
|
|
45
|
+
*
|
|
46
|
+
* Every helper underneath no-ops when its tag is absent, so passing a
|
|
47
|
+
* config with only two vendors configured fires exactly those two.
|
|
48
|
+
*
|
|
49
|
+
* Identifier discipline, and it is not cosmetic:
|
|
50
|
+
* - `productId` is the catalogue key. Meta's content_ids, TikTok's
|
|
51
|
+
* content_id and the feed's <g:id> must be the SAME value or the
|
|
52
|
+
* event matches no catalogue entry, which costs dynamic ads and
|
|
53
|
+
* product-level reporting on both platforms.
|
|
54
|
+
* - GA4 is the exception on purpose: its items are keyed by VARIANT,
|
|
55
|
+
* because GA4 reports on what was actually bought rather than
|
|
56
|
+
* matching an ads catalogue.
|
|
57
|
+
* - Purchase dedup keys are derived from `displayId` inside the
|
|
58
|
+
* helpers, never passed in, so the browser and the server cannot
|
|
59
|
+
* drift apart.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/** One line, in the shape every vendor is derived from. */
|
|
63
|
+
export type TrackedLine = {
|
|
64
|
+
/** Catalogue key. Meta + TikTok content ids, and the feed's <g:id>. */
|
|
65
|
+
productId: string
|
|
66
|
+
/** What was actually bought. GA4 item_id. */
|
|
67
|
+
variantId?: string
|
|
68
|
+
title: string
|
|
69
|
+
quantity: number
|
|
70
|
+
/** Unit price, in `currency`. */
|
|
71
|
+
price: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type TrackedOrder = {
|
|
75
|
+
/** THE dedup key across every vendor and both sides of the wire. */
|
|
76
|
+
displayId: string | number
|
|
77
|
+
currency: string
|
|
78
|
+
/** Order total actually collected. */
|
|
79
|
+
value: number
|
|
80
|
+
tax?: number
|
|
81
|
+
shipping?: number
|
|
82
|
+
coupon?: string
|
|
83
|
+
lines: TrackedLine[]
|
|
84
|
+
/**
|
|
85
|
+
* First order for this buyer. Computed server-side by the order.placed
|
|
86
|
+
* forwarder and persisted on the order as `metadata.customer_type`;
|
|
87
|
+
* read it from there rather than deriving it in the browser, so the two
|
|
88
|
+
* sides tell the ad platforms the same thing. Omit when unknown.
|
|
89
|
+
*/
|
|
90
|
+
customerType?: "new" | "returning"
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const gaItems = (lines: TrackedLine[], currency: string) =>
|
|
94
|
+
lines.map((line, index) => ({
|
|
95
|
+
item_id: line.variantId || line.productId,
|
|
96
|
+
item_name: line.title,
|
|
97
|
+
quantity: line.quantity,
|
|
98
|
+
price: line.price,
|
|
99
|
+
currency,
|
|
100
|
+
index,
|
|
101
|
+
}))
|
|
102
|
+
|
|
103
|
+
const metaContents = (lines: TrackedLine[]) =>
|
|
104
|
+
lines.map((line) => ({
|
|
105
|
+
id: line.productId,
|
|
106
|
+
quantity: line.quantity,
|
|
107
|
+
item_price: line.price,
|
|
108
|
+
}))
|
|
109
|
+
|
|
110
|
+
const tiktokContents = (lines: TrackedLine[]) =>
|
|
111
|
+
lines.map((line) => ({
|
|
112
|
+
content_id: line.productId,
|
|
113
|
+
content_name: line.title,
|
|
114
|
+
quantity: line.quantity,
|
|
115
|
+
price: line.price,
|
|
116
|
+
}))
|
|
117
|
+
|
|
118
|
+
const unitCount = (lines: TrackedLine[]) =>
|
|
119
|
+
lines.reduce((sum, line) => sum + (Number(line.quantity) || 0), 0)
|
|
120
|
+
|
|
121
|
+
/** Product page view. */
|
|
122
|
+
export function trackProductView(input: {
|
|
123
|
+
line: TrackedLine
|
|
124
|
+
currency: string
|
|
125
|
+
value: number
|
|
126
|
+
}): void {
|
|
127
|
+
const { line, currency, value } = input
|
|
128
|
+
trackViewContent({
|
|
129
|
+
content_ids: [line.productId],
|
|
130
|
+
content_type: "product",
|
|
131
|
+
currency,
|
|
132
|
+
value,
|
|
133
|
+
})
|
|
134
|
+
trackTikTokViewContent({
|
|
135
|
+
contentId: line.productId,
|
|
136
|
+
contentName: line.title,
|
|
137
|
+
currency,
|
|
138
|
+
value,
|
|
139
|
+
})
|
|
140
|
+
trackGAViewItem({ currency, value, items: gaItems([line], currency) })
|
|
141
|
+
trackRybbitViewItem({
|
|
142
|
+
item_id: line.variantId || line.productId,
|
|
143
|
+
item_name: line.title,
|
|
144
|
+
currency,
|
|
145
|
+
value,
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Add to cart, from ANY surface — listing card, buy box, upsell rail. */
|
|
150
|
+
export function trackCartAdd(input: {
|
|
151
|
+
line: TrackedLine
|
|
152
|
+
currency: string
|
|
153
|
+
value: number
|
|
154
|
+
}): void {
|
|
155
|
+
const { line, currency, value } = input
|
|
156
|
+
trackAddToCart({
|
|
157
|
+
content_ids: [line.productId],
|
|
158
|
+
content_type: "product",
|
|
159
|
+
currency,
|
|
160
|
+
value,
|
|
161
|
+
contents: metaContents([line]),
|
|
162
|
+
})
|
|
163
|
+
trackTikTokAddToCart({
|
|
164
|
+
contentId: line.productId,
|
|
165
|
+
contentName: line.title,
|
|
166
|
+
quantity: line.quantity,
|
|
167
|
+
price: line.price,
|
|
168
|
+
currency,
|
|
169
|
+
value,
|
|
170
|
+
})
|
|
171
|
+
trackGAAddToCart({ currency, value, items: gaItems([line], currency) })
|
|
172
|
+
trackRybbitAddToCart({
|
|
173
|
+
item_id: line.variantId || line.productId,
|
|
174
|
+
item_name: line.title,
|
|
175
|
+
quantity: line.quantity,
|
|
176
|
+
currency,
|
|
177
|
+
value,
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Checkout started. */
|
|
182
|
+
export function trackCheckoutStart(input: {
|
|
183
|
+
lines: TrackedLine[]
|
|
184
|
+
currency: string
|
|
185
|
+
value: number
|
|
186
|
+
coupon?: string
|
|
187
|
+
}): void {
|
|
188
|
+
const { lines, currency, value, coupon } = input
|
|
189
|
+
trackInitiateCheckout({
|
|
190
|
+
content_ids: lines.map((line) => line.productId),
|
|
191
|
+
content_type: "product",
|
|
192
|
+
currency,
|
|
193
|
+
value,
|
|
194
|
+
num_items: unitCount(lines),
|
|
195
|
+
contents: metaContents(lines),
|
|
196
|
+
})
|
|
197
|
+
trackTikTokInitiateCheckout({
|
|
198
|
+
contents: tiktokContents(lines),
|
|
199
|
+
currency,
|
|
200
|
+
value,
|
|
201
|
+
})
|
|
202
|
+
trackGABeginCheckout({
|
|
203
|
+
currency,
|
|
204
|
+
value,
|
|
205
|
+
items: gaItems(lines, currency),
|
|
206
|
+
...(coupon ? { coupon } : {}),
|
|
207
|
+
})
|
|
208
|
+
trackRybbitBeginCheckout({
|
|
209
|
+
item_ids: lines.map((line) => line.variantId || line.productId),
|
|
210
|
+
num_items: unitCount(lines),
|
|
211
|
+
currency,
|
|
212
|
+
value,
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Order confirmed — the money event.
|
|
218
|
+
*
|
|
219
|
+
* Google Ads only fires when the store configured BOTH the account id
|
|
220
|
+
* and the purchase conversion label, which is why the config is a
|
|
221
|
+
* parameter here: `googleAdsPurchaseSendTo` returns null otherwise, and a
|
|
222
|
+
* malformed `send_to` is accepted by Google and silently dropped.
|
|
223
|
+
*
|
|
224
|
+
* Everything here is deduped against the server: Meta on
|
|
225
|
+
* `purchase_<displayId>`, TikTok on `tt_purchase_<displayId>`, GA4 and
|
|
226
|
+
* Google Ads on the transaction id, which is `displayId` itself.
|
|
227
|
+
*/
|
|
228
|
+
export function trackOrderPurchase(
|
|
229
|
+
order: TrackedOrder,
|
|
230
|
+
config?: TrackingConfig
|
|
231
|
+
): void {
|
|
232
|
+
const {
|
|
233
|
+
displayId,
|
|
234
|
+
currency,
|
|
235
|
+
value,
|
|
236
|
+
lines,
|
|
237
|
+
tax,
|
|
238
|
+
shipping,
|
|
239
|
+
coupon,
|
|
240
|
+
customerType,
|
|
241
|
+
} = order
|
|
242
|
+
const transactionId = String(displayId)
|
|
243
|
+
|
|
244
|
+
trackPurchase(
|
|
245
|
+
{
|
|
246
|
+
content_ids: lines.map((line) => line.productId),
|
|
247
|
+
content_type: "product",
|
|
248
|
+
currency,
|
|
249
|
+
value,
|
|
250
|
+
num_items: unitCount(lines),
|
|
251
|
+
contents: metaContents(lines),
|
|
252
|
+
},
|
|
253
|
+
displayId
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
trackTikTokPurchase({
|
|
257
|
+
contents: tiktokContents(lines),
|
|
258
|
+
currency,
|
|
259
|
+
value,
|
|
260
|
+
displayId,
|
|
261
|
+
customerType,
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
trackGAPurchase({
|
|
265
|
+
transaction_id: transactionId,
|
|
266
|
+
currency,
|
|
267
|
+
value,
|
|
268
|
+
items: gaItems(lines, currency),
|
|
269
|
+
...(typeof tax === "number" ? { tax } : {}),
|
|
270
|
+
...(typeof shipping === "number" ? { shipping } : {}),
|
|
271
|
+
...(coupon ? { coupon } : {}),
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
trackRybbitPurchase({
|
|
275
|
+
transaction_id: transactionId,
|
|
276
|
+
item_ids: lines.map((line) => line.variantId || line.productId),
|
|
277
|
+
num_items: unitCount(lines),
|
|
278
|
+
currency,
|
|
279
|
+
value,
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
const sendTo = config ? googleAdsPurchaseSendTo(config) : null
|
|
283
|
+
if (sendTo) {
|
|
284
|
+
trackGoogleAdsPurchase({
|
|
285
|
+
sendTo,
|
|
286
|
+
value,
|
|
287
|
+
currency,
|
|
288
|
+
transactionId,
|
|
289
|
+
...(customerType
|
|
290
|
+
? { newCustomer: customerType === "new" }
|
|
291
|
+
: {}),
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
}
|
package/src/tracking/ga4.tsx
CHANGED
|
@@ -1,49 +1,93 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import Script from "next/script"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import Script from "next/script"
|
|
4
|
+
|
|
5
|
+
import { jsStringLiteral, urlParam } from "./inline-script"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* GoogleTag — THE single Google tag for a storefront.
|
|
9
|
+
*
|
|
10
|
+
* Loads `https://www.googletagmanager.com/gtag/js` once and issues one
|
|
11
|
+
* `config` per destination: GA4 for analytics, Google Ads for conversions
|
|
12
|
+
* and remarketing audiences.
|
|
13
|
+
*
|
|
14
|
+
* ONE loader, TWO configs, and that is Google's own instruction: when a
|
|
15
|
+
* page already carries a Google tag you add the Ads conversion ID with an
|
|
16
|
+
* additional `config` command, never a second `<script src>`
|
|
17
|
+
* (https://support.google.com/google-ads/answer/2476688). Every agency
|
|
18
|
+
* onboarding email pastes a second loader anyway, contradicting the line
|
|
19
|
+
* printed above it in the same email. Take the instruction, not the
|
|
20
|
+
* snippet.
|
|
21
|
+
*
|
|
22
|
+
* The Ads `config` is also what powers Google Ads remarketing audiences.
|
|
23
|
+
* It is not optional for an account that intends to retarget, and linking
|
|
24
|
+
* GA4 to Ads does not substitute for it.
|
|
25
|
+
*
|
|
26
|
+
* Consent needs nothing here: <ConsentInit> sets the Consent Mode v2
|
|
27
|
+
* defaults synchronously ahead of this loader, and because the Ads
|
|
28
|
+
* destination rides the same gtag instance, `ad_storage` /
|
|
29
|
+
* `ad_user_data` / `ad_personalization` gate it with no extra wiring.
|
|
30
|
+
* That is the direct payoff of refusing the second script tag.
|
|
31
|
+
*
|
|
32
|
+
* Renders nothing when neither id is configured, so a layout can mount it
|
|
33
|
+
* unconditionally.
|
|
34
|
+
*
|
|
35
|
+
* `send_page_view` stays at its default, so the initial page_view fires
|
|
36
|
+
* on load. SPA route changes are NOT auto-tracked by gtag; a storefront
|
|
37
|
+
* that wants per-route page_views calls `gtag('event', 'page_view', …)`
|
|
38
|
+
* from a route-change effect. Take care doing that: a `router.replace`
|
|
39
|
+
* that only rewrites a query parameter reads as a navigation to gtag, to
|
|
40
|
+
* the Meta pixel and to TikTok alike, and every vendor then counts two
|
|
41
|
+
* views of one page.
|
|
42
|
+
*/
|
|
43
|
+
export function GoogleTag({
|
|
44
|
+
measurementId,
|
|
45
|
+
adsConversionId,
|
|
46
|
+
}: {
|
|
47
|
+
/** GA4 destination, e.g. "G-8BLJ6CW7VX". */
|
|
48
|
+
measurementId?: string
|
|
49
|
+
/** Google Ads destination, e.g. "AW-18150814603". */
|
|
50
|
+
adsConversionId?: string
|
|
51
|
+
}) {
|
|
52
|
+
const destinations = [measurementId, adsConversionId].filter(
|
|
53
|
+
(id): id is string => Boolean(id && id.trim())
|
|
54
|
+
)
|
|
55
|
+
if (destinations.length === 0) return null
|
|
56
|
+
|
|
57
|
+
// Which id lands in the loader URL is cosmetic — gtag treats every
|
|
58
|
+
// `config` equally once loaded. GA4 leads when present so the URL stays
|
|
59
|
+
// what it has always been for stores that only run analytics.
|
|
60
|
+
const loaderId = destinations[0]
|
|
61
|
+
|
|
62
|
+
const initSnippet = `
|
|
63
|
+
window.dataLayer = window.dataLayer || [];
|
|
64
|
+
function gtag(){dataLayer.push(arguments);}
|
|
65
|
+
gtag('js', new Date());
|
|
66
|
+
${destinations.map((id) => `gtag('config', ${jsStringLiteral(id)});`).join("\n")}
|
|
67
|
+
`.trim()
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<>
|
|
71
|
+
<Script
|
|
72
|
+
id="google-tag-loader"
|
|
73
|
+
strategy="afterInteractive"
|
|
74
|
+
src={`https://www.googletagmanager.com/gtag/js?id=${urlParam(loaderId)}`}
|
|
75
|
+
/>
|
|
76
|
+
<Script
|
|
77
|
+
id="google-tag-init"
|
|
78
|
+
strategy="afterInteractive"
|
|
79
|
+
dangerouslySetInnerHTML={{ __html: initSnippet }}
|
|
80
|
+
/>
|
|
81
|
+
</>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* GA4 — the analytics-only door, kept because storefronts mount it by
|
|
87
|
+
* this name. It is <GoogleTag> with one destination; a store that also
|
|
88
|
+
* runs Google Ads should mount <GoogleTag> with both ids instead, since
|
|
89
|
+
* two loaders on one page is the thing Google tells you not to do.
|
|
90
|
+
*/
|
|
91
|
+
export function GA4({ measurementId }: { measurementId?: string }) {
|
|
92
|
+
return <GoogleTag measurementId={measurementId} />
|
|
93
|
+
}
|
|
@@ -3,6 +3,7 @@ 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"
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Reads Meta + GA4 attribution signals from the current Next.js server
|
|
@@ -110,6 +111,35 @@ export async function getTrackingAttribution(
|
|
|
110
111
|
applyUtmCookie(utmFirstRaw, "utm_first", result)
|
|
111
112
|
applyUtmCookie(utmLastRaw, "utm_last", result)
|
|
112
113
|
|
|
114
|
+
// Ad-click identifiers — TikTok's ttclid / _ttp and Google's gclid /
|
|
115
|
+
// gbraid / wbraid, captured into cookies by captureClickIdsFromUrl().
|
|
116
|
+
// They travel the same road as the UTMs: cart.metadata at checkout,
|
|
117
|
+
// order.metadata at completion, and from there into the TikTok Events
|
|
118
|
+
// API `user` block. Without ttclid TikTok cannot tie a conversion back
|
|
119
|
+
// to the click that produced it, which is the number the advertiser
|
|
120
|
+
// looks at first.
|
|
121
|
+
try {
|
|
122
|
+
const cookieStore = await cookies()
|
|
123
|
+
// The key map is a plain tuple list (attribution.ts owns the cookie
|
|
124
|
+
// names), so the write goes through one narrow cast rather than a
|
|
125
|
+
// per-key branch that would have to be edited for every new platform.
|
|
126
|
+
const sink = result as Record<string, unknown>
|
|
127
|
+
for (const [cookieName, metadataKey] of CLICK_ID_METADATA_KEYS) {
|
|
128
|
+
const value = cookieStore.get(cookieName)?.value
|
|
129
|
+
if (value) {
|
|
130
|
+
// Written encoded by the capture; decoded once here so the wire
|
|
131
|
+
// carries the id the platform actually issued.
|
|
132
|
+
try {
|
|
133
|
+
sink[metadataKey] = decodeURIComponent(value)
|
|
134
|
+
} catch {
|
|
135
|
+
sink[metadataKey] = value
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
// best-effort — attribution never blocks a checkout
|
|
141
|
+
}
|
|
142
|
+
|
|
113
143
|
// _ga cookie format: "GA1.1.<client_id>.<timestamp>" — backend wants
|
|
114
144
|
// the full <client_id>.<timestamp> portion (the canonical GA client_id).
|
|
115
145
|
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
|
+
}
|