@thorprovider/blocks 0.1.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 (184) hide show
  1. package/.turbo/turbo-build.log +24 -0
  2. package/.turbo/turbo-test.log +10 -0
  3. package/.turbo/turbo-type-check.log +0 -0
  4. package/AGENTS.md +1 -0
  5. package/README.md +161 -0
  6. package/components.json +33 -0
  7. package/dist/.tsbuildinfo +1 -0
  8. package/dist/contracts.d.mts +563 -0
  9. package/dist/contracts.d.ts +563 -0
  10. package/dist/contracts.js +207 -0
  11. package/dist/contracts.js.map +1 -0
  12. package/dist/contracts.mjs +168 -0
  13. package/dist/contracts.mjs.map +1 -0
  14. package/dist/index.d.mts +2408 -0
  15. package/dist/index.d.ts +2408 -0
  16. package/dist/index.js +16422 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/index.mjs +16352 -0
  19. package/dist/index.mjs.map +1 -0
  20. package/docs/standards/data-contracts.md +70 -0
  21. package/docs/standards/theming.md +85 -0
  22. package/package.json +62 -0
  23. package/src/blocks/application/app-shells/app-shell-1-sidebar.tsx +198 -0
  24. package/src/blocks/application/app-shells/app-shell-1.tsx +196 -0
  25. package/src/blocks/application/login-forms/login-page-1.tsx +246 -0
  26. package/src/blocks/application/login-forms/login-page-2.tsx +207 -0
  27. package/src/blocks/application/password-recovery/forgot-password-page-1-brand-mark.tsx +38 -0
  28. package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +320 -0
  29. package/src/blocks/application/signup-forms/signup-page-1.tsx +231 -0
  30. package/src/blocks/application/verification/verify-1.tsx +307 -0
  31. package/src/blocks/application/verification/verify-confetti.tsx +192 -0
  32. package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +260 -0
  33. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +179 -0
  34. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +341 -0
  35. package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +57 -0
  36. package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +124 -0
  37. package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +170 -0
  38. package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +52 -0
  39. package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +57 -0
  40. package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +68 -0
  41. package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +298 -0
  42. package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +47 -0
  43. package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +9 -0
  44. package/src/blocks/e-commerce/checkout-forms/checkbox-check.tsx +74 -0
  45. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +189 -0
  46. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +283 -0
  47. package/src/blocks/e-commerce/checkout-forms/checkout-field-feedback.tsx +29 -0
  48. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +96 -0
  49. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-country-field.tsx +51 -0
  50. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +139 -0
  51. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +184 -0
  52. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +36 -0
  53. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +107 -0
  54. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +235 -0
  55. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +130 -0
  56. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +175 -0
  57. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +190 -0
  58. package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +312 -0
  59. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +91 -0
  60. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +110 -0
  61. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +137 -0
  62. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +68 -0
  63. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +79 -0
  64. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +172 -0
  65. package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +392 -0
  66. package/src/blocks/e-commerce/checkout-forms/checkout-form-types.ts +7 -0
  67. package/src/blocks/e-commerce/navigation/category-icon.test.tsx +54 -0
  68. package/src/blocks/e-commerce/navigation/category-icon.tsx +94 -0
  69. package/src/blocks/e-commerce/navigation/mega-menu.test.tsx +139 -0
  70. package/src/blocks/e-commerce/navigation/mega-menu.tsx +271 -0
  71. package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +152 -0
  72. package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +276 -0
  73. package/src/blocks/e-commerce/order-history/order-history-1.tsx +159 -0
  74. package/src/blocks/e-commerce/order-history/order-history-2.tsx +222 -0
  75. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +86 -0
  76. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +126 -0
  77. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +100 -0
  78. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +129 -0
  79. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +218 -0
  80. package/src/blocks/e-commerce/product-list/product-card-1.tsx +131 -0
  81. package/src/blocks/e-commerce/product-list/product-card-2.tsx +71 -0
  82. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +166 -0
  83. package/src/blocks/e-commerce/product-list/product-card-3.tsx +215 -0
  84. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +98 -0
  85. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +171 -0
  86. package/src/blocks/e-commerce/product-list/product-card-5.tsx +367 -0
  87. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +201 -0
  88. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +272 -0
  89. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +59 -0
  90. package/src/blocks/e-commerce/product-overview/product-overview-5-lightbox-tab.tsx +104 -0
  91. package/src/blocks/e-commerce/product-overview/product-overview-5-reviews-tab.tsx +82 -0
  92. package/src/blocks/e-commerce/product-overview/product-overview-5-tabs.tsx +69 -0
  93. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +274 -0
  94. package/src/blocks/e-commerce/product-overview/product-quick-view-card.tsx +135 -0
  95. package/src/blocks/e-commerce/product-overview/product-quick-view-dialog.tsx +393 -0
  96. package/src/blocks/e-commerce/product-overview/product-quick-view.tsx +136 -0
  97. package/src/blocks/e-commerce/reviews/review-rating-1-review-form.tsx +133 -0
  98. package/src/blocks/e-commerce/reviews/review-rating-1.tsx +178 -0
  99. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +246 -0
  100. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +142 -0
  101. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +291 -0
  102. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +110 -0
  103. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +51 -0
  104. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +146 -0
  105. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +295 -0
  106. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +142 -0
  107. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +342 -0
  108. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +146 -0
  109. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +391 -0
  110. package/src/blocks/e-commerce/store-containers/store-container-1.tsx +29 -0
  111. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +244 -0
  112. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +283 -0
  113. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +318 -0
  114. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +205 -0
  115. package/src/blocks/marketing/banners/avatar-group.tsx +69 -0
  116. package/src/blocks/marketing/banners/banner-1.tsx +188 -0
  117. package/src/blocks/marketing/cta/cta-section-1.tsx +112 -0
  118. package/src/blocks/marketing/faq/faq-block-1.tsx +45 -0
  119. package/src/blocks/marketing/features/feature-1.tsx +97 -0
  120. package/src/blocks/marketing/features/feature-2.tsx +82 -0
  121. package/src/blocks/marketing/footers/footer-section-1.tsx +141 -0
  122. package/src/blocks/marketing/footers/footer-section-3.tsx +192 -0
  123. package/src/blocks/marketing/footers/footer-section-4.tsx +355 -0
  124. package/src/blocks/marketing/hero-sections/avatar-group.tsx +86 -0
  125. package/src/blocks/marketing/hero-sections/hero-section-1.tsx +135 -0
  126. package/src/blocks/marketing/logo-clouds/logo-cloud-1-logo-icon.tsx +52 -0
  127. package/src/blocks/marketing/logo-clouds/logo-cloud-1.tsx +56 -0
  128. package/src/blocks/marketing/navbars/navbar-1.tsx +267 -0
  129. package/src/blocks/marketing/navbars/navbar-2.tsx +351 -0
  130. package/src/blocks/marketing/navbars/navbar-3.tsx +303 -0
  131. package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +118 -0
  132. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +248 -0
  133. package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +177 -0
  134. package/src/blocks/marketing/navbars/navbar-4-mobile-nav.tsx +104 -0
  135. package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +219 -0
  136. package/src/blocks/marketing/navbars/navbar-4-search-bar.tsx +52 -0
  137. package/src/blocks/marketing/navbars/navbar-4.tsx +411 -0
  138. package/src/blocks/marketing/pricing/pricing-section-1.tsx +94 -0
  139. package/src/blocks/marketing/statistics/statistic-1.tsx +93 -0
  140. package/src/blocks/marketing/testimonials/testimonial-1.tsx +141 -0
  141. package/src/blocks/marketing/testimonials/testimonial-2.tsx +108 -0
  142. package/src/blocks/shared/reaction-toggle.tsx +145 -0
  143. package/src/hooks/use-mobile.ts +19 -0
  144. package/src/index.ts +200 -0
  145. package/src/lib/contracts.test.ts +32 -0
  146. package/src/lib/contracts.ts +235 -0
  147. package/src/lib/types/menu.ts +43 -0
  148. package/src/lib/types.ts +4 -0
  149. package/src/lib/utils.ts +10 -0
  150. package/src/ui/accordion.tsx +65 -0
  151. package/src/ui/avatar.tsx +108 -0
  152. package/src/ui/badge.tsx +47 -0
  153. package/src/ui/breadcrumb.tsx +108 -0
  154. package/src/ui/button-group.tsx +83 -0
  155. package/src/ui/button.tsx +63 -0
  156. package/src/ui/card.tsx +91 -0
  157. package/src/ui/carousel.tsx +241 -0
  158. package/src/ui/dialog.tsx +158 -0
  159. package/src/ui/drawer.tsx +134 -0
  160. package/src/ui/dropdown-menu.tsx +256 -0
  161. package/src/ui/empty.tsx +103 -0
  162. package/src/ui/field.tsx +248 -0
  163. package/src/ui/input-group.tsx +170 -0
  164. package/src/ui/input-otp.tsx +76 -0
  165. package/src/ui/input.tsx +20 -0
  166. package/src/ui/label.tsx +23 -0
  167. package/src/ui/navigation-menu.tsx +167 -0
  168. package/src/ui/pagination.tsx +127 -0
  169. package/src/ui/progress.tsx +30 -0
  170. package/src/ui/radio-group.tsx +44 -0
  171. package/src/ui/select.tsx +189 -0
  172. package/src/ui/separator.tsx +27 -0
  173. package/src/ui/sheet.tsx +142 -0
  174. package/src/ui/sidebar.tsx +726 -0
  175. package/src/ui/skeleton.tsx +13 -0
  176. package/src/ui/table.tsx +115 -0
  177. package/src/ui/tabs.tsx +90 -0
  178. package/src/ui/textarea.tsx +17 -0
  179. package/src/ui/toggle-group.tsx +83 -0
  180. package/src/ui/toggle.tsx +46 -0
  181. package/src/ui/tooltip.tsx +56 -0
  182. package/tsconfig.json +20 -0
  183. package/tsup.config.ts +16 -0
  184. package/vitest.config.ts +9 -0
@@ -0,0 +1,166 @@
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
@@ -0,0 +1,215 @@
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
@@ -0,0 +1,98 @@
1
+ 'use client'
2
+
3
+ import { useLayoutEffect, useRef, useState } from 'react'
4
+ import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox'
5
+
6
+ import { cn } from '../../../lib/utils'
7
+
8
+ /**
9
+ * Animated checkbox whose tick draws on with a stroke path instead of appearing instantly.
10
+ *
11
+ * A drop-in replacement for `ui/checkbox`: same props, same look at rest. Two things make the
12
+ * draw work, and both are easy to get wrong:
13
+ *
14
+ * 1. The indicator uses `keepMounted`. Base UI unmounts it when unchecked, and an element that
15
+ * was never in the DOM cannot transition — the tick would still pop.
16
+ * 2. `--check-len` is measured from the real path with `getTotalLength()` rather than hard-coded.
17
+ * A guessed dash length either over-draws (a visible tail) or under-draws (a clipped tick),
18
+ * and it drifts the moment the icon or the box size changes.
19
+ *
20
+ * Transitioning the offset (rather than animating it) means an uncheck mid-draw reverses cleanly
21
+ * from wherever it had got to, instead of jumping to the end and restarting.
22
+ */
23
+ const BOX_MS = 150
24
+ const DRAW_MS = 350
25
+ const UNCHECK_MS = 150
26
+ const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'
27
+
28
+ export function CheckboxCheck({ className, indeterminate, ...props }: CheckboxPrimitive.Root.Props) {
29
+ const pathRef = useRef<SVGPathElement>(null)
30
+ const [length, setLength] = useState(15)
31
+
32
+ useLayoutEffect(() => {
33
+ const path = pathRef.current
34
+ if (path) {
35
+ setLength(Math.ceil(path.getTotalLength()))
36
+ }
37
+ }, [])
38
+
39
+ // A partial selection draws a dash, not a tick: a full tick for "some of these are selected"
40
+ // reads as "all of them". The tick is lucide's `Check`/`Minus` rescaled from their 24-unit
41
+ // viewBox into this 10.17-unit one (×0.4236), so the painted weight matches every static tick.
42
+ const mark = indeterminate ? 'M2.12 5.08H8.47' : 'M1.69 5.08L3.81 7.2L8.47 2.54'
43
+
44
+ return (
45
+ <CheckboxPrimitive.Root
46
+ data-slot="checkbox"
47
+ className={cn(
48
+ 'peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary',
49
+ 'motion-reduce:transition-none',
50
+ className
51
+ )}
52
+ style={{ transition: `background-color ${BOX_MS}ms ${EASE}, border-color ${BOX_MS}ms ${EASE}` }}
53
+ indeterminate={indeterminate}
54
+ {...props}
55
+ >
56
+ <CheckboxPrimitive.Indicator
57
+ keepMounted
58
+ data-slot="checkbox-indicator"
59
+ className={cn(
60
+ 'grid size-3.5 place-content-center text-current',
61
+ // Offset the full dash length at rest, none when checked. Driving it from the
62
+ // indicator's own data-checked keeps the two durations asymmetric: the draw is slower
63
+ // and deliberate, the erase is quick.
64
+ // The transition lives in a class, not inline: an inline `transition` beats any
65
+ // stylesheet rule, so the slower draw duration below could never take effect.
66
+ '[&_path]:[transition:stroke-dashoffset_var(--check-uncheck)_var(--check-ease)]',
67
+ '[&_path]:[stroke-dashoffset:var(--check-len)]',
68
+ 'data-checked:[&_path]:[stroke-dashoffset:0]',
69
+ 'data-checked:[&_path]:[transition-duration:var(--check-draw)]',
70
+ 'motion-reduce:[&_path]:transition-none'
71
+ )}
72
+ style={
73
+ {
74
+ '--check-len': length,
75
+ '--check-draw': `${DRAW_MS}ms`,
76
+ '--check-uncheck': `${UNCHECK_MS}ms`,
77
+ '--check-ease': EASE,
78
+ } as React.CSSProperties
79
+ }
80
+ >
81
+ <svg viewBox="0 0 10.1668 10.1668" fill="none" aria-hidden className="size-full overflow-visible">
82
+ <path
83
+ ref={pathRef}
84
+ d={mark}
85
+ stroke="currentColor"
86
+ // 2 in lucide's 24-unit box, rescaled: the same painted weight as every static tick.
87
+ strokeWidth={0.9}
88
+ strokeLinecap="round"
89
+ strokeLinejoin="round"
90
+ style={{ strokeDasharray: length }}
91
+ />
92
+ </svg>
93
+ </CheckboxPrimitive.Indicator>
94
+ </CheckboxPrimitive.Root>
95
+ )
96
+ }
97
+
98
+ export default CheckboxCheck
@@ -0,0 +1,171 @@
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
+ * The marker and the keyframe names deliberately match `product-card-3`'s copy of this
16
+ * component. Blocks install into one flat directory, so a buyer who takes both gets two files
17
+ * with identical styles — and because the guard is on the marker rather than on the file, the
18
+ * second one finds the first's `<style>` already there and injects nothing.
19
+ */
20
+ const STYLE_MARKER = 'data-reaction-toggle-keyframes'
21
+ const STYLES = `
22
+ @keyframes reaction-toggle-pop {
23
+ 0% { transform: scale(1); }
24
+ 30% { transform: scale(0.82); }
25
+ 100% { transform: scale(1); }
26
+ }
27
+ @keyframes reaction-toggle-burst {
28
+ 0% { opacity: 0; transform: translate(0, 0) scale(0.4); }
29
+ 20% { opacity: 1; transform: translate(calc(var(--rx) * 0.25), calc(var(--ry) * 0.25)) scale(1); }
30
+ 100% { opacity: 0; transform: translate(var(--rx), var(--ry)) scale(0.6); }
31
+ }
32
+ .reaction-toggle-pop {
33
+ animation: reaction-toggle-pop 350ms cubic-bezier(0.34, 1.96, 0.64, 1);
34
+ }
35
+ .reaction-toggle-burst {
36
+ animation-name: reaction-toggle-burst;
37
+ animation-timing-function: ease-out;
38
+ animation-fill-mode: forwards;
39
+ }
40
+ @media (prefers-reduced-motion: reduce) {
41
+ .reaction-toggle-pop,
42
+ .reaction-toggle-burst { animation: none !important; }
43
+ }
44
+ `
45
+
46
+ function useReactionToggleStyles() {
47
+ useEffect(() => {
48
+ if (document.head.querySelector(`[${STYLE_MARKER}]`)) return
49
+
50
+ const style = document.createElement('style')
51
+ style.setAttribute(STYLE_MARKER, '')
52
+ style.textContent = STYLES
53
+ document.head.append(style)
54
+ }, [])
55
+ }
56
+
57
+ type ReactionIconProps = {
58
+ className?: string
59
+ }
60
+
61
+ type Particle = {
62
+ rx: number
63
+ ry: number
64
+ duration: number
65
+ delay: number
66
+ size: number
67
+ }
68
+
69
+ function buildParticles(): Particle[] {
70
+ return Array.from({ length: PARTICLE_COUNT }, (_, i) => {
71
+ const angle = (Math.PI * 2 * i) / PARTICLE_COUNT + (Math.random() - 0.5) * 0.4
72
+ const dist = 16 + Math.random() * 10
73
+
74
+ return {
75
+ rx: Math.round(Math.cos(angle) * dist * 10) / 10,
76
+ ry: Math.round(Math.sin(angle) * dist * 10) / 10,
77
+ duration: Math.round(500 + Math.random() * 200),
78
+ delay: Math.round(Math.random() * 60),
79
+ size: Math.round((0.7 + Math.random() * 0.8) * 100) / 100,
80
+ }
81
+ })
82
+ }
83
+
84
+ type ReactionToggleProps = {
85
+ /** Whether the reaction is currently active (liked/helpful/etc). */
86
+ active: boolean
87
+ /** Icon component to render, e.g. `Heart` or `ThumbsUp` from lucide-react. */
88
+ icon: ComponentType<ReactionIconProps>
89
+ /** Wrapper class - sizing/positioning of the whole control. */
90
+ className?: string
91
+ /** Class applied to the icon itself (e.g. `size-5`). */
92
+ iconClassName?: string
93
+ /**
94
+ * Tailwind color class (e.g. `text-primary`, `text-destructive`) applied to the
95
+ * icon and particles while active. Drives `fill-current`/`bg-current`, so it
96
+ * stays in sync with the design system instead of a hardcoded brand color.
97
+ */
98
+ activeColorClassName?: string
99
+ }
100
+
101
+ /**
102
+ * Heart/thumbs-up style reaction control: the icon pops with an overshoot scale
103
+ * and sprinkles 8 particles outward the moment it becomes active.
104
+ */
105
+ export function ProductCard5ReactionToggle({
106
+ active,
107
+ icon: Icon,
108
+ className,
109
+ iconClassName,
110
+ activeColorClassName = 'text-primary',
111
+ }: ReactionToggleProps) {
112
+ const [isBursting, setIsBursting] = useState(false)
113
+ const wasActive = useRef(active)
114
+ const particlesRef = useRef<Particle[]>(buildParticles())
115
+
116
+ useReactionToggleStyles()
117
+
118
+ useEffect(() => {
119
+ if (active && !wasActive.current) {
120
+ particlesRef.current = buildParticles()
121
+ setIsBursting(true)
122
+ const timeout = setTimeout(() => setIsBursting(false), 700)
123
+ wasActive.current = active
124
+ return () => clearTimeout(timeout)
125
+ }
126
+
127
+ wasActive.current = active
128
+ }, [active])
129
+
130
+ return (
131
+ <span className={cn('relative inline-flex', className)}>
132
+ <span className={cn('inline-flex', active && 'reaction-toggle-pop')}>
133
+ <Icon
134
+ className={cn(
135
+ 'transition-[fill,stroke,color] duration-150 ease-[cubic-bezier(0.22,1,0.36,1)]',
136
+ active ? cn('fill-current', activeColorClassName) : 'fill-transparent',
137
+ iconClassName
138
+ )}
139
+ />
140
+ </span>
141
+
142
+ {isBursting && (
143
+ <span
144
+ aria-hidden='true'
145
+ className={cn('pointer-events-none absolute top-1/2 left-1/2 size-0', activeColorClassName)}
146
+ >
147
+ {particlesRef.current.map((particle, index) => (
148
+ <i
149
+ key={index}
150
+ className='reaction-toggle-burst absolute rounded-full bg-current opacity-0'
151
+ style={
152
+ {
153
+ '--rx': `${particle.rx}px`,
154
+ '--ry': `${particle.ry}px`,
155
+ animationDuration: `${particle.duration}ms`,
156
+ animationDelay: `${particle.delay}ms`,
157
+ width: `${2.5 * particle.size}px`,
158
+ height: `${2.5 * particle.size}px`,
159
+ left: `${-1.25 * particle.size}px`,
160
+ top: `${-1.25 * particle.size}px`,
161
+ } as React.CSSProperties
162
+ }
163
+ />
164
+ ))}
165
+ </span>
166
+ )}
167
+ </span>
168
+ )
169
+ }
170
+
171
+ export default ProductCard5ReactionToggle