@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.
Files changed (36) hide show
  1. package/README.md +9 -3
  2. package/package.json +2 -1
  3. package/src/api/customers.ts +4 -7
  4. package/src/api/products.ts +4 -0
  5. package/src/cart-drawer/context.tsx +11 -1
  6. package/src/cart-drawer/item/variant.tsx +8 -3
  7. package/src/checkout/checkout-client.tsx +10 -1
  8. package/src/checkout/line-item-card.tsx +5 -2
  9. package/src/checkout/order-summary.tsx +9 -5
  10. package/src/common/country-select.tsx +11 -65
  11. package/src/common/index.ts +2 -0
  12. package/src/common/market-select.tsx +57 -0
  13. package/src/lib/variant-caption.ts +32 -0
  14. package/src/locales/context.ts +37 -0
  15. package/src/locales/provider.tsx +17 -21
  16. package/src/locales/types.ts +34 -6
  17. package/src/order/order-completed-template.tsx +10 -4
  18. package/src/order/order-item.tsx +4 -4
  19. package/src/products/product-template.tsx +16 -0
  20. package/src/products/related-products.tsx +11 -5
  21. package/src/reviews-ui/photo-upload.tsx +2 -1
  22. package/src/reviews-ui/review-list.tsx +4 -2
  23. package/src/reviews-ui/review-widget.tsx +4 -1
  24. package/src/reviews-ui/review-wizard.tsx +3 -2
  25. package/src/store/category-template.tsx +8 -1
  26. package/src/store/collection-template.tsx +8 -1
  27. package/src/store/search-template.tsx +10 -4
  28. package/src/store/store-template.tsx +10 -4
  29. package/src/tracking/chatgpt-pixel.tsx +17 -10
  30. package/src/tracking/consent-init.tsx +62 -44
  31. package/src/tracking/consent.ts +58 -7
  32. package/src/tracking/index.ts +2 -0
  33. package/src/tracking/meta-pixel.tsx +27 -15
  34. package/src/tracking/storefront-tags.tsx +14 -7
  35. package/src/tracking/tiktok-pixel.tsx +15 -7
  36. 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 { defaultProductLabels, type ProductLabels } from "./labels"
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
- labels?: Pick<ProductLabels, "relatedProducts" | "relatedProductsDescription">
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 ?? defaultReviewsUiLabels.close}
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
- const l = { ...defaultReviewsUiLabels, ...labels }
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
- labels?: Partial<StoreLabels>
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
- labels?: Partial<StoreLabels>
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 { defaultStoreLabels, type StoreLabels } from "./labels"
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
- labels?: Partial<StoreLabels>
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 { defaultStoreLabels, type StoreLabels } from "./labels"
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
- labels?: Partial<StoreLabels>
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 { CONSENT_COOKIE } from "./consent"
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 read from the shared `_1c_consent` cookie inside the
36
- * snippet, before anything can be sent, and live changes arrive through
37
- * `applyConsent()` in ./consent.ts there is no cookie watcher.
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({ pixelId }: { pixelId?: string }) {
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
- var ads = false;
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 { CONSENT_INIT_SNIPPET } 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 (CONSENT_INIT_SNIPPET, character-identical) so its ordering
10
- * contract is 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
- * Behavior:
22
- * - No stored choice (first visit) default ALL DENIED. Tags still
23
- * load (advanced consent mode): gtag sends cookieless pings, the
24
- * Pixel queues client-side. The <ConsentBanner> then collects the
25
- * choice and applies the live 'update'.
26
- * - Stored choice default mirrors it, so returning visitors are
27
- * correct from the very first hit with no banner and no race.
28
- *
29
- * `ads_data_redaction` strips ad-click identifiers (gclid et al.) from
30
- * the cookieless pings while ad_storage is denied, per Google's docs.
31
- *
32
- * The script is static (no props interpolated beyond the shared cookie
33
- * name) safe as a server-component inline script with a stable id.
34
- * CARD TRAP: it must NEVER wait on the /api/store/consent fetch — the
35
- * per-store config drives the BANNER, not this default.
36
- */
37
- export function ConsentInit() {
38
- return (
39
- <script
40
- id="1click-consent-init"
41
- dangerouslySetInnerHTML={{ __html: CONSENT_INIT_SNIPPET }}
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
+ }
@@ -169,20 +169,43 @@ export function openConsentSettings(): void {
169
169
  }
170
170
 
171
171
  /**
172
- * The synchronous Consent Mode v2 "default" snippet the crown jewel
173
- * (card trap: NEVER async, NEVER config-dependent-late). Exported as a
174
- * string so the ordering contract is unit-testable without a DOM:
175
- * character-identical to the shipped @1click/ui v2.3.1 snippet.
176
- * `<ConsentInit>` renders it as a plain inline <script>.
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 const CONSENT_INIT_SNIPPET = `
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=!!(c&&c.ads), an=!!(c&&c.analytics);
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
  //
@@ -79,6 +79,8 @@ export {
79
79
  writeConsentCookie,
80
80
  shouldRenderBanner,
81
81
  pickConsentCopy,
82
+ consentInitSnippet,
83
+ adsConsentSnippet,
82
84
  CONSENT_COOKIE,
83
85
  CONSENT_MAX_AGE_SECONDS,
84
86
  CONSENT_OPEN_EVENT,
@@ -9,7 +9,8 @@ import {
9
9
  sha256Hex,
10
10
  type KnownVisitor,
11
11
  } from "./attribution"
12
- import { CONSENT_COOKIE } from "./consent"
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 from the `_1c_consent`
36
- * cookie BEFORE fbq('init') — Meta only honors a pre-init revoke, which
37
- * makes the Pixel queue every event (incl. this PageView) client-side
38
- * until the <ConsentBanner> grants; a later fbq('consent','grant')
39
- * flushes the queue. No cookie (first visit) = revoke.
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({ pixelId }: { pixelId?: string }) {
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='${pixelId}';
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
- var ads=false;
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', '${pixelId}');
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>