@cartbase/storefront 0.20.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.
- package/LICENSE +21 -21
- package/package.json +258 -258
- package/src/api/auth.ts +108 -108
- package/src/api/carts.ts +523 -523
- package/src/api/categories.ts +184 -184
- package/src/api/checkout.ts +526 -526
- package/src/api/collections.ts +130 -130
- package/src/api/consent.ts +75 -75
- package/src/api/content.ts +125 -125
- package/src/api/customers.ts +303 -303
- package/src/api/gift-cards.ts +112 -112
- package/src/api/http.ts +180 -180
- package/src/api/index.ts +30 -30
- package/src/api/menus.ts +77 -77
- package/src/api/metaobjects.ts +136 -136
- package/src/api/orders.ts +290 -290
- package/src/api/redirects.ts +37 -37
- package/src/api/regions.ts +200 -200
- package/src/api/search.ts +163 -163
- package/src/api/store.ts +35 -35
- package/src/api/types.ts +91 -91
- package/src/cart-drawer/context.tsx +778 -778
- package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
- package/src/cart-drawer/gift-wrap.tsx +82 -82
- package/src/cart-drawer/item/index.tsx +162 -162
- package/src/cart-drawer/item/upsell.tsx +110 -110
- package/src/cart-drawer/labels.ts +123 -123
- package/src/cart-drawer/mutation-queue.ts +78 -78
- package/src/cart-drawer/notes.tsx +131 -131
- package/src/cart-drawer/sticky-footer.tsx +73 -73
- package/src/cart-drawer/summary-breakdown.tsx +197 -197
- package/src/checkout/address-error-copy.ts +117 -117
- package/src/checkout/boxnow-locker-selector.tsx +410 -410
- package/src/checkout/compare-addresses.ts +40 -40
- package/src/checkout/discount-section.tsx +218 -218
- package/src/checkout/error-copy-codes.ts +63 -63
- package/src/checkout/geocode.ts +154 -154
- package/src/checkout/gift-card-section.tsx +224 -224
- package/src/checkout/index.ts +72 -72
- package/src/checkout/labels.ts +495 -495
- package/src/checkout/payment-button.tsx +372 -372
- package/src/checkout/payment-error-copy.ts +154 -154
- package/src/checkout/promotion-error-copy.ts +91 -91
- package/src/common/country-flag.tsx +52 -52
- package/src/common/country-select.tsx +11 -11
- package/src/common/index.ts +20 -20
- package/src/common/market-select.tsx +57 -57
- package/src/index.ts +12 -12
- package/src/lib/country-name.ts +59 -59
- package/src/lib/get-product-price.ts +133 -133
- package/src/lib/media-image.tsx +39 -39
- package/src/lib/payment-constants.ts +53 -53
- package/src/lib/platform.ts +13 -13
- package/src/lib/price.tsx +39 -39
- package/src/lib/store-api-error.ts +36 -36
- package/src/lib/variant-caption.ts +32 -32
- package/src/locales/context.ts +37 -37
- package/src/locales/en.ts +26 -26
- package/src/locales/index.ts +19 -19
- package/src/locales/provider.tsx +59 -59
- package/src/locales/types.ts +77 -77
- package/src/order/index.ts +62 -62
- package/src/order/labels.ts +79 -79
- package/src/order/order-totals.tsx +250 -250
- package/src/primitives/select-field.tsx +93 -93
- package/src/products/image-gallery.tsx +43 -43
- package/src/products/option-select.tsx +67 -67
- package/src/products/product-info.tsx +5 -5
- package/src/products/product-price.tsx +69 -69
- package/src/products/product-promises.tsx +61 -61
- package/src/products/product-specs.tsx +65 -65
- package/src/products/product-tabs.tsx +123 -123
- package/src/products/purchase-options.tsx +130 -130
- package/src/products/sets.ts +110 -110
- package/src/products/variant-matching.ts +71 -71
- package/src/products/variant-url.ts +74 -74
- package/src/reviews-ui/lightbox-state.ts +46 -46
- package/src/reviews-ui/review-header.tsx +166 -166
- package/src/reviews-ui/review-lightbox.tsx +271 -271
- package/src/reviews-ui/review-list.tsx +193 -193
- package/src/reviews-ui/review-widget.tsx +219 -218
- package/src/reviews-ui/widget-options.ts +55 -55
- package/src/store/category-template.tsx +136 -136
- package/src/store/index.ts +40 -40
- package/src/tracking/chatgpt-pixel.tsx +99 -99
- package/src/tracking/consent-init.tsx +62 -62
- package/src/tracking/events.ts +348 -348
- package/src/tracking/ga4.tsx +93 -93
- package/src/tracking/google-ads.ts +84 -84
- package/src/tracking/gtm.tsx +60 -60
- package/src/tracking/inline-script.ts +49 -49
- package/src/tracking/oaiq.ts +206 -206
- package/src/tracking/tiktok-pixel.tsx +91 -91
- package/src/tracking/track-init.tsx +56 -56
- package/src/tracking/track-order-purchase.tsx +122 -122
- package/src/tracking/ttq.ts +180 -180
- package/src/tracking/use-tracking-config.ts +54 -54
- 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 }
|
package/src/store/index.ts
CHANGED
|
@@ -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
|
+
}
|