@cartbase/storefront 0.1.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 (176) hide show
  1. package/package.json +226 -0
  2. package/src/api/auth.ts +108 -0
  3. package/src/api/carts.ts +506 -0
  4. package/src/api/categories.ts +184 -0
  5. package/src/api/checkout.ts +440 -0
  6. package/src/api/collections.ts +130 -0
  7. package/src/api/consent.ts +75 -0
  8. package/src/api/content.ts +125 -0
  9. package/src/api/customers.ts +307 -0
  10. package/src/api/gift-cards.ts +112 -0
  11. package/src/api/http.ts +122 -0
  12. package/src/api/index.ts +29 -0
  13. package/src/api/integrations.ts +130 -0
  14. package/src/api/menus.ts +77 -0
  15. package/src/api/metaobjects.ts +136 -0
  16. package/src/api/orders.ts +290 -0
  17. package/src/api/products.ts +303 -0
  18. package/src/api/redirects.ts +37 -0
  19. package/src/api/regions.ts +141 -0
  20. package/src/api/reviews.ts +259 -0
  21. package/src/api/search.ts +133 -0
  22. package/src/api/types.ts +91 -0
  23. package/src/cart-drawer/cart-drawer.tsx +86 -0
  24. package/src/cart-drawer/context.tsx +569 -0
  25. package/src/cart-drawer/continue-shopping.tsx +28 -0
  26. package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
  27. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
  28. package/src/cart-drawer/empty.tsx +52 -0
  29. package/src/cart-drawer/free-gift.tsx +71 -0
  30. package/src/cart-drawer/gift-wrap.tsx +83 -0
  31. package/src/cart-drawer/header.tsx +52 -0
  32. package/src/cart-drawer/index.ts +69 -0
  33. package/src/cart-drawer/item/index.tsx +164 -0
  34. package/src/cart-drawer/item/quantity.tsx +100 -0
  35. package/src/cart-drawer/item/upsell.tsx +110 -0
  36. package/src/cart-drawer/item/variant.tsx +46 -0
  37. package/src/cart-drawer/labels-bg.ts +72 -0
  38. package/src/cart-drawer/labels.ts +119 -0
  39. package/src/cart-drawer/notes.tsx +131 -0
  40. package/src/cart-drawer/payment-badges.tsx +96 -0
  41. package/src/cart-drawer/promo-banner.tsx +43 -0
  42. package/src/cart-drawer/rewards-points.tsx +78 -0
  43. package/src/cart-drawer/sticky-footer.tsx +73 -0
  44. package/src/cart-drawer/summary-breakdown.tsx +196 -0
  45. package/src/cart-drawer/template.tsx +225 -0
  46. package/src/cart-drawer/tiered-progress.tsx +168 -0
  47. package/src/checkout/address-error-copy.ts +119 -0
  48. package/src/checkout/address-form.tsx +224 -0
  49. package/src/checkout/address-select.tsx +79 -0
  50. package/src/checkout/boxnow-locker-selector.tsx +410 -0
  51. package/src/checkout/checkout-client.tsx +222 -0
  52. package/src/checkout/company-details.tsx +94 -0
  53. package/src/checkout/compare-addresses.ts +40 -0
  54. package/src/checkout/context.tsx +76 -0
  55. package/src/checkout/discount-section.tsx +218 -0
  56. package/src/checkout/econt-office-selector.tsx +332 -0
  57. package/src/checkout/error-message.tsx +25 -0
  58. package/src/checkout/geocode.ts +154 -0
  59. package/src/checkout/gift-card-section.tsx +224 -0
  60. package/src/checkout/index.ts +74 -0
  61. package/src/checkout/labels-bg.ts +128 -0
  62. package/src/checkout/labels.ts +263 -0
  63. package/src/checkout/line-item-card.tsx +152 -0
  64. package/src/checkout/order-summary.tsx +524 -0
  65. package/src/checkout/payment-button.tsx +373 -0
  66. package/src/checkout/payment-error-copy.ts +269 -0
  67. package/src/checkout/payment-method-list.tsx +365 -0
  68. package/src/checkout/payment-wrapper.tsx +102 -0
  69. package/src/checkout/promotion-error-copy.ts +124 -0
  70. package/src/checkout/shipping-method-list.tsx +335 -0
  71. package/src/checkout/stripe-wrapper.tsx +165 -0
  72. package/src/checkout/use-checkout-orchestration.ts +1504 -0
  73. package/src/common/cart-button-client.tsx +39 -0
  74. package/src/common/cart-button.tsx +28 -0
  75. package/src/common/country-select.tsx +65 -0
  76. package/src/common/delete-button.tsx +66 -0
  77. package/src/common/index.ts +17 -0
  78. package/src/common/language-select.tsx +78 -0
  79. package/src/common/localized-link.tsx +45 -0
  80. package/src/common/skeleton.tsx +29 -0
  81. package/src/index.ts +12 -0
  82. package/src/lib/cart-helpers.ts +113 -0
  83. package/src/lib/dual-price.tsx +73 -0
  84. package/src/lib/get-percentage-diff.ts +5 -0
  85. package/src/lib/get-product-price.ts +133 -0
  86. package/src/lib/hooks/use-intersection.ts +30 -0
  87. package/src/lib/hooks/use-toggle-state.ts +25 -0
  88. package/src/lib/money.ts +73 -0
  89. package/src/lib/payment-constants.ts +66 -0
  90. package/src/lib/product.ts +22 -0
  91. package/src/lib/sort-products.ts +63 -0
  92. package/src/lib/store-api-error.ts +36 -0
  93. package/src/lib/utils.ts +16 -0
  94. package/src/order/context.tsx +32 -0
  95. package/src/order/index.ts +63 -0
  96. package/src/order/labels-bg.ts +39 -0
  97. package/src/order/labels.ts +79 -0
  98. package/src/order/order-address-card.tsx +47 -0
  99. package/src/order/order-completed-template.tsx +165 -0
  100. package/src/order/order-confirmation-header.tsx +65 -0
  101. package/src/order/order-delivery-card.tsx +258 -0
  102. package/src/order/order-help-section.tsx +47 -0
  103. package/src/order/order-item.tsx +201 -0
  104. package/src/order/order-items-list.tsx +52 -0
  105. package/src/order/order-payment-card.tsx +95 -0
  106. package/src/order/order-timeline.tsx +141 -0
  107. package/src/order/order-totals.tsx +245 -0
  108. package/src/primitives/field.tsx +125 -0
  109. package/src/primitives/select-field.tsx +77 -0
  110. package/src/primitives/ui/accordion.tsx +61 -0
  111. package/src/primitives/ui/button.tsx +68 -0
  112. package/src/primitives/ui/collapsible.tsx +16 -0
  113. package/src/primitives/ui/dialog.tsx +112 -0
  114. package/src/primitives/ui/input.tsx +30 -0
  115. package/src/primitives/ui/label.tsx +31 -0
  116. package/src/primitives/ui/popover.tsx +38 -0
  117. package/src/primitives/ui/select.tsx +163 -0
  118. package/src/primitives/ui/sheet.tsx +131 -0
  119. package/src/primitives/ui/tabs.tsx +62 -0
  120. package/src/products/context.tsx +34 -0
  121. package/src/products/image-gallery.tsx +43 -0
  122. package/src/products/index.ts +44 -0
  123. package/src/products/labels-bg.ts +35 -0
  124. package/src/products/labels.ts +57 -0
  125. package/src/products/mobile-actions.tsx +180 -0
  126. package/src/products/option-select.tsx +67 -0
  127. package/src/products/preview-price.tsx +36 -0
  128. package/src/products/product-actions-wrapper.tsx +58 -0
  129. package/src/products/product-actions.tsx +217 -0
  130. package/src/products/product-info.tsx +43 -0
  131. package/src/products/product-preview.tsx +49 -0
  132. package/src/products/product-price.tsx +69 -0
  133. package/src/products/product-tabs.tsx +169 -0
  134. package/src/products/product-template.tsx +114 -0
  135. package/src/products/purchase-options.tsx +130 -0
  136. package/src/products/related-products.tsx +86 -0
  137. package/src/products/thumbnail.tsx +71 -0
  138. package/src/products/variant-matching.ts +71 -0
  139. package/src/reviews-ui/helpers.ts +174 -0
  140. package/src/reviews-ui/index.ts +74 -0
  141. package/src/reviews-ui/labels-bg.ts +91 -0
  142. package/src/reviews-ui/labels.ts +199 -0
  143. package/src/reviews-ui/photo-upload.tsx +345 -0
  144. package/src/reviews-ui/review-list.tsx +249 -0
  145. package/src/reviews-ui/review-widget.tsx +224 -0
  146. package/src/reviews-ui/review-wizard.tsx +560 -0
  147. package/src/reviews-ui/star-badge.tsx +104 -0
  148. package/src/reviews-ui/wizard-state.ts +81 -0
  149. package/src/store/category-template.tsx +129 -0
  150. package/src/store/collection-template.tsx +139 -0
  151. package/src/store/index.ts +41 -0
  152. package/src/store/labels-bg.ts +22 -0
  153. package/src/store/labels.ts +52 -0
  154. package/src/store/paginated-products.tsx +116 -0
  155. package/src/store/pagination.tsx +103 -0
  156. package/src/store/search-params.ts +256 -0
  157. package/src/store/search-template.tsx +249 -0
  158. package/src/store/skeleton-product-grid.tsx +26 -0
  159. package/src/store/sort-select.tsx +81 -0
  160. package/src/store/store-template.tsx +65 -0
  161. package/src/tracking/attribution.ts +418 -0
  162. package/src/tracking/consent-banner.tsx +355 -0
  163. package/src/tracking/consent-init.tsx +44 -0
  164. package/src/tracking/consent.ts +243 -0
  165. package/src/tracking/fbq.ts +168 -0
  166. package/src/tracking/ga4.tsx +49 -0
  167. package/src/tracking/get-tracking-attribution.ts +224 -0
  168. package/src/tracking/get-tracking-config.ts +50 -0
  169. package/src/tracking/gtag.ts +200 -0
  170. package/src/tracking/index.ts +133 -0
  171. package/src/tracking/meta-pixel.tsx +166 -0
  172. package/src/tracking/rybbit-events.ts +242 -0
  173. package/src/tracking/rybbit.tsx +40 -0
  174. package/src/tracking/types.ts +185 -0
  175. package/src/tracking/use-engagement-time.ts +58 -0
  176. package/tailwind-preset.cjs +72 -0
@@ -0,0 +1,217 @@
1
+ "use client"
2
+
3
+ /**
4
+ * PDP add-to-cart panel — ported from
5
+ * `@1click/ui/src/products/product-actions.tsx` (v2.3.1).
6
+ *
7
+ * Data-seam adaptations (batch-10 port):
8
+ * - `HttpTypes.*` → barter SDK DTOs (`@cartbase/storefront/api/products`).
9
+ * - The `addToCart` server action (Medusa js-sdk, cart-cookie owner) is now
10
+ * an injected prop: the host app wires it to its own cart orchestration
11
+ * (typically `api/carts` `addLineItem` + its cart-id cookie). Same for the
12
+ * cart drawer: `openCart` replaces the `useCartDrawer()` context import so
13
+ * the products family has no hard dependency on the cart-drawer family.
14
+ * - `countryCode` (Medusa region routing) is dropped — barter pricing
15
+ * context travels with the product fetch, and the host's `addToCart`
16
+ * closes over whatever routing it needs.
17
+ * - Stock: barter's store surface does NOT expose `inventory_quantity`
18
+ * (availability is a search facet; the server enforces stock at add with
19
+ * 400 `insufficient_inventory`). Managed-inventory variants without an
20
+ * exposed quantity are treated as in stock optimistically; a thrown
21
+ * `insufficient_inventory` flips the button to the out-of-stock state.
22
+ * Richer DTOs that DO carry `inventory_quantity` keep the original
23
+ * quantity check.
24
+ *
25
+ * Variant matching is the extracted pure module `./variant-matching`
26
+ * (handles barter's option-value link shape).
27
+ */
28
+ import { usePathname, useRouter, useSearchParams } from "next/navigation"
29
+ import { useEffect, useMemo, useRef, useState } from "react"
30
+
31
+ import type { StoreProduct, StoreProductVariant } from "../api/products"
32
+ import { useIntersection } from "../lib/hooks/use-intersection"
33
+ import { Button } from "../primitives/ui/button"
34
+ import { useProductLabels } from "./context"
35
+ import { OptionSelect } from "./option-select"
36
+ import { ProductPrice } from "./product-price"
37
+ import { MobileActions } from "./mobile-actions"
38
+ import { findMatchingVariant, optionsAsKeymap, optionsMatch } from "./variant-matching"
39
+
40
+ /** Input the host's cart seam receives on add. */
41
+ export type AddToCartInput = {
42
+ variantId: string
43
+ quantity: number
44
+ }
45
+
46
+ type ProductActionsProps = {
47
+ product: StoreProduct
48
+ /**
49
+ * The cart seam: called with the selected variant. Wire to the host's
50
+ * cart orchestration (`api/carts` `addLineItem` behind the host's cart-id
51
+ * storage). May throw `StoreApiError` — `insufficient_inventory` is
52
+ * handled here (button flips to out-of-stock).
53
+ */
54
+ addToCart: (input: AddToCartInput) => Promise<void>
55
+ disabled?: boolean
56
+ /** Fired after a successful add (analytics: trackAddToCart trio). */
57
+ onAddToCart?: (product: StoreProduct, variant: StoreProductVariant) => void
58
+ /** Open the host's cart drawer after a successful add. */
59
+ openCart?: () => void
60
+ }
61
+
62
+ export function ProductActions({
63
+ product,
64
+ addToCart,
65
+ disabled,
66
+ onAddToCart,
67
+ openCart,
68
+ }: ProductActionsProps) {
69
+ const labels = useProductLabels()
70
+ const router = useRouter()
71
+ const pathname = usePathname()
72
+ const searchParams = useSearchParams()
73
+
74
+ const [options, setOptions] = useState<Record<string, string | undefined>>({})
75
+ const [isAdding, setIsAdding] = useState(false)
76
+ // Set when the server rejected an add with `insufficient_inventory`
77
+ // (barter exposes no inventory_quantity on the store surface — the server
78
+ // is the stock truth). Cleared when the selection changes.
79
+ const [stockExhausted, setStockExhausted] = useState(false)
80
+
81
+ useEffect(() => {
82
+ if (product.variants?.length === 1) {
83
+ const variantOptions = optionsAsKeymap(product.variants[0]?.options ?? null)
84
+ setOptions(variantOptions ?? {})
85
+ }
86
+ }, [product.variants])
87
+
88
+ const selectedVariant = useMemo(
89
+ () => findMatchingVariant(product.variants, options),
90
+ [product.variants, options]
91
+ )
92
+
93
+ const setOptionValue = (optionId: string, value: string) => {
94
+ setOptions((prev) => ({ ...prev, [optionId]: value }))
95
+ }
96
+
97
+ const isValidVariant = useMemo(() => {
98
+ return product.variants?.some((v) =>
99
+ optionsMatch(options, optionsAsKeymap(v.options))
100
+ )
101
+ }, [product.variants, options])
102
+
103
+ useEffect(() => {
104
+ setStockExhausted(false)
105
+ }, [selectedVariant?.id])
106
+
107
+ useEffect(() => {
108
+ const params = new URLSearchParams(searchParams.toString())
109
+ const value = isValidVariant ? selectedVariant?.id : null
110
+
111
+ if (params.get("v_id") === value) return
112
+
113
+ if (value) {
114
+ params.set("v_id", value)
115
+ } else {
116
+ params.delete("v_id")
117
+ }
118
+
119
+ router.replace(pathname + "?" + params.toString())
120
+ }, [selectedVariant, isValidVariant, searchParams, pathname, router])
121
+
122
+ const inStock = useMemo(() => {
123
+ if (!selectedVariant) return false
124
+ if (!selectedVariant.manage_inventory) return true
125
+ if (selectedVariant.allow_backorder) return true
126
+ // barter wire shape carries no inventory_quantity — optimistic; the
127
+ // server enforces stock on add. Richer DTOs keep the original check.
128
+ const qty = (selectedVariant as { inventory_quantity?: number })
129
+ .inventory_quantity
130
+ if (qty === undefined) return !stockExhausted
131
+ return qty > 0
132
+ }, [selectedVariant, stockExhausted])
133
+
134
+ const actionsRef = useRef<HTMLDivElement>(null)
135
+ const inView = useIntersection(actionsRef, "0px")
136
+
137
+ const handleAddToCart = async () => {
138
+ if (!selectedVariant?.id) return
139
+
140
+ setIsAdding(true)
141
+ try {
142
+ await addToCart({ variantId: selectedVariant.id, quantity: 1 })
143
+ } catch (e) {
144
+ if ((e as { code?: string } | null)?.code === "insufficient_inventory") {
145
+ setStockExhausted(true)
146
+ return
147
+ }
148
+ throw e
149
+ } finally {
150
+ setIsAdding(false)
151
+ }
152
+
153
+ onAddToCart?.(product, selectedVariant)
154
+ openCart?.()
155
+ router.refresh()
156
+ }
157
+
158
+ return (
159
+ <>
160
+ <div className="flex flex-col gap-y-2" ref={actionsRef}>
161
+ <div>
162
+ {(product.variants?.length ?? 0) > 1 && (
163
+ <div className="flex flex-col gap-y-4">
164
+ {(product.options || []).map((option) => (
165
+ <div key={option.id}>
166
+ <OptionSelect
167
+ option={option}
168
+ current={options[option.id]}
169
+ updateOption={setOptionValue}
170
+ title={option.title ?? ""}
171
+ data-testid="product-options"
172
+ disabled={!!disabled || isAdding}
173
+ />
174
+ </div>
175
+ ))}
176
+ <div className="h-px bg-border" />
177
+ </div>
178
+ )}
179
+ </div>
180
+
181
+ <ProductPrice product={product} variant={selectedVariant} />
182
+
183
+ <Button
184
+ onClick={handleAddToCart}
185
+ disabled={
186
+ !inStock ||
187
+ !selectedVariant ||
188
+ !!disabled ||
189
+ isAdding ||
190
+ !isValidVariant
191
+ }
192
+ className="w-full h-10"
193
+ data-testid="add-product-button"
194
+ >
195
+ {!selectedVariant && !options
196
+ ? labels.selectVariant
197
+ : !inStock || !isValidVariant
198
+ ? labels.outOfStock
199
+ : labels.addToCart}
200
+ </Button>
201
+ <MobileActions
202
+ product={product}
203
+ variant={selectedVariant}
204
+ options={options}
205
+ updateOptions={setOptionValue}
206
+ inStock={inStock}
207
+ handleAddToCart={handleAddToCart}
208
+ isAdding={isAdding}
209
+ show={!inView}
210
+ optionsDisabled={!!disabled || isAdding}
211
+ />
212
+ </div>
213
+ </>
214
+ )
215
+ }
216
+
217
+ export { type ProductActionsProps }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * PDP title/collection/description block — ported from
3
+ * `@1click/ui/src/products/product-info.tsx` (v2.3.1). Data seam:
4
+ * `HttpTypes.StoreProduct` → barter `StoreProduct` (the collection embed is
5
+ * `{id, title, handle}` — exactly what the link needs). Server-safe.
6
+ */
7
+ import Link from "next/link"
8
+ import type { StoreProduct } from "../api/products"
9
+
10
+ type ProductInfoProps = {
11
+ product: StoreProduct
12
+ }
13
+
14
+ export function ProductInfo({ product }: ProductInfoProps) {
15
+ return (
16
+ <div id="product-info">
17
+ <div className="flex flex-col gap-y-4 lg:max-w-[500px] mx-auto">
18
+ {product.collection && (
19
+ <Link
20
+ href={`/collections/${product.collection.handle}`}
21
+ className="text-sm text-muted-foreground hover:text-muted-foreground"
22
+ >
23
+ {product.collection.title}
24
+ </Link>
25
+ )}
26
+ <h2
27
+ className="text-3xl leading-10 text-foreground font-bold"
28
+ data-testid="product-title"
29
+ >
30
+ {product.title}
31
+ </h2>
32
+ <p
33
+ className="text-sm text-muted-foreground whitespace-pre-line"
34
+ data-testid="product-description"
35
+ >
36
+ {product.description}
37
+ </p>
38
+ </div>
39
+ </div>
40
+ )
41
+ }
42
+
43
+ export { type ProductInfoProps }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Product card — ported from `@1click/ui/src/products/product-preview.tsx`
3
+ * (v2.3.1). Data seam: `HttpTypes.StoreProduct` → barter `StoreProduct`.
4
+ * The ONE card renderer per the SDK doctrine: search results, collection
5
+ * membership pages, and related products all serve the same canonical
6
+ * product object — reuse this card (or pass a custom one via the templates'
7
+ * `renderProduct`) everywhere. Price comes from the server-computed
8
+ * `variant.calculated_price` via `lib/get-product-price`. Server-safe.
9
+ */
10
+ import Link from "next/link"
11
+ import type { StoreProduct } from "../api/products"
12
+ import { getProductPrice } from "../lib/get-product-price"
13
+ import { Thumbnail } from "./thumbnail"
14
+ import { PreviewPrice } from "./preview-price"
15
+
16
+ type ProductPreviewProps = {
17
+ product: StoreProduct
18
+ isFeatured?: boolean
19
+ }
20
+
21
+ export function ProductPreview({
22
+ product,
23
+ isFeatured,
24
+ }: ProductPreviewProps) {
25
+ const { cheapestPrice } = getProductPrice({ product })
26
+
27
+ return (
28
+ <Link href={`/products/${product.handle}`} className="group">
29
+ <div data-testid="product-wrapper">
30
+ <Thumbnail
31
+ thumbnail={product.thumbnail}
32
+ images={product.images}
33
+ size="square"
34
+ isFeatured={isFeatured}
35
+ />
36
+ <div className="flex text-sm mt-4 justify-between">
37
+ <span className="text-muted-foreground" data-testid="product-title">
38
+ {product.title}
39
+ </span>
40
+ <div className="flex items-center gap-x-2">
41
+ {cheapestPrice && <PreviewPrice price={cheapestPrice} />}
42
+ </div>
43
+ </div>
44
+ </div>
45
+ </Link>
46
+ )
47
+ }
48
+
49
+ export { type ProductPreviewProps }
@@ -0,0 +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 barter SDK DTOs (`@cartbase/storefront/api/products`).
8
+ * Pricing stays SERVER truth — `lib/get-product-price` only selects and
9
+ * formats `variant.calculated_price` (barter'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
+ }
@@ -0,0 +1,169 @@
1
+ "use client"
2
+
3
+ /**
4
+ * PDP info/shipping accordion — ported from
5
+ * `@1click/ui/src/products/product-tabs.tsx` (v2.3.1). Data seam:
6
+ * `HttpTypes.StoreProduct` → barter `StoreProduct` (same physical fields:
7
+ * material, origin_country, type.value, weight, length/width/height).
8
+ * Layout, morphing +/− trigger, and label wiring unchanged.
9
+ */
10
+ import * as AccordionPrimitive from "@radix-ui/react-accordion"
11
+ import { Truck, RefreshCw, Undo2 } from "lucide-react"
12
+
13
+ import type { StoreProduct } from "../api/products"
14
+ import { useProductLabels } from "./context"
15
+
16
+ type ProductTabsProps = {
17
+ product: StoreProduct
18
+ }
19
+
20
+ export function ProductTabs({ product }: ProductTabsProps) {
21
+ const labels = useProductLabels()
22
+
23
+ const tabs = [
24
+ {
25
+ label: labels.productInformation,
26
+ component: <ProductInfoTab product={product} />,
27
+ },
28
+ {
29
+ label: labels.shippingAndReturns,
30
+ component: <ShippingInfoTab />,
31
+ },
32
+ ]
33
+
34
+ return (
35
+ <div className="w-full">
36
+ <AccordionPrimitive.Root type="multiple">
37
+ {tabs.map((tab, i) => (
38
+ <AccordionPrimitive.Item
39
+ key={i}
40
+ value={tab.label}
41
+ className="border-t border-border py-3 last:border-b"
42
+ >
43
+ <AccordionPrimitive.Header className="px-1">
44
+ <div className="flex w-full items-center justify-between">
45
+ <span className="text-muted-foreground text-sm">{tab.label}</span>
46
+ <AccordionPrimitive.Trigger>
47
+ <MorphingTrigger />
48
+ </AccordionPrimitive.Trigger>
49
+ </div>
50
+ </AccordionPrimitive.Header>
51
+ <AccordionPrimitive.Content className="overflow-hidden data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down px-1">
52
+ <div className="w-full">{tab.component}</div>
53
+ </AccordionPrimitive.Content>
54
+ </AccordionPrimitive.Item>
55
+ ))}
56
+ </AccordionPrimitive.Root>
57
+ </div>
58
+ )
59
+ }
60
+
61
+ function ProductInfoTab({ product }: ProductTabsProps) {
62
+ const labels = useProductLabels()
63
+
64
+ return (
65
+ <div className="text-sm py-8">
66
+ <div className="grid grid-cols-2 gap-x-8">
67
+ <div className="flex flex-col gap-y-4">
68
+ <div>
69
+ <span className="font-semibold text-foreground">
70
+ {labels.material}
71
+ </span>
72
+ <p className="text-muted-foreground">
73
+ {product.material ? product.material : "-"}
74
+ </p>
75
+ </div>
76
+ <div>
77
+ <span className="font-semibold text-foreground">
78
+ {labels.countryOfOrigin}
79
+ </span>
80
+ <p className="text-muted-foreground">
81
+ {product.origin_country ? product.origin_country : "-"}
82
+ </p>
83
+ </div>
84
+ <div>
85
+ <span className="font-semibold text-foreground">{labels.type}</span>
86
+ <p className="text-muted-foreground">
87
+ {product.type ? product.type.value : "-"}
88
+ </p>
89
+ </div>
90
+ </div>
91
+ <div className="flex flex-col gap-y-4">
92
+ <div>
93
+ <span className="font-semibold text-foreground">
94
+ {labels.weight}
95
+ </span>
96
+ <p className="text-muted-foreground">
97
+ {product.weight ? `${product.weight} g` : "-"}
98
+ </p>
99
+ </div>
100
+ <div>
101
+ <span className="font-semibold text-foreground">
102
+ {labels.dimensions}
103
+ </span>
104
+ <p className="text-muted-foreground">
105
+ {product.length && product.width && product.height
106
+ ? `${product.length}L x ${product.width}W x ${product.height}H`
107
+ : "-"}
108
+ </p>
109
+ </div>
110
+ </div>
111
+ </div>
112
+ </div>
113
+ )
114
+ }
115
+
116
+ function ShippingInfoTab() {
117
+ const labels = useProductLabels()
118
+
119
+ return (
120
+ <div className="text-sm py-8">
121
+ <div className="grid grid-cols-1 gap-y-8">
122
+ <div className="flex items-start gap-x-2">
123
+ <Truck className="w-5 h-5 text-muted-foreground flex-shrink-0 mt-0.5" />
124
+ <div>
125
+ <span className="font-semibold text-foreground">
126
+ {labels.fastDelivery}
127
+ </span>
128
+ <p className="max-w-sm text-muted-foreground">
129
+ {labels.fastDeliveryDescription}
130
+ </p>
131
+ </div>
132
+ </div>
133
+ <div className="flex items-start gap-x-2">
134
+ <RefreshCw className="w-5 h-5 text-muted-foreground flex-shrink-0 mt-0.5" />
135
+ <div>
136
+ <span className="font-semibold text-foreground">
137
+ {labels.simpleExchanges}
138
+ </span>
139
+ <p className="max-w-sm text-muted-foreground">
140
+ {labels.simpleExchangesDescription}
141
+ </p>
142
+ </div>
143
+ </div>
144
+ <div className="flex items-start gap-x-2">
145
+ <Undo2 className="w-5 h-5 text-muted-foreground flex-shrink-0 mt-0.5" />
146
+ <div>
147
+ <span className="font-semibold text-foreground">
148
+ {labels.easyReturns}
149
+ </span>
150
+ <p className="max-w-sm text-muted-foreground">
151
+ {labels.easyReturnsDescription}
152
+ </p>
153
+ </div>
154
+ </div>
155
+ </div>
156
+ </div>
157
+ )
158
+ }
159
+
160
+ function MorphingTrigger() {
161
+ return (
162
+ <div className="text-muted-foreground hover:bg-muted rounded-lg relative p-1.5">
163
+ <div className="h-5 w-5">
164
+ <span className="bg-muted-foreground rounded-full absolute inset-y-[31.75%] left-[48%] right-1/2 w-[1.5px] transition-transform duration-300 group-data-[state=open]:rotate-90" />
165
+ <span className="bg-muted-foreground rounded-full absolute inset-x-[31.75%] top-[48%] bottom-1/2 h-[1.5px] transition-transform duration-300 group-data-[state=open]:rotate-90 group-data-[state=open]:left-1/2 group-data-[state=open]:right-1/2" />
166
+ </div>
167
+ </div>
168
+ )
169
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Full PDP layout — ported from
3
+ * `@1click/ui/src/products/product-template.tsx` (v2.3.1). Data seam:
4
+ * `region`/`countryCode` (Medusa region routing) are replaced by
5
+ * `client` + `pricingContext`; the cart seam (`addToCart`/`openCart`)
6
+ * threads through to `ProductActions` (see product-actions.tsx for the
7
+ * seam rationale). Layout — sticky info column, scrolling gallery, sticky
8
+ * actions column, related strip — unchanged.
9
+ *
10
+ * Server component: fetch the product in the page (`retrieveProduct` by
11
+ * handle with the pricing context) and pass it in; the wrapper re-fetches
12
+ * live prices inside Suspense.
13
+ */
14
+ import { Suspense } from "react"
15
+ import { notFound } from "next/navigation"
16
+
17
+ import type { StorefrontClient } from "../api/http"
18
+ import type { PricingContextQuery } from "../api/types"
19
+ import type { StoreProduct, StoreProductVariant } from "../api/products"
20
+ import { ImageGallery } from "./image-gallery"
21
+ import { ProductActions, type AddToCartInput } from "./product-actions"
22
+ import { ProductTabs } from "./product-tabs"
23
+ import { RelatedProducts } from "./related-products"
24
+ import { ProductInfo } from "./product-info"
25
+ import { ProductActionsWrapper } from "./product-actions-wrapper"
26
+
27
+ type ProductTemplateProps = {
28
+ client: StorefrontClient
29
+ product: StoreProduct
30
+ /** Pricing context (currency_code/region_id) for actions + related. */
31
+ pricingContext?: PricingContextQuery
32
+ addToCart: (input: AddToCartInput) => Promise<void>
33
+ onAddToCart?: (product: StoreProduct, variant: StoreProductVariant) => void
34
+ openCart?: () => void
35
+ }
36
+
37
+ export function ProductTemplate({
38
+ client,
39
+ product,
40
+ pricingContext,
41
+ addToCart,
42
+ onAddToCart,
43
+ openCart,
44
+ }: ProductTemplateProps) {
45
+ if (!product || !product.id) {
46
+ return notFound()
47
+ }
48
+
49
+ const images = product.images ?? []
50
+
51
+ return (
52
+ <>
53
+ <div
54
+ className="max-w-7xl mx-auto px-4 flex flex-col sm:flex-row sm:items-start py-6 relative"
55
+ data-testid="product-container"
56
+ >
57
+ <div className="flex flex-col sm:sticky sm:top-48 sm:py-0 sm:max-w-[300px] w-full py-8 gap-y-6">
58
+ <ProductInfo product={product} />
59
+ <ProductTabs product={product} />
60
+ </div>
61
+ <div className="block w-full relative">
62
+ <ImageGallery images={images} />
63
+ </div>
64
+ <div className="flex flex-col sm:sticky sm:top-48 sm:py-0 sm:max-w-[300px] w-full py-8 gap-y-12">
65
+ <Suspense
66
+ fallback={
67
+ <ProductActions
68
+ disabled={true}
69
+ product={product}
70
+ addToCart={addToCart}
71
+ onAddToCart={onAddToCart}
72
+ openCart={openCart}
73
+ />
74
+ }
75
+ >
76
+ <ProductActionsWrapper
77
+ client={client}
78
+ id={product.id}
79
+ pricingContext={pricingContext}
80
+ addToCart={addToCart}
81
+ onAddToCart={onAddToCart}
82
+ openCart={openCart}
83
+ />
84
+ </Suspense>
85
+ </div>
86
+ </div>
87
+ <div
88
+ className="max-w-7xl mx-auto px-4 my-16 sm:my-32"
89
+ data-testid="related-products-container"
90
+ >
91
+ <Suspense
92
+ fallback={
93
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
94
+ {Array.from({ length: 4 }).map((_, i) => (
95
+ <div
96
+ key={i}
97
+ className="aspect-[9/16] bg-muted animate-pulse rounded-lg"
98
+ />
99
+ ))}
100
+ </div>
101
+ }
102
+ >
103
+ <RelatedProducts
104
+ client={client}
105
+ product={product}
106
+ pricingContext={pricingContext}
107
+ />
108
+ </Suspense>
109
+ </div>
110
+ </>
111
+ )
112
+ }
113
+
114
+ export { type ProductTemplateProps }