@thorprovider/blocks 0.1.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +107 -0
- package/README.md +108 -23
- package/docs/standards/data-contracts.md +13 -2
- package/package.json +26 -10
- package/scripts/fix-esm-extensions.mjs +40 -0
- package/src/blocks/e-commerce/navigation/back-to-top.test.tsx +27 -0
- package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
- package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
- package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
- package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
- package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
- package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
- package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
- package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
- package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
- package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
- package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
- package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
- package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
- package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
- package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
- package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
- package/src/blocks/e-commerce/product-list/product-card.tsx +424 -0
- package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
- package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
- package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
- package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
- package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
- package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
- package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
- package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
- package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
- package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
- package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
- package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
- package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +204 -109
- package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
- package/src/blocks/marketing/features/feature.test.tsx +153 -0
- package/src/blocks/marketing/features/feature.tsx +195 -0
- package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +56 -0
- package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +150 -0
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +102 -9
- package/src/blocks/marketing/navbars/navbar-4.test.tsx +154 -2
- package/src/blocks/marketing/navbars/navbar-4.tsx +152 -32
- package/src/index.ts +93 -46
- package/src/lib/contracts-curated.test.ts +42 -24
- package/src/lib/contracts.test.ts +143 -2
- package/src/lib/contracts.ts +395 -13
- package/src/ui/button-group.tsx +1 -1
- package/src/ui/carousel.tsx +1 -1
- package/src/ui/dialog.tsx +1 -1
- package/src/ui/field.tsx +2 -2
- package/src/ui/input-group.tsx +3 -3
- package/src/ui/pagination.tsx +1 -1
- package/src/ui/sidebar.tsx +7 -7
- package/src/ui/toggle-group.tsx +1 -1
- package/tsconfig.build.json +12 -0
- package/tsup.config.ts +11 -7
- package/.turbo/turbo-build.log +0 -24
- package/.turbo/turbo-test.log +0 -37
- package/.turbo/turbo-type-check.log +0 -0
- package/dist/.tsbuildinfo +0 -1
- package/dist/contracts.d.mts +0 -1584
- package/dist/contracts.d.ts +0 -1584
- package/dist/contracts.js +0 -390
- package/dist/contracts.js.map +0 -1
- package/dist/contracts.mjs +0 -334
- package/dist/contracts.mjs.map +0 -1
- package/dist/index.d.mts +0 -2786
- package/dist/index.d.ts +0 -2786
- package/dist/index.js +0 -17139
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -17059
- package/dist/index.mjs.map +0 -1
- package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
- package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
- package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
- package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
- package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
- package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
- package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -131
- package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
- package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
- package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
- package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
- package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
- package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
- package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
- package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
- package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
- package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
- package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -137
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -65
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
- package/src/blocks/marketing/features/feature-1.tsx +0 -97
- package/src/blocks/marketing/features/feature-2.tsx +0 -82
|
@@ -1,367 +0,0 @@
|
|
|
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
|
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
-
import { describe, expect, it } from 'vitest'
|
|
3
|
-
import ProductOverview1, {
|
|
4
|
-
galleryImages,
|
|
5
|
-
resolveAddToCartPayload,
|
|
6
|
-
variantLabelOf,
|
|
7
|
-
type ProductDetailProduct,
|
|
8
|
-
} from './product-overview-1'
|
|
9
|
-
|
|
10
|
-
const product: ProductDetailProduct = {
|
|
11
|
-
id: 'p1',
|
|
12
|
-
handle: 'taladro',
|
|
13
|
-
title: 'Taladro inalámbrico',
|
|
14
|
-
description: 'Un taladro',
|
|
15
|
-
vendor: 'Acme',
|
|
16
|
-
options: [{ id: 'o1', name: 'Color', values: ['Rojo', 'Azul'] }],
|
|
17
|
-
variants: [
|
|
18
|
-
{
|
|
19
|
-
id: 'v-red',
|
|
20
|
-
title: 'Rojo',
|
|
21
|
-
availableForSale: true,
|
|
22
|
-
selectedOptions: [{ name: 'Color', value: 'Rojo' }],
|
|
23
|
-
price: { amount: '10.00', currencyCode: 'USD' },
|
|
24
|
-
},
|
|
25
|
-
{
|
|
26
|
-
id: 'v-blue',
|
|
27
|
-
title: 'Azul',
|
|
28
|
-
availableForSale: true,
|
|
29
|
-
selectedOptions: [{ name: 'Color', value: 'Azul' }],
|
|
30
|
-
price: { amount: '25.50', currencyCode: 'USD' },
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
id: 'v-sold',
|
|
34
|
-
title: 'Verde',
|
|
35
|
-
availableForSale: false,
|
|
36
|
-
selectedOptions: [{ name: 'Color', value: 'Verde' }],
|
|
37
|
-
price: { amount: '9.00', currencyCode: 'USD' },
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
id: 'v-noprice',
|
|
41
|
-
title: 'Gris',
|
|
42
|
-
availableForSale: true,
|
|
43
|
-
selectedOptions: [{ name: 'Color', value: 'Gris' }],
|
|
44
|
-
price: null,
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
id: 'v-free',
|
|
48
|
-
title: 'Blanco',
|
|
49
|
-
availableForSale: true,
|
|
50
|
-
selectedOptions: [{ name: 'Color', value: 'Blanco' }],
|
|
51
|
-
price: { amount: '0', currencyCode: 'USD' },
|
|
52
|
-
},
|
|
53
|
-
],
|
|
54
|
-
images: [],
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const formatPrice = (money: { amount: string; currencyCode: string }) =>
|
|
58
|
-
`${money.currencyCode} ${money.amount}`
|
|
59
|
-
|
|
60
|
-
describe('ProductOverview1 — explicit, controlled variant selection', () => {
|
|
61
|
-
it('does not default to the first variant and prompts for an explicit selection', () => {
|
|
62
|
-
const html = renderToStaticMarkup(
|
|
63
|
-
<ProductOverview1 product={product} formatPrice={formatPrice} onAddToCart={() => {}} />,
|
|
64
|
-
)
|
|
65
|
-
expect(html).toContain('Selecciona una variante')
|
|
66
|
-
expect(html).not.toContain('USD 10.00')
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
it('shows the selected non-default variant at its current price', () => {
|
|
70
|
-
const html = renderToStaticMarkup(
|
|
71
|
-
<ProductOverview1
|
|
72
|
-
product={product}
|
|
73
|
-
selectedVariantId='v-blue'
|
|
74
|
-
formatPrice={formatPrice}
|
|
75
|
-
onAddToCart={() => {}}
|
|
76
|
-
/>,
|
|
77
|
-
)
|
|
78
|
-
expect(html).toContain('USD 25.50')
|
|
79
|
-
expect(html).not.toContain('USD 10.00')
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
it('reports a missing price as unavailable instead of zero', () => {
|
|
83
|
-
const html = renderToStaticMarkup(
|
|
84
|
-
<ProductOverview1 product={product} selectedVariantId='v-noprice' formatPrice={formatPrice} />,
|
|
85
|
-
)
|
|
86
|
-
expect(html).toContain('Precio no disponible')
|
|
87
|
-
expect(html).not.toContain('USD 0')
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
it('preserves a backend-resolved valid zero', () => {
|
|
91
|
-
const html = renderToStaticMarkup(
|
|
92
|
-
<ProductOverview1 product={product} selectedVariantId='v-free' formatPrice={formatPrice} />,
|
|
93
|
-
)
|
|
94
|
-
expect(html).toContain('USD 0')
|
|
95
|
-
expect(html).not.toContain('Precio no disponible')
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
it('enables purchase only for an available, priced, explicitly selected variant', () => {
|
|
99
|
-
const onlyAvailable: ProductDetailProduct = {
|
|
100
|
-
...product,
|
|
101
|
-
variants: [product.variants[0]],
|
|
102
|
-
}
|
|
103
|
-
const enabled = renderToStaticMarkup(
|
|
104
|
-
<ProductOverview1
|
|
105
|
-
product={onlyAvailable}
|
|
106
|
-
selectedVariantId='v-red'
|
|
107
|
-
formatPrice={formatPrice}
|
|
108
|
-
onAddToCart={() => {}}
|
|
109
|
-
/>,
|
|
110
|
-
)
|
|
111
|
-
expect(enabled).not.toContain('disabled=""')
|
|
112
|
-
|
|
113
|
-
const noSelection = renderToStaticMarkup(
|
|
114
|
-
<ProductOverview1 product={onlyAvailable} formatPrice={formatPrice} onAddToCart={() => {}} />,
|
|
115
|
-
)
|
|
116
|
-
expect(noSelection).toContain('disabled=""')
|
|
117
|
-
})
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
describe('ProductOverview1 — truthful optional data', () => {
|
|
121
|
-
it('renders no rating, delivery estimate, or shipping when the backend supplies none', () => {
|
|
122
|
-
const html = renderToStaticMarkup(<ProductOverview1 product={product} formatPrice={formatPrice} />)
|
|
123
|
-
expect(html).not.toContain('Reseñas')
|
|
124
|
-
expect(html).not.toContain('Entrega estimada')
|
|
125
|
-
expect(html).not.toContain('Envío')
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
it('renders rating, delivery estimate, and shipping only when supplied', () => {
|
|
129
|
-
const html = renderToStaticMarkup(
|
|
130
|
-
<ProductOverview1
|
|
131
|
-
product={product}
|
|
132
|
-
formatPrice={formatPrice}
|
|
133
|
-
rating={{ value: 4, count: 12 }}
|
|
134
|
-
deliveryEstimate='3-5 días'
|
|
135
|
-
shipping={{ amount: '4.99', currencyCode: 'USD' }}
|
|
136
|
-
/>,
|
|
137
|
-
)
|
|
138
|
-
expect(html).toContain('Reseñas')
|
|
139
|
-
expect(html).toContain('3-5 días')
|
|
140
|
-
expect(html).toContain('USD 4.99')
|
|
141
|
-
})
|
|
142
|
-
|
|
143
|
-
it('never emits an empty image src when the product has no images', () => {
|
|
144
|
-
const html = renderToStaticMarkup(<ProductOverview1 product={product} formatPrice={formatPrice} />)
|
|
145
|
-
expect(html).not.toMatch(/src=""/)
|
|
146
|
-
})
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
describe('resolveAddToCartPayload', () => {
|
|
150
|
-
const variant = product.variants[1]
|
|
151
|
-
|
|
152
|
-
it('returns null when no variant is selected (no first-variant shortcut)', () => {
|
|
153
|
-
expect(resolveAddToCartPayload(null, 2)).toBeNull()
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
it('uses the selected backend variant id and defaults the quantity to 1', () => {
|
|
157
|
-
expect(resolveAddToCartPayload(variant, undefined)).toEqual({ variantId: 'v-blue', quantity: 1 })
|
|
158
|
-
})
|
|
159
|
-
|
|
160
|
-
it('carries the requested quantity', () => {
|
|
161
|
-
expect(resolveAddToCartPayload(variant, 3)).toEqual({ variantId: 'v-blue', quantity: 3 })
|
|
162
|
-
})
|
|
163
|
-
})
|
|
164
|
-
|
|
165
|
-
describe('variant helpers', () => {
|
|
166
|
-
it('prefers the variant title', () => {
|
|
167
|
-
expect(variantLabelOf(product.variants[0])).toBe('Rojo')
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
it('falls back to option values when a variant has no title', () => {
|
|
171
|
-
expect(variantLabelOf({ ...product.variants[0], title: '' })).toBe('Rojo')
|
|
172
|
-
})
|
|
173
|
-
|
|
174
|
-
it('uses the product images when present and never a fabricated URL', () => {
|
|
175
|
-
const withImages: ProductDetailProduct = {
|
|
176
|
-
...product,
|
|
177
|
-
images: [{ url: 'https://cdn.example/p1.jpg', altText: 'p1' }],
|
|
178
|
-
}
|
|
179
|
-
expect(galleryImages(withImages, 'FALLBACK')).toEqual(['https://cdn.example/p1.jpg'])
|
|
180
|
-
expect(galleryImages(product, 'FALLBACK')).toEqual(['FALLBACK'])
|
|
181
|
-
})
|
|
182
|
-
})
|