@thorprovider/blocks 0.1.3 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +73 -29
  3. package/docs/standards/data-contracts.md +13 -2
  4. package/package.json +26 -10
  5. package/scripts/fix-esm-extensions.mjs +40 -0
  6. package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
  7. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  8. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  9. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  10. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  11. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  12. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  13. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  14. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  15. package/src/blocks/e-commerce/product-list/product-card.tsx +424 -0
  16. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  17. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  18. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  19. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  20. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  21. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  22. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  23. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  24. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  25. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  26. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +119 -85
  27. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  28. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  29. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  30. package/src/blocks/marketing/features/feature.tsx +195 -0
  31. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +27 -9
  32. package/src/blocks/marketing/navbars/navbar-4.test.tsx +33 -2
  33. package/src/blocks/marketing/navbars/navbar-4.tsx +29 -11
  34. package/src/index.ts +77 -48
  35. package/src/lib/contracts-curated.test.ts +42 -24
  36. package/src/lib/contracts.test.ts +143 -2
  37. package/src/lib/contracts.ts +346 -14
  38. package/src/ui/button-group.tsx +1 -1
  39. package/src/ui/carousel.tsx +1 -1
  40. package/src/ui/dialog.tsx +1 -1
  41. package/src/ui/field.tsx +2 -2
  42. package/src/ui/input-group.tsx +3 -3
  43. package/src/ui/pagination.tsx +1 -1
  44. package/src/ui/sidebar.tsx +7 -7
  45. package/src/ui/toggle-group.tsx +1 -1
  46. package/tsconfig.build.json +12 -0
  47. package/tsup.config.ts +11 -7
  48. package/.turbo/turbo-build.log +0 -24
  49. package/.turbo/turbo-test.log +0 -47
  50. package/.turbo/turbo-type-check.log +0 -0
  51. package/dist/.tsbuildinfo +0 -1
  52. package/dist/contracts.d.mts +0 -1732
  53. package/dist/contracts.d.ts +0 -1732
  54. package/dist/contracts.js +0 -423
  55. package/dist/contracts.js.map +0 -1
  56. package/dist/contracts.mjs +0 -365
  57. package/dist/contracts.mjs.map +0 -1
  58. package/dist/index.d.mts +0 -3068
  59. package/dist/index.d.ts +0 -3068
  60. package/dist/index.js +0 -17834
  61. package/dist/index.js.map +0 -1
  62. package/dist/index.mjs +0 -17740
  63. package/dist/index.mjs.map +0 -1
  64. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  65. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  66. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  67. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  68. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  69. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  70. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +0 -88
  71. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -192
  72. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  73. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  74. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  75. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  76. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  77. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  78. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  79. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  80. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  81. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  82. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  83. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  84. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  85. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  86. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  87. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  88. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  89. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  90. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  91. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  92. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -229
  93. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -72
  94. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  95. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  96. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  97. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  98. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  99. package/src/blocks/marketing/features/feature-2.tsx +0 -82
@@ -1,218 +0,0 @@
1
- 'use client'
2
-
3
- import { useState } from 'react'
4
- import { Card, CardContent } from '../../../ui/card'
5
- import { Badge } from '../../../ui/badge'
6
- import { Button } from '../../../ui/button'
7
- import { Input } from '../../../ui/input'
8
- import {
9
- ArrowRight,
10
- Search,
11
- Laptop,
12
- Shirt,
13
- Home,
14
- Dumbbell,
15
- BookOpen,
16
- Heart,
17
- Car,
18
- Utensils,
19
- Gamepad2,
20
- Baby,
21
- Gift,
22
- Wrench,
23
- } from 'lucide-react'
24
- import { cn } from '../../../lib/utils'
25
-
26
- export type CategoryIcon =
27
- | 'electronics'
28
- | 'fashion'
29
- | 'home'
30
- | 'sports'
31
- | 'books'
32
- | 'beauty'
33
- | 'automotive'
34
- | 'food'
35
- | 'gaming'
36
- | 'baby'
37
- | 'gifts'
38
- | 'tools'
39
-
40
- export interface ProductCategory5Category {
41
- id: string
42
- name: string
43
- icon: CategoryIcon
44
- description: string
45
- itemCount: number
46
- subcategories: string[]
47
- featured?: boolean
48
- }
49
-
50
- export interface ProductCategory5Props {
51
- title?: string
52
- description?: string
53
- categories: ProductCategory5Category[]
54
- searchPlaceholder?: string
55
- productsLabel?: string
56
- featuredLabel?: string
57
- browseLabel?: string
58
- viewAllLabel?: string
59
- notFoundLabel?: string
60
- onBrowse?: (category: ProductCategory5Category) => void
61
- onViewAll?: () => void
62
- }
63
-
64
- const categoryIcons: Record<CategoryIcon, typeof Laptop> = {
65
- electronics: Laptop,
66
- fashion: Shirt,
67
- home: Home,
68
- sports: Dumbbell,
69
- books: BookOpen,
70
- beauty: Heart,
71
- automotive: Car,
72
- food: Utensils,
73
- gaming: Gamepad2,
74
- baby: Baby,
75
- gifts: Gift,
76
- tools: Wrench,
77
- }
78
-
79
- export function ProductCategory5({
80
- title = 'Explora categorías',
81
- description = 'Encuentra lo que necesitas en nuestras categorías organizadas',
82
- categories,
83
- searchPlaceholder = 'Buscar categorías...',
84
- productsLabel = 'productos disponibles',
85
- featuredLabel = 'Destacado',
86
- browseLabel = 'Explorar',
87
- viewAllLabel = 'Ver todos los productos',
88
- notFoundLabel = '¿No encuentras lo que buscas?',
89
- onBrowse,
90
- onViewAll,
91
- }: ProductCategory5Props) {
92
- const [query, setQuery] = useState('')
93
-
94
- const filteredCategories = categories.filter(category => {
95
- const q = query.trim().toLowerCase()
96
- if (!q) return true
97
- return (
98
- category.name.toLowerCase().includes(q) ||
99
- category.subcategories.some(sub => sub.toLowerCase().includes(q))
100
- )
101
- })
102
-
103
- const totalCount = categories.reduce((sum, category) => sum + category.itemCount, 0)
104
-
105
- return (
106
- <section className='py-12'>
107
- <div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
108
- <div className='mb-8 text-center'>
109
- <h2 className='mb-4 text-3xl font-bold tracking-tight text-balance'>{title}</h2>
110
- <p className='text-muted-foreground mb-6 text-lg'>{description}</p>
111
-
112
- <div className='relative mx-auto max-w-md'>
113
- <Search
114
- className='text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2'
115
- aria-hidden='true'
116
- />
117
- <Input
118
- placeholder={searchPlaceholder}
119
- value={query}
120
- onChange={e => setQuery(e.target.value)}
121
- className='ps-9 h-9'
122
- aria-label='Buscar categorías'
123
- />
124
- </div>
125
- </div>
126
-
127
- {filteredCategories.length === 0 ? (
128
- <p className='text-muted-foreground py-10 text-center text-sm'>
129
- Sin resultados para &quot;{query.trim()}&quot;
130
- </p>
131
- ) : (
132
- <div className='grid grid-cols-1 gap-4 lg:grid-cols-2'>
133
- {filteredCategories.map(category => {
134
- const Icon = categoryIcons[category.icon]
135
-
136
- return (
137
- <Card
138
- key={category.id}
139
- className={cn(
140
- 'group hover:border-primary relative py-0 transition-all duration-200 hover:shadow-md'
141
- )}
142
- >
143
- <CardContent className='p-6'>
144
- <div className='flex items-start gap-4'>
145
- <div className='flex-shrink-0'>
146
- <div className='bg-primary/10 group-hover:bg-primary/20 flex size-12 items-center justify-center rounded-lg transition-colors'>
147
- <Icon className='text-primary size-6' aria-hidden='true' />
148
- </div>
149
- </div>
150
-
151
- <div className='min-w-0 flex-1'>
152
- <div className='flex items-start justify-between'>
153
- <div className='flex-1'>
154
- <div className='mb-2 flex items-center gap-2'>
155
- <h3 className='text-lg font-semibold transition-colors'>{category.name}</h3>
156
- {category.featured ? (
157
- <Badge
158
- variant='secondary'
159
- className='rounded-sm px-2 py-0.5 font-semibold'
160
- >
161
- {featuredLabel}
162
- </Badge>
163
- ) : null}
164
- </div>
165
- <p className='text-muted-foreground mb-3 line-clamp-2 text-sm'>
166
- {category.description}
167
- </p>
168
-
169
- <div className='mb-3 flex flex-wrap gap-2'>
170
- {category.subcategories.map((sub, index) => (
171
- <span
172
- key={index}
173
- className='bg-muted text-muted-foreground rounded-md px-2 py-1 text-xs'
174
- >
175
- {sub}
176
- </span>
177
- ))}
178
- </div>
179
-
180
- <div className='text-muted-foreground text-sm'>
181
- {category.itemCount.toLocaleString()} {productsLabel}
182
- </div>
183
- </div>
184
- </div>
185
- </div>
186
- </div>
187
- <Button
188
- variant='ghost'
189
- size='sm'
190
- className='absolute end-4 bottom-4 h-8 cursor-pointer px-3 text-xs opacity-0 transition-opacity group-hover:opacity-100'
191
- onClick={onBrowse ? () => onBrowse(category) : undefined}
192
- >
193
- {browseLabel}
194
- <ArrowRight />
195
- </Button>
196
- </CardContent>
197
- </Card>
198
- )
199
- })}
200
- </div>
201
- )}
202
-
203
- <div className='mt-8 text-center'>
204
- <p className='text-muted-foreground mb-4 text-sm'>{notFoundLabel}</p>
205
- <Button
206
- variant='outline'
207
- className='h-9 cursor-pointer px-4 py-2'
208
- onClick={onViewAll}
209
- >
210
- {viewAllLabel} {totalCount.toLocaleString()}
211
- </Button>
212
- </div>
213
- </div>
214
- </section>
215
- )
216
- }
217
-
218
- export default ProductCategory5
@@ -1,88 +0,0 @@
1
- import { renderToStaticMarkup } from 'react-dom/server'
2
- import { describe, expect, it } from 'vitest'
3
- import { ProductCard1, type ProductCard1Product } from './product-card-1'
4
-
5
- const legacy: ProductCard1Product = {
6
- id: 'legacy-1',
7
- title: 'Taladro percutor',
8
- price: 49.99,
9
- originalPrice: 69.99,
10
- image: '/demo/drill.svg',
11
- }
12
-
13
- const canonical: ProductCard1Product = {
14
- id: 'sku-1',
15
- title: 'Llave de impacto',
16
- price: { amount: '129.50', currencyCode: 'EUR' },
17
- compareAtPrice: { amount: '159.00', currencyCode: 'EUR' },
18
- image: '/demo/impact.svg',
19
- imageAlt: 'Llave de impacto inalámbrica',
20
- rating: 4.5,
21
- badge: 'Nuevo',
22
- }
23
-
24
- const render = (products: ProductCard1Product[], extra: Record<string, unknown> = {}) =>
25
- renderToStaticMarkup(<ProductCard1 products={products} {...extra} />)
26
-
27
- describe('ProductCard1 — legacy numeric path unchanged', () => {
28
- it('keeps the legacy dollar output and originalPrice', () => {
29
- const html = render([legacy])
30
- expect(html).toContain('$49.99')
31
- expect(html).toContain('$69.99')
32
- })
33
- })
34
-
35
- describe('ProductCard1 — canonical Money path', () => {
36
- it('formats the canonical price in its own currency (no dollars)', () => {
37
- const html = render([canonical])
38
- expect(html).not.toContain('$')
39
- expect(html).toContain('129')
40
- })
41
-
42
- it('renders a comparison price only when it is strictly higher', () => {
43
- expect(render([canonical])).toContain('159')
44
- const equal = render([
45
- { ...canonical, compareAtPrice: { amount: '129.50', currencyCode: 'EUR' } },
46
- ])
47
- const lower = render([
48
- { ...canonical, compareAtPrice: { amount: '99.00', currencyCode: 'EUR' } },
49
- ])
50
- expect(equal).not.toContain('159')
51
- expect(lower).not.toContain('99')
52
- })
53
-
54
- it('treats a valid zero as a real price', () => {
55
- const html = render([
56
- { ...canonical, price: { amount: '0.00', currencyCode: 'USD' }, compareAtPrice: undefined },
57
- ])
58
- expect(html).toContain('$0.00')
59
- expect(html).not.toContain('Precio no disponible')
60
- })
61
-
62
- it('shows the unavailable label for an unresolved price (never $0)', () => {
63
- const html = render([
64
- { ...canonical, price: { amount: '', currencyCode: '' }, compareAtPrice: undefined },
65
- ])
66
- expect(html).toContain('Precio no disponible')
67
- expect(html).not.toContain('$0')
68
- })
69
-
70
- it('renders the rating and badge only from real data', () => {
71
- expect(render([canonical])).toContain('4.5')
72
- expect(render([canonical])).toContain('Nuevo')
73
- const withoutRating = render([{ ...canonical, rating: undefined, badge: undefined }])
74
- expect(withoutRating).not.toContain('4.5')
75
- expect(withoutRating).not.toContain('Nuevo')
76
- })
77
-
78
- it('omits the image source entirely when there is no image (no empty src)', () => {
79
- const html = render([{ ...canonical, image: undefined }])
80
- expect(html).not.toContain('src=""')
81
- expect(html).toContain('Llave de impacto inalámbrica')
82
- })
83
-
84
- it('falls back to the title when no image alt is provided', () => {
85
- const html = render([{ ...canonical, imageAlt: undefined }])
86
- expect(html).toContain('alt="Llave de impacto"')
87
- })
88
- })
@@ -1,192 +0,0 @@
1
- 'use client'
2
-
3
- import { Star } from 'lucide-react'
4
- import { motion, useReducedMotion } from 'motion/react'
5
-
6
- import { Button } from '../../../ui/button'
7
- import { Card, CardContent, CardFooter } from '../../../ui/card'
8
- import { formatMoney } from '../../../lib/money'
9
- import type { ProductPresentation } from '../../../lib/contracts'
10
-
11
- export interface Product {
12
- id: string | number
13
- title: string
14
- price: number
15
- originalPrice?: number
16
- image: string
17
- badge?: string
18
- }
19
-
20
- /**
21
- * A card entry is either the legacy numeric-price product or the canonical
22
- * `Money`-price presentation. The block renders both without breaking legacy
23
- * consumers: a numeric `price` keeps the legacy `$` output unchanged, while a
24
- * canonical `Money` price is formatted in its own currency and locale.
25
- */
26
- export type ProductCard1Product = Product | ProductPresentation
27
-
28
- export interface ProductCard1Props {
29
- products: ProductCard1Product[]
30
- heading?: string
31
- maxItems?: number
32
- onAddToCart?: (product: ProductCard1Product) => void
33
- onProductClick?: (product: ProductCard1Product) => void
34
- addToCartLabel?: string
35
- /** Shown when a canonical price is present but unavailable (never `$0`). */
36
- unavailablePriceLabel?: string
37
- }
38
-
39
- function isPresentation(product: ProductCard1Product): product is ProductPresentation {
40
- return typeof product.price === 'object'
41
- }
42
-
43
- function priceText(product: ProductCard1Product, unavailablePriceLabel: string): string {
44
- if (isPresentation(product)) {
45
- return formatMoney(product.price) ?? unavailablePriceLabel
46
- }
47
- return `$${product.price.toFixed(2)}`
48
- }
49
-
50
- function compareAtText(product: ProductCard1Product): string | undefined {
51
- if (isPresentation(product)) {
52
- const compareAt = product.compareAtPrice
53
- if (!compareAt) return undefined
54
- const formatted = formatMoney(compareAt)
55
- const price = formatMoney(product.price)
56
- // Render the comparison price only when both are valid and it is higher.
57
- if (!formatted || !price) return undefined
58
- if (Number(compareAt.amount) <= Number(product.price.amount)) return undefined
59
- return formatted
60
- }
61
- // Legacy behavior is unchanged: truthiness only.
62
- return product.originalPrice ? `$${product.originalPrice.toFixed(2)}` : undefined
63
- }
64
-
65
- function imageAlt(product: ProductCard1Product): string {
66
- return isPresentation(product) ? (product.imageAlt ?? product.title) : product.title
67
- }
68
-
69
- function rating(product: ProductCard1Product): number | undefined {
70
- if (!isPresentation(product)) return undefined
71
- return typeof product.rating === 'number' && Number.isFinite(product.rating)
72
- ? product.rating
73
- : undefined
74
- }
75
-
76
- export function ProductCard1({
77
- products,
78
- heading,
79
- maxItems = 6,
80
- onAddToCart,
81
- onProductClick,
82
- addToCartLabel = 'Agregar al carrito',
83
- unavailablePriceLabel = 'Precio no disponible',
84
- }: ProductCard1Props) {
85
- const reduceMotion = useReducedMotion()
86
- const displayedProducts = products.slice(0, maxItems)
87
-
88
- return (
89
- <section className='py-12'>
90
- <div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
91
- {heading && (
92
- <h2 className='mb-8 text-center text-2xl font-bold text-balance md:text-3xl'>{heading}</h2>
93
- )}
94
-
95
- <div className='grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:gap-6'>
96
- {displayedProducts.map((product, index) => {
97
- const compareAt = compareAtText(product)
98
- const ratingValue = rating(product)
99
- return (
100
- <motion.div
101
- key={product.id}
102
- initial={reduceMotion ? false : { opacity: 0, y: 16 }}
103
- whileInView={{ opacity: 1, y: 0 }}
104
- viewport={{ once: true, margin: '-40px' }}
105
- whileHover={reduceMotion ? undefined : { y: -4 }}
106
- transition={{
107
- duration: 0.35,
108
- delay: reduceMotion ? 0 : index * 0.05,
109
- ease: 'easeOut',
110
- }}
111
- >
112
- <Card
113
- className={`flex h-full flex-col gap-4 overflow-hidden rounded-lg py-4 shadow-none transition-shadow duration-300 hover:shadow-md ${
114
- onProductClick ? 'cursor-pointer' : ''
115
- }`}
116
- onClick={onProductClick ? () => onProductClick(product) : undefined}
117
- >
118
- <CardContent className='flex flex-1 flex-col gap-4 px-4 '>
119
- <div className='relative aspect-square overflow-hidden rounded-md'>
120
- {product.badge && (
121
- <span className='bg-destructive text-destructive-foreground absolute top-2 left-2 z-10 rounded-full px-2 py-0.5 text-xs font-semibold'>
122
- {product.badge}
123
- </span>
124
- )}
125
- <img
126
- src={product.image || undefined}
127
- alt={imageAlt(product)}
128
- className='size-full rounded-md object-contain'
129
- loading='lazy'
130
- width={400}
131
- height={400}
132
- />
133
- </div>
134
-
135
- <div className='flex flex-1 flex-col'>
136
- <h2 className='mb-1 font-medium text-balance'>
137
- {onProductClick ? (
138
- <button
139
- type='button'
140
- onClick={(e) => {
141
- e.stopPropagation()
142
- onProductClick(product)
143
- }}
144
- className='hover:text-primary cursor-pointer text-left transition-colors'
145
- >
146
- {product.title}
147
- </button>
148
- ) : (
149
- product.title
150
- )}
151
- </h2>
152
- {ratingValue !== undefined && (
153
- <div className='text-muted-foreground mb-1 flex items-center gap-1 text-xs'>
154
- <Star className='size-3.5 fill-current' aria-hidden='true' />
155
- <span>{ratingValue.toFixed(1)}</span>
156
- </div>
157
- )}
158
- <div className='mt-auto flex items-baseline gap-2'>
159
- <p className='font-semibold'>{priceText(product, unavailablePriceLabel)}</p>
160
- {compareAt && (
161
- <p className='text-muted-foreground text-sm line-through md:text-base xl:text-sm 2xl:text-base'>
162
- {compareAt}
163
- </p>
164
- )}
165
- </div>
166
- </div>
167
- </CardContent>
168
-
169
- <CardFooter className='bg-transparent px-3 pt-0 border-t-0 md:px-4'>
170
- <Button
171
- variant='outline'
172
- size='sm'
173
- className='h-8 w-full cursor-pointer px-3 text-sm text-xs'
174
- onClick={(e) => {
175
- e.stopPropagation()
176
- onAddToCart?.(product)
177
- }}
178
- >
179
- {addToCartLabel}
180
- </Button>
181
- </CardFooter>
182
- </Card>
183
- </motion.div>
184
- )
185
- })}
186
- </div>
187
- </div>
188
- </section>
189
- )
190
- }
191
-
192
- export default ProductCard1
@@ -1,71 +0,0 @@
1
- import { Star } from 'lucide-react'
2
- import { Card, CardContent, CardTitle } from '../../../ui/card'
3
-
4
- export interface ProductCard2Product {
5
- id: string | number
6
- title: string
7
- image: string
8
- price: number
9
- rating: number
10
- }
11
-
12
- export interface ProductCard2Props {
13
- products: ProductCard2Product[]
14
- heading?: string
15
- description?: string
16
- }
17
-
18
- export function ProductCard2({ products, heading, description }: ProductCard2Props) {
19
- return (
20
- <section className='py-12'>
21
- <div className='mx-auto flex w-full max-w-7xl flex-col gap-8 px-4 sm:px-6 lg:px-8'>
22
- {heading || description ? (
23
- <header className='flex flex-col gap-2'>
24
- {heading && <h2 className='text-3xl font-bold text-balance sm:text-4xl'>{heading}</h2>}
25
- {description && (
26
- <p className='text-muted-foreground max-w-[60ch] text-balance'>{description}</p>
27
- )}
28
- </header>
29
- ) : null}
30
-
31
- <div className='grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3'>
32
- {products.map(product => (
33
- <Card key={product.id} className='group overflow-hidden transition-all hover:shadow-lg'>
34
- <CardContent className='flex flex-col gap-4'>
35
- <div className='overflow-hidden rounded-md'>
36
- <img
37
- src={product.image || undefined}
38
- alt={product.title}
39
- width={400}
40
- height={400}
41
- className='aspect-square w-full object-cover transition-transform duration-300 group-hover:scale-105'
42
- />
43
- </div>
44
-
45
- <div className='flex flex-col gap-2'>
46
- <CardTitle className='line-clamp-1 text-lg font-semibold text-balance sm:text-xl'>
47
- {product.title}
48
- </CardTitle>
49
-
50
- <div
51
- className='flex items-center gap-0.5'
52
- aria-label={`${product.rating} out of 5 stars`}
53
- role='img'
54
- >
55
- {Array.from({ length: product.rating }).map((_, i) => (
56
- <Star key={i} className='fill-foreground text-foreground size-4 sm:size-5' />
57
- ))}
58
- </div>
59
-
60
- <p className='text-lg font-semibold sm:text-xl'>${product.price.toFixed(2)}</p>
61
- </div>
62
- </CardContent>
63
- </Card>
64
- ))}
65
- </div>
66
- </div>
67
- </section>
68
- )
69
- }
70
-
71
- export default ProductCard2