@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,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
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { render, screen } from '@testing-library/react'
|
|
5
|
+
import userEvent from '@testing-library/user-event'
|
|
6
|
+
import { ProductCategory } from './product-category'
|
|
7
|
+
import type { ProductCategoryItem } from '../../../lib/contracts'
|
|
8
|
+
|
|
9
|
+
beforeAll(() => {
|
|
10
|
+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const base: ProductCategoryItem = {
|
|
14
|
+
id: 'pcat_herramientas',
|
|
15
|
+
handle: 'herramientas',
|
|
16
|
+
name: 'Herramientas',
|
|
17
|
+
description: 'Taladros y accesorios',
|
|
18
|
+
image: '/products/drill.png',
|
|
19
|
+
trending: false,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const renderGrid = (category: ProductCategoryItem, extra: Record<string, unknown> = {}) =>
|
|
23
|
+
renderToStaticMarkup(<ProductCategory categories={[category]} {...extra} />)
|
|
24
|
+
|
|
25
|
+
// The count is the only text node that ends in "items" (the `items-center`
|
|
26
|
+
// utility lives in a class attribute and is never followed by `</span>`).
|
|
27
|
+
const hasGridCount = (html: string) => html.includes('items</span>')
|
|
28
|
+
|
|
29
|
+
describe('ProductCategory — optional itemCount (grid)', () => {
|
|
30
|
+
it('renders no count when itemCount is omitted (unknown projection)', () => {
|
|
31
|
+
const html = renderGrid(base, { itemsLabel: 'items' })
|
|
32
|
+
expect(hasGridCount(html)).toBe(false)
|
|
33
|
+
// The tile itself still renders.
|
|
34
|
+
expect(html).toContain('Herramientas')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('preserves a real zero count', () => {
|
|
38
|
+
expect(renderGrid({ ...base, itemCount: 0 }, { itemsLabel: 'items' })).toContain('0 items</span>')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('renders a positive count', () => {
|
|
42
|
+
expect(renderGrid({ ...base, itemCount: 7 }, { itemsLabel: 'items' })).toContain('7 items</span>')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('renders the bare number when no items label is supplied', () => {
|
|
46
|
+
const html = renderGrid({ ...base, itemCount: 7 })
|
|
47
|
+
expect(html).toContain('>7<')
|
|
48
|
+
expect(html).not.toContain('items</span>')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('omits a negative count', () => {
|
|
52
|
+
expect(hasGridCount(renderGrid({ ...base, itemCount: -1 }, { itemsLabel: 'items' }))).toBe(false)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('omits a non-finite count', () => {
|
|
56
|
+
expect(hasGridCount(renderGrid({ ...base, itemCount: Number.NaN }, { itemsLabel: 'items' }))).toBe(false)
|
|
57
|
+
expect(hasGridCount(renderGrid({ ...base, itemCount: Number.POSITIVE_INFINITY }, { itemsLabel: 'items' }))).toBe(
|
|
58
|
+
false,
|
|
59
|
+
)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('ProductCategory — no demo defaults or hardcoded locale copy', () => {
|
|
64
|
+
it('ships no copy when the host supplies none', () => {
|
|
65
|
+
const html = renderGrid(base)
|
|
66
|
+
expect(html).toContain('Herramientas')
|
|
67
|
+
expect(html).not.toContain('Browse')
|
|
68
|
+
expect(html).not.toContain('Trending')
|
|
69
|
+
expect(html).not.toContain('items</span>')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('renders the browse action only when a handler and label are supplied', () => {
|
|
73
|
+
expect(renderGrid({ ...base, trending: true })).not.toContain('Browse')
|
|
74
|
+
expect(renderGrid(base, { onBrowse: () => {}, browseLabel: 'Explorar' })).toContain('Explorar')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('renders the trending badge only when a label is supplied', () => {
|
|
78
|
+
expect(renderGrid({ ...base, trending: true })).not.toContain('Trending')
|
|
79
|
+
expect(renderGrid({ ...base, trending: true }, { trendingLabel: 'Tendencia' })).toContain('Tendencia')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('ProductCategory — honest imagery', () => {
|
|
84
|
+
it('never emits src="" and falls back to a neutral placeholder', () => {
|
|
85
|
+
const html = renderGrid({ ...base, image: undefined })
|
|
86
|
+
expect(html).not.toContain('src=""')
|
|
87
|
+
expect(html).toContain('data:image/svg+xml')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('treats a blank image as missing', () => {
|
|
91
|
+
expect(renderGrid({ ...base, image: ' ' })).toContain('data:image/svg+xml')
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('ProductCategory — bounded presentations', () => {
|
|
96
|
+
it('icons presentation renders no image and a different structure than grid', () => {
|
|
97
|
+
const category: ProductCategoryItem = { id: 'pcat_x', name: 'Categoría', icon: 'shirt' }
|
|
98
|
+
const icons = renderToStaticMarkup(<ProductCategory categories={[category]} presentation='icons' />)
|
|
99
|
+
expect(icons).toContain('Categoría')
|
|
100
|
+
expect(icons).not.toContain('<img')
|
|
101
|
+
expect(icons).not.toContain('aspect-[5/4]')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('icons presentation ignores category counts (no demo metrics)', () => {
|
|
105
|
+
const category: ProductCategoryItem = { id: 'pcat_x', name: 'Categoría', icon: 'shirt', itemCount: 42 }
|
|
106
|
+
const icons = renderToStaticMarkup(
|
|
107
|
+
<ProductCategory categories={[category]} presentation='icons' itemsLabel='items' />,
|
|
108
|
+
)
|
|
109
|
+
expect(icons).not.toContain('42')
|
|
110
|
+
expect(icons).not.toContain('items</span>')
|
|
111
|
+
expect(icons).not.toContain('items</div>')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('falls back to a neutral icon for an unknown or absent key', () => {
|
|
115
|
+
const unknown = renderToStaticMarkup(
|
|
116
|
+
<ProductCategory categories={[{ id: 'pcat_x', name: 'X', icon: 'not-a-real-icon' }]} presentation='icons' />,
|
|
117
|
+
)
|
|
118
|
+
const absent = renderToStaticMarkup(
|
|
119
|
+
<ProductCategory categories={[{ id: 'pcat_y', name: 'Y' }]} presentation='icons' />,
|
|
120
|
+
)
|
|
121
|
+
expect(unknown).toContain('X')
|
|
122
|
+
expect(absent).toContain('Y')
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe('ProductCategory — icons: keyboard-operable interactive category', () => {
|
|
127
|
+
const category: ProductCategoryItem = { id: 'pcat_herramientas', name: 'Herramientas', icon: 'wrench' }
|
|
128
|
+
|
|
129
|
+
it('renders a native button whose accessible name is the category name', () => {
|
|
130
|
+
render(<ProductCategory categories={[category]} presentation='icons' onBrowse={() => {}} />)
|
|
131
|
+
const control = screen.getByRole('button', { name: 'Herramientas' })
|
|
132
|
+
expect(control.tagName).toBe('BUTTON')
|
|
133
|
+
expect(control.getAttribute('type')).toBe('button')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('is focusable and activates with the Enter key, reporting onBrowse', async () => {
|
|
137
|
+
const user = userEvent.setup()
|
|
138
|
+
const onBrowse = vi.fn()
|
|
139
|
+
render(<ProductCategory categories={[category]} presentation='icons' onBrowse={onBrowse} />)
|
|
140
|
+
await user.tab()
|
|
141
|
+
const control = screen.getByRole('button', { name: 'Herramientas' })
|
|
142
|
+
expect(document.activeElement).toBe(control)
|
|
143
|
+
await user.keyboard('{Enter}')
|
|
144
|
+
expect(onBrowse).toHaveBeenCalledWith(category)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('activates with the Space key, reporting onBrowse', async () => {
|
|
148
|
+
const user = userEvent.setup()
|
|
149
|
+
const onBrowse = vi.fn()
|
|
150
|
+
render(<ProductCategory categories={[category]} presentation='icons' onBrowse={onBrowse} />)
|
|
151
|
+
await user.tab()
|
|
152
|
+
await user.keyboard(' ')
|
|
153
|
+
expect(onBrowse).toHaveBeenCalledWith(category)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('is not a focusable control when no browse handler is supplied', async () => {
|
|
157
|
+
const user = userEvent.setup()
|
|
158
|
+
render(<ProductCategory categories={[category]} presentation='icons' />)
|
|
159
|
+
expect(screen.queryByRole('button')).toBeNull()
|
|
160
|
+
await user.tab()
|
|
161
|
+
expect(document.activeElement).toBe(document.body)
|
|
162
|
+
expect(screen.getByText('Herramientas')).toBeTruthy()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('uses an inset focus ring so the indicator is not clipped by the card', () => {
|
|
166
|
+
render(<ProductCategory categories={[category]} presentation='icons' onBrowse={() => {}} />)
|
|
167
|
+
const control = screen.getByRole('button', { name: 'Herramientas' })
|
|
168
|
+
expect(control.className).toContain('focus-visible:inset-ring-2')
|
|
169
|
+
expect(control.className).toContain('focus-visible:inset-ring-ring')
|
|
170
|
+
expect(control.className).not.toContain('focus-visible:ring-2')
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
describe('ProductCategory — grid/list browse controls expose the category name', () => {
|
|
175
|
+
const category: ProductCategoryItem = { id: 'pcat_herramientas', name: 'Herramientas', image: '/products/drill.png' }
|
|
176
|
+
|
|
177
|
+
it('names the grid browse control with the host label and the category name', () => {
|
|
178
|
+
render(
|
|
179
|
+
<ProductCategory categories={[category]} presentation='grid' onBrowse={() => {}} browseLabel='Explorar' />,
|
|
180
|
+
)
|
|
181
|
+
const control = screen.getByRole('button', { name: 'Explorar Herramientas' })
|
|
182
|
+
expect(control.textContent).toContain('Explorar')
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('names the list browse control with the host label and the category name', () => {
|
|
186
|
+
render(
|
|
187
|
+
<ProductCategory categories={[category]} presentation='list' onBrowse={() => {}} browseLabel='Explorar' />,
|
|
188
|
+
)
|
|
189
|
+
const control = screen.getByRole('button', { name: 'Explorar Herramientas' })
|
|
190
|
+
expect(control.textContent).toContain('Explorar')
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
describe('ProductCategory — list presentation owns no filtering', () => {
|
|
195
|
+
const categories: ProductCategoryItem[] = [
|
|
196
|
+
{ id: 'pcat_shirts', name: 'Camisas', icon: 'shirt', subcategories: ['Manga larga', 'Manga corta'], itemCount: 12 },
|
|
197
|
+
{ id: 'pcat_boots', name: 'Botas', icon: 'shirt', featured: true },
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
it('renders only the categories the host supplies for the current query', () => {
|
|
201
|
+
const html = renderToStaticMarkup(
|
|
202
|
+
<ProductCategory categories={[categories[1]]} presentation='list' query='bot' onQueryChange={() => {}} />,
|
|
203
|
+
)
|
|
204
|
+
expect(html).toContain('Botas')
|
|
205
|
+
expect(html).not.toContain('Camisas')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('renders subcategory chips and the count with the host label', () => {
|
|
209
|
+
const html = renderToStaticMarkup(
|
|
210
|
+
<ProductCategory categories={[categories[0]]} presentation='list' itemsLabel='productos' />,
|
|
211
|
+
)
|
|
212
|
+
expect(html).toContain('Manga larga')
|
|
213
|
+
expect(html).toContain('12 productos')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('renders the featured badge only when a label is supplied', () => {
|
|
217
|
+
const without = renderToStaticMarkup(<ProductCategory categories={[categories[1]]} presentation='list' />)
|
|
218
|
+
const with_ = renderToStaticMarkup(
|
|
219
|
+
<ProductCategory categories={[categories[1]]} presentation='list' featuredLabel='Destacado' />,
|
|
220
|
+
)
|
|
221
|
+
expect(without).not.toContain('Destacado')
|
|
222
|
+
expect(with_).toContain('Destacado')
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('reflects the controlled query in the search input', () => {
|
|
226
|
+
const html = renderToStaticMarkup(
|
|
227
|
+
<ProductCategory categories={categories} presentation='list' query='cam' onQueryChange={() => {}} />,
|
|
228
|
+
)
|
|
229
|
+
expect(html).toContain('value="cam"')
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('shows the no-results copy for an empty searched list and the empty copy otherwise', () => {
|
|
233
|
+
const searched = renderToStaticMarkup(
|
|
234
|
+
<ProductCategory
|
|
235
|
+
categories={[]}
|
|
236
|
+
presentation='list'
|
|
237
|
+
query='zzz'
|
|
238
|
+
onQueryChange={() => {}}
|
|
239
|
+
noResultsLabel='Sin resultados'
|
|
240
|
+
emptyLabel='Sin categorías'
|
|
241
|
+
/>,
|
|
242
|
+
)
|
|
243
|
+
const empty = renderToStaticMarkup(
|
|
244
|
+
<ProductCategory categories={[]} presentation='list' emptyLabel='Sin categorías' noResultsLabel='Sin resultados' />,
|
|
245
|
+
)
|
|
246
|
+
expect(searched).toContain('Sin resultados')
|
|
247
|
+
expect(empty).toContain('Sin categorías')
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
describe('ProductCategory — reusable across sectors without demo content', () => {
|
|
252
|
+
it('renders distinct sector datasets through the same component and contract', () => {
|
|
253
|
+
const fashion: ProductCategoryItem[] = [
|
|
254
|
+
{ id: 'pcat_fashion_shirts', name: 'Camisas', icon: 'shirt' },
|
|
255
|
+
]
|
|
256
|
+
const electronics: ProductCategoryItem[] = [
|
|
257
|
+
{ id: 'pcat_electronics_laptops', name: 'Portátiles', icon: 'laptop' },
|
|
258
|
+
]
|
|
259
|
+
const fashionHtml = renderToStaticMarkup(<ProductCategory categories={fashion} presentation='icons' />)
|
|
260
|
+
const electronicsHtml = renderToStaticMarkup(<ProductCategory categories={electronics} presentation='icons' />)
|
|
261
|
+
// Same component and contract; only the supplied data differs (no branch on sector).
|
|
262
|
+
expect(fashionHtml).toContain('Camisas')
|
|
263
|
+
expect(electronicsHtml).toContain('Portátiles')
|
|
264
|
+
expect(fashionHtml).not.toContain('Portátiles')
|
|
265
|
+
})
|
|
266
|
+
})
|