@thorprovider/blocks 0.1.3 → 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 +73 -29
- 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/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-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} +119 -85
- 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 +27 -9
- package/src/blocks/marketing/navbars/navbar-4.test.tsx +33 -2
- package/src/blocks/marketing/navbars/navbar-4.tsx +29 -11
- package/src/index.ts +77 -48
- package/src/lib/contracts-curated.test.ts +42 -24
- package/src/lib/contracts.test.ts +143 -2
- package/src/lib/contracts.ts +346 -14
- 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 -47
- package/.turbo/turbo-type-check.log +0 -0
- package/dist/.tsbuildinfo +0 -1
- package/dist/contracts.d.mts +0 -1732
- package/dist/contracts.d.ts +0 -1732
- package/dist/contracts.js +0 -423
- package/dist/contracts.js.map +0 -1
- package/dist/contracts.mjs +0 -365
- package/dist/contracts.mjs.map +0 -1
- package/dist/index.d.mts +0 -3068
- package/dist/index.d.ts +0 -3068
- package/dist/index.js +0 -17834
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -17740
- 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.test.tsx +0 -88
- package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -192
- 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 -229
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -72
- 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
|
@@ -1,370 +0,0 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
|
-
import { useState } from 'react'
|
|
4
|
-
import { Heart, Minus, Plus, Star } from 'lucide-react'
|
|
5
|
-
import type { Image, Money, ProductOption, SelectedOption } from '@thorprovider/types'
|
|
6
|
-
import { Button } from '../../../ui/button'
|
|
7
|
-
import { cn } from '../../../lib/utils'
|
|
8
|
-
import { PRODUCT_IMAGE_FALLBACK } from '../../../lib/contracts'
|
|
9
|
-
import { formatMoney, isAvailableMoney } from '../../../lib/money'
|
|
10
|
-
import { ReactionToggle } from '../../shared/reaction-toggle'
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Canonical product-detail projection.
|
|
14
|
-
*
|
|
15
|
-
* The block consumes the canonical `@thorprovider/types` shapes (`Money`,
|
|
16
|
-
* `ProductOption`, `SelectedOption`, `Image`) so adapter output maps without a
|
|
17
|
-
* second, drifting product type. The only deviation is `price`: the canon marks
|
|
18
|
-
* it required, but a missing/unresolved backend price is a real, distinct state
|
|
19
|
-
* that must disable purchase — so the projection allows `price` to be absent
|
|
20
|
-
* and the block treats absence as "unavailable", never as zero.
|
|
21
|
-
*/
|
|
22
|
-
export interface ProductDetailVariant {
|
|
23
|
-
id: string
|
|
24
|
-
title: string
|
|
25
|
-
sku?: string
|
|
26
|
-
availableForSale: boolean
|
|
27
|
-
selectedOptions: SelectedOption[]
|
|
28
|
-
/** Canonical Money; omit/null when the backend has no resolved price. */
|
|
29
|
-
price?: Money | null
|
|
30
|
-
compareAtPrice?: Money
|
|
31
|
-
image?: Image
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export interface ProductDetailProduct {
|
|
35
|
-
id: string
|
|
36
|
-
handle?: string
|
|
37
|
-
title: string
|
|
38
|
-
description?: string
|
|
39
|
-
vendor?: string
|
|
40
|
-
options: ProductOption[]
|
|
41
|
-
variants: ProductDetailVariant[]
|
|
42
|
-
images: Image[]
|
|
43
|
-
featuredImage?: Image
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface ProductOverview1Rating {
|
|
47
|
-
value: number
|
|
48
|
-
count: number
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface ProductOverview1Props {
|
|
52
|
-
product: ProductDetailProduct
|
|
53
|
-
/** Controlled selection: the backend variant id, or null/undefined when nothing is explicitly selected. */
|
|
54
|
-
selectedVariantId?: string | null
|
|
55
|
-
onSelectVariant?: (variantId: string) => void
|
|
56
|
-
/** Controlled quantity for the add-to-cart action; defaults to 1 when omitted. */
|
|
57
|
-
quantity?: number
|
|
58
|
-
onQuantityChange?: (quantity: number) => void
|
|
59
|
-
/** Add-to-cart identity is the selected backend variant id, with the requested quantity. */
|
|
60
|
-
onAddToCart?: (variantId: string, quantity: number) => void
|
|
61
|
-
isWishlisted?: boolean
|
|
62
|
-
onToggleWishlist?: (variantId: string, isWishlisted: boolean) => void
|
|
63
|
-
/** Rendered only when the backend supplies it. */
|
|
64
|
-
rating?: ProductOverview1Rating
|
|
65
|
-
/** Rendered only when the backend supplies it. */
|
|
66
|
-
deliveryEstimate?: string
|
|
67
|
-
/** Rendered only when the backend supplies it. */
|
|
68
|
-
shipping?: Money
|
|
69
|
-
formatPrice?: (money: Money) => string
|
|
70
|
-
sizesLabel?: string
|
|
71
|
-
variantLabel?: string
|
|
72
|
-
reviewsLabel?: string
|
|
73
|
-
addToCartLabel?: string
|
|
74
|
-
outOfStockLabel?: string
|
|
75
|
-
unavailablePriceLabel?: string
|
|
76
|
-
selectVariantLabel?: string
|
|
77
|
-
quantityLabel?: string
|
|
78
|
-
deliveryEstimateLabel?: string
|
|
79
|
-
shippingLabel?: string
|
|
80
|
-
removeFromWishlistLabel?: string
|
|
81
|
-
addToWishlistLabel?: string
|
|
82
|
-
imageFallback?: string
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function variantLabelOf(variant: ProductDetailVariant): string {
|
|
86
|
-
if (variant.title.trim() !== '') return variant.title
|
|
87
|
-
return variant.selectedOptions.map(option => option.value).join(' / ')
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* The add-to-cart payload contract: the explicitly selected backend variant id
|
|
92
|
-
* and a positive quantity (defaults to 1). Returns `null` when no variant has
|
|
93
|
-
* been selected — the block never substitutes the first variant.
|
|
94
|
-
*/
|
|
95
|
-
export function resolveAddToCartPayload(
|
|
96
|
-
variant: ProductDetailVariant | null,
|
|
97
|
-
quantity: number | undefined,
|
|
98
|
-
): { variantId: string; quantity: number } | null {
|
|
99
|
-
if (!variant) return null
|
|
100
|
-
return {
|
|
101
|
-
variantId: variant.id,
|
|
102
|
-
quantity: typeof quantity === 'number' && quantity > 0 ? quantity : 1,
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function galleryImages(product: ProductDetailProduct, fallback: string): string[] {
|
|
107
|
-
const images = product.images
|
|
108
|
-
.map(image => image.url)
|
|
109
|
-
.filter((url): url is string => typeof url === 'string' && url.trim() !== '')
|
|
110
|
-
if (images.length > 0) return images
|
|
111
|
-
const featured = product.featuredImage?.url
|
|
112
|
-
if (typeof featured === 'string' && featured.trim() !== '') return [featured]
|
|
113
|
-
return [fallback]
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export default function ProductOverview1({
|
|
117
|
-
product,
|
|
118
|
-
selectedVariantId,
|
|
119
|
-
onSelectVariant,
|
|
120
|
-
quantity,
|
|
121
|
-
onQuantityChange,
|
|
122
|
-
onAddToCart,
|
|
123
|
-
isWishlisted = false,
|
|
124
|
-
onToggleWishlist,
|
|
125
|
-
rating,
|
|
126
|
-
deliveryEstimate,
|
|
127
|
-
shipping,
|
|
128
|
-
formatPrice,
|
|
129
|
-
sizesLabel = 'Variantes',
|
|
130
|
-
variantLabel = 'Variante',
|
|
131
|
-
reviewsLabel = 'Reseñas',
|
|
132
|
-
addToCartLabel = 'Agregar al carrito',
|
|
133
|
-
outOfStockLabel = 'Agotado',
|
|
134
|
-
unavailablePriceLabel = 'Precio no disponible',
|
|
135
|
-
selectVariantLabel = 'Selecciona una variante',
|
|
136
|
-
quantityLabel = 'Cantidad',
|
|
137
|
-
deliveryEstimateLabel = 'Entrega estimada',
|
|
138
|
-
shippingLabel = 'Envío',
|
|
139
|
-
removeFromWishlistLabel = 'Quitar de favoritos',
|
|
140
|
-
addToWishlistLabel = 'Agregar a favoritos',
|
|
141
|
-
imageFallback = PRODUCT_IMAGE_FALLBACK,
|
|
142
|
-
}: ProductOverview1Props) {
|
|
143
|
-
const [selectedImage, setSelectedImage] = useState(0)
|
|
144
|
-
|
|
145
|
-
const selectedVariant =
|
|
146
|
-
selectedVariantId != null
|
|
147
|
-
? product.variants.find(variant => variant.id === selectedVariantId) ?? null
|
|
148
|
-
: null
|
|
149
|
-
|
|
150
|
-
const selectedPrice = selectedVariant?.price
|
|
151
|
-
const selectedCompareAt = selectedVariant?.compareAtPrice
|
|
152
|
-
const priceAvailable = isAvailableMoney(selectedPrice)
|
|
153
|
-
const compareAtAvailable = isAvailableMoney(selectedCompareAt)
|
|
154
|
-
const canPurchase = Boolean(selectedVariant?.availableForSale) && priceAvailable
|
|
155
|
-
const quantityValue = quantity ?? 1
|
|
156
|
-
const addPayload = resolveAddToCartPayload(selectedVariant, quantityValue)
|
|
157
|
-
|
|
158
|
-
const images = galleryImages(product, imageFallback)
|
|
159
|
-
const hasRealImages = images[0] !== imageFallback || product.featuredImage != null
|
|
160
|
-
const activeImage = images[Math.min(selectedImage, images.length - 1)]
|
|
161
|
-
|
|
162
|
-
const toggleWishlist = () => {
|
|
163
|
-
if (!selectedVariant || !onToggleWishlist) return
|
|
164
|
-
onToggleWishlist(selectedVariant.id, !isWishlisted)
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
return (
|
|
168
|
-
<div>
|
|
169
|
-
<section className='@container mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
|
|
170
|
-
<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'>
|
|
171
|
-
<div className='flex flex-col justify-between gap-6 lg:gap-8'>
|
|
172
|
-
<div className='flex flex-col gap-2 lg:gap-4'>
|
|
173
|
-
{product.vendor ? (
|
|
174
|
-
<span className='text-sm font-semibold tracking-wide uppercase'>{product.vendor}</span>
|
|
175
|
-
) : null}
|
|
176
|
-
<h2 className='text-xl font-bold tracking-tight text-balance lg:text-3xl'>{product.title}</h2>
|
|
177
|
-
{product.description ? (
|
|
178
|
-
<p className='text-muted-foreground text-balance'>{product.description}</p>
|
|
179
|
-
) : null}
|
|
180
|
-
|
|
181
|
-
{selectedVariant ? (
|
|
182
|
-
priceAvailable ? (
|
|
183
|
-
<p className='text-2xl font-bold tracking-tight'>
|
|
184
|
-
{formatMoney(selectedPrice, formatPrice)}
|
|
185
|
-
{compareAtAvailable ? (
|
|
186
|
-
<span className='text-muted-foreground ms-2 text-base font-normal line-through'>
|
|
187
|
-
{formatMoney(selectedCompareAt, formatPrice)}
|
|
188
|
-
</span>
|
|
189
|
-
) : null}
|
|
190
|
-
</p>
|
|
191
|
-
) : (
|
|
192
|
-
<p className='text-muted-foreground text-sm'>{unavailablePriceLabel}</p>
|
|
193
|
-
)
|
|
194
|
-
) : (
|
|
195
|
-
<p className='text-muted-foreground text-sm'>{selectVariantLabel}</p>
|
|
196
|
-
)}
|
|
197
|
-
</div>
|
|
198
|
-
|
|
199
|
-
{hasRealImages ? (
|
|
200
|
-
<div className='flex flex-wrap gap-4'>
|
|
201
|
-
{images.map((url, index) => (
|
|
202
|
-
<button
|
|
203
|
-
key={`${product.id}:image:${index}`}
|
|
204
|
-
type='button'
|
|
205
|
-
onClick={() => setSelectedImage(index)}
|
|
206
|
-
aria-label={`${product.title} ${index + 1}`}
|
|
207
|
-
aria-pressed={selectedImage === index}
|
|
208
|
-
className={cn(
|
|
209
|
-
'ring-offset-background size-16 cursor-pointer overflow-hidden rounded-sm ring-offset-2 transition-all lg:size-18',
|
|
210
|
-
selectedImage === index && 'ring-foreground ring-2',
|
|
211
|
-
)}
|
|
212
|
-
>
|
|
213
|
-
<img src={url || undefined} alt='' className='size-full object-cover' />
|
|
214
|
-
</button>
|
|
215
|
-
))}
|
|
216
|
-
</div>
|
|
217
|
-
) : null}
|
|
218
|
-
</div>
|
|
219
|
-
|
|
220
|
-
<div className='row-span-2 row-start-1 lg:col-start-2'>
|
|
221
|
-
<div className='bg-muted aspect-square w-full overflow-hidden rounded-lg'>
|
|
222
|
-
<img
|
|
223
|
-
src={activeImage || undefined}
|
|
224
|
-
alt={product.title}
|
|
225
|
-
className='h-90 w-full object-cover'
|
|
226
|
-
/>
|
|
227
|
-
</div>
|
|
228
|
-
</div>
|
|
229
|
-
|
|
230
|
-
<div className='flex flex-col gap-6 lg:gap-10'>
|
|
231
|
-
{product.variants.length > 0 ? (
|
|
232
|
-
<div className='flex flex-col gap-2'>
|
|
233
|
-
<h3 className='font-bold'>{sizesLabel}</h3>
|
|
234
|
-
<div className='flex flex-wrap gap-3'>
|
|
235
|
-
{product.variants.map(variant => {
|
|
236
|
-
const isSelected = variant.id === selectedVariantId
|
|
237
|
-
return (
|
|
238
|
-
<Button
|
|
239
|
-
key={variant.id}
|
|
240
|
-
variant={isSelected ? 'default' : 'outline'}
|
|
241
|
-
onClick={onSelectVariant ? () => onSelectVariant(variant.id) : undefined}
|
|
242
|
-
disabled={!variant.availableForSale}
|
|
243
|
-
aria-pressed={isSelected}
|
|
244
|
-
className='h-9 cursor-pointer rounded-full px-4'
|
|
245
|
-
>
|
|
246
|
-
{variantLabelOf(variant)}
|
|
247
|
-
{!variant.availableForSale ? (
|
|
248
|
-
<span className='text-muted-foreground ms-2 text-xs font-normal'>
|
|
249
|
-
{outOfStockLabel}
|
|
250
|
-
</span>
|
|
251
|
-
) : null}
|
|
252
|
-
</Button>
|
|
253
|
-
)
|
|
254
|
-
})}
|
|
255
|
-
</div>
|
|
256
|
-
{selectedVariant && selectedVariant.selectedOptions.length > 0 ? (
|
|
257
|
-
<p className='text-muted-foreground text-sm'>
|
|
258
|
-
{selectedVariant.selectedOptions
|
|
259
|
-
.map((option: SelectedOption) => `${option.name}: ${option.value}`)
|
|
260
|
-
.join(' · ')}
|
|
261
|
-
</p>
|
|
262
|
-
) : null}
|
|
263
|
-
</div>
|
|
264
|
-
) : null}
|
|
265
|
-
|
|
266
|
-
{rating ? (
|
|
267
|
-
<div className='flex flex-col gap-2'>
|
|
268
|
-
<h3 className='font-bold'>{reviewsLabel}</h3>
|
|
269
|
-
<div className='flex items-center gap-2'>
|
|
270
|
-
<div className='flex gap-1' aria-hidden='true'>
|
|
271
|
-
{[0, 1, 2, 3, 4].map(index => (
|
|
272
|
-
<Star
|
|
273
|
-
key={index}
|
|
274
|
-
className='text-foreground size-5'
|
|
275
|
-
fill={index < rating.value ? 'currentColor' : 'none'}
|
|
276
|
-
/>
|
|
277
|
-
))}
|
|
278
|
-
</div>
|
|
279
|
-
<span className='text-muted-foreground text-sm tabular-nums'>
|
|
280
|
-
{`${rating.value} (${rating.count})`}
|
|
281
|
-
</span>
|
|
282
|
-
</div>
|
|
283
|
-
</div>
|
|
284
|
-
) : null}
|
|
285
|
-
|
|
286
|
-
{onQuantityChange ? (
|
|
287
|
-
<div className='flex flex-col gap-2'>
|
|
288
|
-
<h3 className='font-bold'>{quantityLabel}</h3>
|
|
289
|
-
<div className='border-border flex w-fit items-center rounded-md border'>
|
|
290
|
-
<Button
|
|
291
|
-
type='button'
|
|
292
|
-
variant='ghost'
|
|
293
|
-
size='icon'
|
|
294
|
-
className='size-9 rounded-r-none'
|
|
295
|
-
aria-label={`${quantityLabel} -1`}
|
|
296
|
-
onClick={() => onQuantityChange(Math.max(1, quantityValue - 1))}
|
|
297
|
-
disabled={quantityValue <= 1}
|
|
298
|
-
>
|
|
299
|
-
<Minus />
|
|
300
|
-
</Button>
|
|
301
|
-
<span className='w-10 text-center text-sm tabular-nums'>{quantityValue}</span>
|
|
302
|
-
<Button
|
|
303
|
-
type='button'
|
|
304
|
-
variant='ghost'
|
|
305
|
-
size='icon'
|
|
306
|
-
className='size-9 rounded-l-none'
|
|
307
|
-
aria-label={`${quantityLabel} +1`}
|
|
308
|
-
onClick={() => onQuantityChange(quantityValue + 1)}
|
|
309
|
-
>
|
|
310
|
-
<Plus />
|
|
311
|
-
</Button>
|
|
312
|
-
</div>
|
|
313
|
-
</div>
|
|
314
|
-
) : null}
|
|
315
|
-
|
|
316
|
-
{deliveryEstimate || shipping ? (
|
|
317
|
-
<dl className='text-muted-foreground flex flex-col gap-1 text-sm'>
|
|
318
|
-
{deliveryEstimate ? (
|
|
319
|
-
<div className='flex gap-2'>
|
|
320
|
-
<dt>{deliveryEstimateLabel}</dt>
|
|
321
|
-
<dd className='text-foreground'>{deliveryEstimate}</dd>
|
|
322
|
-
</div>
|
|
323
|
-
) : null}
|
|
324
|
-
{isAvailableMoney(shipping) ? (
|
|
325
|
-
<div className='flex gap-2'>
|
|
326
|
-
<dt>{shippingLabel}</dt>
|
|
327
|
-
<dd className='text-foreground'>{formatMoney(shipping, formatPrice)}</dd>
|
|
328
|
-
</div>
|
|
329
|
-
) : null}
|
|
330
|
-
</dl>
|
|
331
|
-
) : null}
|
|
332
|
-
|
|
333
|
-
<div className='flex gap-4'>
|
|
334
|
-
<Button
|
|
335
|
-
className='h-10 flex-1 cursor-pointer rounded-full px-8'
|
|
336
|
-
size='lg'
|
|
337
|
-
disabled={!canPurchase}
|
|
338
|
-
onClick={
|
|
339
|
-
onAddToCart && addPayload
|
|
340
|
-
? () => onAddToCart(addPayload.variantId, addPayload.quantity)
|
|
341
|
-
: undefined
|
|
342
|
-
}
|
|
343
|
-
>
|
|
344
|
-
{addToCartLabel}
|
|
345
|
-
</Button>
|
|
346
|
-
<Button
|
|
347
|
-
variant='outline'
|
|
348
|
-
size='icon'
|
|
349
|
-
className='size-9 cursor-pointer rounded-full'
|
|
350
|
-
disabled={!selectedVariant}
|
|
351
|
-
onClick={onToggleWishlist ? toggleWishlist : undefined}
|
|
352
|
-
aria-pressed={isWishlisted}
|
|
353
|
-
aria-label={isWishlisted ? removeFromWishlistLabel : addToWishlistLabel}
|
|
354
|
-
>
|
|
355
|
-
<ReactionToggle
|
|
356
|
-
active={isWishlisted}
|
|
357
|
-
icon={Heart}
|
|
358
|
-
iconClassName='size-5'
|
|
359
|
-
activeColorClassName='text-primary'
|
|
360
|
-
/>
|
|
361
|
-
</Button>
|
|
362
|
-
</div>
|
|
363
|
-
</div>
|
|
364
|
-
</div>
|
|
365
|
-
</section>
|
|
366
|
-
</div>
|
|
367
|
-
)
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
export { variantLabelOf, galleryImages }
|
|
@@ -1,272 +0,0 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
|
-
import { useState, useEffect } from 'react'
|
|
4
|
-
import { Button } from '../../../ui/button'
|
|
5
|
-
import { cn } from '../../../lib/utils'
|
|
6
|
-
import { Heart, ShoppingBag, Truck, Star, Check } from 'lucide-react'
|
|
7
|
-
import { Carousel, CarouselContent, CarouselItem, type CarouselApi } from '../../../ui/carousel'
|
|
8
|
-
import { ReactionToggle } from '../../shared/reaction-toggle'
|
|
9
|
-
|
|
10
|
-
export interface ProductOverview2ProductImage {
|
|
11
|
-
src: string
|
|
12
|
-
alt: string
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface ProductOverview2ProductColor {
|
|
16
|
-
name: string
|
|
17
|
-
value: string
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface ProductOverview2ProductSize {
|
|
21
|
-
eu: number
|
|
22
|
-
uk: number
|
|
23
|
-
us: number
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface ProductOverview2Product {
|
|
27
|
-
id: string
|
|
28
|
-
brand: string
|
|
29
|
-
name: string
|
|
30
|
-
sku: string
|
|
31
|
-
price: number
|
|
32
|
-
currency: string
|
|
33
|
-
rating: number
|
|
34
|
-
reviews: number
|
|
35
|
-
images: ProductOverview2ProductImage[]
|
|
36
|
-
sizes: ProductOverview2ProductSize[]
|
|
37
|
-
colors: ProductOverview2ProductColor[]
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export interface ProductOverview2Props {
|
|
41
|
-
product: ProductOverview2Product
|
|
42
|
-
sizesLabel?: string
|
|
43
|
-
colorLabel?: string
|
|
44
|
-
reviewsLabel?: string
|
|
45
|
-
addToCartLabel?: string
|
|
46
|
-
removeFromWishlistLabel?: string
|
|
47
|
-
addToWishlistLabel?: string
|
|
48
|
-
sizeGuideLabel?: string
|
|
49
|
-
freeDeliveryLabel?: string
|
|
50
|
-
onAddToCart?: (product: ProductOverview2Product) => void
|
|
51
|
-
onToggleWishlist?: (product: ProductOverview2Product, isWishlisted: boolean) => void
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export default function ProductOverview2({
|
|
55
|
-
product,
|
|
56
|
-
sizesLabel = 'Tallas',
|
|
57
|
-
colorLabel = 'Color',
|
|
58
|
-
reviewsLabel = 'Reseñas',
|
|
59
|
-
addToCartLabel = 'Agregar al carrito',
|
|
60
|
-
removeFromWishlistLabel = 'Quitar de favoritos',
|
|
61
|
-
addToWishlistLabel = 'Agregar a favoritos',
|
|
62
|
-
sizeGuideLabel = 'Guía de tallas',
|
|
63
|
-
freeDeliveryLabel = 'Envío gratis en pedidos superiores a $30.00',
|
|
64
|
-
onAddToCart,
|
|
65
|
-
onToggleWishlist,
|
|
66
|
-
}: ProductOverview2Props) {
|
|
67
|
-
const [selectedImage, setSelectedImage] = useState(0)
|
|
68
|
-
const [selectedSize, setSelectedSize] = useState<number | null>(product.sizes[1]?.eu ?? null)
|
|
69
|
-
const [selectedColor, setSelectedColor] = useState(product.colors[0])
|
|
70
|
-
const [isWishlisted, setIsWishlisted] = useState(false)
|
|
71
|
-
const [sizeType, setSizeType] = useState<'eu' | 'uk' | 'us'>('eu')
|
|
72
|
-
const [carouselApi, setCarouselApi] = useState<CarouselApi>()
|
|
73
|
-
|
|
74
|
-
useEffect(() => {
|
|
75
|
-
if (!carouselApi) return
|
|
76
|
-
|
|
77
|
-
// Set carousel to the selected image
|
|
78
|
-
carouselApi.scrollTo(selectedImage)
|
|
79
|
-
|
|
80
|
-
// Update selected image when carousel changes
|
|
81
|
-
const handleSelect = () => {
|
|
82
|
-
const currentIndex = carouselApi.selectedScrollSnap()
|
|
83
|
-
setSelectedImage(currentIndex)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
carouselApi.on('select', handleSelect)
|
|
87
|
-
return () => {
|
|
88
|
-
carouselApi.off('select', handleSelect)
|
|
89
|
-
}
|
|
90
|
-
}, [carouselApi, selectedImage])
|
|
91
|
-
|
|
92
|
-
return (
|
|
93
|
-
<section className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
|
|
94
|
-
<div className='grid grid-cols-1 gap-8 py-4 md:py-6 lg:grid-cols-2 lg:py-12'>
|
|
95
|
-
{/* Product Gallery */}
|
|
96
|
-
<div className='flex flex-col gap-4'>
|
|
97
|
-
{/* Main Image - Carousel */}
|
|
98
|
-
<Carousel setApi={setCarouselApi} className='w-full'>
|
|
99
|
-
<CarouselContent>
|
|
100
|
-
{product.images.map((image, index) => (
|
|
101
|
-
<CarouselItem key={index}>
|
|
102
|
-
<div className='flex h-130 w-full items-center justify-center overflow-hidden rounded-lg bg-muted'>
|
|
103
|
-
<img src={image.src || undefined} alt={image.alt} className='h-full w-full object-cover' />
|
|
104
|
-
</div>
|
|
105
|
-
</CarouselItem>
|
|
106
|
-
))}
|
|
107
|
-
</CarouselContent>
|
|
108
|
-
</Carousel>
|
|
109
|
-
|
|
110
|
-
{/* Thumbnails */}
|
|
111
|
-
<div className='flex flex-wrap gap-4'>
|
|
112
|
-
{product.images.map((image, index) => (
|
|
113
|
-
<button
|
|
114
|
-
type='button'
|
|
115
|
-
key={index}
|
|
116
|
-
onClick={() => setSelectedImage(index)}
|
|
117
|
-
aria-label={`Mostrar imagen ${index + 1} de ${product.name}`}
|
|
118
|
-
className='aspect-square size-16 cursor-pointer'
|
|
119
|
-
>
|
|
120
|
-
<img
|
|
121
|
-
src={image.src || undefined}
|
|
122
|
-
alt={image.alt}
|
|
123
|
-
className={cn(
|
|
124
|
-
'size-full cursor-pointer rounded-md object-cover ring-offset-2 transition-all',
|
|
125
|
-
selectedImage === index ? 'ring-foreground ring-2' : '',
|
|
126
|
-
)}
|
|
127
|
-
/>
|
|
128
|
-
</button>
|
|
129
|
-
))}
|
|
130
|
-
</div>
|
|
131
|
-
</div>
|
|
132
|
-
|
|
133
|
-
{/* Product Details */}
|
|
134
|
-
<div className='flex flex-col gap-6'>
|
|
135
|
-
{/* Product Header */}
|
|
136
|
-
<header className='flex flex-col gap-4'>
|
|
137
|
-
<div className='flex items-center'>
|
|
138
|
-
<Button variant='ghost' size='icon' className='bg-primary size-6 scale-75 cursor-pointer rounded-full'>
|
|
139
|
-
<Check className='text-primary-foreground size-4' />
|
|
140
|
-
</Button>
|
|
141
|
-
<span className='ms-2 font-medium'>{product.brand}</span>
|
|
142
|
-
<span className='text-muted-foreground ms-auto'>{product.sku}</span>
|
|
143
|
-
</div>
|
|
144
|
-
<h1 className='text-xl font-bold text-balance xl:text-3xl'>{product.name}</h1>
|
|
145
|
-
|
|
146
|
-
{/* Ratings */}
|
|
147
|
-
<div className='flex items-center gap-2'>
|
|
148
|
-
<div className='flex'>
|
|
149
|
-
{[...Array(5)].map((_, i) => (
|
|
150
|
-
<Star
|
|
151
|
-
key={i}
|
|
152
|
-
className={cn(
|
|
153
|
-
'size-5',
|
|
154
|
-
i < product.rating ? 'fill-foreground text-foreground' : 'text-muted-foreground',
|
|
155
|
-
)}
|
|
156
|
-
/>
|
|
157
|
-
))}
|
|
158
|
-
</div>
|
|
159
|
-
<span className='text-muted-foreground text-sm'>
|
|
160
|
-
{product.reviews} {reviewsLabel}
|
|
161
|
-
</span>
|
|
162
|
-
</div>
|
|
163
|
-
</header>
|
|
164
|
-
|
|
165
|
-
{/* Price */}
|
|
166
|
-
<p className='text-4xl font-bold'>
|
|
167
|
-
{product.currency}
|
|
168
|
-
{product.price.toFixed(2)}
|
|
169
|
-
</p>
|
|
170
|
-
{/* Color Selection */}
|
|
171
|
-
<div>
|
|
172
|
-
<div className='mb-4 flex items-center text-sm'>
|
|
173
|
-
<span className='font-medium'>{colorLabel}:</span>
|
|
174
|
-
<span className='text-muted-foreground ms-1'>{selectedColor.name}</span>
|
|
175
|
-
</div>
|
|
176
|
-
<div className='flex gap-3'>
|
|
177
|
-
{product.colors.map(color => (
|
|
178
|
-
<Button
|
|
179
|
-
key={color.name}
|
|
180
|
-
onClick={() => setSelectedColor(color)}
|
|
181
|
-
className={cn(
|
|
182
|
-
'h-9 px-4 py-2',
|
|
183
|
-
'ring-offset-background size-8 cursor-pointer rounded-sm ring-offset-2 transition-all',
|
|
184
|
-
selectedColor.name === color.name && 'ring-foreground ring-2',
|
|
185
|
-
selectedColor.name !== color.name &&
|
|
186
|
-
['Black', 'White'].includes(color.name) &&
|
|
187
|
-
'outline-muted outline-solid',
|
|
188
|
-
)}
|
|
189
|
-
style={{ backgroundColor: color.value }}
|
|
190
|
-
aria-label={`Seleccionar color ${color.name}`}
|
|
191
|
-
aria-selected={selectedColor.name === color.name}
|
|
192
|
-
/>
|
|
193
|
-
))}
|
|
194
|
-
</div>
|
|
195
|
-
</div>
|
|
196
|
-
|
|
197
|
-
{/* Size Selection */}
|
|
198
|
-
<div>
|
|
199
|
-
<div className='mb-4 flex items-center justify-between'>
|
|
200
|
-
<div className='flex items-center'>
|
|
201
|
-
<span className='me-4 text-sm font-medium'>{sizesLabel}</span>
|
|
202
|
-
<div className='border-input flex rounded-sm border p-1'>
|
|
203
|
-
{['eu', 'uk', 'us'].map(type => (
|
|
204
|
-
<Button
|
|
205
|
-
key={type}
|
|
206
|
-
variant='ghost'
|
|
207
|
-
size='sm'
|
|
208
|
-
onClick={() => setSizeType(type as 'eu' | 'uk' | 'us')}
|
|
209
|
-
className={cn(
|
|
210
|
-
'cursor-pointer rounded-sm px-2 text-xs',
|
|
211
|
-
sizeType === type && 'bg-secondary text-secondary-foreground',
|
|
212
|
-
)}
|
|
213
|
-
aria-label={`Seleccionar talla ${type.toUpperCase()}`}
|
|
214
|
-
aria-selected={sizeType === type}
|
|
215
|
-
>
|
|
216
|
-
{type.toUpperCase()}
|
|
217
|
-
</Button>
|
|
218
|
-
))}
|
|
219
|
-
</div>
|
|
220
|
-
</div>
|
|
221
|
-
<Button variant='ghost' className='h-9 px-4 py-2 text-primary cursor-pointer text-sm hover:underline'>
|
|
222
|
-
{sizeGuideLabel}
|
|
223
|
-
</Button>
|
|
224
|
-
</div>
|
|
225
|
-
<div className='grid grid-cols-4 gap-2 xl:grid-cols-5'>
|
|
226
|
-
{product.sizes.map(size => (
|
|
227
|
-
<Button
|
|
228
|
-
key={size.eu}
|
|
229
|
-
variant={selectedSize === size.eu ? 'secondary' : 'outline'}
|
|
230
|
-
onClick={() => setSelectedSize(size.eu)}
|
|
231
|
-
className='h-12 cursor-pointer rounded-full shadow-none'
|
|
232
|
-
>
|
|
233
|
-
{sizeType === 'eu' ? size.eu : sizeType === 'uk' ? size.uk : size.us}
|
|
234
|
-
</Button>
|
|
235
|
-
))}
|
|
236
|
-
</div>
|
|
237
|
-
</div>
|
|
238
|
-
{/* Add to Cart */}
|
|
239
|
-
<div>
|
|
240
|
-
<div className='mb-4 flex gap-4'>
|
|
241
|
-
<Button
|
|
242
|
-
className='h-12 flex-1 cursor-pointer rounded-md'
|
|
243
|
-
size='lg'
|
|
244
|
-
onClick={onAddToCart ? () => onAddToCart(product) : undefined}
|
|
245
|
-
>
|
|
246
|
-
<ShoppingBag />
|
|
247
|
-
{addToCartLabel}
|
|
248
|
-
</Button>
|
|
249
|
-
<Button
|
|
250
|
-
variant='outline'
|
|
251
|
-
size='icon'
|
|
252
|
-
className='border-input size-12 cursor-pointer rounded-md transition-colors'
|
|
253
|
-
onClick={() => {
|
|
254
|
-
const next = !isWishlisted
|
|
255
|
-
setIsWishlisted(next)
|
|
256
|
-
onToggleWishlist?.(product, next)
|
|
257
|
-
}}
|
|
258
|
-
aria-label={isWishlisted ? removeFromWishlistLabel : addToWishlistLabel}
|
|
259
|
-
>
|
|
260
|
-
<ReactionToggle active={isWishlisted} icon={Heart} iconClassName='size-5' activeColorClassName='text-primary' />
|
|
261
|
-
</Button>
|
|
262
|
-
</div>
|
|
263
|
-
<div className='text-muted-foreground flex items-center justify-center gap-2 text-sm'>
|
|
264
|
-
<Truck className='size-4' />
|
|
265
|
-
<span>{freeDeliveryLabel}</span>
|
|
266
|
-
</div>
|
|
267
|
-
</div>
|
|
268
|
-
</div>
|
|
269
|
-
</div>
|
|
270
|
-
</section>
|
|
271
|
-
)
|
|
272
|
-
}
|