@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.
Files changed (111) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +108 -23
  3. package/docs/standards/data-contracts.md +13 -2
  4. package/package.json +26 -10
  5. package/scripts/fix-esm-extensions.mjs +40 -0
  6. package/src/blocks/e-commerce/navigation/back-to-top.test.tsx +27 -0
  7. package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
  8. package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
  9. package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
  10. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
  11. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
  12. package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
  13. package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
  14. package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
  15. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  16. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  17. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  18. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  19. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  20. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  21. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  22. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  23. package/src/blocks/e-commerce/product-list/product-card.tsx +424 -0
  24. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  25. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  26. package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
  27. package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
  28. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  29. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  30. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  31. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  32. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  33. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  34. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  35. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  36. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +204 -109
  37. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  38. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  39. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  40. package/src/blocks/marketing/features/feature.tsx +195 -0
  41. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +56 -0
  42. package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
  43. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +150 -0
  44. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +102 -9
  45. package/src/blocks/marketing/navbars/navbar-4.test.tsx +154 -2
  46. package/src/blocks/marketing/navbars/navbar-4.tsx +152 -32
  47. package/src/index.ts +93 -46
  48. package/src/lib/contracts-curated.test.ts +42 -24
  49. package/src/lib/contracts.test.ts +143 -2
  50. package/src/lib/contracts.ts +395 -13
  51. package/src/ui/button-group.tsx +1 -1
  52. package/src/ui/carousel.tsx +1 -1
  53. package/src/ui/dialog.tsx +1 -1
  54. package/src/ui/field.tsx +2 -2
  55. package/src/ui/input-group.tsx +3 -3
  56. package/src/ui/pagination.tsx +1 -1
  57. package/src/ui/sidebar.tsx +7 -7
  58. package/src/ui/toggle-group.tsx +1 -1
  59. package/tsconfig.build.json +12 -0
  60. package/tsup.config.ts +11 -7
  61. package/.turbo/turbo-build.log +0 -24
  62. package/.turbo/turbo-test.log +0 -37
  63. package/.turbo/turbo-type-check.log +0 -0
  64. package/dist/.tsbuildinfo +0 -1
  65. package/dist/contracts.d.mts +0 -1584
  66. package/dist/contracts.d.ts +0 -1584
  67. package/dist/contracts.js +0 -390
  68. package/dist/contracts.js.map +0 -1
  69. package/dist/contracts.mjs +0 -334
  70. package/dist/contracts.mjs.map +0 -1
  71. package/dist/index.d.mts +0 -2786
  72. package/dist/index.d.ts +0 -2786
  73. package/dist/index.js +0 -17139
  74. package/dist/index.js.map +0 -1
  75. package/dist/index.mjs +0 -17059
  76. package/dist/index.mjs.map +0 -1
  77. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  78. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  79. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  80. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  81. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  82. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  83. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -131
  84. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  85. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  86. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  87. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  88. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  89. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  90. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  91. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  92. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  93. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  94. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  95. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  96. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  97. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  98. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  99. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  100. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  101. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  102. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  103. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  104. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -137
  105. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -65
  106. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  107. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  108. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  109. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  110. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  111. 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