@cartbase/storefront 0.20.0 → 0.21.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 (109) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +4 -1
  3. package/src/api/auth.ts +108 -108
  4. package/src/api/carts.ts +523 -523
  5. package/src/api/categories.ts +184 -184
  6. package/src/api/checkout.ts +526 -526
  7. package/src/api/collections.ts +130 -130
  8. package/src/api/consent.ts +75 -75
  9. package/src/api/content.ts +125 -125
  10. package/src/api/customers.ts +303 -303
  11. package/src/api/gift-cards.ts +112 -112
  12. package/src/api/http.ts +181 -180
  13. package/src/api/index.ts +30 -30
  14. package/src/api/menus.ts +77 -77
  15. package/src/api/metaobjects.ts +136 -136
  16. package/src/api/orders.ts +290 -290
  17. package/src/api/redirects.ts +37 -37
  18. package/src/api/regions.ts +200 -200
  19. package/src/api/search.ts +163 -163
  20. package/src/api/store.ts +56 -35
  21. package/src/api/types.ts +91 -91
  22. package/src/cart-drawer/context.tsx +778 -778
  23. package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
  24. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
  25. package/src/cart-drawer/gift-wrap.tsx +82 -82
  26. package/src/cart-drawer/item/index.tsx +162 -162
  27. package/src/cart-drawer/item/upsell.tsx +110 -110
  28. package/src/cart-drawer/labels.ts +123 -123
  29. package/src/cart-drawer/mutation-queue.ts +78 -78
  30. package/src/cart-drawer/notes.tsx +131 -131
  31. package/src/cart-drawer/payment-badges.tsx +36 -96
  32. package/src/cart-drawer/sticky-footer.tsx +73 -73
  33. package/src/cart-drawer/summary-breakdown.tsx +197 -197
  34. package/src/checkout/address-error-copy.ts +117 -117
  35. package/src/checkout/boxnow-locker-selector.tsx +410 -410
  36. package/src/checkout/compare-addresses.ts +40 -40
  37. package/src/checkout/discount-section.tsx +218 -218
  38. package/src/checkout/error-copy-codes.ts +63 -63
  39. package/src/checkout/geocode.ts +154 -154
  40. package/src/checkout/gift-card-section.tsx +224 -224
  41. package/src/checkout/index.ts +72 -72
  42. package/src/checkout/labels.ts +495 -495
  43. package/src/checkout/payment-button.tsx +372 -372
  44. package/src/checkout/payment-error-copy.ts +154 -154
  45. package/src/checkout/promotion-error-copy.ts +91 -91
  46. package/src/common/country-flag.tsx +52 -52
  47. package/src/common/country-select.tsx +11 -11
  48. package/src/common/icons/cartbase-mark.ts +9 -0
  49. package/src/common/icons/payment-marks.ts +73 -0
  50. package/src/common/icons/social-marks.ts +61 -0
  51. package/src/common/index.ts +35 -20
  52. package/src/common/market-select.tsx +57 -57
  53. package/src/common/payment-icons.tsx +54 -0
  54. package/src/common/powered-by-cartbase.tsx +47 -0
  55. package/src/common/social-links.tsx +65 -0
  56. package/src/index.ts +12 -12
  57. package/src/lib/country-name.ts +59 -59
  58. package/src/lib/get-product-price.ts +133 -133
  59. package/src/lib/media-image.tsx +39 -39
  60. package/src/lib/payment-constants.ts +53 -53
  61. package/src/lib/platform.ts +13 -13
  62. package/src/lib/price.tsx +39 -39
  63. package/src/lib/store-api-error.ts +36 -36
  64. package/src/lib/variant-caption.ts +32 -32
  65. package/src/locales/bg.ts +3 -0
  66. package/src/locales/context.ts +37 -37
  67. package/src/locales/en.ts +26 -26
  68. package/src/locales/es.ts +3 -0
  69. package/src/locales/index.ts +19 -19
  70. package/src/locales/provider.tsx +59 -59
  71. package/src/locales/types.ts +77 -77
  72. package/src/order/index.ts +62 -62
  73. package/src/order/labels.ts +79 -79
  74. package/src/order/order-totals.tsx +250 -250
  75. package/src/primitives/select-field.tsx +93 -93
  76. package/src/products/image-gallery.tsx +43 -43
  77. package/src/products/option-select.tsx +67 -67
  78. package/src/products/product-info.tsx +5 -5
  79. package/src/products/product-price.tsx +69 -69
  80. package/src/products/product-promises.tsx +61 -61
  81. package/src/products/product-specs.tsx +65 -65
  82. package/src/products/product-tabs.tsx +123 -123
  83. package/src/products/purchase-options.tsx +130 -130
  84. package/src/products/sets.ts +110 -110
  85. package/src/products/variant-matching.ts +71 -71
  86. package/src/products/variant-url.ts +74 -74
  87. package/src/reviews-ui/lightbox-state.ts +46 -46
  88. package/src/reviews-ui/review-header.tsx +166 -166
  89. package/src/reviews-ui/review-lightbox.tsx +271 -271
  90. package/src/reviews-ui/review-list.tsx +193 -193
  91. package/src/reviews-ui/review-widget.tsx +219 -218
  92. package/src/reviews-ui/widget-options.ts +55 -55
  93. package/src/store/category-template.tsx +136 -136
  94. package/src/store/index.ts +40 -40
  95. package/src/store/labels.ts +10 -0
  96. package/src/tracking/chatgpt-pixel.tsx +99 -99
  97. package/src/tracking/consent-init.tsx +62 -62
  98. package/src/tracking/events.ts +348 -348
  99. package/src/tracking/ga4.tsx +93 -93
  100. package/src/tracking/google-ads.ts +84 -84
  101. package/src/tracking/gtm.tsx +60 -60
  102. package/src/tracking/inline-script.ts +49 -49
  103. package/src/tracking/oaiq.ts +206 -206
  104. package/src/tracking/tiktok-pixel.tsx +91 -91
  105. package/src/tracking/track-init.tsx +56 -56
  106. package/src/tracking/track-order-purchase.tsx +122 -122
  107. package/src/tracking/ttq.ts +180 -180
  108. package/src/tracking/use-tracking-config.ts +54 -54
  109. package/theme/index.css +25 -25
@@ -1,136 +1,136 @@
1
- /**
2
- * Category page — ported from
3
- * `@1click/ui/src/store/category-template.tsx` (v2.3.1). Data seam:
4
- * `HttpTypes.StoreProductCategory` → Cartbase `StoreProductCategory`
5
- * (`@cartbase/storefront/api/categories`); products come from the plain
6
- * listing filtered by `category_id` (membership), via `PaginatedProducts`.
7
- *
8
- * Breadcrumbs need `parent_category` chains and the child list needs
9
- * `category_children` — fetch the category with `include_ancestors_tree` +
10
- * `include_descendants_tree` (`retrieveCategory` query flags), otherwise
11
- * those sections simply don't render. Skeleton count is the fixed default
12
- * (Cartbase categories don't embed a products array).
13
- */
14
- import { Suspense } from "react"
15
- import { notFound } from "next/navigation"
16
- import Link from "next/link"
17
- import type { StorefrontClient } from "../api/http"
18
- import type { PricingContextQuery } from "../api/types"
19
- import type { StoreProductCategory } from "../api/categories"
20
- import type { SortOptions } from "../lib/sort-products"
21
- import type { StoreLabels } from "./labels"
22
- import { SortSelect } from "./sort-select"
23
- import { PaginatedProducts, type ProductCardComponent } from "./paginated-products"
24
- import { SkeletonProductGrid } from "./skeleton-product-grid"
25
-
26
- type CategoryTemplateProps = {
27
- client: StorefrontClient
28
- category: StoreProductCategory
29
- sortBy?: SortOptions
30
- /** From the `page` query param. */
31
- page?: string
32
- pricingContext?: PricingContextQuery
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
41
- /** Override the default ProductPreview with a store-specific card. */
42
- renderProduct?: ProductCardComponent
43
- }
44
-
45
- export function CategoryTemplate({
46
- client,
47
- category,
48
- sortBy,
49
- page,
50
- pricingContext,
51
- labels,
52
- renderProduct,
53
- }: CategoryTemplateProps) {
54
- const pageNumber = page ? parseInt(page) : 1
55
- const sort = sortBy || "created_at"
56
-
57
- if (!category) notFound()
58
-
59
- const parents: StoreProductCategory[] = []
60
- const getParents = (cat: StoreProductCategory) => {
61
- if (cat.parent_category) {
62
- parents.push(cat.parent_category)
63
- getParents(cat.parent_category)
64
- }
65
- }
66
- getParents(category)
67
-
68
- return (
69
- <div
70
- className="flex flex-col sm:flex-row sm:items-start py-6 max-w-7xl mx-auto px-4"
71
- data-testid="category-container"
72
- >
73
- <div className="flex sm:flex-col gap-12 py-4 mb-8 sm:px-0 pl-6 sm:min-w-[250px]">
74
- <SortSelect
75
- sortBy={sort}
76
- labels={labels}
77
- data-testid="sort-by-container"
78
- />
79
- </div>
80
- <div className="w-full">
81
- <div className="flex flex-row mb-8 gap-4">
82
- {parents.map((parent) => (
83
- <span key={parent.id} className="text-muted-foreground text-2xl">
84
- <Link
85
- className="mr-4 hover:text-foreground"
86
- href={`/categories/${parent.handle}`}
87
- >
88
- {parent.name}
89
- </Link>
90
- /
91
- </span>
92
- ))}
93
- <h1
94
- className="text-2xl font-semibold text-foreground"
95
- data-testid="category-page-title"
96
- >
97
- {category.name}
98
- </h1>
99
- </div>
100
- {category.description && (
101
- <div className="mb-8 text-sm text-muted-foreground">
102
- <p>{category.description}</p>
103
- </div>
104
- )}
105
- {category.category_children && (
106
- <div className="mb-8">
107
- <ul className="grid grid-cols-1 gap-2">
108
- {category.category_children.map((c) => (
109
- <li key={c.id}>
110
- <Link
111
- href={`/categories/${c.handle}`}
112
- className="text-primary hover:underline"
113
- >
114
- {c.name}
115
- </Link>
116
- </li>
117
- ))}
118
- </ul>
119
- </div>
120
- )}
121
- <Suspense fallback={<SkeletonProductGrid />}>
122
- <PaginatedProducts
123
- client={client}
124
- sortBy={sort}
125
- page={pageNumber}
126
- categoryId={category.id}
127
- pricingContext={pricingContext}
128
- renderProduct={renderProduct}
129
- />
130
- </Suspense>
131
- </div>
132
- </div>
133
- )
134
- }
135
-
136
- export { type CategoryTemplateProps }
1
+ /**
2
+ * Category page — ported from
3
+ * `@1click/ui/src/store/category-template.tsx` (v2.3.1). Data seam:
4
+ * `HttpTypes.StoreProductCategory` → Cartbase `StoreProductCategory`
5
+ * (`@cartbase/storefront/api/categories`); products come from the plain
6
+ * listing filtered by `category_id` (membership), via `PaginatedProducts`.
7
+ *
8
+ * Breadcrumbs need `parent_category` chains and the child list needs
9
+ * `category_children` — fetch the category with `include_ancestors_tree` +
10
+ * `include_descendants_tree` (`retrieveCategory` query flags), otherwise
11
+ * those sections simply don't render. Skeleton count is the fixed default
12
+ * (Cartbase categories don't embed a products array).
13
+ */
14
+ import { Suspense } from "react"
15
+ import { notFound } from "next/navigation"
16
+ import Link from "next/link"
17
+ import type { StorefrontClient } from "../api/http"
18
+ import type { PricingContextQuery } from "../api/types"
19
+ import type { StoreProductCategory } from "../api/categories"
20
+ import type { SortOptions } from "../lib/sort-products"
21
+ import type { StoreLabels } from "./labels"
22
+ import { SortSelect } from "./sort-select"
23
+ import { PaginatedProducts, type ProductCardComponent } from "./paginated-products"
24
+ import { SkeletonProductGrid } from "./skeleton-product-grid"
25
+
26
+ type CategoryTemplateProps = {
27
+ client: StorefrontClient
28
+ category: StoreProductCategory
29
+ sortBy?: SortOptions
30
+ /** From the `page` query param. */
31
+ page?: string
32
+ pricingContext?: PricingContextQuery
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
41
+ /** Override the default ProductPreview with a store-specific card. */
42
+ renderProduct?: ProductCardComponent
43
+ }
44
+
45
+ export function CategoryTemplate({
46
+ client,
47
+ category,
48
+ sortBy,
49
+ page,
50
+ pricingContext,
51
+ labels,
52
+ renderProduct,
53
+ }: CategoryTemplateProps) {
54
+ const pageNumber = page ? parseInt(page) : 1
55
+ const sort = sortBy || "created_at"
56
+
57
+ if (!category) notFound()
58
+
59
+ const parents: StoreProductCategory[] = []
60
+ const getParents = (cat: StoreProductCategory) => {
61
+ if (cat.parent_category) {
62
+ parents.push(cat.parent_category)
63
+ getParents(cat.parent_category)
64
+ }
65
+ }
66
+ getParents(category)
67
+
68
+ return (
69
+ <div
70
+ className="flex flex-col sm:flex-row sm:items-start py-6 max-w-7xl mx-auto px-4"
71
+ data-testid="category-container"
72
+ >
73
+ <div className="flex sm:flex-col gap-12 py-4 mb-8 sm:px-0 pl-6 sm:min-w-[250px]">
74
+ <SortSelect
75
+ sortBy={sort}
76
+ labels={labels}
77
+ data-testid="sort-by-container"
78
+ />
79
+ </div>
80
+ <div className="w-full">
81
+ <div className="flex flex-row mb-8 gap-4">
82
+ {parents.map((parent) => (
83
+ <span key={parent.id} className="text-muted-foreground text-2xl">
84
+ <Link
85
+ className="mr-4 hover:text-foreground"
86
+ href={`/categories/${parent.handle}`}
87
+ >
88
+ {parent.name}
89
+ </Link>
90
+ /
91
+ </span>
92
+ ))}
93
+ <h1
94
+ className="text-2xl font-semibold text-foreground"
95
+ data-testid="category-page-title"
96
+ >
97
+ {category.name}
98
+ </h1>
99
+ </div>
100
+ {category.description && (
101
+ <div className="mb-8 text-sm text-muted-foreground">
102
+ <p>{category.description}</p>
103
+ </div>
104
+ )}
105
+ {category.category_children && (
106
+ <div className="mb-8">
107
+ <ul className="grid grid-cols-1 gap-2">
108
+ {category.category_children.map((c) => (
109
+ <li key={c.id}>
110
+ <Link
111
+ href={`/categories/${c.handle}`}
112
+ className="text-primary hover:underline"
113
+ >
114
+ {c.name}
115
+ </Link>
116
+ </li>
117
+ ))}
118
+ </ul>
119
+ </div>
120
+ )}
121
+ <Suspense fallback={<SkeletonProductGrid />}>
122
+ <PaginatedProducts
123
+ client={client}
124
+ sortBy={sort}
125
+ page={pageNumber}
126
+ categoryId={category.id}
127
+ pricingContext={pricingContext}
128
+ renderProduct={renderProduct}
129
+ />
130
+ </Suspense>
131
+ </div>
132
+ </div>
133
+ )
134
+ }
135
+
136
+ export { type CategoryTemplateProps }
@@ -1,40 +1,40 @@
1
- /**
2
- * @cartbase/storefront — store (listing) family barrel. Prefer subpath
3
- * imports in apps; the barrel exists for convenience and for the extra pure
4
- * module (search-params).
5
- */
6
- export {
7
- defaultStoreLabels,
8
- sortOptionLabelKeys,
9
- type StoreLabels,
10
- } from "./labels"
11
-
12
- export { Pagination } from "./pagination"
13
- export { SortSelect, type SortSelectProps } from "./sort-select"
14
- export { PaginatedProducts, type ProductCardComponent } from "./paginated-products"
15
- export { SkeletonProductGrid } from "./skeleton-product-grid"
16
- export { StoreTemplate, type StoreTemplateProps } from "./store-template"
17
- export {
18
- CollectionTemplate,
19
- type CollectionTemplateProps,
20
- } from "./collection-template"
21
- export {
22
- CategoryTemplate,
23
- type CategoryTemplateProps,
24
- } from "./category-template"
25
- export { SearchTemplate, type SearchTemplateProps } from "./search-template"
26
-
27
- export {
28
- parseSearchParams,
29
- buildSearchQueryString,
30
- fromQueryString,
31
- toggleFacetSelection,
32
- isFacetSelected,
33
- clearFilters,
34
- toSearchQuery,
35
- type SearchPageState,
36
- type RawSearchParams,
37
- type FacetBucketRef,
38
- } from "./search-params"
39
-
40
- export { type SortOptions } from "../lib/sort-products"
1
+ /**
2
+ * @cartbase/storefront — store (listing) family barrel. Prefer subpath
3
+ * imports in apps; the barrel exists for convenience and for the extra pure
4
+ * module (search-params).
5
+ */
6
+ export {
7
+ defaultStoreLabels,
8
+ sortOptionLabelKeys,
9
+ type StoreLabels,
10
+ } from "./labels"
11
+
12
+ export { Pagination } from "./pagination"
13
+ export { SortSelect, type SortSelectProps } from "./sort-select"
14
+ export { PaginatedProducts, type ProductCardComponent } from "./paginated-products"
15
+ export { SkeletonProductGrid } from "./skeleton-product-grid"
16
+ export { StoreTemplate, type StoreTemplateProps } from "./store-template"
17
+ export {
18
+ CollectionTemplate,
19
+ type CollectionTemplateProps,
20
+ } from "./collection-template"
21
+ export {
22
+ CategoryTemplate,
23
+ type CategoryTemplateProps,
24
+ } from "./category-template"
25
+ export { SearchTemplate, type SearchTemplateProps } from "./search-template"
26
+
27
+ export {
28
+ parseSearchParams,
29
+ buildSearchQueryString,
30
+ fromQueryString,
31
+ toggleFacetSelection,
32
+ isFacetSelected,
33
+ clearFilters,
34
+ toSearchQuery,
35
+ type SearchPageState,
36
+ type RawSearchParams,
37
+ type FacetBucketRef,
38
+ } from "./search-params"
39
+
40
+ export { type SortOptions } from "../lib/sort-products"
@@ -22,6 +22,13 @@ export type StoreLabels = {
22
22
  noResults: string
23
23
  filters: string
24
24
  clearFilters: string
25
+ // the store's footer marks (common/payment-icons, social-links, powered-by-cartbase)
26
+ /** "Powered by", before the Cartbase wordmark. */
27
+ poweredBy: string
28
+ /** The payment marks' list name, for screen readers. */
29
+ paymentMethods: string
30
+ /** The social links' list name, for screen readers. */
31
+ socialProfiles: string
25
32
  }
26
33
 
27
34
  export const defaultStoreLabels: StoreLabels = {
@@ -38,6 +45,9 @@ export const defaultStoreLabels: StoreLabels = {
38
45
  noResults: "No products found",
39
46
  filters: "Filters",
40
47
  clearFilters: "Clear filters",
48
+ poweredBy: "Powered by",
49
+ paymentMethods: "Payment methods",
50
+ socialProfiles: "Follow us",
41
51
  }
42
52
 
43
53
  /**
@@ -1,99 +1,99 @@
1
- "use client"
2
-
3
- import Script from "next/script"
4
-
5
- import { adsConsentSnippet } from "./consent"
6
- import { jsStringLiteral } from "./inline-script"
7
-
8
- /**
9
- * ChatGptPixel — the OpenAI measurement pixel, the ChatGPT Ads twin of
10
- * <MetaPixel> and <TikTokPixel>.
11
- *
12
- * The loader is OpenAI's own, verbatim from
13
- * https://developers.openai.com/ads/measurement-pixel: a command queue on
14
- * `window.oaiq` that buffers calls until the SDK arrives from
15
- * bzrcdn.openai.com, injected with `<Script strategy="afterInteractive">`.
16
- *
17
- * CONSENT IS CLOSED BEFORE INIT, and that order is the whole point. Their
18
- * consent default is TRUE, so an unconsented visitor is measured unless we
19
- * say otherwise first:
20
- *
21
- * oaiq("consent", false) // queued BEFORE init — no pings
22
- * oaiq("init", { pixelId })
23
- * oaiq("consent", true) // only once the visitor accepted
24
- *
25
- * The script is LOADED either way, like the Meta and TikTok gates beside
26
- * it, so a later grant costs no round trip. But the page view is fired
27
- * only AFTER a grant, and that is deliberate rather than cautious: their
28
- * docs state that with consent false the pixel sends no measurement
29
- * pings, and they do NOT document whether a call made while consent was
30
- * false is released on a later grant. TikTok documents exactly that
31
- * (`holdConsent` queues, `grantConsent` flushes) and our TikTok gate
32
- * relies on it. Nothing here relies on behaviour OpenAI has not written
33
- * down: an unconsented page view is not sent, and not counted on later.
34
- *
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.
42
- *
43
- * The page view is ours to fire at all: their pixel sends nothing at init,
44
- * unlike Meta's and TikTok's base code.
45
- *
46
- * Their SDK also captures `oppref` off the landing URL into a first-party
47
- * `__oppref` cookie by itself. Our attribution module captures the same
48
- * parameter into `_1c_oppref` independently, so the server-side
49
- * Conversions API event can carry the click even on a visit where this
50
- * script never loaded at all.
51
- *
52
- * Renders nothing when `pixelId` is falsy, so a layout can mount it
53
- * unconditionally.
54
- */
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
- }) {
63
- if (!pixelId) return null
64
-
65
- const id = jsStringLiteral(pixelId)
66
-
67
- const initSnippet = `
68
- (function (w, d, s, u) {
69
- if (w.oaiq) return;
70
- var q = function () { q.q.push(arguments); };
71
- q.q = [];
72
- w.oaiq = q;
73
- var js = d.createElement(s);
74
- js.async = true;
75
- js.src = u;
76
- var f = d.getElementsByTagName(s)[0];
77
- f.parentNode.insertBefore(js, f);
78
- })(window, document, "script", "https://bzrcdn.openai.com/sdk/oaiq.min.js");
79
-
80
- oaiq("consent", false);
81
- oaiq("init", { pixelId: ${id} });
82
-
83
- (function (d) {
84
- ${adsConsentSnippet(consentRequired, "d")}
85
- if (ads) {
86
- oaiq("consent", true);
87
- oaiq("measure", "page_viewed", { type: "contents" });
88
- }
89
- })(document);
90
- `.trim()
91
-
92
- return (
93
- <Script
94
- id="chatgpt-pixel-init"
95
- strategy="afterInteractive"
96
- dangerouslySetInnerHTML={{ __html: initSnippet }}
97
- />
98
- )
99
- }
1
+ "use client"
2
+
3
+ import Script from "next/script"
4
+
5
+ import { adsConsentSnippet } from "./consent"
6
+ import { jsStringLiteral } from "./inline-script"
7
+
8
+ /**
9
+ * ChatGptPixel — the OpenAI measurement pixel, the ChatGPT Ads twin of
10
+ * <MetaPixel> and <TikTokPixel>.
11
+ *
12
+ * The loader is OpenAI's own, verbatim from
13
+ * https://developers.openai.com/ads/measurement-pixel: a command queue on
14
+ * `window.oaiq` that buffers calls until the SDK arrives from
15
+ * bzrcdn.openai.com, injected with `<Script strategy="afterInteractive">`.
16
+ *
17
+ * CONSENT IS CLOSED BEFORE INIT, and that order is the whole point. Their
18
+ * consent default is TRUE, so an unconsented visitor is measured unless we
19
+ * say otherwise first:
20
+ *
21
+ * oaiq("consent", false) // queued BEFORE init — no pings
22
+ * oaiq("init", { pixelId })
23
+ * oaiq("consent", true) // only once the visitor accepted
24
+ *
25
+ * The script is LOADED either way, like the Meta and TikTok gates beside
26
+ * it, so a later grant costs no round trip. But the page view is fired
27
+ * only AFTER a grant, and that is deliberate rather than cautious: their
28
+ * docs state that with consent false the pixel sends no measurement
29
+ * pings, and they do NOT document whether a call made while consent was
30
+ * false is released on a later grant. TikTok documents exactly that
31
+ * (`holdConsent` queues, `grantConsent` flushes) and our TikTok gate
32
+ * relies on it. Nothing here relies on behaviour OpenAI has not written
33
+ * down: an unconsented page view is not sent, and not counted on later.
34
+ *
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.
42
+ *
43
+ * The page view is ours to fire at all: their pixel sends nothing at init,
44
+ * unlike Meta's and TikTok's base code.
45
+ *
46
+ * Their SDK also captures `oppref` off the landing URL into a first-party
47
+ * `__oppref` cookie by itself. Our attribution module captures the same
48
+ * parameter into `_1c_oppref` independently, so the server-side
49
+ * Conversions API event can carry the click even on a visit where this
50
+ * script never loaded at all.
51
+ *
52
+ * Renders nothing when `pixelId` is falsy, so a layout can mount it
53
+ * unconditionally.
54
+ */
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
+ }) {
63
+ if (!pixelId) return null
64
+
65
+ const id = jsStringLiteral(pixelId)
66
+
67
+ const initSnippet = `
68
+ (function (w, d, s, u) {
69
+ if (w.oaiq) return;
70
+ var q = function () { q.q.push(arguments); };
71
+ q.q = [];
72
+ w.oaiq = q;
73
+ var js = d.createElement(s);
74
+ js.async = true;
75
+ js.src = u;
76
+ var f = d.getElementsByTagName(s)[0];
77
+ f.parentNode.insertBefore(js, f);
78
+ })(window, document, "script", "https://bzrcdn.openai.com/sdk/oaiq.min.js");
79
+
80
+ oaiq("consent", false);
81
+ oaiq("init", { pixelId: ${id} });
82
+
83
+ (function (d) {
84
+ ${adsConsentSnippet(consentRequired, "d")}
85
+ if (ads) {
86
+ oaiq("consent", true);
87
+ oaiq("measure", "page_viewed", { type: "contents" });
88
+ }
89
+ })(document);
90
+ `.trim()
91
+
92
+ return (
93
+ <Script
94
+ id="chatgpt-pixel-init"
95
+ strategy="afterInteractive"
96
+ dangerouslySetInnerHTML={{ __html: initSnippet }}
97
+ />
98
+ )
99
+ }