@thorprovider/blocks 0.1.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +108 -23
  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/navigation/back-to-top.test.tsx +27 -0
  7. package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
  8. package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
  9. package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
  10. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
  11. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
  12. package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
  13. package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
  14. package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
  15. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  16. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  17. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  18. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  19. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  20. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  21. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  22. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  23. package/src/blocks/e-commerce/product-list/product-card.tsx +424 -0
  24. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  25. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  26. package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
  27. package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
  28. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  29. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  30. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  31. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  32. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  33. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  34. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  35. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  36. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +204 -109
  37. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  38. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  39. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  40. package/src/blocks/marketing/features/feature.tsx +195 -0
  41. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +56 -0
  42. package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
  43. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +150 -0
  44. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +102 -9
  45. package/src/blocks/marketing/navbars/navbar-4.test.tsx +154 -2
  46. package/src/blocks/marketing/navbars/navbar-4.tsx +152 -32
  47. package/src/index.ts +93 -46
  48. package/src/lib/contracts-curated.test.ts +42 -24
  49. package/src/lib/contracts.test.ts +143 -2
  50. package/src/lib/contracts.ts +395 -13
  51. package/src/ui/button-group.tsx +1 -1
  52. package/src/ui/carousel.tsx +1 -1
  53. package/src/ui/dialog.tsx +1 -1
  54. package/src/ui/field.tsx +2 -2
  55. package/src/ui/input-group.tsx +3 -3
  56. package/src/ui/pagination.tsx +1 -1
  57. package/src/ui/sidebar.tsx +7 -7
  58. package/src/ui/toggle-group.tsx +1 -1
  59. package/tsconfig.build.json +12 -0
  60. package/tsup.config.ts +11 -7
  61. package/.turbo/turbo-build.log +0 -24
  62. package/.turbo/turbo-test.log +0 -37
  63. package/.turbo/turbo-type-check.log +0 -0
  64. package/dist/.tsbuildinfo +0 -1
  65. package/dist/contracts.d.mts +0 -1584
  66. package/dist/contracts.d.ts +0 -1584
  67. package/dist/contracts.js +0 -390
  68. package/dist/contracts.js.map +0 -1
  69. package/dist/contracts.mjs +0 -334
  70. package/dist/contracts.mjs.map +0 -1
  71. package/dist/index.d.mts +0 -2786
  72. package/dist/index.d.ts +0 -2786
  73. package/dist/index.js +0 -17139
  74. package/dist/index.js.map +0 -1
  75. package/dist/index.mjs +0 -17059
  76. package/dist/index.mjs.map +0 -1
  77. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  78. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  79. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  80. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  81. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  82. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  83. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -131
  84. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  85. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  86. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  87. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  88. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  89. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  90. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  91. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  92. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  93. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  94. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  95. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  96. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  97. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  98. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  99. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  100. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  101. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  102. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  103. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  104. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -137
  105. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -65
  106. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  107. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  108. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  109. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  110. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  111. package/src/blocks/marketing/features/feature-2.tsx +0 -82
@@ -1,218 +0,0 @@
1
- 'use client'
2
-
3
- import { useState } from 'react'
4
- import { Card, CardContent } from '../../../ui/card'
5
- import { Badge } from '../../../ui/badge'
6
- import { Button } from '../../../ui/button'
7
- import { Input } from '../../../ui/input'
8
- import {
9
- ArrowRight,
10
- Search,
11
- Laptop,
12
- Shirt,
13
- Home,
14
- Dumbbell,
15
- BookOpen,
16
- Heart,
17
- Car,
18
- Utensils,
19
- Gamepad2,
20
- Baby,
21
- Gift,
22
- Wrench,
23
- } from 'lucide-react'
24
- import { cn } from '../../../lib/utils'
25
-
26
- export type CategoryIcon =
27
- | 'electronics'
28
- | 'fashion'
29
- | 'home'
30
- | 'sports'
31
- | 'books'
32
- | 'beauty'
33
- | 'automotive'
34
- | 'food'
35
- | 'gaming'
36
- | 'baby'
37
- | 'gifts'
38
- | 'tools'
39
-
40
- export interface ProductCategory5Category {
41
- id: string
42
- name: string
43
- icon: CategoryIcon
44
- description: string
45
- itemCount: number
46
- subcategories: string[]
47
- featured?: boolean
48
- }
49
-
50
- export interface ProductCategory5Props {
51
- title?: string
52
- description?: string
53
- categories: ProductCategory5Category[]
54
- searchPlaceholder?: string
55
- productsLabel?: string
56
- featuredLabel?: string
57
- browseLabel?: string
58
- viewAllLabel?: string
59
- notFoundLabel?: string
60
- onBrowse?: (category: ProductCategory5Category) => void
61
- onViewAll?: () => void
62
- }
63
-
64
- const categoryIcons: Record<CategoryIcon, typeof Laptop> = {
65
- electronics: Laptop,
66
- fashion: Shirt,
67
- home: Home,
68
- sports: Dumbbell,
69
- books: BookOpen,
70
- beauty: Heart,
71
- automotive: Car,
72
- food: Utensils,
73
- gaming: Gamepad2,
74
- baby: Baby,
75
- gifts: Gift,
76
- tools: Wrench,
77
- }
78
-
79
- export function ProductCategory5({
80
- title = 'Explora categorías',
81
- description = 'Encuentra lo que necesitas en nuestras categorías organizadas',
82
- categories,
83
- searchPlaceholder = 'Buscar categorías...',
84
- productsLabel = 'productos disponibles',
85
- featuredLabel = 'Destacado',
86
- browseLabel = 'Explorar',
87
- viewAllLabel = 'Ver todos los productos',
88
- notFoundLabel = '¿No encuentras lo que buscas?',
89
- onBrowse,
90
- onViewAll,
91
- }: ProductCategory5Props) {
92
- const [query, setQuery] = useState('')
93
-
94
- const filteredCategories = categories.filter(category => {
95
- const q = query.trim().toLowerCase()
96
- if (!q) return true
97
- return (
98
- category.name.toLowerCase().includes(q) ||
99
- category.subcategories.some(sub => sub.toLowerCase().includes(q))
100
- )
101
- })
102
-
103
- const totalCount = categories.reduce((sum, category) => sum + category.itemCount, 0)
104
-
105
- return (
106
- <section className='py-12'>
107
- <div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
108
- <div className='mb-8 text-center'>
109
- <h2 className='mb-4 text-3xl font-bold tracking-tight text-balance'>{title}</h2>
110
- <p className='text-muted-foreground mb-6 text-lg'>{description}</p>
111
-
112
- <div className='relative mx-auto max-w-md'>
113
- <Search
114
- className='text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2'
115
- aria-hidden='true'
116
- />
117
- <Input
118
- placeholder={searchPlaceholder}
119
- value={query}
120
- onChange={e => setQuery(e.target.value)}
121
- className='ps-9 h-9'
122
- aria-label='Buscar categorías'
123
- />
124
- </div>
125
- </div>
126
-
127
- {filteredCategories.length === 0 ? (
128
- <p className='text-muted-foreground py-10 text-center text-sm'>
129
- Sin resultados para &quot;{query.trim()}&quot;
130
- </p>
131
- ) : (
132
- <div className='grid grid-cols-1 gap-4 lg:grid-cols-2'>
133
- {filteredCategories.map(category => {
134
- const Icon = categoryIcons[category.icon]
135
-
136
- return (
137
- <Card
138
- key={category.id}
139
- className={cn(
140
- 'group hover:border-primary relative py-0 transition-all duration-200 hover:shadow-md'
141
- )}
142
- >
143
- <CardContent className='p-6'>
144
- <div className='flex items-start gap-4'>
145
- <div className='flex-shrink-0'>
146
- <div className='bg-primary/10 group-hover:bg-primary/20 flex size-12 items-center justify-center rounded-lg transition-colors'>
147
- <Icon className='text-primary size-6' aria-hidden='true' />
148
- </div>
149
- </div>
150
-
151
- <div className='min-w-0 flex-1'>
152
- <div className='flex items-start justify-between'>
153
- <div className='flex-1'>
154
- <div className='mb-2 flex items-center gap-2'>
155
- <h3 className='text-lg font-semibold transition-colors'>{category.name}</h3>
156
- {category.featured ? (
157
- <Badge
158
- variant='secondary'
159
- className='rounded-sm px-2 py-0.5 font-semibold'
160
- >
161
- {featuredLabel}
162
- </Badge>
163
- ) : null}
164
- </div>
165
- <p className='text-muted-foreground mb-3 line-clamp-2 text-sm'>
166
- {category.description}
167
- </p>
168
-
169
- <div className='mb-3 flex flex-wrap gap-2'>
170
- {category.subcategories.map((sub, index) => (
171
- <span
172
- key={index}
173
- className='bg-muted text-muted-foreground rounded-md px-2 py-1 text-xs'
174
- >
175
- {sub}
176
- </span>
177
- ))}
178
- </div>
179
-
180
- <div className='text-muted-foreground text-sm'>
181
- {category.itemCount.toLocaleString()} {productsLabel}
182
- </div>
183
- </div>
184
- </div>
185
- </div>
186
- </div>
187
- <Button
188
- variant='ghost'
189
- size='sm'
190
- className='absolute end-4 bottom-4 h-8 cursor-pointer px-3 text-xs opacity-0 transition-opacity group-hover:opacity-100'
191
- onClick={onBrowse ? () => onBrowse(category) : undefined}
192
- >
193
- {browseLabel}
194
- <ArrowRight />
195
- </Button>
196
- </CardContent>
197
- </Card>
198
- )
199
- })}
200
- </div>
201
- )}
202
-
203
- <div className='mt-8 text-center'>
204
- <p className='text-muted-foreground mb-4 text-sm'>{notFoundLabel}</p>
205
- <Button
206
- variant='outline'
207
- className='h-9 cursor-pointer px-4 py-2'
208
- onClick={onViewAll}
209
- >
210
- {viewAllLabel} {totalCount.toLocaleString()}
211
- </Button>
212
- </div>
213
- </div>
214
- </section>
215
- )
216
- }
217
-
218
- export default ProductCategory5
@@ -1,131 +0,0 @@
1
- 'use client'
2
-
3
- import { motion, useReducedMotion } from 'motion/react'
4
-
5
- import { Button } from '../../../ui/button'
6
- import { Card, CardContent, CardFooter } from '../../../ui/card'
7
-
8
- export interface Product {
9
- id: string | number
10
- title: string
11
- price: number
12
- originalPrice?: number
13
- image: string
14
- badge?: string
15
- }
16
-
17
- export interface ProductCard1Props {
18
- products: Product[]
19
- heading?: string
20
- maxItems?: number
21
- onAddToCart?: (product: Product) => void
22
- onProductClick?: (product: Product) => void
23
- addToCartLabel?: string
24
- }
25
-
26
- export function ProductCard1({
27
- products,
28
- heading,
29
- maxItems = 6,
30
- onAddToCart,
31
- onProductClick,
32
- addToCartLabel = 'Agregar al carrito',
33
- }: ProductCard1Props) {
34
- const reduceMotion = useReducedMotion()
35
- const displayedProducts = products.slice(0, maxItems)
36
-
37
- return (
38
- <section className='py-12'>
39
- <div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
40
- {heading && (
41
- <h2 className='mb-8 text-center text-2xl font-bold text-balance md:text-3xl'>{heading}</h2>
42
- )}
43
-
44
- <div className='grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:gap-6'>
45
- {displayedProducts.map((product, index) => (
46
- <motion.div
47
- key={product.id}
48
- initial={reduceMotion ? false : { opacity: 0, y: 16 }}
49
- whileInView={{ opacity: 1, y: 0 }}
50
- viewport={{ once: true, margin: '-40px' }}
51
- whileHover={reduceMotion ? undefined : { y: -4 }}
52
- transition={{
53
- duration: 0.35,
54
- delay: reduceMotion ? 0 : index * 0.05,
55
- ease: 'easeOut',
56
- }}
57
- >
58
- <Card
59
- className={`flex h-full flex-col gap-4 overflow-hidden rounded-lg py-4 shadow-none transition-shadow duration-300 hover:shadow-md ${
60
- onProductClick ? 'cursor-pointer' : ''
61
- }`}
62
- onClick={onProductClick ? () => onProductClick(product) : undefined}
63
- >
64
- <CardContent className='flex flex-1 flex-col gap-4 px-4 '>
65
- <div className='relative aspect-square overflow-hidden rounded-md'>
66
- {product.badge && (
67
- <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'>
68
- {product.badge}
69
- </span>
70
- )}
71
- <img
72
- src={product.image || undefined}
73
- alt={product.title}
74
- className='size-full rounded-md object-contain'
75
- loading='lazy'
76
- width={400}
77
- height={400}
78
- />
79
- </div>
80
-
81
- <div className='flex flex-1 flex-col'>
82
- <h2 className='mb-1 font-medium text-balance'>
83
- {onProductClick ? (
84
- <button
85
- type='button'
86
- onClick={(e) => {
87
- e.stopPropagation()
88
- onProductClick(product)
89
- }}
90
- className='hover:text-primary cursor-pointer text-left transition-colors'
91
- >
92
- {product.title}
93
- </button>
94
- ) : (
95
- product.title
96
- )}
97
- </h2>
98
- <div className='mt-auto flex items-baseline gap-2'>
99
- <p className='font-semibold'>${product.price.toFixed(2)}</p>
100
- {product.originalPrice && (
101
- <p className='text-muted-foreground text-sm line-through md:text-base xl:text-sm 2xl:text-base'>
102
- ${product.originalPrice.toFixed(2)}
103
- </p>
104
- )}
105
- </div>
106
- </div>
107
- </CardContent>
108
-
109
- <CardFooter className='bg-transparent px-3 pt-0 border-t-0 md:px-4'>
110
- <Button
111
- variant='outline'
112
- size='sm'
113
- className='h-8 w-full cursor-pointer px-3 text-sm text-xs'
114
- onClick={(e) => {
115
- e.stopPropagation()
116
- onAddToCart?.(product)
117
- }}
118
- >
119
- {addToCartLabel}
120
- </Button>
121
- </CardFooter>
122
- </Card>
123
- </motion.div>
124
- ))}
125
- </div>
126
- </div>
127
- </section>
128
- )
129
- }
130
-
131
- 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