@cartbase/storefront 0.8.0 → 0.9.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cartbase/storefront",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Storefront SDK + UI component library for Cartbase stores: typed API client, checkout orchestration, cart drawer, product/catalog components, tracking. Source-shipped TypeScript — add it to transpilePackages.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -49,7 +49,7 @@
49
49
  "./lib/get-percentage-diff": "./src/lib/get-percentage-diff.ts",
50
50
  "./lib/product": "./src/lib/product.ts",
51
51
  "./lib/sort-products": "./src/lib/sort-products.ts",
52
- "./lib/dual-price": "./src/lib/dual-price.tsx",
52
+ "./lib/price": "./src/lib/price.tsx",
53
53
  "./lib/payment-constants": "./src/lib/payment-constants.ts",
54
54
  "./lib/store-api-error": "./src/lib/store-api-error.ts",
55
55
  "./lib/hooks/use-intersection": "./src/lib/hooks/use-intersection.ts",
@@ -4,7 +4,7 @@ import Image from "next/image"
4
4
  import Link from "next/link"
5
5
  import { useRef, useState } from "react"
6
6
 
7
- import { DualPrice } from "../lib/dual-price"
7
+ import { Price } from "../lib/price"
8
8
  import { cn } from "../lib/utils"
9
9
  import { useCartDrawer } from "./context"
10
10
 
@@ -178,11 +178,10 @@ export function CartCrossSellCarousel({
178
178
  >
179
179
  {product.title}
180
180
  </Link>
181
- <DualPrice
181
+ <Price
182
182
  amount={product.price}
183
183
  currencyCode={product.currencyCode}
184
184
  className="text-[13px] font-bold text-foreground block mt-0.5"
185
- bgnClassName="text-muted-foreground text-[10px] ml-1"
186
185
  />
187
186
  </div>
188
187
 
@@ -1,83 +1,82 @@
1
- "use client"
2
-
3
- import { useState } from "react"
4
-
5
- import { DualPrice } from "../lib/dual-price"
6
- import { cn } from "../lib/utils"
7
- import { useCartDrawer } from "./context"
8
-
9
- /**
10
- * CartGiftWrap — inline toggle row for adding gift wrapping to the cart.
11
- *
12
- * Ported from `@1click/ui/src/cart-drawer/gift-wrap.tsx` (v2.3.1);
13
- * originally extracted from mindpages-storefront
14
- * src/modules/cart-drawer/cart-gift-wrap.tsx. `price` is EUR major units.
15
- * The store owns the effect: wire `onToggle` to add/remove the store's
16
- * gift-wrap variant via the provider's `addItem` / `removeItem`.
17
- */
18
-
19
- type CartGiftWrapProps = {
20
- price: number
21
- currencyCode: string
22
- enabled?: boolean
23
- onToggle?: (enabled: boolean) => void
24
- }
25
-
26
- export function CartGiftWrap({
27
- price,
28
- currencyCode,
29
- enabled = false,
30
- onToggle,
31
- }: CartGiftWrapProps) {
32
- const { labels } = useCartDrawer()
33
- const [checked, setChecked] = useState(enabled)
34
-
35
- const handleToggle = () => {
36
- const next = !checked
37
- setChecked(next)
38
- onToggle?.(next)
39
- }
40
-
41
- return (
42
- <div className="px-5 sm:px-6 py-2">
43
- <button
44
- type="button"
45
- onClick={handleToggle}
46
- className="flex items-center gap-3 w-full text-left py-2 min-h-[44px] group"
47
- >
48
- <div
49
- className={cn(
50
- "w-5 h-5 rounded border-[1.5px] flex items-center justify-center transition-all flex-shrink-0",
51
- checked
52
- ? "bg-foreground border-text-base"
53
- : "border-border group-hover:border-text-muted group-active:border-text-muted"
54
- )}
55
- >
56
- {checked && (
57
- <svg
58
- width="10"
59
- height="10"
60
- viewBox="0 0 10 10"
61
- fill="none"
62
- stroke="white"
63
- strokeWidth="2"
64
- strokeLinecap="round"
65
- strokeLinejoin="round"
66
- >
67
- <path d="M2 5l2 2 4-4" />
68
- </svg>
69
- )}
70
- </div>
71
- <span className="text-[13px] text-foreground flex-1">
72
- {labels.addGiftWrap}
73
- </span>
74
- <DualPrice
75
- amount={price}
76
- currencyCode={currencyCode}
77
- className="text-[13px] text-muted-foreground font-medium"
78
- bgnClassName="text-muted-foreground text-[10px] ml-1"
79
- />
80
- </button>
81
- </div>
82
- )
83
- }
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+
5
+ import { Price } from "../lib/price"
6
+ import { cn } from "../lib/utils"
7
+ import { useCartDrawer } from "./context"
8
+
9
+ /**
10
+ * CartGiftWrap — inline toggle row for adding gift wrapping to the cart.
11
+ *
12
+ * Ported from `@1click/ui/src/cart-drawer/gift-wrap.tsx` (v2.3.1);
13
+ * originally extracted from mindpages-storefront
14
+ * src/modules/cart-drawer/cart-gift-wrap.tsx. `price` is EUR major units.
15
+ * The store owns the effect: wire `onToggle` to add/remove the store's
16
+ * gift-wrap variant via the provider's `addItem` / `removeItem`.
17
+ */
18
+
19
+ type CartGiftWrapProps = {
20
+ price: number
21
+ currencyCode: string
22
+ enabled?: boolean
23
+ onToggle?: (enabled: boolean) => void
24
+ }
25
+
26
+ export function CartGiftWrap({
27
+ price,
28
+ currencyCode,
29
+ enabled = false,
30
+ onToggle,
31
+ }: CartGiftWrapProps) {
32
+ const { labels } = useCartDrawer()
33
+ const [checked, setChecked] = useState(enabled)
34
+
35
+ const handleToggle = () => {
36
+ const next = !checked
37
+ setChecked(next)
38
+ onToggle?.(next)
39
+ }
40
+
41
+ return (
42
+ <div className="px-5 sm:px-6 py-2">
43
+ <button
44
+ type="button"
45
+ onClick={handleToggle}
46
+ className="flex items-center gap-3 w-full text-left py-2 min-h-[44px] group"
47
+ >
48
+ <div
49
+ className={cn(
50
+ "w-5 h-5 rounded border-[1.5px] flex items-center justify-center transition-all flex-shrink-0",
51
+ checked
52
+ ? "bg-foreground border-text-base"
53
+ : "border-border group-hover:border-text-muted group-active:border-text-muted"
54
+ )}
55
+ >
56
+ {checked && (
57
+ <svg
58
+ width="10"
59
+ height="10"
60
+ viewBox="0 0 10 10"
61
+ fill="none"
62
+ stroke="white"
63
+ strokeWidth="2"
64
+ strokeLinecap="round"
65
+ strokeLinejoin="round"
66
+ >
67
+ <path d="M2 5l2 2 4-4" />
68
+ </svg>
69
+ )}
70
+ </div>
71
+ <span className="text-[13px] text-foreground flex-1">
72
+ {labels.addGiftWrap}
73
+ </span>
74
+ <Price
75
+ amount={price}
76
+ currencyCode={currencyCode}
77
+ className="text-[13px] text-muted-foreground font-medium"
78
+ />
79
+ </button>
80
+ </div>
81
+ )
82
+ }
@@ -5,7 +5,7 @@ import Link from "next/link"
5
5
  import { type ReactNode } from "react"
6
6
 
7
7
  import type { CartLineItem } from "../../api/carts"
8
- import { DualPrice } from "../../lib/dual-price"
8
+ import { Price } from "../../lib/price"
9
9
  import { cn } from "../../lib/utils"
10
10
  import { useCartDrawer } from "../context"
11
11
  import { CartItemQuantity } from "./quantity"
@@ -13,7 +13,7 @@ import { CartItemVariant } from "./variant"
13
13
 
14
14
  /**
15
15
  * CartItem — a single cart line row: thumbnail, title, variant, quantity
16
- * stepper, price (with dual-currency + optional strikethrough), remove button.
16
+ * stepper, price (with optional strikethrough), remove button.
17
17
  *
18
18
  * Ported from `@1click/ui/src/cart-drawer/item/index.tsx` (v2.3.1). Cartbase
19
19
  * data seam: `item` is the SDK's decorated `CartLineItem`
@@ -111,22 +111,20 @@ export function CartItem({ item, currencyCode, children }: CartItemProps) {
111
111
  <div className="text-right flex-shrink-0">
112
112
  {hasDiscount && (
113
113
  <span className="text-xs text-muted-foreground line-through block leading-none mb-0.5">
114
- <DualPrice
114
+ <Price
115
115
  amount={originalTotal}
116
116
  currencyCode={currencyCode}
117
117
  className="text-xs text-muted-foreground"
118
- bgnClassName="hidden"
119
118
  />
120
119
  </span>
121
120
  )}
122
- <DualPrice
121
+ <Price
123
122
  amount={total}
124
123
  currencyCode={currencyCode}
125
124
  className={cn(
126
125
  "text-sm font-bold",
127
126
  hasDiscount ? "text-success" : "text-foreground"
128
127
  )}
129
- bgnClassName="text-muted-foreground text-[10px] ml-1"
130
128
  />
131
129
  </div>
132
130
  </div>
@@ -1,73 +1,73 @@
1
- "use client"
2
-
3
- import Link from "next/link"
4
-
5
- import { DualPrice } from "../lib/dual-price"
6
- import { productTotal } from "../lib/cart-helpers"
7
- import { useCartDrawer } from "./context"
8
-
9
- /**
10
- * CartStickyFooter — bottom-of-drawer subtotal + checkout CTA with
11
- * iPhone home-indicator safe-area padding.
12
- *
13
- * Ported from `@1click/ui/src/cart-drawer/sticky-footer.tsx` (v2.3.1);
14
- * originally extracted from mindpages-storefront
15
- * src/modules/cart-drawer/cart-sticky-footer.tsx. Amounts EUR major units.
16
- */
17
-
18
- export function CartStickyFooter() {
19
- const { cart, close, labels, hrefs } = useCartDrawer()
20
- if (!cart || !cart.items?.length) return null
21
-
22
- // Drawer is pre-checkout context — never read `cart.total` here.
23
- // `cart.total` includes shipping/tax/COD fee, all of which are
24
- // checkout-context state that must not leak into the shopping
25
- // drawer. Sum of product line totals only.
26
- const total = productTotal(cart.items)
27
- const currencyCode = cart.currency_code
28
-
29
- return (
30
- <div
31
- className="flex-shrink-0 bg-card px-5 sm:px-6 pt-4 sm:pt-5 relative z-10"
32
- style={{
33
- boxShadow: "0 -4px 12px rgba(0,0,0,0.04)",
34
- paddingBottom: "max(1.25rem, env(safe-area-inset-bottom, 1.25rem))",
35
- }}
36
- >
37
- <div className="flex items-baseline justify-between mb-1">
38
- <span className="text-[15px] font-bold text-foreground">
39
- {labels.subtotal}
40
- </span>
41
- <DualPrice
42
- amount={total}
43
- currencyCode={currencyCode}
44
- className="text-xl font-bold text-foreground tracking-tight"
45
- />
46
- </div>
47
- <p className="text-[11px] text-muted-foreground mb-3.5 sm:mb-4">
48
- {labels.taxAndShipping}
49
- </p>
50
- <Link href={hrefs.checkout} onClick={close}>
51
- <button
52
- type="button"
53
- className="w-full h-[52px] bg-foreground hover:bg-foreground/90 active:bg-foreground/95 text-card text-[15px] sm:text-sm font-semibold rounded-[2px] flex items-center justify-center gap-2.5 transition-colors active:scale-[0.98]"
54
- >
55
- <svg
56
- width="16"
57
- height="16"
58
- viewBox="0 0 16 16"
59
- fill="none"
60
- stroke="currentColor"
61
- strokeWidth="1.5"
62
- strokeLinecap="round"
63
- strokeLinejoin="round"
64
- >
65
- <rect x="3" y="6" width="10" height="8" rx="1.5" />
66
- <path d="M5.5 6V4.5a2.5 2.5 0 015 0V6" />
67
- </svg>
68
- {labels.secureCheckout}
69
- </button>
70
- </Link>
71
- </div>
72
- )
73
- }
1
+ "use client"
2
+
3
+ import Link from "next/link"
4
+
5
+ import { Price } from "../lib/price"
6
+ import { productTotal } from "../lib/cart-helpers"
7
+ import { useCartDrawer } from "./context"
8
+
9
+ /**
10
+ * CartStickyFooter — bottom-of-drawer subtotal + checkout CTA with
11
+ * iPhone home-indicator safe-area padding.
12
+ *
13
+ * Ported from `@1click/ui/src/cart-drawer/sticky-footer.tsx` (v2.3.1);
14
+ * originally extracted from mindpages-storefront
15
+ * src/modules/cart-drawer/cart-sticky-footer.tsx. Amounts EUR major units.
16
+ */
17
+
18
+ export function CartStickyFooter() {
19
+ const { cart, close, labels, hrefs } = useCartDrawer()
20
+ if (!cart || !cart.items?.length) return null
21
+
22
+ // Drawer is pre-checkout context — never read `cart.total` here.
23
+ // `cart.total` includes shipping/tax/COD fee, all of which are
24
+ // checkout-context state that must not leak into the shopping
25
+ // drawer. Sum of product line totals only.
26
+ const total = productTotal(cart.items)
27
+ const currencyCode = cart.currency_code
28
+
29
+ return (
30
+ <div
31
+ className="flex-shrink-0 bg-card px-5 sm:px-6 pt-4 sm:pt-5 relative z-10"
32
+ style={{
33
+ boxShadow: "0 -4px 12px rgba(0,0,0,0.04)",
34
+ paddingBottom: "max(1.25rem, env(safe-area-inset-bottom, 1.25rem))",
35
+ }}
36
+ >
37
+ <div className="flex items-baseline justify-between mb-1">
38
+ <span className="text-[15px] font-bold text-foreground">
39
+ {labels.subtotal}
40
+ </span>
41
+ <Price
42
+ amount={total}
43
+ currencyCode={currencyCode}
44
+ className="text-xl font-bold text-foreground tracking-tight"
45
+ />
46
+ </div>
47
+ <p className="text-[11px] text-muted-foreground mb-3.5 sm:mb-4">
48
+ {labels.taxAndShipping}
49
+ </p>
50
+ <Link href={hrefs.checkout} onClick={close}>
51
+ <button
52
+ type="button"
53
+ className="w-full h-[52px] bg-foreground hover:bg-foreground/90 active:bg-foreground/95 text-card text-[15px] sm:text-sm font-semibold rounded-[2px] flex items-center justify-center gap-2.5 transition-colors active:scale-[0.98]"
54
+ >
55
+ <svg
56
+ width="16"
57
+ height="16"
58
+ viewBox="0 0 16 16"
59
+ fill="none"
60
+ stroke="currentColor"
61
+ strokeWidth="1.5"
62
+ strokeLinecap="round"
63
+ strokeLinejoin="round"
64
+ >
65
+ <rect x="3" y="6" width="10" height="8" rx="1.5" />
66
+ <path d="M5.5 6V4.5a2.5 2.5 0 015 0V6" />
67
+ </svg>
68
+ {labels.secureCheckout}
69
+ </button>
70
+ </Link>
71
+ </div>
72
+ )
73
+ }
@@ -5,7 +5,7 @@ import { useState } from "react"
5
5
  import type { StorefrontClient } from "../api/http"
6
6
  import type { Cart } from "../api/carts"
7
7
  import { applyGiftCard, removeGiftCard } from "../api/gift-cards"
8
- import { DualPrice } from "../lib/dual-price"
8
+ import { Price } from "../lib/price"
9
9
  import { cn } from "../lib/utils"
10
10
  import { useCheckoutLabels } from "./context"
11
11
  import { translateGiftCardError } from "./payment-error-copy"
@@ -201,7 +201,7 @@ export function GiftCardSection({
201
201
  remainder didn't shrink. */}
202
202
  <span className="text-sm font-medium text-success">
203
203
  -
204
- <DualPrice
204
+ <Price
205
205
  amount={gc.amount}
206
206
  currencyCode={cart.currency_code}
207
207
  />
@@ -4,14 +4,14 @@ import { useState } from "react"
4
4
 
5
5
  import type { StorefrontClient } from "../api/http"
6
6
  import { updateLineItem, type Cart, type CartLineItem } from "../api/carts"
7
- import { DualPrice } from "../lib/dual-price"
7
+ import { Price } from "../lib/price"
8
8
  import { cn } from "../lib/utils"
9
9
  import { useCheckoutLabels } from "./context"
10
10
 
11
11
  /**
12
12
  * LineItemCard — a single cart line shown inside the checkout order
13
13
  * summary card. Includes thumbnail, title, variant, quantity select,
14
- * and dual-currency price.
14
+ * and price.
15
15
  *
16
16
  * Ported from `@1click/ui/src/checkout/line-item-card.tsx` (v2.3.1) with
17
17
  * the Cartbase data seam: the cookie-bound `updateLineItem` server action →
@@ -141,7 +141,7 @@ export function LineItemCard({
141
141
  </div>
142
142
 
143
143
  <div className="flex flex-col items-end justify-center flex-shrink-0">
144
- <DualPrice
144
+ <Price
145
145
  amount={total}
146
146
  currencyCode={currencyCode}
147
147
  className="text-sm font-bold text-foreground"
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from "react"
4
4
 
5
5
  import type { StorefrontClient } from "../api/http"
6
6
  import { updateLineItem, type Cart, type CartLineItem } from "../api/carts"
7
- import { DualPrice } from "../lib/dual-price"
7
+ import { Price } from "../lib/price"
8
8
  import { isProductLine } from "../lib/cart-helpers"
9
9
  import { cn } from "../lib/utils"
10
10
  import { useCheckoutLabels } from "./context"
@@ -170,7 +170,7 @@ export function CheckoutLineItem({
170
170
  <div className="hidden sm:flex flex-1 min-w-0 flex-col gap-2.5">
171
171
  <div className="flex items-baseline justify-between gap-3">
172
172
  {titleBlock}
173
- <DualPrice
173
+ <Price
174
174
  amount={item.total ?? 0}
175
175
  currencyCode={currencyCode}
176
176
  className="text-sm font-bold text-foreground text-right flex-shrink-0"
@@ -185,7 +185,7 @@ export function CheckoutLineItem({
185
185
  {titleBlock}
186
186
  <div className="flex items-center justify-between gap-3">
187
187
  {qtyPill}
188
- <DualPrice
188
+ <Price
189
189
  amount={item.total ?? 0}
190
190
  currencyCode={currencyCode}
191
191
  className="text-sm font-bold text-foreground text-right"
@@ -376,7 +376,7 @@ export function OrderSummary({
376
376
  <div className="px-5 sm:px-6 space-y-3">
377
377
  <div className="flex justify-between text-sm">
378
378
  <span className="text-muted-foreground">{labels.subtotal}</span>
379
- <DualPrice
379
+ <Price
380
380
  amount={productSubtotal}
381
381
  currencyCode={cart.currency_code}
382
382
  className="font-medium text-foreground"
@@ -391,7 +391,7 @@ export function OrderSummary({
391
391
  {labels.shippingFree}
392
392
  </span>
393
393
  ) : (
394
- <DualPrice
394
+ <Price
395
395
  amount={shippingCost ?? 0}
396
396
  currencyCode={cart.currency_code}
397
397
  className="font-medium text-foreground"
@@ -409,7 +409,7 @@ export function OrderSummary({
409
409
  <span className="text-muted-foreground">
410
410
  {methodFeeLabel ?? cart.payment_method_fee_label ?? labels.paymentMethodFee}
411
411
  </span>
412
- <DualPrice
412
+ <Price
413
413
  amount={methodFeeAmount}
414
414
  currencyCode={cart.currency_code}
415
415
  className="font-medium text-foreground"
@@ -422,7 +422,7 @@ export function OrderSummary({
422
422
  <span>{labels.discount}</span>
423
423
  <span className="font-medium">
424
424
  -
425
- <DualPrice
425
+ <Price
426
426
  amount={cart.discount_total}
427
427
  currencyCode={cart.currency_code}
428
428
  />
@@ -450,7 +450,7 @@ export function OrderSummary({
450
450
  {labels.taxTooltip}
451
451
  </span>
452
452
  </span>
453
- <DualPrice
453
+ <Price
454
454
  amount={cart.tax_total ?? 0}
455
455
  currencyCode={cart.currency_code}
456
456
  className="font-medium text-foreground"
@@ -464,11 +464,10 @@ export function OrderSummary({
464
464
  {labels.total}
465
465
  </span>
466
466
  <div className="text-right">
467
- <DualPrice
467
+ <Price
468
468
  amount={displayTotal ?? 0}
469
469
  currencyCode={cart.currency_code}
470
470
  className="text-2xl font-bold text-foreground tracking-tight"
471
- bgnClassName="ml-2 text-sm text-muted-foreground/70 font-normal"
472
471
  />
473
472
  </div>
474
473
  </div>
@@ -482,7 +481,7 @@ export function OrderSummary({
482
481
  <span>{labels.giftCardApplied}</span>
483
482
  <span className="font-medium">
484
483
  -
485
- <DualPrice
484
+ <Price
486
485
  amount={giftCardTotal}
487
486
  currencyCode={cart.currency_code}
488
487
  />
@@ -492,7 +491,7 @@ export function OrderSummary({
492
491
  <span className="text-muted-foreground">
493
492
  {labels.giftCardRemaining}
494
493
  </span>
495
- <DualPrice
494
+ <Price
496
495
  amount={giftCardRemainder}
497
496
  currencyCode={cart.currency_code}
498
497
  className="font-semibold text-foreground"
@@ -3,7 +3,7 @@
3
3
  import { useElements, useStripe } from "@stripe/react-stripe-js"
4
4
  import { useContext, useEffect, useRef, useState } from "react"
5
5
 
6
- import { DualPrice } from "../lib/dual-price"
6
+ import { Price } from "../lib/price"
7
7
  import { cn } from "../lib/utils"
8
8
  import { translatePaymentError } from "./payment-error-copy"
9
9
  import { useCheckoutLabels } from "./context"
@@ -153,11 +153,10 @@ function OrderButton({
153
153
  {total !== undefined && currencyCode && (
154
154
  <span className="inline-flex items-center gap-1">
155
155
  <span aria-hidden="true"> · </span>
156
- <DualPrice
156
+ <Price
157
157
  amount={total}
158
158
  currencyCode={currencyCode}
159
159
  className="font-semibold"
160
- bgnClassName="text-card/70 text-[11px] ml-1"
161
160
  />
162
161
  </span>
163
162
  )}
@@ -3,7 +3,7 @@
3
3
  import type { StorefrontClient } from "../api/http"
4
4
  import type { StoreShippingOption } from "../api/checkout"
5
5
  import type { BoxNowLocker } from "../api/integrations"
6
- import { DualPrice } from "../lib/dual-price"
6
+ import { Price } from "../lib/price"
7
7
  import { cn } from "../lib/utils"
8
8
  import { useCheckoutLabels } from "./context"
9
9
  import {
@@ -273,7 +273,7 @@ export function CheckoutShippingMethodList({
273
273
  isFree ? (
274
274
  labels.shippingFree
275
275
  ) : (
276
- <DualPrice amount={price} currencyCode={currencyCode} />
276
+ <Price amount={price} currencyCode={currencyCode} />
277
277
  )
278
278
  ) : isLoadingPrices ? (
279
279
  <svg
package/src/lib/money.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Money utilities — pure, side-effect-free currency formatting helpers.
3
3
  *
4
4
  * Extracted from mindpages-storefront src/lib/util/money.ts + isEmpty.ts.
5
- * Used by DualPrice and any other price rendering in the library.
5
+ * Used by Price and any other price rendering in the library.
6
6
  */
7
7
 
8
8
  function isEmpty(value: unknown): boolean {
@@ -0,0 +1,39 @@
1
+ import * as React from "react"
2
+
3
+ import { convertToLocale } from "./money"
4
+
5
+ /**
6
+ * Price — render an amount in its own currency, and nothing else.
7
+ *
8
+ * Replaces `DualPrice` (deleted 2026-09-13, Alexander: "clean and clear
9
+ * the dual pricing"). That component appended a grey BGN leg to every EUR
10
+ * price for Bulgaria's euro-changeover disclosure rule, with the statutory
11
+ * 1.95583 rate baked in. Cartbase prices in EUR (see the EUR-only law) and
12
+ * the second currency belongs to a store's own locale work if it ever
13
+ * wants one, not to every price in the library.
14
+ *
15
+ * Stateless and side-effect-free, so it renders in server and client
16
+ * components alike, and it is deliberately NOT a client component: the old
17
+ * one carried "use client" for no reason, which pulled every price
18
+ * rendering into the client bundle.
19
+ *
20
+ * @example
21
+ * <Price amount={19.99} currencyCode="eur" /> // "€19.99"
22
+ */
23
+
24
+ export type PriceProps = {
25
+ /** Amount in major units of the currency (19.99 for €19.99). */
26
+ amount: number
27
+ /** ISO currency code, case-insensitive. */
28
+ currencyCode: string
29
+ /** Class for the span. */
30
+ className?: string
31
+ }
32
+
33
+ export function Price({ amount, currencyCode, className }: PriceProps): React.ReactElement {
34
+ return (
35
+ <span className={className}>
36
+ {convertToLocale({ amount, currency_code: currencyCode })}
37
+ </span>
38
+ )
39
+ }
@@ -1,5 +1,5 @@
1
1
  import { ExternalLink, MapPin, PackageSearch, Truck } from "lucide-react"
2
- import { DualPrice } from "../lib/dual-price"
2
+ import { Price } from "../lib/price"
3
3
  import type { StoreOrderFulfillment } from "../api/orders"
4
4
  import { defaultOrderLabels, type OrderLabels } from "./labels"
5
5
 
@@ -245,7 +245,7 @@ function ShippingMethodRow({
245
245
  {amount === 0 ? (
246
246
  <span className="text-sm font-medium text-success">{labels.free}</span>
247
247
  ) : (
248
- <DualPrice
248
+ <Price
249
249
  amount={amount}
250
250
  currencyCode={currencyCode}
251
251
  className="text-sm font-medium text-foreground"
@@ -1,6 +1,6 @@
1
1
  import Image from "next/image"
2
2
  import { ImageOff } from "lucide-react"
3
- import { DualPrice } from "../lib/dual-price"
3
+ import { Price } from "../lib/price"
4
4
  import type { StoreOrderItem } from "../api/orders"
5
5
  import { defaultOrderLabels, type OrderLabels } from "./labels"
6
6
 
@@ -177,14 +177,14 @@ export function OrderItem({ item, currencyCode, labels }: OrderItemProps) {
177
177
  <div className="text-right flex-shrink-0">
178
178
  {hasDiscount && (
179
179
  <span className="text-xs text-muted-foreground line-through block leading-none mb-0.5">
180
- <DualPrice
180
+ <Price
181
181
  amount={item.originalTotal ?? 0}
182
182
  currencyCode={currencyCode}
183
183
  className="text-xs text-muted-foreground"
184
184
  />
185
185
  </span>
186
186
  )}
187
- <DualPrice
187
+ <Price
188
188
  amount={item.total}
189
189
  currencyCode={currencyCode}
190
190
  className={`text-sm font-bold ${
@@ -1,4 +1,4 @@
1
- import { DualPrice } from "../lib/dual-price"
1
+ import { Price } from "../lib/price"
2
2
  import { findFeeLine, type LineLike } from "../lib/cart-helpers"
3
3
  import { defaultOrderLabels, type OrderLabels } from "./labels"
4
4
 
@@ -217,14 +217,14 @@ export function OrderTotals({
217
217
  ) : row.negative ? (
218
218
  <span className="text-sm text-success">
219
219
  -{" "}
220
- <DualPrice
220
+ <Price
221
221
  amount={row.amount}
222
222
  currencyCode={currencyCode}
223
223
  className="text-sm text-success"
224
224
  />
225
225
  </span>
226
226
  ) : (
227
- <DualPrice
227
+ <Price
228
228
  amount={row.amount}
229
229
  currencyCode={currencyCode}
230
230
  className="text-sm text-foreground"
@@ -237,7 +237,7 @@ export function OrderTotals({
237
237
  <div className="h-px bg-border my-3" />
238
238
  <div className="flex justify-between items-baseline">
239
239
  <span className="text-[15px] font-bold text-foreground">{l.total}</span>
240
- <DualPrice
240
+ <Price
241
241
  amount={total}
242
242
  currencyCode={currencyCode}
243
243
  className="text-xl font-bold text-foreground tracking-tight"
@@ -496,6 +496,16 @@ const TTCLID_COOKIE = "_1c_ttclid"
496
496
  const GCLID_COOKIE = "_1c_gclid"
497
497
  const GBRAID_COOKIE = "_1c_gbraid"
498
498
  const WBRAID_COOKIE = "_1c_wbraid"
499
+ /**
500
+ * OpenAI's click id, from `oppref` on the landing URL.
501
+ *
502
+ * Their own SDK stores the same parameter in a first-party `__oppref`
503
+ * cookie at init, and we capture it here anyway: the Conversions API takes
504
+ * `oppref` on the server event explicitly, and this module runs on the
505
+ * landing page whether or not their script loaded or was consented to. Ours
506
+ * is the one the server reads.
507
+ */
508
+ const OPPREF_COOKIE = "_1c_oppref"
499
509
 
500
510
  /** TikTok's ttclid lifetime follows the CTA window in Attribution
501
511
  * Manager; Google's conversion window tops out at 90 days. One TTL
@@ -509,6 +519,7 @@ const CLICK_ID_CAPTURE: ReadonlyArray<readonly [string, string]> = [
509
519
  [GCLID_COOKIE, "gclid"],
510
520
  [GBRAID_COOKIE, "gbraid"],
511
521
  [WBRAID_COOKIE, "wbraid"],
522
+ [OPPREF_COOKIE, "oppref"],
512
523
  ]
513
524
 
514
525
  /**
@@ -522,6 +533,7 @@ export const CLICK_ID_COOKIES = {
522
533
  gbraid: GBRAID_COOKIE,
523
534
  wbraid: WBRAID_COOKIE,
524
535
  ttp: "_ttp",
536
+ oppref: OPPREF_COOKIE,
525
537
  } as const
526
538
 
527
539
  /** cookie name → order.metadata key (code truth for the server side:
@@ -532,6 +544,7 @@ export const CLICK_ID_METADATA_KEYS: ReadonlyArray<readonly [string, string]> =
532
544
  [CLICK_ID_COOKIES.gclid, "google_gclid"],
533
545
  [CLICK_ID_COOKIES.gbraid, "google_gbraid"],
534
546
  [CLICK_ID_COOKIES.wbraid, "google_wbraid"],
547
+ [CLICK_ID_COOKIES.oppref, "oai_oppref"],
535
548
  ]
536
549
 
537
550
  /**
@@ -0,0 +1,92 @@
1
+ "use client"
2
+
3
+ import Script from "next/script"
4
+
5
+ import { CONSENT_COOKIE } 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 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.
38
+ *
39
+ * The page view is ours to fire at all: their pixel sends nothing at init,
40
+ * unlike Meta's and TikTok's base code.
41
+ *
42
+ * Their SDK also captures `oppref` off the landing URL into a first-party
43
+ * `__oppref` cookie by itself. Our attribution module captures the same
44
+ * parameter into `_1c_oppref` independently, so the server-side
45
+ * Conversions API event can carry the click even on a visit where this
46
+ * script never loaded at all.
47
+ *
48
+ * Renders nothing when `pixelId` is falsy, so a layout can mount it
49
+ * unconditionally.
50
+ */
51
+ export function ChatGptPixel({ pixelId }: { pixelId?: string }) {
52
+ if (!pixelId) return null
53
+
54
+ const id = jsStringLiteral(pixelId)
55
+
56
+ const initSnippet = `
57
+ (function (w, d, s, u) {
58
+ if (w.oaiq) return;
59
+ var q = function () { q.q.push(arguments); };
60
+ q.q = [];
61
+ w.oaiq = q;
62
+ var js = d.createElement(s);
63
+ js.async = true;
64
+ js.src = u;
65
+ var f = d.getElementsByTagName(s)[0];
66
+ f.parentNode.insertBefore(js, f);
67
+ })(window, document, "script", "https://bzrcdn.openai.com/sdk/oaiq.min.js");
68
+
69
+ oaiq("consent", false);
70
+ oaiq("init", { pixelId: ${id} });
71
+
72
+ (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) {}
78
+ if (ads) {
79
+ oaiq("consent", true);
80
+ oaiq("measure", "page_viewed", { type: "contents" });
81
+ }
82
+ })(document);
83
+ `.trim()
84
+
85
+ return (
86
+ <Script
87
+ id="chatgpt-pixel-init"
88
+ strategy="afterInteractive"
89
+ dangerouslySetInnerHTML={{ __html: initSnippet }}
90
+ />
91
+ )
92
+ }
@@ -131,6 +131,18 @@ export function applyConsent(choices: ConsentChoices): void {
131
131
  if (choices.ads) w.ttq.grantConsent()
132
132
  else w.ttq.revokeConsent()
133
133
  }
134
+
135
+ // ChatGPT Ads. One command on their own queue, `oaiq("consent", bool)`,
136
+ // which works before the SDK has arrived because the queue buffers it.
137
+ //
138
+ // A grant also fires the page view the loader withheld: their docs say
139
+ // an unconsented event sends no ping and do not promise that a held call
140
+ // is released later, so the visitor who accepts on their third page is
141
+ // counted from the grant onward rather than not at all.
142
+ if (typeof w.oaiq === "function") {
143
+ w.oaiq("consent", choices.ads)
144
+ if (choices.ads) w.oaiq("measure", "page_viewed", { type: "contents" })
145
+ }
134
146
  }
135
147
 
136
148
  /**
@@ -24,6 +24,14 @@ import {
24
24
  trackTikTokPurchase,
25
25
  trackTikTokViewContent,
26
26
  } from "./ttq"
27
+ import {
28
+ minorUnits,
29
+ trackOpenAiCheckoutStarted,
30
+ trackOpenAiContentsViewed,
31
+ trackOpenAiItemsAdded,
32
+ trackOpenAiOrderCreated,
33
+ type OpenAiContentItem,
34
+ } from "./oaiq"
27
35
  import { googleAdsPurchaseSendTo, trackGoogleAdsPurchase } from "./google-ads"
28
36
  import type { TrackingConfig } from "./types"
29
37
 
@@ -118,6 +126,25 @@ const tiktokContents = (lines: TrackedLine[]) =>
118
126
  const unitCount = (lines: TrackedLine[]) =>
119
127
  lines.reduce((sum, line) => sum + (Number(line.quantity) || 0), 0)
120
128
 
129
+ /**
130
+ * ChatGPT Ads contents. The shape is close to TikTok's, but every amount
131
+ * is an INTEGER IN MINOR UNITS on this vendor, which is why the mapping
132
+ * cannot be shared with `tiktokContents` above: the same numbers in the
133
+ * same field names would be a hundredfold under-report.
134
+ */
135
+ const openAiContents = (
136
+ lines: TrackedLine[],
137
+ currency: string
138
+ ): OpenAiContentItem[] =>
139
+ lines.map((line) => ({
140
+ id: line.productId,
141
+ name: line.title,
142
+ content_type: "product",
143
+ quantity: line.quantity,
144
+ amount: minorUnits(line.price),
145
+ currency,
146
+ }))
147
+
121
148
  /** Product page view. */
122
149
  export function trackProductView(input: {
123
150
  line: TrackedLine
@@ -137,6 +164,12 @@ export function trackProductView(input: {
137
164
  currency,
138
165
  value,
139
166
  })
167
+ trackOpenAiContentsViewed({
168
+ contentId: line.productId,
169
+ contentName: line.title,
170
+ currency,
171
+ value,
172
+ })
140
173
  trackGAViewItem({ currency, value, items: gaItems([line], currency) })
141
174
  trackRybbitViewItem({
142
175
  item_id: line.variantId || line.productId,
@@ -168,6 +201,14 @@ export function trackCartAdd(input: {
168
201
  currency,
169
202
  value,
170
203
  })
204
+ trackOpenAiItemsAdded({
205
+ contentId: line.productId,
206
+ contentName: line.title,
207
+ quantity: line.quantity,
208
+ price: line.price,
209
+ currency,
210
+ value,
211
+ })
171
212
  trackGAAddToCart({ currency, value, items: gaItems([line], currency) })
172
213
  trackRybbitAddToCart({
173
214
  item_id: line.variantId || line.productId,
@@ -199,6 +240,11 @@ export function trackCheckoutStart(input: {
199
240
  currency,
200
241
  value,
201
242
  })
243
+ trackOpenAiCheckoutStarted({
244
+ contents: openAiContents(lines, currency),
245
+ currency,
246
+ value,
247
+ })
202
248
  trackGABeginCheckout({
203
249
  currency,
204
250
  value,
@@ -222,8 +268,9 @@ export function trackCheckoutStart(input: {
222
268
  * malformed `send_to` is accepted by Google and silently dropped.
223
269
  *
224
270
  * Everything here is deduped against the server: Meta on
225
- * `purchase_<displayId>`, TikTok on `tt_purchase_<displayId>`, GA4 and
226
- * Google Ads on the transaction id, which is `displayId` itself.
271
+ * `purchase_<displayId>`, TikTok on `tt_purchase_<displayId>`, ChatGPT Ads
272
+ * on `oai_order_<displayId>`, GA4 and Google Ads on the transaction id,
273
+ * which is `displayId` itself.
227
274
  */
228
275
  export function trackOrderPurchase(
229
276
  order: TrackedOrder,
@@ -261,6 +308,13 @@ export function trackOrderPurchase(
261
308
  customerType,
262
309
  })
263
310
 
311
+ trackOpenAiOrderCreated({
312
+ contents: openAiContents(lines, currency),
313
+ currency,
314
+ value,
315
+ displayId,
316
+ })
317
+
264
318
  trackGAPurchase({
265
319
  transaction_id: transactionId,
266
320
  currency,
@@ -57,6 +57,7 @@ export { MetaPixel, updatePixelAdvancedMatching } from "./meta-pixel"
57
57
  export { GA4, GoogleTag } from "./ga4"
58
58
  export { Gtm } from "./gtm"
59
59
  export { TikTokPixel } from "./tiktok-pixel"
60
+ export { ChatGptPixel } from "./chatgpt-pixel"
60
61
  export { StorefrontTags } from "./storefront-tags"
61
62
  export { TrackInit } from "./track-init"
62
63
  // The live-visitor heartbeat. Mounted by <StorefrontTags> so a storefront
@@ -134,6 +135,17 @@ export {
134
135
  tiktokPurchaseEventId,
135
136
  type TikTokContentItem,
136
137
  } from "./ttq"
138
+ export {
139
+ trackOpenAiPageView,
140
+ trackOpenAiContentsViewed,
141
+ trackOpenAiItemsAdded,
142
+ trackOpenAiCheckoutStarted,
143
+ trackOpenAiOrderCreated,
144
+ applyOpenAiConsent,
145
+ openAiOrderEventId,
146
+ minorUnits,
147
+ type OpenAiContentItem,
148
+ } from "./oaiq"
137
149
  export {
138
150
  trackGoogleAdsPurchase,
139
151
  googleAdsPurchaseSendTo,
@@ -0,0 +1,206 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Typed wrappers around the global `oaiq` command queue from the OpenAI
5
+ * measurement pixel — the ChatGPT Ads twin of ./ttq.ts and ./fbq.ts, and
6
+ * deliberately the same shape so a surface that fires one can fire the
7
+ * other on the line below.
8
+ *
9
+ * Every helper no-ops when `window.oaiq` is absent (pixel not configured,
10
+ * server render, script blocked), so callers need no guards.
11
+ *
12
+ * The call signature is theirs, from
13
+ * https://developers.openai.com/ads/measurement-pixel:
14
+ *
15
+ * oaiq("measure", eventName, eventData, { event_id })
16
+ *
17
+ * Two things here are NOT like the other vendors, and both are silent when
18
+ * wrong:
19
+ *
20
+ * 1. MONEY IS AN INTEGER IN MINOR UNITS. Meta, TikTok and GA4 all read
21
+ * `value: 25.99`; OpenAI reads `amount: 2599`. Passing a decimal
22
+ * under-reports revenue by a factor of a hundred with nothing to show
23
+ * for it, so every amount goes through `minorUnits` below and no
24
+ * caller passes `amount` itself.
25
+ * 2. EVENT DATA CARRIES ITS OWN `type`. Each event name is bound to one
26
+ * of four data shapes; the five commerce events we fire all take
27
+ * `contents`, so the wrappers set it and callers cannot get it wrong.
28
+ *
29
+ * Event names are theirs and are not interchangeable with Meta's or
30
+ * TikTok's: `contents_viewed`, `items_added`, `checkout_started`,
31
+ * `order_created`, `page_viewed`.
32
+ */
33
+
34
+ /** One product line inside `contents`. */
35
+ export type OpenAiContentItem = {
36
+ id: string
37
+ name?: string
38
+ content_type?: string
39
+ quantity?: number
40
+ /** Integer, minor units. */
41
+ amount?: number
42
+ currency?: string
43
+ }
44
+
45
+ type OpenAiEventData = {
46
+ type: "contents"
47
+ /** Integer, minor units. */
48
+ amount?: number
49
+ currency?: string
50
+ contents?: OpenAiContentItem[]
51
+ }
52
+
53
+ type OaiqFn = (
54
+ command: "init" | "measure" | "consent",
55
+ ...args: unknown[]
56
+ ) => void
57
+
58
+ function safeOaiq(): OaiqFn | null {
59
+ if (typeof window === "undefined") return null
60
+ const oaiq = (window as unknown as { oaiq?: OaiqFn }).oaiq
61
+ return typeof oaiq === "function" ? oaiq : null
62
+ }
63
+
64
+ /**
65
+ * Major units to their integer minor units: 25.99 becomes 2599.
66
+ *
67
+ * The twin of `openAiMinorUnits` in the platform's
68
+ * src/lib/tracking/constants.ts. Duplicated rather than imported because
69
+ * this package cannot reach into the platform, and rounded rather than
70
+ * truncated because 0.1 + 0.2 is 0.30000000000000004 in binary floating
71
+ * point and truncation would report 29 cents for 30.
72
+ */
73
+ export function minorUnits(amountInMajorUnits: number): number {
74
+ return Math.round((Number(amountInMajorUnits) || 0) * 100)
75
+ }
76
+
77
+ /**
78
+ * THE order dedup key — must equal what the backend forwarder sends
79
+ * (`openAiOrderEventId` in the platform's src/lib/tracking/constants.ts).
80
+ * Built from display_id here so the format cannot drift between the two
81
+ * sides, the same guarantee `trackPurchase` gives for Meta and
82
+ * `tiktokPurchaseEventId` for TikTok.
83
+ *
84
+ * OpenAI deduplicates on pixel id + event name + id, keeping the FIRST
85
+ * event it receives, so the browser and the server collapse into one
86
+ * conversion whichever arrives first.
87
+ */
88
+ export function openAiOrderEventId(displayId: string | number): string {
89
+ return `oai_order_${displayId}`
90
+ }
91
+
92
+ /** A page view. Their pixel fires nothing on its own at init. */
93
+ export function trackOpenAiPageView(): void {
94
+ const oaiq = safeOaiq()
95
+ if (!oaiq) return
96
+ oaiq("measure", "page_viewed", { type: "contents" } satisfies OpenAiEventData)
97
+ }
98
+
99
+ export function trackOpenAiContentsViewed(data: {
100
+ contentId: string
101
+ contentName?: string
102
+ currency: string
103
+ /** Major units — converted here. */
104
+ value: number
105
+ }): void {
106
+ const oaiq = safeOaiq()
107
+ if (!oaiq) return
108
+ oaiq("measure", "contents_viewed", {
109
+ type: "contents",
110
+ amount: minorUnits(data.value),
111
+ currency: data.currency,
112
+ contents: [
113
+ {
114
+ id: data.contentId,
115
+ name: data.contentName,
116
+ content_type: "product",
117
+ quantity: 1,
118
+ },
119
+ ],
120
+ } satisfies OpenAiEventData)
121
+ }
122
+
123
+ export function trackOpenAiItemsAdded(data: {
124
+ contentId: string
125
+ contentName?: string
126
+ quantity: number
127
+ /** Unit price, major units. */
128
+ price: number
129
+ currency: string
130
+ /** Line value, major units. */
131
+ value: number
132
+ }): void {
133
+ const oaiq = safeOaiq()
134
+ if (!oaiq) return
135
+ oaiq("measure", "items_added", {
136
+ type: "contents",
137
+ amount: minorUnits(data.value),
138
+ currency: data.currency,
139
+ contents: [
140
+ {
141
+ id: data.contentId,
142
+ name: data.contentName,
143
+ content_type: "product",
144
+ quantity: data.quantity,
145
+ amount: minorUnits(data.price),
146
+ currency: data.currency,
147
+ },
148
+ ],
149
+ } satisfies OpenAiEventData)
150
+ }
151
+
152
+ export function trackOpenAiCheckoutStarted(data: {
153
+ contents: OpenAiContentItem[]
154
+ currency: string
155
+ /** Cart value, major units. */
156
+ value: number
157
+ }): void {
158
+ const oaiq = safeOaiq()
159
+ if (!oaiq) return
160
+ oaiq("measure", "checkout_started", {
161
+ type: "contents",
162
+ amount: minorUnits(data.value),
163
+ currency: data.currency,
164
+ contents: data.contents,
165
+ } satisfies OpenAiEventData)
166
+ }
167
+
168
+ export function trackOpenAiOrderCreated(data: {
169
+ contents: OpenAiContentItem[]
170
+ currency: string
171
+ /** Order total, major units. */
172
+ value: number
173
+ /** The order's display id — the dedup key shared with the server. */
174
+ displayId: string | number
175
+ }): void {
176
+ const oaiq = safeOaiq()
177
+ if (!oaiq) return
178
+ oaiq(
179
+ "measure",
180
+ "order_created",
181
+ {
182
+ type: "contents",
183
+ amount: minorUnits(data.value),
184
+ currency: data.currency,
185
+ contents: data.contents,
186
+ } satisfies OpenAiEventData,
187
+ { event_id: openAiOrderEventId(data.displayId) }
188
+ )
189
+ }
190
+
191
+ /**
192
+ * Push a live consent decision to the pixel.
193
+ *
194
+ * Normally you do NOT call this: `applyConsent()` in ./consent.ts relays
195
+ * every decision to gtag, fbq, ttq and oaiq together. Exported for a host
196
+ * app driving the pixel from a consent layer of its own.
197
+ *
198
+ * Their own gate, from the measurement-pixel docs: with consent false the
199
+ * pixel sends no measurement pings, and the default is TRUE, which is why
200
+ * the loader closes it explicitly before init rather than trusting it.
201
+ */
202
+ export function applyOpenAiConsent(adsGranted: boolean): void {
203
+ const oaiq = safeOaiq()
204
+ if (!oaiq) return
205
+ oaiq("consent", adsGranted)
206
+ }
@@ -4,6 +4,7 @@ import { PageViews } from "./page-views"
4
4
  import { Gtm } from "./gtm"
5
5
  import { MetaPixel } from "./meta-pixel"
6
6
  import { TikTokPixel } from "./tiktok-pixel"
7
+ import { ChatGptPixel } from "./chatgpt-pixel"
7
8
  import { getTrackingConfig } from "./get-tracking-config"
8
9
  import type { StorefrontClient } from "../api/http"
9
10
  import type { TrackingConfig } from "./types"
@@ -65,6 +66,7 @@ export async function StorefrontTags({
65
66
  <LiveHeartbeat />
66
67
  <MetaPixel pixelId={tracking.facebookPixel?.pixelId} />
67
68
  <TikTokPixel pixelId={tracking.tiktok?.pixelId} />
69
+ <ChatGptPixel pixelId={tracking.chatgptAds?.pixelId} />
68
70
  <GoogleTag
69
71
  measurementId={tracking.ga4?.measurementId}
70
72
  adsConversionId={tracking.googleAds?.conversionId}
@@ -44,6 +44,10 @@ export type TrackingConfig = {
44
44
  * read by the order.placed forwarder, exactly like Meta's CAPI token,
45
45
  * which has never traversed this surface. */
46
46
  tiktok?: { pixelId: string }
47
+ /** ChatGPT Ads, pixel id ONLY — the Conversions API key is a bearer
48
+ * token and stays on the platform, read by the order.placed forwarder
49
+ * (chatgpt-ads-pixel card). */
50
+ chatgptAds?: { pixelId: string }
47
51
  /** True when the store's consent CMP is enabled — client tags must
48
52
  * mount through the consent gate (`_1c_consent` / Consent Mode v2). */
49
53
  consent_required?: boolean
@@ -1,73 +0,0 @@
1
- "use client"
2
-
3
- import * as React from "react"
4
-
5
- import { cn } from "./utils"
6
- import { convertToLocale } from "./money"
7
-
8
- /**
9
- * DualPrice — render a EUR price with BGN next to it.
10
- *
11
- * Bulgarian law (Дв, effective through 2026-08) requires showing both EUR
12
- * and BGN on every price displayed to consumers. Fixed statutory rate:
13
- * 1 EUR = 1.95583 BGN
14
- *
15
- * For non-EUR currencies the BGN leg is skipped and only the primary locale
16
- * currency is shown. The component is stateless and side-effect-free — safe
17
- * to render in server components and client components alike.
18
- *
19
- * The EUR-to-BGN rate and the law's expiry date are baked in for now. When
20
- * the law expires (or when a store outside Bulgaria consumes this library),
21
- * we'll move both to a BrandingContext value so each store can override.
22
- *
23
- * @example
24
- * <DualPrice amount={19.99} currencyCode="eur" />
25
- * // → "€19.99 39,10 лв."
26
- */
27
-
28
- const EUR_TO_BGN = 1.95583
29
-
30
- function toBGN(eurAmount: number): string {
31
- const bgn = eurAmount * EUR_TO_BGN
32
- return bgn.toFixed(2).replace(".", ",") + " лв."
33
- }
34
-
35
- export type DualPriceProps = {
36
- /** Amount in major units of the primary currency (e.g. 19.99 for €19.99) */
37
- amount: number
38
- /** ISO currency code (case-insensitive). Only "eur" triggers the BGN leg. */
39
- currencyCode: string
40
- /** Optional class for the outer span */
41
- className?: string
42
- /** Optional class for the BGN leg (defaults to small muted) */
43
- bgnClassName?: string
44
- }
45
-
46
- export function DualPrice({
47
- amount,
48
- currencyCode,
49
- className,
50
- bgnClassName,
51
- }: DualPriceProps): React.ReactElement {
52
- const isEur = currencyCode?.toLowerCase() === "eur"
53
-
54
- if (!isEur) {
55
- return (
56
- <span className={className}>
57
- {convertToLocale({ amount, currency_code: currencyCode })}
58
- </span>
59
- )
60
- }
61
-
62
- return (
63
- <span className={className}>
64
- {convertToLocale({ amount, currency_code: currencyCode })}
65
- <span className={cn("ml-1.5 text-muted-foreground/70 font-normal", bgnClassName)}>
66
- {toBGN(amount)}
67
- </span>
68
- </span>
69
- )
70
- }
71
-
72
- /** Exposed for advanced callers that need the rate directly. */
73
- export const EUR_TO_BGN_RATE = EUR_TO_BGN