@thorprovider/blocks 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/.turbo/turbo-build.log +24 -0
  2. package/.turbo/turbo-test.log +10 -0
  3. package/.turbo/turbo-type-check.log +0 -0
  4. package/AGENTS.md +1 -0
  5. package/README.md +161 -0
  6. package/components.json +33 -0
  7. package/dist/.tsbuildinfo +1 -0
  8. package/dist/contracts.d.mts +563 -0
  9. package/dist/contracts.d.ts +563 -0
  10. package/dist/contracts.js +207 -0
  11. package/dist/contracts.js.map +1 -0
  12. package/dist/contracts.mjs +168 -0
  13. package/dist/contracts.mjs.map +1 -0
  14. package/dist/index.d.mts +2408 -0
  15. package/dist/index.d.ts +2408 -0
  16. package/dist/index.js +16422 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/index.mjs +16352 -0
  19. package/dist/index.mjs.map +1 -0
  20. package/docs/standards/data-contracts.md +70 -0
  21. package/docs/standards/theming.md +85 -0
  22. package/package.json +62 -0
  23. package/src/blocks/application/app-shells/app-shell-1-sidebar.tsx +198 -0
  24. package/src/blocks/application/app-shells/app-shell-1.tsx +196 -0
  25. package/src/blocks/application/login-forms/login-page-1.tsx +246 -0
  26. package/src/blocks/application/login-forms/login-page-2.tsx +207 -0
  27. package/src/blocks/application/password-recovery/forgot-password-page-1-brand-mark.tsx +38 -0
  28. package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +320 -0
  29. package/src/blocks/application/signup-forms/signup-page-1.tsx +231 -0
  30. package/src/blocks/application/verification/verify-1.tsx +307 -0
  31. package/src/blocks/application/verification/verify-confetti.tsx +192 -0
  32. package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +260 -0
  33. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +179 -0
  34. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +341 -0
  35. package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +57 -0
  36. package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +124 -0
  37. package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +170 -0
  38. package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +52 -0
  39. package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +57 -0
  40. package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +68 -0
  41. package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +298 -0
  42. package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +47 -0
  43. package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +9 -0
  44. package/src/blocks/e-commerce/checkout-forms/checkbox-check.tsx +74 -0
  45. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +189 -0
  46. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +283 -0
  47. package/src/blocks/e-commerce/checkout-forms/checkout-field-feedback.tsx +29 -0
  48. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +96 -0
  49. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-country-field.tsx +51 -0
  50. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +139 -0
  51. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +184 -0
  52. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +36 -0
  53. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +107 -0
  54. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +235 -0
  55. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +130 -0
  56. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +175 -0
  57. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +190 -0
  58. package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +312 -0
  59. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +91 -0
  60. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +110 -0
  61. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +137 -0
  62. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +68 -0
  63. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +79 -0
  64. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +172 -0
  65. package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +392 -0
  66. package/src/blocks/e-commerce/checkout-forms/checkout-form-types.ts +7 -0
  67. package/src/blocks/e-commerce/navigation/category-icon.test.tsx +54 -0
  68. package/src/blocks/e-commerce/navigation/category-icon.tsx +94 -0
  69. package/src/blocks/e-commerce/navigation/mega-menu.test.tsx +139 -0
  70. package/src/blocks/e-commerce/navigation/mega-menu.tsx +271 -0
  71. package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +152 -0
  72. package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +276 -0
  73. package/src/blocks/e-commerce/order-history/order-history-1.tsx +159 -0
  74. package/src/blocks/e-commerce/order-history/order-history-2.tsx +222 -0
  75. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +86 -0
  76. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +126 -0
  77. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +100 -0
  78. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +129 -0
  79. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +218 -0
  80. package/src/blocks/e-commerce/product-list/product-card-1.tsx +131 -0
  81. package/src/blocks/e-commerce/product-list/product-card-2.tsx +71 -0
  82. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +166 -0
  83. package/src/blocks/e-commerce/product-list/product-card-3.tsx +215 -0
  84. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +98 -0
  85. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +171 -0
  86. package/src/blocks/e-commerce/product-list/product-card-5.tsx +367 -0
  87. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +201 -0
  88. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +272 -0
  89. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +59 -0
  90. package/src/blocks/e-commerce/product-overview/product-overview-5-lightbox-tab.tsx +104 -0
  91. package/src/blocks/e-commerce/product-overview/product-overview-5-reviews-tab.tsx +82 -0
  92. package/src/blocks/e-commerce/product-overview/product-overview-5-tabs.tsx +69 -0
  93. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +274 -0
  94. package/src/blocks/e-commerce/product-overview/product-quick-view-card.tsx +135 -0
  95. package/src/blocks/e-commerce/product-overview/product-quick-view-dialog.tsx +393 -0
  96. package/src/blocks/e-commerce/product-overview/product-quick-view.tsx +136 -0
  97. package/src/blocks/e-commerce/reviews/review-rating-1-review-form.tsx +133 -0
  98. package/src/blocks/e-commerce/reviews/review-rating-1.tsx +178 -0
  99. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +246 -0
  100. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +142 -0
  101. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +291 -0
  102. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +110 -0
  103. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +51 -0
  104. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +146 -0
  105. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +295 -0
  106. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +142 -0
  107. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +342 -0
  108. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +146 -0
  109. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +391 -0
  110. package/src/blocks/e-commerce/store-containers/store-container-1.tsx +29 -0
  111. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +244 -0
  112. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +283 -0
  113. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +318 -0
  114. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +205 -0
  115. package/src/blocks/marketing/banners/avatar-group.tsx +69 -0
  116. package/src/blocks/marketing/banners/banner-1.tsx +188 -0
  117. package/src/blocks/marketing/cta/cta-section-1.tsx +112 -0
  118. package/src/blocks/marketing/faq/faq-block-1.tsx +45 -0
  119. package/src/blocks/marketing/features/feature-1.tsx +97 -0
  120. package/src/blocks/marketing/features/feature-2.tsx +82 -0
  121. package/src/blocks/marketing/footers/footer-section-1.tsx +141 -0
  122. package/src/blocks/marketing/footers/footer-section-3.tsx +192 -0
  123. package/src/blocks/marketing/footers/footer-section-4.tsx +355 -0
  124. package/src/blocks/marketing/hero-sections/avatar-group.tsx +86 -0
  125. package/src/blocks/marketing/hero-sections/hero-section-1.tsx +135 -0
  126. package/src/blocks/marketing/logo-clouds/logo-cloud-1-logo-icon.tsx +52 -0
  127. package/src/blocks/marketing/logo-clouds/logo-cloud-1.tsx +56 -0
  128. package/src/blocks/marketing/navbars/navbar-1.tsx +267 -0
  129. package/src/blocks/marketing/navbars/navbar-2.tsx +351 -0
  130. package/src/blocks/marketing/navbars/navbar-3.tsx +303 -0
  131. package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +118 -0
  132. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +248 -0
  133. package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +177 -0
  134. package/src/blocks/marketing/navbars/navbar-4-mobile-nav.tsx +104 -0
  135. package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +219 -0
  136. package/src/blocks/marketing/navbars/navbar-4-search-bar.tsx +52 -0
  137. package/src/blocks/marketing/navbars/navbar-4.tsx +411 -0
  138. package/src/blocks/marketing/pricing/pricing-section-1.tsx +94 -0
  139. package/src/blocks/marketing/statistics/statistic-1.tsx +93 -0
  140. package/src/blocks/marketing/testimonials/testimonial-1.tsx +141 -0
  141. package/src/blocks/marketing/testimonials/testimonial-2.tsx +108 -0
  142. package/src/blocks/shared/reaction-toggle.tsx +145 -0
  143. package/src/hooks/use-mobile.ts +19 -0
  144. package/src/index.ts +200 -0
  145. package/src/lib/contracts.test.ts +32 -0
  146. package/src/lib/contracts.ts +235 -0
  147. package/src/lib/types/menu.ts +43 -0
  148. package/src/lib/types.ts +4 -0
  149. package/src/lib/utils.ts +10 -0
  150. package/src/ui/accordion.tsx +65 -0
  151. package/src/ui/avatar.tsx +108 -0
  152. package/src/ui/badge.tsx +47 -0
  153. package/src/ui/breadcrumb.tsx +108 -0
  154. package/src/ui/button-group.tsx +83 -0
  155. package/src/ui/button.tsx +63 -0
  156. package/src/ui/card.tsx +91 -0
  157. package/src/ui/carousel.tsx +241 -0
  158. package/src/ui/dialog.tsx +158 -0
  159. package/src/ui/drawer.tsx +134 -0
  160. package/src/ui/dropdown-menu.tsx +256 -0
  161. package/src/ui/empty.tsx +103 -0
  162. package/src/ui/field.tsx +248 -0
  163. package/src/ui/input-group.tsx +170 -0
  164. package/src/ui/input-otp.tsx +76 -0
  165. package/src/ui/input.tsx +20 -0
  166. package/src/ui/label.tsx +23 -0
  167. package/src/ui/navigation-menu.tsx +167 -0
  168. package/src/ui/pagination.tsx +127 -0
  169. package/src/ui/progress.tsx +30 -0
  170. package/src/ui/radio-group.tsx +44 -0
  171. package/src/ui/select.tsx +189 -0
  172. package/src/ui/separator.tsx +27 -0
  173. package/src/ui/sheet.tsx +142 -0
  174. package/src/ui/sidebar.tsx +726 -0
  175. package/src/ui/skeleton.tsx +13 -0
  176. package/src/ui/table.tsx +115 -0
  177. package/src/ui/tabs.tsx +90 -0
  178. package/src/ui/textarea.tsx +17 -0
  179. package/src/ui/toggle-group.tsx +83 -0
  180. package/src/ui/toggle.tsx +46 -0
  181. package/src/ui/tooltip.tsx +56 -0
  182. package/tsconfig.json +20 -0
  183. package/tsup.config.ts +16 -0
  184. package/vitest.config.ts +9 -0
@@ -0,0 +1,272 @@
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
+ }
@@ -0,0 +1,59 @@
1
+ import { Camera } from 'lucide-react'
2
+ import { Badge } from '../../../ui/badge'
3
+ import { cn } from '../../../lib/utils'
4
+
5
+ import type { ProductOverview5Product } from './product-overview-5'
6
+
7
+ export interface ProductOverview5GalleryProps {
8
+ product: ProductOverview5Product
9
+ badgeLabel?: string
10
+ selectedImage: number
11
+ onSelectImage: (index: number) => void
12
+ photosLabel?: string
13
+ }
14
+
15
+ export function ProductOverview5Gallery({
16
+ product,
17
+ badgeLabel,
18
+ selectedImage,
19
+ onSelectImage,
20
+ photosLabel = 'fotos',
21
+ }: ProductOverview5GalleryProps) {
22
+ return (
23
+ <div>
24
+ <div className='bg-muted relative aspect-square overflow-hidden rounded-xl'>
25
+ <img src={product.mainImages[selectedImage] || undefined} alt={product.name} className='size-full object-cover' />
26
+ {badgeLabel && (
27
+ <Badge className='px-2.5 py-0.5 font-semibold absolute top-4 left-4'>
28
+ {badgeLabel}
29
+ </Badge>
30
+ )}
31
+ <div className='absolute right-4 bottom-4 rounded-full bg-black/60 px-3 py-1.5 text-sm text-white backdrop-blur-sm'>
32
+ <Camera className='mr-1 inline size-4' />
33
+ {product.mainImages.length} {photosLabel}
34
+ </div>
35
+ </div>
36
+
37
+ {product.mainImages.length > 1 && (
38
+ <div className='mt-4 flex gap-3 overflow-x-auto pb-2'>
39
+ {product.mainImages.map((image, index) => (
40
+ <button
41
+ type='button'
42
+ key={index}
43
+ onClick={() => onSelectImage(index)}
44
+ className={cn(
45
+ 'relative aspect-square size-20 flex-shrink-0 cursor-pointer overflow-hidden rounded-lg border-2 transition-all',
46
+ selectedImage === index ? 'border-primary ring-primary/20 shadow-md ring-2' : 'border-muted hover:border-muted-foreground/50',
47
+ )}
48
+ aria-label={`Mostrar imagen ${index + 1} de ${product.name}`}
49
+ >
50
+ <img src={image || undefined} alt={`${product.name} ${index + 1}`} className='size-full object-cover' />
51
+ </button>
52
+ ))}
53
+ </div>
54
+ )}
55
+ </div>
56
+ )
57
+ }
58
+
59
+ export default ProductOverview5Gallery
@@ -0,0 +1,104 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { ChevronLeft, ChevronRight, X } from 'lucide-react'
5
+ import { Button } from '../../../ui/button'
6
+ import { Card, CardContent } from '../../../ui/card'
7
+ import { Dialog, DialogClose, DialogContent, DialogTitle, DialogTrigger } from '../../../ui/dialog'
8
+ import { cn } from '../../../lib/utils'
9
+
10
+ import type { ProductOverview5Product } from './product-overview-5'
11
+
12
+ export interface ProductOverview5LightboxTabProps {
13
+ product: ProductOverview5Product
14
+ galleryLabel?: string
15
+ ofCounterLabel?: string
16
+ }
17
+
18
+ export function ProductOverview5LightboxTab({
19
+ product,
20
+ galleryLabel = 'Galería',
21
+ ofCounterLabel = '{current} de {total}',
22
+ }: ProductOverview5LightboxTabProps) {
23
+ const [modalImageIndex, setModalImageIndex] = useState(0)
24
+
25
+ const nextImage = () => setModalImageIndex(prev => (prev + 1) % product.galleryImages.length)
26
+ const prevImage = () => setModalImageIndex(prev => (prev - 1 + product.galleryImages.length) % product.galleryImages.length)
27
+
28
+ return (
29
+ <Card className='border shadow-none'>
30
+ <CardContent className='p-8 py-0'>
31
+ <h3 className='mb-6 text-xl font-semibold text-balance'>{galleryLabel}</h3>
32
+ <div className='grid gap-4 sm:grid-cols-2 lg:grid-cols-3'>
33
+ {product.galleryImages.map((image, index) => (
34
+ <Dialog key={index}>
35
+ <DialogTrigger asChild>
36
+ <button
37
+ type='button'
38
+ className='block aspect-square cursor-pointer overflow-hidden rounded-xl'
39
+ onClick={() => setModalImageIndex(index)}
40
+ >
41
+ <img src={image || undefined} alt={`${product.name} ${index + 1}`} className='size-full object-cover' />
42
+ </button>
43
+ </DialogTrigger>
44
+ <DialogContent className='max-w-4xl border-0 bg-transparent p-0' showCloseButton={false}>
45
+ <DialogTitle className='sr-only'>Imagen de galería {modalImageIndex + 1}</DialogTitle>
46
+ <div className='relative'>
47
+ <img
48
+ src={product.galleryImages[modalImageIndex] || undefined}
49
+ alt={`${product.name} ${modalImageIndex + 1}`}
50
+ className='max-h-[80vh] w-full object-contain'
51
+ />
52
+ <DialogClose className='absolute top-4 right-4 rounded-full bg-black/20 p-2 text-white transition-colors hover:bg-black/30'>
53
+ <X className='size-4' />
54
+ </DialogClose>
55
+
56
+ {product.galleryImages.length > 1 && (
57
+ <>
58
+ <Button
59
+ variant='ghost'
60
+ size='icon'
61
+ className='size-9 absolute top-1/2 left-4 -translate-y-1/2 cursor-pointer rounded-full bg-black/20 text-white hover:bg-black/30'
62
+ onClick={prevImage}
63
+ >
64
+ <ChevronLeft className='size-4' />
65
+ </Button>
66
+ <Button
67
+ variant='ghost'
68
+ size='icon'
69
+ className='size-9 absolute top-1/2 right-4 -translate-y-1/2 cursor-pointer rounded-full bg-black/20 text-white hover:bg-black/30'
70
+ onClick={nextImage}
71
+ >
72
+ <ChevronRight className='size-4' />
73
+ </Button>
74
+ </>
75
+ )}
76
+
77
+ <div className='absolute bottom-4 left-1/2 flex -translate-x-1/2 gap-2'>
78
+ {product.galleryImages.map((_, idx) => (
79
+ <button
80
+ type='button'
81
+ key={idx}
82
+ aria-label={`Mostrar imagen ${idx + 1}`}
83
+ className={cn('size-2 rounded-full transition-colors', idx === modalImageIndex ? 'bg-white' : 'bg-white/50')}
84
+ onClick={() => setModalImageIndex(idx)}
85
+ />
86
+ ))}
87
+ </div>
88
+
89
+ <div className='absolute right-4 bottom-4 rounded-full bg-black/20 px-3 py-1 text-sm text-white'>
90
+ {ofCounterLabel
91
+ .replace('{current}', String(modalImageIndex + 1))
92
+ .replace('{total}', String(product.galleryImages.length))}
93
+ </div>
94
+ </div>
95
+ </DialogContent>
96
+ </Dialog>
97
+ ))}
98
+ </div>
99
+ </CardContent>
100
+ </Card>
101
+ )
102
+ }
103
+
104
+ export default ProductOverview5LightboxTab
@@ -0,0 +1,82 @@
1
+ import { Star } from 'lucide-react'
2
+ import { Card, CardContent } from '../../../ui/card'
3
+ import { Progress } from '../../../ui/progress'
4
+
5
+ import type { ProductOverview5Product } from './product-overview-5'
6
+
7
+ export interface ProductOverview5ReviewsTabProps {
8
+ product: ProductOverview5Product
9
+ basedOnReviewsLabel?: string
10
+ foundHelpfulLabel?: string
11
+ }
12
+
13
+ function renderStars(rating: number) {
14
+ return Array.from({ length: 5 }, (_, i) => (
15
+ <Star key={i} className={`size-4 ${i < Math.floor(rating) ? 'fill-foreground text-foreground' : i < rating ? 'text-foreground' : ''}`} />
16
+ ))
17
+ }
18
+
19
+ function formatDate(dateString: string) {
20
+ return new Date(dateString).toLocaleDateString('es-ES', { year: 'numeric', month: 'short', day: 'numeric' })
21
+ }
22
+
23
+ export function ProductOverview5ReviewsTab({
24
+ product,
25
+ basedOnReviewsLabel = 'Basado en {count} reseñas',
26
+ foundHelpfulLabel = '{count} personas encontraron esto útil',
27
+ }: ProductOverview5ReviewsTabProps) {
28
+ return (
29
+ <div className='flex flex-col gap-6'>
30
+ <Card className='border shadow-none'>
31
+ <CardContent className='p-8 py-0'>
32
+ <div className='flex flex-col items-start gap-8 md:flex-row md:items-center'>
33
+ <div className='text-center'>
34
+ <div className='text-4xl font-bold'>{product.rating}</div>
35
+ <div className='mt-2 flex justify-center'>{renderStars(product.rating)}</div>
36
+ <div className='text-muted-foreground mt-2 text-sm'>
37
+ {basedOnReviewsLabel.replace('{count}', String(product.userReviews.length))}
38
+ </div>
39
+ </div>
40
+ <div className='flex-1'>
41
+ {[5, 4, 3, 2, 1].map(stars => {
42
+ const count = product.userReviews.filter(review => Math.floor(review.rating) === stars).length
43
+ const percentage = (count / product.userReviews.length) * 100
44
+ return (
45
+ <div key={stars} className='mb-3 flex items-center gap-3'>
46
+ <span className='w-8 text-sm font-medium'>{stars}★</span>
47
+ <Progress value={percentage} className='h-2 flex-1 rounded-full' />
48
+ <span className='text-muted-foreground w-8 text-sm'>{count}</span>
49
+ </div>
50
+ )
51
+ })}
52
+ </div>
53
+ </div>
54
+ </CardContent>
55
+ </Card>
56
+
57
+ <div className='flex flex-col gap-4'>
58
+ {product.userReviews.map(review => (
59
+ <Card key={review.id} className='border shadow-none'>
60
+ <CardContent className='p-6 py-0'>
61
+ <div className='mb-4 flex items-start justify-between'>
62
+ <div>
63
+ <div className='text-sm font-medium'>{review.author}</div>
64
+ <div className='mt-2 flex items-center gap-2'>
65
+ <div className='flex'>{renderStars(review.rating)}</div>
66
+ <span className='text-muted-foreground text-sm'>{formatDate(review.date)}</span>
67
+ </div>
68
+ </div>
69
+ </div>
70
+ <p className='text-muted-foreground mb-3 text-sm leading-relaxed'>{review.comment}</p>
71
+ <div className='text-muted-foreground text-xs'>
72
+ {foundHelpfulLabel.replace('{count}', String(review.helpful))}
73
+ </div>
74
+ </CardContent>
75
+ </Card>
76
+ ))}
77
+ </div>
78
+ </div>
79
+ )
80
+ }
81
+
82
+ export default ProductOverview5ReviewsTab
@@ -0,0 +1,69 @@
1
+ import { Tabs as TabsPrimitive } from '@base-ui/react/tabs'
2
+
3
+ import { cn } from '../../../lib/utils'
4
+
5
+ /**
6
+ * The full tab set this block renders on.
7
+ *
8
+ * The block's classes target Base UI state attributes (`data-active:bg-transparent` on the
9
+ * triggers), the indicator reads Base UI positioning CSS variables (`--active-tab-*`), and the
10
+ * content fade relies on Base UI unmounting the inactive panel - so the whole tabs area runs on
11
+ * Base UI rather than the Radix-backed shadcn `tabs`. It lives here rather than in the shared ui
12
+ * `tabs` so the block installs standalone: the shadcn `tabs` component you get from the registry
13
+ * has no indicator or fading panel.
14
+ */
15
+ export function ProductOverview5Tabs(props: TabsPrimitive.Root.Props) {
16
+ return <TabsPrimitive.Root data-slot='tabs' {...props} />
17
+ }
18
+
19
+ export function ProductOverview5TabsList({ className, ...props }: TabsPrimitive.List.Props) {
20
+ return <TabsPrimitive.List data-slot='tabs-list' className={className} {...props} />
21
+ }
22
+
23
+ /**
24
+ * Base UI's tab button carries no layout of its own, so the inline-flex centring that the
25
+ * Radix-backed shadcn trigger used to supply is re-stated here.
26
+ */
27
+ export function ProductOverview5TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
28
+ return (
29
+ <TabsPrimitive.Tab
30
+ data-slot='tabs-trigger'
31
+ className={cn('relative inline-flex items-center justify-center whitespace-nowrap', className)}
32
+ {...props}
33
+ />
34
+ )
35
+ }
36
+
37
+ /**
38
+ * Panel with a fade-in on every switch. Base UI unmounts the inactive panel by default, so each
39
+ * activation is a fresh mount - `animate-in` plays automatically without needing a transition-state
40
+ * data attribute to gate it.
41
+ */
42
+ export function ProductOverview5TabsContentFade({ className, ...props }: TabsPrimitive.Panel.Props) {
43
+ return (
44
+ <TabsPrimitive.Panel
45
+ className={cn('animate-in fade-in-0 duration-200 motion-reduce:animate-none', className)}
46
+ {...props}
47
+ />
48
+ )
49
+ }
50
+
51
+ /**
52
+ * Sliding pill that tracks the active trigger. Render as the first child of TabsList, and pair
53
+ * with `data-active:bg-transparent` on the triggers so their own active background doesn't double
54
+ * up with the pill.
55
+ */
56
+ export function ProductOverview5TabsIndicator({ className, ...props }: TabsPrimitive.Indicator.Props) {
57
+ return (
58
+ <TabsPrimitive.Indicator
59
+ data-slot='tabs-indicator'
60
+ className={cn(
61
+ 'absolute z-0 rounded-md bg-background shadow-sm transition-[left,top,width,height] duration-[250ms] ease-[cubic-bezier(0.22,1,0.36,1)] dark:bg-input/30',
62
+ 'left-(--active-tab-left) top-(--active-tab-top) h-(--active-tab-height) w-(--active-tab-width)',
63
+ 'motion-reduce:transition-none',
64
+ className
65
+ )}
66
+ {...props}
67
+ />
68
+ )
69
+ }