@cartbase/storefront 0.14.0 → 0.16.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 +9 -3
- package/package.json +4 -1
- package/src/api/customers.ts +9 -13
- package/src/api/products.ts +330 -310
- package/src/cart-drawer/context.tsx +11 -1
- package/src/cart-drawer/item/variant.tsx +8 -3
- package/src/checkout/checkout-client.tsx +10 -1
- package/src/checkout/line-item-card.tsx +5 -2
- package/src/checkout/order-summary.tsx +9 -5
- package/src/common/country-select.tsx +11 -65
- package/src/common/index.ts +2 -0
- package/src/common/market-select.tsx +57 -0
- package/src/lib/variant-caption.ts +32 -0
- package/src/locales/bg.ts +3 -0
- package/src/locales/context.ts +37 -0
- package/src/locales/es.ts +3 -0
- package/src/locales/provider.tsx +17 -21
- package/src/locales/types.ts +24 -21
- package/src/order/order-completed-template.tsx +10 -4
- package/src/order/order-item.tsx +4 -4
- package/src/products/index.ts +14 -0
- package/src/products/labels.ts +7 -0
- package/src/products/product-actions-wrapper.tsx +62 -58
- package/src/products/product-actions.tsx +104 -177
- package/src/products/product-info.tsx +10 -5
- package/src/products/product-template.tsx +162 -147
- package/src/products/related-products.tsx +9 -9
- package/src/products/use-product-actions.ts +164 -0
- package/src/products/variant-url.ts +74 -0
- package/src/reviews-ui/photo-upload.tsx +2 -1
- package/src/reviews-ui/review-list.tsx +4 -2
- package/src/reviews-ui/review-widget.tsx +4 -1
- package/src/reviews-ui/review-wizard.tsx +3 -2
- package/src/store/category-template.tsx +8 -1
- package/src/store/collection-template.tsx +8 -1
- package/src/store/paginated-products.tsx +3 -2
- package/src/store/search-template.tsx +10 -4
- package/src/store/store-template.tsx +10 -4
- package/src/tracking/chatgpt-pixel.tsx +17 -10
- package/src/tracking/consent-init.tsx +62 -44
- package/src/tracking/consent.ts +58 -7
- package/src/tracking/index.ts +2 -0
- package/src/tracking/meta-pixel.tsx +27 -15
- package/src/tracking/storefront-tags.tsx +14 -7
- package/src/tracking/tiktok-pixel.tsx +15 -7
- package/src/tracking/types.ts +5 -2
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "../api/reviews"
|
|
12
12
|
import { StoreApiError } from "../api/types"
|
|
13
13
|
import { cn } from "../lib/utils"
|
|
14
|
+
import { useLocaleArea } from "../locales/context"
|
|
14
15
|
import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
|
|
15
16
|
import { MAX_BODY, firstNameOf, formatLabel } from "./helpers"
|
|
16
17
|
import {
|
|
@@ -89,7 +90,7 @@ export function ReviewWizard({
|
|
|
89
90
|
storeHref = "/",
|
|
90
91
|
labels,
|
|
91
92
|
}: ReviewWizardProps) {
|
|
92
|
-
const l = { ...defaultReviewsUiLabels, ...labels }
|
|
93
|
+
const l = { ...useLocaleArea("reviews", defaultReviewsUiLabels), ...labels }
|
|
93
94
|
const entry = resolveWizardEntry(validation)
|
|
94
95
|
|
|
95
96
|
// ── Invalid / expired ────────────────────────────────────────────────
|
|
@@ -254,7 +255,7 @@ export function ReviewWizardForm({
|
|
|
254
255
|
initialRewardCode?: string | null
|
|
255
256
|
labels?: Partial<ReviewsUiLabels>
|
|
256
257
|
}) {
|
|
257
|
-
const l = { ...defaultReviewsUiLabels, ...labels }
|
|
258
|
+
const l = { ...useLocaleArea("reviews", defaultReviewsUiLabels), ...labels }
|
|
258
259
|
const [step, setStep] = useState<WizardStep>(initialStep)
|
|
259
260
|
const [rating, setRating] = useState<number>(0)
|
|
260
261
|
const [hoverRating, setHoverRating] = useState<number>(0)
|
|
@@ -30,7 +30,14 @@ type CategoryTemplateProps = {
|
|
|
30
30
|
/** From the `page` query param. */
|
|
31
31
|
page?: string
|
|
32
32
|
pricingContext?: PricingContextQuery
|
|
33
|
-
|
|
33
|
+
/**
|
|
34
|
+
* The store's pack for this area, REQUIRED: this is a server component and
|
|
35
|
+
* cannot read `StorefrontLocaleProvider`, so the page passes
|
|
36
|
+
* `STORE_LOCALE.store` (`en.store` for an English store). Required since
|
|
37
|
+
* 2026-09-14 so that forgetting it fails the build instead of shipping
|
|
38
|
+
* silent English.
|
|
39
|
+
*/
|
|
40
|
+
labels: StoreLabels
|
|
34
41
|
/** Override the default ProductPreview with a store-specific card. */
|
|
35
42
|
renderProduct?: ProductCardComponent
|
|
36
43
|
}
|
|
@@ -46,7 +46,14 @@ type CollectionTemplateProps = {
|
|
|
46
46
|
/** From the `page` query param. */
|
|
47
47
|
page?: string
|
|
48
48
|
pricingContext?: PricingContextQuery
|
|
49
|
-
|
|
49
|
+
/**
|
|
50
|
+
* The store's pack for this area, REQUIRED: this is a server component and
|
|
51
|
+
* cannot read `StorefrontLocaleProvider`, so the page passes
|
|
52
|
+
* `STORE_LOCALE.store` (`en.store` for an English store). Required since
|
|
53
|
+
* 2026-09-14 so that forgetting it fails the build instead of shipping
|
|
54
|
+
* silent English.
|
|
55
|
+
*/
|
|
56
|
+
labels: StoreLabels
|
|
50
57
|
/** Override the default ProductPreview with a store-specific card. */
|
|
51
58
|
renderProduct?: ProductCardComponent
|
|
52
59
|
}
|
|
@@ -48,8 +48,9 @@ export async function PaginatedProducts({
|
|
|
48
48
|
client: StorefrontClient
|
|
49
49
|
sortBy?: SortOptions
|
|
50
50
|
page: number
|
|
51
|
-
/** Filters by
|
|
52
|
-
*
|
|
51
|
+
/** Filters by collection MEMBERSHIP since 2026-09-15 (a product in three
|
|
52
|
+
* collections is listed under all three). For the collection's OWN order
|
|
53
|
+
* use collection-template / `listCollectionProducts`. */
|
|
53
54
|
collectionId?: string
|
|
54
55
|
categoryId?: string
|
|
55
56
|
productsIds?: string[]
|
|
@@ -24,7 +24,7 @@ import { searchProducts } from "../api/search"
|
|
|
24
24
|
import { cn } from "../lib/utils"
|
|
25
25
|
import { Button } from "../primitives/ui/button"
|
|
26
26
|
import { ProductPreview } from "../products/product-preview"
|
|
27
|
-
import {
|
|
27
|
+
import type { StoreLabels } from "./labels"
|
|
28
28
|
import type { ProductCardComponent } from "./paginated-products"
|
|
29
29
|
import { Pagination } from "./pagination"
|
|
30
30
|
import {
|
|
@@ -50,7 +50,14 @@ type SearchTemplateProps = {
|
|
|
50
50
|
pricingContext?: PricingContextQuery
|
|
51
51
|
/** Page size, 1–100 (server default 20). */
|
|
52
52
|
limit?: number
|
|
53
|
-
|
|
53
|
+
/**
|
|
54
|
+
* The store's pack for this area, REQUIRED: this is a server component and
|
|
55
|
+
* cannot read `StorefrontLocaleProvider`, so the page passes
|
|
56
|
+
* `STORE_LOCALE.store` (`en.store` for an English store). Required since
|
|
57
|
+
* 2026-09-14 so that forgetting it fails the build instead of shipping
|
|
58
|
+
* silent English, which is exactly how evoo's search page stayed English.
|
|
59
|
+
*/
|
|
60
|
+
labels: StoreLabels
|
|
54
61
|
/** Override the default ProductPreview with a store-specific card. */
|
|
55
62
|
renderProduct?: ProductCardComponent
|
|
56
63
|
}
|
|
@@ -61,10 +68,9 @@ export async function SearchTemplate({
|
|
|
61
68
|
basePath = "/search",
|
|
62
69
|
pricingContext,
|
|
63
70
|
limit = DEFAULT_LIMIT,
|
|
64
|
-
labels,
|
|
71
|
+
labels: l,
|
|
65
72
|
renderProduct: Card = ProductPreview,
|
|
66
73
|
}: SearchTemplateProps) {
|
|
67
|
-
const l = { ...defaultStoreLabels, ...labels }
|
|
68
74
|
const state = parseSearchParams(searchParams)
|
|
69
75
|
|
|
70
76
|
let response: SearchProductsResponse | null = null
|
|
@@ -8,7 +8,7 @@ import { Suspense } from "react"
|
|
|
8
8
|
import type { StorefrontClient } from "../api/http"
|
|
9
9
|
import type { PricingContextQuery } from "../api/types"
|
|
10
10
|
import type { SortOptions } from "../lib/sort-products"
|
|
11
|
-
import {
|
|
11
|
+
import type { StoreLabels } from "./labels"
|
|
12
12
|
import { SortSelect } from "./sort-select"
|
|
13
13
|
import { PaginatedProducts, type ProductCardComponent } from "./paginated-products"
|
|
14
14
|
import { SkeletonProductGrid } from "./skeleton-product-grid"
|
|
@@ -20,7 +20,14 @@ type StoreTemplateProps = {
|
|
|
20
20
|
/** From the `page` query param. */
|
|
21
21
|
page?: string
|
|
22
22
|
pricingContext?: PricingContextQuery
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* The store's pack for this area, REQUIRED: this is a server component and
|
|
25
|
+
* cannot read `StorefrontLocaleProvider`, so the page passes
|
|
26
|
+
* `STORE_LOCALE.store` (`en.store` for an English store). Required since
|
|
27
|
+
* 2026-09-14 so that forgetting it fails the build instead of shipping
|
|
28
|
+
* silent English, which is how evoo's search page stayed English.
|
|
29
|
+
*/
|
|
30
|
+
labels: StoreLabels
|
|
24
31
|
/** Override the default ProductPreview with a store-specific card. */
|
|
25
32
|
renderProduct?: ProductCardComponent
|
|
26
33
|
}
|
|
@@ -30,10 +37,9 @@ export function StoreTemplate({
|
|
|
30
37
|
sortBy,
|
|
31
38
|
page,
|
|
32
39
|
pricingContext,
|
|
33
|
-
labels,
|
|
40
|
+
labels: l,
|
|
34
41
|
renderProduct,
|
|
35
42
|
}: StoreTemplateProps) {
|
|
36
|
-
const l = { ...defaultStoreLabels, ...labels }
|
|
37
43
|
const pageNumber = page ? parseInt(page) : 1
|
|
38
44
|
const sort = sortBy || "created_at"
|
|
39
45
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import Script from "next/script"
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { adsConsentSnippet } from "./consent"
|
|
6
6
|
import { jsStringLiteral } from "./inline-script"
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -32,9 +32,13 @@ import { jsStringLiteral } from "./inline-script"
|
|
|
32
32
|
* relies on it. Nothing here relies on behaviour OpenAI has not written
|
|
33
33
|
* down: an unconsented page view is not sent, and not counted on later.
|
|
34
34
|
*
|
|
35
|
-
* The decision is
|
|
36
|
-
*
|
|
37
|
-
*
|
|
35
|
+
* The decision is the store's default overridden by the visitor's stored
|
|
36
|
+
* choice (`adsConsentSnippet`), resolved inside the snippet before anything
|
|
37
|
+
* can be sent: a store that collects consent starts closed, a store with
|
|
38
|
+
* no consent gate starts open, the cookie winning either way. Live changes
|
|
39
|
+
* arrive through `applyConsent()` in ./consent.ts — there is no cookie
|
|
40
|
+
* watcher. `consentRequired` is `tracking.consent_required`, passed by
|
|
41
|
+
* <StorefrontTags>, and required for the reason <ConsentInit> gives.
|
|
38
42
|
*
|
|
39
43
|
* The page view is ours to fire at all: their pixel sends nothing at init,
|
|
40
44
|
* unlike Meta's and TikTok's base code.
|
|
@@ -48,7 +52,14 @@ import { jsStringLiteral } from "./inline-script"
|
|
|
48
52
|
* Renders nothing when `pixelId` is falsy, so a layout can mount it
|
|
49
53
|
* unconditionally.
|
|
50
54
|
*/
|
|
51
|
-
export function ChatGptPixel({
|
|
55
|
+
export function ChatGptPixel({
|
|
56
|
+
pixelId,
|
|
57
|
+
consentRequired,
|
|
58
|
+
}: {
|
|
59
|
+
pixelId?: string
|
|
60
|
+
/** `tracking.consent_required`: does this store collect consent before it tracks? */
|
|
61
|
+
consentRequired: boolean
|
|
62
|
+
}) {
|
|
52
63
|
if (!pixelId) return null
|
|
53
64
|
|
|
54
65
|
const id = jsStringLiteral(pixelId)
|
|
@@ -70,11 +81,7 @@ oaiq("consent", false);
|
|
|
70
81
|
oaiq("init", { pixelId: ${id} });
|
|
71
82
|
|
|
72
83
|
(function (d) {
|
|
73
|
-
|
|
74
|
-
try {
|
|
75
|
-
var m = d.cookie.match(/(?:^|; )${CONSENT_COOKIE}=([^;]*)/);
|
|
76
|
-
if (m) { ads = !!JSON.parse(decodeURIComponent(m[1])).ads; }
|
|
77
|
-
} catch (e) {}
|
|
84
|
+
${adsConsentSnippet(consentRequired, "d")}
|
|
78
85
|
if (ads) {
|
|
79
86
|
oaiq("consent", true);
|
|
80
87
|
oaiq("measure", "page_viewed", { type: "contents" });
|
|
@@ -1,44 +1,62 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* ConsentInit — the synchronous Consent Mode v2 "default" snippet.
|
|
5
|
-
*
|
|
6
|
-
* Ported from the Cartbase reference implementation
|
|
7
|
-
* `src/components/storefront/consent/consent-init.tsx` (itself from
|
|
8
|
-
* `@1click/ui/src/tracking/consent-init.tsx` v2.3.1); the snippet lives in
|
|
9
|
-
* ./consent.ts (
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* Render it as the FIRST child of <body> (App Router layouts don't
|
|
13
|
-
* expose <head> for inline scripts). A plain inline <script>
|
|
14
|
-
* (deliberately NOT next/script) executes at HTML-parse time — long
|
|
15
|
-
* before the afterInteractive <GA4>/<MetaPixel> loaders, which run
|
|
16
|
-
* post-hydration. Google's consent-mode docs require the default to be
|
|
17
|
-
* set synchronously ahead of the tag; an async default races the
|
|
18
|
-
* loader and the first hit of a returning consented visitor would
|
|
19
|
-
* ship denied.
|
|
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
|
-
|
|
1
|
+
import { consentInitSnippet } from "./consent"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ConsentInit — the synchronous Consent Mode v2 "default" snippet.
|
|
5
|
+
*
|
|
6
|
+
* Ported from the Cartbase reference implementation
|
|
7
|
+
* `src/components/storefront/consent/consent-init.tsx` (itself from
|
|
8
|
+
* `@1click/ui/src/tracking/consent-init.tsx` v2.3.1); the snippet lives in
|
|
9
|
+
* ./consent.ts (`consentInitSnippet`) so its ordering contract is
|
|
10
|
+
* unit-testable.
|
|
11
|
+
*
|
|
12
|
+
* Render it as the FIRST child of <body> (App Router layouts don't
|
|
13
|
+
* expose <head> for inline scripts). A plain inline <script>
|
|
14
|
+
* (deliberately NOT next/script) executes at HTML-parse time — long
|
|
15
|
+
* before the afterInteractive <GA4>/<MetaPixel> loaders, which run
|
|
16
|
+
* post-hydration. Google's consent-mode docs require the default to be
|
|
17
|
+
* set synchronously ahead of the tag; an async default races the
|
|
18
|
+
* loader and the first hit of a returning consented visitor would
|
|
19
|
+
* ship denied.
|
|
20
|
+
*
|
|
21
|
+
* `required` is the store's consent switch, `consent.enabled` from
|
|
22
|
+
* `GET /api/store/consent`, which the layout already fetches for the
|
|
23
|
+
* banner. It is REQUIRED on purpose: with it absent there is no safe
|
|
24
|
+
* default to fall back on. Defaulting to true would keep the pixels of a
|
|
25
|
+
* store that switched its banner off silent forever (the defect fixed on
|
|
26
|
+
* 2026-09-14); defaulting to false would fire them on an EU store whose
|
|
27
|
+
* layout forgot the prop. So the layout states it, and a layout that does
|
|
28
|
+
* not fails to compile.
|
|
29
|
+
*
|
|
30
|
+
* Behavior:
|
|
31
|
+
* - `required` and no stored choice (first visit) → default ALL DENIED.
|
|
32
|
+
* Tags still load (advanced consent mode): gtag sends cookieless
|
|
33
|
+
* pings, the Pixel queues client-side. The <ConsentBanner> then
|
|
34
|
+
* collects the choice and applies the live 'update'.
|
|
35
|
+
* - not `required` and no stored choice → default ALL GRANTED: the store
|
|
36
|
+
* collects no consent, so there is nothing to wait for.
|
|
37
|
+
* - Stored choice → default mirrors it, whatever the switch, so
|
|
38
|
+
* returning visitors are correct from the very first hit with no
|
|
39
|
+
* banner and no race, and a visitor who declined keeps that decision.
|
|
40
|
+
*
|
|
41
|
+
* `ads_data_redaction` strips ad-click identifiers (gclid et al.) from
|
|
42
|
+
* the cookieless pings while ad_storage is denied, per Google's docs.
|
|
43
|
+
*
|
|
44
|
+
* The script interpolates one boolean literal and the shared cookie name,
|
|
45
|
+
* nothing typed by a person — safe as a server-component inline script
|
|
46
|
+
* with a stable id. CARD TRAP: it must NEVER wait on the
|
|
47
|
+
* /api/store/consent fetch inside the browser; the layout resolves the
|
|
48
|
+
* setting on the server and inlines it here.
|
|
49
|
+
*/
|
|
50
|
+
export function ConsentInit({
|
|
51
|
+
required,
|
|
52
|
+
}: {
|
|
53
|
+
/** The store's consent switch: `consent.enabled` from `GET /api/store/consent`. */
|
|
54
|
+
required: boolean
|
|
55
|
+
}) {
|
|
56
|
+
return (
|
|
57
|
+
<script
|
|
58
|
+
id="1click-consent-init"
|
|
59
|
+
dangerouslySetInnerHTML={{ __html: consentInitSnippet(required) }}
|
|
60
|
+
/>
|
|
61
|
+
)
|
|
62
|
+
}
|
package/src/tracking/consent.ts
CHANGED
|
@@ -169,20 +169,43 @@ export function openConsentSettings(): void {
|
|
|
169
169
|
}
|
|
170
170
|
|
|
171
171
|
/**
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
172
|
+
* THE DEFAULT FLIPS WITH THE STORE'S CONSENT SWITCH; A STORED CHOICE WINS.
|
|
173
|
+
*
|
|
174
|
+
* `required` is the store's consent setting (`consent.enabled` on
|
|
175
|
+
* `GET /api/store/consent`, served again as `tracking.consent_required` on
|
|
176
|
+
* the integrations block). A store that collects consent starts every
|
|
177
|
+
* visitor DENIED until they choose, which is what the banner is for. A
|
|
178
|
+
* store that does not collect consent has nothing to wait for, so its
|
|
179
|
+
* visitors start GRANTED. Either way a choice the visitor already stored
|
|
180
|
+
* in the cookie is what counts: someone who declined under a banner keeps
|
|
181
|
+
* that decision if the banner is later switched off.
|
|
182
|
+
*
|
|
183
|
+
* Until 2026-09-14 the default was denied whatever the setting, and the
|
|
184
|
+
* cookie is written only by the banner or an external CMP, so a store with
|
|
185
|
+
* the banner off never fired Meta, TikTok, ChatGPT or the Google Ads
|
|
186
|
+
* destination at all, silently. Alexander's rule: a banner means consent is
|
|
187
|
+
* collected and the pixels fire on it; no banner means the pixels fire.
|
|
188
|
+
* This is Consent Mode's own model too: `default` is what applies before a
|
|
189
|
+
* decision, and Google documents setting it granted where no consent is
|
|
190
|
+
* needed.
|
|
191
|
+
*
|
|
192
|
+
* The synchronous Consent Mode v2 "default" snippet is the crown jewel
|
|
193
|
+
* (card trap: NEVER async, NEVER waiting on a fetch). It is built as a
|
|
194
|
+
* string so the ordering contract is unit-testable without a DOM, and the
|
|
195
|
+
* one value interpolated is a boolean literal, so nothing typed by anyone
|
|
196
|
+
* can reach the script. `<ConsentInit required>` renders it as a plain
|
|
197
|
+
* inline <script>.
|
|
177
198
|
*/
|
|
178
|
-
export
|
|
199
|
+
export function consentInitSnippet(required: boolean): string {
|
|
200
|
+
const fallback = required ? "false" : "true"
|
|
201
|
+
return `
|
|
179
202
|
(function(){
|
|
180
203
|
var c=null;
|
|
181
204
|
try{
|
|
182
205
|
var m=document.cookie.match(/(?:^|; )${CONSENT_COOKIE}=([^;]*)/);
|
|
183
206
|
if(m){c=JSON.parse(decodeURIComponent(m[1]));}
|
|
184
207
|
}catch(e){}
|
|
185
|
-
var ads
|
|
208
|
+
var ads=c?!!c.ads:${fallback}, an=c?!!c.analytics:${fallback};
|
|
186
209
|
window.dataLayer=window.dataLayer||[];
|
|
187
210
|
function gtag(){dataLayer.push(arguments);}
|
|
188
211
|
gtag('consent','default',{
|
|
@@ -194,6 +217,34 @@ export const CONSENT_INIT_SNIPPET = `
|
|
|
194
217
|
gtag('set','ads_data_redaction',!ads);
|
|
195
218
|
})();
|
|
196
219
|
`.trim()
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The snippet for a store that collects consent, kept under its old name:
|
|
224
|
+
* the shape every test and every mount before 2026-09-14 knew.
|
|
225
|
+
*/
|
|
226
|
+
export const CONSENT_INIT_SNIPPET = consentInitSnippet(true)
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The same rule for the vendors that read the cookie themselves (Meta,
|
|
230
|
+
* TikTok, ChatGPT: each gates its own SDK before init and cannot use the
|
|
231
|
+
* gtag default). Returns the statements that leave `var ads` holding the
|
|
232
|
+
* decision: the store's default, overridden by a stored choice.
|
|
233
|
+
*
|
|
234
|
+
* ONE implementation for every pixel-side gate, so a vendor added later
|
|
235
|
+
* gets the rule by calling this rather than by copying a cookie read.
|
|
236
|
+
* `cookieSource` is the identifier the vendor's snippet uses for
|
|
237
|
+
* `document` (TikTok's and OpenAI's loaders alias it as `d`).
|
|
238
|
+
*/
|
|
239
|
+
export function adsConsentSnippet(required: boolean, cookieSource = "document"): string {
|
|
240
|
+
return `
|
|
241
|
+
var ads=${required ? "false" : "true"};
|
|
242
|
+
try{
|
|
243
|
+
var m=${cookieSource}.cookie.match(/(?:^|; )${CONSENT_COOKIE}=([^;]*)/);
|
|
244
|
+
if(m){ads=!!JSON.parse(decodeURIComponent(m[1])).ads;}
|
|
245
|
+
}catch(e){}
|
|
246
|
+
`.trim()
|
|
247
|
+
}
|
|
197
248
|
|
|
198
249
|
// ── Per-store consent config (GET /api/store/consent) ────────────────
|
|
199
250
|
//
|
package/src/tracking/index.ts
CHANGED
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
sha256Hex,
|
|
10
10
|
type KnownVisitor,
|
|
11
11
|
} from "./attribution"
|
|
12
|
-
import {
|
|
12
|
+
import { adsConsentSnippet } from "./consent"
|
|
13
|
+
import { jsStringLiteral } from "./inline-script"
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* MetaPixel — client-side base pixel injector.
|
|
@@ -32,17 +33,32 @@ import { CONSENT_COOKIE } from "./consent"
|
|
|
32
33
|
* single base init covers the initial page; further events are fired
|
|
33
34
|
* explicitly via the helpers in `./fbq.ts`.
|
|
34
35
|
*
|
|
35
|
-
* Consent gate: fbq('consent', …) is pushed
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
36
|
+
* Consent gate: fbq('consent', …) is pushed BEFORE fbq('init') — Meta
|
|
37
|
+
* only honors a pre-init revoke, which makes the Pixel queue every event
|
|
38
|
+
* (incl. this PageView) client-side until the <ConsentBanner> grants; a
|
|
39
|
+
* later fbq('consent','grant') flushes the queue. The decision is the
|
|
40
|
+
* store's default overridden by the visitor's stored choice
|
|
41
|
+
* (`adsConsentSnippet`): a store that collects consent starts revoked, a
|
|
42
|
+
* store with no consent gate starts granted, and a cookie wins either way.
|
|
43
|
+
*
|
|
44
|
+
* `consentRequired` is `tracking.consent_required` from the store's
|
|
45
|
+
* integrations block; <StorefrontTags> passes it. It is required on the
|
|
46
|
+
* prop for the reason <ConsentInit> gives: neither default is safe.
|
|
40
47
|
*/
|
|
41
|
-
export function MetaPixel({
|
|
48
|
+
export function MetaPixel({
|
|
49
|
+
pixelId,
|
|
50
|
+
consentRequired,
|
|
51
|
+
}: {
|
|
52
|
+
pixelId?: string
|
|
53
|
+
/** `tracking.consent_required`: does this store collect consent before it tracks? */
|
|
54
|
+
consentRequired: boolean
|
|
55
|
+
}) {
|
|
42
56
|
if (!pixelId) return null
|
|
43
57
|
|
|
58
|
+
const id = jsStringLiteral(pixelId)
|
|
59
|
+
|
|
44
60
|
const initSnippet = `
|
|
45
|
-
window.__1click_fb_pixel_id
|
|
61
|
+
window.__1click_fb_pixel_id=${id};
|
|
46
62
|
!function(f,b,e,v,n,t,s)
|
|
47
63
|
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
|
|
48
64
|
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
|
|
@@ -52,14 +68,10 @@ t.src=v;s=b.getElementsByTagName(e)[0];
|
|
|
52
68
|
s.parentNode.insertBefore(t,s)}(window, document,'script',
|
|
53
69
|
'https://connect.facebook.net/en_US/fbevents.js');
|
|
54
70
|
(function(){
|
|
55
|
-
|
|
56
|
-
try{
|
|
57
|
-
var m=document.cookie.match(/(?:^|; )${CONSENT_COOKIE}=([^;]*)/);
|
|
58
|
-
if(m){ads=!!JSON.parse(decodeURIComponent(m[1])).ads;}
|
|
59
|
-
}catch(e){}
|
|
71
|
+
${adsConsentSnippet(consentRequired)}
|
|
60
72
|
fbq('consent', ads?'grant':'revoke');
|
|
61
73
|
})();
|
|
62
|
-
fbq('init',
|
|
74
|
+
fbq('init', ${id});
|
|
63
75
|
fbq('track', 'PageView');
|
|
64
76
|
`.trim()
|
|
65
77
|
|
|
@@ -75,7 +87,7 @@ fbq('track', 'PageView');
|
|
|
75
87
|
height="1"
|
|
76
88
|
width="1"
|
|
77
89
|
style={{ display: "none" }}
|
|
78
|
-
src={`https://www.facebook.com/tr?id=${pixelId}&ev=PageView&noscript=1`}
|
|
90
|
+
src={`https://www.facebook.com/tr?id=${encodeURIComponent(pixelId)}&ev=PageView&noscript=1`}
|
|
79
91
|
alt=""
|
|
80
92
|
/>
|
|
81
93
|
</noscript>
|
|
@@ -22,10 +22,15 @@ import type { TrackingConfig } from "./types"
|
|
|
22
22
|
* it at the next deploy.
|
|
23
23
|
*
|
|
24
24
|
* Consent: nothing extra to wire. <ConsentInit> sets the Consent Mode v2
|
|
25
|
-
* defaults synchronously ahead of these, the Meta
|
|
26
|
-
* the
|
|
27
|
-
* `applyConsent()` relays a live decision to
|
|
28
|
-
* AFTER <ConsentInit> and the gate holds.
|
|
25
|
+
* defaults synchronously ahead of these, the Meta, TikTok and ChatGPT
|
|
26
|
+
* snippets resolve the same decision before they are allowed to send, and
|
|
27
|
+
* `applyConsent()` relays a live decision to every vendor. Mount this
|
|
28
|
+
* AFTER <ConsentInit> and the gate holds. Whether there IS a gate is the
|
|
29
|
+
* store's own setting, `consent_required` on this same block: a store that
|
|
30
|
+
* collects consent starts every visitor denied until they choose, a store
|
|
31
|
+
* with the banner off starts them granted, and a stored choice wins either
|
|
32
|
+
* way. The block always carries the flag; an absent one is read as
|
|
33
|
+
* required, the closed side.
|
|
29
34
|
*
|
|
30
35
|
* Google is deliberately ONE tag with two destinations rather than two
|
|
31
36
|
* loaders — see the comment block in ./ga4.tsx. It is also why the Ads
|
|
@@ -56,6 +61,8 @@ export async function StorefrontTags({
|
|
|
56
61
|
}
|
|
57
62
|
}
|
|
58
63
|
|
|
64
|
+
const consentRequired = tracking.consent_required ?? true
|
|
65
|
+
|
|
59
66
|
return (
|
|
60
67
|
<>
|
|
61
68
|
{/* Cartbase's own analytics, always on and never a merchant setting:
|
|
@@ -64,9 +71,9 @@ export async function StorefrontTags({
|
|
|
64
71
|
only appear when the merchant has entered their ids. */}
|
|
65
72
|
<PageViews />
|
|
66
73
|
<LiveHeartbeat />
|
|
67
|
-
<MetaPixel pixelId={tracking.facebookPixel?.pixelId} />
|
|
68
|
-
<TikTokPixel pixelId={tracking.tiktok?.pixelId} />
|
|
69
|
-
<ChatGptPixel pixelId={tracking.chatgptAds?.pixelId} />
|
|
74
|
+
<MetaPixel pixelId={tracking.facebookPixel?.pixelId} consentRequired={consentRequired} />
|
|
75
|
+
<TikTokPixel pixelId={tracking.tiktok?.pixelId} consentRequired={consentRequired} />
|
|
76
|
+
<ChatGptPixel pixelId={tracking.chatgptAds?.pixelId} consentRequired={consentRequired} />
|
|
70
77
|
<GoogleTag
|
|
71
78
|
measurementId={tracking.ga4?.measurementId}
|
|
72
79
|
adsConversionId={tracking.googleAds?.conversionId}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import Script from "next/script"
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { adsConsentSnippet } from "./consent"
|
|
6
6
|
import { jsStringLiteral } from "./inline-script"
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -33,6 +33,11 @@ import { jsStringLiteral } from "./inline-script"
|
|
|
33
33
|
*
|
|
34
34
|
* Live decisions arrive through `applyConsent()` in ./consent.ts, which
|
|
35
35
|
* relays to gtag, fbq and ttq together — there is no cookie watcher.
|
|
36
|
+
* The first decision is the store's default overridden by the visitor's
|
|
37
|
+
* stored choice (`adsConsentSnippet`): revoked on a store that collects
|
|
38
|
+
* consent, granted on a store with no consent gate, the cookie winning
|
|
39
|
+
* either way. `consentRequired` is `tracking.consent_required`, passed by
|
|
40
|
+
* <StorefrontTags>, and required for the reason <ConsentInit> gives.
|
|
36
41
|
*
|
|
37
42
|
* One dependency outside the code: Cookie Consent Mode must be switched
|
|
38
43
|
* on for this pixel in TikTok Events Manager. With it off, `holdConsent`
|
|
@@ -43,7 +48,14 @@ import { jsStringLiteral } from "./inline-script"
|
|
|
43
48
|
* Renders nothing when `pixelId` is falsy, so a layout can mount it
|
|
44
49
|
* unconditionally.
|
|
45
50
|
*/
|
|
46
|
-
export function TikTokPixel({
|
|
51
|
+
export function TikTokPixel({
|
|
52
|
+
pixelId,
|
|
53
|
+
consentRequired,
|
|
54
|
+
}: {
|
|
55
|
+
pixelId?: string
|
|
56
|
+
/** `tracking.consent_required`: does this store collect consent before it tracks? */
|
|
57
|
+
consentRequired: boolean
|
|
58
|
+
}) {
|
|
47
59
|
if (!pixelId) return null
|
|
48
60
|
|
|
49
61
|
const id = jsStringLiteral(pixelId)
|
|
@@ -63,11 +75,7 @@ export function TikTokPixel({ pixelId }: { pixelId?: string }) {
|
|
|
63
75
|
ttq.page();
|
|
64
76
|
|
|
65
77
|
(function(){
|
|
66
|
-
|
|
67
|
-
try{
|
|
68
|
-
var m=d.cookie.match(/(?:^|; )${CONSENT_COOKIE}=([^;]*)/);
|
|
69
|
-
if(m){ads=!!JSON.parse(decodeURIComponent(m[1])).ads;}
|
|
70
|
-
}catch(e){}
|
|
78
|
+
${adsConsentSnippet(consentRequired, "d")}
|
|
71
79
|
if(ads){ttq.grantConsent();}else{ttq.revokeConsent();}
|
|
72
80
|
})();
|
|
73
81
|
}(window, document, 'ttq');
|
package/src/tracking/types.ts
CHANGED
|
@@ -48,8 +48,11 @@ export type TrackingConfig = {
|
|
|
48
48
|
* token and stays on the platform, read by the order.placed forwarder
|
|
49
49
|
* (chatgpt-ads-pixel card). */
|
|
50
50
|
chatgptAds?: { pixelId: string }
|
|
51
|
-
/**
|
|
52
|
-
*
|
|
51
|
+
/** Whether this store collects consent before it tracks (the consent
|
|
52
|
+
* CMP's `enabled`). True: every tag starts denied until the visitor
|
|
53
|
+
* chooses on the banner. False: the store has no consent gate and every
|
|
54
|
+
* tag starts granted. A choice stored in `_1c_consent` wins either way.
|
|
55
|
+
* Always served; a reader treats an absent value as true. */
|
|
53
56
|
consent_required?: boolean
|
|
54
57
|
}
|
|
55
58
|
|