@cartbase/storefront 0.19.0 → 0.20.1
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/LICENSE +21 -21
- package/package.json +258 -258
- package/src/api/auth.ts +108 -108
- package/src/api/carts.ts +523 -523
- package/src/api/categories.ts +184 -184
- package/src/api/checkout.ts +526 -526
- package/src/api/collections.ts +130 -130
- package/src/api/consent.ts +75 -75
- package/src/api/content.ts +125 -125
- package/src/api/customers.ts +303 -303
- package/src/api/gift-cards.ts +112 -112
- package/src/api/http.ts +180 -180
- package/src/api/index.ts +30 -30
- package/src/api/menus.ts +77 -77
- package/src/api/metaobjects.ts +136 -136
- package/src/api/orders.ts +290 -290
- package/src/api/redirects.ts +37 -37
- package/src/api/regions.ts +200 -200
- package/src/api/reviews.ts +22 -1
- package/src/api/search.ts +163 -163
- package/src/api/store.ts +35 -35
- package/src/api/types.ts +91 -91
- package/src/cart-drawer/context.tsx +778 -778
- package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
- package/src/cart-drawer/gift-wrap.tsx +82 -82
- package/src/cart-drawer/item/index.tsx +162 -162
- package/src/cart-drawer/item/upsell.tsx +110 -110
- package/src/cart-drawer/labels.ts +123 -123
- package/src/cart-drawer/mutation-queue.ts +78 -78
- package/src/cart-drawer/notes.tsx +131 -131
- package/src/cart-drawer/sticky-footer.tsx +73 -73
- package/src/cart-drawer/summary-breakdown.tsx +197 -197
- package/src/checkout/address-error-copy.ts +117 -117
- package/src/checkout/boxnow-locker-selector.tsx +410 -410
- package/src/checkout/compare-addresses.ts +40 -40
- package/src/checkout/discount-section.tsx +218 -218
- package/src/checkout/error-copy-codes.ts +63 -63
- package/src/checkout/geocode.ts +154 -154
- package/src/checkout/gift-card-section.tsx +224 -224
- package/src/checkout/index.ts +72 -72
- package/src/checkout/labels.ts +495 -495
- package/src/checkout/payment-button.tsx +372 -372
- package/src/checkout/payment-error-copy.ts +154 -154
- package/src/checkout/promotion-error-copy.ts +91 -91
- package/src/common/country-flag.tsx +52 -52
- package/src/common/country-select.tsx +11 -11
- package/src/common/index.ts +20 -20
- package/src/common/market-select.tsx +57 -57
- package/src/index.ts +12 -12
- package/src/lib/country-name.ts +59 -59
- package/src/lib/get-product-price.ts +133 -133
- package/src/lib/media-image.tsx +39 -39
- package/src/lib/payment-constants.ts +53 -53
- package/src/lib/platform.ts +13 -13
- package/src/lib/price.tsx +39 -39
- package/src/lib/store-api-error.ts +36 -36
- package/src/lib/variant-caption.ts +32 -32
- package/src/locales/bg.ts +3 -2
- package/src/locales/context.ts +37 -37
- package/src/locales/en.ts +26 -26
- package/src/locales/es.ts +3 -2
- package/src/locales/index.ts +19 -19
- package/src/locales/provider.tsx +59 -59
- package/src/locales/types.ts +77 -77
- package/src/order/index.ts +62 -62
- package/src/order/labels.ts +79 -79
- package/src/order/order-totals.tsx +250 -250
- package/src/primitives/select-field.tsx +93 -93
- package/src/products/image-gallery.tsx +43 -43
- package/src/products/option-select.tsx +67 -67
- package/src/products/product-info.tsx +5 -5
- package/src/products/product-price.tsx +69 -69
- package/src/products/product-promises.tsx +61 -61
- package/src/products/product-specs.tsx +65 -65
- package/src/products/product-tabs.tsx +123 -123
- package/src/products/purchase-options.tsx +130 -130
- package/src/products/sets.ts +110 -110
- package/src/products/variant-matching.ts +71 -71
- package/src/products/variant-url.ts +74 -74
- package/src/reviews-ui/index.ts +12 -0
- package/src/reviews-ui/labels.ts +5 -2
- package/src/reviews-ui/lightbox-state.ts +46 -46
- package/src/reviews-ui/review-header.tsx +166 -0
- package/src/reviews-ui/review-lightbox.tsx +271 -271
- package/src/reviews-ui/review-list.tsx +193 -191
- package/src/reviews-ui/review-widget.tsx +63 -71
- package/src/reviews-ui/star-badge.tsx +6 -4
- package/src/reviews-ui/widget-options.ts +55 -0
- package/src/store/category-template.tsx +136 -136
- package/src/store/index.ts +40 -40
- package/src/tracking/chatgpt-pixel.tsx +99 -99
- package/src/tracking/consent-init.tsx +62 -62
- package/src/tracking/events.ts +348 -348
- package/src/tracking/ga4.tsx +93 -93
- package/src/tracking/google-ads.ts +84 -84
- package/src/tracking/gtm.tsx +60 -60
- package/src/tracking/inline-script.ts +49 -49
- package/src/tracking/oaiq.ts +206 -206
- package/src/tracking/tiktok-pixel.tsx +91 -91
- package/src/tracking/track-init.tsx +56 -56
- package/src/tracking/track-order-purchase.tsx +122 -122
- package/src/tracking/ttq.ts +180 -180
- package/src/tracking/use-tracking-config.ts +54 -54
- package/theme/index.css +25 -25
package/src/tracking/ga4.tsx
CHANGED
|
@@ -1,93 +1,93 @@
|
|
|
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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,84 +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
|
-
}
|
|
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/gtm.tsx
CHANGED
|
@@ -1,60 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,49 +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
|
-
}
|
|
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
|
+
}
|