@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
@@ -0,0 +1,263 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from 'react'
3
+ import { createRoot, type Root } from 'react-dom/client'
4
+ import { renderToStaticMarkup } from 'react-dom/server'
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
+
7
+ import { ProductCard, type ProductCardProps } from './product-card'
8
+ import type { ProductPresentation } from '../../../lib/contracts'
9
+
10
+ ;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
11
+
12
+ // jsdom lacks the browser APIs the classic presentation's motion layer touches.
13
+ if (!window.matchMedia) {
14
+ window.matchMedia = ((query: string) => ({
15
+ matches: false,
16
+ media: query,
17
+ onchange: null,
18
+ addEventListener: () => {},
19
+ removeEventListener: () => {},
20
+ addListener: () => {},
21
+ removeListener: () => {},
22
+ dispatchEvent: () => false,
23
+ })) as unknown as typeof window.matchMedia
24
+ }
25
+ if (!('IntersectionObserver' in window)) {
26
+ ;(window as Record<string, unknown>).IntersectionObserver = class {
27
+ observe() {}
28
+ unobserve() {}
29
+ disconnect() {}
30
+ takeRecords() {
31
+ return []
32
+ }
33
+ }
34
+ }
35
+
36
+ const baseItem: ProductPresentation = {
37
+ id: 'p1',
38
+ title: 'Llave de impacto',
39
+ image: '/demo/impact.svg',
40
+ imageAlt: 'Llave de impacto inalámbrica',
41
+ price: { amount: '129.50', currencyCode: 'EUR' },
42
+ compareAtPrice: { amount: '159.00', currencyCode: 'EUR' },
43
+ rating: 4.5,
44
+ badge: 'Nuevo',
45
+ }
46
+
47
+ const renderMarkup = (props: ProductCardProps) => renderToStaticMarkup(<ProductCard {...props} />)
48
+
49
+ describe('ProductCard — renders from canonical data', () => {
50
+ it('renders one item cell from canonical Money, requiring no demo-only fields', () => {
51
+ const html = renderMarkup({ item: baseItem })
52
+ expect(html).toContain('Llave de impacto')
53
+ expect(html).toContain('129')
54
+ expect(html).not.toContain('$')
55
+ })
56
+
57
+ it('renders exactly one cell, with no section wrapper or heading', () => {
58
+ const html = renderMarkup({ item: baseItem })
59
+ expect(html.match(/<h3/g)?.length).toBe(1)
60
+ expect(html).not.toContain('<section')
61
+ expect(html).not.toContain('<h2')
62
+ })
63
+ })
64
+
65
+ describe('ProductCard — optional fields are omitted, not emptied', () => {
66
+ it('renders without empty src, stars, or chip when optionals are absent', () => {
67
+ const html = renderMarkup({
68
+ item: { ...baseItem, image: undefined, imageAlt: undefined, rating: undefined, badge: undefined },
69
+ })
70
+ expect(html).not.toContain('src=""')
71
+ expect(html).not.toContain('4.5')
72
+ expect(html).not.toContain('Nuevo')
73
+ })
74
+
75
+ it('falls back to the title as the image alt text', () => {
76
+ const html = renderMarkup({ item: { ...baseItem, imageAlt: undefined } })
77
+ expect(html).toContain('alt="Llave de impacto"')
78
+ })
79
+ })
80
+
81
+ describe('ProductCard — missing price is unavailable, never zero', () => {
82
+ it('shows the unavailable label, disables add-to-cart, and never renders a zero', () => {
83
+ const html = renderMarkup({
84
+ item: { ...baseItem, price: { amount: '', currencyCode: '' }, compareAtPrice: undefined },
85
+ })
86
+ expect(html).toContain('Precio no disponible')
87
+ expect(html).not.toContain('$0')
88
+ expect(html).toContain('disabled')
89
+ })
90
+
91
+ it('treats a valid zero as a real price', () => {
92
+ const html = renderMarkup({
93
+ item: { ...baseItem, price: { amount: '0.00', currencyCode: 'USD' }, compareAtPrice: undefined },
94
+ })
95
+ expect(html).toContain('$0.00')
96
+ expect(html).not.toContain('Precio no disponible')
97
+ })
98
+ })
99
+
100
+ describe('ProductCard — no evidence is invented', () => {
101
+ it('shows no stars, chip, scarcity copy, or crossed-out price when none is supplied', () => {
102
+ const html = renderMarkup({
103
+ item: { id: 'p1', title: 'Producto 1', price: { amount: '10.00', currencyCode: 'EUR' } },
104
+ })
105
+ expect(html).not.toContain('line-through')
106
+ expect(html).not.toContain('Nuevo')
107
+ expect(html).not.toContain('¡Solo')
108
+ })
109
+ })
110
+
111
+ describe('ProductCard — compare-at only when valid and higher', () => {
112
+ it('renders a comparison price only when strictly higher', () => {
113
+ expect(renderMarkup({ item: baseItem })).toContain('159')
114
+ const equal = renderMarkup({
115
+ item: { ...baseItem, compareAtPrice: { amount: '129.50', currencyCode: 'EUR' } },
116
+ })
117
+ const lower = renderMarkup({
118
+ item: { ...baseItem, compareAtPrice: { amount: '99.00', currencyCode: 'EUR' } },
119
+ })
120
+ const invalid = renderMarkup({
121
+ item: { ...baseItem, compareAtPrice: { amount: '', currencyCode: '' } },
122
+ })
123
+ expect(equal).not.toContain('159')
124
+ expect(lower).not.toContain('99')
125
+ expect(invalid).not.toContain('line-through')
126
+ })
127
+ })
128
+
129
+ describe('ProductCard — bounded presentation surface', () => {
130
+ it('changes structure across presentations while keeping the same contract', () => {
131
+ const classic = renderMarkup({ item: baseItem })
132
+ const minimal = renderMarkup({ item: baseItem, presentation: 'minimal' })
133
+ const rich = renderMarkup({ item: baseItem, presentation: 'rich' })
134
+ for (const html of [classic, minimal, rich]) {
135
+ expect(html).toContain('129')
136
+ expect(html).toContain('Llave de impacto')
137
+ expect(html).toContain('Agregar al carrito')
138
+ }
139
+ expect(classic).toContain('>Agregar al carrito<')
140
+ expect(minimal).not.toContain('>Agregar al carrito<')
141
+ expect(minimal).toContain('aria-label="Agregar al carrito"')
142
+ expect(rich).toContain('aria-pressed')
143
+ expect(rich).toContain('>Agregar al carrito<')
144
+ })
145
+ })
146
+
147
+ describe('ProductCard — rich presentation degrades honestly', () => {
148
+ const heartTag = (html: string) => html.match(/<button[^>]*aria-pressed[^>]*>/)?.[0]
149
+ const isDisabled = (tag: string | undefined) => /\sdisabled(?:="")?(?=[\s>])/.test(tag ?? '')
150
+
151
+ it('reflects the wishlisted prop and disables the heart without wiring', () => {
152
+ const unwired = renderMarkup({ item: baseItem, presentation: 'rich' })
153
+ expect(unwired).toContain('aria-pressed="false"')
154
+ expect(isDisabled(heartTag(unwired))).toBe(true)
155
+ expect(unwired).not.toContain('Ver similares')
156
+ expect(renderMarkup({ item: baseItem, presentation: 'rich', wishlisted: true })).toContain(
157
+ 'aria-pressed="true"',
158
+ )
159
+ const wired = renderMarkup({ item: baseItem, presentation: 'rich', onToggleWishlist: () => {} })
160
+ expect(isDisabled(heartTag(wired))).toBe(false)
161
+ })
162
+ })
163
+
164
+ describe('ProductCard — labels are overridable', () => {
165
+ it('uses supplied labels for actions and availability copy', () => {
166
+ const html = renderMarkup({
167
+ item: { ...baseItem, price: { amount: '', currencyCode: '' } },
168
+ addToCartLabel: 'Comprar ahora',
169
+ unavailablePriceLabel: 'Consultar precio',
170
+ addToWishlistLabel: 'Guardar',
171
+ viewSimilarLabel: 'Ver parecidos',
172
+ presentation: 'rich',
173
+ onViewSimilar: () => {},
174
+ })
175
+ expect(html).toContain('Comprar ahora')
176
+ expect(html).toContain('Consultar precio')
177
+ expect(html).toContain('Guardar')
178
+ expect(html).toContain('Ver parecidos')
179
+ expect(html).not.toContain('Agregar al carrito')
180
+ })
181
+ })
182
+
183
+ describe('ProductCard — actions are reported, not owned', () => {
184
+ let container: HTMLDivElement
185
+ let root: Root
186
+ let mounted: Root | undefined
187
+
188
+ const mount = (props: ProductCardProps) => {
189
+ root = createRoot(container)
190
+ mounted = root
191
+ act(() => root.render(<ProductCard {...props} />))
192
+ }
193
+
194
+ const click = (element: Element | undefined | null) => {
195
+ expect(element).toBeDefined()
196
+ act(() => {
197
+ element!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
198
+ })
199
+ }
200
+
201
+ beforeEach(() => {
202
+ container = document.createElement('div')
203
+ document.body.appendChild(container)
204
+ })
205
+
206
+ afterEach(() => {
207
+ const root = mounted
208
+ if (root) act(() => root.unmount())
209
+ container.remove()
210
+ })
211
+
212
+ it('reports add-to-cart and product clicks with the item identity', () => {
213
+ const onAddToCart = vi.fn()
214
+ const onProductClick = vi.fn()
215
+ mount({ item: baseItem, onAddToCart, onProductClick })
216
+
217
+ const addToCart = Array.from(container.querySelectorAll('button')).find((b) =>
218
+ b.textContent?.includes('Agregar al carrito'),
219
+ )
220
+ click(addToCart)
221
+ expect(onAddToCart).toHaveBeenCalledWith(baseItem)
222
+
223
+ click(container.querySelector('h3 button'))
224
+ expect(onProductClick).toHaveBeenCalledWith(baseItem)
225
+ })
226
+
227
+ it('reports the wishlist toggle with the next state and reflects the prop', () => {
228
+ const onToggleWishlist = vi.fn()
229
+ mount({ item: baseItem, presentation: 'rich', wishlisted: false, onToggleWishlist })
230
+
231
+ const heart = () => container.querySelector('button[aria-pressed]')
232
+ expect(heart()!.getAttribute('aria-pressed')).toBe('false')
233
+ click(heart())
234
+ expect(onToggleWishlist).toHaveBeenCalledWith(baseItem, true)
235
+ expect(heart()!.getAttribute('aria-pressed')).toBe('false')
236
+ })
237
+
238
+ it('keeps an unwired heart disabled and inert', () => {
239
+ mount({ item: baseItem, presentation: 'rich', wishlisted: false })
240
+ const heart = () => container.querySelector('button[aria-pressed]') as HTMLButtonElement
241
+ expect(heart().disabled).toBe(true)
242
+ click(heart())
243
+ expect(heart().getAttribute('aria-pressed')).toBe('false')
244
+ })
245
+
246
+ it('reflects host-owned wishlist membership from the prop', () => {
247
+ const onToggleWishlist = vi.fn()
248
+ mount({ item: baseItem, presentation: 'rich', wishlisted: true, onToggleWishlist })
249
+
250
+ const heart = () => container.querySelector('button[aria-pressed]')
251
+ expect(heart()!.getAttribute('aria-pressed')).toBe('true')
252
+ click(heart())
253
+ expect(onToggleWishlist).toHaveBeenCalledWith(baseItem, false)
254
+ expect(heart()!.getAttribute('aria-pressed')).toBe('true')
255
+ })
256
+
257
+ it('reports view-similar through its callback and omits the affordance without it', () => {
258
+ const onViewSimilar = vi.fn()
259
+ mount({ item: baseItem, presentation: 'rich', onViewSimilar })
260
+ click(container.querySelector('button[aria-label="Ver similares"]'))
261
+ expect(onViewSimilar).toHaveBeenCalledWith(baseItem)
262
+ })
263
+ })
@@ -0,0 +1,424 @@
1
+ 'use client'
2
+
3
+ import { GalleryHorizontalEnd, Heart, ShoppingCart, Star } from 'lucide-react'
4
+ import { motion, useReducedMotion } from 'motion/react'
5
+
6
+ import { Badge } from '../../../ui/badge'
7
+ import { Button } from '../../../ui/button'
8
+ import { Card, CardContent, CardFooter } from '../../../ui/card'
9
+ import { cn } from '../../../lib/utils'
10
+ import { formatMoney, isAvailableMoney } from '../../../lib/money'
11
+ import type { ProductCardSpecProps, ProductPresentation } from '../../../lib/contracts'
12
+
13
+ /**
14
+ * The bounded presentation surface. `classic` is the boxed card with an
15
+ * add-to-cart footer, `minimal` is a lean image-forward tile, and `rich` adds
16
+ * overlay actions (wishlist heart, view similar). The value changes structure,
17
+ * never the data contract or the callbacks.
18
+ */
19
+ export type ProductCardPresentation = NonNullable<ProductCardSpecProps['presentation']>
20
+
21
+ export interface ProductCardProps {
22
+ /** The single canonical product presentation item (canonical `Money`). */
23
+ item: ProductPresentation
24
+ /** Bounded presentation option; defaults to `classic`. */
25
+ presentation?: ProductCardPresentation
26
+ /** Host-resolved wishlist membership; reflects the rich presentation heart. */
27
+ wishlisted?: boolean
28
+ onAddToCart?: (item: ProductPresentation) => void
29
+ onProductClick?: (item: ProductPresentation) => void
30
+ /**
31
+ * Host-owned wishlist toggle; reports the item and the next state from the
32
+ * click handler (never from an effect).
33
+ */
34
+ onToggleWishlist?: (item: ProductPresentation, nextState: boolean) => void
35
+ onViewSimilar?: (item: ProductPresentation) => void
36
+ addToCartLabel?: string
37
+ /** Shown when a price is present but unavailable (never a zero amount). */
38
+ unavailablePriceLabel?: string
39
+ addToWishlistLabel?: string
40
+ removeFromWishlistLabel?: string
41
+ viewSimilarLabel?: string
42
+ }
43
+
44
+ /** What the presentations receive: every label already resolved by `ProductCard`. */
45
+ type PresentationProps = ProductCardProps & {
46
+ addToCartLabel: string
47
+ unavailablePriceLabel: string
48
+ addToWishlistLabel: string
49
+ removeFromWishlistLabel: string
50
+ viewSimilarLabel: string
51
+ }
52
+
53
+ /** Comparison price only when both values are valid and the compare is higher. */
54
+ function compareAtText(item: ProductPresentation): string | undefined {
55
+ const compareAt = item.compareAtPrice
56
+ if (!compareAt) return undefined
57
+ const formatted = formatMoney(compareAt)
58
+ const price = formatMoney(item.price)
59
+ if (!formatted || !price) return undefined
60
+ if (Number(compareAt.amount) <= Number(item.price.amount)) return undefined
61
+ return formatted
62
+ }
63
+
64
+ function ratingValue(item: ProductPresentation): number | undefined {
65
+ return typeof item.rating === 'number' && Number.isFinite(item.rating) ? item.rating : undefined
66
+ }
67
+
68
+ function PriceRow({
69
+ item,
70
+ unavailablePriceLabel,
71
+ }: {
72
+ item: ProductPresentation
73
+ unavailablePriceLabel: string
74
+ }) {
75
+ const available = isAvailableMoney(item.price)
76
+ const compareAt = compareAtText(item)
77
+ return (
78
+ <div className='mt-auto flex items-baseline gap-2'>
79
+ <p className='font-semibold'>
80
+ {available ? formatMoney(item.price) : unavailablePriceLabel}
81
+ </p>
82
+ {compareAt && (
83
+ <p className='text-muted-foreground text-sm line-through md:text-base xl:text-sm 2xl:text-base'>
84
+ {compareAt}
85
+ </p>
86
+ )}
87
+ </div>
88
+ )
89
+ }
90
+
91
+ function RatingRow({ value }: { value: number }) {
92
+ return (
93
+ <div className='text-muted-foreground flex items-center gap-1 text-xs'>
94
+ <Star className='size-3.5 fill-current' aria-hidden='true' />
95
+ <span>{value.toFixed(1)}</span>
96
+ </div>
97
+ )
98
+ }
99
+
100
+ function ClassicPresentation({
101
+ item,
102
+ onAddToCart,
103
+ onProductClick,
104
+ addToCartLabel,
105
+ unavailablePriceLabel,
106
+ }: PresentationProps) {
107
+ const reduceMotion = useReducedMotion()
108
+ const available = isAvailableMoney(item.price)
109
+ const rating = ratingValue(item)
110
+
111
+ return (
112
+ <motion.div
113
+ initial={reduceMotion ? false : { opacity: 0, y: 16 }}
114
+ whileInView={{ opacity: 1, y: 0 }}
115
+ viewport={{ once: true, margin: '-40px' }}
116
+ whileHover={reduceMotion ? undefined : { y: -4 }}
117
+ transition={{ duration: 0.35, ease: 'easeOut' }}
118
+ >
119
+ <Card
120
+ className={`flex h-full flex-col gap-4 overflow-hidden rounded-lg py-4 shadow-none transition-shadow duration-300 hover:shadow-md ${
121
+ onProductClick ? 'cursor-pointer' : ''
122
+ }`}
123
+ onClick={onProductClick ? () => onProductClick(item) : undefined}
124
+ >
125
+ <CardContent className='flex flex-1 flex-col gap-4 px-4 '>
126
+ <div className='relative aspect-square overflow-hidden rounded-md'>
127
+ {item.badge && (
128
+ <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'>
129
+ {item.badge}
130
+ </span>
131
+ )}
132
+ {item.image ? (
133
+ <img
134
+ src={item.image}
135
+ alt={item.imageAlt ?? item.title}
136
+ className='size-full rounded-md object-contain'
137
+ loading='lazy'
138
+ width={400}
139
+ height={400}
140
+ />
141
+ ) : null}
142
+ </div>
143
+
144
+ <div className='flex flex-1 flex-col'>
145
+ <h3 className='mb-1 font-medium text-balance'>
146
+ {onProductClick ? (
147
+ <button
148
+ type='button'
149
+ onClick={(e) => {
150
+ e.stopPropagation()
151
+ onProductClick(item)
152
+ }}
153
+ className='hover:text-primary cursor-pointer text-left transition-colors'
154
+ >
155
+ {item.title}
156
+ </button>
157
+ ) : (
158
+ item.title
159
+ )}
160
+ </h3>
161
+ {rating !== undefined && <RatingRow value={rating} />}
162
+ <PriceRow item={item} unavailablePriceLabel={unavailablePriceLabel} />
163
+ </div>
164
+ </CardContent>
165
+
166
+ <CardFooter className='bg-transparent px-3 pt-0 border-t-0 md:px-4'>
167
+ <Button
168
+ variant='outline'
169
+ size='sm'
170
+ className='h-8 w-full cursor-pointer px-3 text-sm text-xs'
171
+ disabled={!available}
172
+ onClick={(e) => {
173
+ e.stopPropagation()
174
+ onAddToCart?.(item)
175
+ }}
176
+ >
177
+ {addToCartLabel}
178
+ </Button>
179
+ </CardFooter>
180
+ </Card>
181
+ </motion.div>
182
+ )
183
+ }
184
+
185
+ function MinimalPresentation({
186
+ item,
187
+ onAddToCart,
188
+ onProductClick,
189
+ addToCartLabel,
190
+ unavailablePriceLabel,
191
+ }: PresentationProps) {
192
+ const available = isAvailableMoney(item.price)
193
+ const rating = ratingValue(item)
194
+
195
+ return (
196
+ <Card
197
+ className={cn(
198
+ 'group overflow-hidden py-4 shadow-none transition-all hover:shadow-lg',
199
+ onProductClick && 'cursor-pointer',
200
+ )}
201
+ onClick={onProductClick ? () => onProductClick(item) : undefined}
202
+ >
203
+ <CardContent className='flex flex-col gap-4 px-4'>
204
+ <div className='relative aspect-square overflow-hidden rounded-md'>
205
+ {item.badge && (
206
+ <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'>
207
+ {item.badge}
208
+ </span>
209
+ )}
210
+ {item.image ? (
211
+ <img
212
+ src={item.image}
213
+ alt={item.imageAlt ?? item.title}
214
+ width={400}
215
+ height={400}
216
+ className='size-full object-cover transition-transform duration-300 group-hover:scale-105'
217
+ loading='lazy'
218
+ />
219
+ ) : null}
220
+ </div>
221
+
222
+ <div className='flex flex-col gap-2'>
223
+ <h3 className='line-clamp-1 text-lg font-semibold text-balance'>{item.title}</h3>
224
+ {rating !== undefined && <RatingRow value={rating} />}
225
+ <div className='flex items-center justify-between gap-2'>
226
+ <PriceRow item={item} unavailablePriceLabel={unavailablePriceLabel} />
227
+ <Button
228
+ variant='ghost'
229
+ size='icon'
230
+ className='size-8 shrink-0 cursor-pointer'
231
+ aria-label={addToCartLabel}
232
+ disabled={!available}
233
+ onClick={(e) => {
234
+ e.stopPropagation()
235
+ onAddToCart?.(item)
236
+ }}
237
+ >
238
+ <ShoppingCart className='size-4' aria-hidden='true' />
239
+ </Button>
240
+ </div>
241
+ </div>
242
+ </CardContent>
243
+ </Card>
244
+ )
245
+ }
246
+
247
+ function WishlistHeart({
248
+ item,
249
+ wishlisted,
250
+ onToggleWishlist,
251
+ addToWishlistLabel,
252
+ removeFromWishlistLabel,
253
+ }: {
254
+ item: ProductPresentation
255
+ wishlisted: boolean
256
+ onToggleWishlist?: (item: ProductPresentation, nextState: boolean) => void
257
+ addToWishlistLabel: string
258
+ removeFromWishlistLabel: string
259
+ }) {
260
+ return (
261
+ <Button
262
+ variant='ghost'
263
+ size='icon'
264
+ className='bg-background/80 absolute end-2 top-2 z-20 size-8 cursor-pointer rounded-full p-0 backdrop-blur-xs disabled:cursor-not-allowed'
265
+ aria-label={wishlisted ? removeFromWishlistLabel : addToWishlistLabel}
266
+ aria-pressed={wishlisted}
267
+ disabled={!onToggleWishlist}
268
+ onClick={(e) => {
269
+ e.stopPropagation()
270
+ onToggleWishlist?.(item, !wishlisted)
271
+ }}
272
+ >
273
+ <Heart
274
+ className={cn(
275
+ 'size-4 transition-colors',
276
+ wishlisted ? 'fill-destructive text-destructive' : 'fill-transparent text-muted-foreground',
277
+ )}
278
+ aria-hidden='true'
279
+ />
280
+ </Button>
281
+ )
282
+ }
283
+
284
+ function RichPresentation({
285
+ item,
286
+ wishlisted = false,
287
+ onAddToCart,
288
+ onProductClick,
289
+ onToggleWishlist,
290
+ onViewSimilar,
291
+ addToCartLabel,
292
+ unavailablePriceLabel,
293
+ addToWishlistLabel,
294
+ removeFromWishlistLabel,
295
+ viewSimilarLabel,
296
+ }: PresentationProps) {
297
+ const available = isAvailableMoney(item.price)
298
+ const rating = ratingValue(item)
299
+
300
+ return (
301
+ <Card
302
+ className={cn(
303
+ 'relative overflow-hidden rounded-md py-0 shadow-none transition-all hover:shadow-md',
304
+ onProductClick && 'cursor-pointer',
305
+ )}
306
+ onClick={onProductClick ? () => onProductClick(item) : undefined}
307
+ >
308
+ <CardContent className='px-0'>
309
+ <div className='relative aspect-square w-full overflow-hidden'>
310
+ {item.image ? (
311
+ <img
312
+ src={item.image}
313
+ alt={item.imageAlt ?? item.title}
314
+ className='size-full object-cover'
315
+ loading='lazy'
316
+ />
317
+ ) : null}
318
+
319
+ {item.badge && (
320
+ <Badge className='absolute start-2 top-2 z-20 flex items-center gap-1 px-2 py-1 font-semibold text-white backdrop-blur-xs'>
321
+ {item.badge}
322
+ </Badge>
323
+ )}
324
+
325
+ {rating !== undefined && (
326
+ <div className='bg-background/80 absolute start-2 bottom-2 z-10 flex items-center gap-1 rounded-md border px-2 py-1 text-sm backdrop-blur-xs'>
327
+ <Star className='fill-foreground text-foreground size-4' aria-hidden='true' />
328
+ <span>{rating.toFixed(1)}</span>
329
+ </div>
330
+ )}
331
+
332
+ {onViewSimilar && (
333
+ <div className='group absolute end-2 bottom-2 z-10'>
334
+ <Button
335
+ variant='ghost'
336
+ size='sm'
337
+ className='group bg-background/80 hover:bg-background h-8 cursor-pointer overflow-hidden rounded-full p-0 px-3 text-xs shadow-xs backdrop-blur-xs transition-all duration-200'
338
+ aria-label={viewSimilarLabel}
339
+ onClick={(e) => {
340
+ e.stopPropagation()
341
+ onViewSimilar(item)
342
+ }}
343
+ >
344
+ <GalleryHorizontalEnd className='absolute start-2 size-4 shrink-0' aria-hidden='true' />
345
+ <span className='ms-6 whitespace-nowrap text-xs opacity-0 group-hover:opacity-100'>
346
+ {viewSimilarLabel}
347
+ </span>
348
+ </Button>
349
+ </div>
350
+ )}
351
+
352
+ <WishlistHeart
353
+ item={item}
354
+ wishlisted={wishlisted}
355
+ onToggleWishlist={onToggleWishlist}
356
+ addToWishlistLabel={addToWishlistLabel}
357
+ removeFromWishlistLabel={removeFromWishlistLabel}
358
+ />
359
+ </div>
360
+
361
+ <div className='flex flex-col gap-2 p-4'>
362
+ <h3 className='font-medium text-balance'>{item.title}</h3>
363
+ <PriceRow item={item} unavailablePriceLabel={unavailablePriceLabel} />
364
+ <Button
365
+ variant='outline'
366
+ size='sm'
367
+ className='mt-2 w-full cursor-pointer text-xs'
368
+ disabled={!available}
369
+ onClick={(e) => {
370
+ e.stopPropagation()
371
+ onAddToCart?.(item)
372
+ }}
373
+ >
374
+ {addToCartLabel}
375
+ </Button>
376
+ </div>
377
+ </CardContent>
378
+ </Card>
379
+ )
380
+ }
381
+
382
+ /**
383
+ * Canonical product card — the item cell of a product listing. Renders only
384
+ * the canonical `ProductPresentation` contract (canonical `Money`) with
385
+ * host-supplied evidence (rating, badge, compare-at); it owns no listing state
386
+ * and reports every action through its callbacks. Prices render through the
387
+ * canonical `formatMoney` path, so a missing price is an unavailable state,
388
+ * never a zero amount.
389
+ */
390
+ export function ProductCard({
391
+ item,
392
+ presentation = 'classic',
393
+ wishlisted,
394
+ onAddToCart,
395
+ onProductClick,
396
+ onToggleWishlist,
397
+ onViewSimilar,
398
+ addToCartLabel = 'Agregar al carrito',
399
+ unavailablePriceLabel = 'Precio no disponible',
400
+ addToWishlistLabel = 'Añadir a favoritos',
401
+ removeFromWishlistLabel = 'Quitar de favoritos',
402
+ viewSimilarLabel = 'Ver similares',
403
+ }: ProductCardProps) {
404
+ const shared: PresentationProps = {
405
+ item,
406
+ presentation,
407
+ wishlisted,
408
+ onAddToCart,
409
+ onProductClick,
410
+ onToggleWishlist,
411
+ onViewSimilar,
412
+ addToCartLabel,
413
+ unavailablePriceLabel,
414
+ addToWishlistLabel,
415
+ removeFromWishlistLabel,
416
+ viewSimilarLabel,
417
+ }
418
+
419
+ if (presentation === 'minimal') return <MinimalPresentation {...shared} />
420
+ if (presentation === 'rich') return <RichPresentation {...shared} />
421
+ return <ClassicPresentation {...shared} />
422
+ }
423
+
424
+ export default ProductCard