@thorprovider/blocks 0.1.3 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +155 -0
- package/README.md +228 -68
- package/docs/standards/data-contracts.md +13 -2
- package/package.json +26 -10
- package/scripts/fix-esm-extensions.mjs +40 -0
- package/src/blocks/application/login-forms/login-page.test.tsx +190 -0
- package/src/blocks/application/login-forms/login-page.tsx +240 -0
- package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +6 -8
- package/src/blocks/application/signup-forms/signup-page-1.tsx +127 -129
- package/src/blocks/application/verification/verify-1.tsx +2 -2
- package/src/blocks/application/viewport-ownership.test.tsx +41 -0
- package/src/blocks/e-commerce/category-filters/category-listing-filter-fields.tsx +218 -0
- package/src/blocks/e-commerce/category-filters/{category-filter-2.test.tsx → category-listing.test.tsx} +287 -199
- package/src/blocks/e-commerce/category-filters/category-listing.tsx +934 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-contact-step.tsx +149 -0
- package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-country-field.tsx → checkout-country-field.tsx} +10 -8
- package/src/blocks/e-commerce/checkout-forms/checkout-order-summary.tsx +230 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-payment-step.tsx +100 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-progress.tsx +62 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-review-step.tsx +59 -0
- package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-shipping-step.tsx → checkout-shipping-step.tsx} +65 -41
- package/src/blocks/e-commerce/checkout-forms/checkout.test.tsx +478 -0
- package/src/blocks/e-commerce/checkout-forms/checkout.tsx +550 -0
- package/src/blocks/e-commerce/navigation/storefront-navbar-account-menu.test.tsx +56 -0
- package/src/blocks/{marketing/navbars/navbar-4-account-menu.tsx → e-commerce/navigation/storefront-navbar-account-menu.tsx} +3 -3
- package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.test.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.test.tsx} +22 -22
- package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.tsx} +3 -3
- package/src/blocks/{marketing/navbars/navbar-4-mobile-nav.tsx → e-commerce/navigation/storefront-navbar-mobile-nav.tsx} +4 -12
- package/src/blocks/{marketing/navbars/navbar-4-search-bar.tsx → e-commerce/navigation/storefront-navbar-search-bar.tsx} +3 -3
- package/src/blocks/{marketing/navbars/navbar-4.test.tsx → e-commerce/navigation/storefront-navbar.test.tsx} +67 -36
- package/src/blocks/{marketing/navbars/navbar-4.tsx → e-commerce/navigation/storefront-navbar.tsx} +43 -25
- package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
- package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
- package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
- package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
- package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
- package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
- package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
- package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
- package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
- package/src/blocks/e-commerce/product-list/product-card.tsx +447 -0
- package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
- package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
- package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
- package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
- package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
- package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
- package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
- package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
- package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
- package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
- package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +119 -85
- package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
- package/src/blocks/marketing/features/feature.test.tsx +153 -0
- package/src/blocks/marketing/features/feature.tsx +195 -0
- package/src/blocks/marketing/footers/footer-section-accessibility.test.tsx +118 -0
- package/src/blocks/marketing/footers/footer-section-actions.test.tsx +131 -0
- package/src/blocks/marketing/footers/footer-section-data.test.tsx +127 -0
- package/src/blocks/marketing/footers/footer-section-native-links.test.tsx +100 -0
- package/src/blocks/marketing/footers/footer-section-public-api.test.tsx +11 -0
- package/src/blocks/marketing/footers/footer-section.test.tsx +46 -0
- package/src/blocks/marketing/footers/footer-section.tsx +532 -0
- package/src/blocks/marketing/navbars/navbar-public-api.test.tsx +57 -0
- package/src/blocks/marketing/navbars/navbar.mega.test.tsx +149 -0
- package/src/blocks/marketing/navbars/navbar.test.tsx +155 -0
- package/src/blocks/marketing/navbars/navbar.tsx +735 -0
- package/src/blocks/marketing/testimonials/testimonial-section.test.tsx +171 -0
- package/src/blocks/marketing/testimonials/testimonial-section.tsx +237 -0
- package/src/index.ts +156 -128
- package/src/lib/contracts-curated.test.ts +47 -29
- package/src/lib/contracts.test.ts +198 -2
- package/src/lib/contracts.ts +410 -16
- package/src/ui/button-group.tsx +1 -1
- package/src/ui/carousel.tsx +1 -1
- package/src/ui/dialog.tsx +1 -1
- package/src/ui/field.tsx +2 -2
- package/src/ui/input-group.tsx +3 -3
- package/src/ui/pagination.tsx +1 -1
- package/src/ui/sidebar.tsx +7 -7
- package/src/ui/toggle-group.tsx +1 -1
- package/tsconfig.build.json +12 -0
- package/tsup.config.ts +11 -7
- package/.turbo/turbo-build.log +0 -24
- package/.turbo/turbo-test.log +0 -47
- package/.turbo/turbo-type-check.log +0 -0
- package/dist/.tsbuildinfo +0 -1
- package/dist/contracts.d.mts +0 -1732
- package/dist/contracts.d.ts +0 -1732
- package/dist/contracts.js +0 -423
- package/dist/contracts.js.map +0 -1
- package/dist/contracts.mjs +0 -365
- package/dist/contracts.mjs.map +0 -1
- package/dist/index.d.mts +0 -3068
- package/dist/index.d.ts +0 -3068
- package/dist/index.js +0 -17834
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -17740
- package/dist/index.mjs.map +0 -1
- package/src/blocks/application/login-forms/login-page-1.tsx +0 -246
- package/src/blocks/application/login-forms/login-page-2.tsx +0 -207
- package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +0 -260
- package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +0 -222
- package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +0 -921
- package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +0 -57
- package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +0 -124
- package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +0 -170
- package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +0 -52
- package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +0 -57
- package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +0 -68
- package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +0 -298
- package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +0 -47
- package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +0 -9
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +0 -96
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +0 -252
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +0 -188
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +0 -36
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +0 -216
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +0 -338
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +0 -130
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +0 -175
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +0 -190
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +0 -312
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +0 -91
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +0 -110
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +0 -137
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +0 -68
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +0 -79
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +0 -172
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +0 -392
- package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
- package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
- package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
- package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
- package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
- package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
- package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +0 -88
- package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -192
- package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
- package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
- package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
- package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
- package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
- package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
- package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
- package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
- package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
- package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
- package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -229
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -72
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
- package/src/blocks/marketing/features/feature-1.tsx +0 -97
- package/src/blocks/marketing/features/feature-2.tsx +0 -82
- package/src/blocks/marketing/footers/footer-section-1.test.tsx +0 -117
- package/src/blocks/marketing/footers/footer-section-1.tsx +0 -178
- package/src/blocks/marketing/footers/footer-section-3.tsx +0 -192
- package/src/blocks/marketing/footers/footer-section-4.tsx +0 -355
- package/src/blocks/marketing/navbars/navbar-1.tsx +0 -267
- package/src/blocks/marketing/navbars/navbar-2.tsx +0 -351
- package/src/blocks/marketing/navbars/navbar-3.tsx +0 -303
- package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +0 -38
- package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +0 -177
- package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +0 -219
- package/src/blocks/marketing/testimonials/testimonial-1.tsx +0 -141
- package/src/blocks/marketing/testimonials/testimonial-2.tsx +0 -108
|
@@ -1,192 +0,0 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
|
-
import { Star } from 'lucide-react'
|
|
4
|
-
import { motion, useReducedMotion } from 'motion/react'
|
|
5
|
-
|
|
6
|
-
import { Button } from '../../../ui/button'
|
|
7
|
-
import { Card, CardContent, CardFooter } from '../../../ui/card'
|
|
8
|
-
import { formatMoney } from '../../../lib/money'
|
|
9
|
-
import type { ProductPresentation } from '../../../lib/contracts'
|
|
10
|
-
|
|
11
|
-
export interface Product {
|
|
12
|
-
id: string | number
|
|
13
|
-
title: string
|
|
14
|
-
price: number
|
|
15
|
-
originalPrice?: number
|
|
16
|
-
image: string
|
|
17
|
-
badge?: string
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* A card entry is either the legacy numeric-price product or the canonical
|
|
22
|
-
* `Money`-price presentation. The block renders both without breaking legacy
|
|
23
|
-
* consumers: a numeric `price` keeps the legacy `$` output unchanged, while a
|
|
24
|
-
* canonical `Money` price is formatted in its own currency and locale.
|
|
25
|
-
*/
|
|
26
|
-
export type ProductCard1Product = Product | ProductPresentation
|
|
27
|
-
|
|
28
|
-
export interface ProductCard1Props {
|
|
29
|
-
products: ProductCard1Product[]
|
|
30
|
-
heading?: string
|
|
31
|
-
maxItems?: number
|
|
32
|
-
onAddToCart?: (product: ProductCard1Product) => void
|
|
33
|
-
onProductClick?: (product: ProductCard1Product) => void
|
|
34
|
-
addToCartLabel?: string
|
|
35
|
-
/** Shown when a canonical price is present but unavailable (never `$0`). */
|
|
36
|
-
unavailablePriceLabel?: string
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function isPresentation(product: ProductCard1Product): product is ProductPresentation {
|
|
40
|
-
return typeof product.price === 'object'
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function priceText(product: ProductCard1Product, unavailablePriceLabel: string): string {
|
|
44
|
-
if (isPresentation(product)) {
|
|
45
|
-
return formatMoney(product.price) ?? unavailablePriceLabel
|
|
46
|
-
}
|
|
47
|
-
return `$${product.price.toFixed(2)}`
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function compareAtText(product: ProductCard1Product): string | undefined {
|
|
51
|
-
if (isPresentation(product)) {
|
|
52
|
-
const compareAt = product.compareAtPrice
|
|
53
|
-
if (!compareAt) return undefined
|
|
54
|
-
const formatted = formatMoney(compareAt)
|
|
55
|
-
const price = formatMoney(product.price)
|
|
56
|
-
// Render the comparison price only when both are valid and it is higher.
|
|
57
|
-
if (!formatted || !price) return undefined
|
|
58
|
-
if (Number(compareAt.amount) <= Number(product.price.amount)) return undefined
|
|
59
|
-
return formatted
|
|
60
|
-
}
|
|
61
|
-
// Legacy behavior is unchanged: truthiness only.
|
|
62
|
-
return product.originalPrice ? `$${product.originalPrice.toFixed(2)}` : undefined
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function imageAlt(product: ProductCard1Product): string {
|
|
66
|
-
return isPresentation(product) ? (product.imageAlt ?? product.title) : product.title
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function rating(product: ProductCard1Product): number | undefined {
|
|
70
|
-
if (!isPresentation(product)) return undefined
|
|
71
|
-
return typeof product.rating === 'number' && Number.isFinite(product.rating)
|
|
72
|
-
? product.rating
|
|
73
|
-
: undefined
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function ProductCard1({
|
|
77
|
-
products,
|
|
78
|
-
heading,
|
|
79
|
-
maxItems = 6,
|
|
80
|
-
onAddToCart,
|
|
81
|
-
onProductClick,
|
|
82
|
-
addToCartLabel = 'Agregar al carrito',
|
|
83
|
-
unavailablePriceLabel = 'Precio no disponible',
|
|
84
|
-
}: ProductCard1Props) {
|
|
85
|
-
const reduceMotion = useReducedMotion()
|
|
86
|
-
const displayedProducts = products.slice(0, maxItems)
|
|
87
|
-
|
|
88
|
-
return (
|
|
89
|
-
<section className='py-12'>
|
|
90
|
-
<div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
|
|
91
|
-
{heading && (
|
|
92
|
-
<h2 className='mb-8 text-center text-2xl font-bold text-balance md:text-3xl'>{heading}</h2>
|
|
93
|
-
)}
|
|
94
|
-
|
|
95
|
-
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:gap-6'>
|
|
96
|
-
{displayedProducts.map((product, index) => {
|
|
97
|
-
const compareAt = compareAtText(product)
|
|
98
|
-
const ratingValue = rating(product)
|
|
99
|
-
return (
|
|
100
|
-
<motion.div
|
|
101
|
-
key={product.id}
|
|
102
|
-
initial={reduceMotion ? false : { opacity: 0, y: 16 }}
|
|
103
|
-
whileInView={{ opacity: 1, y: 0 }}
|
|
104
|
-
viewport={{ once: true, margin: '-40px' }}
|
|
105
|
-
whileHover={reduceMotion ? undefined : { y: -4 }}
|
|
106
|
-
transition={{
|
|
107
|
-
duration: 0.35,
|
|
108
|
-
delay: reduceMotion ? 0 : index * 0.05,
|
|
109
|
-
ease: 'easeOut',
|
|
110
|
-
}}
|
|
111
|
-
>
|
|
112
|
-
<Card
|
|
113
|
-
className={`flex h-full flex-col gap-4 overflow-hidden rounded-lg py-4 shadow-none transition-shadow duration-300 hover:shadow-md ${
|
|
114
|
-
onProductClick ? 'cursor-pointer' : ''
|
|
115
|
-
}`}
|
|
116
|
-
onClick={onProductClick ? () => onProductClick(product) : undefined}
|
|
117
|
-
>
|
|
118
|
-
<CardContent className='flex flex-1 flex-col gap-4 px-4 '>
|
|
119
|
-
<div className='relative aspect-square overflow-hidden rounded-md'>
|
|
120
|
-
{product.badge && (
|
|
121
|
-
<span className='bg-destructive text-destructive-foreground absolute top-2 left-2 z-10 rounded-full px-2 py-0.5 text-xs font-semibold'>
|
|
122
|
-
{product.badge}
|
|
123
|
-
</span>
|
|
124
|
-
)}
|
|
125
|
-
<img
|
|
126
|
-
src={product.image || undefined}
|
|
127
|
-
alt={imageAlt(product)}
|
|
128
|
-
className='size-full rounded-md object-contain'
|
|
129
|
-
loading='lazy'
|
|
130
|
-
width={400}
|
|
131
|
-
height={400}
|
|
132
|
-
/>
|
|
133
|
-
</div>
|
|
134
|
-
|
|
135
|
-
<div className='flex flex-1 flex-col'>
|
|
136
|
-
<h2 className='mb-1 font-medium text-balance'>
|
|
137
|
-
{onProductClick ? (
|
|
138
|
-
<button
|
|
139
|
-
type='button'
|
|
140
|
-
onClick={(e) => {
|
|
141
|
-
e.stopPropagation()
|
|
142
|
-
onProductClick(product)
|
|
143
|
-
}}
|
|
144
|
-
className='hover:text-primary cursor-pointer text-left transition-colors'
|
|
145
|
-
>
|
|
146
|
-
{product.title}
|
|
147
|
-
</button>
|
|
148
|
-
) : (
|
|
149
|
-
product.title
|
|
150
|
-
)}
|
|
151
|
-
</h2>
|
|
152
|
-
{ratingValue !== undefined && (
|
|
153
|
-
<div className='text-muted-foreground mb-1 flex items-center gap-1 text-xs'>
|
|
154
|
-
<Star className='size-3.5 fill-current' aria-hidden='true' />
|
|
155
|
-
<span>{ratingValue.toFixed(1)}</span>
|
|
156
|
-
</div>
|
|
157
|
-
)}
|
|
158
|
-
<div className='mt-auto flex items-baseline gap-2'>
|
|
159
|
-
<p className='font-semibold'>{priceText(product, unavailablePriceLabel)}</p>
|
|
160
|
-
{compareAt && (
|
|
161
|
-
<p className='text-muted-foreground text-sm line-through md:text-base xl:text-sm 2xl:text-base'>
|
|
162
|
-
{compareAt}
|
|
163
|
-
</p>
|
|
164
|
-
)}
|
|
165
|
-
</div>
|
|
166
|
-
</div>
|
|
167
|
-
</CardContent>
|
|
168
|
-
|
|
169
|
-
<CardFooter className='bg-transparent px-3 pt-0 border-t-0 md:px-4'>
|
|
170
|
-
<Button
|
|
171
|
-
variant='outline'
|
|
172
|
-
size='sm'
|
|
173
|
-
className='h-8 w-full cursor-pointer px-3 text-sm text-xs'
|
|
174
|
-
onClick={(e) => {
|
|
175
|
-
e.stopPropagation()
|
|
176
|
-
onAddToCart?.(product)
|
|
177
|
-
}}
|
|
178
|
-
>
|
|
179
|
-
{addToCartLabel}
|
|
180
|
-
</Button>
|
|
181
|
-
</CardFooter>
|
|
182
|
-
</Card>
|
|
183
|
-
</motion.div>
|
|
184
|
-
)
|
|
185
|
-
})}
|
|
186
|
-
</div>
|
|
187
|
-
</div>
|
|
188
|
-
</section>
|
|
189
|
-
)
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
export default ProductCard1
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import { Star } from 'lucide-react'
|
|
2
|
-
import { Card, CardContent, CardTitle } from '../../../ui/card'
|
|
3
|
-
|
|
4
|
-
export interface ProductCard2Product {
|
|
5
|
-
id: string | number
|
|
6
|
-
title: string
|
|
7
|
-
image: string
|
|
8
|
-
price: number
|
|
9
|
-
rating: number
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface ProductCard2Props {
|
|
13
|
-
products: ProductCard2Product[]
|
|
14
|
-
heading?: string
|
|
15
|
-
description?: string
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function ProductCard2({ products, heading, description }: ProductCard2Props) {
|
|
19
|
-
return (
|
|
20
|
-
<section className='py-12'>
|
|
21
|
-
<div className='mx-auto flex w-full max-w-7xl flex-col gap-8 px-4 sm:px-6 lg:px-8'>
|
|
22
|
-
{heading || description ? (
|
|
23
|
-
<header className='flex flex-col gap-2'>
|
|
24
|
-
{heading && <h2 className='text-3xl font-bold text-balance sm:text-4xl'>{heading}</h2>}
|
|
25
|
-
{description && (
|
|
26
|
-
<p className='text-muted-foreground max-w-[60ch] text-balance'>{description}</p>
|
|
27
|
-
)}
|
|
28
|
-
</header>
|
|
29
|
-
) : null}
|
|
30
|
-
|
|
31
|
-
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3'>
|
|
32
|
-
{products.map(product => (
|
|
33
|
-
<Card key={product.id} className='group overflow-hidden transition-all hover:shadow-lg'>
|
|
34
|
-
<CardContent className='flex flex-col gap-4'>
|
|
35
|
-
<div className='overflow-hidden rounded-md'>
|
|
36
|
-
<img
|
|
37
|
-
src={product.image || undefined}
|
|
38
|
-
alt={product.title}
|
|
39
|
-
width={400}
|
|
40
|
-
height={400}
|
|
41
|
-
className='aspect-square w-full object-cover transition-transform duration-300 group-hover:scale-105'
|
|
42
|
-
/>
|
|
43
|
-
</div>
|
|
44
|
-
|
|
45
|
-
<div className='flex flex-col gap-2'>
|
|
46
|
-
<CardTitle className='line-clamp-1 text-lg font-semibold text-balance sm:text-xl'>
|
|
47
|
-
{product.title}
|
|
48
|
-
</CardTitle>
|
|
49
|
-
|
|
50
|
-
<div
|
|
51
|
-
className='flex items-center gap-0.5'
|
|
52
|
-
aria-label={`${product.rating} out of 5 stars`}
|
|
53
|
-
role='img'
|
|
54
|
-
>
|
|
55
|
-
{Array.from({ length: product.rating }).map((_, i) => (
|
|
56
|
-
<Star key={i} className='fill-foreground text-foreground size-4 sm:size-5' />
|
|
57
|
-
))}
|
|
58
|
-
</div>
|
|
59
|
-
|
|
60
|
-
<p className='text-lg font-semibold sm:text-xl'>${product.price.toFixed(2)}</p>
|
|
61
|
-
</div>
|
|
62
|
-
</CardContent>
|
|
63
|
-
</Card>
|
|
64
|
-
))}
|
|
65
|
-
</div>
|
|
66
|
-
</div>
|
|
67
|
-
</section>
|
|
68
|
-
)
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export default ProductCard2
|
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
|
-
import { useEffect, useRef, useState, type ComponentType } from 'react'
|
|
4
|
-
|
|
5
|
-
import { cn } from '../../../lib/utils'
|
|
6
|
-
|
|
7
|
-
const PARTICLE_COUNT = 8
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* The pop/burst keyframes travel with the component instead of living in the
|
|
11
|
-
* app's stylesheet, so this file is self-contained: drop it in and the
|
|
12
|
-
* animation works with no Tailwind config or global CSS to copy over.
|
|
13
|
-
* Injected once per page, guarded by the marker attribute.
|
|
14
|
-
*/
|
|
15
|
-
const STYLE_MARKER = 'data-reaction-toggle-keyframes'
|
|
16
|
-
const STYLES = `
|
|
17
|
-
@keyframes reaction-toggle-pop {
|
|
18
|
-
0% { transform: scale(1); }
|
|
19
|
-
30% { transform: scale(0.82); }
|
|
20
|
-
100% { transform: scale(1); }
|
|
21
|
-
}
|
|
22
|
-
@keyframes reaction-toggle-burst {
|
|
23
|
-
0% { opacity: 0; transform: translate(0, 0) scale(0.4); }
|
|
24
|
-
20% { opacity: 1; transform: translate(calc(var(--rx) * 0.25), calc(var(--ry) * 0.25)) scale(1); }
|
|
25
|
-
100% { opacity: 0; transform: translate(var(--rx), var(--ry)) scale(0.6); }
|
|
26
|
-
}
|
|
27
|
-
.reaction-toggle-pop {
|
|
28
|
-
animation: reaction-toggle-pop 350ms cubic-bezier(0.34, 1.96, 0.64, 1);
|
|
29
|
-
}
|
|
30
|
-
.reaction-toggle-burst {
|
|
31
|
-
animation-name: reaction-toggle-burst;
|
|
32
|
-
animation-timing-function: ease-out;
|
|
33
|
-
animation-fill-mode: forwards;
|
|
34
|
-
}
|
|
35
|
-
@media (prefers-reduced-motion: reduce) {
|
|
36
|
-
.reaction-toggle-pop,
|
|
37
|
-
.reaction-toggle-burst { animation: none !important; }
|
|
38
|
-
}
|
|
39
|
-
`
|
|
40
|
-
|
|
41
|
-
function useReactionToggleStyles() {
|
|
42
|
-
useEffect(() => {
|
|
43
|
-
if (document.head.querySelector(`[${STYLE_MARKER}]`)) return
|
|
44
|
-
|
|
45
|
-
const style = document.createElement('style')
|
|
46
|
-
style.setAttribute(STYLE_MARKER, '')
|
|
47
|
-
style.textContent = STYLES
|
|
48
|
-
document.head.append(style)
|
|
49
|
-
}, [])
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
type ReactionIconProps = {
|
|
53
|
-
className?: string
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
type Particle = {
|
|
57
|
-
rx: number
|
|
58
|
-
ry: number
|
|
59
|
-
duration: number
|
|
60
|
-
delay: number
|
|
61
|
-
size: number
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function buildParticles(): Particle[] {
|
|
65
|
-
return Array.from({ length: PARTICLE_COUNT }, (_, i) => {
|
|
66
|
-
const angle = (Math.PI * 2 * i) / PARTICLE_COUNT + (Math.random() - 0.5) * 0.4
|
|
67
|
-
const dist = 16 + Math.random() * 10
|
|
68
|
-
|
|
69
|
-
return {
|
|
70
|
-
rx: Math.round(Math.cos(angle) * dist * 10) / 10,
|
|
71
|
-
ry: Math.round(Math.sin(angle) * dist * 10) / 10,
|
|
72
|
-
duration: Math.round(500 + Math.random() * 200),
|
|
73
|
-
delay: Math.round(Math.random() * 60),
|
|
74
|
-
size: Math.round((0.7 + Math.random() * 0.8) * 100) / 100,
|
|
75
|
-
}
|
|
76
|
-
})
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
type ReactionToggleProps = {
|
|
80
|
-
/** Whether the reaction is currently active (liked/helpful/etc). */
|
|
81
|
-
active: boolean
|
|
82
|
-
/** Icon component to render, e.g. `Heart` or `ThumbsUp` from lucide-react. */
|
|
83
|
-
icon: ComponentType<ReactionIconProps>
|
|
84
|
-
/** Wrapper class - sizing/positioning of the whole control. */
|
|
85
|
-
className?: string
|
|
86
|
-
/** Class applied to the icon itself (e.g. `size-5`). */
|
|
87
|
-
iconClassName?: string
|
|
88
|
-
/**
|
|
89
|
-
* Tailwind color class (e.g. `text-primary`, `text-destructive`) applied to the
|
|
90
|
-
* icon and particles while active. Drives `fill-current`/`bg-current`, so it
|
|
91
|
-
* stays in sync with the design system instead of a hardcoded brand color.
|
|
92
|
-
*/
|
|
93
|
-
activeColorClassName?: string
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Heart/thumbs-up style reaction control: the icon pops with an overshoot scale
|
|
98
|
-
* and sprinkles 8 particles outward the moment it becomes active.
|
|
99
|
-
*/
|
|
100
|
-
export function ProductCard3ReactionToggle({
|
|
101
|
-
active,
|
|
102
|
-
icon: Icon,
|
|
103
|
-
className,
|
|
104
|
-
iconClassName,
|
|
105
|
-
activeColorClassName = 'text-primary',
|
|
106
|
-
}: ReactionToggleProps) {
|
|
107
|
-
const [isBursting, setIsBursting] = useState(false)
|
|
108
|
-
const wasActive = useRef(active)
|
|
109
|
-
const particlesRef = useRef<Particle[]>(buildParticles())
|
|
110
|
-
|
|
111
|
-
useReactionToggleStyles()
|
|
112
|
-
|
|
113
|
-
useEffect(() => {
|
|
114
|
-
if (active && !wasActive.current) {
|
|
115
|
-
particlesRef.current = buildParticles()
|
|
116
|
-
setIsBursting(true)
|
|
117
|
-
const timeout = setTimeout(() => setIsBursting(false), 700)
|
|
118
|
-
wasActive.current = active
|
|
119
|
-
return () => clearTimeout(timeout)
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
wasActive.current = active
|
|
123
|
-
}, [active])
|
|
124
|
-
|
|
125
|
-
return (
|
|
126
|
-
<span className={cn('relative inline-flex', className)}>
|
|
127
|
-
<span className={cn('inline-flex', active && 'reaction-toggle-pop')}>
|
|
128
|
-
<Icon
|
|
129
|
-
className={cn(
|
|
130
|
-
'transition-[fill,stroke,color] duration-150 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
|
131
|
-
active ? cn('fill-current', activeColorClassName) : 'fill-transparent',
|
|
132
|
-
iconClassName
|
|
133
|
-
)}
|
|
134
|
-
/>
|
|
135
|
-
</span>
|
|
136
|
-
|
|
137
|
-
{isBursting && (
|
|
138
|
-
<span
|
|
139
|
-
aria-hidden='true'
|
|
140
|
-
className={cn('pointer-events-none absolute top-1/2 left-1/2 size-0', activeColorClassName)}
|
|
141
|
-
>
|
|
142
|
-
{particlesRef.current.map((particle, index) => (
|
|
143
|
-
<i
|
|
144
|
-
key={index}
|
|
145
|
-
className='reaction-toggle-burst absolute rounded-full bg-current opacity-0'
|
|
146
|
-
style={
|
|
147
|
-
{
|
|
148
|
-
'--rx': `${particle.rx}px`,
|
|
149
|
-
'--ry': `${particle.ry}px`,
|
|
150
|
-
animationDuration: `${particle.duration}ms`,
|
|
151
|
-
animationDelay: `${particle.delay}ms`,
|
|
152
|
-
width: `${2.5 * particle.size}px`,
|
|
153
|
-
height: `${2.5 * particle.size}px`,
|
|
154
|
-
left: `${-1.25 * particle.size}px`,
|
|
155
|
-
top: `${-1.25 * particle.size}px`,
|
|
156
|
-
} as React.CSSProperties
|
|
157
|
-
}
|
|
158
|
-
/>
|
|
159
|
-
))}
|
|
160
|
-
</span>
|
|
161
|
-
)}
|
|
162
|
-
</span>
|
|
163
|
-
)
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export default ProductCard3ReactionToggle
|
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
|
-
import { useState, type JSX } from 'react'
|
|
4
|
-
import { Heart, Star, Sparkles, Flame, Trophy, GalleryHorizontalEnd } from 'lucide-react'
|
|
5
|
-
|
|
6
|
-
import { cn } from '../../../lib/utils'
|
|
7
|
-
import { Badge } from '../../../ui/badge'
|
|
8
|
-
import { Button } from '../../../ui/button'
|
|
9
|
-
import { Card, CardContent } from '../../../ui/card'
|
|
10
|
-
import { ProductCard3ReactionToggle } from './product-card-3-reaction-toggle'
|
|
11
|
-
|
|
12
|
-
export interface ProductCard3Product {
|
|
13
|
-
id: string | number
|
|
14
|
-
title: string
|
|
15
|
-
subtitle: string
|
|
16
|
-
image: string
|
|
17
|
-
price: number
|
|
18
|
-
originalPrice: number
|
|
19
|
-
rating: number
|
|
20
|
-
reviewCount: number
|
|
21
|
-
discountPercentage: number
|
|
22
|
-
isLowStock?: boolean
|
|
23
|
-
badge?: {
|
|
24
|
-
text: string
|
|
25
|
-
variant: 'trending' | 'bestseller' | 'new'
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface ProductCard3Props {
|
|
30
|
-
products: ProductCard3Product[]
|
|
31
|
-
currencySymbol?: string
|
|
32
|
-
onViewSimilar?: (product: ProductCard3Product) => void
|
|
33
|
-
onToggleWishlist?: (product: ProductCard3Product) => void
|
|
34
|
-
onProductClick?: (product: ProductCard3Product) => void
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
interface BadgeStyle {
|
|
38
|
-
bg: string
|
|
39
|
-
icon: JSX.Element | null
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const badgeStyles: Record<string, BadgeStyle> = {
|
|
43
|
-
trending: {
|
|
44
|
-
bg: 'bg-black',
|
|
45
|
-
icon: <Flame className='size-3' />,
|
|
46
|
-
},
|
|
47
|
-
bestseller: {
|
|
48
|
-
bg: 'bg-black',
|
|
49
|
-
icon: <Trophy className='size-3' />,
|
|
50
|
-
},
|
|
51
|
-
new: {
|
|
52
|
-
bg: 'bg-black',
|
|
53
|
-
icon: <Sparkles className='size-3' />,
|
|
54
|
-
},
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
interface ProductCardProps {
|
|
58
|
-
product: ProductCard3Product
|
|
59
|
-
className?: string
|
|
60
|
-
currencySymbol?: string
|
|
61
|
-
onViewSimilar?: (product: ProductCard3Product) => void
|
|
62
|
-
onToggleWishlist?: (product: ProductCard3Product) => void
|
|
63
|
-
onProductClick?: (product: ProductCard3Product) => void
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function ProductCard({
|
|
67
|
-
product,
|
|
68
|
-
className,
|
|
69
|
-
currencySymbol = '$',
|
|
70
|
-
onViewSimilar,
|
|
71
|
-
onToggleWishlist,
|
|
72
|
-
onProductClick,
|
|
73
|
-
}: ProductCardProps) {
|
|
74
|
-
const [isWishlisted, setIsWishlisted] = useState(false)
|
|
75
|
-
|
|
76
|
-
const badgeStyle = product.badge ? badgeStyles[product.badge.variant] || badgeStyles.new : null
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* The heart stays in uncontrolled per-card state only when no callback is
|
|
80
|
-
* given; with onToggleWishlist the parent owns the wishlist state.
|
|
81
|
-
*/
|
|
82
|
-
const handleToggleWishlist = () => {
|
|
83
|
-
if (onToggleWishlist) {
|
|
84
|
-
onToggleWishlist(product)
|
|
85
|
-
return
|
|
86
|
-
}
|
|
87
|
-
setIsWishlisted(prev => !prev)
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return (
|
|
91
|
-
<Card
|
|
92
|
-
className={cn(
|
|
93
|
-
'relative overflow-hidden rounded-md py-0 shadow-none transition-all hover:shadow-md',
|
|
94
|
-
onProductClick && 'cursor-pointer',
|
|
95
|
-
className,
|
|
96
|
-
)}
|
|
97
|
-
onClick={onProductClick ? () => onProductClick(product) : undefined}
|
|
98
|
-
>
|
|
99
|
-
<CardContent className='px-0'>
|
|
100
|
-
<div className='relative aspect-square w-full overflow-hidden'>
|
|
101
|
-
<img src={product.image || undefined} alt={product.title} className='size-full object-cover' loading='lazy' />
|
|
102
|
-
|
|
103
|
-
{product.badge && badgeStyle && (
|
|
104
|
-
<div className='absolute start-2 top-2 z-20'>
|
|
105
|
-
<Badge className={cn('h-auto font-semibold flex items-center gap-1 px-2 py-1 text-white backdrop-blur-xs', badgeStyle.bg)}>
|
|
106
|
-
{badgeStyle.icon}
|
|
107
|
-
<span className='text-xs font-medium'>{product.badge.text}</span>
|
|
108
|
-
</Badge>
|
|
109
|
-
</div>
|
|
110
|
-
)}
|
|
111
|
-
|
|
112
|
-
{product.rating > 0 && (
|
|
113
|
-
<div className='bg-background/80 absolute start-2 bottom-2 z-10 flex items-center gap-1 rounded-md border px-2 py-1 text-sm backdrop-blur-xs'>
|
|
114
|
-
<Star className='fill-foreground text-foreground size-4' />
|
|
115
|
-
<span>{product.rating}</span>
|
|
116
|
-
{product.reviewCount > 0 && (
|
|
117
|
-
<span className='text-muted-foreground'>
|
|
118
|
-
| {product.reviewCount >= 1000 ? `${(product.reviewCount / 1000).toFixed(1)}k` : product.reviewCount}
|
|
119
|
-
</span>
|
|
120
|
-
)}
|
|
121
|
-
</div>
|
|
122
|
-
)}
|
|
123
|
-
|
|
124
|
-
<div className='absolute end-2 bottom-2 z-10'>
|
|
125
|
-
<Button
|
|
126
|
-
variant='ghost'
|
|
127
|
-
size='sm'
|
|
128
|
-
className="h-8 px-3 text-xs group bg-background/80 hover:bg-background size-8 cursor-pointer overflow-hidden rounded-full p-0 shadow-xs backdrop-blur-xs transition-all duration-200 hover:w-[130px]"
|
|
129
|
-
aria-label='Ver productos similares'
|
|
130
|
-
onClick={
|
|
131
|
-
onViewSimilar
|
|
132
|
-
? (e) => {
|
|
133
|
-
e.stopPropagation()
|
|
134
|
-
onViewSimilar(product)
|
|
135
|
-
}
|
|
136
|
-
: undefined
|
|
137
|
-
}
|
|
138
|
-
>
|
|
139
|
-
<GalleryHorizontalEnd className='absolute start-2 size-4 shrink-0' />
|
|
140
|
-
<span className='ms-6 text-xs whitespace-nowrap opacity-0 group-hover:opacity-100'>Ver similares</span>
|
|
141
|
-
</Button>
|
|
142
|
-
</div>
|
|
143
|
-
|
|
144
|
-
<Button
|
|
145
|
-
variant='ghost'
|
|
146
|
-
size='icon'
|
|
147
|
-
className="bg-background/80 absolute end-2 top-2 z-20 size-8 cursor-pointer rounded-full p-0 backdrop-blur-xs"
|
|
148
|
-
onClick={(e) => {
|
|
149
|
-
e.stopPropagation()
|
|
150
|
-
handleToggleWishlist()
|
|
151
|
-
}}
|
|
152
|
-
aria-label={isWishlisted ? 'Quitar de favoritos' : 'Añadir a favoritos'}
|
|
153
|
-
>
|
|
154
|
-
<ProductCard3ReactionToggle
|
|
155
|
-
active={isWishlisted}
|
|
156
|
-
icon={Heart}
|
|
157
|
-
iconClassName={cn('size-4', !isWishlisted && 'text-muted-foreground hover:text-destructive')}
|
|
158
|
-
activeColorClassName='text-destructive'
|
|
159
|
-
/>
|
|
160
|
-
</Button>
|
|
161
|
-
</div>
|
|
162
|
-
|
|
163
|
-
<div className='p-4'>
|
|
164
|
-
<div className='flex flex-col gap-2'>
|
|
165
|
-
<h3 className='font-medium text-balance'>{product.title}</h3>
|
|
166
|
-
<p className='text-muted-foreground text-sm text-balance'>{product.subtitle}</p>
|
|
167
|
-
</div>
|
|
168
|
-
|
|
169
|
-
<div className='flex items-center gap-2'>
|
|
170
|
-
<span className='font-semibold'>
|
|
171
|
-
{currencySymbol}
|
|
172
|
-
{product.price}
|
|
173
|
-
</span>
|
|
174
|
-
<span className='text-muted-foreground text-sm line-through'>
|
|
175
|
-
{currencySymbol}
|
|
176
|
-
{product.originalPrice}
|
|
177
|
-
</span>
|
|
178
|
-
<span className='text-sm'>({product.discountPercentage}% OFF)</span>
|
|
179
|
-
</div>
|
|
180
|
-
|
|
181
|
-
{product.isLowStock && <p className='text-sm text-destructive'>¡Solo quedan pocas unidades!</p>}
|
|
182
|
-
</div>
|
|
183
|
-
</CardContent>
|
|
184
|
-
</Card>
|
|
185
|
-
)
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
export function ProductCard3({
|
|
189
|
-
products,
|
|
190
|
-
currencySymbol = '$',
|
|
191
|
-
onViewSimilar,
|
|
192
|
-
onToggleWishlist,
|
|
193
|
-
onProductClick,
|
|
194
|
-
}: ProductCard3Props) {
|
|
195
|
-
return (
|
|
196
|
-
<section className='py-12'>
|
|
197
|
-
<div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
|
|
198
|
-
<div className='grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3'>
|
|
199
|
-
{products.map(product => (
|
|
200
|
-
<ProductCard
|
|
201
|
-
key={product.id}
|
|
202
|
-
product={product}
|
|
203
|
-
currencySymbol={currencySymbol}
|
|
204
|
-
onViewSimilar={onViewSimilar}
|
|
205
|
-
onToggleWishlist={onToggleWishlist}
|
|
206
|
-
onProductClick={onProductClick}
|
|
207
|
-
/>
|
|
208
|
-
))}
|
|
209
|
-
</div>
|
|
210
|
-
</div>
|
|
211
|
-
</section>
|
|
212
|
-
)
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
export default ProductCard3
|