@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.
- package/package.json +226 -0
- package/src/api/auth.ts +108 -0
- package/src/api/carts.ts +506 -0
- package/src/api/categories.ts +184 -0
- package/src/api/checkout.ts +440 -0
- package/src/api/collections.ts +130 -0
- package/src/api/consent.ts +75 -0
- package/src/api/content.ts +125 -0
- package/src/api/customers.ts +307 -0
- package/src/api/gift-cards.ts +112 -0
- package/src/api/http.ts +122 -0
- package/src/api/index.ts +29 -0
- package/src/api/integrations.ts +130 -0
- package/src/api/menus.ts +77 -0
- package/src/api/metaobjects.ts +136 -0
- package/src/api/orders.ts +290 -0
- package/src/api/products.ts +303 -0
- package/src/api/redirects.ts +37 -0
- package/src/api/regions.ts +141 -0
- package/src/api/reviews.ts +259 -0
- package/src/api/search.ts +133 -0
- package/src/api/types.ts +91 -0
- package/src/cart-drawer/cart-drawer.tsx +86 -0
- package/src/cart-drawer/context.tsx +569 -0
- package/src/cart-drawer/continue-shopping.tsx +28 -0
- package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
- package/src/cart-drawer/empty.tsx +52 -0
- package/src/cart-drawer/free-gift.tsx +71 -0
- package/src/cart-drawer/gift-wrap.tsx +83 -0
- package/src/cart-drawer/header.tsx +52 -0
- package/src/cart-drawer/index.ts +69 -0
- package/src/cart-drawer/item/index.tsx +164 -0
- package/src/cart-drawer/item/quantity.tsx +100 -0
- package/src/cart-drawer/item/upsell.tsx +110 -0
- package/src/cart-drawer/item/variant.tsx +46 -0
- package/src/cart-drawer/labels-bg.ts +72 -0
- package/src/cart-drawer/labels.ts +119 -0
- package/src/cart-drawer/notes.tsx +131 -0
- package/src/cart-drawer/payment-badges.tsx +96 -0
- package/src/cart-drawer/promo-banner.tsx +43 -0
- package/src/cart-drawer/rewards-points.tsx +78 -0
- package/src/cart-drawer/sticky-footer.tsx +73 -0
- package/src/cart-drawer/summary-breakdown.tsx +196 -0
- package/src/cart-drawer/template.tsx +225 -0
- package/src/cart-drawer/tiered-progress.tsx +168 -0
- package/src/checkout/address-error-copy.ts +119 -0
- package/src/checkout/address-form.tsx +224 -0
- package/src/checkout/address-select.tsx +79 -0
- package/src/checkout/boxnow-locker-selector.tsx +410 -0
- package/src/checkout/checkout-client.tsx +222 -0
- package/src/checkout/company-details.tsx +94 -0
- package/src/checkout/compare-addresses.ts +40 -0
- package/src/checkout/context.tsx +76 -0
- package/src/checkout/discount-section.tsx +218 -0
- package/src/checkout/econt-office-selector.tsx +332 -0
- package/src/checkout/error-message.tsx +25 -0
- package/src/checkout/geocode.ts +154 -0
- package/src/checkout/gift-card-section.tsx +224 -0
- package/src/checkout/index.ts +74 -0
- package/src/checkout/labels-bg.ts +128 -0
- package/src/checkout/labels.ts +263 -0
- package/src/checkout/line-item-card.tsx +152 -0
- package/src/checkout/order-summary.tsx +524 -0
- package/src/checkout/payment-button.tsx +373 -0
- package/src/checkout/payment-error-copy.ts +269 -0
- package/src/checkout/payment-method-list.tsx +365 -0
- package/src/checkout/payment-wrapper.tsx +102 -0
- package/src/checkout/promotion-error-copy.ts +124 -0
- package/src/checkout/shipping-method-list.tsx +335 -0
- package/src/checkout/stripe-wrapper.tsx +165 -0
- package/src/checkout/use-checkout-orchestration.ts +1504 -0
- package/src/common/cart-button-client.tsx +39 -0
- package/src/common/cart-button.tsx +28 -0
- package/src/common/country-select.tsx +65 -0
- package/src/common/delete-button.tsx +66 -0
- package/src/common/index.ts +17 -0
- package/src/common/language-select.tsx +78 -0
- package/src/common/localized-link.tsx +45 -0
- package/src/common/skeleton.tsx +29 -0
- package/src/index.ts +12 -0
- package/src/lib/cart-helpers.ts +113 -0
- package/src/lib/dual-price.tsx +73 -0
- package/src/lib/get-percentage-diff.ts +5 -0
- package/src/lib/get-product-price.ts +133 -0
- package/src/lib/hooks/use-intersection.ts +30 -0
- package/src/lib/hooks/use-toggle-state.ts +25 -0
- package/src/lib/money.ts +73 -0
- package/src/lib/payment-constants.ts +66 -0
- package/src/lib/product.ts +22 -0
- package/src/lib/sort-products.ts +63 -0
- package/src/lib/store-api-error.ts +36 -0
- package/src/lib/utils.ts +16 -0
- package/src/order/context.tsx +32 -0
- package/src/order/index.ts +63 -0
- package/src/order/labels-bg.ts +39 -0
- package/src/order/labels.ts +79 -0
- package/src/order/order-address-card.tsx +47 -0
- package/src/order/order-completed-template.tsx +165 -0
- package/src/order/order-confirmation-header.tsx +65 -0
- package/src/order/order-delivery-card.tsx +258 -0
- package/src/order/order-help-section.tsx +47 -0
- package/src/order/order-item.tsx +201 -0
- package/src/order/order-items-list.tsx +52 -0
- package/src/order/order-payment-card.tsx +95 -0
- package/src/order/order-timeline.tsx +141 -0
- package/src/order/order-totals.tsx +245 -0
- package/src/primitives/field.tsx +125 -0
- package/src/primitives/select-field.tsx +77 -0
- package/src/primitives/ui/accordion.tsx +61 -0
- package/src/primitives/ui/button.tsx +68 -0
- package/src/primitives/ui/collapsible.tsx +16 -0
- package/src/primitives/ui/dialog.tsx +112 -0
- package/src/primitives/ui/input.tsx +30 -0
- package/src/primitives/ui/label.tsx +31 -0
- package/src/primitives/ui/popover.tsx +38 -0
- package/src/primitives/ui/select.tsx +163 -0
- package/src/primitives/ui/sheet.tsx +131 -0
- package/src/primitives/ui/tabs.tsx +62 -0
- package/src/products/context.tsx +34 -0
- package/src/products/image-gallery.tsx +43 -0
- package/src/products/index.ts +44 -0
- package/src/products/labels-bg.ts +35 -0
- package/src/products/labels.ts +57 -0
- package/src/products/mobile-actions.tsx +180 -0
- package/src/products/option-select.tsx +67 -0
- package/src/products/preview-price.tsx +36 -0
- package/src/products/product-actions-wrapper.tsx +58 -0
- package/src/products/product-actions.tsx +217 -0
- package/src/products/product-info.tsx +43 -0
- package/src/products/product-preview.tsx +49 -0
- package/src/products/product-price.tsx +69 -0
- package/src/products/product-tabs.tsx +169 -0
- package/src/products/product-template.tsx +114 -0
- package/src/products/purchase-options.tsx +130 -0
- package/src/products/related-products.tsx +86 -0
- package/src/products/thumbnail.tsx +71 -0
- package/src/products/variant-matching.ts +71 -0
- package/src/reviews-ui/helpers.ts +174 -0
- package/src/reviews-ui/index.ts +74 -0
- package/src/reviews-ui/labels-bg.ts +91 -0
- package/src/reviews-ui/labels.ts +199 -0
- package/src/reviews-ui/photo-upload.tsx +345 -0
- package/src/reviews-ui/review-list.tsx +249 -0
- package/src/reviews-ui/review-widget.tsx +224 -0
- package/src/reviews-ui/review-wizard.tsx +560 -0
- package/src/reviews-ui/star-badge.tsx +104 -0
- package/src/reviews-ui/wizard-state.ts +81 -0
- package/src/store/category-template.tsx +129 -0
- package/src/store/collection-template.tsx +139 -0
- package/src/store/index.ts +41 -0
- package/src/store/labels-bg.ts +22 -0
- package/src/store/labels.ts +52 -0
- package/src/store/paginated-products.tsx +116 -0
- package/src/store/pagination.tsx +103 -0
- package/src/store/search-params.ts +256 -0
- package/src/store/search-template.tsx +249 -0
- package/src/store/skeleton-product-grid.tsx +26 -0
- package/src/store/sort-select.tsx +81 -0
- package/src/store/store-template.tsx +65 -0
- package/src/tracking/attribution.ts +418 -0
- package/src/tracking/consent-banner.tsx +355 -0
- package/src/tracking/consent-init.tsx +44 -0
- package/src/tracking/consent.ts +243 -0
- package/src/tracking/fbq.ts +168 -0
- package/src/tracking/ga4.tsx +49 -0
- package/src/tracking/get-tracking-attribution.ts +224 -0
- package/src/tracking/get-tracking-config.ts +50 -0
- package/src/tracking/gtag.ts +200 -0
- package/src/tracking/index.ts +133 -0
- package/src/tracking/meta-pixel.tsx +166 -0
- package/src/tracking/rybbit-events.ts +242 -0
- package/src/tracking/rybbit.tsx +40 -0
- package/src/tracking/types.ts +185 -0
- package/src/tracking/use-engagement-time.ts +58 -0
- 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 }
|