@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
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { GalleryHorizontalEnd, Heart, ShoppingCart, Star } from 'lucide-react'
|
|
4
|
+
import { motion, useReducedMotion } from 'motion/react'
|
|
5
|
+
|
|
6
|
+
import { Badge } from '../../../ui/badge'
|
|
7
|
+
import { Button } from '../../../ui/button'
|
|
8
|
+
import { Card, CardContent, CardFooter } from '../../../ui/card'
|
|
9
|
+
import { cn } from '../../../lib/utils'
|
|
10
|
+
import { formatMoney, isAvailableMoney } from '../../../lib/money'
|
|
11
|
+
import type { ProductCardSpecProps, ProductPresentation } from '../../../lib/contracts'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The bounded presentation surface. `classic` is the boxed card with an
|
|
15
|
+
* add-to-cart footer, `minimal` is a lean image-forward tile, and `rich` adds
|
|
16
|
+
* overlay actions (wishlist heart, view similar). The value changes structure,
|
|
17
|
+
* never the data contract or the callbacks.
|
|
18
|
+
*/
|
|
19
|
+
export type ProductCardPresentation = NonNullable<ProductCardSpecProps['presentation']>
|
|
20
|
+
|
|
21
|
+
export interface ProductCardProps {
|
|
22
|
+
/** The single canonical product presentation item (canonical `Money`). */
|
|
23
|
+
item: ProductPresentation
|
|
24
|
+
/** Bounded presentation option; defaults to `classic`. */
|
|
25
|
+
presentation?: ProductCardPresentation
|
|
26
|
+
/** Host-resolved wishlist membership; reflects the rich presentation heart. */
|
|
27
|
+
wishlisted?: boolean
|
|
28
|
+
onAddToCart?: (item: ProductPresentation) => void
|
|
29
|
+
/**
|
|
30
|
+
* Hides the add-to-cart control for listings whose only action is opening the
|
|
31
|
+
* product. Without this a host with no cart callback would render an inert
|
|
32
|
+
* button.
|
|
33
|
+
*/
|
|
34
|
+
hideAddToCart?: boolean
|
|
35
|
+
onProductClick?: (item: ProductPresentation) => void
|
|
36
|
+
/**
|
|
37
|
+
* Host-owned wishlist toggle; reports the item and the next state from the
|
|
38
|
+
* click handler (never from an effect).
|
|
39
|
+
*/
|
|
40
|
+
onToggleWishlist?: (item: ProductPresentation, nextState: boolean) => void
|
|
41
|
+
onViewSimilar?: (item: ProductPresentation) => void
|
|
42
|
+
addToCartLabel?: string
|
|
43
|
+
/** Shown when a price is present but unavailable (never a zero amount). */
|
|
44
|
+
unavailablePriceLabel?: string
|
|
45
|
+
addToWishlistLabel?: string
|
|
46
|
+
removeFromWishlistLabel?: string
|
|
47
|
+
viewSimilarLabel?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What the presentations receive: every label already resolved by `ProductCard`. */
|
|
51
|
+
type PresentationProps = ProductCardProps & {
|
|
52
|
+
addToCartLabel: string
|
|
53
|
+
unavailablePriceLabel: string
|
|
54
|
+
addToWishlistLabel: string
|
|
55
|
+
removeFromWishlistLabel: string
|
|
56
|
+
viewSimilarLabel: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Comparison price only when both values are valid and the compare is higher. */
|
|
60
|
+
function compareAtText(item: ProductPresentation): string | undefined {
|
|
61
|
+
const compareAt = item.compareAtPrice
|
|
62
|
+
if (!compareAt) return undefined
|
|
63
|
+
const formatted = formatMoney(compareAt)
|
|
64
|
+
const price = formatMoney(item.price)
|
|
65
|
+
if (!formatted || !price) return undefined
|
|
66
|
+
if (Number(compareAt.amount) <= Number(item.price.amount)) return undefined
|
|
67
|
+
return formatted
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ratingValue(item: ProductPresentation): number | undefined {
|
|
71
|
+
return typeof item.rating === 'number' && Number.isFinite(item.rating) ? item.rating : undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function PriceRow({
|
|
75
|
+
item,
|
|
76
|
+
unavailablePriceLabel,
|
|
77
|
+
}: {
|
|
78
|
+
item: ProductPresentation
|
|
79
|
+
unavailablePriceLabel: string
|
|
80
|
+
}) {
|
|
81
|
+
const available = isAvailableMoney(item.price)
|
|
82
|
+
const compareAt = compareAtText(item)
|
|
83
|
+
return (
|
|
84
|
+
<div className='mt-auto flex items-baseline gap-2'>
|
|
85
|
+
<p className='font-semibold'>
|
|
86
|
+
{available ? formatMoney(item.price) : unavailablePriceLabel}
|
|
87
|
+
</p>
|
|
88
|
+
{compareAt && (
|
|
89
|
+
<p className='text-muted-foreground text-sm line-through md:text-base xl:text-sm 2xl:text-base'>
|
|
90
|
+
{compareAt}
|
|
91
|
+
</p>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function RatingRow({ value }: { value: number }) {
|
|
98
|
+
return (
|
|
99
|
+
<div className='text-muted-foreground flex items-center gap-1 text-xs'>
|
|
100
|
+
<Star className='size-3.5 fill-current' aria-hidden='true' />
|
|
101
|
+
<span>{value.toFixed(1)}</span>
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function ClassicPresentation({
|
|
107
|
+
item,
|
|
108
|
+
hideAddToCart = false,
|
|
109
|
+
onAddToCart,
|
|
110
|
+
onProductClick,
|
|
111
|
+
addToCartLabel,
|
|
112
|
+
unavailablePriceLabel,
|
|
113
|
+
}: PresentationProps) {
|
|
114
|
+
const reduceMotion = useReducedMotion()
|
|
115
|
+
const available = isAvailableMoney(item.price)
|
|
116
|
+
const rating = ratingValue(item)
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<motion.div
|
|
120
|
+
initial={reduceMotion ? false : { opacity: 0, y: 16 }}
|
|
121
|
+
whileInView={{ opacity: 1, y: 0 }}
|
|
122
|
+
viewport={{ once: true, margin: '-40px' }}
|
|
123
|
+
whileHover={reduceMotion ? undefined : { y: -4 }}
|
|
124
|
+
transition={{ duration: 0.35, ease: 'easeOut' }}
|
|
125
|
+
>
|
|
126
|
+
<Card
|
|
127
|
+
className={`flex h-full flex-col gap-4 overflow-hidden rounded-lg py-4 shadow-none transition-shadow duration-300 hover:shadow-md ${
|
|
128
|
+
onProductClick ? 'cursor-pointer' : ''
|
|
129
|
+
}`}
|
|
130
|
+
onClick={onProductClick ? () => onProductClick(item) : undefined}
|
|
131
|
+
>
|
|
132
|
+
<CardContent className='flex flex-1 flex-col gap-4 px-4 '>
|
|
133
|
+
<div className='relative aspect-square overflow-hidden rounded-md'>
|
|
134
|
+
{item.badge && (
|
|
135
|
+
<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'>
|
|
136
|
+
{item.badge}
|
|
137
|
+
</span>
|
|
138
|
+
)}
|
|
139
|
+
{item.image ? (
|
|
140
|
+
<img
|
|
141
|
+
src={item.image}
|
|
142
|
+
alt={item.imageAlt ?? item.title}
|
|
143
|
+
className='size-full rounded-md object-contain'
|
|
144
|
+
loading='lazy'
|
|
145
|
+
width={400}
|
|
146
|
+
height={400}
|
|
147
|
+
/>
|
|
148
|
+
) : (
|
|
149
|
+
<div data-slot='product-image-placeholder' aria-hidden='true' className='bg-muted size-full rounded-md' />
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div className='flex flex-1 flex-col'>
|
|
154
|
+
<h3 className='mb-1 font-medium text-balance'>
|
|
155
|
+
{onProductClick ? (
|
|
156
|
+
<button
|
|
157
|
+
type='button'
|
|
158
|
+
onClick={(e) => {
|
|
159
|
+
e.stopPropagation()
|
|
160
|
+
onProductClick(item)
|
|
161
|
+
}}
|
|
162
|
+
className='hover:text-primary cursor-pointer text-left transition-colors'
|
|
163
|
+
>
|
|
164
|
+
{item.title}
|
|
165
|
+
</button>
|
|
166
|
+
) : (
|
|
167
|
+
item.title
|
|
168
|
+
)}
|
|
169
|
+
</h3>
|
|
170
|
+
{rating !== undefined && <RatingRow value={rating} />}
|
|
171
|
+
<PriceRow item={item} unavailablePriceLabel={unavailablePriceLabel} />
|
|
172
|
+
</div>
|
|
173
|
+
</CardContent>
|
|
174
|
+
|
|
175
|
+
<CardFooter className='bg-transparent px-3 pt-0 border-t-0 md:px-4'>
|
|
176
|
+
{hideAddToCart ? null : (
|
|
177
|
+
<Button
|
|
178
|
+
variant='outline'
|
|
179
|
+
size='sm'
|
|
180
|
+
className='h-8 w-full cursor-pointer px-3 text-sm text-xs'
|
|
181
|
+
disabled={!available}
|
|
182
|
+
onClick={(e) => {
|
|
183
|
+
e.stopPropagation()
|
|
184
|
+
onAddToCart?.(item)
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
{addToCartLabel}
|
|
188
|
+
</Button>
|
|
189
|
+
)}
|
|
190
|
+
</CardFooter>
|
|
191
|
+
</Card>
|
|
192
|
+
</motion.div>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function MinimalPresentation({
|
|
197
|
+
item,
|
|
198
|
+
hideAddToCart = false,
|
|
199
|
+
onAddToCart,
|
|
200
|
+
onProductClick,
|
|
201
|
+
addToCartLabel,
|
|
202
|
+
unavailablePriceLabel,
|
|
203
|
+
}: PresentationProps) {
|
|
204
|
+
const available = isAvailableMoney(item.price)
|
|
205
|
+
const rating = ratingValue(item)
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<Card
|
|
209
|
+
className={cn(
|
|
210
|
+
'group overflow-hidden py-4 shadow-none transition-all hover:shadow-lg',
|
|
211
|
+
onProductClick && 'cursor-pointer',
|
|
212
|
+
)}
|
|
213
|
+
onClick={onProductClick ? () => onProductClick(item) : undefined}
|
|
214
|
+
>
|
|
215
|
+
<CardContent className='flex flex-col gap-4 px-4'>
|
|
216
|
+
<div className='relative aspect-square overflow-hidden rounded-md'>
|
|
217
|
+
{item.badge && (
|
|
218
|
+
<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'>
|
|
219
|
+
{item.badge}
|
|
220
|
+
</span>
|
|
221
|
+
)}
|
|
222
|
+
{item.image ? (
|
|
223
|
+
<img
|
|
224
|
+
src={item.image}
|
|
225
|
+
alt={item.imageAlt ?? item.title}
|
|
226
|
+
width={400}
|
|
227
|
+
height={400}
|
|
228
|
+
className='size-full object-cover transition-transform duration-300 group-hover:scale-105'
|
|
229
|
+
loading='lazy'
|
|
230
|
+
/>
|
|
231
|
+
) : (
|
|
232
|
+
<div data-slot='product-image-placeholder' aria-hidden='true' className='bg-muted size-full' />
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
<div className='flex flex-col gap-2'>
|
|
237
|
+
<h3 className='line-clamp-1 text-lg font-semibold text-balance'>{item.title}</h3>
|
|
238
|
+
{rating !== undefined && <RatingRow value={rating} />}
|
|
239
|
+
<div className='flex items-center justify-between gap-2'>
|
|
240
|
+
<PriceRow item={item} unavailablePriceLabel={unavailablePriceLabel} />
|
|
241
|
+
{hideAddToCart ? null : (
|
|
242
|
+
<Button
|
|
243
|
+
variant='ghost'
|
|
244
|
+
size='icon'
|
|
245
|
+
className='size-8 shrink-0 cursor-pointer'
|
|
246
|
+
aria-label={addToCartLabel}
|
|
247
|
+
disabled={!available}
|
|
248
|
+
onClick={(e) => {
|
|
249
|
+
e.stopPropagation()
|
|
250
|
+
onAddToCart?.(item)
|
|
251
|
+
}}
|
|
252
|
+
>
|
|
253
|
+
<ShoppingCart className='size-4' aria-hidden='true' />
|
|
254
|
+
</Button>
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
</CardContent>
|
|
259
|
+
</Card>
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function WishlistHeart({
|
|
264
|
+
item,
|
|
265
|
+
wishlisted,
|
|
266
|
+
onToggleWishlist,
|
|
267
|
+
addToWishlistLabel,
|
|
268
|
+
removeFromWishlistLabel,
|
|
269
|
+
}: {
|
|
270
|
+
item: ProductPresentation
|
|
271
|
+
wishlisted: boolean
|
|
272
|
+
onToggleWishlist?: (item: ProductPresentation, nextState: boolean) => void
|
|
273
|
+
addToWishlistLabel: string
|
|
274
|
+
removeFromWishlistLabel: string
|
|
275
|
+
}) {
|
|
276
|
+
return (
|
|
277
|
+
<Button
|
|
278
|
+
variant='ghost'
|
|
279
|
+
size='icon'
|
|
280
|
+
className='bg-background/80 absolute end-2 top-2 z-20 size-8 cursor-pointer rounded-full p-0 backdrop-blur-xs disabled:cursor-not-allowed'
|
|
281
|
+
aria-label={wishlisted ? removeFromWishlistLabel : addToWishlistLabel}
|
|
282
|
+
aria-pressed={wishlisted}
|
|
283
|
+
disabled={!onToggleWishlist}
|
|
284
|
+
onClick={(e) => {
|
|
285
|
+
e.stopPropagation()
|
|
286
|
+
onToggleWishlist?.(item, !wishlisted)
|
|
287
|
+
}}
|
|
288
|
+
>
|
|
289
|
+
<Heart
|
|
290
|
+
className={cn(
|
|
291
|
+
'size-4 transition-colors',
|
|
292
|
+
wishlisted ? 'fill-destructive text-destructive' : 'fill-transparent text-muted-foreground',
|
|
293
|
+
)}
|
|
294
|
+
aria-hidden='true'
|
|
295
|
+
/>
|
|
296
|
+
</Button>
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function RichPresentation({
|
|
301
|
+
item,
|
|
302
|
+
hideAddToCart = false,
|
|
303
|
+
wishlisted = false,
|
|
304
|
+
onAddToCart,
|
|
305
|
+
onProductClick,
|
|
306
|
+
onToggleWishlist,
|
|
307
|
+
onViewSimilar,
|
|
308
|
+
addToCartLabel,
|
|
309
|
+
unavailablePriceLabel,
|
|
310
|
+
addToWishlistLabel,
|
|
311
|
+
removeFromWishlistLabel,
|
|
312
|
+
viewSimilarLabel,
|
|
313
|
+
}: PresentationProps) {
|
|
314
|
+
const available = isAvailableMoney(item.price)
|
|
315
|
+
const rating = ratingValue(item)
|
|
316
|
+
|
|
317
|
+
return (
|
|
318
|
+
<Card
|
|
319
|
+
className={cn(
|
|
320
|
+
'relative overflow-hidden rounded-md py-0 shadow-none transition-all hover:shadow-md',
|
|
321
|
+
onProductClick && 'cursor-pointer',
|
|
322
|
+
)}
|
|
323
|
+
onClick={onProductClick ? () => onProductClick(item) : undefined}
|
|
324
|
+
>
|
|
325
|
+
<CardContent className='px-0'>
|
|
326
|
+
<div className='relative aspect-square w-full overflow-hidden'>
|
|
327
|
+
{item.image ? (
|
|
328
|
+
<img
|
|
329
|
+
src={item.image}
|
|
330
|
+
alt={item.imageAlt ?? item.title}
|
|
331
|
+
className='size-full object-cover'
|
|
332
|
+
loading='lazy'
|
|
333
|
+
/>
|
|
334
|
+
) : (
|
|
335
|
+
<div data-slot='product-image-placeholder' aria-hidden='true' className='bg-muted size-full' />
|
|
336
|
+
)}
|
|
337
|
+
|
|
338
|
+
{item.badge && (
|
|
339
|
+
<Badge className='absolute start-2 top-2 z-20 flex items-center gap-1 px-2 py-1 font-semibold text-white backdrop-blur-xs'>
|
|
340
|
+
{item.badge}
|
|
341
|
+
</Badge>
|
|
342
|
+
)}
|
|
343
|
+
|
|
344
|
+
{rating !== undefined && (
|
|
345
|
+
<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'>
|
|
346
|
+
<Star className='fill-foreground text-foreground size-4' aria-hidden='true' />
|
|
347
|
+
<span>{rating.toFixed(1)}</span>
|
|
348
|
+
</div>
|
|
349
|
+
)}
|
|
350
|
+
|
|
351
|
+
{onViewSimilar && (
|
|
352
|
+
<div className='group absolute end-2 bottom-2 z-10'>
|
|
353
|
+
<Button
|
|
354
|
+
variant='ghost'
|
|
355
|
+
size='sm'
|
|
356
|
+
className='group bg-background/80 hover:bg-background h-8 cursor-pointer overflow-hidden rounded-full p-0 px-3 text-xs shadow-xs backdrop-blur-xs transition-all duration-200'
|
|
357
|
+
aria-label={viewSimilarLabel}
|
|
358
|
+
onClick={(e) => {
|
|
359
|
+
e.stopPropagation()
|
|
360
|
+
onViewSimilar(item)
|
|
361
|
+
}}
|
|
362
|
+
>
|
|
363
|
+
<GalleryHorizontalEnd className='absolute start-2 size-4 shrink-0' aria-hidden='true' />
|
|
364
|
+
<span className='ms-6 whitespace-nowrap text-xs opacity-0 group-hover:opacity-100'>
|
|
365
|
+
{viewSimilarLabel}
|
|
366
|
+
</span>
|
|
367
|
+
</Button>
|
|
368
|
+
</div>
|
|
369
|
+
)}
|
|
370
|
+
|
|
371
|
+
<WishlistHeart
|
|
372
|
+
item={item}
|
|
373
|
+
wishlisted={wishlisted}
|
|
374
|
+
onToggleWishlist={onToggleWishlist}
|
|
375
|
+
addToWishlistLabel={addToWishlistLabel}
|
|
376
|
+
removeFromWishlistLabel={removeFromWishlistLabel}
|
|
377
|
+
/>
|
|
378
|
+
</div>
|
|
379
|
+
|
|
380
|
+
<div className='flex flex-col gap-2 p-4'>
|
|
381
|
+
<h3 className='font-medium text-balance'>{item.title}</h3>
|
|
382
|
+
<PriceRow item={item} unavailablePriceLabel={unavailablePriceLabel} />
|
|
383
|
+
{hideAddToCart ? null : (
|
|
384
|
+
<Button
|
|
385
|
+
variant='outline'
|
|
386
|
+
size='sm'
|
|
387
|
+
className='mt-2 w-full cursor-pointer text-xs'
|
|
388
|
+
disabled={!available}
|
|
389
|
+
onClick={(e) => {
|
|
390
|
+
e.stopPropagation()
|
|
391
|
+
onAddToCart?.(item)
|
|
392
|
+
}}
|
|
393
|
+
>
|
|
394
|
+
{addToCartLabel}
|
|
395
|
+
</Button>
|
|
396
|
+
)}
|
|
397
|
+
</div>
|
|
398
|
+
</CardContent>
|
|
399
|
+
</Card>
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Canonical product card — the item cell of a product listing. Renders only
|
|
405
|
+
* the canonical `ProductPresentation` contract (canonical `Money`) with
|
|
406
|
+
* host-supplied evidence (rating, badge, compare-at); it owns no listing state
|
|
407
|
+
* and reports every action through its callbacks. Prices render through the
|
|
408
|
+
* canonical `formatMoney` path, so a missing price is an unavailable state,
|
|
409
|
+
* never a zero amount.
|
|
410
|
+
*/
|
|
411
|
+
export function ProductCard({
|
|
412
|
+
item,
|
|
413
|
+
presentation = 'classic',
|
|
414
|
+
wishlisted,
|
|
415
|
+
hideAddToCart,
|
|
416
|
+
onAddToCart,
|
|
417
|
+
onProductClick,
|
|
418
|
+
onToggleWishlist,
|
|
419
|
+
onViewSimilar,
|
|
420
|
+
addToCartLabel = 'Agregar al carrito',
|
|
421
|
+
unavailablePriceLabel = 'Precio no disponible',
|
|
422
|
+
addToWishlistLabel = 'Añadir a favoritos',
|
|
423
|
+
removeFromWishlistLabel = 'Quitar de favoritos',
|
|
424
|
+
viewSimilarLabel = 'Ver similares',
|
|
425
|
+
}: ProductCardProps) {
|
|
426
|
+
const shared: PresentationProps = {
|
|
427
|
+
item,
|
|
428
|
+
presentation,
|
|
429
|
+
wishlisted,
|
|
430
|
+
hideAddToCart,
|
|
431
|
+
onAddToCart,
|
|
432
|
+
onProductClick,
|
|
433
|
+
onToggleWishlist,
|
|
434
|
+
onViewSimilar,
|
|
435
|
+
addToCartLabel,
|
|
436
|
+
unavailablePriceLabel,
|
|
437
|
+
addToWishlistLabel,
|
|
438
|
+
removeFromWishlistLabel,
|
|
439
|
+
viewSimilarLabel,
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (presentation === 'minimal') return <MinimalPresentation {...shared} />
|
|
443
|
+
if (presentation === 'rich') return <RichPresentation {...shared} />
|
|
444
|
+
return <ClassicPresentation {...shared} />
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export default ProductCard
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
4
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
6
|
+
|
|
7
|
+
import { ProductGrid, type ProductGridProps } from './product-grid'
|
|
8
|
+
import type { ProductPresentation } from '../../../lib/contracts'
|
|
9
|
+
|
|
10
|
+
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
|
11
|
+
|
|
12
|
+
// jsdom lacks the browser APIs the classic presentation's motion layer touches.
|
|
13
|
+
if (!window.matchMedia) {
|
|
14
|
+
window.matchMedia = ((query: string) => ({
|
|
15
|
+
matches: false,
|
|
16
|
+
media: query,
|
|
17
|
+
onchange: null,
|
|
18
|
+
addEventListener: () => {},
|
|
19
|
+
removeEventListener: () => {},
|
|
20
|
+
addListener: () => {},
|
|
21
|
+
removeListener: () => {},
|
|
22
|
+
dispatchEvent: () => false,
|
|
23
|
+
})) as unknown as typeof window.matchMedia
|
|
24
|
+
}
|
|
25
|
+
if (!('IntersectionObserver' in window)) {
|
|
26
|
+
;(window as Record<string, unknown>).IntersectionObserver = class {
|
|
27
|
+
observe() {}
|
|
28
|
+
unobserve() {}
|
|
29
|
+
disconnect() {}
|
|
30
|
+
takeRecords() {
|
|
31
|
+
return []
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const baseItem: ProductPresentation = {
|
|
37
|
+
id: 'p1',
|
|
38
|
+
title: 'Llave de impacto',
|
|
39
|
+
image: '/demo/impact.svg',
|
|
40
|
+
imageAlt: 'Llave de impacto inalámbrica',
|
|
41
|
+
price: { amount: '129.50', currencyCode: 'EUR' },
|
|
42
|
+
rating: 4.5,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const items = (count: number): ProductPresentation[] =>
|
|
46
|
+
Array.from({ length: count }, (_, i) => ({
|
|
47
|
+
...baseItem,
|
|
48
|
+
id: `p${i + 1}`,
|
|
49
|
+
title: `Producto ${i + 1}`,
|
|
50
|
+
}))
|
|
51
|
+
|
|
52
|
+
const renderMarkup = (props: ProductGridProps) => renderToStaticMarkup(<ProductGrid {...props} />)
|
|
53
|
+
|
|
54
|
+
const countOccurrences = (html: string, needle: string) => html.split(needle).length - 1
|
|
55
|
+
|
|
56
|
+
describe('ProductGrid — renders a collection of item cells', () => {
|
|
57
|
+
it('renders one ProductCard cell per item, in the supplied order', () => {
|
|
58
|
+
const html = renderMarkup({ items: items(3) })
|
|
59
|
+
expect(html).toContain('>Producto 1<')
|
|
60
|
+
expect(html).toContain('>Producto 2<')
|
|
61
|
+
expect(html).toContain('>Producto 3<')
|
|
62
|
+
expect(countOccurrences(html, 'Agregar al carrito')).toBe(3)
|
|
63
|
+
expect(html.indexOf('Producto 1')).toBeLessThan(html.indexOf('Producto 2'))
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('renders the optional heading as section chrome', () => {
|
|
67
|
+
const html = renderMarkup({ items: items(1), heading: 'Oferta' })
|
|
68
|
+
expect(html).toContain('<section')
|
|
69
|
+
expect(html).toContain('<h2')
|
|
70
|
+
expect(html).toContain('>Oferta<')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('slices items to maxItems when supplied', () => {
|
|
74
|
+
const html = renderMarkup({ items: items(8), maxItems: 2 })
|
|
75
|
+
expect(html).toContain('>Producto 2<')
|
|
76
|
+
expect(html).not.toContain('>Producto 3<')
|
|
77
|
+
expect(countOccurrences(html, 'Agregar al carrito')).toBe(2)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('forwards the bounded presentation to every cell', () => {
|
|
81
|
+
const html = renderMarkup({ items: items(2), presentation: 'minimal' })
|
|
82
|
+
expect(html).not.toContain('>Agregar al carrito<')
|
|
83
|
+
expect(countOccurrences(html, 'aria-label="Agregar al carrito"')).toBe(2)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('ProductGrid — owns the listing states', () => {
|
|
88
|
+
it('renders skeleton placeholders while loading instead of cells', () => {
|
|
89
|
+
const html = renderMarkup({ items: items(3), loading: true })
|
|
90
|
+
expect(html).toContain('data-slot="skeleton"')
|
|
91
|
+
expect(html).toContain('aria-busy="true"')
|
|
92
|
+
expect(html).not.toContain('Llave de impacto')
|
|
93
|
+
expect(html).not.toContain('Agregar al carrito')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('defaults to six skeletons and honors skeletonCount', () => {
|
|
97
|
+
expect(countOccurrences(renderMarkup({ items: [], loading: true }), 'data-slot="skeleton"')).toBe(18)
|
|
98
|
+
expect(
|
|
99
|
+
countOccurrences(
|
|
100
|
+
renderMarkup({ items: [], loading: true, skeletonCount: 1 }),
|
|
101
|
+
'data-slot="skeleton"',
|
|
102
|
+
),
|
|
103
|
+
).toBe(3)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('renders an empty state with default copy instead of a blank grid', () => {
|
|
107
|
+
const html = renderMarkup({ items: [] })
|
|
108
|
+
expect(html).toContain('No hay productos para mostrar.')
|
|
109
|
+
expect(html).not.toContain('Agregar al carrito')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('uses the overridable empty label', () => {
|
|
113
|
+
expect(renderMarkup({ items: [], emptyLabel: 'Sin productos' })).toContain('Sin productos')
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('ProductGrid — resolves wishlist membership per cell', () => {
|
|
118
|
+
it('passes each cell only its own wishlisted boolean', () => {
|
|
119
|
+
const html = renderMarkup({
|
|
120
|
+
items: items(2),
|
|
121
|
+
presentation: 'rich',
|
|
122
|
+
wishlistedIds: ['p1'],
|
|
123
|
+
})
|
|
124
|
+
expect(html).toContain('aria-pressed="true"')
|
|
125
|
+
expect(html).toContain('aria-pressed="false"')
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('ProductGrid — actions bubble from the cells', () => {
|
|
130
|
+
let container: HTMLDivElement
|
|
131
|
+
let root: Root
|
|
132
|
+
let mounted: Root | undefined
|
|
133
|
+
|
|
134
|
+
const mount = (props: ProductGridProps) => {
|
|
135
|
+
root = createRoot(container)
|
|
136
|
+
mounted = root
|
|
137
|
+
act(() => root.render(<ProductGrid {...props} />))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
beforeEach(() => {
|
|
141
|
+
container = document.createElement('div')
|
|
142
|
+
document.body.appendChild(container)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
afterEach(() => {
|
|
146
|
+
const root = mounted
|
|
147
|
+
if (root) act(() => root.unmount())
|
|
148
|
+
container.remove()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('reports the identity of the clicked cell item', () => {
|
|
152
|
+
const onAddToCart = vi.fn()
|
|
153
|
+
mount({ items: items(2), onAddToCart })
|
|
154
|
+
const buttons = Array.from(container.querySelectorAll('button')).filter((b) =>
|
|
155
|
+
b.textContent?.includes('Agregar al carrito'),
|
|
156
|
+
)
|
|
157
|
+
act(() => {
|
|
158
|
+
buttons[1]!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
159
|
+
})
|
|
160
|
+
expect(onAddToCart).toHaveBeenCalledWith(expect.objectContaining({ id: 'p2' }))
|
|
161
|
+
})
|
|
162
|
+
})
|