@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.
Files changed (99) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +73 -29
  3. package/docs/standards/data-contracts.md +13 -2
  4. package/package.json +26 -10
  5. package/scripts/fix-esm-extensions.mjs +40 -0
  6. package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
  7. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  8. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  9. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  10. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  11. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  12. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  13. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  14. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  15. package/src/blocks/e-commerce/product-list/product-card.tsx +424 -0
  16. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  17. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  18. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  19. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  20. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  21. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  22. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  23. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  24. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  25. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  26. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +119 -85
  27. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  28. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  29. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  30. package/src/blocks/marketing/features/feature.tsx +195 -0
  31. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +27 -9
  32. package/src/blocks/marketing/navbars/navbar-4.test.tsx +33 -2
  33. package/src/blocks/marketing/navbars/navbar-4.tsx +29 -11
  34. package/src/index.ts +77 -48
  35. package/src/lib/contracts-curated.test.ts +42 -24
  36. package/src/lib/contracts.test.ts +143 -2
  37. package/src/lib/contracts.ts +346 -14
  38. package/src/ui/button-group.tsx +1 -1
  39. package/src/ui/carousel.tsx +1 -1
  40. package/src/ui/dialog.tsx +1 -1
  41. package/src/ui/field.tsx +2 -2
  42. package/src/ui/input-group.tsx +3 -3
  43. package/src/ui/pagination.tsx +1 -1
  44. package/src/ui/sidebar.tsx +7 -7
  45. package/src/ui/toggle-group.tsx +1 -1
  46. package/tsconfig.build.json +12 -0
  47. package/tsup.config.ts +11 -7
  48. package/.turbo/turbo-build.log +0 -24
  49. package/.turbo/turbo-test.log +0 -47
  50. package/.turbo/turbo-type-check.log +0 -0
  51. package/dist/.tsbuildinfo +0 -1
  52. package/dist/contracts.d.mts +0 -1732
  53. package/dist/contracts.d.ts +0 -1732
  54. package/dist/contracts.js +0 -423
  55. package/dist/contracts.js.map +0 -1
  56. package/dist/contracts.mjs +0 -365
  57. package/dist/contracts.mjs.map +0 -1
  58. package/dist/index.d.mts +0 -3068
  59. package/dist/index.d.ts +0 -3068
  60. package/dist/index.js +0 -17834
  61. package/dist/index.js.map +0 -1
  62. package/dist/index.mjs +0 -17740
  63. package/dist/index.mjs.map +0 -1
  64. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  65. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  66. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  67. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  68. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  69. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  70. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +0 -88
  71. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -192
  72. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  73. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  74. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  75. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  76. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  77. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  78. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  79. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  80. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  81. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  82. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  83. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  84. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  85. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  86. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  87. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  88. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  89. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  90. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  91. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  92. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -229
  93. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -72
  94. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  95. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  96. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  97. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  98. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  99. 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
- }