@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,367 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { Card, CardContent, CardHeader, CardTitle } from '../../../ui/card'
5
+ import { Badge } from '../../../ui/badge'
6
+ import { Button } from '../../../ui/button'
7
+ import { CheckboxCheck } from './product-card-5-checkbox-check'
8
+ import { InputGroup, InputGroupAddon, InputGroupInput } from '../../../ui/input-group'
9
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../../ui/table'
10
+ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '../../../ui/select'
11
+ import { Heart, ShoppingCart, Star, Search, Filter, Eye, ArrowUpDown, GitCompare } from 'lucide-react'
12
+ import { cn } from '../../../lib/utils'
13
+ import { ProductCard5ReactionToggle } from './product-card-5-reaction-toggle'
14
+
15
+ export interface ProductCard5Product {
16
+ id: string
17
+ name: string
18
+ category: string
19
+ price: number
20
+ rating: number
21
+ reviews: number
22
+ image: string
23
+ stock: string
24
+ specifications: string
25
+ brand: string
26
+ }
27
+
28
+ export interface ProductCard5Props {
29
+ products: ProductCard5Product[]
30
+ onToggleWishlist?: (product: ProductCard5Product) => void
31
+ onAddToCart?: (product: ProductCard5Product) => void
32
+ onQuickView?: (product: ProductCard5Product) => void
33
+ onCompare?: (products: ProductCard5Product[]) => void
34
+ onExport?: (products: ProductCard5Product[]) => void
35
+ }
36
+
37
+ /**
38
+ * Stock state as a Badge variant rather than a hand-mixed palette.
39
+ *
40
+ * These used to be literal `bg-green-100 … dark:bg-green-900/20` triples, which is the one thing
41
+ * a themed design system cannot absorb: they ignored the customizer entirely and needed a manual
42
+ * `dark:` for every value. `secondary` and `destructive` are the tokens that already mean
43
+ * "fine" and "not fine", and the label carries the meaning in words either way.
44
+ */
45
+ const stockVariants: Record<string, 'secondary' | 'outline' | 'destructive'> = {
46
+ 'In Stock': 'secondary',
47
+ 'Low Stock': 'outline',
48
+ 'Out of Stock': 'destructive',
49
+ }
50
+
51
+ export function ProductCard5({
52
+ products,
53
+ onToggleWishlist,
54
+ onAddToCart,
55
+ onQuickView,
56
+ onCompare,
57
+ onExport,
58
+ }: ProductCard5Props) {
59
+ const [searchQuery, setSearchQuery] = useState('')
60
+ const [categoryFilter, setCategoryFilter] = useState('all')
61
+ const [selectedProducts, setSelectedProducts] = useState<string[]>([])
62
+ const [sortBy, setSortBy] = useState<'name' | 'price' | 'rating'>('name')
63
+ const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc')
64
+ const [wishlisted, setWishlisted] = useState<string[]>([])
65
+
66
+ const filteredProducts = products
67
+ .filter(product => {
68
+ const matchesSearch =
69
+ product.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
70
+ product.brand.toLowerCase().includes(searchQuery.toLowerCase())
71
+ const matchesCategory = categoryFilter === 'all' || product.category === categoryFilter
72
+ return matchesSearch && matchesCategory
73
+ })
74
+ .sort((a, b) => {
75
+ let aValue: string | number = a[sortBy]
76
+ let bValue: string | number = b[sortBy]
77
+
78
+ if (sortOrder === 'asc') {
79
+ return aValue < bValue ? -1 : aValue > bValue ? 1 : 0
80
+ } else {
81
+ return aValue > bValue ? -1 : aValue < bValue ? 1 : 0
82
+ }
83
+ })
84
+
85
+ const categories = [...new Set(products.map(p => p.category))]
86
+
87
+ const selectedProductObjects = products.filter(p => selectedProducts.includes(p.id))
88
+
89
+ const handleSort = (field: 'name' | 'price' | 'rating') => {
90
+ if (sortBy === field) {
91
+ setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
92
+ } else {
93
+ setSortBy(field)
94
+ setSortOrder('asc')
95
+ }
96
+ }
97
+
98
+ const toggleProductSelection = (productId: string) => {
99
+ setSelectedProducts(prev => (prev.includes(productId) ? prev.filter(id => id !== productId) : [...prev, productId]))
100
+ }
101
+
102
+ const toggleWishlist = (product: ProductCard5Product) => {
103
+ setWishlisted(prev => (prev.includes(product.id) ? prev.filter(id => id !== product.id) : [...prev, product.id]))
104
+ onToggleWishlist?.(product)
105
+ }
106
+
107
+ const renderStars = (rating: number) => {
108
+ return Array.from({ length: 5 }, (_, i) => (
109
+ <Star
110
+ key={i}
111
+ aria-hidden='true'
112
+ className={cn(
113
+ 'size-3',
114
+ i < Math.floor(rating) ? 'fill-foreground text-foreground' : i < rating ? 'text-foreground' : ''
115
+ )}
116
+ />
117
+ ))
118
+ }
119
+
120
+ return (
121
+ <section className='py-8'>
122
+ <div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
123
+ <Card>
124
+ <CardHeader>
125
+ <div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between'>
126
+ <div>
127
+ <CardTitle className='text-2xl text-balance'>Comparación de productos</CardTitle>
128
+ <p className='text-muted-foreground'>Compara productos lado a lado para tomar la mejor decisión</p>
129
+ </div>
130
+ {/*
131
+ No height, padding or cursor overrides on top of the variant: the default size
132
+ already is `h-9 px-4`, and re-stating it by class is how a button drifts out of
133
+ step with every other button the moment the design system moves.
134
+ */}
135
+ {selectedProducts.length > 0 && (
136
+ <Button onClick={onCompare ? () => onCompare(selectedProductObjects) : undefined}>
137
+ <GitCompare data-icon='inline-start' aria-hidden='true' />
138
+ Comparar {selectedProducts.length} productos
139
+ </Button>
140
+ )}
141
+ </div>
142
+ </CardHeader>
143
+ <CardContent>
144
+ {/* Filters */}
145
+ <div className='mb-6 flex flex-col gap-4 sm:flex-row sm:items-center'>
146
+ {/*
147
+ An InputGroup rather than a relative div with an absolutely positioned icon and
148
+ a hand-picked `ps-9` to clear it. The group owns that offset, so the icon and the
149
+ text cannot drift apart when the radius or the input's height changes.
150
+ */}
151
+ <InputGroup className='max-w-md flex-1'>
152
+ <InputGroupAddon>
153
+ <Search />
154
+ </InputGroupAddon>
155
+ <InputGroupInput
156
+ placeholder='Buscar productos o marcas...'
157
+ value={searchQuery}
158
+ onChange={e => setSearchQuery(e.target.value)}
159
+ aria-label='Buscar productos o marcas'
160
+ />
161
+ </InputGroup>
162
+ <Select value={categoryFilter} onValueChange={value => value && setCategoryFilter(value)}>
163
+ <SelectTrigger className='w-[180px]' aria-label='Filtrar por categoría'>
164
+ <Filter className='me-2 size-4' />
165
+ <SelectValue>{categoryFilter === 'all' ? 'Todas las categorías' : categoryFilter}</SelectValue>
166
+ </SelectTrigger>
167
+ <SelectContent>
168
+ {/* Items belong in a group — the list is one set, not loose children. */}
169
+ <SelectGroup>
170
+ <SelectItem value='all'>Todas las categorías</SelectItem>
171
+ {categories.map(category => (
172
+ <SelectItem key={category} value={category}>
173
+ {category}
174
+ </SelectItem>
175
+ ))}
176
+ </SelectGroup>
177
+ </SelectContent>
178
+ </Select>
179
+ </div>
180
+
181
+ {/* Products Table */}
182
+ <div className='overflow-x-auto rounded-md border'>
183
+ <Table className='min-w-full table-fixed'>
184
+ <TableHeader>
185
+ <TableRow>
186
+ <TableHead className='w-12 ps-4'>
187
+ <CheckboxCheck
188
+ checked={selectedProducts.length === filteredProducts.length && filteredProducts.length > 0}
189
+ onCheckedChange={checked => {
190
+ if (checked) {
191
+ setSelectedProducts(filteredProducts.map(p => p.id))
192
+ } else {
193
+ setSelectedProducts([])
194
+ }
195
+ }}
196
+ />
197
+ </TableHead>
198
+ <TableHead className='w-56 min-w-[200px]'>Producto</TableHead>
199
+ {/*
200
+ A Button inside the header, not an onClick on the cell.
201
+
202
+ A `<th>` with a click handler is invisible to the keyboard and announces
203
+ nothing: there was no way to sort this table without a mouse, and a screen
204
+ reader was never told the column was sortable or which way it currently
205
+ runs. `aria-sort` on the header states the direction, and the button is the
206
+ thing that takes focus.
207
+ */}
208
+ {([
209
+ { field: 'name', label: 'Detalles', width: 'w-52 min-w-[180px]' },
210
+ { field: 'price', label: 'Precio', width: 'w-32 min-w-[100px]' },
211
+ { field: 'rating', label: 'Valoración', width: 'w-32 min-w-[100px]' },
212
+ ] as const).map(column => (
213
+ <TableHead
214
+ key={column.field}
215
+ className={column.width}
216
+ aria-sort={
217
+ sortBy === column.field
218
+ ? sortOrder === 'asc'
219
+ ? 'ascending'
220
+ : 'descending'
221
+ : 'none'
222
+ }
223
+ >
224
+ <Button
225
+ variant='ghost'
226
+ size='sm'
227
+ className='-ms-2 h-8 gap-1 px-2'
228
+ onClick={() => handleSort(column.field)}
229
+ >
230
+ {column.label}
231
+ <ArrowUpDown data-icon='inline-end' aria-hidden='true' />
232
+ </Button>
233
+ </TableHead>
234
+ ))}
235
+ <TableHead className='w-32 min-w-[100px] text-center'>Stock</TableHead>
236
+ <TableHead className='w-34 min-w-[90px] pe-3 text-center'>Acciones</TableHead>
237
+ </TableRow>
238
+ </TableHeader>
239
+ <TableBody>
240
+ {filteredProducts.map(product => (
241
+ <TableRow key={product.id}>
242
+ <TableCell className='ps-4'>
243
+ <CheckboxCheck
244
+ checked={selectedProducts.includes(product.id)}
245
+ onCheckedChange={() => toggleProductSelection(product.id)}
246
+ />
247
+ </TableCell>
248
+ <TableCell className='w-56 min-w-[200px] break-words whitespace-normal'>
249
+ <div className='flex items-center gap-3'>
250
+ <img src={product.image || undefined} alt={product.name} className='size-12 rounded-md object-cover' />
251
+ <div>
252
+ <div className='font-medium'>{product.name}</div>
253
+ <div className='text-muted-foreground text-sm'>
254
+ {product.brand} • {product.category}
255
+ </div>
256
+ </div>
257
+ </div>
258
+ </TableCell>
259
+ <TableCell className='w-52 min-w-[180px] break-words whitespace-normal'>
260
+ <div>
261
+ <div className='text-muted-foreground text-sm'>{product.specifications}</div>
262
+ </div>
263
+ </TableCell>
264
+ <TableCell className='w-32 min-w-[100px]'>
265
+ <div className='flex items-center gap-2'>
266
+ <span className='font-semibold'>${product.price}</span>
267
+ </div>
268
+ </TableCell>
269
+ <TableCell className='w-32 min-w-[100px]'>
270
+ <div className='flex items-center gap-2'>
271
+ <div className='flex'>{renderStars(product.rating)}</div>
272
+ <span className='text-muted-foreground text-sm'>
273
+ {product.rating} ({product.reviews})
274
+ </span>
275
+ </div>
276
+ </TableCell>
277
+ <TableCell className='w-32 min-w-[100px] text-center'>
278
+ <Badge variant={stockVariants[product.stock] ?? 'secondary'} className='rounded-full'>
279
+ {product.stock}
280
+ </Badge>
281
+ </TableCell>
282
+ <TableCell className='w-34 min-w-[90px] pe-4 text-center'>
283
+ <div className='flex items-center justify-center gap-1'>
284
+ <Button
285
+ variant='ghost'
286
+ size='icon-sm'
287
+ aria-label={`Vista rápida de ${product.name}`}
288
+ onClick={onQuickView ? () => onQuickView(product) : undefined}
289
+ >
290
+ <Eye />
291
+ </Button>
292
+
293
+ {/*
294
+ The same reaction control the card blocks use: the heart pops with an
295
+ overshoot and throws eight particles the moment it becomes active.
296
+ It carries its own copy of the file rather than importing from another
297
+ block, because blocks install one at a time into a flat directory — a
298
+ part shared across two blocks only arrives if the buyer happens to
299
+ take both. The keyframes dedupe by marker if they do.
300
+ */}
301
+ <Button
302
+ variant='ghost'
303
+ size='icon-sm'
304
+ onClick={() => toggleWishlist(product)}
305
+ aria-pressed={wishlisted.includes(product.id)}
306
+ aria-label={
307
+ wishlisted.includes(product.id)
308
+ ? `Quitar ${product.name} de la lista de deseos`
309
+ : `Agregar ${product.name} a la lista de deseos`
310
+ }
311
+ >
312
+ <ProductCard5ReactionToggle
313
+ active={wishlisted.includes(product.id)}
314
+ icon={Heart}
315
+ iconClassName={cn(
316
+ 'size-4',
317
+ !wishlisted.includes(product.id) && 'text-muted-foreground'
318
+ )}
319
+ activeColorClassName='text-destructive'
320
+ />
321
+ </Button>
322
+
323
+ <Button
324
+ size='icon-sm'
325
+ disabled={product.stock === 'Out of Stock'}
326
+ aria-label={`Agregar ${product.name} al carrito`}
327
+ onClick={onAddToCart ? () => onAddToCart(product) : undefined}
328
+ >
329
+ <ShoppingCart />
330
+ </Button>
331
+ </div>
332
+ </TableCell>
333
+ </TableRow>
334
+ ))}
335
+ </TableBody>
336
+ </Table>
337
+ </div>
338
+
339
+ {/* Summary */}
340
+ <div className='text-muted-foreground mt-6 flex flex-wrap items-center justify-between gap-4 text-sm'>
341
+ <div>
342
+ Mostrando {filteredProducts.length} de {products.length} productos
343
+ {selectedProducts.length > 0 && (
344
+ <span className='ms-2'>• {selectedProducts.length} seleccionados</span>
345
+ )}
346
+ </div>
347
+ <div className='flex items-center gap-4'>
348
+ <span>
349
+ Ordenar por: {sortBy} ({sortOrder})
350
+ </span>
351
+ <Button
352
+ variant='outline'
353
+ size='sm'
354
+ onClick={onExport ? () => onExport(filteredProducts) : undefined}
355
+ >
356
+ Exportar lista
357
+ </Button>
358
+ </div>
359
+ </div>
360
+ </CardContent>
361
+ </Card>
362
+ </div>
363
+ </section>
364
+ )
365
+ }
366
+
367
+ export default ProductCard5
@@ -0,0 +1,201 @@
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, Star } from 'lucide-react'
7
+ import { Carousel, CarouselContent, CarouselItem, type CarouselApi } from '../../../ui/carousel'
8
+ import { ReactionToggle } from '../../shared/reaction-toggle'
9
+
10
+ export interface ProductOverview1ProductImage {
11
+ id: number
12
+ src: string
13
+ alt: string
14
+ }
15
+
16
+ export interface ProductOverview1ProductColor {
17
+ name: string
18
+ value: string
19
+ }
20
+
21
+ export interface ProductOverview1Product {
22
+ id: string
23
+ brand: string
24
+ name: string
25
+ description: string
26
+ price: number
27
+ currency: string
28
+ rating: number
29
+ images: ProductOverview1ProductImage[]
30
+ sizes: number[]
31
+ colors: ProductOverview1ProductColor[]
32
+ }
33
+
34
+ export interface ProductOverview1Props {
35
+ product: ProductOverview1Product
36
+ sizesLabel?: string
37
+ colorLabel?: string
38
+ reviewsLabel?: string
39
+ addToCartLabel?: string
40
+ removeFromWishlistLabel?: string
41
+ addToWishlistLabel?: string
42
+ onAddToCart?: (product: ProductOverview1Product) => void
43
+ onToggleWishlist?: (product: ProductOverview1Product, isWishlisted: boolean) => void
44
+ }
45
+
46
+ export default function ProductOverview1({
47
+ product,
48
+ sizesLabel = 'Tallas',
49
+ colorLabel = 'Color',
50
+ reviewsLabel = 'Reseñas',
51
+ addToCartLabel = 'Agregar al carrito',
52
+ removeFromWishlistLabel = 'Quitar de favoritos',
53
+ addToWishlistLabel = 'Agregar a favoritos',
54
+ onAddToCart,
55
+ onToggleWishlist,
56
+ }: ProductOverview1Props) {
57
+ const [selectedImage, setSelectedImage] = useState(0)
58
+ const [selectedSize, setSelectedSize] = useState<number | null>(product.sizes[0])
59
+ const [selectedColor, setSelectedColor] = useState(product.colors[0])
60
+ const [isWishlisted, setIsWishlisted] = useState(false)
61
+ const [carouselApi, setCarouselApi] = useState<CarouselApi>()
62
+
63
+ useEffect(() => {
64
+ if (!carouselApi) return
65
+
66
+ carouselApi.scrollTo(selectedImage)
67
+
68
+ const handleSelect = () => {
69
+ const currentIndex = carouselApi.selectedScrollSnap()
70
+ setSelectedImage(currentIndex)
71
+ }
72
+
73
+ carouselApi.on('select', handleSelect)
74
+ return () => {
75
+ carouselApi.off('select', handleSelect)
76
+ }
77
+ }, [carouselApi, selectedImage])
78
+
79
+ return (
80
+ <div>
81
+ <section className='@container mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
82
+ <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'>
83
+ <div className='flex flex-col justify-between gap-6 lg:gap-8'>
84
+ <div className='flex flex-col gap-2 lg:gap-4'>
85
+ <span className='text-sm font-semibold tracking-wide uppercase'>{product.brand} —</span>
86
+ <h2 className='text-xl font-bold tracking-tight text-balance lg:text-3xl'>{product.name}</h2>
87
+ <p className='text-muted-foreground text-balance'>{product.description}</p>
88
+ <p className='text-2xl font-bold tracking-tight'>
89
+ {product.currency}
90
+ {product.price}
91
+ </p>
92
+ </div>
93
+
94
+ <div className='flex flex-wrap gap-4'>
95
+ {product.images.map((image, index) => (
96
+ <div
97
+ key={image.id}
98
+ onMouseEnter={() => setSelectedImage(index)}
99
+ className={cn(
100
+ 'ring-offset-background size-16 cursor-pointer overflow-hidden rounded-sm ring-offset-2 transition-all lg:size-18',
101
+ selectedImage === index && 'ring-foreground ring-2',
102
+ )}
103
+ >
104
+ <img src={image.src || undefined} alt={image.alt} className='size-full object-cover' />
105
+ </div>
106
+ ))}
107
+ </div>
108
+ </div>
109
+
110
+ <div className='row-span-2 row-start-1 lg:col-start-2'>
111
+ <Carousel setApi={setCarouselApi} className='w-full'>
112
+ <CarouselContent>
113
+ {product.images.map(image => (
114
+ <CarouselItem key={image.id}>
115
+ <img src={image.src || undefined} alt={image.alt} className='w-full h-90 rounded-lg object-cover' />
116
+ </CarouselItem>
117
+ ))}
118
+ </CarouselContent>
119
+ </Carousel>
120
+ </div>
121
+
122
+ <div className='flex flex-col gap-6 lg:gap-10'>
123
+ <div className='flex flex-col gap-2'>
124
+ <h3 className='font-bold'>{sizesLabel}</h3>
125
+ <div className='flex flex-wrap gap-3'>
126
+ {product.sizes.map(size => (
127
+ <Button
128
+ key={size}
129
+ variant={selectedSize === size ? 'default' : 'outline'}
130
+ onClick={() => setSelectedSize(size)}
131
+ className='h-9 px-4 py-2 size-12 cursor-pointer rounded-full p-0'
132
+ >
133
+ {size}
134
+ </Button>
135
+ ))}
136
+ </div>
137
+ </div>
138
+
139
+ <div className='flex flex-col gap-2'>
140
+ <h3 className='font-bold'>{colorLabel}</h3>
141
+ <div className='flex gap-3'>
142
+ {product.colors.map(color => (
143
+ <Button
144
+ key={color.name}
145
+ onClick={() => setSelectedColor(color)}
146
+ className={cn(
147
+ 'h-9 px-4 py-2',
148
+ 'ring-offset-background size-8 cursor-pointer rounded-full ring-offset-2 transition-all',
149
+ selectedColor.name === color.name && 'ring-foreground ring-2',
150
+ selectedColor.name !== color.name &&
151
+ ['Black', 'White'].includes(color.name) &&
152
+ 'outline-muted outline-solid',
153
+ )}
154
+ style={{ backgroundColor: color.value }}
155
+ aria-label={`Seleccionar color ${color.name}`}
156
+ />
157
+ ))}
158
+ </div>
159
+ </div>
160
+
161
+ <div className='flex flex-col gap-2'>
162
+ <h3 className='font-bold'>{reviewsLabel}</h3>
163
+ <div className='flex gap-1'>
164
+ {[...Array(5)].map((_, i) => (
165
+ <Star
166
+ key={i}
167
+ className='text-foreground size-5'
168
+ fill={i < product.rating ? 'currentColor' : 'none'}
169
+ />
170
+ ))}
171
+ </div>
172
+ </div>
173
+
174
+ <div className='flex gap-4'>
175
+ <Button
176
+ className='h-10 px-8 flex-1 cursor-pointer rounded-full'
177
+ size='lg'
178
+ onClick={onAddToCart ? () => onAddToCart(product) : undefined}
179
+ >
180
+ {addToCartLabel}
181
+ </Button>
182
+ <Button
183
+ variant='outline'
184
+ size='icon'
185
+ className='size-9 rounded-full cursor-pointer'
186
+ onClick={() => {
187
+ const next = !isWishlisted
188
+ setIsWishlisted(next)
189
+ onToggleWishlist?.(product, next)
190
+ }}
191
+ aria-label={isWishlisted ? removeFromWishlistLabel : addToWishlistLabel}
192
+ >
193
+ <ReactionToggle active={isWishlisted} icon={Heart} iconClassName='size-5' activeColorClassName='text-primary' />
194
+ </Button>
195
+ </div>
196
+ </div>
197
+ </div>
198
+ </section>
199
+ </div>
200
+ )
201
+ }