@thorprovider/blocks 0.1.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +107 -0
- package/README.md +108 -23
- package/docs/standards/data-contracts.md +13 -2
- package/package.json +26 -10
- package/scripts/fix-esm-extensions.mjs +40 -0
- package/src/blocks/e-commerce/navigation/back-to-top.test.tsx +27 -0
- package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
- package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
- package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
- package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
- package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
- package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
- package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
- 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 +424 -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-list/product-showcase-1.test.tsx +78 -0
- package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -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} +204 -109
- 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/navbars/navbar-4-account-menu.test.tsx +56 -0
- package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +150 -0
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +102 -9
- package/src/blocks/marketing/navbars/navbar-4.test.tsx +154 -2
- package/src/blocks/marketing/navbars/navbar-4.tsx +152 -32
- package/src/index.ts +93 -46
- package/src/lib/contracts-curated.test.ts +42 -24
- package/src/lib/contracts.test.ts +143 -2
- package/src/lib/contracts.ts +395 -13
- 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 -37
- package/.turbo/turbo-type-check.log +0 -0
- package/dist/.tsbuildinfo +0 -1
- package/dist/contracts.d.mts +0 -1584
- package/dist/contracts.d.ts +0 -1584
- package/dist/contracts.js +0 -390
- package/dist/contracts.js.map +0 -1
- package/dist/contracts.mjs +0 -334
- package/dist/contracts.mjs.map +0 -1
- package/dist/index.d.mts +0 -2786
- package/dist/index.d.ts +0 -2786
- package/dist/index.js +0 -17139
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -17059
- package/dist/index.mjs.map +0 -1
- 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.tsx +0 -131
- 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 -137
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -65
- 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
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
|
|
5
|
+
import { Skeleton } from '../../../ui/skeleton'
|
|
6
|
+
import type { ProductGridSpecProps, ProductPresentation } from '../../../lib/contracts'
|
|
7
|
+
import { ProductCard } from './product-card'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Props for the canonical `ProductGrid` listing. The serializable spec props
|
|
11
|
+
* (`ProductGridSpecProps`) plus the runtime-only action callbacks and label
|
|
12
|
+
* strings, which the grid forwards to each cell.
|
|
13
|
+
*/
|
|
14
|
+
export interface ProductGridProps extends ProductGridSpecProps {
|
|
15
|
+
onAddToCart?: (item: ProductPresentation) => void
|
|
16
|
+
onProductClick?: (item: ProductPresentation) => void
|
|
17
|
+
/** Host-owned wishlist toggle; reports the item and the next state. */
|
|
18
|
+
onToggleWishlist?: (item: ProductPresentation, nextState: boolean) => void
|
|
19
|
+
onViewSimilar?: (item: ProductPresentation) => void
|
|
20
|
+
addToCartLabel?: string
|
|
21
|
+
/** Shown when a price is present but unavailable (never a zero amount). */
|
|
22
|
+
unavailablePriceLabel?: string
|
|
23
|
+
addToWishlistLabel?: string
|
|
24
|
+
removeFromWishlistLabel?: string
|
|
25
|
+
viewSimilarLabel?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const GRID_CLASSES = 'grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:gap-6'
|
|
29
|
+
|
|
30
|
+
function SkeletonCell() {
|
|
31
|
+
return (
|
|
32
|
+
<div className='flex flex-col gap-3'>
|
|
33
|
+
<Skeleton className='aspect-square w-full rounded-md' />
|
|
34
|
+
<Skeleton className='h-4 w-3/4' />
|
|
35
|
+
<Skeleton className='h-4 w-1/3' />
|
|
36
|
+
</div>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Canonical product grid — the listing that renders a collection of product
|
|
42
|
+
* items as a grid of `ProductCard` item cells. It owns the section chrome
|
|
43
|
+
* (heading), the responsive layout, the item cap, wishlist membership
|
|
44
|
+
* resolution, and the loading/empty/ready states; the host owns data errors.
|
|
45
|
+
*/
|
|
46
|
+
export function ProductGrid({
|
|
47
|
+
items,
|
|
48
|
+
presentation,
|
|
49
|
+
heading,
|
|
50
|
+
maxItems,
|
|
51
|
+
wishlistedIds,
|
|
52
|
+
loading = false,
|
|
53
|
+
skeletonCount = 6,
|
|
54
|
+
emptyLabel = 'No hay productos para mostrar.',
|
|
55
|
+
onAddToCart,
|
|
56
|
+
onProductClick,
|
|
57
|
+
onToggleWishlist,
|
|
58
|
+
onViewSimilar,
|
|
59
|
+
addToCartLabel,
|
|
60
|
+
unavailablePriceLabel,
|
|
61
|
+
addToWishlistLabel,
|
|
62
|
+
removeFromWishlistLabel,
|
|
63
|
+
viewSimilarLabel,
|
|
64
|
+
}: ProductGridProps) {
|
|
65
|
+
const displayedItems = maxItems !== undefined ? items.slice(0, maxItems) : items
|
|
66
|
+
|
|
67
|
+
let body: ReactNode
|
|
68
|
+
if (loading) {
|
|
69
|
+
body = (
|
|
70
|
+
<div role='status' aria-busy='true' className={GRID_CLASSES}>
|
|
71
|
+
{Array.from({ length: skeletonCount }, (_, index) => (
|
|
72
|
+
<SkeletonCell key={index} />
|
|
73
|
+
))}
|
|
74
|
+
</div>
|
|
75
|
+
)
|
|
76
|
+
} else if (displayedItems.length === 0) {
|
|
77
|
+
body = <p className='text-muted-foreground text-center'>{emptyLabel}</p>
|
|
78
|
+
} else {
|
|
79
|
+
body = (
|
|
80
|
+
<div className={GRID_CLASSES}>
|
|
81
|
+
{displayedItems.map((item) => (
|
|
82
|
+
<ProductCard
|
|
83
|
+
key={item.id}
|
|
84
|
+
item={item}
|
|
85
|
+
presentation={presentation}
|
|
86
|
+
wishlisted={wishlistedIds ? wishlistedIds.includes(item.id) : undefined}
|
|
87
|
+
onAddToCart={onAddToCart}
|
|
88
|
+
onProductClick={onProductClick}
|
|
89
|
+
onToggleWishlist={onToggleWishlist}
|
|
90
|
+
onViewSimilar={onViewSimilar}
|
|
91
|
+
addToCartLabel={addToCartLabel}
|
|
92
|
+
unavailablePriceLabel={unavailablePriceLabel}
|
|
93
|
+
addToWishlistLabel={addToWishlistLabel}
|
|
94
|
+
removeFromWishlistLabel={removeFromWishlistLabel}
|
|
95
|
+
viewSimilarLabel={viewSimilarLabel}
|
|
96
|
+
/>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<section className='py-12'>
|
|
104
|
+
<div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
|
|
105
|
+
{heading && (
|
|
106
|
+
<h2 className='mb-8 text-center text-2xl font-bold text-balance md:text-3xl'>{heading}</h2>
|
|
107
|
+
)}
|
|
108
|
+
{body}
|
|
109
|
+
</div>
|
|
110
|
+
</section>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export default ProductGrid
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { ProductShowcase1, type ProductShowcase1Props } from './product-showcase-1'
|
|
4
|
+
import type { ProductPresentation } from '../../../lib/contracts'
|
|
5
|
+
|
|
6
|
+
const product = (index: number): ProductPresentation => ({
|
|
7
|
+
id: `sku-${index}`,
|
|
8
|
+
title: `Producto ${index}`,
|
|
9
|
+
price: { amount: `${index}.00`, currencyCode: 'EUR' },
|
|
10
|
+
image: `/demo/${index}.svg`,
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const seven = Array.from({ length: 7 }, (_, i) => product(i + 1))
|
|
14
|
+
|
|
15
|
+
const render = (props: Partial<ProductShowcase1Props> = {}) =>
|
|
16
|
+
renderToStaticMarkup(<ProductShowcase1 products={seven} pageSize={3} {...props} />)
|
|
17
|
+
|
|
18
|
+
describe('ProductShowcase1 — manual paging', () => {
|
|
19
|
+
it('renders only the first page of products', () => {
|
|
20
|
+
const html = render()
|
|
21
|
+
expect(html).toContain('Producto 1')
|
|
22
|
+
expect(html).toContain('Producto 3')
|
|
23
|
+
expect(html).not.toContain('Producto 4')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('renders visible prev/next controls with accessible labels', () => {
|
|
27
|
+
const html = render()
|
|
28
|
+
expect(html).toContain('aria-label="Anterior"')
|
|
29
|
+
expect(html).toContain('aria-label="Siguiente"')
|
|
30
|
+
expect(html).toContain('disabled')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('renders one page indicator per page with the current page marked', () => {
|
|
34
|
+
const html = render()
|
|
35
|
+
const dots = html.match(/aria-label="Página \d+"/g) ?? []
|
|
36
|
+
expect(dots).toHaveLength(3)
|
|
37
|
+
expect(html).toContain('aria-current="true"')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('exposes a polite live region with the current page position', () => {
|
|
41
|
+
const html = render()
|
|
42
|
+
expect(html).toContain('aria-live="polite"')
|
|
43
|
+
expect(html).toContain('Página 1 / 3')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('never autoplays: no timer is scheduled on render', () => {
|
|
47
|
+
const originalSetInterval = globalThis.setInterval
|
|
48
|
+
let calls = 0
|
|
49
|
+
globalThis.setInterval = ((...args: Parameters<typeof setInterval>) => {
|
|
50
|
+
calls += 1
|
|
51
|
+
return originalSetInterval(...args)
|
|
52
|
+
}) as typeof setInterval
|
|
53
|
+
try {
|
|
54
|
+
render()
|
|
55
|
+
} finally {
|
|
56
|
+
globalThis.setInterval = originalSetInterval
|
|
57
|
+
}
|
|
58
|
+
expect(calls).toBe(0)
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('ProductShowcase1 — truthful states', () => {
|
|
63
|
+
it('renders an empty state instead of fabricated products', () => {
|
|
64
|
+
const html = renderToStaticMarkup(<ProductShowcase1 products={[]} emptyLabel='Sin productos' />)
|
|
65
|
+
expect(html).toContain('Sin productos')
|
|
66
|
+
expect(html).not.toContain('Producto 1')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('renders the unavailable price label for an unresolved price', () => {
|
|
70
|
+
const html = renderToStaticMarkup(
|
|
71
|
+
<ProductShowcase1
|
|
72
|
+
products={[{ id: 'x', title: 'X', price: { amount: '', currencyCode: '' } }]}
|
|
73
|
+
/>,
|
|
74
|
+
)
|
|
75
|
+
expect(html).toContain('Precio no disponible')
|
|
76
|
+
expect(html).not.toContain('$0')
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from 'react'
|
|
4
|
+
import { ChevronLeft, ChevronRight, Star } from 'lucide-react'
|
|
5
|
+
import { motion, useReducedMotion } from 'motion/react'
|
|
6
|
+
|
|
7
|
+
import { Button } from '../../../ui/button'
|
|
8
|
+
import { Card, CardContent, CardFooter } from '../../../ui/card'
|
|
9
|
+
import { formatMoney } from '../../../lib/money'
|
|
10
|
+
import type { ProductPresentation } from '../../../lib/contracts'
|
|
11
|
+
|
|
12
|
+
export interface ProductShowcase1Props {
|
|
13
|
+
products: ProductPresentation[]
|
|
14
|
+
heading?: string
|
|
15
|
+
description?: string
|
|
16
|
+
pageSize?: number
|
|
17
|
+
prevLabel?: string
|
|
18
|
+
nextLabel?: string
|
|
19
|
+
/** Label for the current-page status, e.g. "Página". */
|
|
20
|
+
pageLabel?: string
|
|
21
|
+
unavailablePriceLabel?: string
|
|
22
|
+
emptyLabel?: string
|
|
23
|
+
addToCartLabel?: string
|
|
24
|
+
onAddToCart?: (product: ProductPresentation) => void
|
|
25
|
+
onProductClick?: (product: ProductPresentation) => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEFAULT_PAGE_SIZE = 6
|
|
29
|
+
const SWIPE_THRESHOLD = 48
|
|
30
|
+
|
|
31
|
+
function chunk<T>(items: readonly T[], size: number): T[][] {
|
|
32
|
+
const pages: T[][] = []
|
|
33
|
+
for (let index = 0; index < items.length; index += size) {
|
|
34
|
+
pages.push(items.slice(index, index + size))
|
|
35
|
+
}
|
|
36
|
+
return pages
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function compareAtText(product: ProductPresentation): string | undefined {
|
|
40
|
+
const compareAt = product.compareAtPrice
|
|
41
|
+
if (!compareAt) return undefined
|
|
42
|
+
const formatted = formatMoney(compareAt)
|
|
43
|
+
const price = formatMoney(product.price)
|
|
44
|
+
if (!formatted || !price) return undefined
|
|
45
|
+
if (Number(compareAt.amount) <= Number(product.price.amount)) return undefined
|
|
46
|
+
return formatted
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Combined grid-and-carousel product presentation: fixed-size grid pages
|
|
51
|
+
* navigated manually (prev/next) with a visible current-page indication. There
|
|
52
|
+
* is no autoplay, no auto-advance, and no infinite loop. Controls are real
|
|
53
|
+
* buttons (keyboard operable and visible at every breakpoint); touch swipe is an
|
|
54
|
+
* addition, never the only way to navigate. Below-fold images are lazy.
|
|
55
|
+
*
|
|
56
|
+
* Data access lives in the host: this block only renders the props it receives
|
|
57
|
+
* and reports interactions through callbacks (no backend/router/measurement
|
|
58
|
+
* imports).
|
|
59
|
+
*/
|
|
60
|
+
export function ProductShowcase1({
|
|
61
|
+
products,
|
|
62
|
+
heading,
|
|
63
|
+
description,
|
|
64
|
+
pageSize = DEFAULT_PAGE_SIZE,
|
|
65
|
+
prevLabel = 'Anterior',
|
|
66
|
+
nextLabel = 'Siguiente',
|
|
67
|
+
pageLabel = 'Página',
|
|
68
|
+
unavailablePriceLabel = 'Precio no disponible',
|
|
69
|
+
emptyLabel = 'No hay productos para mostrar.',
|
|
70
|
+
addToCartLabel = 'Agregar al carrito',
|
|
71
|
+
onAddToCart,
|
|
72
|
+
onProductClick,
|
|
73
|
+
}: ProductShowcase1Props) {
|
|
74
|
+
const reduceMotion = useReducedMotion()
|
|
75
|
+
const size = Number.isInteger(pageSize) && pageSize > 0 ? pageSize : DEFAULT_PAGE_SIZE
|
|
76
|
+
const pages = useMemo(() => chunk(products, size), [products, size])
|
|
77
|
+
const pageCount = pages.length
|
|
78
|
+
const [page, setPage] = useState(0)
|
|
79
|
+
const [touchStart, setTouchStart] = useState<number | null>(null)
|
|
80
|
+
const safePage = pageCount === 0 ? 0 : Math.min(page, pageCount - 1)
|
|
81
|
+
|
|
82
|
+
const goTo = (next: number) => {
|
|
83
|
+
if (pageCount === 0) return
|
|
84
|
+
if (next < 0 || next >= pageCount) return
|
|
85
|
+
setPage(next)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (pageCount === 0) {
|
|
89
|
+
return (
|
|
90
|
+
<section className='py-12'>
|
|
91
|
+
<div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
|
|
92
|
+
{heading && (
|
|
93
|
+
<h2 className='mb-4 text-center text-2xl font-bold text-balance md:text-3xl'>
|
|
94
|
+
{heading}
|
|
95
|
+
</h2>
|
|
96
|
+
)}
|
|
97
|
+
<p className='text-muted-foreground text-center'>{emptyLabel}</p>
|
|
98
|
+
</div>
|
|
99
|
+
</section>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const visible = pages[safePage]
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<section
|
|
107
|
+
className='py-12'
|
|
108
|
+
aria-roledescription='carousel'
|
|
109
|
+
onKeyDown={(event) => {
|
|
110
|
+
if (event.key === 'ArrowLeft') {
|
|
111
|
+
event.preventDefault()
|
|
112
|
+
goTo(safePage - 1)
|
|
113
|
+
} else if (event.key === 'ArrowRight') {
|
|
114
|
+
event.preventDefault()
|
|
115
|
+
goTo(safePage + 1)
|
|
116
|
+
}
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
119
|
+
<div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
|
|
120
|
+
{(heading || description) && (
|
|
121
|
+
<div className='mb-8 text-center'>
|
|
122
|
+
{heading && (
|
|
123
|
+
<h2 className='text-2xl font-bold text-balance md:text-3xl'>{heading}</h2>
|
|
124
|
+
)}
|
|
125
|
+
{description && <p className='text-muted-foreground mt-2 text-balance'>{description}</p>}
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
<div
|
|
130
|
+
onTouchStart={(event) => setTouchStart(event.touches[0]?.clientX ?? null)}
|
|
131
|
+
onTouchEnd={(event) => {
|
|
132
|
+
if (touchStart === null) return
|
|
133
|
+
const delta = (event.changedTouches[0]?.clientX ?? touchStart) - touchStart
|
|
134
|
+
if (delta <= -SWIPE_THRESHOLD) goTo(safePage + 1)
|
|
135
|
+
else if (delta >= SWIPE_THRESHOLD) goTo(safePage - 1)
|
|
136
|
+
setTouchStart(null)
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
<motion.div
|
|
140
|
+
key={safePage}
|
|
141
|
+
initial={reduceMotion ? false : { opacity: 0 }}
|
|
142
|
+
animate={{ opacity: 1 }}
|
|
143
|
+
transition={{ duration: 0.3, ease: 'easeOut' }}
|
|
144
|
+
className='grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:gap-6'
|
|
145
|
+
>
|
|
146
|
+
{visible.map((product) => {
|
|
147
|
+
const compareAt = compareAtText(product)
|
|
148
|
+
return (
|
|
149
|
+
<Card
|
|
150
|
+
key={product.id}
|
|
151
|
+
className={`flex h-full flex-col gap-4 overflow-hidden rounded-lg py-4 shadow-none transition-shadow duration-300 hover:shadow-md ${
|
|
152
|
+
onProductClick ? 'cursor-pointer' : ''
|
|
153
|
+
}`}
|
|
154
|
+
onClick={onProductClick ? () => onProductClick(product) : undefined}
|
|
155
|
+
>
|
|
156
|
+
<CardContent className='flex flex-1 flex-col gap-4 px-4'>
|
|
157
|
+
<div className='relative aspect-square overflow-hidden rounded-md'>
|
|
158
|
+
{product.badge && (
|
|
159
|
+
<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'>
|
|
160
|
+
{product.badge}
|
|
161
|
+
</span>
|
|
162
|
+
)}
|
|
163
|
+
<img
|
|
164
|
+
src={product.image || undefined}
|
|
165
|
+
alt={product.imageAlt ?? product.title}
|
|
166
|
+
className='size-full rounded-md object-contain'
|
|
167
|
+
loading='lazy'
|
|
168
|
+
width={400}
|
|
169
|
+
height={400}
|
|
170
|
+
/>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<div className='flex flex-1 flex-col'>
|
|
174
|
+
<h3 className='mb-1 font-medium text-balance'>
|
|
175
|
+
{onProductClick ? (
|
|
176
|
+
<button
|
|
177
|
+
type='button'
|
|
178
|
+
onClick={(event) => {
|
|
179
|
+
event.stopPropagation()
|
|
180
|
+
onProductClick(product)
|
|
181
|
+
}}
|
|
182
|
+
className='hover:text-primary cursor-pointer text-left transition-colors'
|
|
183
|
+
>
|
|
184
|
+
{product.title}
|
|
185
|
+
</button>
|
|
186
|
+
) : (
|
|
187
|
+
product.title
|
|
188
|
+
)}
|
|
189
|
+
</h3>
|
|
190
|
+
{typeof product.rating === 'number' && Number.isFinite(product.rating) && (
|
|
191
|
+
<div className='text-muted-foreground mb-1 flex items-center gap-1 text-xs'>
|
|
192
|
+
<Star className='size-3.5 fill-current' aria-hidden='true' />
|
|
193
|
+
<span>{product.rating.toFixed(1)}</span>
|
|
194
|
+
</div>
|
|
195
|
+
)}
|
|
196
|
+
<div className='mt-auto flex items-baseline gap-2'>
|
|
197
|
+
<p className='font-semibold'>
|
|
198
|
+
{formatMoney(product.price) ?? unavailablePriceLabel}
|
|
199
|
+
</p>
|
|
200
|
+
{compareAt && (
|
|
201
|
+
<p className='text-muted-foreground text-sm line-through'>{compareAt}</p>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
</CardContent>
|
|
206
|
+
|
|
207
|
+
<CardFooter className='bg-transparent border-t-0 px-3 pt-0 md:px-4'>
|
|
208
|
+
<Button
|
|
209
|
+
variant='outline'
|
|
210
|
+
size='sm'
|
|
211
|
+
className='h-8 w-full cursor-pointer px-3 text-sm'
|
|
212
|
+
onClick={(event) => {
|
|
213
|
+
event.stopPropagation()
|
|
214
|
+
onAddToCart?.(product)
|
|
215
|
+
}}
|
|
216
|
+
>
|
|
217
|
+
{addToCartLabel}
|
|
218
|
+
</Button>
|
|
219
|
+
</CardFooter>
|
|
220
|
+
</Card>
|
|
221
|
+
)
|
|
222
|
+
})}
|
|
223
|
+
</motion.div>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
{pageCount > 1 && (
|
|
227
|
+
<div className='mt-6 flex items-center justify-center gap-3'>
|
|
228
|
+
<Button
|
|
229
|
+
type='button'
|
|
230
|
+
variant='outline'
|
|
231
|
+
size='icon-sm'
|
|
232
|
+
className='cursor-pointer'
|
|
233
|
+
aria-label={prevLabel}
|
|
234
|
+
disabled={safePage === 0}
|
|
235
|
+
onClick={() => goTo(safePage - 1)}
|
|
236
|
+
>
|
|
237
|
+
<ChevronLeft className='size-4' aria-hidden='true' />
|
|
238
|
+
</Button>
|
|
239
|
+
|
|
240
|
+
<div className='flex items-center gap-2'>
|
|
241
|
+
{pages.map((_, index) => (
|
|
242
|
+
<button
|
|
243
|
+
key={index}
|
|
244
|
+
type='button'
|
|
245
|
+
aria-label={`${pageLabel} ${index + 1}`}
|
|
246
|
+
aria-current={index === safePage ? 'true' : undefined}
|
|
247
|
+
className={`size-2 rounded-full transition-colors ${
|
|
248
|
+
index === safePage ? 'bg-primary' : 'bg-muted-foreground/40'
|
|
249
|
+
}`}
|
|
250
|
+
onClick={() => goTo(index)}
|
|
251
|
+
/>
|
|
252
|
+
))}
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<Button
|
|
256
|
+
type='button'
|
|
257
|
+
variant='outline'
|
|
258
|
+
size='icon-sm'
|
|
259
|
+
className='cursor-pointer'
|
|
260
|
+
aria-label={nextLabel}
|
|
261
|
+
disabled={safePage === pageCount - 1}
|
|
262
|
+
onClick={() => goTo(safePage + 1)}
|
|
263
|
+
>
|
|
264
|
+
<ChevronRight className='size-4' aria-hidden='true' />
|
|
265
|
+
</Button>
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
|
|
269
|
+
<p aria-live='polite' className='sr-only'>
|
|
270
|
+
{pageLabel} {safePage + 1} / {pageCount}
|
|
271
|
+
</p>
|
|
272
|
+
</div>
|
|
273
|
+
</section>
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export default ProductShowcase1
|