@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 +2 -2
- package/src/cart-drawer/cross-sell-carousel.tsx +2 -3
- package/src/cart-drawer/gift-wrap.tsx +82 -83
- package/src/cart-drawer/item/index.tsx +4 -6
- package/src/cart-drawer/sticky-footer.tsx +73 -73
- package/src/checkout/gift-card-section.tsx +2 -2
- package/src/checkout/line-item-card.tsx +3 -3
- package/src/checkout/order-summary.tsx +11 -12
- package/src/checkout/payment-button.tsx +2 -3
- package/src/checkout/shipping-method-list.tsx +2 -2
- package/src/lib/money.ts +1 -1
- package/src/lib/price.tsx +39 -0
- package/src/order/order-delivery-card.tsx +2 -2
- package/src/order/order-item.tsx +3 -3
- package/src/order/order-totals.tsx +4 -4
- package/src/tracking/attribution.ts +13 -0
- package/src/tracking/chatgpt-pixel.tsx +92 -0
- package/src/tracking/consent.ts +12 -0
- package/src/tracking/events.ts +56 -2
- package/src/tracking/index.ts +12 -0
- package/src/tracking/oaiq.ts +206 -0
- package/src/tracking/storefront-tags.tsx +2 -0
- package/src/tracking/types.ts +4 -0
- package/src/lib/dual-price.tsx +0 -73
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cartbase/storefront",
|
|
3
|
-
"version": "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/
|
|
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 {
|
|
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
|
-
<
|
|
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 {
|
|
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
|
-
<
|
|
75
|
-
amount={price}
|
|
76
|
-
currencyCode={currencyCode}
|
|
77
|
-
className="text-[13px] text-muted-foreground font-medium"
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 {
|
|
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
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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 {
|
|
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
|
-
<
|
|
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 {
|
|
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
|
-
<
|
|
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 {
|
|
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
|
|
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
|
-
<
|
|
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 {
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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 {
|
|
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
|
-
<
|
|
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 {
|
|
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
|
-
<
|
|
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
|
|
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 {
|
|
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
|
-
<
|
|
248
|
+
<Price
|
|
249
249
|
amount={amount}
|
|
250
250
|
currencyCode={currencyCode}
|
|
251
251
|
className="text-sm font-medium text-foreground"
|
package/src/order/order-item.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import Image from "next/image"
|
|
2
2
|
import { ImageOff } from "lucide-react"
|
|
3
|
-
import {
|
|
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
|
-
<
|
|
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
|
-
<
|
|
187
|
+
<Price
|
|
188
188
|
amount={item.total}
|
|
189
189
|
currencyCode={currencyCode}
|
|
190
190
|
className={`text-sm font-bold ${
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
+
}
|
package/src/tracking/consent.ts
CHANGED
|
@@ -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
|
/**
|
package/src/tracking/events.ts
CHANGED
|
@@ -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>`,
|
|
226
|
-
* Google Ads on the transaction id,
|
|
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,
|
package/src/tracking/index.ts
CHANGED
|
@@ -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}
|
package/src/tracking/types.ts
CHANGED
|
@@ -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
|
package/src/lib/dual-price.tsx
DELETED
|
@@ -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
|