@cartbase/storefront 0.17.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +3 -1
  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 +367 -168
  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 +73 -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 +1604 -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/cookie-names.ts +14 -0
  56. package/src/lib/country-name.ts +59 -59
  57. package/src/lib/get-product-price.ts +133 -133
  58. package/src/lib/media-image.tsx +39 -39
  59. package/src/lib/payment-constants.ts +53 -53
  60. package/src/lib/platform.ts +13 -13
  61. package/src/lib/price.tsx +39 -39
  62. package/src/lib/store-api-error.ts +36 -36
  63. package/src/lib/variant-caption.ts +32 -32
  64. package/src/locales/bg.ts +467 -467
  65. package/src/locales/context.ts +37 -37
  66. package/src/locales/en.ts +26 -26
  67. package/src/locales/es.ts +466 -466
  68. package/src/locales/index.ts +19 -19
  69. package/src/locales/provider.tsx +59 -59
  70. package/src/locales/types.ts +77 -77
  71. package/src/order/index.ts +62 -62
  72. package/src/order/labels.ts +79 -79
  73. package/src/order/order-totals.tsx +250 -250
  74. package/src/primitives/select-field.tsx +93 -93
  75. package/src/products/image-gallery.tsx +43 -43
  76. package/src/products/index.ts +68 -59
  77. package/src/products/option-select.tsx +67 -67
  78. package/src/products/product-actions-wrapper.tsx +63 -62
  79. package/src/products/product-actions.tsx +151 -150
  80. package/src/products/product-price.tsx +69 -69
  81. package/src/products/product-promises.tsx +61 -61
  82. package/src/products/product-specs.tsx +65 -65
  83. package/src/products/product-tabs.tsx +123 -123
  84. package/src/products/product-template.tsx +163 -162
  85. package/src/products/purchase-options.tsx +130 -130
  86. package/src/products/sets.ts +110 -0
  87. package/src/products/use-product-actions.ts +199 -164
  88. package/src/products/variant-matching.ts +71 -71
  89. package/src/products/variant-url.ts +74 -74
  90. package/src/reviews-ui/index.ts +73 -73
  91. package/src/store/category-template.tsx +136 -136
  92. package/src/store/index.ts +40 -40
  93. package/src/tracking/chatgpt-pixel.tsx +99 -99
  94. package/src/tracking/consent-init.tsx +62 -62
  95. package/src/tracking/events.ts +348 -348
  96. package/src/tracking/ga4.tsx +93 -93
  97. package/src/tracking/google-ads.ts +84 -84
  98. package/src/tracking/gtm.tsx +60 -60
  99. package/src/tracking/inline-script.ts +49 -49
  100. package/src/tracking/oaiq.ts +206 -206
  101. package/src/tracking/tiktok-pixel.tsx +91 -91
  102. package/src/tracking/track-init.tsx +56 -56
  103. package/src/tracking/track-order-purchase.tsx +122 -122
  104. package/src/tracking/ttq.ts +180 -180
  105. package/src/tracking/use-tracking-config.ts +54 -54
  106. package/theme/index.css +25 -25
  107. package/theme/theme.css +150 -150
  108. package/theme/tokens.css +106 -106
@@ -1,162 +1,162 @@
1
- "use client"
2
-
3
- import { StoreImage as Image } from "../../lib/media-image"
4
- import Link from "next/link"
5
- import { type ReactNode } from "react"
6
-
7
- import type { CartLineItem } from "../../api/carts"
8
- import { Price } from "../../lib/price"
9
- import { cn } from "../../lib/utils"
10
- import { useCartDrawer } from "../context"
11
- import { CartItemQuantity } from "./quantity"
12
- import { CartItemVariant } from "./variant"
13
-
14
- /**
15
- * CartItem — a single cart line row: thumbnail, title, variant, quantity
16
- * stepper, price (with optional strikethrough), remove button.
17
- *
18
- * Ported from `@1click/ui/src/cart-drawer/item/index.tsx` (v2.3.1). Cartbase
19
- * data seam: `item` is the SDK's decorated `CartLineItem`
20
- * (`@cartbase/storefront/api/carts`) — per-line `total`/`original_total` are
21
- * server-computed EUR major units. Remove goes through the provider's
22
- * SDK-wired `removeItem` (DELETE line-items/:id, returns the full decorated
23
- * cart) instead of the Medusa server action. The variant row renders the
24
- * line's flat `variant_title` — Cartbase cart lines carry the variant title
25
- * string, not an embedded variant object.
26
- *
27
- * `children` is rendered below the main row — intended for per-item upsell
28
- * slots (see `CartItemUpsell` in `./upsell.tsx`).
29
- */
30
-
31
- type CartItemProps = {
32
- item: CartLineItem
33
- currencyCode: string
34
- children?: ReactNode
35
- }
36
-
37
- export function CartItem({ item, currencyCode, children }: CartItemProps) {
38
- const { labels, hrefs, removeItem } = useCartDrawer()
39
-
40
- // Optimistic remove: the item is filtered out of the optimistic cart
41
- // immediately on click, so the row vanishes at React update speed.
42
- // The deleteLineItem SDK call runs in the background. On failure
43
- // useOptimistic auto-reverts (confirmed snapshot unchanged) and the
44
- // row reappears.
45
- const handleRemove = () => {
46
- void removeItem(item.id)
47
- }
48
- const removing = false // optimistic remove unmounts the row immediately
49
-
50
- const total = item.total ?? 0
51
- const originalTotal = item.original_total ?? 0
52
- const hasDiscount = total < originalTotal
53
- const productHref = `${hrefs.productPrefix}/${item.product_handle}`
54
-
55
- return (
56
- <div
57
- className={cn(
58
- "px-6 py-5 transition-opacity",
59
- removing && "opacity-30 pointer-events-none"
60
- )}
61
- >
62
- <div className="flex gap-4">
63
- <Link href={productHref} className="flex-shrink-0">
64
- <div
65
- className="w-[64px] h-[84px] sm:w-[72px] sm:h-[72px] rounded-xl overflow-hidden bg-muted relative"
66
- style={{ boxShadow: "0 1px 3px rgba(0,0,0,0.06)" }}
67
- >
68
- {item.thumbnail ? (
69
- <Image
70
- src={item.thumbnail}
71
- alt={item.product_title || ""}
72
- fill
73
- className="object-cover"
74
- sizes="72px"
75
- />
76
- ) : (
77
- <div className="w-full h-full flex items-center justify-center">
78
- <svg
79
- width="20"
80
- height="20"
81
- viewBox="0 0 24 24"
82
- fill="none"
83
- stroke="currentColor"
84
- className="text-muted-foreground"
85
- strokeWidth="1.2"
86
- >
87
- <rect x="3" y="3" width="18" height="18" rx="3" />
88
- <circle cx="8.5" cy="8.5" r="1.5" />
89
- <path
90
- d="M21 15l-5-5L5 21"
91
- strokeLinecap="round"
92
- strokeLinejoin="round"
93
- />
94
- </svg>
95
- </div>
96
- )}
97
- </div>
98
- </Link>
99
-
100
- <div className="flex-1 min-w-0 flex flex-col">
101
- <div className="flex items-baseline justify-between gap-3">
102
- <div className="min-w-0">
103
- <Link
104
- href={productHref}
105
- className="text-sm font-semibold text-foreground hover:text-muted-foreground transition-colors line-clamp-2 leading-snug block"
106
- >
107
- {item.product_title}
108
- </Link>
109
- <CartItemVariant variantTitle={item.variant_title} />
110
- </div>
111
- <div className="text-right flex-shrink-0">
112
- {hasDiscount && (
113
- <span className="text-xs text-muted-foreground line-through block leading-none mb-0.5">
114
- <Price
115
- amount={originalTotal}
116
- currencyCode={currencyCode}
117
- className="text-xs text-muted-foreground"
118
- />
119
- </span>
120
- )}
121
- <Price
122
- amount={total}
123
- currencyCode={currencyCode}
124
- className={cn(
125
- "text-sm font-bold",
126
- hasDiscount ? "text-success" : "text-foreground"
127
- )}
128
- />
129
- </div>
130
- </div>
131
-
132
- <div className="flex items-center justify-between mt-3">
133
- <CartItemQuantity lineId={item.id} quantity={item.quantity} />
134
- <button
135
- type="button"
136
- onClick={handleRemove}
137
- className="w-8 h-8 flex items-center justify-center rounded-lg text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
138
- aria-label={labels.remove}
139
- >
140
- <svg
141
- width="16"
142
- height="16"
143
- viewBox="0 0 16 16"
144
- fill="none"
145
- stroke="currentColor"
146
- strokeWidth="1.3"
147
- strokeLinecap="round"
148
- strokeLinejoin="round"
149
- >
150
- <path d="M2.5 4.5h11M5.5 4.5V3a1 1 0 011-1h3a1 1 0 011 1v1.5M12.5 4.5v9a1 1 0 01-1 1h-7a1 1 0 01-1-1v-9" />
151
- <path d="M6.5 7v4M9.5 7v4" />
152
- </svg>
153
- </button>
154
- </div>
155
- </div>
156
- </div>
157
-
158
- {children}
159
- <div className="mt-5 border-b border-border" />
160
- </div>
161
- )
162
- }
1
+ "use client"
2
+
3
+ import { StoreImage as Image } from "../../lib/media-image"
4
+ import Link from "next/link"
5
+ import { type ReactNode } from "react"
6
+
7
+ import type { CartLineItem } from "../../api/carts"
8
+ import { Price } from "../../lib/price"
9
+ import { cn } from "../../lib/utils"
10
+ import { useCartDrawer } from "../context"
11
+ import { CartItemQuantity } from "./quantity"
12
+ import { CartItemVariant } from "./variant"
13
+
14
+ /**
15
+ * CartItem — a single cart line row: thumbnail, title, variant, quantity
16
+ * stepper, price (with optional strikethrough), remove button.
17
+ *
18
+ * Ported from `@1click/ui/src/cart-drawer/item/index.tsx` (v2.3.1). Cartbase
19
+ * data seam: `item` is the SDK's decorated `CartLineItem`
20
+ * (`@cartbase/storefront/api/carts`) — per-line `total`/`original_total` are
21
+ * server-computed EUR major units. Remove goes through the provider's
22
+ * SDK-wired `removeItem` (DELETE line-items/:id, returns the full decorated
23
+ * cart) instead of the Medusa server action. The variant row renders the
24
+ * line's flat `variant_title` — Cartbase cart lines carry the variant title
25
+ * string, not an embedded variant object.
26
+ *
27
+ * `children` is rendered below the main row — intended for per-item upsell
28
+ * slots (see `CartItemUpsell` in `./upsell.tsx`).
29
+ */
30
+
31
+ type CartItemProps = {
32
+ item: CartLineItem
33
+ currencyCode: string
34
+ children?: ReactNode
35
+ }
36
+
37
+ export function CartItem({ item, currencyCode, children }: CartItemProps) {
38
+ const { labels, hrefs, removeItem } = useCartDrawer()
39
+
40
+ // Optimistic remove: the item is filtered out of the optimistic cart
41
+ // immediately on click, so the row vanishes at React update speed.
42
+ // The deleteLineItem SDK call runs in the background. On failure
43
+ // useOptimistic auto-reverts (confirmed snapshot unchanged) and the
44
+ // row reappears.
45
+ const handleRemove = () => {
46
+ void removeItem(item.id)
47
+ }
48
+ const removing = false // optimistic remove unmounts the row immediately
49
+
50
+ const total = item.total ?? 0
51
+ const originalTotal = item.original_total ?? 0
52
+ const hasDiscount = total < originalTotal
53
+ const productHref = `${hrefs.productPrefix}/${item.product_handle}`
54
+
55
+ return (
56
+ <div
57
+ className={cn(
58
+ "px-6 py-5 transition-opacity",
59
+ removing && "opacity-30 pointer-events-none"
60
+ )}
61
+ >
62
+ <div className="flex gap-4">
63
+ <Link href={productHref} className="flex-shrink-0">
64
+ <div
65
+ className="w-[64px] h-[84px] sm:w-[72px] sm:h-[72px] rounded-xl overflow-hidden bg-muted relative"
66
+ style={{ boxShadow: "0 1px 3px rgba(0,0,0,0.06)" }}
67
+ >
68
+ {item.thumbnail ? (
69
+ <Image
70
+ src={item.thumbnail}
71
+ alt={item.product_title || ""}
72
+ fill
73
+ className="object-cover"
74
+ sizes="72px"
75
+ />
76
+ ) : (
77
+ <div className="w-full h-full flex items-center justify-center">
78
+ <svg
79
+ width="20"
80
+ height="20"
81
+ viewBox="0 0 24 24"
82
+ fill="none"
83
+ stroke="currentColor"
84
+ className="text-muted-foreground"
85
+ strokeWidth="1.2"
86
+ >
87
+ <rect x="3" y="3" width="18" height="18" rx="3" />
88
+ <circle cx="8.5" cy="8.5" r="1.5" />
89
+ <path
90
+ d="M21 15l-5-5L5 21"
91
+ strokeLinecap="round"
92
+ strokeLinejoin="round"
93
+ />
94
+ </svg>
95
+ </div>
96
+ )}
97
+ </div>
98
+ </Link>
99
+
100
+ <div className="flex-1 min-w-0 flex flex-col">
101
+ <div className="flex items-baseline justify-between gap-3">
102
+ <div className="min-w-0">
103
+ <Link
104
+ href={productHref}
105
+ className="text-sm font-semibold text-foreground hover:text-muted-foreground transition-colors line-clamp-2 leading-snug block"
106
+ >
107
+ {item.product_title}
108
+ </Link>
109
+ <CartItemVariant variantTitle={item.variant_title} />
110
+ </div>
111
+ <div className="text-right flex-shrink-0">
112
+ {hasDiscount && (
113
+ <span className="text-xs text-muted-foreground line-through block leading-none mb-0.5">
114
+ <Price
115
+ amount={originalTotal}
116
+ currencyCode={currencyCode}
117
+ className="text-xs text-muted-foreground"
118
+ />
119
+ </span>
120
+ )}
121
+ <Price
122
+ amount={total}
123
+ currencyCode={currencyCode}
124
+ className={cn(
125
+ "text-sm font-bold",
126
+ hasDiscount ? "text-success" : "text-foreground"
127
+ )}
128
+ />
129
+ </div>
130
+ </div>
131
+
132
+ <div className="flex items-center justify-between mt-3">
133
+ <CartItemQuantity lineId={item.id} quantity={item.quantity} />
134
+ <button
135
+ type="button"
136
+ onClick={handleRemove}
137
+ className="w-8 h-8 flex items-center justify-center rounded-lg text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-all"
138
+ aria-label={labels.remove}
139
+ >
140
+ <svg
141
+ width="16"
142
+ height="16"
143
+ viewBox="0 0 16 16"
144
+ fill="none"
145
+ stroke="currentColor"
146
+ strokeWidth="1.3"
147
+ strokeLinecap="round"
148
+ strokeLinejoin="round"
149
+ >
150
+ <path d="M2.5 4.5h11M5.5 4.5V3a1 1 0 011-1h3a1 1 0 011 1v1.5M12.5 4.5v9a1 1 0 01-1 1h-7a1 1 0 01-1-1v-9" />
151
+ <path d="M6.5 7v4M9.5 7v4" />
152
+ </svg>
153
+ </button>
154
+ </div>
155
+ </div>
156
+ </div>
157
+
158
+ {children}
159
+ <div className="mt-5 border-b border-border" />
160
+ </div>
161
+ )
162
+ }
@@ -1,18 +1,16 @@
1
1
  "use client"
2
2
 
3
- import { useState } from "react"
4
-
5
3
  import { cn } from "../../lib/utils"
6
4
  import { useCartDrawer } from "../context"
7
5
 
8
6
  /**
9
7
  * CartItemQuantity — +/- quantity stepper for a single cart line item.
10
8
  *
11
- * Optimistic: the provider's `updateQuantity` dispatches an
12
- * "update_quantity" action against the useOptimistic state BEFORE awaiting
13
- * the SDK call. The stepper number changes instantly; the server work
14
- * happens in the background. On failure the optimistic state reverts
15
- * automatically (confirmed snapshot unchanged).
9
+ * Instant: every click shows at once and the stepper never locks; the
10
+ * provider combines fast clicks on one line and sends the number the
11
+ * shopper stopped at (`mutation-queue.ts`). On failure the optimistic state
12
+ * reverts automatically (confirmed snapshot unchanged). Until 2026-09-16
13
+ * the stepper locked for the whole round trip, so fast clicks were lost.
16
14
  *
17
15
  * Ported from `@1click/ui/src/cart-drawer/item/quantity.tsx` (v2.3.1).
18
16
  * Cartbase data seam: `updateQuantity` wraps
@@ -36,21 +34,17 @@ export function CartItemQuantity({
36
34
  className,
37
35
  }: CartItemQuantityProps) {
38
36
  const { updateQuantity } = useCartDrawer()
39
- const [updating, setUpdating] = useState(false)
40
37
 
41
38
  const change = (newQty: number) => {
42
- if (newQty < 1 || newQty > maxQuantity || updating) return
43
- setUpdating(true)
44
- // Resolves true/false when the server settles; failures are funneled
45
- // through the provider's onOptimisticError.
46
- void updateQuantity(lineId, newQty).finally(() => setUpdating(false))
39
+ if (newQty < 1 || newQty > maxQuantity) return
40
+ // Failures are funneled through the provider's onOptimisticError.
41
+ void updateQuantity(lineId, newQty)
47
42
  }
48
43
 
49
44
  return (
50
45
  <div
51
46
  className={cn(
52
47
  "inline-flex items-center border border-border rounded-lg h-9",
53
- updating && "opacity-50 pointer-events-none",
54
48
  className
55
49
  )}
56
50
  >
@@ -1,110 +1,110 @@
1
- "use client"
2
-
3
- import { StoreImage as Image } from "../../lib/media-image"
4
- import Link from "next/link"
5
-
6
- import { convertToLocale } from "../../lib/money"
7
- import { useCartDrawer } from "../context"
8
-
9
- /**
10
- * CartItemUpsell — slot rendered INSIDE a cart item for per-product
11
- * complementary recommendations. Accepts a list of products and an
12
- * onAdd handler.
13
- *
14
- * Ported from `@1click/ui/src/cart-drawer/item/upsell.tsx` (v2.3.1).
15
- * Cartbase data seam: source stores fed this from Medusa product fetches —
16
- * feed it from `@cartbase/storefront/api/search` `listRelatedProducts`
17
- * (per-anchor complements: manual admin picks + deterministic fallback)
18
- * mapped via `toCrossSellProduct` (`../cross-sell-sidebar`). `variantId`
19
- * was added to `UpsellProduct` and to the `onAdd` signature so the handler
20
- * can call the provider's `addItem(variantId)` directly — Cartbase carts add
21
- * by variant id, not product id. Prices are EUR major units.
22
- */
23
-
24
- export type UpsellProduct = {
25
- id: string
26
- title: string
27
- handle: string
28
- thumbnail: string | null
29
- price: number
30
- currencyCode: string
31
- /** Variant to add (Cartbase add-to-cart is by variant id). */
32
- variantId?: string
33
- }
34
-
35
- type CartItemUpsellProps = {
36
- label?: string
37
- products: UpsellProduct[]
38
- onAdd?: (productId: string, variantId?: string) => void
39
- }
40
-
41
- export function CartItemUpsell({
42
- label,
43
- products,
44
- onAdd,
45
- }: CartItemUpsellProps) {
46
- const { labels, hrefs } = useCartDrawer()
47
- if (!products.length) return null
48
-
49
- return (
50
- <div className="mt-3 pt-3 border-t border-border">
51
- <p className="text-xs font-medium text-muted-foreground mb-2">
52
- {label ?? labels.pairsWellWith}
53
- </p>
54
- <div className="space-y-2">
55
- {products.map((product) => (
56
- <div
57
- key={product.id}
58
- className="flex items-center gap-3 p-2 rounded-lg bg-muted hover:bg-muted/70 transition-colors"
59
- >
60
- <div className="w-10 h-10 rounded-lg overflow-hidden bg-card flex-shrink-0 relative">
61
- {product.thumbnail ? (
62
- <Image
63
- src={product.thumbnail}
64
- alt={product.title}
65
- fill
66
- className="object-cover"
67
- sizes="40px"
68
- />
69
- ) : (
70
- <div className="w-full h-full bg-muted" />
71
- )}
72
- </div>
73
- <div className="flex-1 min-w-0">
74
- <Link
75
- href={`${hrefs.productPrefix}/${product.handle}`}
76
- className="text-xs font-medium text-foreground truncate block"
77
- >
78
- {product.title}
79
- </Link>
80
- <span className="text-xs text-muted-foreground">
81
- {convertToLocale({
82
- amount: product.price,
83
- currency_code: product.currencyCode,
84
- })}
85
- </span>
86
- </div>
87
- <button
88
- type="button"
89
- onClick={() => onAdd?.(product.id, product.variantId)}
90
- className="w-7 h-7 flex items-center justify-center rounded-full border border-border text-muted-foreground hover:border-text-base hover:text-foreground transition-colors flex-shrink-0"
91
- aria-label={`${labels.addToCart} ${product.title}`}
92
- >
93
- <svg
94
- width="12"
95
- height="12"
96
- viewBox="0 0 12 12"
97
- fill="none"
98
- stroke="currentColor"
99
- strokeWidth="1.5"
100
- strokeLinecap="round"
101
- >
102
- <path d="M6 2.5v7M2.5 6h7" />
103
- </svg>
104
- </button>
105
- </div>
106
- ))}
107
- </div>
108
- </div>
109
- )
110
- }
1
+ "use client"
2
+
3
+ import { StoreImage as Image } from "../../lib/media-image"
4
+ import Link from "next/link"
5
+
6
+ import { convertToLocale } from "../../lib/money"
7
+ import { useCartDrawer } from "../context"
8
+
9
+ /**
10
+ * CartItemUpsell — slot rendered INSIDE a cart item for per-product
11
+ * complementary recommendations. Accepts a list of products and an
12
+ * onAdd handler.
13
+ *
14
+ * Ported from `@1click/ui/src/cart-drawer/item/upsell.tsx` (v2.3.1).
15
+ * Cartbase data seam: source stores fed this from Medusa product fetches —
16
+ * feed it from `@cartbase/storefront/api/search` `listRelatedProducts`
17
+ * (per-anchor complements: manual admin picks + deterministic fallback)
18
+ * mapped via `toCrossSellProduct` (`../cross-sell-sidebar`). `variantId`
19
+ * was added to `UpsellProduct` and to the `onAdd` signature so the handler
20
+ * can call the provider's `addItem(variantId)` directly — Cartbase carts add
21
+ * by variant id, not product id. Prices are EUR major units.
22
+ */
23
+
24
+ export type UpsellProduct = {
25
+ id: string
26
+ title: string
27
+ handle: string
28
+ thumbnail: string | null
29
+ price: number
30
+ currencyCode: string
31
+ /** Variant to add (Cartbase add-to-cart is by variant id). */
32
+ variantId?: string
33
+ }
34
+
35
+ type CartItemUpsellProps = {
36
+ label?: string
37
+ products: UpsellProduct[]
38
+ onAdd?: (productId: string, variantId?: string) => void
39
+ }
40
+
41
+ export function CartItemUpsell({
42
+ label,
43
+ products,
44
+ onAdd,
45
+ }: CartItemUpsellProps) {
46
+ const { labels, hrefs } = useCartDrawer()
47
+ if (!products.length) return null
48
+
49
+ return (
50
+ <div className="mt-3 pt-3 border-t border-border">
51
+ <p className="text-xs font-medium text-muted-foreground mb-2">
52
+ {label ?? labels.pairsWellWith}
53
+ </p>
54
+ <div className="space-y-2">
55
+ {products.map((product) => (
56
+ <div
57
+ key={product.id}
58
+ className="flex items-center gap-3 p-2 rounded-lg bg-muted hover:bg-muted/70 transition-colors"
59
+ >
60
+ <div className="w-10 h-10 rounded-lg overflow-hidden bg-card flex-shrink-0 relative">
61
+ {product.thumbnail ? (
62
+ <Image
63
+ src={product.thumbnail}
64
+ alt={product.title}
65
+ fill
66
+ className="object-cover"
67
+ sizes="40px"
68
+ />
69
+ ) : (
70
+ <div className="w-full h-full bg-muted" />
71
+ )}
72
+ </div>
73
+ <div className="flex-1 min-w-0">
74
+ <Link
75
+ href={`${hrefs.productPrefix}/${product.handle}`}
76
+ className="text-xs font-medium text-foreground truncate block"
77
+ >
78
+ {product.title}
79
+ </Link>
80
+ <span className="text-xs text-muted-foreground">
81
+ {convertToLocale({
82
+ amount: product.price,
83
+ currency_code: product.currencyCode,
84
+ })}
85
+ </span>
86
+ </div>
87
+ <button
88
+ type="button"
89
+ onClick={() => onAdd?.(product.id, product.variantId)}
90
+ className="w-7 h-7 flex items-center justify-center rounded-full border border-border text-muted-foreground hover:border-text-base hover:text-foreground transition-colors flex-shrink-0"
91
+ aria-label={`${labels.addToCart} ${product.title}`}
92
+ >
93
+ <svg
94
+ width="12"
95
+ height="12"
96
+ viewBox="0 0 12 12"
97
+ fill="none"
98
+ stroke="currentColor"
99
+ strokeWidth="1.5"
100
+ strokeLinecap="round"
101
+ >
102
+ <path d="M6 2.5v7M2.5 6h7" />
103
+ </svg>
104
+ </button>
105
+ </div>
106
+ ))}
107
+ </div>
108
+ </div>
109
+ )
110
+ }