@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,153 @@
|
|
|
1
|
+
import { Zap } from 'lucide-react'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import {
|
|
5
|
+
featureCardsSpecSchema,
|
|
6
|
+
featurePropsSchema,
|
|
7
|
+
featureSplitSpecSchema,
|
|
8
|
+
} from '../../../lib/contracts'
|
|
9
|
+
import Feature, { type FeatureItem, type FeatureProps } from './feature'
|
|
10
|
+
|
|
11
|
+
const items: FeatureItem[] = [
|
|
12
|
+
{ id: 'f1', title: 'Fast', description: 'Ships quickly.', icon: Zap },
|
|
13
|
+
{ id: 'f2', title: 'Safe', description: 'Built to last.', icon: Zap },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
const specItems = [
|
|
17
|
+
{ id: 'f1', title: 'Fast', description: 'Ships quickly.', icon: 'Zap' },
|
|
18
|
+
{ id: 'f2', title: 'Safe', description: 'Built to last.', icon: 'Zap' },
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
const render = (props: FeatureProps) => renderToStaticMarkup(<Feature {...props} />)
|
|
22
|
+
|
|
23
|
+
describe('Feature — canonical render', () => {
|
|
24
|
+
it('renders the cards structure by default (former Feature1)', () => {
|
|
25
|
+
const html = render({
|
|
26
|
+
items,
|
|
27
|
+
badge: 'Why us',
|
|
28
|
+
title: 'Benefits',
|
|
29
|
+
description: 'Everything you need',
|
|
30
|
+
learnMoreLabel: 'Learn more',
|
|
31
|
+
ctaLabel: 'See all',
|
|
32
|
+
onCtaClick: () => {},
|
|
33
|
+
onLearnMore: () => {},
|
|
34
|
+
})
|
|
35
|
+
expect(html).toContain('Why us')
|
|
36
|
+
expect(html).toContain('Benefits')
|
|
37
|
+
expect(html).toContain('Fast')
|
|
38
|
+
expect(html).toContain('Learn more')
|
|
39
|
+
expect(html).toContain('See all')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('renders the split structure with presentation: split (former Feature2)', () => {
|
|
43
|
+
const html = render({
|
|
44
|
+
items,
|
|
45
|
+
presentation: 'split',
|
|
46
|
+
title: 'Benefits',
|
|
47
|
+
description: 'Everything you need',
|
|
48
|
+
image: { src: 'https://cdn.example/img.jpg', alt: 'Workshop' },
|
|
49
|
+
className: 'bg-background',
|
|
50
|
+
})
|
|
51
|
+
expect(html).toContain('Benefits')
|
|
52
|
+
expect(html).toContain('Fast')
|
|
53
|
+
expect(html).toContain('https://cdn.example/img.jpg')
|
|
54
|
+
expect(html).toContain('grayscale')
|
|
55
|
+
expect(html).toContain('bg-background')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('omits the badge and per-item learn-more in the split presentation', () => {
|
|
59
|
+
const html = render({
|
|
60
|
+
items,
|
|
61
|
+
presentation: 'split',
|
|
62
|
+
badge: 'Why us',
|
|
63
|
+
title: 'Benefits',
|
|
64
|
+
learnMoreLabel: 'Learn more',
|
|
65
|
+
onLearnMore: () => {},
|
|
66
|
+
image: { src: 'https://cdn.example/img.jpg', alt: 'Workshop' },
|
|
67
|
+
} as unknown as FeatureProps)
|
|
68
|
+
expect(html).not.toContain('Why us')
|
|
69
|
+
expect(html).not.toContain('Learn more')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('renders no empty chrome when optional content is absent (cards)', () => {
|
|
73
|
+
const html = render({ items })
|
|
74
|
+
expect(html).not.toContain('badge')
|
|
75
|
+
expect(html).not.toContain('Learn more')
|
|
76
|
+
expect(html).not.toContain('See all')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('rejects split without an image at the serialized boundary', () => {
|
|
80
|
+
expect(() =>
|
|
81
|
+
featureSplitSpecSchema.parse({ items: specItems, presentation: 'split' }),
|
|
82
|
+
).toThrow()
|
|
83
|
+
expect(() =>
|
|
84
|
+
featurePropsSchema.parse({ items: specItems, presentation: 'split' }),
|
|
85
|
+
).toThrow()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('rejects split with a blank image source', () => {
|
|
89
|
+
expect(() =>
|
|
90
|
+
featureSplitSpecSchema.parse({
|
|
91
|
+
items: specItems,
|
|
92
|
+
presentation: 'split',
|
|
93
|
+
image: { src: ' ', alt: 'Workshop' },
|
|
94
|
+
}),
|
|
95
|
+
).toThrow()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('accepts cards without an image at the serialized boundary', () => {
|
|
99
|
+
expect(() => featurePropsSchema.parse({ items: specItems })).not.toThrow()
|
|
100
|
+
expect(() =>
|
|
101
|
+
featurePropsSchema.parse({ items: specItems, presentation: 'cards' }),
|
|
102
|
+
).not.toThrow()
|
|
103
|
+
expect(() =>
|
|
104
|
+
featureCardsSpecSchema.parse({ items: specItems }),
|
|
105
|
+
).not.toThrow()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('throws a configuration error for untyped split callers without an image', () => {
|
|
109
|
+
expect(() =>
|
|
110
|
+
render({ items, presentation: 'split' } as unknown as FeatureProps),
|
|
111
|
+
).toThrow(/presentation="split".*image/)
|
|
112
|
+
expect(() =>
|
|
113
|
+
render({
|
|
114
|
+
items,
|
|
115
|
+
presentation: 'split',
|
|
116
|
+
image: { src: ' ', alt: 'Workshop' },
|
|
117
|
+
} as unknown as FeatureProps),
|
|
118
|
+
).toThrow(/presentation="split".*image/)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('renders one image with the supplied src and alt and never emits an empty src', () => {
|
|
122
|
+
const html = render({
|
|
123
|
+
items,
|
|
124
|
+
presentation: 'split',
|
|
125
|
+
image: { src: 'https://cdn.example/img.jpg', alt: 'Workshop' },
|
|
126
|
+
})
|
|
127
|
+
expect(html).toContain('<img')
|
|
128
|
+
expect(html).toContain('src="https://cdn.example/img.jpg"')
|
|
129
|
+
expect(html).toContain('alt="Workshop"')
|
|
130
|
+
expect(html.match(/<img/g)).toHaveLength(1)
|
|
131
|
+
expect(html).not.toContain('src=""')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('renders an empty items list without item chrome or errors', () => {
|
|
135
|
+
const html = render({ items: [], title: 'Benefits' })
|
|
136
|
+
expect(html).toContain('Benefits')
|
|
137
|
+
expect(html).not.toContain('<article')
|
|
138
|
+
expect(html).not.toContain('<h3')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('validates a serialized props object against featurePropsSchema', () => {
|
|
142
|
+
const spec = {
|
|
143
|
+
items: [
|
|
144
|
+
{ id: 'f1', title: 'Fast', description: 'Ships quickly.', icon: 'Zap' },
|
|
145
|
+
],
|
|
146
|
+
presentation: 'split',
|
|
147
|
+
title: 'Benefits',
|
|
148
|
+
image: { src: 'https://cdn.example/img.jpg', alt: 'Workshop' },
|
|
149
|
+
}
|
|
150
|
+
const parsed = featurePropsSchema.parse(spec)
|
|
151
|
+
expect(parsed.items[0]?.icon).toBe('Zap')
|
|
152
|
+
})
|
|
153
|
+
})
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { Badge } from '../../../ui/badge'
|
|
2
|
+
import { Button } from '../../../ui/button'
|
|
3
|
+
import { Card, CardContent } from '../../../ui/card'
|
|
4
|
+
import { ArrowRight } from 'lucide-react'
|
|
5
|
+
import type { LucideIcon } from 'lucide-react'
|
|
6
|
+
import { cn } from '../../../lib/utils'
|
|
7
|
+
|
|
8
|
+
export interface FeatureItem {
|
|
9
|
+
id: string
|
|
10
|
+
title: string
|
|
11
|
+
description: string
|
|
12
|
+
icon: LucideIcon
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The bounded presentation surface. `cards` is the card grid with a centered
|
|
17
|
+
* header, per-item learn-more and a section CTA; `split` is the editorial
|
|
18
|
+
* split layout with a required image column and deliberately ignores the
|
|
19
|
+
* badge and per-item learn-more. The value changes structure, never the item
|
|
20
|
+
* contract.
|
|
21
|
+
*/
|
|
22
|
+
export type FeaturePresentation = 'cards' | 'split'
|
|
23
|
+
|
|
24
|
+
interface FeatureBaseProps {
|
|
25
|
+
items: FeatureItem[]
|
|
26
|
+
title?: string
|
|
27
|
+
description?: string
|
|
28
|
+
className?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FeatureCardsProps extends FeatureBaseProps {
|
|
32
|
+
/** Bounded presentation option; defaults to `cards`. */
|
|
33
|
+
presentation?: 'cards'
|
|
34
|
+
/** `cards`-only header chrome. */
|
|
35
|
+
badge?: string
|
|
36
|
+
/** Section CTA (`cards` only). */
|
|
37
|
+
ctaLabel?: string
|
|
38
|
+
onCtaClick?: () => void
|
|
39
|
+
/** Per-item link (`cards` only). */
|
|
40
|
+
learnMoreLabel?: string
|
|
41
|
+
onLearnMore?: (item: FeatureItem) => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface FeatureSplitProps extends FeatureBaseProps {
|
|
45
|
+
presentation: 'split'
|
|
46
|
+
/** Required editorial image; a non-empty `src` is part of the contract. */
|
|
47
|
+
image: { src: string; alt: string }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type FeatureProps = FeatureCardsProps | FeatureSplitProps
|
|
51
|
+
|
|
52
|
+
function SplitFeature({ items, title, description, image, className }: FeatureSplitProps) {
|
|
53
|
+
if (!image?.src || image.src.trim() === '') {
|
|
54
|
+
throw new Error(
|
|
55
|
+
'Feature: presentation="split" requires a non-empty image.src. Pass { image: { src, alt } } or use presentation="cards".',
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<section
|
|
61
|
+
className={cn('mx-auto w-full max-w-7xl px-4 py-12 sm:px-6 md:py-16 lg:px-8', className)}
|
|
62
|
+
>
|
|
63
|
+
<div className='grid gap-12 lg:grid-cols-2 lg:gap-16'>
|
|
64
|
+
<div className='flex flex-col gap-10'>
|
|
65
|
+
{(title || description) && (
|
|
66
|
+
<header className='flex flex-col gap-4'>
|
|
67
|
+
{title && (
|
|
68
|
+
<h2 className='text-3xl font-bold tracking-tight text-balance sm:text-4xl lg:text-5xl'>
|
|
69
|
+
{title}
|
|
70
|
+
</h2>
|
|
71
|
+
)}
|
|
72
|
+
{description && (
|
|
73
|
+
<p className='text-muted-foreground text-base text-pretty md:text-lg'>
|
|
74
|
+
{description}
|
|
75
|
+
</p>
|
|
76
|
+
)}
|
|
77
|
+
</header>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
<div className='grid gap-6 sm:grid-cols-2 sm:gap-8'>
|
|
81
|
+
{items.map(item => (
|
|
82
|
+
<div key={item.id} className='group flex flex-col items-start gap-3 rounded-lg'>
|
|
83
|
+
<div className='bg-secondary text-secondary-foreground inline-flex size-10 items-center justify-center rounded-full'>
|
|
84
|
+
<item.icon className='size-5' aria-hidden='true' />
|
|
85
|
+
</div>
|
|
86
|
+
<div className='flex flex-col gap-1.5'>
|
|
87
|
+
<h3 className='text-foreground text-base font-semibold md:text-lg'>{item.title}</h3>
|
|
88
|
+
<p className='text-muted-foreground text-sm text-balance'>{item.description}</p>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
))}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div className='relative'>
|
|
96
|
+
<div className='bg-muted relative z-10 overflow-hidden rounded-lg shadow-sm transition-all duration-300 hover:shadow-md'>
|
|
97
|
+
<div className='aspect-[4/3] w-full'>
|
|
98
|
+
<img
|
|
99
|
+
src={image.src}
|
|
100
|
+
alt={image.alt}
|
|
101
|
+
className='size-full object-cover grayscale'
|
|
102
|
+
loading='lazy'
|
|
103
|
+
decoding='async'
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</section>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function CardsFeature({
|
|
114
|
+
items,
|
|
115
|
+
badge,
|
|
116
|
+
title,
|
|
117
|
+
description,
|
|
118
|
+
ctaLabel,
|
|
119
|
+
onCtaClick,
|
|
120
|
+
learnMoreLabel,
|
|
121
|
+
onLearnMore,
|
|
122
|
+
className,
|
|
123
|
+
}: FeatureCardsProps) {
|
|
124
|
+
return (
|
|
125
|
+
<section
|
|
126
|
+
className={cn('mx-auto w-full max-w-7xl px-4 py-12 sm:px-6 md:py-16 lg:px-8', className)}
|
|
127
|
+
>
|
|
128
|
+
{(badge || title || description) && (
|
|
129
|
+
<header className='mx-auto mb-12 max-w-2xl text-center'>
|
|
130
|
+
{badge && (
|
|
131
|
+
<Badge variant='secondary' className='h-auto mb-4 rounded-full px-4 py-1 font-medium'>
|
|
132
|
+
{badge}
|
|
133
|
+
</Badge>
|
|
134
|
+
)}
|
|
135
|
+
{title && (
|
|
136
|
+
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl'>{title}</h2>
|
|
137
|
+
)}
|
|
138
|
+
{description && (
|
|
139
|
+
<p className='text-muted-foreground text-pretty'>{description}</p>
|
|
140
|
+
)}
|
|
141
|
+
</header>
|
|
142
|
+
)}
|
|
143
|
+
|
|
144
|
+
<div className='grid gap-4 sm:grid-cols-2 sm:gap-6 md:grid-cols-3'>
|
|
145
|
+
{items.map(feature => (
|
|
146
|
+
<article key={feature.id} className='group'>
|
|
147
|
+
<Card className='relative h-full overflow-hidden transition-all hover:shadow-md'>
|
|
148
|
+
<CardContent className='px-6'>
|
|
149
|
+
<div className='bg-secondary text-secondary-foreground mb-4 inline-flex size-12 items-center justify-center rounded-xl'>
|
|
150
|
+
<feature.icon className='size-5' aria-hidden='true' />
|
|
151
|
+
</div>
|
|
152
|
+
<h3 className='mb-2 text-lg font-semibold'>{feature.title}</h3>
|
|
153
|
+
<p className='text-muted-foreground mb-4 text-sm'>{feature.description}</p>
|
|
154
|
+
|
|
155
|
+
{learnMoreLabel && (
|
|
156
|
+
<Button
|
|
157
|
+
variant='link'
|
|
158
|
+
size='sm'
|
|
159
|
+
className='h-8 px-3 text-xs text-muted-foreground hover:text-foreground h-auto cursor-pointer !p-0 text-sm'
|
|
160
|
+
onClick={onLearnMore ? () => onLearnMore(feature) : undefined}
|
|
161
|
+
>
|
|
162
|
+
{learnMoreLabel}
|
|
163
|
+
<ArrowRight />
|
|
164
|
+
</Button>
|
|
165
|
+
)}
|
|
166
|
+
</CardContent>
|
|
167
|
+
</Card>
|
|
168
|
+
</article>
|
|
169
|
+
))}
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
{ctaLabel && (
|
|
173
|
+
<div className='mt-12 text-center'>
|
|
174
|
+
<Button
|
|
175
|
+
size='lg'
|
|
176
|
+
className='h-10 cursor-pointer rounded-lg !px-8'
|
|
177
|
+
onClick={onCtaClick}
|
|
178
|
+
>
|
|
179
|
+
{ctaLabel}
|
|
180
|
+
<ArrowRight />
|
|
181
|
+
</Button>
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
</section>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function Feature(props: FeatureProps) {
|
|
189
|
+
if (props.presentation === 'split') {
|
|
190
|
+
return <SplitFeature {...props} />
|
|
191
|
+
}
|
|
192
|
+
return <CardsFeature {...props} />
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export default Feature
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import userEvent from '@testing-library/user-event'
|
|
4
|
+
import { AtSign } from 'lucide-react'
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
FooterSection,
|
|
9
|
+
type FooterSectionProps,
|
|
10
|
+
} from './footer-section'
|
|
11
|
+
|
|
12
|
+
const presentations = [
|
|
13
|
+
'brand-columns',
|
|
14
|
+
'newsletter-grid',
|
|
15
|
+
'contact',
|
|
16
|
+
] as const satisfies readonly FooterSectionProps['presentation'][]
|
|
17
|
+
|
|
18
|
+
function createProps(
|
|
19
|
+
presentation: FooterSectionProps['presentation'] = 'contact',
|
|
20
|
+
): FooterSectionProps {
|
|
21
|
+
return {
|
|
22
|
+
presentation,
|
|
23
|
+
brandName: 'Thor Provider',
|
|
24
|
+
linkColumns: [
|
|
25
|
+
{
|
|
26
|
+
title: 'Repuestos',
|
|
27
|
+
links: [{ label: 'Catálogo', href: '/repuestos' }],
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
newsletter: {
|
|
31
|
+
title: 'Novedades para talleres',
|
|
32
|
+
buttonLabel: 'Suscribirme',
|
|
33
|
+
},
|
|
34
|
+
contact: {
|
|
35
|
+
heading: 'Contacto',
|
|
36
|
+
address: { label: 'Av. Talleres 123' },
|
|
37
|
+
phone: { label: '+34 900 123 456', href: 'tel:+34900123456' },
|
|
38
|
+
email: { label: 'ventas@thorprovider.es', href: 'mailto:ventas@thorprovider.es' },
|
|
39
|
+
},
|
|
40
|
+
socialLinks: [
|
|
41
|
+
{ label: 'Instagram', href: '/redes/instagram', icon: 'instagram' },
|
|
42
|
+
],
|
|
43
|
+
socialIcons: { instagram: AtSign },
|
|
44
|
+
onNewsletterSubmit: vi.fn(),
|
|
45
|
+
onLinkClick: vi.fn(),
|
|
46
|
+
onSocialClick: vi.fn(),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('FooterSection accessibility', () => {
|
|
51
|
+
it('uses consumer-supplied localized copy as the newsletter field label', () => {
|
|
52
|
+
render(<FooterSection {...createProps()} />)
|
|
53
|
+
|
|
54
|
+
expect(
|
|
55
|
+
screen.getByRole('textbox', { name: 'Novedades para talleres' }),
|
|
56
|
+
).toBeDefined()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('exposes named footer and navigation landmarks', () => {
|
|
60
|
+
render(<FooterSection {...createProps()} />)
|
|
61
|
+
|
|
62
|
+
expect(
|
|
63
|
+
screen.getByRole('contentinfo', { name: 'Thor Provider footer' }),
|
|
64
|
+
).toBeDefined()
|
|
65
|
+
expect(screen.getByRole('navigation', { name: 'Repuestos' })).toBeDefined()
|
|
66
|
+
expect(screen.getByRole('navigation', { name: 'Social media' })).toBeDefined()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('keeps decorative newsletter and social icons hidden from assistive technology', () => {
|
|
70
|
+
render(<FooterSection {...createProps()} />)
|
|
71
|
+
|
|
72
|
+
const newsletterIcon = screen
|
|
73
|
+
.getByRole('button', { name: 'Suscribirme' })
|
|
74
|
+
.querySelector('svg')
|
|
75
|
+
const socialIcon = screen
|
|
76
|
+
.getByRole('link', { name: 'Instagram' })
|
|
77
|
+
.querySelector('svg')
|
|
78
|
+
|
|
79
|
+
expect(newsletterIcon?.getAttribute('aria-hidden')).toBe('true')
|
|
80
|
+
expect(socialIcon?.getAttribute('aria-hidden')).toBe('true')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('keeps every actionable item keyboard reachable in page order', async () => {
|
|
84
|
+
const user = userEvent.setup()
|
|
85
|
+
const props = createProps()
|
|
86
|
+
render(<FooterSection {...props} />)
|
|
87
|
+
|
|
88
|
+
const phone = screen.getByRole('link', { name: '+34 900 123 456' })
|
|
89
|
+
const email = screen.getByRole('link', { name: 'ventas@thorprovider.es' })
|
|
90
|
+
const newsletter = screen.getByRole('textbox', {
|
|
91
|
+
name: 'Novedades para talleres',
|
|
92
|
+
})
|
|
93
|
+
const submit = screen.getByRole('button', { name: 'Suscribirme' })
|
|
94
|
+
const columnLink = screen.getByRole('link', { name: 'Catálogo' })
|
|
95
|
+
const socialLink = screen.getByRole('link', { name: 'Instagram' })
|
|
96
|
+
|
|
97
|
+
for (const control of [phone, email, newsletter, submit, columnLink, socialLink]) {
|
|
98
|
+
await user.tab()
|
|
99
|
+
expect(document.activeElement).toBe(control)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
expect(columnLink.getAttribute('class')).toContain('focus-visible')
|
|
103
|
+
expect(socialLink.getAttribute('class')).toContain('focus-visible')
|
|
104
|
+
|
|
105
|
+
await user.keyboard('{Enter}')
|
|
106
|
+
expect(props.onSocialClick).toHaveBeenCalledWith('Instagram')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it.each(presentations)(
|
|
110
|
+
'keeps the %s layout constrained responsively',
|
|
111
|
+
(presentation) => {
|
|
112
|
+
render(<FooterSection {...createProps(presentation)} />)
|
|
113
|
+
|
|
114
|
+
const footer = screen.getByRole('contentinfo')
|
|
115
|
+
expect(footer.querySelector('[class*="md:grid-cols"]')).not.toBeNull()
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import userEvent from '@testing-library/user-event'
|
|
4
|
+
import { AtSign } from 'lucide-react'
|
|
5
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
6
|
+
|
|
7
|
+
import { FooterSection, type FooterSectionProps } from './footer-section'
|
|
8
|
+
|
|
9
|
+
const sharedProps = {
|
|
10
|
+
brandName: 'Thor Provider',
|
|
11
|
+
brandDescription: 'Repuestos y soluciones para talleres.',
|
|
12
|
+
linkColumns: [
|
|
13
|
+
{
|
|
14
|
+
title: 'Repuestos',
|
|
15
|
+
links: [{ label: 'Catálogo de repuestos', href: '/repuestos' }],
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
newsletter: {
|
|
19
|
+
title: 'Recibe novedades',
|
|
20
|
+
placeholder: 'Correo electrónico',
|
|
21
|
+
buttonLabel: 'Suscribirme',
|
|
22
|
+
},
|
|
23
|
+
socialLinks: [
|
|
24
|
+
{
|
|
25
|
+
label: 'Instagram',
|
|
26
|
+
href: 'https://instagram.com/thorprovider',
|
|
27
|
+
icon: 'instagram',
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
socialIcons: { instagram: AtSign },
|
|
31
|
+
contact: {
|
|
32
|
+
heading: 'Contacto',
|
|
33
|
+
phone: { label: '+56 2 2345 6789', href: 'tel:+56223456789' },
|
|
34
|
+
email: {
|
|
35
|
+
label: 'hola@thorprovider.cl',
|
|
36
|
+
href: 'mailto:hola@thorprovider.cl',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
} satisfies Omit<FooterSectionProps, 'presentation'>
|
|
40
|
+
|
|
41
|
+
describe('FooterSection host actions', () => {
|
|
42
|
+
it('reports the entered newsletter email through the host callback', async () => {
|
|
43
|
+
const user = userEvent.setup()
|
|
44
|
+
const onNewsletterSubmit = vi.fn()
|
|
45
|
+
|
|
46
|
+
render(
|
|
47
|
+
<FooterSection
|
|
48
|
+
{...sharedProps}
|
|
49
|
+
presentation="newsletter-grid"
|
|
50
|
+
onNewsletterSubmit={onNewsletterSubmit}
|
|
51
|
+
/>,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
await user.type(
|
|
55
|
+
screen.getByPlaceholderText('Correo electrónico'),
|
|
56
|
+
'taller@thorprovider.cl',
|
|
57
|
+
)
|
|
58
|
+
await user.click(screen.getByRole('button', { name: 'Suscribirme' }))
|
|
59
|
+
|
|
60
|
+
expect(onNewsletterSubmit).toHaveBeenCalledTimes(1)
|
|
61
|
+
expect(onNewsletterSubmit).toHaveBeenCalledWith('taller@thorprovider.cl')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('keeps newsletter controls visible but disabled without a callback', () => {
|
|
65
|
+
render(<FooterSection {...sharedProps} presentation="newsletter-grid" />)
|
|
66
|
+
|
|
67
|
+
expect(
|
|
68
|
+
(screen.getByPlaceholderText('Correo electrónico') as HTMLInputElement)
|
|
69
|
+
.disabled,
|
|
70
|
+
).toBe(true)
|
|
71
|
+
expect(
|
|
72
|
+
(screen.getByRole('button', { name: 'Suscribirme' }) as HTMLButtonElement)
|
|
73
|
+
.disabled,
|
|
74
|
+
).toBe(true)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('renders footer-column and social destinations as text without callbacks', () => {
|
|
78
|
+
render(<FooterSection {...sharedProps} presentation="contact" />)
|
|
79
|
+
|
|
80
|
+
expect(screen.getByText('Catálogo de repuestos').closest('a')).toBeNull()
|
|
81
|
+
expect(screen.getByText('Instagram').closest('a')).toBeNull()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('reports host-routed footer and social actions while retaining their hrefs', async () => {
|
|
85
|
+
const user = userEvent.setup()
|
|
86
|
+
const onLinkClick = vi.fn()
|
|
87
|
+
const onSocialClick = vi.fn()
|
|
88
|
+
|
|
89
|
+
render(
|
|
90
|
+
<FooterSection
|
|
91
|
+
{...sharedProps}
|
|
92
|
+
presentation="contact"
|
|
93
|
+
onLinkClick={onLinkClick}
|
|
94
|
+
onSocialClick={onSocialClick}
|
|
95
|
+
/>,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
const footerLink = screen.getByRole('link', {
|
|
99
|
+
name: 'Catálogo de repuestos',
|
|
100
|
+
})
|
|
101
|
+
const socialLink = screen.getByRole('link', { name: 'Instagram' })
|
|
102
|
+
expect(footerLink.getAttribute('href')).toBe('/repuestos')
|
|
103
|
+
expect(socialLink.getAttribute('href')).toBe(
|
|
104
|
+
'https://instagram.com/thorprovider',
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
await user.click(footerLink)
|
|
108
|
+
await user.click(socialLink)
|
|
109
|
+
|
|
110
|
+
expect(onLinkClick).toHaveBeenCalledWith({
|
|
111
|
+
label: 'Catálogo de repuestos',
|
|
112
|
+
href: '/repuestos',
|
|
113
|
+
})
|
|
114
|
+
expect(onSocialClick).toHaveBeenCalledWith('Instagram')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('preserves native telephone and email contact destinations', () => {
|
|
118
|
+
render(<FooterSection {...sharedProps} presentation="contact" />)
|
|
119
|
+
|
|
120
|
+
expect(
|
|
121
|
+
screen
|
|
122
|
+
.getByRole('link', { name: '+56 2 2345 6789' })
|
|
123
|
+
.getAttribute('href'),
|
|
124
|
+
).toBe('tel:+56223456789')
|
|
125
|
+
expect(
|
|
126
|
+
screen
|
|
127
|
+
.getByRole('link', { name: 'hola@thorprovider.cl' })
|
|
128
|
+
.getAttribute('href'),
|
|
129
|
+
).toBe('mailto:hola@thorprovider.cl')
|
|
130
|
+
})
|
|
131
|
+
})
|