@cartbase/storefront 0.13.0 → 0.15.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 +2 -1
- package/src/api/customers.ts +4 -7
- package/src/api/products.ts +4 -0
- 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/context.ts +37 -0
- package/src/locales/provider.tsx +17 -21
- package/src/locales/types.ts +34 -6
- package/src/order/order-completed-template.tsx +10 -4
- package/src/order/order-item.tsx +4 -4
- package/src/products/product-template.tsx +16 -0
- package/src/products/related-products.tsx +11 -5
- 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/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
|
@@ -21,6 +21,7 @@ import { ImageGallery } from "./image-gallery"
|
|
|
21
21
|
import { ProductActions, type AddToCartInput } from "./product-actions"
|
|
22
22
|
import { ProductTabs, type ProductSection } from "./product-tabs"
|
|
23
23
|
import type { ProductPromise } from "./product-promises"
|
|
24
|
+
import type { ProductLabels } from "./labels"
|
|
24
25
|
import { RelatedProducts } from "./related-products"
|
|
25
26
|
import { ProductInfo } from "./product-info"
|
|
26
27
|
import { ProductActionsWrapper } from "./product-actions-wrapper"
|
|
@@ -39,6 +40,19 @@ type ProductTemplateProps = {
|
|
|
39
40
|
* the library cannot promise anything on a merchant's behalf.
|
|
40
41
|
*/
|
|
41
42
|
promises?: ProductPromise[]
|
|
43
|
+
/**
|
|
44
|
+
* The store's product pack, REQUIRED even when a `StorefrontLocaleProvider`
|
|
45
|
+
* is mounted: the related-products strip is a SERVER component and a
|
|
46
|
+
* server component cannot read a client context, so its heading stays
|
|
47
|
+
* English unless the pack arrives as a prop. Until 2026-09-13 this
|
|
48
|
+
* template did not accept one at all and did not pass one down, which made
|
|
49
|
+
* that strip untranslatable without ejecting the file (Alexander found it
|
|
50
|
+
* on evoo: a Bulgarian page ending in "Related products / You might also
|
|
51
|
+
* want to check out these products"). Optional until 2026-09-14, which is
|
|
52
|
+
* how a page could still forget it; now the build refuses the page.
|
|
53
|
+
* Pass `STORE_LOCALE.products` (`en.products` for an English store).
|
|
54
|
+
*/
|
|
55
|
+
labels: ProductLabels
|
|
42
56
|
/** Drop the physical-facts section even for a product that has facts. */
|
|
43
57
|
hideSpecs?: boolean
|
|
44
58
|
/** Anything else the store wants in the accordion, appended in order. */
|
|
@@ -53,6 +67,7 @@ export function ProductTemplate({
|
|
|
53
67
|
onAddToCart,
|
|
54
68
|
openCart,
|
|
55
69
|
promises,
|
|
70
|
+
labels,
|
|
56
71
|
hideSpecs,
|
|
57
72
|
sections,
|
|
58
73
|
}: ProductTemplateProps) {
|
|
@@ -123,6 +138,7 @@ export function ProductTemplate({
|
|
|
123
138
|
client={client}
|
|
124
139
|
product={product}
|
|
125
140
|
pricingContext={pricingContext}
|
|
141
|
+
labels={labels}
|
|
126
142
|
/>
|
|
127
143
|
</Suspense>
|
|
128
144
|
</div>
|
|
@@ -22,7 +22,7 @@ import type { StoreProduct } from "../api/products"
|
|
|
22
22
|
import { listRelatedProducts } from "../api/search"
|
|
23
23
|
import { StoreApiError } from "../api/types"
|
|
24
24
|
import { ProductPreview } from "./product-preview"
|
|
25
|
-
import {
|
|
25
|
+
import type { ProductLabels } from "./labels"
|
|
26
26
|
|
|
27
27
|
type RelatedProductsProps = {
|
|
28
28
|
client: StorefrontClient
|
|
@@ -31,7 +31,15 @@ type RelatedProductsProps = {
|
|
|
31
31
|
pricingContext?: PricingContextQuery
|
|
32
32
|
/** 1–24, server default 12. */
|
|
33
33
|
limit?: number
|
|
34
|
-
|
|
34
|
+
/**
|
|
35
|
+
* The store's product pack, REQUIRED. It has to arrive as a PROP: this is
|
|
36
|
+
* a server component, so it cannot read `ProductLabelsProvider`, and its
|
|
37
|
+
* heading stays English without one. `ProductTemplate` passes the pack it
|
|
38
|
+
* is given straight through; a page mounting this strip on its own passes
|
|
39
|
+
* `STORE_LOCALE.products`. Required since 2026-09-14 so that forgetting it
|
|
40
|
+
* fails the build instead of shipping silent English.
|
|
41
|
+
*/
|
|
42
|
+
labels: ProductLabels
|
|
35
43
|
/** Override the default ProductPreview with a store-specific card. */
|
|
36
44
|
renderProduct?: ComponentType<{ product: StoreProduct }>
|
|
37
45
|
}
|
|
@@ -41,11 +49,9 @@ export async function RelatedProducts({
|
|
|
41
49
|
product,
|
|
42
50
|
pricingContext,
|
|
43
51
|
limit,
|
|
44
|
-
labels,
|
|
52
|
+
labels: l,
|
|
45
53
|
renderProduct: Card = ProductPreview,
|
|
46
54
|
}: RelatedProductsProps) {
|
|
47
|
-
const l = { ...defaultProductLabels, ...labels }
|
|
48
|
-
|
|
49
55
|
let products: StoreProduct[]
|
|
50
56
|
try {
|
|
51
57
|
const res = await listRelatedProducts(client, product.id, {
|
|
@@ -5,6 +5,7 @@ import { Loader2, Upload, X } from "lucide-react"
|
|
|
5
5
|
import type { StorefrontClient } from "../api/http"
|
|
6
6
|
import { createUploadUrl, type ReviewMedia } from "../api/reviews"
|
|
7
7
|
import { cn } from "../lib/utils"
|
|
8
|
+
import { useLocaleArea } from "../locales/context"
|
|
8
9
|
import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
|
|
9
10
|
import {
|
|
10
11
|
MAX_IMAGES,
|
|
@@ -134,7 +135,7 @@ export function ReviewPhotoUpload({
|
|
|
134
135
|
onError,
|
|
135
136
|
labels,
|
|
136
137
|
}: ReviewPhotoUploadProps) {
|
|
137
|
-
const l = { ...defaultReviewsUiLabels, ...labels }
|
|
138
|
+
const l = { ...useLocaleArea("reviews", defaultReviewsUiLabels), ...labels }
|
|
138
139
|
const [dragging, setDragging] = useState(false)
|
|
139
140
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
140
141
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useEffect, useState } from "react"
|
|
4
4
|
import { ShieldCheck, X } from "lucide-react"
|
|
5
5
|
import type { PublicReview } from "../api/reviews"
|
|
6
|
+
import { useLocaleArea } from "../locales/context"
|
|
6
7
|
import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
|
|
7
8
|
import { formatReviewDate, reviewDisplayName } from "./helpers"
|
|
8
9
|
import { StarRow } from "./star-badge"
|
|
@@ -39,7 +40,7 @@ export function ReviewList({
|
|
|
39
40
|
labels,
|
|
40
41
|
className,
|
|
41
42
|
}: ReviewListProps) {
|
|
42
|
-
const l = { ...defaultReviewsUiLabels, ...labels }
|
|
43
|
+
const l = { ...useLocaleArea("reviews", defaultReviewsUiLabels), ...labels }
|
|
43
44
|
// Lightbox: when set, the indicated review's media at the given index
|
|
44
45
|
// is shown full-bleed.
|
|
45
46
|
const [lightbox, setLightbox] = useState<{
|
|
@@ -194,6 +195,7 @@ export function ReviewLightbox({
|
|
|
194
195
|
closeLabel?: string
|
|
195
196
|
onClose: () => void
|
|
196
197
|
}) {
|
|
198
|
+
const fallback = useLocaleArea("reviews", defaultReviewsUiLabels)
|
|
197
199
|
// Close on Escape
|
|
198
200
|
useEffect(() => {
|
|
199
201
|
const onKey = (e: KeyboardEvent) => {
|
|
@@ -219,7 +221,7 @@ export function ReviewLightbox({
|
|
|
219
221
|
>
|
|
220
222
|
<button
|
|
221
223
|
type="button"
|
|
222
|
-
aria-label={closeLabel ??
|
|
224
|
+
aria-label={closeLabel ?? fallback.close}
|
|
223
225
|
onClick={onClose}
|
|
224
226
|
className="absolute top-4 right-4 w-10 h-10 rounded-full bg-background/10 hover:bg-background/20 text-background flex items-center justify-center"
|
|
225
227
|
>
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type ReviewWidgetPayload,
|
|
10
10
|
} from "../api/reviews"
|
|
11
11
|
import { cn } from "../lib/utils"
|
|
12
|
+
import { useLocaleArea } from "../locales/context"
|
|
12
13
|
import { defaultReviewsUiLabels, type ReviewsUiLabels } from "./labels"
|
|
13
14
|
import { sortParamsFor, type ReviewSortKey } from "./helpers"
|
|
14
15
|
import { RatingDistribution, StarBadge } from "./star-badge"
|
|
@@ -54,7 +55,9 @@ export function ReviewWidget({
|
|
|
54
55
|
labels,
|
|
55
56
|
className,
|
|
56
57
|
}: ReviewWidgetProps) {
|
|
57
|
-
|
|
58
|
+
// The mounted language, then the store's overrides (2026-09-14): a store
|
|
59
|
+
// that mounts StorefrontLocaleProvider passes nothing here.
|
|
60
|
+
const l = { ...useLocaleArea("reviews", defaultReviewsUiLabels), ...labels }
|
|
58
61
|
const [data, setData] = useState<ReviewWidgetPayload | null>(
|
|
59
62
|
initialData ?? null
|
|
60
63
|
)
|
|
@@ -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
|
}
|
|
@@ -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>
|