@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
@@ -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
@@ -1,166 +0,0 @@
1
- 'use client'
2
-
3
- import { useEffect, useRef, useState, type ComponentType } from 'react'
4
-
5
- import { cn } from '../../../lib/utils'
6
-
7
- const PARTICLE_COUNT = 8
8
-
9
- /**
10
- * The pop/burst keyframes travel with the component instead of living in the
11
- * app's stylesheet, so this file is self-contained: drop it in and the
12
- * animation works with no Tailwind config or global CSS to copy over.
13
- * Injected once per page, guarded by the marker attribute.
14
- */
15
- const STYLE_MARKER = 'data-reaction-toggle-keyframes'
16
- const STYLES = `
17
- @keyframes reaction-toggle-pop {
18
- 0% { transform: scale(1); }
19
- 30% { transform: scale(0.82); }
20
- 100% { transform: scale(1); }
21
- }
22
- @keyframes reaction-toggle-burst {
23
- 0% { opacity: 0; transform: translate(0, 0) scale(0.4); }
24
- 20% { opacity: 1; transform: translate(calc(var(--rx) * 0.25), calc(var(--ry) * 0.25)) scale(1); }
25
- 100% { opacity: 0; transform: translate(var(--rx), var(--ry)) scale(0.6); }
26
- }
27
- .reaction-toggle-pop {
28
- animation: reaction-toggle-pop 350ms cubic-bezier(0.34, 1.96, 0.64, 1);
29
- }
30
- .reaction-toggle-burst {
31
- animation-name: reaction-toggle-burst;
32
- animation-timing-function: ease-out;
33
- animation-fill-mode: forwards;
34
- }
35
- @media (prefers-reduced-motion: reduce) {
36
- .reaction-toggle-pop,
37
- .reaction-toggle-burst { animation: none !important; }
38
- }
39
- `
40
-
41
- function useReactionToggleStyles() {
42
- useEffect(() => {
43
- if (document.head.querySelector(`[${STYLE_MARKER}]`)) return
44
-
45
- const style = document.createElement('style')
46
- style.setAttribute(STYLE_MARKER, '')
47
- style.textContent = STYLES
48
- document.head.append(style)
49
- }, [])
50
- }
51
-
52
- type ReactionIconProps = {
53
- className?: string
54
- }
55
-
56
- type Particle = {
57
- rx: number
58
- ry: number
59
- duration: number
60
- delay: number
61
- size: number
62
- }
63
-
64
- function buildParticles(): Particle[] {
65
- return Array.from({ length: PARTICLE_COUNT }, (_, i) => {
66
- const angle = (Math.PI * 2 * i) / PARTICLE_COUNT + (Math.random() - 0.5) * 0.4
67
- const dist = 16 + Math.random() * 10
68
-
69
- return {
70
- rx: Math.round(Math.cos(angle) * dist * 10) / 10,
71
- ry: Math.round(Math.sin(angle) * dist * 10) / 10,
72
- duration: Math.round(500 + Math.random() * 200),
73
- delay: Math.round(Math.random() * 60),
74
- size: Math.round((0.7 + Math.random() * 0.8) * 100) / 100,
75
- }
76
- })
77
- }
78
-
79
- type ReactionToggleProps = {
80
- /** Whether the reaction is currently active (liked/helpful/etc). */
81
- active: boolean
82
- /** Icon component to render, e.g. `Heart` or `ThumbsUp` from lucide-react. */
83
- icon: ComponentType<ReactionIconProps>
84
- /** Wrapper class - sizing/positioning of the whole control. */
85
- className?: string
86
- /** Class applied to the icon itself (e.g. `size-5`). */
87
- iconClassName?: string
88
- /**
89
- * Tailwind color class (e.g. `text-primary`, `text-destructive`) applied to the
90
- * icon and particles while active. Drives `fill-current`/`bg-current`, so it
91
- * stays in sync with the design system instead of a hardcoded brand color.
92
- */
93
- activeColorClassName?: string
94
- }
95
-
96
- /**
97
- * Heart/thumbs-up style reaction control: the icon pops with an overshoot scale
98
- * and sprinkles 8 particles outward the moment it becomes active.
99
- */
100
- export function ProductCard3ReactionToggle({
101
- active,
102
- icon: Icon,
103
- className,
104
- iconClassName,
105
- activeColorClassName = 'text-primary',
106
- }: ReactionToggleProps) {
107
- const [isBursting, setIsBursting] = useState(false)
108
- const wasActive = useRef(active)
109
- const particlesRef = useRef<Particle[]>(buildParticles())
110
-
111
- useReactionToggleStyles()
112
-
113
- useEffect(() => {
114
- if (active && !wasActive.current) {
115
- particlesRef.current = buildParticles()
116
- setIsBursting(true)
117
- const timeout = setTimeout(() => setIsBursting(false), 700)
118
- wasActive.current = active
119
- return () => clearTimeout(timeout)
120
- }
121
-
122
- wasActive.current = active
123
- }, [active])
124
-
125
- return (
126
- <span className={cn('relative inline-flex', className)}>
127
- <span className={cn('inline-flex', active && 'reaction-toggle-pop')}>
128
- <Icon
129
- className={cn(
130
- 'transition-[fill,stroke,color] duration-150 ease-[cubic-bezier(0.22,1,0.36,1)]',
131
- active ? cn('fill-current', activeColorClassName) : 'fill-transparent',
132
- iconClassName
133
- )}
134
- />
135
- </span>
136
-
137
- {isBursting && (
138
- <span
139
- aria-hidden='true'
140
- className={cn('pointer-events-none absolute top-1/2 left-1/2 size-0', activeColorClassName)}
141
- >
142
- {particlesRef.current.map((particle, index) => (
143
- <i
144
- key={index}
145
- className='reaction-toggle-burst absolute rounded-full bg-current opacity-0'
146
- style={
147
- {
148
- '--rx': `${particle.rx}px`,
149
- '--ry': `${particle.ry}px`,
150
- animationDuration: `${particle.duration}ms`,
151
- animationDelay: `${particle.delay}ms`,
152
- width: `${2.5 * particle.size}px`,
153
- height: `${2.5 * particle.size}px`,
154
- left: `${-1.25 * particle.size}px`,
155
- top: `${-1.25 * particle.size}px`,
156
- } as React.CSSProperties
157
- }
158
- />
159
- ))}
160
- </span>
161
- )}
162
- </span>
163
- )
164
- }
165
-
166
- export default ProductCard3ReactionToggle
@@ -1,215 +0,0 @@
1
- 'use client'
2
-
3
- import { useState, type JSX } from 'react'
4
- import { Heart, Star, Sparkles, Flame, Trophy, GalleryHorizontalEnd } from 'lucide-react'
5
-
6
- import { cn } from '../../../lib/utils'
7
- import { Badge } from '../../../ui/badge'
8
- import { Button } from '../../../ui/button'
9
- import { Card, CardContent } from '../../../ui/card'
10
- import { ProductCard3ReactionToggle } from './product-card-3-reaction-toggle'
11
-
12
- export interface ProductCard3Product {
13
- id: string | number
14
- title: string
15
- subtitle: string
16
- image: string
17
- price: number
18
- originalPrice: number
19
- rating: number
20
- reviewCount: number
21
- discountPercentage: number
22
- isLowStock?: boolean
23
- badge?: {
24
- text: string
25
- variant: 'trending' | 'bestseller' | 'new'
26
- }
27
- }
28
-
29
- export interface ProductCard3Props {
30
- products: ProductCard3Product[]
31
- currencySymbol?: string
32
- onViewSimilar?: (product: ProductCard3Product) => void
33
- onToggleWishlist?: (product: ProductCard3Product) => void
34
- onProductClick?: (product: ProductCard3Product) => void
35
- }
36
-
37
- interface BadgeStyle {
38
- bg: string
39
- icon: JSX.Element | null
40
- }
41
-
42
- const badgeStyles: Record<string, BadgeStyle> = {
43
- trending: {
44
- bg: 'bg-black',
45
- icon: <Flame className='size-3' />,
46
- },
47
- bestseller: {
48
- bg: 'bg-black',
49
- icon: <Trophy className='size-3' />,
50
- },
51
- new: {
52
- bg: 'bg-black',
53
- icon: <Sparkles className='size-3' />,
54
- },
55
- }
56
-
57
- interface ProductCardProps {
58
- product: ProductCard3Product
59
- className?: string
60
- currencySymbol?: string
61
- onViewSimilar?: (product: ProductCard3Product) => void
62
- onToggleWishlist?: (product: ProductCard3Product) => void
63
- onProductClick?: (product: ProductCard3Product) => void
64
- }
65
-
66
- function ProductCard({
67
- product,
68
- className,
69
- currencySymbol = '$',
70
- onViewSimilar,
71
- onToggleWishlist,
72
- onProductClick,
73
- }: ProductCardProps) {
74
- const [isWishlisted, setIsWishlisted] = useState(false)
75
-
76
- const badgeStyle = product.badge ? badgeStyles[product.badge.variant] || badgeStyles.new : null
77
-
78
- /**
79
- * The heart stays in uncontrolled per-card state only when no callback is
80
- * given; with onToggleWishlist the parent owns the wishlist state.
81
- */
82
- const handleToggleWishlist = () => {
83
- if (onToggleWishlist) {
84
- onToggleWishlist(product)
85
- return
86
- }
87
- setIsWishlisted(prev => !prev)
88
- }
89
-
90
- return (
91
- <Card
92
- className={cn(
93
- 'relative overflow-hidden rounded-md py-0 shadow-none transition-all hover:shadow-md',
94
- onProductClick && 'cursor-pointer',
95
- className,
96
- )}
97
- onClick={onProductClick ? () => onProductClick(product) : undefined}
98
- >
99
- <CardContent className='px-0'>
100
- <div className='relative aspect-square w-full overflow-hidden'>
101
- <img src={product.image || undefined} alt={product.title} className='size-full object-cover' loading='lazy' />
102
-
103
- {product.badge && badgeStyle && (
104
- <div className='absolute start-2 top-2 z-20'>
105
- <Badge className={cn('h-auto font-semibold flex items-center gap-1 px-2 py-1 text-white backdrop-blur-xs', badgeStyle.bg)}>
106
- {badgeStyle.icon}
107
- <span className='text-xs font-medium'>{product.badge.text}</span>
108
- </Badge>
109
- </div>
110
- )}
111
-
112
- {product.rating > 0 && (
113
- <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'>
114
- <Star className='fill-foreground text-foreground size-4' />
115
- <span>{product.rating}</span>
116
- {product.reviewCount > 0 && (
117
- <span className='text-muted-foreground'>
118
- | {product.reviewCount >= 1000 ? `${(product.reviewCount / 1000).toFixed(1)}k` : product.reviewCount}
119
- </span>
120
- )}
121
- </div>
122
- )}
123
-
124
- <div className='absolute end-2 bottom-2 z-10'>
125
- <Button
126
- variant='ghost'
127
- size='sm'
128
- className="h-8 px-3 text-xs group bg-background/80 hover:bg-background size-8 cursor-pointer overflow-hidden rounded-full p-0 shadow-xs backdrop-blur-xs transition-all duration-200 hover:w-[130px]"
129
- aria-label='Ver productos similares'
130
- onClick={
131
- onViewSimilar
132
- ? (e) => {
133
- e.stopPropagation()
134
- onViewSimilar(product)
135
- }
136
- : undefined
137
- }
138
- >
139
- <GalleryHorizontalEnd className='absolute start-2 size-4 shrink-0' />
140
- <span className='ms-6 text-xs whitespace-nowrap opacity-0 group-hover:opacity-100'>Ver similares</span>
141
- </Button>
142
- </div>
143
-
144
- <Button
145
- variant='ghost'
146
- size='icon'
147
- className="bg-background/80 absolute end-2 top-2 z-20 size-8 cursor-pointer rounded-full p-0 backdrop-blur-xs"
148
- onClick={(e) => {
149
- e.stopPropagation()
150
- handleToggleWishlist()
151
- }}
152
- aria-label={isWishlisted ? 'Quitar de favoritos' : 'Añadir a favoritos'}
153
- >
154
- <ProductCard3ReactionToggle
155
- active={isWishlisted}
156
- icon={Heart}
157
- iconClassName={cn('size-4', !isWishlisted && 'text-muted-foreground hover:text-destructive')}
158
- activeColorClassName='text-destructive'
159
- />
160
- </Button>
161
- </div>
162
-
163
- <div className='p-4'>
164
- <div className='flex flex-col gap-2'>
165
- <h3 className='font-medium text-balance'>{product.title}</h3>
166
- <p className='text-muted-foreground text-sm text-balance'>{product.subtitle}</p>
167
- </div>
168
-
169
- <div className='flex items-center gap-2'>
170
- <span className='font-semibold'>
171
- {currencySymbol}
172
- {product.price}
173
- </span>
174
- <span className='text-muted-foreground text-sm line-through'>
175
- {currencySymbol}
176
- {product.originalPrice}
177
- </span>
178
- <span className='text-sm'>({product.discountPercentage}% OFF)</span>
179
- </div>
180
-
181
- {product.isLowStock && <p className='text-sm text-destructive'>¡Solo quedan pocas unidades!</p>}
182
- </div>
183
- </CardContent>
184
- </Card>
185
- )
186
- }
187
-
188
- export function ProductCard3({
189
- products,
190
- currencySymbol = '$',
191
- onViewSimilar,
192
- onToggleWishlist,
193
- onProductClick,
194
- }: ProductCard3Props) {
195
- return (
196
- <section className='py-12'>
197
- <div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
198
- <div className='grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3'>
199
- {products.map(product => (
200
- <ProductCard
201
- key={product.id}
202
- product={product}
203
- currencySymbol={currencySymbol}
204
- onViewSimilar={onViewSimilar}
205
- onToggleWishlist={onToggleWishlist}
206
- onProductClick={onProductClick}
207
- />
208
- ))}
209
- </div>
210
- </div>
211
- </section>
212
- )
213
- }
214
-
215
- export default ProductCard3