@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.
Files changed (46) hide show
  1. package/README.md +9 -3
  2. package/package.json +4 -1
  3. package/src/api/customers.ts +9 -13
  4. package/src/api/products.ts +330 -310
  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/bg.ts +3 -0
  15. package/src/locales/context.ts +37 -0
  16. package/src/locales/es.ts +3 -0
  17. package/src/locales/provider.tsx +17 -21
  18. package/src/locales/types.ts +24 -21
  19. package/src/order/order-completed-template.tsx +10 -4
  20. package/src/order/order-item.tsx +4 -4
  21. package/src/products/index.ts +14 -0
  22. package/src/products/labels.ts +7 -0
  23. package/src/products/product-actions-wrapper.tsx +62 -58
  24. package/src/products/product-actions.tsx +104 -177
  25. package/src/products/product-info.tsx +10 -5
  26. package/src/products/product-template.tsx +162 -147
  27. package/src/products/related-products.tsx +9 -9
  28. package/src/products/use-product-actions.ts +164 -0
  29. package/src/products/variant-url.ts +74 -0
  30. package/src/reviews-ui/photo-upload.tsx +2 -1
  31. package/src/reviews-ui/review-list.tsx +4 -2
  32. package/src/reviews-ui/review-widget.tsx +4 -1
  33. package/src/reviews-ui/review-wizard.tsx +3 -2
  34. package/src/store/category-template.tsx +8 -1
  35. package/src/store/collection-template.tsx +8 -1
  36. package/src/store/paginated-products.tsx +3 -2
  37. package/src/store/search-template.tsx +10 -4
  38. package/src/store/store-template.tsx +10 -4
  39. package/src/tracking/chatgpt-pixel.tsx +17 -10
  40. package/src/tracking/consent-init.tsx +62 -44
  41. package/src/tracking/consent.ts +58 -7
  42. package/src/tracking/index.ts +2 -0
  43. package/src/tracking/meta-pixel.tsx +27 -15
  44. package/src/tracking/storefront-tags.tsx +14 -7
  45. package/src/tracking/tiktok-pixel.tsx +15 -7
  46. 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
- 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
  }
@@ -48,8 +48,9 @@ export async function PaginatedProducts({
48
48
  client: StorefrontClient
49
49
  sortBy?: SortOptions
50
50
  page: number
51
- /** Filters by PRIMARY collection (`products.collection_id`) for the
52
- * membership join use collection-template / `listCollectionProducts`. */
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 { 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>
@@ -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 and TikTok snippets read
26
- * the shared consent cookie before they are allowed to write cookies, and
27
- * `applyConsent()` relays a live decision to all three vendors. Mount this
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 { CONSENT_COOKIE } from "./consent"
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({ pixelId }: { pixelId?: string }) {
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
- var ads=false;
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');
@@ -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
- /** True when the store's consent CMP is enabled client tags must
52
- * mount through the consent gate (`_1c_consent` / Consent Mode v2). */
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