@cartbase/storefront 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +258 -256
  3. package/src/api/auth.ts +108 -108
  4. package/src/api/carts.ts +523 -523
  5. package/src/api/categories.ts +184 -184
  6. package/src/api/checkout.ts +526 -526
  7. package/src/api/collections.ts +130 -130
  8. package/src/api/consent.ts +75 -75
  9. package/src/api/content.ts +125 -125
  10. package/src/api/customers.ts +303 -303
  11. package/src/api/gift-cards.ts +112 -112
  12. package/src/api/http.ts +180 -180
  13. package/src/api/index.ts +30 -30
  14. package/src/api/menus.ts +77 -77
  15. package/src/api/metaobjects.ts +136 -136
  16. package/src/api/orders.ts +290 -290
  17. package/src/api/products.ts +43 -1
  18. package/src/api/redirects.ts +37 -37
  19. package/src/api/regions.ts +200 -200
  20. package/src/api/reviews.ts +259 -259
  21. package/src/api/search.ts +163 -163
  22. package/src/api/store.ts +35 -35
  23. package/src/api/types.ts +91 -91
  24. package/src/cart-drawer/context.tsx +277 -164
  25. package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
  26. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
  27. package/src/cart-drawer/gift-wrap.tsx +82 -82
  28. package/src/cart-drawer/index.ts +71 -68
  29. package/src/cart-drawer/item/index.tsx +162 -162
  30. package/src/cart-drawer/item/quantity.tsx +8 -14
  31. package/src/cart-drawer/item/upsell.tsx +110 -110
  32. package/src/cart-drawer/labels.ts +123 -123
  33. package/src/cart-drawer/mutation-queue.ts +78 -0
  34. package/src/cart-drawer/notes.tsx +131 -131
  35. package/src/cart-drawer/sticky-footer.tsx +73 -73
  36. package/src/cart-drawer/summary-breakdown.tsx +197 -197
  37. package/src/checkout/address-error-copy.ts +117 -117
  38. package/src/checkout/boxnow-locker-selector.tsx +410 -410
  39. package/src/checkout/compare-addresses.ts +40 -40
  40. package/src/checkout/discount-section.tsx +218 -218
  41. package/src/checkout/error-copy-codes.ts +63 -63
  42. package/src/checkout/geocode.ts +154 -154
  43. package/src/checkout/gift-card-section.tsx +224 -224
  44. package/src/checkout/index.ts +72 -72
  45. package/src/checkout/labels.ts +495 -495
  46. package/src/checkout/payment-button.tsx +372 -372
  47. package/src/checkout/payment-error-copy.ts +154 -154
  48. package/src/checkout/promotion-error-copy.ts +91 -91
  49. package/src/checkout/use-checkout-orchestration.ts +1592 -1592
  50. package/src/common/country-flag.tsx +52 -52
  51. package/src/common/country-select.tsx +11 -11
  52. package/src/common/index.ts +20 -20
  53. package/src/common/market-select.tsx +57 -57
  54. package/src/index.ts +12 -12
  55. package/src/lib/country-name.ts +59 -59
  56. package/src/lib/get-product-price.ts +133 -133
  57. package/src/lib/media-image.tsx +39 -39
  58. package/src/lib/payment-constants.ts +53 -53
  59. package/src/lib/platform.ts +13 -13
  60. package/src/lib/price.tsx +39 -39
  61. package/src/lib/store-api-error.ts +36 -36
  62. package/src/lib/variant-caption.ts +32 -32
  63. package/src/locales/bg.ts +467 -467
  64. package/src/locales/context.ts +37 -37
  65. package/src/locales/en.ts +26 -26
  66. package/src/locales/es.ts +466 -466
  67. package/src/locales/index.ts +19 -19
  68. package/src/locales/provider.tsx +59 -59
  69. package/src/locales/types.ts +77 -77
  70. package/src/order/index.ts +62 -62
  71. package/src/order/labels.ts +79 -79
  72. package/src/order/order-totals.tsx +250 -250
  73. package/src/primitives/select-field.tsx +93 -93
  74. package/src/products/image-gallery.tsx +43 -43
  75. package/src/products/index.ts +68 -59
  76. package/src/products/option-select.tsx +67 -67
  77. package/src/products/product-actions-wrapper.tsx +63 -62
  78. package/src/products/product-actions.tsx +151 -150
  79. package/src/products/product-price.tsx +69 -69
  80. package/src/products/product-promises.tsx +61 -61
  81. package/src/products/product-specs.tsx +65 -65
  82. package/src/products/product-tabs.tsx +123 -123
  83. package/src/products/product-template.tsx +163 -162
  84. package/src/products/purchase-options.tsx +130 -130
  85. package/src/products/sets.ts +110 -0
  86. package/src/products/use-product-actions.ts +199 -164
  87. package/src/products/variant-matching.ts +71 -71
  88. package/src/products/variant-url.ts +74 -74
  89. package/src/reviews-ui/index.ts +73 -73
  90. package/src/store/category-template.tsx +136 -136
  91. package/src/store/index.ts +40 -40
  92. package/src/tracking/chatgpt-pixel.tsx +99 -99
  93. package/src/tracking/consent-init.tsx +62 -62
  94. package/src/tracking/events.ts +348 -348
  95. package/src/tracking/ga4.tsx +93 -93
  96. package/src/tracking/google-ads.ts +84 -84
  97. package/src/tracking/gtm.tsx +60 -60
  98. package/src/tracking/inline-script.ts +49 -49
  99. package/src/tracking/oaiq.ts +206 -206
  100. package/src/tracking/tiktok-pixel.tsx +91 -91
  101. package/src/tracking/track-init.tsx +56 -56
  102. package/src/tracking/track-order-purchase.tsx +122 -122
  103. package/src/tracking/ttq.ts +180 -180
  104. package/src/tracking/use-tracking-config.ts +54 -54
  105. package/theme/index.css +25 -25
  106. package/theme/theme.css +150 -150
  107. package/theme/tokens.css +106 -106
@@ -1,62 +1,63 @@
1
- /**
2
- * Fresh-price fetch wrapper for ProductActions — ported from
3
- * `@1click/ui/src/products/product-actions-wrapper.tsx` (v2.3.1). Data
4
- * seam: the Medusa `listProducts({id})` + region-priced fetch becomes
5
- * `api/products` `retrieveProduct(idOrHandle, pricingContext)` — the PDP
6
- * shell renders with the (possibly cached) product while this wrapper
7
- * re-fetches `calculated_price` with the live pricing context (and the
8
- * customer's Bearer JWT via the client, for group-aware B2B prices).
9
- *
10
- * Async server component — render inside `<Suspense>` with a disabled
11
- * `<ProductActions>` fallback (see ProductTemplate).
12
- */
13
- import type { StorefrontClient } from "../api/http"
14
- import type { PricingContextQuery } from "../api/types"
15
- import type { StoreProduct, StoreProductVariant } from "../api/products"
16
- import { retrieveProduct } from "../api/products"
17
- import { StoreApiError } from "../api/types"
18
- import { ProductActions, type AddToCartInput } from "./product-actions"
19
-
20
- type ProductActionsWrapperProps = {
21
- client: StorefrontClient
22
- /** Product id (`prod_…`) or handle. */
23
- id: string
24
- /** Pricing context so `calculated_price` is present for the price panel. */
25
- pricingContext?: PricingContextQuery
26
- /** The variant the address names; see the product page contract. */
27
- initialVariantId?: string | null
28
- addToCart: (input: AddToCartInput) => Promise<void>
29
- onAddToCart?: (product: StoreProduct, variant: StoreProductVariant) => void
30
- openCart?: () => void
31
- }
32
-
33
- export async function ProductActionsWrapper({
34
- client,
35
- id,
36
- pricingContext,
37
- initialVariantId,
38
- addToCart,
39
- onAddToCart,
40
- openCart,
41
- }: ProductActionsWrapperProps) {
42
- let product: StoreProduct
43
- try {
44
- const res = await retrieveProduct(client, id, pricingContext)
45
- product = res.product
46
- } catch (e) {
47
- if (e instanceof StoreApiError && e.status === 404) return null
48
- throw e
49
- }
50
-
51
- return (
52
- <ProductActions
53
- product={product}
54
- initialVariantId={initialVariantId}
55
- addToCart={addToCart}
56
- onAddToCart={onAddToCart}
57
- openCart={openCart}
58
- />
59
- )
60
- }
61
-
62
- export { type ProductActionsWrapperProps }
1
+ /**
2
+ * Fresh-price fetch wrapper for ProductActions — ported from
3
+ * `@1click/ui/src/products/product-actions-wrapper.tsx` (v2.3.1). Data
4
+ * seam: the Medusa `listProducts({id})` + region-priced fetch becomes
5
+ * `api/products` `retrieveProduct(idOrHandle, pricingContext)` — the PDP
6
+ * shell renders with the (possibly cached) product while this wrapper
7
+ * re-fetches `calculated_price` with the live pricing context (and the
8
+ * customer's Bearer JWT via the client, for group-aware B2B prices).
9
+ *
10
+ * Async server component — render inside `<Suspense>` with a disabled
11
+ * `<ProductActions>` fallback (see ProductTemplate).
12
+ */
13
+ import type { StorefrontClient } from "../api/http"
14
+ import type { PricingContextQuery } from "../api/types"
15
+ import type { StoreProduct, StoreProductVariant } from "../api/products"
16
+ import { retrieveProduct } from "../api/products"
17
+ import { StoreApiError } from "../api/types"
18
+ import { ProductActions, type AddToCartInput } from "./product-actions"
19
+
20
+ type ProductActionsWrapperProps = {
21
+ client: StorefrontClient
22
+ /** Product id (`prod_…`) or handle. */
23
+ id: string
24
+ /** Pricing context so `calculated_price` is present for the price panel. */
25
+ pricingContext?: PricingContextQuery
26
+ /** The variant the address names; see the product page contract. */
27
+ initialVariantId?: string | null
28
+ /** A server action; leave it out for the instant add through the mounted cart drawer. */
29
+ addToCart?: (input: AddToCartInput) => Promise<void>
30
+ onAddToCart?: (product: StoreProduct, variant: StoreProductVariant) => void
31
+ openCart?: () => void
32
+ }
33
+
34
+ export async function ProductActionsWrapper({
35
+ client,
36
+ id,
37
+ pricingContext,
38
+ initialVariantId,
39
+ addToCart,
40
+ onAddToCart,
41
+ openCart,
42
+ }: ProductActionsWrapperProps) {
43
+ let product: StoreProduct
44
+ try {
45
+ const res = await retrieveProduct(client, id, pricingContext)
46
+ product = res.product
47
+ } catch (e) {
48
+ if (e instanceof StoreApiError && e.status === 404) return null
49
+ throw e
50
+ }
51
+
52
+ return (
53
+ <ProductActions
54
+ product={product}
55
+ initialVariantId={initialVariantId}
56
+ addToCart={addToCart}
57
+ onAddToCart={onAddToCart}
58
+ openCart={openCart}
59
+ />
60
+ )
61
+ }
62
+
63
+ export { type ProductActionsWrapperProps }
@@ -1,150 +1,151 @@
1
- "use client"
2
-
3
- /**
4
- * The package's default buy panel: options, price, quantity, the button,
5
- * and the sticky bar on phones. Every rule of the product page contract
6
- * lives in `useProductActions`; this file is only the markup, and a store
7
- * that wants its own look draws its own panel on the same hook and
8
- * inherits the same behaviour (evoo's `features/product/buy-box.tsx` is
9
- * the reference).
10
- *
11
- * Until 2026-09-15 this panel carried the behaviour itself, and carried it
12
- * wrong: it wrote the variant into the address through the router as
13
- * `v_id=<whole key>`, so every click was a server render and the address
14
- * lagged; it chose nothing on a product with several variants until the
15
- * shopper clicked; and it added one unit, always. The hook fixed all three
16
- * for every consumer at once.
17
- */
18
- import { useRef } from "react"
19
- import { Minus, Plus } from "lucide-react"
20
-
21
- import type { StoreProduct, StoreProductVariant } from "../api/products"
22
- import { useIntersection } from "../lib/hooks/use-intersection"
23
- import { Button } from "../primitives/ui/button"
24
- import { useProductLabels } from "./context"
25
- import { OptionSelect } from "./option-select"
26
- import { ProductPrice } from "./product-price"
27
- import { MobileActions } from "./mobile-actions"
28
- import { useProductActions, type AddToCartInput } from "./use-product-actions"
29
-
30
- export type { AddToCartInput }
31
-
32
- type ProductActionsProps = {
33
- product: StoreProduct
34
- /**
35
- * The cart seam: called with the selected variant and quantity. Wire to
36
- * the host's cart orchestration (`api/carts` `addLineItem` behind the
37
- * host's cart-id storage). May throw `StoreApiError`;
38
- * `insufficient_inventory` is handled here (button flips to out of stock).
39
- */
40
- addToCart: (input: AddToCartInput) => Promise<void>
41
- /**
42
- * The variant the address names (`searchParams.variant`), passed from the
43
- * page so the first paint is the right variant. See the product page
44
- * contract in `use-product-actions.ts`.
45
- */
46
- initialVariantId?: string | null
47
- disabled?: boolean
48
- /** Fired after a successful add (analytics: trackAddToCart trio). */
49
- onAddToCart?: (product: StoreProduct, variant: StoreProductVariant) => void
50
- /** Open the host's cart drawer after a successful add. */
51
- openCart?: () => void
52
- /** The quantity stepper; on by default. */
53
- showQuantity?: boolean
54
- }
55
-
56
- export function ProductActions({
57
- product,
58
- addToCart,
59
- initialVariantId,
60
- disabled,
61
- onAddToCart,
62
- openCart,
63
- showQuantity = true,
64
- }: ProductActionsProps) {
65
- const labels = useProductLabels()
66
- const state = useProductActions({ product, addToCart, initialVariantId, onAddToCart, openCart })
67
- const { variant, chosen, choose, quantity, setQuantity, inStock, isAdding, isValidVariant, add } = state
68
-
69
- const actionsRef = useRef<HTMLDivElement>(null)
70
- const inView = useIntersection(actionsRef, "0px")
71
- const busy = !!disabled || isAdding
72
-
73
- return (
74
- <div className="flex flex-col gap-y-2" ref={actionsRef}>
75
- {(product.variants?.length ?? 0) > 1 && (
76
- <div className="flex flex-col gap-y-4">
77
- {(product.options || []).map((option) => (
78
- <div key={option.id}>
79
- <OptionSelect
80
- option={option}
81
- current={chosen[option.id]}
82
- updateOption={choose}
83
- title={option.title ?? ""}
84
- data-testid="product-options"
85
- disabled={busy}
86
- />
87
- </div>
88
- ))}
89
- <div className="h-px bg-border" />
90
- </div>
91
- )}
92
-
93
- <ProductPrice product={product} variant={variant} />
94
-
95
- {showQuantity && (
96
- <div className="flex flex-col gap-y-2">
97
- <span className="text-sm text-foreground">{labels.quantity}</span>
98
- <div className="inline-flex w-fit items-stretch rounded-lg border border-border bg-card">
99
- <button
100
- type="button"
101
- onClick={() => setQuantity(quantity - 1)}
102
- aria-label={labels.decreaseQuantity}
103
- disabled={quantity <= 1 || busy}
104
- className="px-3 text-foreground transition-colors hover:bg-muted disabled:opacity-40"
105
- data-testid="quantity-decrease"
106
- >
107
- <Minus className="size-4" aria-hidden />
108
- </button>
109
- <output className="min-w-10 py-2 text-center text-sm text-foreground" aria-live="polite" data-testid="quantity">
110
- {quantity}
111
- </output>
112
- <button
113
- type="button"
114
- onClick={() => setQuantity(quantity + 1)}
115
- aria-label={labels.increaseQuantity}
116
- disabled={busy}
117
- className="px-3 text-foreground transition-colors hover:bg-muted disabled:opacity-40"
118
- data-testid="quantity-increase"
119
- >
120
- <Plus className="size-4" aria-hidden />
121
- </button>
122
- </div>
123
- </div>
124
- )}
125
-
126
- <Button
127
- onClick={add}
128
- disabled={!inStock || !variant || busy || !isValidVariant}
129
- className="w-full h-10"
130
- data-testid="add-product-button"
131
- >
132
- {!variant ? labels.selectVariant : !inStock ? labels.outOfStock : labels.addToCart}
133
- </Button>
134
-
135
- <MobileActions
136
- product={product}
137
- variant={variant}
138
- options={chosen}
139
- updateOptions={choose}
140
- inStock={inStock}
141
- handleAddToCart={add}
142
- isAdding={isAdding}
143
- show={!inView}
144
- optionsDisabled={busy}
145
- />
146
- </div>
147
- )
148
- }
149
-
150
- export { type ProductActionsProps }
1
+ "use client"
2
+
3
+ /**
4
+ * The package's default buy panel: options, price, quantity, the button,
5
+ * and the sticky bar on phones. Every rule of the product page contract
6
+ * lives in `useProductActions`; this file is only the markup, and a store
7
+ * that wants its own look draws its own panel on the same hook and
8
+ * inherits the same behaviour (evoo's `features/product/buy-box.tsx` is
9
+ * the reference).
10
+ *
11
+ * Until 2026-09-15 this panel carried the behaviour itself, and carried it
12
+ * wrong: it wrote the variant into the address through the router as
13
+ * `v_id=<whole key>`, so every click was a server render and the address
14
+ * lagged; it chose nothing on a product with several variants until the
15
+ * shopper clicked; and it added one unit, always. The hook fixed all three
16
+ * for every consumer at once.
17
+ */
18
+ import { useRef } from "react"
19
+ import { Minus, Plus } from "lucide-react"
20
+
21
+ import type { StoreProduct, StoreProductVariant } from "../api/products"
22
+ import { useIntersection } from "../lib/hooks/use-intersection"
23
+ import { Button } from "../primitives/ui/button"
24
+ import { useProductLabels } from "./context"
25
+ import { OptionSelect } from "./option-select"
26
+ import { ProductPrice } from "./product-price"
27
+ import { MobileActions } from "./mobile-actions"
28
+ import { useProductActions, type AddToCartInput } from "./use-product-actions"
29
+
30
+ export type { AddToCartInput }
31
+
32
+ type ProductActionsProps = {
33
+ product: StoreProduct
34
+ /**
35
+ * The host's own cart seam (a server action), called with the selected
36
+ * variant and quantity. Leave it out for the instant add through the
37
+ * mounted `CartDrawerProvider`: the drawer opens at the click with the
38
+ * product in it. May throw `StoreApiError`; `insufficient_inventory` is
39
+ * handled here (button flips to out of stock).
40
+ */
41
+ addToCart?: (input: AddToCartInput) => Promise<void>
42
+ /**
43
+ * The variant the address names (`searchParams.variant`), passed from the
44
+ * page so the first paint is the right variant. See the product page
45
+ * contract in `use-product-actions.ts`.
46
+ */
47
+ initialVariantId?: string | null
48
+ disabled?: boolean
49
+ /** Fired after a successful add (analytics: trackAddToCart trio). */
50
+ onAddToCart?: (product: StoreProduct, variant: StoreProductVariant) => void
51
+ /** Open the host's cart: at the click on the instant path, after the seam on the server-action path. */
52
+ openCart?: () => void
53
+ /** The quantity stepper; on by default. */
54
+ showQuantity?: boolean
55
+ }
56
+
57
+ export function ProductActions({
58
+ product,
59
+ addToCart,
60
+ initialVariantId,
61
+ disabled,
62
+ onAddToCart,
63
+ openCart,
64
+ showQuantity = true,
65
+ }: ProductActionsProps) {
66
+ const labels = useProductLabels()
67
+ const state = useProductActions({ product, addToCart, initialVariantId, onAddToCart, openCart })
68
+ const { variant, chosen, choose, quantity, setQuantity, inStock, isAdding, isValidVariant, add } = state
69
+
70
+ const actionsRef = useRef<HTMLDivElement>(null)
71
+ const inView = useIntersection(actionsRef, "0px")
72
+ const busy = !!disabled || isAdding
73
+
74
+ return (
75
+ <div className="flex flex-col gap-y-2" ref={actionsRef}>
76
+ {(product.variants?.length ?? 0) > 1 && (
77
+ <div className="flex flex-col gap-y-4">
78
+ {(product.options || []).map((option) => (
79
+ <div key={option.id}>
80
+ <OptionSelect
81
+ option={option}
82
+ current={chosen[option.id]}
83
+ updateOption={choose}
84
+ title={option.title ?? ""}
85
+ data-testid="product-options"
86
+ disabled={busy}
87
+ />
88
+ </div>
89
+ ))}
90
+ <div className="h-px bg-border" />
91
+ </div>
92
+ )}
93
+
94
+ <ProductPrice product={product} variant={variant} />
95
+
96
+ {showQuantity && (
97
+ <div className="flex flex-col gap-y-2">
98
+ <span className="text-sm text-foreground">{labels.quantity}</span>
99
+ <div className="inline-flex w-fit items-stretch rounded-lg border border-border bg-card">
100
+ <button
101
+ type="button"
102
+ onClick={() => setQuantity(quantity - 1)}
103
+ aria-label={labels.decreaseQuantity}
104
+ disabled={quantity <= 1 || busy}
105
+ className="px-3 text-foreground transition-colors hover:bg-muted disabled:opacity-40"
106
+ data-testid="quantity-decrease"
107
+ >
108
+ <Minus className="size-4" aria-hidden />
109
+ </button>
110
+ <output className="min-w-10 py-2 text-center text-sm text-foreground" aria-live="polite" data-testid="quantity">
111
+ {quantity}
112
+ </output>
113
+ <button
114
+ type="button"
115
+ onClick={() => setQuantity(quantity + 1)}
116
+ aria-label={labels.increaseQuantity}
117
+ disabled={busy}
118
+ className="px-3 text-foreground transition-colors hover:bg-muted disabled:opacity-40"
119
+ data-testid="quantity-increase"
120
+ >
121
+ <Plus className="size-4" aria-hidden />
122
+ </button>
123
+ </div>
124
+ </div>
125
+ )}
126
+
127
+ <Button
128
+ onClick={add}
129
+ disabled={!inStock || !variant || busy || !isValidVariant}
130
+ className="w-full h-10"
131
+ data-testid="add-product-button"
132
+ >
133
+ {!variant ? labels.selectVariant : !inStock ? labels.outOfStock : labels.addToCart}
134
+ </Button>
135
+
136
+ <MobileActions
137
+ product={product}
138
+ variant={variant}
139
+ options={chosen}
140
+ updateOptions={choose}
141
+ inStock={inStock}
142
+ handleAddToCart={add}
143
+ isAdding={isAdding}
144
+ show={!inView}
145
+ optionsDisabled={busy}
146
+ />
147
+ </div>
148
+ )
149
+ }
150
+
151
+ export { type ProductActionsProps }
@@ -1,69 +1,69 @@
1
- "use client"
2
-
3
- /**
4
- * PDP price display — ported from
5
- * `@1click/ui/src/products/product-price.tsx` (v2.3.1). Data seam:
6
- * `@medusajs/types` `HttpTypes.StoreProduct/StoreProductVariant` are
7
- * replaced by the Cartbase SDK DTOs (`@cartbase/storefront/api/products`).
8
- * Pricing stays SERVER truth — `lib/get-product-price` only selects and
9
- * formats `variant.calculated_price` (Cartbase's flat `price_list_type`).
10
- */
11
- import type { StoreProduct, StoreProductVariant } from "../api/products"
12
- import { cn } from "../lib/utils"
13
- import { getProductPrice } from "../lib/get-product-price"
14
- import { useProductLabels } from "./context"
15
-
16
- export function ProductPrice({
17
- product,
18
- variant,
19
- }: {
20
- product: StoreProduct
21
- variant?: StoreProductVariant
22
- }) {
23
- const labels = useProductLabels()
24
- const { cheapestPrice, variantPrice } = getProductPrice({
25
- product,
26
- variantId: variant?.id,
27
- })
28
-
29
- const selectedPrice = variant ? variantPrice : cheapestPrice
30
-
31
- if (!selectedPrice) {
32
- return <div className="block w-32 h-9 bg-muted animate-pulse rounded" />
33
- }
34
-
35
- return (
36
- <div className="flex flex-col text-foreground">
37
- <span
38
- className={cn("text-xl font-semibold", {
39
- "text-primary": selectedPrice.price_type === "sale",
40
- })}
41
- >
42
- {!variant && `${labels.from} `}
43
- <span
44
- data-testid="product-price"
45
- data-value={selectedPrice.calculated_price_number}
46
- >
47
- {selectedPrice.calculated_price}
48
- </span>
49
- </span>
50
- {selectedPrice.price_type === "sale" && (
51
- <>
52
- <p>
53
- <span className="text-muted-foreground">{labels.original} </span>
54
- <span
55
- className="line-through"
56
- data-testid="original-product-price"
57
- data-value={selectedPrice.original_price_number}
58
- >
59
- {selectedPrice.original_price}
60
- </span>
61
- </p>
62
- <span className="text-primary">
63
- -{selectedPrice.percentage_diff}%
64
- </span>
65
- </>
66
- )}
67
- </div>
68
- )
69
- }
1
+ "use client"
2
+
3
+ /**
4
+ * PDP price display — ported from
5
+ * `@1click/ui/src/products/product-price.tsx` (v2.3.1). Data seam:
6
+ * `@medusajs/types` `HttpTypes.StoreProduct/StoreProductVariant` are
7
+ * replaced by the Cartbase SDK DTOs (`@cartbase/storefront/api/products`).
8
+ * Pricing stays SERVER truth — `lib/get-product-price` only selects and
9
+ * formats `variant.calculated_price` (Cartbase's flat `price_list_type`).
10
+ */
11
+ import type { StoreProduct, StoreProductVariant } from "../api/products"
12
+ import { cn } from "../lib/utils"
13
+ import { getProductPrice } from "../lib/get-product-price"
14
+ import { useProductLabels } from "./context"
15
+
16
+ export function ProductPrice({
17
+ product,
18
+ variant,
19
+ }: {
20
+ product: StoreProduct
21
+ variant?: StoreProductVariant
22
+ }) {
23
+ const labels = useProductLabels()
24
+ const { cheapestPrice, variantPrice } = getProductPrice({
25
+ product,
26
+ variantId: variant?.id,
27
+ })
28
+
29
+ const selectedPrice = variant ? variantPrice : cheapestPrice
30
+
31
+ if (!selectedPrice) {
32
+ return <div className="block w-32 h-9 bg-muted animate-pulse rounded" />
33
+ }
34
+
35
+ return (
36
+ <div className="flex flex-col text-foreground">
37
+ <span
38
+ className={cn("text-xl font-semibold", {
39
+ "text-primary": selectedPrice.price_type === "sale",
40
+ })}
41
+ >
42
+ {!variant && `${labels.from} `}
43
+ <span
44
+ data-testid="product-price"
45
+ data-value={selectedPrice.calculated_price_number}
46
+ >
47
+ {selectedPrice.calculated_price}
48
+ </span>
49
+ </span>
50
+ {selectedPrice.price_type === "sale" && (
51
+ <>
52
+ <p>
53
+ <span className="text-muted-foreground">{labels.original} </span>
54
+ <span
55
+ className="line-through"
56
+ data-testid="original-product-price"
57
+ data-value={selectedPrice.original_price_number}
58
+ >
59
+ {selectedPrice.original_price}
60
+ </span>
61
+ </p>
62
+ <span className="text-primary">
63
+ -{selectedPrice.percentage_diff}%
64
+ </span>
65
+ </>
66
+ )}
67
+ </div>
68
+ )
69
+ }