@thorprovider/blocks 0.1.3 → 2.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 (178) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/README.md +228 -68
  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/application/login-forms/login-page.test.tsx +190 -0
  7. package/src/blocks/application/login-forms/login-page.tsx +240 -0
  8. package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +6 -8
  9. package/src/blocks/application/signup-forms/signup-page-1.tsx +127 -129
  10. package/src/blocks/application/verification/verify-1.tsx +2 -2
  11. package/src/blocks/application/viewport-ownership.test.tsx +41 -0
  12. package/src/blocks/e-commerce/category-filters/category-listing-filter-fields.tsx +218 -0
  13. package/src/blocks/e-commerce/category-filters/{category-filter-2.test.tsx → category-listing.test.tsx} +287 -199
  14. package/src/blocks/e-commerce/category-filters/category-listing.tsx +934 -0
  15. package/src/blocks/e-commerce/checkout-forms/checkout-contact-step.tsx +149 -0
  16. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-country-field.tsx → checkout-country-field.tsx} +10 -8
  17. package/src/blocks/e-commerce/checkout-forms/checkout-order-summary.tsx +230 -0
  18. package/src/blocks/e-commerce/checkout-forms/checkout-payment-step.tsx +100 -0
  19. package/src/blocks/e-commerce/checkout-forms/checkout-progress.tsx +62 -0
  20. package/src/blocks/e-commerce/checkout-forms/checkout-review-step.tsx +59 -0
  21. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-shipping-step.tsx → checkout-shipping-step.tsx} +65 -41
  22. package/src/blocks/e-commerce/checkout-forms/checkout.test.tsx +478 -0
  23. package/src/blocks/e-commerce/checkout-forms/checkout.tsx +550 -0
  24. package/src/blocks/e-commerce/navigation/storefront-navbar-account-menu.test.tsx +56 -0
  25. package/src/blocks/{marketing/navbars/navbar-4-account-menu.tsx → e-commerce/navigation/storefront-navbar-account-menu.tsx} +3 -3
  26. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.test.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.test.tsx} +22 -22
  27. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.tsx} +3 -3
  28. package/src/blocks/{marketing/navbars/navbar-4-mobile-nav.tsx → e-commerce/navigation/storefront-navbar-mobile-nav.tsx} +4 -12
  29. package/src/blocks/{marketing/navbars/navbar-4-search-bar.tsx → e-commerce/navigation/storefront-navbar-search-bar.tsx} +3 -3
  30. package/src/blocks/{marketing/navbars/navbar-4.test.tsx → e-commerce/navigation/storefront-navbar.test.tsx} +67 -36
  31. package/src/blocks/{marketing/navbars/navbar-4.tsx → e-commerce/navigation/storefront-navbar.tsx} +43 -25
  32. package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
  33. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  34. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  35. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  36. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  37. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  38. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  39. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  40. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  41. package/src/blocks/e-commerce/product-list/product-card.tsx +447 -0
  42. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  43. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  44. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  45. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  46. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  47. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  48. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  49. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  50. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  51. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  52. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +119 -85
  53. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  54. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  55. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  56. package/src/blocks/marketing/features/feature.tsx +195 -0
  57. package/src/blocks/marketing/footers/footer-section-accessibility.test.tsx +118 -0
  58. package/src/blocks/marketing/footers/footer-section-actions.test.tsx +131 -0
  59. package/src/blocks/marketing/footers/footer-section-data.test.tsx +127 -0
  60. package/src/blocks/marketing/footers/footer-section-native-links.test.tsx +100 -0
  61. package/src/blocks/marketing/footers/footer-section-public-api.test.tsx +11 -0
  62. package/src/blocks/marketing/footers/footer-section.test.tsx +46 -0
  63. package/src/blocks/marketing/footers/footer-section.tsx +532 -0
  64. package/src/blocks/marketing/navbars/navbar-public-api.test.tsx +57 -0
  65. package/src/blocks/marketing/navbars/navbar.mega.test.tsx +149 -0
  66. package/src/blocks/marketing/navbars/navbar.test.tsx +155 -0
  67. package/src/blocks/marketing/navbars/navbar.tsx +735 -0
  68. package/src/blocks/marketing/testimonials/testimonial-section.test.tsx +171 -0
  69. package/src/blocks/marketing/testimonials/testimonial-section.tsx +237 -0
  70. package/src/index.ts +156 -128
  71. package/src/lib/contracts-curated.test.ts +47 -29
  72. package/src/lib/contracts.test.ts +198 -2
  73. package/src/lib/contracts.ts +410 -16
  74. package/src/ui/button-group.tsx +1 -1
  75. package/src/ui/carousel.tsx +1 -1
  76. package/src/ui/dialog.tsx +1 -1
  77. package/src/ui/field.tsx +2 -2
  78. package/src/ui/input-group.tsx +3 -3
  79. package/src/ui/pagination.tsx +1 -1
  80. package/src/ui/sidebar.tsx +7 -7
  81. package/src/ui/toggle-group.tsx +1 -1
  82. package/tsconfig.build.json +12 -0
  83. package/tsup.config.ts +11 -7
  84. package/.turbo/turbo-build.log +0 -24
  85. package/.turbo/turbo-test.log +0 -47
  86. package/.turbo/turbo-type-check.log +0 -0
  87. package/dist/.tsbuildinfo +0 -1
  88. package/dist/contracts.d.mts +0 -1732
  89. package/dist/contracts.d.ts +0 -1732
  90. package/dist/contracts.js +0 -423
  91. package/dist/contracts.js.map +0 -1
  92. package/dist/contracts.mjs +0 -365
  93. package/dist/contracts.mjs.map +0 -1
  94. package/dist/index.d.mts +0 -3068
  95. package/dist/index.d.ts +0 -3068
  96. package/dist/index.js +0 -17834
  97. package/dist/index.js.map +0 -1
  98. package/dist/index.mjs +0 -17740
  99. package/dist/index.mjs.map +0 -1
  100. package/src/blocks/application/login-forms/login-page-1.tsx +0 -246
  101. package/src/blocks/application/login-forms/login-page-2.tsx +0 -207
  102. package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +0 -260
  103. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +0 -222
  104. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +0 -921
  105. package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +0 -57
  106. package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +0 -124
  107. package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +0 -170
  108. package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +0 -52
  109. package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +0 -57
  110. package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +0 -68
  111. package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +0 -298
  112. package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +0 -47
  113. package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +0 -9
  114. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +0 -96
  115. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +0 -252
  116. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +0 -188
  117. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +0 -36
  118. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +0 -216
  119. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +0 -338
  120. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +0 -130
  121. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +0 -175
  122. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +0 -190
  123. package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +0 -312
  124. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +0 -91
  125. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +0 -110
  126. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +0 -137
  127. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +0 -68
  128. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +0 -79
  129. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +0 -172
  130. package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +0 -392
  131. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  132. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  133. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  134. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  135. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  136. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  137. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +0 -88
  138. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -192
  139. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  140. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  141. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  142. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  143. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  144. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  145. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  146. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  147. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  148. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  149. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  150. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  151. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  152. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  153. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  154. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  155. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  156. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  157. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  158. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  159. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -229
  160. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -72
  161. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  162. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  163. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  164. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  165. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  166. package/src/blocks/marketing/features/feature-2.tsx +0 -82
  167. package/src/blocks/marketing/footers/footer-section-1.test.tsx +0 -117
  168. package/src/blocks/marketing/footers/footer-section-1.tsx +0 -178
  169. package/src/blocks/marketing/footers/footer-section-3.tsx +0 -192
  170. package/src/blocks/marketing/footers/footer-section-4.tsx +0 -355
  171. package/src/blocks/marketing/navbars/navbar-1.tsx +0 -267
  172. package/src/blocks/marketing/navbars/navbar-2.tsx +0 -351
  173. package/src/blocks/marketing/navbars/navbar-3.tsx +0 -303
  174. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +0 -38
  175. package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +0 -177
  176. package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +0 -219
  177. package/src/blocks/marketing/testimonials/testimonial-1.tsx +0 -141
  178. package/src/blocks/marketing/testimonials/testimonial-2.tsx +0 -108
@@ -0,0 +1,447 @@
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
+ /**
30
+ * Hides the add-to-cart control for listings whose only action is opening the
31
+ * product. Without this a host with no cart callback would render an inert
32
+ * button.
33
+ */
34
+ hideAddToCart?: boolean
35
+ onProductClick?: (item: ProductPresentation) => void
36
+ /**
37
+ * Host-owned wishlist toggle; reports the item and the next state from the
38
+ * click handler (never from an effect).
39
+ */
40
+ onToggleWishlist?: (item: ProductPresentation, nextState: boolean) => void
41
+ onViewSimilar?: (item: ProductPresentation) => void
42
+ addToCartLabel?: string
43
+ /** Shown when a price is present but unavailable (never a zero amount). */
44
+ unavailablePriceLabel?: string
45
+ addToWishlistLabel?: string
46
+ removeFromWishlistLabel?: string
47
+ viewSimilarLabel?: string
48
+ }
49
+
50
+ /** What the presentations receive: every label already resolved by `ProductCard`. */
51
+ type PresentationProps = ProductCardProps & {
52
+ addToCartLabel: string
53
+ unavailablePriceLabel: string
54
+ addToWishlistLabel: string
55
+ removeFromWishlistLabel: string
56
+ viewSimilarLabel: string
57
+ }
58
+
59
+ /** Comparison price only when both values are valid and the compare is higher. */
60
+ function compareAtText(item: ProductPresentation): string | undefined {
61
+ const compareAt = item.compareAtPrice
62
+ if (!compareAt) return undefined
63
+ const formatted = formatMoney(compareAt)
64
+ const price = formatMoney(item.price)
65
+ if (!formatted || !price) return undefined
66
+ if (Number(compareAt.amount) <= Number(item.price.amount)) return undefined
67
+ return formatted
68
+ }
69
+
70
+ function ratingValue(item: ProductPresentation): number | undefined {
71
+ return typeof item.rating === 'number' && Number.isFinite(item.rating) ? item.rating : undefined
72
+ }
73
+
74
+ function PriceRow({
75
+ item,
76
+ unavailablePriceLabel,
77
+ }: {
78
+ item: ProductPresentation
79
+ unavailablePriceLabel: string
80
+ }) {
81
+ const available = isAvailableMoney(item.price)
82
+ const compareAt = compareAtText(item)
83
+ return (
84
+ <div className='mt-auto flex items-baseline gap-2'>
85
+ <p className='font-semibold'>
86
+ {available ? formatMoney(item.price) : unavailablePriceLabel}
87
+ </p>
88
+ {compareAt && (
89
+ <p className='text-muted-foreground text-sm line-through md:text-base xl:text-sm 2xl:text-base'>
90
+ {compareAt}
91
+ </p>
92
+ )}
93
+ </div>
94
+ )
95
+ }
96
+
97
+ function RatingRow({ value }: { value: number }) {
98
+ return (
99
+ <div className='text-muted-foreground flex items-center gap-1 text-xs'>
100
+ <Star className='size-3.5 fill-current' aria-hidden='true' />
101
+ <span>{value.toFixed(1)}</span>
102
+ </div>
103
+ )
104
+ }
105
+
106
+ function ClassicPresentation({
107
+ item,
108
+ hideAddToCart = false,
109
+ onAddToCart,
110
+ onProductClick,
111
+ addToCartLabel,
112
+ unavailablePriceLabel,
113
+ }: PresentationProps) {
114
+ const reduceMotion = useReducedMotion()
115
+ const available = isAvailableMoney(item.price)
116
+ const rating = ratingValue(item)
117
+
118
+ return (
119
+ <motion.div
120
+ initial={reduceMotion ? false : { opacity: 0, y: 16 }}
121
+ whileInView={{ opacity: 1, y: 0 }}
122
+ viewport={{ once: true, margin: '-40px' }}
123
+ whileHover={reduceMotion ? undefined : { y: -4 }}
124
+ transition={{ duration: 0.35, ease: 'easeOut' }}
125
+ >
126
+ <Card
127
+ className={`flex h-full flex-col gap-4 overflow-hidden rounded-lg py-4 shadow-none transition-shadow duration-300 hover:shadow-md ${
128
+ onProductClick ? 'cursor-pointer' : ''
129
+ }`}
130
+ onClick={onProductClick ? () => onProductClick(item) : undefined}
131
+ >
132
+ <CardContent className='flex flex-1 flex-col gap-4 px-4 '>
133
+ <div className='relative aspect-square overflow-hidden rounded-md'>
134
+ {item.badge && (
135
+ <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'>
136
+ {item.badge}
137
+ </span>
138
+ )}
139
+ {item.image ? (
140
+ <img
141
+ src={item.image}
142
+ alt={item.imageAlt ?? item.title}
143
+ className='size-full rounded-md object-contain'
144
+ loading='lazy'
145
+ width={400}
146
+ height={400}
147
+ />
148
+ ) : (
149
+ <div data-slot='product-image-placeholder' aria-hidden='true' className='bg-muted size-full rounded-md' />
150
+ )}
151
+ </div>
152
+
153
+ <div className='flex flex-1 flex-col'>
154
+ <h3 className='mb-1 font-medium text-balance'>
155
+ {onProductClick ? (
156
+ <button
157
+ type='button'
158
+ onClick={(e) => {
159
+ e.stopPropagation()
160
+ onProductClick(item)
161
+ }}
162
+ className='hover:text-primary cursor-pointer text-left transition-colors'
163
+ >
164
+ {item.title}
165
+ </button>
166
+ ) : (
167
+ item.title
168
+ )}
169
+ </h3>
170
+ {rating !== undefined && <RatingRow value={rating} />}
171
+ <PriceRow item={item} unavailablePriceLabel={unavailablePriceLabel} />
172
+ </div>
173
+ </CardContent>
174
+
175
+ <CardFooter className='bg-transparent px-3 pt-0 border-t-0 md:px-4'>
176
+ {hideAddToCart ? null : (
177
+ <Button
178
+ variant='outline'
179
+ size='sm'
180
+ className='h-8 w-full cursor-pointer px-3 text-sm text-xs'
181
+ disabled={!available}
182
+ onClick={(e) => {
183
+ e.stopPropagation()
184
+ onAddToCart?.(item)
185
+ }}
186
+ >
187
+ {addToCartLabel}
188
+ </Button>
189
+ )}
190
+ </CardFooter>
191
+ </Card>
192
+ </motion.div>
193
+ )
194
+ }
195
+
196
+ function MinimalPresentation({
197
+ item,
198
+ hideAddToCart = false,
199
+ onAddToCart,
200
+ onProductClick,
201
+ addToCartLabel,
202
+ unavailablePriceLabel,
203
+ }: PresentationProps) {
204
+ const available = isAvailableMoney(item.price)
205
+ const rating = ratingValue(item)
206
+
207
+ return (
208
+ <Card
209
+ className={cn(
210
+ 'group overflow-hidden py-4 shadow-none transition-all hover:shadow-lg',
211
+ onProductClick && 'cursor-pointer',
212
+ )}
213
+ onClick={onProductClick ? () => onProductClick(item) : undefined}
214
+ >
215
+ <CardContent className='flex flex-col gap-4 px-4'>
216
+ <div className='relative aspect-square overflow-hidden rounded-md'>
217
+ {item.badge && (
218
+ <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'>
219
+ {item.badge}
220
+ </span>
221
+ )}
222
+ {item.image ? (
223
+ <img
224
+ src={item.image}
225
+ alt={item.imageAlt ?? item.title}
226
+ width={400}
227
+ height={400}
228
+ className='size-full object-cover transition-transform duration-300 group-hover:scale-105'
229
+ loading='lazy'
230
+ />
231
+ ) : (
232
+ <div data-slot='product-image-placeholder' aria-hidden='true' className='bg-muted size-full' />
233
+ )}
234
+ </div>
235
+
236
+ <div className='flex flex-col gap-2'>
237
+ <h3 className='line-clamp-1 text-lg font-semibold text-balance'>{item.title}</h3>
238
+ {rating !== undefined && <RatingRow value={rating} />}
239
+ <div className='flex items-center justify-between gap-2'>
240
+ <PriceRow item={item} unavailablePriceLabel={unavailablePriceLabel} />
241
+ {hideAddToCart ? null : (
242
+ <Button
243
+ variant='ghost'
244
+ size='icon'
245
+ className='size-8 shrink-0 cursor-pointer'
246
+ aria-label={addToCartLabel}
247
+ disabled={!available}
248
+ onClick={(e) => {
249
+ e.stopPropagation()
250
+ onAddToCart?.(item)
251
+ }}
252
+ >
253
+ <ShoppingCart className='size-4' aria-hidden='true' />
254
+ </Button>
255
+ )}
256
+ </div>
257
+ </div>
258
+ </CardContent>
259
+ </Card>
260
+ )
261
+ }
262
+
263
+ function WishlistHeart({
264
+ item,
265
+ wishlisted,
266
+ onToggleWishlist,
267
+ addToWishlistLabel,
268
+ removeFromWishlistLabel,
269
+ }: {
270
+ item: ProductPresentation
271
+ wishlisted: boolean
272
+ onToggleWishlist?: (item: ProductPresentation, nextState: boolean) => void
273
+ addToWishlistLabel: string
274
+ removeFromWishlistLabel: string
275
+ }) {
276
+ return (
277
+ <Button
278
+ variant='ghost'
279
+ size='icon'
280
+ 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'
281
+ aria-label={wishlisted ? removeFromWishlistLabel : addToWishlistLabel}
282
+ aria-pressed={wishlisted}
283
+ disabled={!onToggleWishlist}
284
+ onClick={(e) => {
285
+ e.stopPropagation()
286
+ onToggleWishlist?.(item, !wishlisted)
287
+ }}
288
+ >
289
+ <Heart
290
+ className={cn(
291
+ 'size-4 transition-colors',
292
+ wishlisted ? 'fill-destructive text-destructive' : 'fill-transparent text-muted-foreground',
293
+ )}
294
+ aria-hidden='true'
295
+ />
296
+ </Button>
297
+ )
298
+ }
299
+
300
+ function RichPresentation({
301
+ item,
302
+ hideAddToCart = false,
303
+ wishlisted = false,
304
+ onAddToCart,
305
+ onProductClick,
306
+ onToggleWishlist,
307
+ onViewSimilar,
308
+ addToCartLabel,
309
+ unavailablePriceLabel,
310
+ addToWishlistLabel,
311
+ removeFromWishlistLabel,
312
+ viewSimilarLabel,
313
+ }: PresentationProps) {
314
+ const available = isAvailableMoney(item.price)
315
+ const rating = ratingValue(item)
316
+
317
+ return (
318
+ <Card
319
+ className={cn(
320
+ 'relative overflow-hidden rounded-md py-0 shadow-none transition-all hover:shadow-md',
321
+ onProductClick && 'cursor-pointer',
322
+ )}
323
+ onClick={onProductClick ? () => onProductClick(item) : undefined}
324
+ >
325
+ <CardContent className='px-0'>
326
+ <div className='relative aspect-square w-full overflow-hidden'>
327
+ {item.image ? (
328
+ <img
329
+ src={item.image}
330
+ alt={item.imageAlt ?? item.title}
331
+ className='size-full object-cover'
332
+ loading='lazy'
333
+ />
334
+ ) : (
335
+ <div data-slot='product-image-placeholder' aria-hidden='true' className='bg-muted size-full' />
336
+ )}
337
+
338
+ {item.badge && (
339
+ <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'>
340
+ {item.badge}
341
+ </Badge>
342
+ )}
343
+
344
+ {rating !== undefined && (
345
+ <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'>
346
+ <Star className='fill-foreground text-foreground size-4' aria-hidden='true' />
347
+ <span>{rating.toFixed(1)}</span>
348
+ </div>
349
+ )}
350
+
351
+ {onViewSimilar && (
352
+ <div className='group absolute end-2 bottom-2 z-10'>
353
+ <Button
354
+ variant='ghost'
355
+ size='sm'
356
+ 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'
357
+ aria-label={viewSimilarLabel}
358
+ onClick={(e) => {
359
+ e.stopPropagation()
360
+ onViewSimilar(item)
361
+ }}
362
+ >
363
+ <GalleryHorizontalEnd className='absolute start-2 size-4 shrink-0' aria-hidden='true' />
364
+ <span className='ms-6 whitespace-nowrap text-xs opacity-0 group-hover:opacity-100'>
365
+ {viewSimilarLabel}
366
+ </span>
367
+ </Button>
368
+ </div>
369
+ )}
370
+
371
+ <WishlistHeart
372
+ item={item}
373
+ wishlisted={wishlisted}
374
+ onToggleWishlist={onToggleWishlist}
375
+ addToWishlistLabel={addToWishlistLabel}
376
+ removeFromWishlistLabel={removeFromWishlistLabel}
377
+ />
378
+ </div>
379
+
380
+ <div className='flex flex-col gap-2 p-4'>
381
+ <h3 className='font-medium text-balance'>{item.title}</h3>
382
+ <PriceRow item={item} unavailablePriceLabel={unavailablePriceLabel} />
383
+ {hideAddToCart ? null : (
384
+ <Button
385
+ variant='outline'
386
+ size='sm'
387
+ className='mt-2 w-full cursor-pointer text-xs'
388
+ disabled={!available}
389
+ onClick={(e) => {
390
+ e.stopPropagation()
391
+ onAddToCart?.(item)
392
+ }}
393
+ >
394
+ {addToCartLabel}
395
+ </Button>
396
+ )}
397
+ </div>
398
+ </CardContent>
399
+ </Card>
400
+ )
401
+ }
402
+
403
+ /**
404
+ * Canonical product card — the item cell of a product listing. Renders only
405
+ * the canonical `ProductPresentation` contract (canonical `Money`) with
406
+ * host-supplied evidence (rating, badge, compare-at); it owns no listing state
407
+ * and reports every action through its callbacks. Prices render through the
408
+ * canonical `formatMoney` path, so a missing price is an unavailable state,
409
+ * never a zero amount.
410
+ */
411
+ export function ProductCard({
412
+ item,
413
+ presentation = 'classic',
414
+ wishlisted,
415
+ hideAddToCart,
416
+ onAddToCart,
417
+ onProductClick,
418
+ onToggleWishlist,
419
+ onViewSimilar,
420
+ addToCartLabel = 'Agregar al carrito',
421
+ unavailablePriceLabel = 'Precio no disponible',
422
+ addToWishlistLabel = 'Añadir a favoritos',
423
+ removeFromWishlistLabel = 'Quitar de favoritos',
424
+ viewSimilarLabel = 'Ver similares',
425
+ }: ProductCardProps) {
426
+ const shared: PresentationProps = {
427
+ item,
428
+ presentation,
429
+ wishlisted,
430
+ hideAddToCart,
431
+ onAddToCart,
432
+ onProductClick,
433
+ onToggleWishlist,
434
+ onViewSimilar,
435
+ addToCartLabel,
436
+ unavailablePriceLabel,
437
+ addToWishlistLabel,
438
+ removeFromWishlistLabel,
439
+ viewSimilarLabel,
440
+ }
441
+
442
+ if (presentation === 'minimal') return <MinimalPresentation {...shared} />
443
+ if (presentation === 'rich') return <RichPresentation {...shared} />
444
+ return <ClassicPresentation {...shared} />
445
+ }
446
+
447
+ export default ProductCard
@@ -0,0 +1,162 @@
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 { ProductGrid, type ProductGridProps } from './product-grid'
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
+ rating: 4.5,
43
+ }
44
+
45
+ const items = (count: number): ProductPresentation[] =>
46
+ Array.from({ length: count }, (_, i) => ({
47
+ ...baseItem,
48
+ id: `p${i + 1}`,
49
+ title: `Producto ${i + 1}`,
50
+ }))
51
+
52
+ const renderMarkup = (props: ProductGridProps) => renderToStaticMarkup(<ProductGrid {...props} />)
53
+
54
+ const countOccurrences = (html: string, needle: string) => html.split(needle).length - 1
55
+
56
+ describe('ProductGrid — renders a collection of item cells', () => {
57
+ it('renders one ProductCard cell per item, in the supplied order', () => {
58
+ const html = renderMarkup({ items: items(3) })
59
+ expect(html).toContain('>Producto 1<')
60
+ expect(html).toContain('>Producto 2<')
61
+ expect(html).toContain('>Producto 3<')
62
+ expect(countOccurrences(html, 'Agregar al carrito')).toBe(3)
63
+ expect(html.indexOf('Producto 1')).toBeLessThan(html.indexOf('Producto 2'))
64
+ })
65
+
66
+ it('renders the optional heading as section chrome', () => {
67
+ const html = renderMarkup({ items: items(1), heading: 'Oferta' })
68
+ expect(html).toContain('<section')
69
+ expect(html).toContain('<h2')
70
+ expect(html).toContain('>Oferta<')
71
+ })
72
+
73
+ it('slices items to maxItems when supplied', () => {
74
+ const html = renderMarkup({ items: items(8), maxItems: 2 })
75
+ expect(html).toContain('>Producto 2<')
76
+ expect(html).not.toContain('>Producto 3<')
77
+ expect(countOccurrences(html, 'Agregar al carrito')).toBe(2)
78
+ })
79
+
80
+ it('forwards the bounded presentation to every cell', () => {
81
+ const html = renderMarkup({ items: items(2), presentation: 'minimal' })
82
+ expect(html).not.toContain('>Agregar al carrito<')
83
+ expect(countOccurrences(html, 'aria-label="Agregar al carrito"')).toBe(2)
84
+ })
85
+ })
86
+
87
+ describe('ProductGrid — owns the listing states', () => {
88
+ it('renders skeleton placeholders while loading instead of cells', () => {
89
+ const html = renderMarkup({ items: items(3), loading: true })
90
+ expect(html).toContain('data-slot="skeleton"')
91
+ expect(html).toContain('aria-busy="true"')
92
+ expect(html).not.toContain('Llave de impacto')
93
+ expect(html).not.toContain('Agregar al carrito')
94
+ })
95
+
96
+ it('defaults to six skeletons and honors skeletonCount', () => {
97
+ expect(countOccurrences(renderMarkup({ items: [], loading: true }), 'data-slot="skeleton"')).toBe(18)
98
+ expect(
99
+ countOccurrences(
100
+ renderMarkup({ items: [], loading: true, skeletonCount: 1 }),
101
+ 'data-slot="skeleton"',
102
+ ),
103
+ ).toBe(3)
104
+ })
105
+
106
+ it('renders an empty state with default copy instead of a blank grid', () => {
107
+ const html = renderMarkup({ items: [] })
108
+ expect(html).toContain('No hay productos para mostrar.')
109
+ expect(html).not.toContain('Agregar al carrito')
110
+ })
111
+
112
+ it('uses the overridable empty label', () => {
113
+ expect(renderMarkup({ items: [], emptyLabel: 'Sin productos' })).toContain('Sin productos')
114
+ })
115
+ })
116
+
117
+ describe('ProductGrid — resolves wishlist membership per cell', () => {
118
+ it('passes each cell only its own wishlisted boolean', () => {
119
+ const html = renderMarkup({
120
+ items: items(2),
121
+ presentation: 'rich',
122
+ wishlistedIds: ['p1'],
123
+ })
124
+ expect(html).toContain('aria-pressed="true"')
125
+ expect(html).toContain('aria-pressed="false"')
126
+ })
127
+ })
128
+
129
+ describe('ProductGrid — actions bubble from the cells', () => {
130
+ let container: HTMLDivElement
131
+ let root: Root
132
+ let mounted: Root | undefined
133
+
134
+ const mount = (props: ProductGridProps) => {
135
+ root = createRoot(container)
136
+ mounted = root
137
+ act(() => root.render(<ProductGrid {...props} />))
138
+ }
139
+
140
+ beforeEach(() => {
141
+ container = document.createElement('div')
142
+ document.body.appendChild(container)
143
+ })
144
+
145
+ afterEach(() => {
146
+ const root = mounted
147
+ if (root) act(() => root.unmount())
148
+ container.remove()
149
+ })
150
+
151
+ it('reports the identity of the clicked cell item', () => {
152
+ const onAddToCart = vi.fn()
153
+ mount({ items: items(2), onAddToCart })
154
+ const buttons = Array.from(container.querySelectorAll('button')).filter((b) =>
155
+ b.textContent?.includes('Agregar al carrito'),
156
+ )
157
+ act(() => {
158
+ buttons[1]!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
159
+ })
160
+ expect(onAddToCart).toHaveBeenCalledWith(expect.objectContaining({ id: 'p2' }))
161
+ })
162
+ })