@thorprovider/blocks 0.1.3 → 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 (99) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +73 -29
  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/product-categories/category-card-grid.test.tsx +122 -0
  7. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  8. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  9. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  10. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  11. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  12. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  13. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  14. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  15. package/src/blocks/e-commerce/product-list/product-card.tsx +424 -0
  16. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  17. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  18. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  19. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  20. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  21. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  22. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  23. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  24. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  25. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  26. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +119 -85
  27. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  28. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  29. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  30. package/src/blocks/marketing/features/feature.tsx +195 -0
  31. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +27 -9
  32. package/src/blocks/marketing/navbars/navbar-4.test.tsx +33 -2
  33. package/src/blocks/marketing/navbars/navbar-4.tsx +29 -11
  34. package/src/index.ts +77 -48
  35. package/src/lib/contracts-curated.test.ts +42 -24
  36. package/src/lib/contracts.test.ts +143 -2
  37. package/src/lib/contracts.ts +346 -14
  38. package/src/ui/button-group.tsx +1 -1
  39. package/src/ui/carousel.tsx +1 -1
  40. package/src/ui/dialog.tsx +1 -1
  41. package/src/ui/field.tsx +2 -2
  42. package/src/ui/input-group.tsx +3 -3
  43. package/src/ui/pagination.tsx +1 -1
  44. package/src/ui/sidebar.tsx +7 -7
  45. package/src/ui/toggle-group.tsx +1 -1
  46. package/tsconfig.build.json +12 -0
  47. package/tsup.config.ts +11 -7
  48. package/.turbo/turbo-build.log +0 -24
  49. package/.turbo/turbo-test.log +0 -47
  50. package/.turbo/turbo-type-check.log +0 -0
  51. package/dist/.tsbuildinfo +0 -1
  52. package/dist/contracts.d.mts +0 -1732
  53. package/dist/contracts.d.ts +0 -1732
  54. package/dist/contracts.js +0 -423
  55. package/dist/contracts.js.map +0 -1
  56. package/dist/contracts.mjs +0 -365
  57. package/dist/contracts.mjs.map +0 -1
  58. package/dist/index.d.mts +0 -3068
  59. package/dist/index.d.ts +0 -3068
  60. package/dist/index.js +0 -17834
  61. package/dist/index.js.map +0 -1
  62. package/dist/index.mjs +0 -17740
  63. package/dist/index.mjs.map +0 -1
  64. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  65. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  66. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  67. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  68. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  69. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  70. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +0 -88
  71. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -192
  72. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  73. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  74. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  75. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  76. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  77. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  78. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  79. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  80. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  81. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  82. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  83. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  84. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  85. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  86. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  87. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  88. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  89. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  90. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  91. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  92. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -229
  93. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -72
  94. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  95. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  96. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  97. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  98. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  99. package/src/blocks/marketing/features/feature-2.tsx +0 -82
@@ -0,0 +1,68 @@
1
+ // @vitest-environment jsdom
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { render, screen } from '@testing-library/react'
5
+ import userEvent from '@testing-library/user-event'
6
+ import { CategoryCard } from './category-card'
7
+ import type { ProductCategoryItem } from '../../../lib/contracts'
8
+
9
+ const category: ProductCategoryItem = {
10
+ id: 'pcat_herramientas',
11
+ name: 'Herramientas',
12
+ description: 'Taladros y accesorios',
13
+ image: '/products/drill.png',
14
+ itemCount: 12,
15
+ trending: true,
16
+ featured: true,
17
+ subcategories: ['Taladros', 'Accesorios'],
18
+ }
19
+
20
+ describe('CategoryCard', () => {
21
+ it('renders one item without section-level chrome', () => {
22
+ const html = renderToStaticMarkup(<CategoryCard category={category} />)
23
+ expect(html).toContain('Herramientas')
24
+ expect(html).not.toContain('<section')
25
+ expect(html).not.toContain('<h2')
26
+ })
27
+
28
+ it('renders the selected presentation and forwards presentation-specific copy', () => {
29
+ expect(renderToStaticMarkup(<CategoryCard category={category} />)).toContain('/products/drill.png')
30
+ expect(renderToStaticMarkup(<CategoryCard category={category} presentation="icons" />)).not.toContain('<img')
31
+ const list = renderToStaticMarkup(
32
+ <CategoryCard category={category} presentation="list" itemsLabel="productos" featuredLabel="Destacada" />,
33
+ )
34
+ expect(list).toContain('Taladros y accesorios')
35
+ expect(list).toContain('Destacada')
36
+ expect(list).toContain('productos')
37
+ })
38
+
39
+ it('exposes an accessible browse name and invokes the category action by keyboard', async () => {
40
+ const user = userEvent.setup()
41
+ const onBrowse = vi.fn()
42
+ render(<CategoryCard category={category} presentation="icons" onBrowse={onBrowse} />)
43
+ const button = screen.getByRole('button', { name: 'Herramientas' })
44
+
45
+ button.focus()
46
+ await user.keyboard('{Enter}')
47
+ await user.keyboard(' ')
48
+
49
+ expect(onBrowse).toHaveBeenCalledTimes(2)
50
+ expect(onBrowse).toHaveBeenLastCalledWith(category)
51
+ })
52
+
53
+ it('does not add an inert focus target when browsing is unavailable', () => {
54
+ render(<CategoryCard category={category} presentation="icons" />)
55
+ expect(screen.queryByRole('button')).toBeNull()
56
+ })
57
+
58
+ it('gives grid and list browse buttons an accessible name containing the category', () => {
59
+ const grid = renderToStaticMarkup(
60
+ <CategoryCard category={category} onBrowse={() => undefined} browseLabel="Explorar" />,
61
+ )
62
+ const list = renderToStaticMarkup(
63
+ <CategoryCard category={category} presentation="list" onBrowse={() => undefined} browseLabel="Explorar" />,
64
+ )
65
+ expect(grid).toContain('aria-label="Explorar Herramientas"')
66
+ expect(list).toContain('aria-label="Explorar Herramientas"')
67
+ })
68
+ })
@@ -0,0 +1,172 @@
1
+ 'use client'
2
+
3
+ import { Badge } from '../../../ui/badge'
4
+ import { Button } from '../../../ui/button'
5
+ import { Card, CardContent } from '../../../ui/card'
6
+ import { cn } from '../../../lib/utils'
7
+ import type { CategoryCardSpecProps } from '../../../lib/contracts'
8
+ import type { ProductCategoryItem } from '../../../lib/contracts'
9
+ import {
10
+ formatCount,
11
+ resolveCategoryIcon,
12
+ resolveImage,
13
+ } from './category-shared'
14
+
15
+ export type CategoryCardPresentation = NonNullable<CategoryCardSpecProps['presentation']>
16
+
17
+ export interface CategoryCardProps extends CategoryCardSpecProps {
18
+ onBrowse?: (category: ProductCategoryItem) => void
19
+ }
20
+
21
+ function GridCell({
22
+ category,
23
+ browseLabel,
24
+ itemsLabel,
25
+ trendingLabel,
26
+ imageFallback,
27
+ onBrowse,
28
+ }: CategoryCardProps) {
29
+ const count = formatCount(category.itemCount, itemsLabel)
30
+ return (
31
+ <Card className="group overflow-hidden py-0 transition-all duration-500 hover:shadow-lg">
32
+ <div className="relative aspect-[5/4] overflow-hidden">
33
+ <img
34
+ src={resolveImage(category.image, imageFallback)}
35
+ alt={category.name}
36
+ className="size-full object-cover transition-transform duration-500 group-hover:scale-105"
37
+ />
38
+ <div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent" />
39
+ {category.trending && trendingLabel ? (
40
+ <Badge className="absolute top-4 left-4 rounded-sm px-2.5 py-0.5 font-semibold">
41
+ {trendingLabel}
42
+ </Badge>
43
+ ) : null}
44
+ <div className="absolute right-0 bottom-0 left-0 p-6 text-white">
45
+ <h3 className="mb-1 text-xl font-bold">{category.name}</h3>
46
+ {category.description ? (
47
+ <p className="mb-3 text-sm text-white/90">{category.description}</p>
48
+ ) : null}
49
+ <div className="flex items-center justify-between">
50
+ {count ? <span className="text-sm">{count}</span> : null}
51
+ {onBrowse && browseLabel ? (
52
+ <Button
53
+ size="sm"
54
+ variant="secondary"
55
+ aria-label={`${browseLabel} ${category.name}`}
56
+ className="ms-auto h-8 cursor-pointer border-white/30 bg-white/20 px-3 text-xs text-white backdrop-blur-sm hover:bg-white/30"
57
+ onClick={() => onBrowse(category)}
58
+ >
59
+ {browseLabel}
60
+ </Button>
61
+ ) : null}
62
+ </div>
63
+ </div>
64
+ </div>
65
+ </Card>
66
+ )
67
+ }
68
+
69
+ function IconsCell({ category, onBrowse }: CategoryCardProps) {
70
+ const Icon = resolveCategoryIcon(category.icon)
71
+ const clickable = Boolean(onBrowse)
72
+ return (
73
+ <Card
74
+ className={cn(
75
+ 'group relative overflow-hidden px-2 py-6 shadow-xs transition-all hover:shadow-md',
76
+ clickable && 'cursor-pointer',
77
+ )}
78
+ >
79
+ {clickable ? (
80
+ <button
81
+ type="button"
82
+ onClick={() => onBrowse?.(category)}
83
+ aria-label={category.name}
84
+ className="absolute inset-0 z-10 rounded-xl focus-visible:inset-ring-2 focus-visible:inset-ring-ring focus-visible:outline-none"
85
+ />
86
+ ) : null}
87
+ <CardContent>
88
+ <div className="flex flex-col gap-4">
89
+ <div className="bg-secondary/80 text-secondary-foreground flex size-12 items-center justify-center rounded-lg">
90
+ <Icon className="size-5" strokeWidth={1.5} aria-hidden="true" />
91
+ </div>
92
+ <div>
93
+ <h3 className="text-foreground text-lg font-semibold">{category.name}</h3>
94
+ {category.description ? (
95
+ <p className="text-muted-foreground text-sm">{category.description}</p>
96
+ ) : null}
97
+ </div>
98
+ </div>
99
+ </CardContent>
100
+ </Card>
101
+ )
102
+ }
103
+
104
+ function ListCell({
105
+ category,
106
+ browseLabel,
107
+ itemsLabel,
108
+ featuredLabel,
109
+ onBrowse,
110
+ }: CategoryCardProps) {
111
+ const Icon = resolveCategoryIcon(category.icon)
112
+ const count = formatCount(category.itemCount, itemsLabel)
113
+ return (
114
+ <Card className="group hover:border-primary relative py-0 transition-all duration-200 hover:shadow-md">
115
+ <CardContent className="p-6">
116
+ <div className="flex items-start gap-4">
117
+ <div className="flex-shrink-0">
118
+ <div className="bg-primary/10 group-hover:bg-primary/20 flex size-12 items-center justify-center rounded-lg transition-colors">
119
+ <Icon className="text-primary size-6" aria-hidden="true" />
120
+ </div>
121
+ </div>
122
+ <div className="min-w-0 flex-1">
123
+ <div className="mb-2 flex items-center gap-2">
124
+ <h3 className="text-lg font-semibold">{category.name}</h3>
125
+ {category.featured && featuredLabel ? (
126
+ <Badge variant="secondary" className="rounded-sm px-2 py-0.5 font-semibold">
127
+ {featuredLabel}
128
+ </Badge>
129
+ ) : null}
130
+ </div>
131
+ {category.description ? (
132
+ <p className="text-muted-foreground mb-3 line-clamp-2 text-sm">{category.description}</p>
133
+ ) : null}
134
+ {category.subcategories && category.subcategories.length > 0 ? (
135
+ <div className="mb-3 flex flex-wrap gap-2">
136
+ {category.subcategories.map((subcategory, index) => (
137
+ <span
138
+ key={`${category.id}:sub:${index}`}
139
+ className="bg-muted text-muted-foreground rounded-md px-2 py-1 text-xs"
140
+ >
141
+ {subcategory}
142
+ </span>
143
+ ))}
144
+ </div>
145
+ ) : null}
146
+ {count ? <div className="text-muted-foreground text-sm">{count}</div> : null}
147
+ {onBrowse && browseLabel ? (
148
+ <Button
149
+ variant="ghost"
150
+ size="sm"
151
+ aria-label={`${browseLabel} ${category.name}`}
152
+ className="absolute end-4 bottom-4 h-8 cursor-pointer px-3 text-xs opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
153
+ onClick={() => onBrowse(category)}
154
+ >
155
+ {browseLabel}
156
+ </Button>
157
+ ) : null}
158
+ </div>
159
+ </div>
160
+ </CardContent>
161
+ </Card>
162
+ )
163
+ }
164
+
165
+ export function CategoryCard(props: CategoryCardProps) {
166
+ const presentation = props.presentation ?? 'grid'
167
+ if (presentation === 'icons') return <IconsCell {...props} />
168
+ if (presentation === 'list') return <ListCell {...props} />
169
+ return <GridCell {...props} />
170
+ }
171
+
172
+ export default CategoryCard
@@ -0,0 +1,67 @@
1
+ // @vitest-environment jsdom
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { render, screen } from '@testing-library/react'
5
+ import userEvent from '@testing-library/user-event'
6
+ import { CategoryGrid } from './product-category-grid'
7
+ import type { ProductCategoryItem } from '../../../lib/contracts'
8
+
9
+ const categories: ProductCategoryItem[] = [
10
+ { id: 'pcat_tools', name: 'Herramientas', icon: 'wrench' },
11
+ { id: 'pcat_paint', name: 'Pintura', icon: 'paintbrush' },
12
+ { id: 'pcat_garden', name: 'Jardín', icon: 'flower' },
13
+ ]
14
+
15
+ describe('CategoryGrid', () => {
16
+ it('renders one cell per category in source order and forwards the presentation', () => {
17
+ const html = renderToStaticMarkup(<CategoryGrid categories={categories} presentation="icons" />)
18
+ const names = categories.map(({ name }) => html.indexOf(name))
19
+ expect(html.match(/data-slot="card"/g) ?? []).toHaveLength(categories.length)
20
+ expect(names[0]).toBeLessThan(names[1])
21
+ expect(names[1]).toBeLessThan(names[2])
22
+ expect(html).not.toContain('<img')
23
+ })
24
+
25
+ it('caps displayed categories without reordering them', () => {
26
+ const html = renderToStaticMarkup(<CategoryGrid categories={categories} maxItems={2} />)
27
+ expect(html).toContain('Herramientas')
28
+ expect(html).toContain('Pintura')
29
+ expect(html).not.toContain('Jardín')
30
+ })
31
+
32
+ it('owns loading and empty states', () => {
33
+ const loading = renderToStaticMarkup(<CategoryGrid categories={categories} loading skeletonCount={2} />)
34
+ expect(loading).toContain('aria-busy="true"')
35
+ expect(loading.match(/animate-pulse/g) ?? []).toHaveLength(2)
36
+ expect(loading).not.toContain('data-slot="card"')
37
+ expect(renderToStaticMarkup(<CategoryGrid categories={[]} emptyLabel="Sin categorías" />)).toContain(
38
+ 'Sin categorías',
39
+ )
40
+ })
41
+
42
+ it('controls list search and exposes an accessible label', async () => {
43
+ const user = userEvent.setup()
44
+ const onQueryChange = vi.fn()
45
+ render(
46
+ <CategoryGrid
47
+ categories={categories}
48
+ presentation="list"
49
+ query="tal"
50
+ onQueryChange={onQueryChange}
51
+ searchLabel="Buscar categorías"
52
+ />,
53
+ )
54
+ const input = screen.getByRole('searchbox', { name: 'Buscar categorías' })
55
+ expect((input as HTMLInputElement).value).toBe('tal')
56
+ await user.type(input, 'a')
57
+ expect(onQueryChange).toHaveBeenCalledWith('tala')
58
+ })
59
+
60
+ it('bubbles a cell browse action with its category', async () => {
61
+ const user = userEvent.setup()
62
+ const onBrowse = vi.fn()
63
+ render(<CategoryGrid categories={categories} presentation="icons" onBrowse={onBrowse} />)
64
+ await user.click(screen.getByRole('button', { name: 'Herramientas' }))
65
+ expect(onBrowse).toHaveBeenCalledExactlyOnceWith(categories[0])
66
+ })
67
+ })
@@ -0,0 +1,109 @@
1
+ import {
2
+ ArrowRight,
3
+ Baby,
4
+ BookOpen,
5
+ Boxes,
6
+ Cable,
7
+ Camera,
8
+ Car,
9
+ Dumbbell,
10
+ Gamepad2,
11
+ Gift,
12
+ Heart,
13
+ Home,
14
+ Laptop,
15
+ LayoutGrid,
16
+ Lightbulb,
17
+ Package,
18
+ ShieldCheck,
19
+ Shirt,
20
+ ShoppingBag,
21
+ Smartphone,
22
+ Sparkles,
23
+ Store,
24
+ Tag,
25
+ Utensils,
26
+ Wrench,
27
+ type LucideIcon,
28
+ } from 'lucide-react'
29
+
30
+ import { Button } from '../../../ui/button'
31
+ import { PRODUCT_IMAGE_FALLBACK } from '../../../lib/contracts'
32
+ import type { CTA } from '../../../lib/types'
33
+
34
+ export const CATEGORY_ICONS: Record<string, LucideIcon> = {
35
+ grid: LayoutGrid,
36
+ boxes: Boxes,
37
+ smartphone: Smartphone,
38
+ laptop: Laptop,
39
+ camera: Camera,
40
+ cable: Cable,
41
+ wrench: Wrench,
42
+ shield: ShieldCheck,
43
+ sparkles: Sparkles,
44
+ lightbulb: Lightbulb,
45
+ package: Package,
46
+ tag: Tag,
47
+ store: Store,
48
+ home: Home,
49
+ shirt: Shirt,
50
+ book: BookOpen,
51
+ gift: Gift,
52
+ heart: Heart,
53
+ car: Car,
54
+ utensils: Utensils,
55
+ gamepad: Gamepad2,
56
+ baby: Baby,
57
+ dumbbell: Dumbbell,
58
+ }
59
+
60
+ export const FALLBACK_ICON = LayoutGrid
61
+
62
+ export function resolveCategoryIcon(icon?: string): LucideIcon {
63
+ if (!icon) return FALLBACK_ICON
64
+ return CATEGORY_ICONS[icon] ?? FALLBACK_ICON
65
+ }
66
+
67
+ export function formatCount(itemCount: number | undefined, itemsLabel?: string): string | undefined {
68
+ if (itemCount === undefined || !Number.isFinite(itemCount) || itemCount < 0) return undefined
69
+ const value = itemCount.toLocaleString()
70
+ return itemsLabel ? `${value} ${itemsLabel}` : value
71
+ }
72
+
73
+ export function resolveImage(image: string | undefined, fallback?: string): string {
74
+ const candidate = typeof image === 'string' ? image.trim() : ''
75
+ const fallbackCandidate = typeof fallback === 'string' ? fallback.trim() : ''
76
+ return candidate || fallbackCandidate || PRODUCT_IMAGE_FALLBACK
77
+ }
78
+
79
+ export function SectionCta({ cta }: { cta?: CTA }) {
80
+ if (!cta) return null
81
+ return (
82
+ <div className="mt-12 text-center">
83
+ <Button size="lg" className="h-10 cursor-pointer gap-2 px-4" asChild>
84
+ <a href={cta.href}>
85
+ <ShoppingBag className="size-5" />
86
+ {cta.label}
87
+ </a>
88
+ </Button>
89
+ </div>
90
+ )
91
+ }
92
+
93
+ export function ViewAllButton({
94
+ onViewAll,
95
+ viewAllLabel,
96
+ }: {
97
+ onViewAll?: () => void
98
+ viewAllLabel?: string
99
+ }) {
100
+ if (!onViewAll || !viewAllLabel) return null
101
+ return (
102
+ <div className="mt-12 text-center">
103
+ <Button variant="outline" className="h-9 cursor-pointer px-4" onClick={onViewAll}>
104
+ {viewAllLabel}
105
+ <ArrowRight />
106
+ </Button>
107
+ </div>
108
+ )
109
+ }
@@ -0,0 +1,170 @@
1
+ 'use client'
2
+
3
+ import { Search } from 'lucide-react'
4
+
5
+ import { Input } from '../../../ui/input'
6
+ import { cn } from '../../../lib/utils'
7
+ import type {
8
+ CategoryGridSpecProps,
9
+ ProductCategoryItem,
10
+ } from '../../../lib/contracts'
11
+ import { CategoryCard, type CategoryCardPresentation } from './category-card'
12
+ import { SectionCta, ViewAllButton } from './category-shared'
13
+
14
+ export type CategoryGridPresentation = NonNullable<CategoryGridSpecProps['presentation']>
15
+
16
+ export interface CategoryGridProps extends CategoryGridSpecProps {
17
+ onBrowse?: (category: ProductCategoryItem) => void
18
+ onViewAll?: () => void
19
+ onQueryChange?: (query: string) => void
20
+ }
21
+
22
+ const CONTAINER = 'mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'
23
+
24
+ const LAYOUT: Record<CategoryGridPresentation, string> = {
25
+ grid: 'grid gap-6 sm:grid-cols-2 lg:grid-cols-3',
26
+ icons: 'mx-auto grid max-w-5xl grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4',
27
+ list: 'grid grid-cols-1 gap-4 lg:grid-cols-2',
28
+ }
29
+
30
+ const SKELETON: Record<CategoryGridPresentation, string> = {
31
+ grid: 'aspect-[5/4]',
32
+ icons: 'h-36',
33
+ list: 'h-28',
34
+ }
35
+
36
+ function Header({
37
+ presentation,
38
+ title,
39
+ description,
40
+ badge,
41
+ }: {
42
+ presentation: CategoryGridPresentation
43
+ title?: string
44
+ description?: string
45
+ badge?: string
46
+ }) {
47
+ if (presentation === 'icons') {
48
+ if (!badge && !title && !description) return null
49
+ return (
50
+ <div className="mx-auto mb-12 max-w-3xl text-center">
51
+ {badge ? (
52
+ <div className="mb-4 inline-flex">
53
+ <span className="bg-secondary/80 text-secondary-foreground rounded-full px-4 py-1.5 text-sm font-medium">
54
+ {badge}
55
+ </span>
56
+ </div>
57
+ ) : null}
58
+ {title ? <h2 className="text-3xl font-bold tracking-tight text-balance">{title}</h2> : null}
59
+ {description ? <p className="text-muted-foreground mt-4 text-lg">{description}</p> : null}
60
+ </div>
61
+ )
62
+ }
63
+
64
+ if (!title && !description) return null
65
+ return (
66
+ <div className={cn('text-center', presentation === 'list' ? 'mb-8' : 'mb-12')}>
67
+ {title ? (
68
+ <h2 className={cn('text-3xl font-bold tracking-tight text-balance', presentation === 'list' && 'mb-4')}>
69
+ {title}
70
+ </h2>
71
+ ) : null}
72
+ {description ? (
73
+ <p className={cn('text-muted-foreground text-lg', presentation !== 'list' && 'mt-4')}>
74
+ {description}
75
+ </p>
76
+ ) : null}
77
+ </div>
78
+ )
79
+ }
80
+
81
+ export function CategoryGrid(props: CategoryGridProps) {
82
+ const presentation = props.presentation ?? 'grid'
83
+ const {
84
+ categories,
85
+ className,
86
+ title,
87
+ description,
88
+ badge,
89
+ cta,
90
+ query,
91
+ onQueryChange,
92
+ onBrowse,
93
+ onViewAll,
94
+ searchPlaceholder,
95
+ searchLabel,
96
+ emptyLabel,
97
+ noResultsLabel,
98
+ viewAllLabel,
99
+ } = props
100
+
101
+ const displayed =
102
+ props.maxItems !== undefined ? categories.slice(0, props.maxItems) : categories
103
+ const hasQuery = Boolean(query && query.trim() !== '')
104
+ const emptyMessage = hasQuery ? (noResultsLabel ?? emptyLabel) : emptyLabel
105
+ const skeletonCount = props.skeletonCount ?? 6
106
+ const cellPresentation = presentation as CategoryCardPresentation
107
+
108
+ return (
109
+ <section className={cn('py-12', className)}>
110
+ <div className={CONTAINER}>
111
+ <Header presentation={presentation} title={title} description={description} badge={badge} />
112
+
113
+ {presentation === 'list' && onQueryChange ? (
114
+ <div className="relative mx-auto mb-8 max-w-md">
115
+ <Search
116
+ className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2"
117
+ aria-hidden="true"
118
+ />
119
+ <Input
120
+ type="search"
121
+ placeholder={searchPlaceholder}
122
+ value={query ?? ''}
123
+ onChange={(event) => onQueryChange(event.target.value)}
124
+ className="ps-9 h-9"
125
+ aria-label={searchLabel ?? searchPlaceholder}
126
+ />
127
+ </div>
128
+ ) : null}
129
+
130
+ {props.loading ? (
131
+ <div className={LAYOUT[presentation]} aria-busy="true">
132
+ {Array.from({ length: skeletonCount }, (_, index) => (
133
+ <div
134
+ key={`skeleton-${index}`}
135
+ className={cn('bg-muted/50 animate-pulse rounded-xl', SKELETON[presentation])}
136
+ />
137
+ ))}
138
+ </div>
139
+ ) : displayed.length === 0 ? (
140
+ emptyMessage ? (
141
+ <p className="text-muted-foreground py-10 text-center text-sm">{emptyMessage}</p>
142
+ ) : null
143
+ ) : (
144
+ <div className={LAYOUT[presentation]}>
145
+ {displayed.map((category) => (
146
+ <CategoryCard
147
+ key={category.id}
148
+ category={category}
149
+ presentation={cellPresentation}
150
+ browseLabel={props.browseLabel}
151
+ itemsLabel={props.itemsLabel}
152
+ trendingLabel={props.trendingLabel}
153
+ featuredLabel={props.featuredLabel}
154
+ imageFallback={props.imageFallback}
155
+ onBrowse={onBrowse}
156
+ />
157
+ ))}
158
+ </div>
159
+ )}
160
+
161
+ {!props.loading && presentation !== 'list' ? <SectionCta cta={cta} /> : null}
162
+ {!props.loading && presentation !== 'grid' ? (
163
+ <ViewAllButton onViewAll={onViewAll} viewAllLabel={viewAllLabel} />
164
+ ) : null}
165
+ </div>
166
+ </section>
167
+ )
168
+ }
169
+
170
+ export default CategoryGrid