@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.
- package/.turbo/turbo-build.log +24 -0
- package/.turbo/turbo-test.log +10 -0
- package/.turbo/turbo-type-check.log +0 -0
- package/AGENTS.md +1 -0
- package/README.md +161 -0
- package/components.json +33 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/contracts.d.mts +563 -0
- package/dist/contracts.d.ts +563 -0
- package/dist/contracts.js +207 -0
- package/dist/contracts.js.map +1 -0
- package/dist/contracts.mjs +168 -0
- package/dist/contracts.mjs.map +1 -0
- package/dist/index.d.mts +2408 -0
- package/dist/index.d.ts +2408 -0
- package/dist/index.js +16422 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +16352 -0
- package/dist/index.mjs.map +1 -0
- package/docs/standards/data-contracts.md +70 -0
- package/docs/standards/theming.md +85 -0
- package/package.json +62 -0
- package/src/blocks/application/app-shells/app-shell-1-sidebar.tsx +198 -0
- package/src/blocks/application/app-shells/app-shell-1.tsx +196 -0
- package/src/blocks/application/login-forms/login-page-1.tsx +246 -0
- package/src/blocks/application/login-forms/login-page-2.tsx +207 -0
- package/src/blocks/application/password-recovery/forgot-password-page-1-brand-mark.tsx +38 -0
- package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +320 -0
- package/src/blocks/application/signup-forms/signup-page-1.tsx +231 -0
- package/src/blocks/application/verification/verify-1.tsx +307 -0
- package/src/blocks/application/verification/verify-confetti.tsx +192 -0
- package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +260 -0
- package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +179 -0
- package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +341 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +57 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +124 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +170 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +52 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +57 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +68 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +298 -0
- package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +47 -0
- package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +9 -0
- package/src/blocks/e-commerce/checkout-forms/checkbox-check.tsx +74 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +189 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +283 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-field-feedback.tsx +29 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +96 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-country-field.tsx +51 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +139 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +184 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +36 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +107 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +235 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +130 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +175 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +190 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +312 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +91 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +110 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +137 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +68 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +79 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +172 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +392 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-types.ts +7 -0
- package/src/blocks/e-commerce/navigation/category-icon.test.tsx +54 -0
- package/src/blocks/e-commerce/navigation/category-icon.tsx +94 -0
- package/src/blocks/e-commerce/navigation/mega-menu.test.tsx +139 -0
- package/src/blocks/e-commerce/navigation/mega-menu.tsx +271 -0
- package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +152 -0
- package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +276 -0
- package/src/blocks/e-commerce/order-history/order-history-1.tsx +159 -0
- package/src/blocks/e-commerce/order-history/order-history-2.tsx +222 -0
- package/src/blocks/e-commerce/product-categories/avatar-group.tsx +86 -0
- package/src/blocks/e-commerce/product-categories/product-category-1.tsx +126 -0
- package/src/blocks/e-commerce/product-categories/product-category-2.tsx +100 -0
- package/src/blocks/e-commerce/product-categories/product-category-4.tsx +129 -0
- package/src/blocks/e-commerce/product-categories/product-category-5.tsx +218 -0
- package/src/blocks/e-commerce/product-list/product-card-1.tsx +131 -0
- package/src/blocks/e-commerce/product-list/product-card-2.tsx +71 -0
- package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +166 -0
- package/src/blocks/e-commerce/product-list/product-card-3.tsx +215 -0
- package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +98 -0
- package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +171 -0
- package/src/blocks/e-commerce/product-list/product-card-5.tsx +367 -0
- package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +201 -0
- package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +272 -0
- package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +59 -0
- package/src/blocks/e-commerce/product-overview/product-overview-5-lightbox-tab.tsx +104 -0
- package/src/blocks/e-commerce/product-overview/product-overview-5-reviews-tab.tsx +82 -0
- package/src/blocks/e-commerce/product-overview/product-overview-5-tabs.tsx +69 -0
- package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +274 -0
- package/src/blocks/e-commerce/product-overview/product-quick-view-card.tsx +135 -0
- package/src/blocks/e-commerce/product-overview/product-quick-view-dialog.tsx +393 -0
- package/src/blocks/e-commerce/product-overview/product-quick-view.tsx +136 -0
- package/src/blocks/e-commerce/reviews/review-rating-1-review-form.tsx +133 -0
- package/src/blocks/e-commerce/reviews/review-rating-1.tsx +178 -0
- package/src/blocks/e-commerce/reviews/review-rating-2.tsx +246 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +142 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +291 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +110 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +51 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +146 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +295 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +142 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +342 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +146 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +391 -0
- package/src/blocks/e-commerce/store-containers/store-container-1.tsx +29 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +244 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +283 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +318 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +205 -0
- package/src/blocks/marketing/banners/avatar-group.tsx +69 -0
- package/src/blocks/marketing/banners/banner-1.tsx +188 -0
- package/src/blocks/marketing/cta/cta-section-1.tsx +112 -0
- package/src/blocks/marketing/faq/faq-block-1.tsx +45 -0
- package/src/blocks/marketing/features/feature-1.tsx +97 -0
- package/src/blocks/marketing/features/feature-2.tsx +82 -0
- package/src/blocks/marketing/footers/footer-section-1.tsx +141 -0
- package/src/blocks/marketing/footers/footer-section-3.tsx +192 -0
- package/src/blocks/marketing/footers/footer-section-4.tsx +355 -0
- package/src/blocks/marketing/hero-sections/avatar-group.tsx +86 -0
- package/src/blocks/marketing/hero-sections/hero-section-1.tsx +135 -0
- package/src/blocks/marketing/logo-clouds/logo-cloud-1-logo-icon.tsx +52 -0
- package/src/blocks/marketing/logo-clouds/logo-cloud-1.tsx +56 -0
- package/src/blocks/marketing/navbars/navbar-1.tsx +267 -0
- package/src/blocks/marketing/navbars/navbar-2.tsx +351 -0
- package/src/blocks/marketing/navbars/navbar-3.tsx +303 -0
- package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +118 -0
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +248 -0
- package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +177 -0
- package/src/blocks/marketing/navbars/navbar-4-mobile-nav.tsx +104 -0
- package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +219 -0
- package/src/blocks/marketing/navbars/navbar-4-search-bar.tsx +52 -0
- package/src/blocks/marketing/navbars/navbar-4.tsx +411 -0
- package/src/blocks/marketing/pricing/pricing-section-1.tsx +94 -0
- package/src/blocks/marketing/statistics/statistic-1.tsx +93 -0
- package/src/blocks/marketing/testimonials/testimonial-1.tsx +141 -0
- package/src/blocks/marketing/testimonials/testimonial-2.tsx +108 -0
- package/src/blocks/shared/reaction-toggle.tsx +145 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/index.ts +200 -0
- package/src/lib/contracts.test.ts +32 -0
- package/src/lib/contracts.ts +235 -0
- package/src/lib/types/menu.ts +43 -0
- package/src/lib/types.ts +4 -0
- package/src/lib/utils.ts +10 -0
- package/src/ui/accordion.tsx +65 -0
- package/src/ui/avatar.tsx +108 -0
- package/src/ui/badge.tsx +47 -0
- package/src/ui/breadcrumb.tsx +108 -0
- package/src/ui/button-group.tsx +83 -0
- package/src/ui/button.tsx +63 -0
- package/src/ui/card.tsx +91 -0
- package/src/ui/carousel.tsx +241 -0
- package/src/ui/dialog.tsx +158 -0
- package/src/ui/drawer.tsx +134 -0
- package/src/ui/dropdown-menu.tsx +256 -0
- package/src/ui/empty.tsx +103 -0
- package/src/ui/field.tsx +248 -0
- package/src/ui/input-group.tsx +170 -0
- package/src/ui/input-otp.tsx +76 -0
- package/src/ui/input.tsx +20 -0
- package/src/ui/label.tsx +23 -0
- package/src/ui/navigation-menu.tsx +167 -0
- package/src/ui/pagination.tsx +127 -0
- package/src/ui/progress.tsx +30 -0
- package/src/ui/radio-group.tsx +44 -0
- package/src/ui/select.tsx +189 -0
- package/src/ui/separator.tsx +27 -0
- package/src/ui/sheet.tsx +142 -0
- package/src/ui/sidebar.tsx +726 -0
- package/src/ui/skeleton.tsx +13 -0
- package/src/ui/table.tsx +115 -0
- package/src/ui/tabs.tsx +90 -0
- package/src/ui/textarea.tsx +17 -0
- package/src/ui/toggle-group.tsx +83 -0
- package/src/ui/toggle.tsx +46 -0
- package/src/ui/tooltip.tsx +56 -0
- package/tsconfig.json +20 -0
- package/tsup.config.ts +16 -0
- 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
|