@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.
Files changed (178) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/README.md +228 -68
  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/application/login-forms/login-page.test.tsx +190 -0
  7. package/src/blocks/application/login-forms/login-page.tsx +240 -0
  8. package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +6 -8
  9. package/src/blocks/application/signup-forms/signup-page-1.tsx +127 -129
  10. package/src/blocks/application/verification/verify-1.tsx +2 -2
  11. package/src/blocks/application/viewport-ownership.test.tsx +41 -0
  12. package/src/blocks/e-commerce/category-filters/category-listing-filter-fields.tsx +218 -0
  13. package/src/blocks/e-commerce/category-filters/{category-filter-2.test.tsx → category-listing.test.tsx} +287 -199
  14. package/src/blocks/e-commerce/category-filters/category-listing.tsx +934 -0
  15. package/src/blocks/e-commerce/checkout-forms/checkout-contact-step.tsx +149 -0
  16. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-country-field.tsx → checkout-country-field.tsx} +10 -8
  17. package/src/blocks/e-commerce/checkout-forms/checkout-order-summary.tsx +230 -0
  18. package/src/blocks/e-commerce/checkout-forms/checkout-payment-step.tsx +100 -0
  19. package/src/blocks/e-commerce/checkout-forms/checkout-progress.tsx +62 -0
  20. package/src/blocks/e-commerce/checkout-forms/checkout-review-step.tsx +59 -0
  21. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-shipping-step.tsx → checkout-shipping-step.tsx} +65 -41
  22. package/src/blocks/e-commerce/checkout-forms/checkout.test.tsx +478 -0
  23. package/src/blocks/e-commerce/checkout-forms/checkout.tsx +550 -0
  24. package/src/blocks/e-commerce/navigation/storefront-navbar-account-menu.test.tsx +56 -0
  25. package/src/blocks/{marketing/navbars/navbar-4-account-menu.tsx → e-commerce/navigation/storefront-navbar-account-menu.tsx} +3 -3
  26. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.test.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.test.tsx} +22 -22
  27. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.tsx} +3 -3
  28. package/src/blocks/{marketing/navbars/navbar-4-mobile-nav.tsx → e-commerce/navigation/storefront-navbar-mobile-nav.tsx} +4 -12
  29. package/src/blocks/{marketing/navbars/navbar-4-search-bar.tsx → e-commerce/navigation/storefront-navbar-search-bar.tsx} +3 -3
  30. package/src/blocks/{marketing/navbars/navbar-4.test.tsx → e-commerce/navigation/storefront-navbar.test.tsx} +67 -36
  31. package/src/blocks/{marketing/navbars/navbar-4.tsx → e-commerce/navigation/storefront-navbar.tsx} +43 -25
  32. package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
  33. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  34. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  35. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  36. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  37. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  38. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  39. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  40. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  41. package/src/blocks/e-commerce/product-list/product-card.tsx +447 -0
  42. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  43. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  44. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  45. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  46. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  47. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  48. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  49. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  50. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  51. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  52. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +119 -85
  53. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  54. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  55. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  56. package/src/blocks/marketing/features/feature.tsx +195 -0
  57. package/src/blocks/marketing/footers/footer-section-accessibility.test.tsx +118 -0
  58. package/src/blocks/marketing/footers/footer-section-actions.test.tsx +131 -0
  59. package/src/blocks/marketing/footers/footer-section-data.test.tsx +127 -0
  60. package/src/blocks/marketing/footers/footer-section-native-links.test.tsx +100 -0
  61. package/src/blocks/marketing/footers/footer-section-public-api.test.tsx +11 -0
  62. package/src/blocks/marketing/footers/footer-section.test.tsx +46 -0
  63. package/src/blocks/marketing/footers/footer-section.tsx +532 -0
  64. package/src/blocks/marketing/navbars/navbar-public-api.test.tsx +57 -0
  65. package/src/blocks/marketing/navbars/navbar.mega.test.tsx +149 -0
  66. package/src/blocks/marketing/navbars/navbar.test.tsx +155 -0
  67. package/src/blocks/marketing/navbars/navbar.tsx +735 -0
  68. package/src/blocks/marketing/testimonials/testimonial-section.test.tsx +171 -0
  69. package/src/blocks/marketing/testimonials/testimonial-section.tsx +237 -0
  70. package/src/index.ts +156 -128
  71. package/src/lib/contracts-curated.test.ts +47 -29
  72. package/src/lib/contracts.test.ts +198 -2
  73. package/src/lib/contracts.ts +410 -16
  74. package/src/ui/button-group.tsx +1 -1
  75. package/src/ui/carousel.tsx +1 -1
  76. package/src/ui/dialog.tsx +1 -1
  77. package/src/ui/field.tsx +2 -2
  78. package/src/ui/input-group.tsx +3 -3
  79. package/src/ui/pagination.tsx +1 -1
  80. package/src/ui/sidebar.tsx +7 -7
  81. package/src/ui/toggle-group.tsx +1 -1
  82. package/tsconfig.build.json +12 -0
  83. package/tsup.config.ts +11 -7
  84. package/.turbo/turbo-build.log +0 -24
  85. package/.turbo/turbo-test.log +0 -47
  86. package/.turbo/turbo-type-check.log +0 -0
  87. package/dist/.tsbuildinfo +0 -1
  88. package/dist/contracts.d.mts +0 -1732
  89. package/dist/contracts.d.ts +0 -1732
  90. package/dist/contracts.js +0 -423
  91. package/dist/contracts.js.map +0 -1
  92. package/dist/contracts.mjs +0 -365
  93. package/dist/contracts.mjs.map +0 -1
  94. package/dist/index.d.mts +0 -3068
  95. package/dist/index.d.ts +0 -3068
  96. package/dist/index.js +0 -17834
  97. package/dist/index.js.map +0 -1
  98. package/dist/index.mjs +0 -17740
  99. package/dist/index.mjs.map +0 -1
  100. package/src/blocks/application/login-forms/login-page-1.tsx +0 -246
  101. package/src/blocks/application/login-forms/login-page-2.tsx +0 -207
  102. package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +0 -260
  103. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +0 -222
  104. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +0 -921
  105. package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +0 -57
  106. package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +0 -124
  107. package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +0 -170
  108. package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +0 -52
  109. package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +0 -57
  110. package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +0 -68
  111. package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +0 -298
  112. package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +0 -47
  113. package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +0 -9
  114. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +0 -96
  115. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +0 -252
  116. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +0 -188
  117. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +0 -36
  118. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +0 -216
  119. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +0 -338
  120. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +0 -130
  121. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +0 -175
  122. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +0 -190
  123. package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +0 -312
  124. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +0 -91
  125. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +0 -110
  126. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +0 -137
  127. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +0 -68
  128. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +0 -79
  129. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +0 -172
  130. package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +0 -392
  131. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  132. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  133. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  134. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  135. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  136. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  137. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +0 -88
  138. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -192
  139. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  140. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  141. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  142. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  143. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  144. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  145. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  146. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  147. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  148. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  149. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  150. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  151. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  152. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  153. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  154. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  155. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  156. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  157. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  158. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  159. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -229
  160. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -72
  161. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  162. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  163. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  164. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  165. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  166. package/src/blocks/marketing/features/feature-2.tsx +0 -82
  167. package/src/blocks/marketing/footers/footer-section-1.test.tsx +0 -117
  168. package/src/blocks/marketing/footers/footer-section-1.tsx +0 -178
  169. package/src/blocks/marketing/footers/footer-section-3.tsx +0 -192
  170. package/src/blocks/marketing/footers/footer-section-4.tsx +0 -355
  171. package/src/blocks/marketing/navbars/navbar-1.tsx +0 -267
  172. package/src/blocks/marketing/navbars/navbar-2.tsx +0 -351
  173. package/src/blocks/marketing/navbars/navbar-3.tsx +0 -303
  174. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +0 -38
  175. package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +0 -177
  176. package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +0 -219
  177. package/src/blocks/marketing/testimonials/testimonial-1.tsx +0 -141
  178. 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
+ })