@cartbase/storefront 0.19.0 → 0.20.1

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 (105) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +258 -258
  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 +180 -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/reviews.ts +22 -1
  20. package/src/api/search.ts +163 -163
  21. package/src/api/store.ts +35 -35
  22. package/src/api/types.ts +91 -91
  23. package/src/cart-drawer/context.tsx +778 -778
  24. package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
  25. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
  26. package/src/cart-drawer/gift-wrap.tsx +82 -82
  27. package/src/cart-drawer/item/index.tsx +162 -162
  28. package/src/cart-drawer/item/upsell.tsx +110 -110
  29. package/src/cart-drawer/labels.ts +123 -123
  30. package/src/cart-drawer/mutation-queue.ts +78 -78
  31. package/src/cart-drawer/notes.tsx +131 -131
  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/index.ts +20 -20
  49. package/src/common/market-select.tsx +57 -57
  50. package/src/index.ts +12 -12
  51. package/src/lib/country-name.ts +59 -59
  52. package/src/lib/get-product-price.ts +133 -133
  53. package/src/lib/media-image.tsx +39 -39
  54. package/src/lib/payment-constants.ts +53 -53
  55. package/src/lib/platform.ts +13 -13
  56. package/src/lib/price.tsx +39 -39
  57. package/src/lib/store-api-error.ts +36 -36
  58. package/src/lib/variant-caption.ts +32 -32
  59. package/src/locales/bg.ts +3 -2
  60. package/src/locales/context.ts +37 -37
  61. package/src/locales/en.ts +26 -26
  62. package/src/locales/es.ts +3 -2
  63. package/src/locales/index.ts +19 -19
  64. package/src/locales/provider.tsx +59 -59
  65. package/src/locales/types.ts +77 -77
  66. package/src/order/index.ts +62 -62
  67. package/src/order/labels.ts +79 -79
  68. package/src/order/order-totals.tsx +250 -250
  69. package/src/primitives/select-field.tsx +93 -93
  70. package/src/products/image-gallery.tsx +43 -43
  71. package/src/products/option-select.tsx +67 -67
  72. package/src/products/product-info.tsx +5 -5
  73. package/src/products/product-price.tsx +69 -69
  74. package/src/products/product-promises.tsx +61 -61
  75. package/src/products/product-specs.tsx +65 -65
  76. package/src/products/product-tabs.tsx +123 -123
  77. package/src/products/purchase-options.tsx +130 -130
  78. package/src/products/sets.ts +110 -110
  79. package/src/products/variant-matching.ts +71 -71
  80. package/src/products/variant-url.ts +74 -74
  81. package/src/reviews-ui/index.ts +12 -0
  82. package/src/reviews-ui/labels.ts +5 -2
  83. package/src/reviews-ui/lightbox-state.ts +46 -46
  84. package/src/reviews-ui/review-header.tsx +166 -0
  85. package/src/reviews-ui/review-lightbox.tsx +271 -271
  86. package/src/reviews-ui/review-list.tsx +193 -191
  87. package/src/reviews-ui/review-widget.tsx +63 -71
  88. package/src/reviews-ui/star-badge.tsx +6 -4
  89. package/src/reviews-ui/widget-options.ts +55 -0
  90. package/src/store/category-template.tsx +136 -136
  91. package/src/store/index.ts +40 -40
  92. package/src/tracking/chatgpt-pixel.tsx +99 -99
  93. package/src/tracking/consent-init.tsx +62 -62
  94. package/src/tracking/events.ts +348 -348
  95. package/src/tracking/ga4.tsx +93 -93
  96. package/src/tracking/google-ads.ts +84 -84
  97. package/src/tracking/gtm.tsx +60 -60
  98. package/src/tracking/inline-script.ts +49 -49
  99. package/src/tracking/oaiq.ts +206 -206
  100. package/src/tracking/tiktok-pixel.tsx +91 -91
  101. package/src/tracking/track-init.tsx +56 -56
  102. package/src/tracking/track-order-purchase.tsx +122 -122
  103. package/src/tracking/ttq.ts +180 -180
  104. package/src/tracking/use-tracking-config.ts +54 -54
  105. package/theme/index.css +25 -25
@@ -11,9 +11,11 @@ import { reviewCountLabel } from "./helpers"
11
11
  * arrives via props (`api/reviews.getAggregate` / `getWidget().aggregate`).
12
12
  */
13
13
 
14
+ const STAR_SIZE = { sm: "size-3.5", md: "size-4.5", lg: "size-5" } as const
15
+
14
16
  /**
15
17
  * Five-star row, filled to `Math.round(rating)` in the theme's `rating`
16
- * colour. `lg` is the lightbox's row.
18
+ * colour. `md` is a review card's row, `lg` the lightbox's and the header's.
17
19
  */
18
20
  export function StarRow({
19
21
  rating,
@@ -21,21 +23,21 @@ export function StarRow({
21
23
  className,
22
24
  }: {
23
25
  rating: number
24
- size?: "sm" | "lg"
26
+ size?: keyof typeof STAR_SIZE
25
27
  className?: string
26
28
  }) {
27
29
  return (
28
30
  <span
29
31
  role="img"
30
32
  aria-label={`${Math.round(rating)} / 5`}
31
- className={cn("inline-flex items-center", size === "lg" ? "gap-px" : "gap-0.5", className)}
33
+ className={cn("inline-flex items-center", size === "sm" ? "gap-0.5" : "gap-px", className)}
32
34
  >
33
35
  {Array.from({ length: 5 }).map((_, i) => (
34
36
  <Star
35
37
  key={i}
36
38
  aria-hidden
37
39
  className={cn(
38
- size === "lg" ? "size-5" : "size-3.5",
40
+ STAR_SIZE[size],
39
41
  i < Math.round(rating)
40
42
  ? "fill-rating text-rating"
41
43
  : "fill-muted text-muted-foreground"
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The reviews widget's display options with every default filled, from the
3
+ * payload the platform serves (`getWidget().options`) or from nothing. A
4
+ * platform older than a field leaves it out; the widget then shows what
5
+ * Loox shows by default. Pure. Unit-tested in
6
+ * tests/unit/storefront-review-lightbox.test.ts.
7
+ */
8
+ import type {
9
+ ReviewWidgetHeader,
10
+ ReviewWidgetHeaderContent,
11
+ ReviewWidgetPayload,
12
+ } from "../api/reviews"
13
+
14
+ export interface ReviewWidgetOptions {
15
+ layout: "masonry" | "list"
16
+ pageSize: number
17
+ header: ReviewWidgetHeader
18
+ headerContent: ReviewWidgetHeaderContent
19
+ showDistribution: boolean
20
+ showSort: boolean
21
+ showDate: boolean
22
+ }
23
+
24
+ export const DEFAULT_REVIEW_WIDGET_OPTIONS: ReviewWidgetOptions = {
25
+ layout: "masonry",
26
+ pageSize: 20,
27
+ header: "minimal",
28
+ headerContent: "count",
29
+ showDistribution: true,
30
+ showSort: true,
31
+ showDate: true,
32
+ }
33
+
34
+ export function reviewWidgetOptions(
35
+ options: Partial<ReviewWidgetPayload["options"]> | null | undefined
36
+ ): ReviewWidgetOptions {
37
+ const d = DEFAULT_REVIEW_WIDGET_OPTIONS
38
+ if (!options) return { ...d }
39
+ return {
40
+ layout: options.layout === "list" ? "list" : d.layout,
41
+ pageSize:
42
+ typeof options.page_size === "number" && options.page_size > 0
43
+ ? Math.trunc(options.page_size)
44
+ : d.pageSize,
45
+ header:
46
+ options.header === "compact" || options.header === "expanded" ? options.header : d.header,
47
+ headerContent:
48
+ options.header_content === "average" || options.header_content === "stars"
49
+ ? options.header_content
50
+ : d.headerContent,
51
+ showDistribution: options.show_distribution ?? d.showDistribution,
52
+ showSort: options.show_sort ?? d.showSort,
53
+ showDate: options.show_date ?? d.showDate,
54
+ }
55
+ }
@@ -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"
@@ -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
+ }