@thorprovider/blocks 0.1.2 → 1.0.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/CHANGELOG.md +107 -0
- package/README.md +108 -23
- package/docs/standards/data-contracts.md +13 -2
- package/package.json +26 -10
- package/scripts/fix-esm-extensions.mjs +40 -0
- package/src/blocks/e-commerce/navigation/back-to-top.test.tsx +27 -0
- package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
- package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
- package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
- package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
- package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
- package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
- package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
- package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
- package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
- package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
- package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
- package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
- package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
- package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
- package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
- package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
- package/src/blocks/e-commerce/product-list/product-card.tsx +424 -0
- package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
- package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
- package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
- package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
- package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
- package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
- package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
- package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
- package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
- package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
- package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
- package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
- package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +204 -109
- package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
- package/src/blocks/marketing/features/feature.test.tsx +153 -0
- package/src/blocks/marketing/features/feature.tsx +195 -0
- package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +56 -0
- package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +150 -0
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +102 -9
- package/src/blocks/marketing/navbars/navbar-4.test.tsx +154 -2
- package/src/blocks/marketing/navbars/navbar-4.tsx +152 -32
- package/src/index.ts +93 -46
- package/src/lib/contracts-curated.test.ts +42 -24
- package/src/lib/contracts.test.ts +143 -2
- package/src/lib/contracts.ts +395 -13
- package/src/ui/button-group.tsx +1 -1
- package/src/ui/carousel.tsx +1 -1
- package/src/ui/dialog.tsx +1 -1
- package/src/ui/field.tsx +2 -2
- package/src/ui/input-group.tsx +3 -3
- package/src/ui/pagination.tsx +1 -1
- package/src/ui/sidebar.tsx +7 -7
- package/src/ui/toggle-group.tsx +1 -1
- package/tsconfig.build.json +12 -0
- package/tsup.config.ts +11 -7
- package/.turbo/turbo-build.log +0 -24
- package/.turbo/turbo-test.log +0 -37
- package/.turbo/turbo-type-check.log +0 -0
- package/dist/.tsbuildinfo +0 -1
- package/dist/contracts.d.mts +0 -1584
- package/dist/contracts.d.ts +0 -1584
- package/dist/contracts.js +0 -390
- package/dist/contracts.js.map +0 -1
- package/dist/contracts.mjs +0 -334
- package/dist/contracts.mjs.map +0 -1
- package/dist/index.d.mts +0 -2786
- package/dist/index.d.ts +0 -2786
- package/dist/index.js +0 -17139
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -17059
- package/dist/index.mjs.map +0 -1
- package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
- package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
- package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
- package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
- package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
- package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
- package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -131
- package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
- package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
- package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
- package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
- package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
- package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
- package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
- package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
- package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
- package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
- package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -137
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -65
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
- package/src/blocks/marketing/features/feature-1.tsx +0 -97
- package/src/blocks/marketing/features/feature-2.tsx +0 -82
|
@@ -0,0 +1,1186 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { Camera, Check, Heart, Info, MessageSquare, Minus, Package, Plus, Share2, ShoppingCart, Star, Truck } from 'lucide-react'
|
|
5
|
+
import type { Image, Money, ProductOption, SelectedOption } from '@thorprovider/types'
|
|
6
|
+
import { Button } from '../../../ui/button'
|
|
7
|
+
import { Card, CardContent } from '../../../ui/card'
|
|
8
|
+
import { Separator } from '../../../ui/separator'
|
|
9
|
+
import { cn } from '../../../lib/utils'
|
|
10
|
+
import { PRODUCT_IMAGE_FALLBACK } from '../../../lib/contracts'
|
|
11
|
+
import { formatMoney, isAvailableMoney } from '../../../lib/money'
|
|
12
|
+
import { ReactionToggle } from '../../shared/reaction-toggle'
|
|
13
|
+
import { Carousel, CarouselContent, CarouselItem, type CarouselApi } from '../../../ui/carousel'
|
|
14
|
+
import { ProductOverviewGallery } from './product-overview-gallery'
|
|
15
|
+
import { ProductOverviewLightboxTab } from './product-overview-lightbox-tab'
|
|
16
|
+
import { ProductOverviewReviewsTab, type ProductOverviewReview } from './product-overview-reviews-tab'
|
|
17
|
+
import {
|
|
18
|
+
ProductOverviewTabs,
|
|
19
|
+
ProductOverviewTabsContentFade,
|
|
20
|
+
ProductOverviewTabsIndicator,
|
|
21
|
+
ProductOverviewTabsList,
|
|
22
|
+
ProductOverviewTabsTrigger,
|
|
23
|
+
} from './product-overview-tabs'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Canonical product-detail projection.
|
|
27
|
+
*
|
|
28
|
+
* The block consumes the canonical `@thorprovider/types` shapes (`Money`,
|
|
29
|
+
* `ProductOption`, `SelectedOption`, `Image`) so adapter output maps without a
|
|
30
|
+
* second, drifting product type. The only deviation is `price`: the canon marks
|
|
31
|
+
* it required, but a missing/unresolved backend price is a real, distinct state
|
|
32
|
+
* that must disable purchase — so the projection allows `price` to be absent
|
|
33
|
+
* and the block treats absence as "unavailable", never as zero.
|
|
34
|
+
*/
|
|
35
|
+
export interface ProductDetailVariant {
|
|
36
|
+
id: string
|
|
37
|
+
title: string
|
|
38
|
+
sku?: string
|
|
39
|
+
availableForSale: boolean
|
|
40
|
+
selectedOptions: SelectedOption[]
|
|
41
|
+
/** Canonical Money; omit/null when the backend has no resolved price. */
|
|
42
|
+
price?: Money | null
|
|
43
|
+
compareAtPrice?: Money
|
|
44
|
+
image?: Image
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ProductDetailProduct {
|
|
48
|
+
id: string
|
|
49
|
+
handle?: string
|
|
50
|
+
title: string
|
|
51
|
+
description?: string
|
|
52
|
+
vendor?: string
|
|
53
|
+
options: ProductOption[]
|
|
54
|
+
variants: ProductDetailVariant[]
|
|
55
|
+
images: Image[]
|
|
56
|
+
featuredImage?: Image
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ProductOverviewRating {
|
|
60
|
+
value: number
|
|
61
|
+
count: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The bounded presentation surface. `standard` is the straightforward detail
|
|
66
|
+
* layout, `gallery` is image-forward, and `rich` adds tabbed overview/specs/
|
|
67
|
+
* reviews/gallery. The value changes structure, never the data contract.
|
|
68
|
+
*/
|
|
69
|
+
export type ProductOverviewPresentation = 'standard' | 'gallery' | 'rich'
|
|
70
|
+
|
|
71
|
+
export interface ProductOverviewProps {
|
|
72
|
+
product: ProductDetailProduct
|
|
73
|
+
/** Bounded presentation option; defaults to `standard`. */
|
|
74
|
+
presentation?: ProductOverviewPresentation
|
|
75
|
+
/** Controlled selection: the backend variant id, or null/undefined when nothing is explicitly selected. */
|
|
76
|
+
selectedVariantId?: string | null
|
|
77
|
+
onSelectVariant?: (variantId: string) => void
|
|
78
|
+
/** Controlled quantity for the add-to-cart action; defaults to 1 when omitted. */
|
|
79
|
+
quantity?: number
|
|
80
|
+
onQuantityChange?: (quantity: number) => void
|
|
81
|
+
/** Add-to-cart identity is the selected backend variant id, with the requested quantity. */
|
|
82
|
+
onAddToCart?: (variantId: string, quantity: number) => void
|
|
83
|
+
isWishlisted?: boolean
|
|
84
|
+
onToggleWishlist?: (variantId: string, isWishlisted: boolean) => void
|
|
85
|
+
/** Optional share action (used by the `rich` presentation). */
|
|
86
|
+
onShare?: () => void
|
|
87
|
+
/** Rendered only when the backend supplies it. */
|
|
88
|
+
rating?: ProductOverviewRating
|
|
89
|
+
/** Rendered only when the backend supplies it. */
|
|
90
|
+
deliveryEstimate?: string
|
|
91
|
+
/** Rendered only when the backend supplies it. */
|
|
92
|
+
shipping?: Money
|
|
93
|
+
formatPrice?: (money: Money) => string
|
|
94
|
+
/** `rich`-only optional data. Omitted sections render no chrome. */
|
|
95
|
+
specifications?: Record<string, string>
|
|
96
|
+
/** `rich`-only optional data. Omitted sections render no chrome. */
|
|
97
|
+
features?: string[]
|
|
98
|
+
/** `rich`-only optional data. Omitted sections render no chrome. */
|
|
99
|
+
reviews?: ProductOverviewReview[]
|
|
100
|
+
sizesLabel?: string
|
|
101
|
+
variantLabel?: string
|
|
102
|
+
reviewsLabel?: string
|
|
103
|
+
addToCartLabel?: string
|
|
104
|
+
outOfStockLabel?: string
|
|
105
|
+
unavailablePriceLabel?: string
|
|
106
|
+
selectVariantLabel?: string
|
|
107
|
+
quantityLabel?: string
|
|
108
|
+
deliveryEstimateLabel?: string
|
|
109
|
+
shippingLabel?: string
|
|
110
|
+
removeFromWishlistLabel?: string
|
|
111
|
+
addToWishlistLabel?: string
|
|
112
|
+
imageFallback?: string
|
|
113
|
+
/** `gallery`/`rich` labels. */
|
|
114
|
+
badgeLabel?: string
|
|
115
|
+
availabilityLabel?: string
|
|
116
|
+
skuLabel?: string
|
|
117
|
+
overviewLabel?: string
|
|
118
|
+
specsLabel?: string
|
|
119
|
+
featuresLabel?: string
|
|
120
|
+
specificationsLabel?: string
|
|
121
|
+
galleryLabel?: string
|
|
122
|
+
photosLabel?: string
|
|
123
|
+
saveLabel?: string
|
|
124
|
+
savedLabel?: string
|
|
125
|
+
shareLabel?: string
|
|
126
|
+
basedOnReviewsLabel?: string
|
|
127
|
+
foundHelpfulLabel?: string
|
|
128
|
+
ofCounterLabel?: string
|
|
129
|
+
freeDeliveryLabel?: string
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function variantLabelOf(variant: ProductDetailVariant): string {
|
|
133
|
+
if (variant.title.trim() !== '') return variant.title
|
|
134
|
+
return variant.selectedOptions.map(option => option.value).join(' / ')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The add-to-cart payload contract: the explicitly selected backend variant id
|
|
139
|
+
* and a positive quantity (defaults to 1). Returns `null` when no variant has
|
|
140
|
+
* been selected — the block never substitutes the first variant.
|
|
141
|
+
*/
|
|
142
|
+
export function resolveAddToCartPayload(
|
|
143
|
+
variant: ProductDetailVariant | null,
|
|
144
|
+
quantity: number | undefined,
|
|
145
|
+
): { variantId: string; quantity: number } | null {
|
|
146
|
+
if (!variant) return null
|
|
147
|
+
return {
|
|
148
|
+
variantId: variant.id,
|
|
149
|
+
quantity: typeof quantity === 'number' && quantity > 0 ? quantity : 1,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function galleryImages(product: ProductDetailProduct, fallback: string): string[] {
|
|
154
|
+
const images = product.images
|
|
155
|
+
.map(image => image.url)
|
|
156
|
+
.filter((url): url is string => typeof url === 'string' && url.trim() !== '')
|
|
157
|
+
if (images.length > 0) return images
|
|
158
|
+
const featured = product.featuredImage?.url
|
|
159
|
+
if (typeof featured === 'string' && featured.trim() !== '') return [featured]
|
|
160
|
+
return [fallback]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
interface ResolvedSelection {
|
|
164
|
+
selectedVariant: ProductDetailVariant | null
|
|
165
|
+
selectedPrice: Money | null | undefined
|
|
166
|
+
selectedCompareAt: Money | undefined
|
|
167
|
+
priceAvailable: boolean
|
|
168
|
+
compareAtAvailable: boolean
|
|
169
|
+
canPurchase: boolean
|
|
170
|
+
quantityValue: number
|
|
171
|
+
addPayload: { variantId: string; quantity: number } | null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export default function ProductOverview({
|
|
175
|
+
product,
|
|
176
|
+
presentation = 'standard',
|
|
177
|
+
selectedVariantId,
|
|
178
|
+
onSelectVariant,
|
|
179
|
+
quantity,
|
|
180
|
+
onQuantityChange,
|
|
181
|
+
onAddToCart,
|
|
182
|
+
isWishlisted = false,
|
|
183
|
+
onToggleWishlist,
|
|
184
|
+
onShare,
|
|
185
|
+
rating,
|
|
186
|
+
deliveryEstimate,
|
|
187
|
+
shipping,
|
|
188
|
+
formatPrice,
|
|
189
|
+
specifications,
|
|
190
|
+
features,
|
|
191
|
+
reviews,
|
|
192
|
+
sizesLabel = 'Variantes',
|
|
193
|
+
variantLabel = 'Variante',
|
|
194
|
+
reviewsLabel = 'Reseñas',
|
|
195
|
+
addToCartLabel = 'Agregar al carrito',
|
|
196
|
+
outOfStockLabel = 'Agotado',
|
|
197
|
+
unavailablePriceLabel = 'Precio no disponible',
|
|
198
|
+
selectVariantLabel = 'Selecciona una variante',
|
|
199
|
+
quantityLabel = 'Cantidad',
|
|
200
|
+
deliveryEstimateLabel = 'Entrega estimada',
|
|
201
|
+
shippingLabel = 'Envío',
|
|
202
|
+
removeFromWishlistLabel = 'Quitar de favoritos',
|
|
203
|
+
addToWishlistLabel = 'Agregar a favoritos',
|
|
204
|
+
imageFallback = PRODUCT_IMAGE_FALLBACK,
|
|
205
|
+
badgeLabel,
|
|
206
|
+
availabilityLabel = 'Disponible',
|
|
207
|
+
skuLabel = 'SKU',
|
|
208
|
+
overviewLabel = 'Resumen',
|
|
209
|
+
specsLabel = 'Especificaciones',
|
|
210
|
+
featuresLabel = 'Características clave',
|
|
211
|
+
specificationsLabel = 'Especificaciones técnicas',
|
|
212
|
+
galleryLabel = 'Galería',
|
|
213
|
+
photosLabel = 'fotos',
|
|
214
|
+
saveLabel = 'Guardar',
|
|
215
|
+
savedLabel = 'Guardado',
|
|
216
|
+
shareLabel = 'Compartir',
|
|
217
|
+
basedOnReviewsLabel = 'Basado en {count} reseñas',
|
|
218
|
+
foundHelpfulLabel = '{count} personas encontraron esto útil',
|
|
219
|
+
ofCounterLabel = '{current} de {total}',
|
|
220
|
+
freeDeliveryLabel,
|
|
221
|
+
}: ProductOverviewProps) {
|
|
222
|
+
const [selectedImage, setSelectedImage] = useState(0)
|
|
223
|
+
const [carouselApi, setCarouselApi] = useState<CarouselApi>()
|
|
224
|
+
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
if (!carouselApi) return
|
|
227
|
+
carouselApi.scrollTo(selectedImage)
|
|
228
|
+
const handleSelect = () => setSelectedImage(carouselApi.selectedScrollSnap())
|
|
229
|
+
carouselApi.on('select', handleSelect)
|
|
230
|
+
return () => {
|
|
231
|
+
carouselApi.off('select', handleSelect)
|
|
232
|
+
}
|
|
233
|
+
}, [carouselApi, selectedImage])
|
|
234
|
+
|
|
235
|
+
const selectedVariant =
|
|
236
|
+
selectedVariantId != null
|
|
237
|
+
? product.variants.find(variant => variant.id === selectedVariantId) ?? null
|
|
238
|
+
: null
|
|
239
|
+
|
|
240
|
+
const selectedPrice = selectedVariant?.price
|
|
241
|
+
const selectedCompareAt = selectedVariant?.compareAtPrice
|
|
242
|
+
const priceAvailable = isAvailableMoney(selectedPrice)
|
|
243
|
+
const compareAtAvailable = isAvailableMoney(selectedCompareAt)
|
|
244
|
+
const canPurchase = Boolean(selectedVariant?.availableForSale) && priceAvailable
|
|
245
|
+
const quantityValue = quantity ?? 1
|
|
246
|
+
const addPayload = resolveAddToCartPayload(selectedVariant, quantityValue)
|
|
247
|
+
|
|
248
|
+
const images = galleryImages(product, imageFallback)
|
|
249
|
+
const hasRealImages = images[0] !== imageFallback || product.featuredImage != null
|
|
250
|
+
const activeImage = images[Math.min(selectedImage, images.length - 1)]
|
|
251
|
+
|
|
252
|
+
const selection: ResolvedSelection = {
|
|
253
|
+
selectedVariant,
|
|
254
|
+
selectedPrice,
|
|
255
|
+
selectedCompareAt,
|
|
256
|
+
priceAvailable,
|
|
257
|
+
compareAtAvailable,
|
|
258
|
+
canPurchase,
|
|
259
|
+
quantityValue,
|
|
260
|
+
addPayload,
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const handleAddToCart = () => {
|
|
264
|
+
if (onAddToCart && addPayload) onAddToCart(addPayload.variantId, addPayload.quantity)
|
|
265
|
+
}
|
|
266
|
+
const handleToggleWishlist = () => {
|
|
267
|
+
if (!selectedVariant || !onToggleWishlist) return
|
|
268
|
+
onToggleWishlist(selectedVariant.id, !isWishlisted)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (presentation === 'gallery') {
|
|
272
|
+
return (
|
|
273
|
+
<GalleryPresentation
|
|
274
|
+
product={product}
|
|
275
|
+
selection={selection}
|
|
276
|
+
images={images}
|
|
277
|
+
activeImage={activeImage}
|
|
278
|
+
selectedImage={selectedImage}
|
|
279
|
+
onSelectImage={setSelectedImage}
|
|
280
|
+
setCarouselApi={setCarouselApi}
|
|
281
|
+
onSelectVariant={onSelectVariant}
|
|
282
|
+
onQuantityChange={onQuantityChange}
|
|
283
|
+
onAddToCart={handleAddToCart}
|
|
284
|
+
isWishlisted={isWishlisted}
|
|
285
|
+
onToggleWishlist={onToggleWishlist ? handleToggleWishlist : undefined}
|
|
286
|
+
rating={rating}
|
|
287
|
+
formatPrice={formatPrice}
|
|
288
|
+
labels={{
|
|
289
|
+
reviewsLabel,
|
|
290
|
+
addToCartLabel,
|
|
291
|
+
outOfStockLabel,
|
|
292
|
+
unavailablePriceLabel,
|
|
293
|
+
selectVariantLabel,
|
|
294
|
+
quantityLabel,
|
|
295
|
+
removeFromWishlistLabel,
|
|
296
|
+
addToWishlistLabel,
|
|
297
|
+
freeDeliveryLabel,
|
|
298
|
+
}}
|
|
299
|
+
/>
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (presentation === 'rich') {
|
|
304
|
+
return (
|
|
305
|
+
<RichPresentation
|
|
306
|
+
product={product}
|
|
307
|
+
selection={selection}
|
|
308
|
+
images={images}
|
|
309
|
+
selectedImage={selectedImage}
|
|
310
|
+
onSelectImage={setSelectedImage}
|
|
311
|
+
onSelectVariant={onSelectVariant}
|
|
312
|
+
onQuantityChange={onQuantityChange}
|
|
313
|
+
onAddToCart={handleAddToCart}
|
|
314
|
+
isWishlisted={isWishlisted}
|
|
315
|
+
onToggleWishlist={onToggleWishlist ? handleToggleWishlist : undefined}
|
|
316
|
+
onShare={onShare}
|
|
317
|
+
rating={rating}
|
|
318
|
+
formatPrice={formatPrice}
|
|
319
|
+
specifications={specifications}
|
|
320
|
+
features={features}
|
|
321
|
+
reviews={reviews}
|
|
322
|
+
labels={{
|
|
323
|
+
reviewsLabel,
|
|
324
|
+
addToCartLabel,
|
|
325
|
+
outOfStockLabel,
|
|
326
|
+
unavailablePriceLabel,
|
|
327
|
+
selectVariantLabel,
|
|
328
|
+
quantityLabel,
|
|
329
|
+
removeFromWishlistLabel,
|
|
330
|
+
addToWishlistLabel,
|
|
331
|
+
badgeLabel,
|
|
332
|
+
availabilityLabel,
|
|
333
|
+
skuLabel,
|
|
334
|
+
overviewLabel,
|
|
335
|
+
specsLabel,
|
|
336
|
+
featuresLabel,
|
|
337
|
+
specificationsLabel,
|
|
338
|
+
galleryLabel,
|
|
339
|
+
photosLabel,
|
|
340
|
+
saveLabel,
|
|
341
|
+
savedLabel,
|
|
342
|
+
shareLabel,
|
|
343
|
+
basedOnReviewsLabel,
|
|
344
|
+
foundHelpfulLabel,
|
|
345
|
+
ofCounterLabel,
|
|
346
|
+
}}
|
|
347
|
+
/>
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return (
|
|
352
|
+
<StandardPresentation
|
|
353
|
+
product={product}
|
|
354
|
+
selection={selection}
|
|
355
|
+
images={images}
|
|
356
|
+
activeImage={activeImage}
|
|
357
|
+
selectedImage={selectedImage}
|
|
358
|
+
onSelectImage={setSelectedImage}
|
|
359
|
+
onSelectVariant={onSelectVariant}
|
|
360
|
+
onQuantityChange={onQuantityChange}
|
|
361
|
+
onAddToCart={handleAddToCart}
|
|
362
|
+
isWishlisted={isWishlisted}
|
|
363
|
+
onToggleWishlist={onToggleWishlist ? handleToggleWishlist : undefined}
|
|
364
|
+
rating={rating}
|
|
365
|
+
deliveryEstimate={deliveryEstimate}
|
|
366
|
+
shipping={shipping}
|
|
367
|
+
formatPrice={formatPrice}
|
|
368
|
+
hasRealImages={hasRealImages}
|
|
369
|
+
labels={{
|
|
370
|
+
sizesLabel,
|
|
371
|
+
variantLabel,
|
|
372
|
+
reviewsLabel,
|
|
373
|
+
addToCartLabel,
|
|
374
|
+
outOfStockLabel,
|
|
375
|
+
unavailablePriceLabel,
|
|
376
|
+
selectVariantLabel,
|
|
377
|
+
quantityLabel,
|
|
378
|
+
deliveryEstimateLabel,
|
|
379
|
+
shippingLabel,
|
|
380
|
+
removeFromWishlistLabel,
|
|
381
|
+
addToWishlistLabel,
|
|
382
|
+
}}
|
|
383
|
+
/>
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
interface SharedLabels {
|
|
388
|
+
reviewsLabel: string
|
|
389
|
+
addToCartLabel: string
|
|
390
|
+
outOfStockLabel: string
|
|
391
|
+
unavailablePriceLabel: string
|
|
392
|
+
selectVariantLabel: string
|
|
393
|
+
quantityLabel: string
|
|
394
|
+
removeFromWishlistLabel: string
|
|
395
|
+
addToWishlistLabel: string
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function VariantSelector({
|
|
399
|
+
product,
|
|
400
|
+
selectedVariantId,
|
|
401
|
+
onSelectVariant,
|
|
402
|
+
sizesLabel,
|
|
403
|
+
variantLabel,
|
|
404
|
+
outOfStockLabel,
|
|
405
|
+
}: {
|
|
406
|
+
product: ProductDetailProduct
|
|
407
|
+
selectedVariantId?: string | null
|
|
408
|
+
onSelectVariant?: (variantId: string) => void
|
|
409
|
+
sizesLabel: string
|
|
410
|
+
variantLabel: string
|
|
411
|
+
outOfStockLabel: string
|
|
412
|
+
}) {
|
|
413
|
+
if (product.variants.length === 0) return null
|
|
414
|
+
return (
|
|
415
|
+
<div className='flex flex-col gap-2'>
|
|
416
|
+
<h3 className='font-bold'>{sizesLabel}</h3>
|
|
417
|
+
<div className='flex flex-wrap gap-3'>
|
|
418
|
+
{product.variants.map(variant => {
|
|
419
|
+
const isSelected = variant.id === selectedVariantId
|
|
420
|
+
return (
|
|
421
|
+
<Button
|
|
422
|
+
key={variant.id}
|
|
423
|
+
variant={isSelected ? 'default' : 'outline'}
|
|
424
|
+
onClick={onSelectVariant ? () => onSelectVariant(variant.id) : undefined}
|
|
425
|
+
disabled={!variant.availableForSale}
|
|
426
|
+
aria-pressed={isSelected}
|
|
427
|
+
className='h-9 cursor-pointer rounded-full px-4'
|
|
428
|
+
>
|
|
429
|
+
{variantLabelOf(variant)}
|
|
430
|
+
{!variant.availableForSale ? (
|
|
431
|
+
<span className='text-muted-foreground ms-2 text-xs font-normal'>{outOfStockLabel}</span>
|
|
432
|
+
) : null}
|
|
433
|
+
</Button>
|
|
434
|
+
)
|
|
435
|
+
})}
|
|
436
|
+
</div>
|
|
437
|
+
{variantLabel ? (
|
|
438
|
+
<span className='sr-only'>{variantLabel}</span>
|
|
439
|
+
) : null}
|
|
440
|
+
</div>
|
|
441
|
+
)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function QuantityStepper({
|
|
445
|
+
quantityValue,
|
|
446
|
+
quantityLabel,
|
|
447
|
+
onQuantityChange,
|
|
448
|
+
}: {
|
|
449
|
+
quantityValue: number
|
|
450
|
+
quantityLabel: string
|
|
451
|
+
onQuantityChange: (quantity: number) => void
|
|
452
|
+
}) {
|
|
453
|
+
return (
|
|
454
|
+
<div className='flex flex-col gap-2'>
|
|
455
|
+
<h3 className='font-bold'>{quantityLabel}</h3>
|
|
456
|
+
<div className='border-border flex w-fit items-center rounded-md border'>
|
|
457
|
+
<Button
|
|
458
|
+
type='button'
|
|
459
|
+
variant='ghost'
|
|
460
|
+
size='icon'
|
|
461
|
+
className='size-9 rounded-r-none'
|
|
462
|
+
aria-label={`${quantityLabel} -1`}
|
|
463
|
+
onClick={() => onQuantityChange(Math.max(1, quantityValue - 1))}
|
|
464
|
+
disabled={quantityValue <= 1}
|
|
465
|
+
>
|
|
466
|
+
<Minus />
|
|
467
|
+
</Button>
|
|
468
|
+
<span className='w-10 text-center text-sm tabular-nums'>{quantityValue}</span>
|
|
469
|
+
<Button
|
|
470
|
+
type='button'
|
|
471
|
+
variant='ghost'
|
|
472
|
+
size='icon'
|
|
473
|
+
className='size-9 rounded-l-none'
|
|
474
|
+
aria-label={`${quantityLabel} +1`}
|
|
475
|
+
onClick={() => onQuantityChange(quantityValue + 1)}
|
|
476
|
+
>
|
|
477
|
+
<Plus />
|
|
478
|
+
</Button>
|
|
479
|
+
</div>
|
|
480
|
+
</div>
|
|
481
|
+
)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function WishlistButton({
|
|
485
|
+
isWishlisted,
|
|
486
|
+
disabled,
|
|
487
|
+
onToggleWishlist,
|
|
488
|
+
removeFromWishlistLabel,
|
|
489
|
+
addToWishlistLabel,
|
|
490
|
+
className,
|
|
491
|
+
}: {
|
|
492
|
+
isWishlisted: boolean
|
|
493
|
+
disabled: boolean
|
|
494
|
+
onToggleWishlist?: () => void
|
|
495
|
+
removeFromWishlistLabel: string
|
|
496
|
+
addToWishlistLabel: string
|
|
497
|
+
className?: string
|
|
498
|
+
}) {
|
|
499
|
+
// Purpose-gated: no wishlist handler means no wishlist affordance. A visible
|
|
500
|
+
// button that does nothing is a dead control, so it is not rendered at all.
|
|
501
|
+
if (!onToggleWishlist) return null
|
|
502
|
+
return (
|
|
503
|
+
<Button
|
|
504
|
+
variant='outline'
|
|
505
|
+
size='icon'
|
|
506
|
+
className={cn('cursor-pointer', className)}
|
|
507
|
+
disabled={disabled}
|
|
508
|
+
onClick={onToggleWishlist}
|
|
509
|
+
aria-pressed={isWishlisted}
|
|
510
|
+
aria-label={isWishlisted ? removeFromWishlistLabel : addToWishlistLabel}
|
|
511
|
+
>
|
|
512
|
+
<ReactionToggle
|
|
513
|
+
active={isWishlisted}
|
|
514
|
+
icon={Heart}
|
|
515
|
+
iconClassName='size-5'
|
|
516
|
+
activeColorClassName='text-primary'
|
|
517
|
+
/>
|
|
518
|
+
</Button>
|
|
519
|
+
)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function StandardPresentation({
|
|
523
|
+
product,
|
|
524
|
+
selection,
|
|
525
|
+
images,
|
|
526
|
+
activeImage,
|
|
527
|
+
selectedImage,
|
|
528
|
+
onSelectImage,
|
|
529
|
+
onSelectVariant,
|
|
530
|
+
onQuantityChange,
|
|
531
|
+
onAddToCart,
|
|
532
|
+
isWishlisted,
|
|
533
|
+
onToggleWishlist,
|
|
534
|
+
rating,
|
|
535
|
+
deliveryEstimate,
|
|
536
|
+
shipping,
|
|
537
|
+
formatPrice,
|
|
538
|
+
hasRealImages,
|
|
539
|
+
labels,
|
|
540
|
+
}: {
|
|
541
|
+
product: ProductDetailProduct
|
|
542
|
+
selection: ResolvedSelection
|
|
543
|
+
images: string[]
|
|
544
|
+
activeImage: string
|
|
545
|
+
selectedImage: number
|
|
546
|
+
onSelectImage: (index: number) => void
|
|
547
|
+
onSelectVariant?: (variantId: string) => void
|
|
548
|
+
onQuantityChange?: (quantity: number) => void
|
|
549
|
+
onAddToCart: () => void
|
|
550
|
+
isWishlisted: boolean
|
|
551
|
+
onToggleWishlist?: () => void
|
|
552
|
+
rating?: ProductOverviewRating
|
|
553
|
+
deliveryEstimate?: string
|
|
554
|
+
shipping?: Money
|
|
555
|
+
formatPrice?: (money: Money) => string
|
|
556
|
+
hasRealImages: boolean
|
|
557
|
+
labels: SharedLabels & {
|
|
558
|
+
sizesLabel: string
|
|
559
|
+
variantLabel: string
|
|
560
|
+
deliveryEstimateLabel: string
|
|
561
|
+
shippingLabel: string
|
|
562
|
+
}
|
|
563
|
+
}) {
|
|
564
|
+
const { selectedVariant, selectedPrice, selectedCompareAt, priceAvailable, compareAtAvailable, canPurchase, quantityValue } =
|
|
565
|
+
selection
|
|
566
|
+
|
|
567
|
+
return (
|
|
568
|
+
<div>
|
|
569
|
+
<section className='@container mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
|
|
570
|
+
<div className='grid grid-cols-1 gap-6 py-4 lg:grid-cols-2 lg:gap-8 lg:py-6 xl:grid-cols-3 xl:gap-12 xl:py-12'>
|
|
571
|
+
<div className='flex flex-col justify-between gap-6 lg:gap-8'>
|
|
572
|
+
<div className='flex flex-col gap-2 lg:gap-4'>
|
|
573
|
+
{product.vendor ? (
|
|
574
|
+
<span className='text-sm font-semibold tracking-wide uppercase'>{product.vendor}</span>
|
|
575
|
+
) : null}
|
|
576
|
+
<h2 className='text-xl font-bold tracking-tight text-balance lg:text-3xl'>{product.title}</h2>
|
|
577
|
+
{product.description ? (
|
|
578
|
+
<p className='text-muted-foreground text-balance'>{product.description}</p>
|
|
579
|
+
) : null}
|
|
580
|
+
|
|
581
|
+
{selectedVariant ? (
|
|
582
|
+
priceAvailable ? (
|
|
583
|
+
<p className='text-2xl font-bold tracking-tight'>
|
|
584
|
+
{formatMoney(selectedPrice, formatPrice)}
|
|
585
|
+
{compareAtAvailable ? (
|
|
586
|
+
<span className='text-muted-foreground ms-2 text-base font-normal line-through'>
|
|
587
|
+
{formatMoney(selectedCompareAt, formatPrice)}
|
|
588
|
+
</span>
|
|
589
|
+
) : null}
|
|
590
|
+
</p>
|
|
591
|
+
) : (
|
|
592
|
+
<p className='text-muted-foreground text-sm'>{labels.unavailablePriceLabel}</p>
|
|
593
|
+
)
|
|
594
|
+
) : (
|
|
595
|
+
<p className='text-muted-foreground text-sm'>{labels.selectVariantLabel}</p>
|
|
596
|
+
)}
|
|
597
|
+
</div>
|
|
598
|
+
|
|
599
|
+
{hasRealImages ? (
|
|
600
|
+
<div className='flex flex-wrap gap-4'>
|
|
601
|
+
{images.map((url, index) => (
|
|
602
|
+
<button
|
|
603
|
+
key={`${product.id}:image:${index}`}
|
|
604
|
+
type='button'
|
|
605
|
+
onClick={() => onSelectImage(index)}
|
|
606
|
+
aria-label={`${product.title} ${index + 1}`}
|
|
607
|
+
aria-pressed={selectedImage === index}
|
|
608
|
+
className={cn(
|
|
609
|
+
'ring-offset-background size-16 cursor-pointer overflow-hidden rounded-sm ring-offset-2 transition-all lg:size-18',
|
|
610
|
+
selectedImage === index && 'ring-foreground ring-2',
|
|
611
|
+
)}
|
|
612
|
+
>
|
|
613
|
+
<img src={url || undefined} alt='' className='size-full object-cover' />
|
|
614
|
+
</button>
|
|
615
|
+
))}
|
|
616
|
+
</div>
|
|
617
|
+
) : null}
|
|
618
|
+
</div>
|
|
619
|
+
|
|
620
|
+
<div className='row-span-2 row-start-1 lg:col-start-2'>
|
|
621
|
+
<div className='bg-muted aspect-square w-full overflow-hidden rounded-lg'>
|
|
622
|
+
<img src={activeImage || undefined} alt={product.title} className='h-90 w-full object-cover' />
|
|
623
|
+
</div>
|
|
624
|
+
</div>
|
|
625
|
+
|
|
626
|
+
<div className='flex flex-col gap-6 lg:gap-10'>
|
|
627
|
+
<VariantSelector
|
|
628
|
+
product={product}
|
|
629
|
+
selectedVariantId={selectedVariant?.id}
|
|
630
|
+
onSelectVariant={onSelectVariant}
|
|
631
|
+
sizesLabel={labels.sizesLabel}
|
|
632
|
+
variantLabel={labels.variantLabel}
|
|
633
|
+
outOfStockLabel={labels.outOfStockLabel}
|
|
634
|
+
/>
|
|
635
|
+
|
|
636
|
+
{selectedVariant && selectedVariant.selectedOptions.length > 0 ? (
|
|
637
|
+
<p className='text-muted-foreground text-sm'>
|
|
638
|
+
{selectedVariant.selectedOptions
|
|
639
|
+
.map((option: SelectedOption) => `${option.name}: ${option.value}`)
|
|
640
|
+
.join(' · ')}
|
|
641
|
+
</p>
|
|
642
|
+
) : null}
|
|
643
|
+
|
|
644
|
+
{rating ? (
|
|
645
|
+
<div className='flex flex-col gap-2'>
|
|
646
|
+
<h3 className='font-bold'>{labels.reviewsLabel}</h3>
|
|
647
|
+
<div className='flex items-center gap-2'>
|
|
648
|
+
<div className='flex gap-1' aria-hidden='true'>
|
|
649
|
+
{[0, 1, 2, 3, 4].map(index => (
|
|
650
|
+
<Star
|
|
651
|
+
key={index}
|
|
652
|
+
className='text-foreground size-5'
|
|
653
|
+
fill={index < rating.value ? 'currentColor' : 'none'}
|
|
654
|
+
/>
|
|
655
|
+
))}
|
|
656
|
+
</div>
|
|
657
|
+
<span className='text-muted-foreground text-sm tabular-nums'>
|
|
658
|
+
{`${rating.value} (${rating.count})`}
|
|
659
|
+
</span>
|
|
660
|
+
</div>
|
|
661
|
+
</div>
|
|
662
|
+
) : null}
|
|
663
|
+
|
|
664
|
+
{onQuantityChange ? (
|
|
665
|
+
<QuantityStepper
|
|
666
|
+
quantityValue={quantityValue}
|
|
667
|
+
quantityLabel={labels.quantityLabel}
|
|
668
|
+
onQuantityChange={onQuantityChange}
|
|
669
|
+
/>
|
|
670
|
+
) : null}
|
|
671
|
+
|
|
672
|
+
{deliveryEstimate || shipping ? (
|
|
673
|
+
<dl className='text-muted-foreground flex flex-col gap-1 text-sm'>
|
|
674
|
+
{deliveryEstimate ? (
|
|
675
|
+
<div className='flex gap-2'>
|
|
676
|
+
<dt>{labels.deliveryEstimateLabel}</dt>
|
|
677
|
+
<dd className='text-foreground'>{deliveryEstimate}</dd>
|
|
678
|
+
</div>
|
|
679
|
+
) : null}
|
|
680
|
+
{isAvailableMoney(shipping) ? (
|
|
681
|
+
<div className='flex gap-2'>
|
|
682
|
+
<dt>{labels.shippingLabel}</dt>
|
|
683
|
+
<dd className='text-foreground'>{formatMoney(shipping, formatPrice)}</dd>
|
|
684
|
+
</div>
|
|
685
|
+
) : null}
|
|
686
|
+
</dl>
|
|
687
|
+
) : null}
|
|
688
|
+
|
|
689
|
+
<div className='flex gap-4'>
|
|
690
|
+
<Button
|
|
691
|
+
className='h-10 flex-1 cursor-pointer rounded-full px-8'
|
|
692
|
+
size='lg'
|
|
693
|
+
disabled={!canPurchase}
|
|
694
|
+
onClick={onAddToCart}
|
|
695
|
+
>
|
|
696
|
+
{labels.addToCartLabel}
|
|
697
|
+
</Button>
|
|
698
|
+
<WishlistButton
|
|
699
|
+
isWishlisted={isWishlisted}
|
|
700
|
+
disabled={!selectedVariant}
|
|
701
|
+
onToggleWishlist={onToggleWishlist}
|
|
702
|
+
removeFromWishlistLabel={labels.removeFromWishlistLabel}
|
|
703
|
+
addToWishlistLabel={labels.addToWishlistLabel}
|
|
704
|
+
className='size-9 rounded-full'
|
|
705
|
+
/>
|
|
706
|
+
</div>
|
|
707
|
+
</div>
|
|
708
|
+
</div>
|
|
709
|
+
</section>
|
|
710
|
+
</div>
|
|
711
|
+
)
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function GalleryPresentation({
|
|
715
|
+
product,
|
|
716
|
+
selection,
|
|
717
|
+
images,
|
|
718
|
+
activeImage,
|
|
719
|
+
selectedImage,
|
|
720
|
+
onSelectImage,
|
|
721
|
+
setCarouselApi,
|
|
722
|
+
onSelectVariant,
|
|
723
|
+
onQuantityChange,
|
|
724
|
+
onAddToCart,
|
|
725
|
+
isWishlisted,
|
|
726
|
+
onToggleWishlist,
|
|
727
|
+
rating,
|
|
728
|
+
formatPrice,
|
|
729
|
+
labels,
|
|
730
|
+
}: {
|
|
731
|
+
product: ProductDetailProduct
|
|
732
|
+
selection: ResolvedSelection
|
|
733
|
+
images: string[]
|
|
734
|
+
activeImage: string
|
|
735
|
+
selectedImage: number
|
|
736
|
+
onSelectImage: (index: number) => void
|
|
737
|
+
setCarouselApi: (api: CarouselApi | undefined) => void
|
|
738
|
+
onSelectVariant?: (variantId: string) => void
|
|
739
|
+
onQuantityChange?: (quantity: number) => void
|
|
740
|
+
onAddToCart: () => void
|
|
741
|
+
isWishlisted: boolean
|
|
742
|
+
onToggleWishlist?: () => void
|
|
743
|
+
rating?: ProductOverviewRating
|
|
744
|
+
formatPrice?: (money: Money) => string
|
|
745
|
+
labels: SharedLabels & { freeDeliveryLabel?: string }
|
|
746
|
+
}) {
|
|
747
|
+
const { selectedVariant, selectedPrice, selectedCompareAt, priceAvailable, compareAtAvailable, canPurchase, quantityValue } =
|
|
748
|
+
selection
|
|
749
|
+
|
|
750
|
+
return (
|
|
751
|
+
<section className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
|
|
752
|
+
<div className='grid grid-cols-1 gap-8 py-4 md:py-6 lg:grid-cols-2 lg:py-12'>
|
|
753
|
+
<div className='flex flex-col gap-4'>
|
|
754
|
+
<Carousel setApi={setCarouselApi} className='w-full'>
|
|
755
|
+
<CarouselContent>
|
|
756
|
+
{images.map((url, index) => (
|
|
757
|
+
<CarouselItem key={`${product.id}:carousel:${index}`}>
|
|
758
|
+
<div className='bg-muted flex h-130 w-full items-center justify-center overflow-hidden rounded-lg'>
|
|
759
|
+
<img src={url || undefined} alt={`${product.title} ${index + 1}`} className='h-full w-full object-cover' />
|
|
760
|
+
</div>
|
|
761
|
+
</CarouselItem>
|
|
762
|
+
))}
|
|
763
|
+
</CarouselContent>
|
|
764
|
+
</Carousel>
|
|
765
|
+
|
|
766
|
+
<div className='flex flex-wrap gap-4'>
|
|
767
|
+
{images.map((url, index) => (
|
|
768
|
+
<button
|
|
769
|
+
type='button'
|
|
770
|
+
key={`${product.id}:thumb:${index}`}
|
|
771
|
+
onClick={() => onSelectImage(index)}
|
|
772
|
+
aria-label={`Mostrar imagen ${index + 1} de ${product.title}`}
|
|
773
|
+
aria-pressed={selectedImage === index}
|
|
774
|
+
className='aspect-square size-16 cursor-pointer'
|
|
775
|
+
>
|
|
776
|
+
<img
|
|
777
|
+
src={url || undefined}
|
|
778
|
+
alt={`${product.title} ${index + 1}`}
|
|
779
|
+
className={cn(
|
|
780
|
+
'size-full cursor-pointer rounded-md object-cover ring-offset-2 transition-all',
|
|
781
|
+
selectedImage === index ? 'ring-foreground ring-2' : '',
|
|
782
|
+
)}
|
|
783
|
+
/>
|
|
784
|
+
</button>
|
|
785
|
+
))}
|
|
786
|
+
</div>
|
|
787
|
+
</div>
|
|
788
|
+
|
|
789
|
+
<div className='flex flex-col gap-6'>
|
|
790
|
+
<header className='flex flex-col gap-4'>
|
|
791
|
+
<div className='flex items-center'>
|
|
792
|
+
<Button variant='ghost' size='icon' className='bg-primary size-6 scale-75 cursor-pointer rounded-full'>
|
|
793
|
+
<Check className='text-primary-foreground size-4' />
|
|
794
|
+
</Button>
|
|
795
|
+
{product.vendor ? <span className='ms-2 font-medium'>{product.vendor}</span> : null}
|
|
796
|
+
{selectedVariant?.sku ? (
|
|
797
|
+
<span className='text-muted-foreground ms-auto'>{selectedVariant.sku}</span>
|
|
798
|
+
) : null}
|
|
799
|
+
</div>
|
|
800
|
+
<h1 className='text-xl font-bold text-balance xl:text-3xl'>{product.title}</h1>
|
|
801
|
+
|
|
802
|
+
{rating ? (
|
|
803
|
+
<div className='flex items-center gap-2'>
|
|
804
|
+
<div className='flex'>
|
|
805
|
+
{[0, 1, 2, 3, 4].map(i => (
|
|
806
|
+
<Star
|
|
807
|
+
key={i}
|
|
808
|
+
className={cn(
|
|
809
|
+
'size-5',
|
|
810
|
+
i < rating.value ? 'fill-foreground text-foreground' : 'text-muted-foreground',
|
|
811
|
+
)}
|
|
812
|
+
/>
|
|
813
|
+
))}
|
|
814
|
+
</div>
|
|
815
|
+
<span className='text-muted-foreground text-sm'>
|
|
816
|
+
{rating.count} {labels.reviewsLabel}
|
|
817
|
+
</span>
|
|
818
|
+
</div>
|
|
819
|
+
) : null}
|
|
820
|
+
</header>
|
|
821
|
+
|
|
822
|
+
{selectedVariant ? (
|
|
823
|
+
priceAvailable ? (
|
|
824
|
+
<p className='text-4xl font-bold'>
|
|
825
|
+
{formatMoney(selectedPrice, formatPrice)}
|
|
826
|
+
{compareAtAvailable ? (
|
|
827
|
+
<span className='text-muted-foreground ms-2 text-xl font-normal line-through'>
|
|
828
|
+
{formatMoney(selectedCompareAt, formatPrice)}
|
|
829
|
+
</span>
|
|
830
|
+
) : null}
|
|
831
|
+
</p>
|
|
832
|
+
) : (
|
|
833
|
+
<p className='text-muted-foreground text-sm'>{labels.unavailablePriceLabel}</p>
|
|
834
|
+
)
|
|
835
|
+
) : (
|
|
836
|
+
<p className='text-muted-foreground text-sm'>{labels.selectVariantLabel}</p>
|
|
837
|
+
)}
|
|
838
|
+
|
|
839
|
+
<VariantSelector
|
|
840
|
+
product={product}
|
|
841
|
+
selectedVariantId={selectedVariant?.id}
|
|
842
|
+
onSelectVariant={onSelectVariant}
|
|
843
|
+
sizesLabel={labels.selectVariantLabel}
|
|
844
|
+
variantLabel=''
|
|
845
|
+
outOfStockLabel={labels.outOfStockLabel}
|
|
846
|
+
/>
|
|
847
|
+
|
|
848
|
+
{onQuantityChange ? (
|
|
849
|
+
<QuantityStepper
|
|
850
|
+
quantityValue={quantityValue}
|
|
851
|
+
quantityLabel={labels.quantityLabel}
|
|
852
|
+
onQuantityChange={onQuantityChange}
|
|
853
|
+
/>
|
|
854
|
+
) : null}
|
|
855
|
+
|
|
856
|
+
<div className='flex gap-4'>
|
|
857
|
+
<Button className='h-12 flex-1 cursor-pointer rounded-md' size='lg' disabled={!canPurchase} onClick={onAddToCart}>
|
|
858
|
+
<ShoppingCart />
|
|
859
|
+
{labels.addToCartLabel}
|
|
860
|
+
</Button>
|
|
861
|
+
<WishlistButton
|
|
862
|
+
isWishlisted={isWishlisted}
|
|
863
|
+
disabled={!selectedVariant}
|
|
864
|
+
onToggleWishlist={onToggleWishlist}
|
|
865
|
+
removeFromWishlistLabel={labels.removeFromWishlistLabel}
|
|
866
|
+
addToWishlistLabel={labels.addToWishlistLabel}
|
|
867
|
+
className='border-input size-12 rounded-md'
|
|
868
|
+
/>
|
|
869
|
+
</div>
|
|
870
|
+
|
|
871
|
+
{labels.freeDeliveryLabel ? (
|
|
872
|
+
<div className='text-muted-foreground flex items-center justify-center gap-2 text-sm'>
|
|
873
|
+
<Truck className='size-4' />
|
|
874
|
+
<span>{labels.freeDeliveryLabel}</span>
|
|
875
|
+
</div>
|
|
876
|
+
) : null}
|
|
877
|
+
</div>
|
|
878
|
+
</div>
|
|
879
|
+
</section>
|
|
880
|
+
)
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function RichPresentation({
|
|
884
|
+
product,
|
|
885
|
+
selection,
|
|
886
|
+
images,
|
|
887
|
+
selectedImage,
|
|
888
|
+
onSelectImage,
|
|
889
|
+
onSelectVariant,
|
|
890
|
+
onQuantityChange,
|
|
891
|
+
onAddToCart,
|
|
892
|
+
isWishlisted,
|
|
893
|
+
onToggleWishlist,
|
|
894
|
+
onShare,
|
|
895
|
+
rating,
|
|
896
|
+
formatPrice,
|
|
897
|
+
specifications,
|
|
898
|
+
features,
|
|
899
|
+
reviews,
|
|
900
|
+
labels,
|
|
901
|
+
}: {
|
|
902
|
+
product: ProductDetailProduct
|
|
903
|
+
selection: ResolvedSelection
|
|
904
|
+
images: string[]
|
|
905
|
+
selectedImage: number
|
|
906
|
+
onSelectImage: (index: number) => void
|
|
907
|
+
onSelectVariant?: (variantId: string) => void
|
|
908
|
+
onQuantityChange?: (quantity: number) => void
|
|
909
|
+
onAddToCart: () => void
|
|
910
|
+
isWishlisted: boolean
|
|
911
|
+
onToggleWishlist?: () => void
|
|
912
|
+
onShare?: () => void
|
|
913
|
+
rating?: ProductOverviewRating
|
|
914
|
+
formatPrice?: (money: Money) => string
|
|
915
|
+
specifications?: Record<string, string>
|
|
916
|
+
features?: string[]
|
|
917
|
+
reviews?: ProductOverviewReview[]
|
|
918
|
+
labels: SharedLabels & {
|
|
919
|
+
badgeLabel?: string
|
|
920
|
+
availabilityLabel: string
|
|
921
|
+
skuLabel: string
|
|
922
|
+
overviewLabel: string
|
|
923
|
+
specsLabel: string
|
|
924
|
+
featuresLabel: string
|
|
925
|
+
specificationsLabel: string
|
|
926
|
+
galleryLabel: string
|
|
927
|
+
photosLabel: string
|
|
928
|
+
saveLabel: string
|
|
929
|
+
savedLabel: string
|
|
930
|
+
shareLabel: string
|
|
931
|
+
basedOnReviewsLabel: string
|
|
932
|
+
foundHelpfulLabel: string
|
|
933
|
+
ofCounterLabel: string
|
|
934
|
+
}
|
|
935
|
+
}) {
|
|
936
|
+
const { selectedVariant, selectedPrice, selectedCompareAt, priceAvailable, compareAtAvailable, canPurchase, quantityValue } =
|
|
937
|
+
selection
|
|
938
|
+
|
|
939
|
+
const featureList = features && features.length > 0 ? features : undefined
|
|
940
|
+
const specEntries = specifications ? Object.entries(specifications) : []
|
|
941
|
+
const hasSpecs = specEntries.length > 0
|
|
942
|
+
const hasReviews = Boolean(reviews && reviews.length > 0)
|
|
943
|
+
const hasGallery = images.length > 1
|
|
944
|
+
const hasTabs = Boolean(featureList) || hasSpecs || hasReviews || hasGallery
|
|
945
|
+
const reviewCount = reviews?.length ?? 0
|
|
946
|
+
|
|
947
|
+
// The initial tab is computed once and frozen: Base UI's uncontrolled Tabs warns if
|
|
948
|
+
// `defaultValue` changes after mount, and this expression tracks the data-derived tab set
|
|
949
|
+
// (which changes when optional props like `specifications` appear or disappear).
|
|
950
|
+
const [defaultTab] = useState(() =>
|
|
951
|
+
featureList ? 'overview' : hasSpecs ? 'specs' : hasReviews ? 'reviews' : 'gallery',
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
return (
|
|
955
|
+
<section className='py-12'>
|
|
956
|
+
<div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
|
|
957
|
+
<div className='grid gap-12 lg:grid-cols-2'>
|
|
958
|
+
<ProductOverviewGallery
|
|
959
|
+
images={images}
|
|
960
|
+
title={product.title}
|
|
961
|
+
badgeLabel={labels.badgeLabel}
|
|
962
|
+
selectedImage={selectedImage}
|
|
963
|
+
onSelectImage={onSelectImage}
|
|
964
|
+
photosLabel={labels.photosLabel}
|
|
965
|
+
/>
|
|
966
|
+
|
|
967
|
+
<div className='flex flex-col gap-8'>
|
|
968
|
+
<div>
|
|
969
|
+
<h1 className='text-3xl leading-tight font-bold text-balance'>{product.title}</h1>
|
|
970
|
+
<div className='mt-4 flex flex-wrap items-center gap-4'>
|
|
971
|
+
{rating ? (
|
|
972
|
+
<div className='flex items-center gap-2'>
|
|
973
|
+
<div className='flex'>
|
|
974
|
+
{[0, 1, 2, 3, 4].map(i => (
|
|
975
|
+
<Star
|
|
976
|
+
key={i}
|
|
977
|
+
className={cn(
|
|
978
|
+
'size-4',
|
|
979
|
+
i < rating.value ? 'fill-foreground text-foreground' : 'text-muted-foreground',
|
|
980
|
+
)}
|
|
981
|
+
/>
|
|
982
|
+
))}
|
|
983
|
+
</div>
|
|
984
|
+
<span className='text-sm font-medium'>{rating.value}</span>
|
|
985
|
+
</div>
|
|
986
|
+
) : null}
|
|
987
|
+
{hasReviews ? (
|
|
988
|
+
<span className='text-muted-foreground text-sm'>
|
|
989
|
+
{reviewCount} {labels.reviewsLabel.toLowerCase()}
|
|
990
|
+
</span>
|
|
991
|
+
) : null}
|
|
992
|
+
{selectedVariant?.sku ? (
|
|
993
|
+
<>
|
|
994
|
+
<Separator orientation='vertical' className='h-4' />
|
|
995
|
+
<span className='text-muted-foreground text-sm'>
|
|
996
|
+
{labels.skuLabel}: {selectedVariant.sku}
|
|
997
|
+
</span>
|
|
998
|
+
</>
|
|
999
|
+
) : null}
|
|
1000
|
+
</div>
|
|
1001
|
+
</div>
|
|
1002
|
+
|
|
1003
|
+
{selectedVariant ? (
|
|
1004
|
+
priceAvailable ? (
|
|
1005
|
+
<div className='flex items-baseline gap-3'>
|
|
1006
|
+
<span className='text-4xl font-bold'>{formatMoney(selectedPrice, formatPrice)}</span>
|
|
1007
|
+
{compareAtAvailable ? (
|
|
1008
|
+
<span className='text-muted-foreground text-xl line-through'>
|
|
1009
|
+
{formatMoney(selectedCompareAt, formatPrice)}
|
|
1010
|
+
</span>
|
|
1011
|
+
) : null}
|
|
1012
|
+
</div>
|
|
1013
|
+
) : (
|
|
1014
|
+
<p className='text-muted-foreground text-sm'>{labels.unavailablePriceLabel}</p>
|
|
1015
|
+
)
|
|
1016
|
+
) : (
|
|
1017
|
+
<p className='text-muted-foreground text-sm'>{labels.selectVariantLabel}</p>
|
|
1018
|
+
)}
|
|
1019
|
+
|
|
1020
|
+
{selectedVariant?.availableForSale ? (
|
|
1021
|
+
<div className='bg-success/10 inline-flex w-fit items-center gap-2 rounded-full px-3 py-1.5'>
|
|
1022
|
+
<div className='bg-success size-2 rounded-full' />
|
|
1023
|
+
<span className='text-success text-sm font-medium'>{labels.availabilityLabel}</span>
|
|
1024
|
+
</div>
|
|
1025
|
+
) : null}
|
|
1026
|
+
|
|
1027
|
+
{product.description ? <p className='text-muted-foreground leading-relaxed'>{product.description}</p> : null}
|
|
1028
|
+
|
|
1029
|
+
<VariantSelector
|
|
1030
|
+
product={product}
|
|
1031
|
+
selectedVariantId={selectedVariant?.id}
|
|
1032
|
+
onSelectVariant={onSelectVariant}
|
|
1033
|
+
sizesLabel={labels.selectVariantLabel}
|
|
1034
|
+
variantLabel=''
|
|
1035
|
+
outOfStockLabel={labels.outOfStockLabel}
|
|
1036
|
+
/>
|
|
1037
|
+
|
|
1038
|
+
{onQuantityChange ? (
|
|
1039
|
+
<QuantityStepper
|
|
1040
|
+
quantityValue={quantityValue}
|
|
1041
|
+
quantityLabel={labels.quantityLabel}
|
|
1042
|
+
onQuantityChange={onQuantityChange}
|
|
1043
|
+
/>
|
|
1044
|
+
) : null}
|
|
1045
|
+
|
|
1046
|
+
<div className='flex flex-col gap-4'>
|
|
1047
|
+
<Button size='lg' className='h-12 w-full cursor-pointer gap-2' disabled={!canPurchase} onClick={onAddToCart}>
|
|
1048
|
+
<ShoppingCart />
|
|
1049
|
+
{labels.addToCartLabel}
|
|
1050
|
+
</Button>
|
|
1051
|
+
<div className='flex gap-3'>
|
|
1052
|
+
<WishlistButton
|
|
1053
|
+
isWishlisted={isWishlisted}
|
|
1054
|
+
disabled={!selectedVariant}
|
|
1055
|
+
onToggleWishlist={onToggleWishlist}
|
|
1056
|
+
removeFromWishlistLabel={labels.removeFromWishlistLabel}
|
|
1057
|
+
addToWishlistLabel={labels.addToWishlistLabel}
|
|
1058
|
+
className='h-12 flex-1 gap-2 rounded-md'
|
|
1059
|
+
/>
|
|
1060
|
+
{onShare ? (
|
|
1061
|
+
<Button
|
|
1062
|
+
variant='outline'
|
|
1063
|
+
size='lg'
|
|
1064
|
+
className='h-12 flex-1 cursor-pointer gap-2'
|
|
1065
|
+
onClick={onShare}
|
|
1066
|
+
>
|
|
1067
|
+
<Share2 />
|
|
1068
|
+
{labels.shareLabel}
|
|
1069
|
+
</Button>
|
|
1070
|
+
) : null}
|
|
1071
|
+
</div>
|
|
1072
|
+
</div>
|
|
1073
|
+
</div>
|
|
1074
|
+
</div>
|
|
1075
|
+
|
|
1076
|
+
{hasTabs ? (
|
|
1077
|
+
<div className='mt-16'>
|
|
1078
|
+
<ProductOverviewTabs defaultValue={defaultTab} className='w-full'>
|
|
1079
|
+
<ProductOverviewTabsList className='bg-muted/50 relative grid !h-12 w-full grid-cols-4 rounded-xl p-1'>
|
|
1080
|
+
<ProductOverviewTabsIndicator />
|
|
1081
|
+
{featureList ? (
|
|
1082
|
+
<ProductOverviewTabsTrigger
|
|
1083
|
+
value='overview'
|
|
1084
|
+
className='z-10 h-10 gap-2 text-sm font-medium data-active:bg-transparent data-active:shadow-none dark:data-active:bg-transparent'
|
|
1085
|
+
>
|
|
1086
|
+
<Info className='size-4 shrink-0' />
|
|
1087
|
+
<span className='hidden sm:inline'>{labels.overviewLabel}</span>
|
|
1088
|
+
</ProductOverviewTabsTrigger>
|
|
1089
|
+
) : null}
|
|
1090
|
+
{hasSpecs ? (
|
|
1091
|
+
<ProductOverviewTabsTrigger
|
|
1092
|
+
value='specs'
|
|
1093
|
+
className='z-10 h-10 gap-2 text-sm font-medium data-active:bg-transparent data-active:shadow-none dark:data-active:bg-transparent'
|
|
1094
|
+
>
|
|
1095
|
+
<Package className='size-4 shrink-0' />
|
|
1096
|
+
<span className='hidden sm:inline'>{labels.specsLabel}</span>
|
|
1097
|
+
</ProductOverviewTabsTrigger>
|
|
1098
|
+
) : null}
|
|
1099
|
+
{hasReviews ? (
|
|
1100
|
+
<ProductOverviewTabsTrigger
|
|
1101
|
+
value='reviews'
|
|
1102
|
+
className='z-10 h-10 gap-2 text-sm font-medium data-active:bg-transparent data-active:shadow-none dark:data-active:bg-transparent'
|
|
1103
|
+
>
|
|
1104
|
+
<MessageSquare className='size-4 shrink-0' />
|
|
1105
|
+
<span className='hidden sm:inline'>
|
|
1106
|
+
{labels.reviewsLabel} ({reviewCount})
|
|
1107
|
+
</span>
|
|
1108
|
+
</ProductOverviewTabsTrigger>
|
|
1109
|
+
) : null}
|
|
1110
|
+
{hasGallery ? (
|
|
1111
|
+
<ProductOverviewTabsTrigger
|
|
1112
|
+
value='gallery'
|
|
1113
|
+
className='z-10 h-10 gap-2 text-sm font-medium data-active:bg-transparent data-active:shadow-none dark:data-active:bg-transparent'
|
|
1114
|
+
>
|
|
1115
|
+
<Camera className='size-4 shrink-0' />
|
|
1116
|
+
<span className='hidden sm:inline'>{labels.galleryLabel}</span>
|
|
1117
|
+
</ProductOverviewTabsTrigger>
|
|
1118
|
+
) : null}
|
|
1119
|
+
</ProductOverviewTabsList>
|
|
1120
|
+
{featureList ? (
|
|
1121
|
+
<ProductOverviewTabsContentFade value='overview' className='mt-8'>
|
|
1122
|
+
<Card className='border shadow-none'>
|
|
1123
|
+
<CardContent className='p-8 py-0'>
|
|
1124
|
+
<h3 className='mb-6 text-xl font-semibold text-balance'>{labels.featuresLabel}</h3>
|
|
1125
|
+
<div className='grid gap-4 sm:grid-cols-2'>
|
|
1126
|
+
{featureList.map(feature => (
|
|
1127
|
+
<div key={feature} className='group flex items-center gap-3'>
|
|
1128
|
+
<div className='bg-primary/20 size-2 rounded-full transition-colors' />
|
|
1129
|
+
<span className='text-sm'>{feature}</span>
|
|
1130
|
+
</div>
|
|
1131
|
+
))}
|
|
1132
|
+
</div>
|
|
1133
|
+
</CardContent>
|
|
1134
|
+
</Card>
|
|
1135
|
+
</ProductOverviewTabsContentFade>
|
|
1136
|
+
) : null}
|
|
1137
|
+
{hasSpecs ? (
|
|
1138
|
+
<ProductOverviewTabsContentFade value='specs' className='mt-8'>
|
|
1139
|
+
<Card className='border shadow-none'>
|
|
1140
|
+
<CardContent className='p-8 py-0'>
|
|
1141
|
+
<h3 className='mb-6 text-xl font-semibold text-balance'>{labels.specificationsLabel}</h3>
|
|
1142
|
+
<div className='flex flex-col gap-4'>
|
|
1143
|
+
{specEntries.map(([key, value]) => (
|
|
1144
|
+
<div
|
|
1145
|
+
key={key}
|
|
1146
|
+
className='border-muted grid grid-cols-1 gap-2 border-b py-3 last:border-b-0 md:grid-cols-3 md:gap-4'
|
|
1147
|
+
>
|
|
1148
|
+
<span className='text-muted-foreground text-sm font-medium'>{key}</span>
|
|
1149
|
+
<span className='col-span-1 text-sm md:col-span-2'>{value}</span>
|
|
1150
|
+
</div>
|
|
1151
|
+
))}
|
|
1152
|
+
</div>
|
|
1153
|
+
</CardContent>
|
|
1154
|
+
</Card>
|
|
1155
|
+
</ProductOverviewTabsContentFade>
|
|
1156
|
+
) : null}
|
|
1157
|
+
{hasReviews && reviews ? (
|
|
1158
|
+
<ProductOverviewTabsContentFade value='reviews' className='mt-8'>
|
|
1159
|
+
<ProductOverviewReviewsTab
|
|
1160
|
+
rating={rating?.value ?? 0}
|
|
1161
|
+
reviews={reviews}
|
|
1162
|
+
basedOnReviewsLabel={labels.basedOnReviewsLabel}
|
|
1163
|
+
foundHelpfulLabel={labels.foundHelpfulLabel}
|
|
1164
|
+
/>
|
|
1165
|
+
</ProductOverviewTabsContentFade>
|
|
1166
|
+
) : null}
|
|
1167
|
+
{hasGallery ? (
|
|
1168
|
+
<ProductOverviewTabsContentFade value='gallery' className='mt-8'>
|
|
1169
|
+
<ProductOverviewLightboxTab
|
|
1170
|
+
images={images}
|
|
1171
|
+
title={product.title}
|
|
1172
|
+
galleryLabel={labels.galleryLabel}
|
|
1173
|
+
ofCounterLabel={labels.ofCounterLabel}
|
|
1174
|
+
/>
|
|
1175
|
+
</ProductOverviewTabsContentFade>
|
|
1176
|
+
) : null}
|
|
1177
|
+
</ProductOverviewTabs>
|
|
1178
|
+
</div>
|
|
1179
|
+
) : null}
|
|
1180
|
+
</div>
|
|
1181
|
+
</section>
|
|
1182
|
+
)
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
export type { ProductOverviewReview }
|
|
1186
|
+
export { ProductOverviewGallery, ProductOverviewLightboxTab, ProductOverviewReviewsTab }
|