@thorprovider/blocks 1.0.0 → 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 (91) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +156 -40
  3. package/package.json +1 -1
  4. package/src/blocks/application/login-forms/login-page.test.tsx +190 -0
  5. package/src/blocks/application/login-forms/login-page.tsx +240 -0
  6. package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +6 -8
  7. package/src/blocks/application/signup-forms/signup-page-1.tsx +127 -129
  8. package/src/blocks/application/verification/verify-1.tsx +2 -2
  9. package/src/blocks/application/viewport-ownership.test.tsx +41 -0
  10. package/src/blocks/e-commerce/category-filters/category-listing-filter-fields.tsx +218 -0
  11. package/src/blocks/e-commerce/category-filters/{category-filter-2.test.tsx → category-listing.test.tsx} +287 -199
  12. package/src/blocks/e-commerce/category-filters/category-listing.tsx +934 -0
  13. package/src/blocks/e-commerce/checkout-forms/checkout-contact-step.tsx +149 -0
  14. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-country-field.tsx → checkout-country-field.tsx} +10 -8
  15. package/src/blocks/e-commerce/checkout-forms/checkout-order-summary.tsx +230 -0
  16. package/src/blocks/e-commerce/checkout-forms/checkout-payment-step.tsx +100 -0
  17. package/src/blocks/e-commerce/checkout-forms/checkout-progress.tsx +62 -0
  18. package/src/blocks/e-commerce/checkout-forms/checkout-review-step.tsx +59 -0
  19. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-shipping-step.tsx → checkout-shipping-step.tsx} +65 -41
  20. package/src/blocks/e-commerce/checkout-forms/checkout.test.tsx +478 -0
  21. package/src/blocks/e-commerce/checkout-forms/checkout.tsx +550 -0
  22. package/src/blocks/{marketing/navbars/navbar-4-account-menu.test.tsx → e-commerce/navigation/storefront-navbar-account-menu.test.tsx} +8 -8
  23. package/src/blocks/{marketing/navbars/navbar-4-account-menu.tsx → e-commerce/navigation/storefront-navbar-account-menu.tsx} +3 -3
  24. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.test.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.test.tsx} +22 -22
  25. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.tsx} +3 -3
  26. package/src/blocks/{marketing/navbars/navbar-4-mobile-nav.tsx → e-commerce/navigation/storefront-navbar-mobile-nav.tsx} +4 -12
  27. package/src/blocks/{marketing/navbars/navbar-4-search-bar.tsx → e-commerce/navigation/storefront-navbar-search-bar.tsx} +3 -3
  28. package/src/blocks/{marketing/navbars/navbar-4.test.tsx → e-commerce/navigation/storefront-navbar.test.tsx} +40 -40
  29. package/src/blocks/{marketing/navbars/navbar-4.tsx → e-commerce/navigation/storefront-navbar.tsx} +17 -17
  30. package/src/blocks/e-commerce/product-list/product-card.tsx +63 -40
  31. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +2 -2
  32. package/src/blocks/marketing/footers/footer-section-accessibility.test.tsx +118 -0
  33. package/src/blocks/marketing/footers/footer-section-actions.test.tsx +131 -0
  34. package/src/blocks/marketing/footers/footer-section-data.test.tsx +127 -0
  35. package/src/blocks/marketing/footers/footer-section-native-links.test.tsx +100 -0
  36. package/src/blocks/marketing/footers/footer-section-public-api.test.tsx +11 -0
  37. package/src/blocks/marketing/footers/footer-section.test.tsx +46 -0
  38. package/src/blocks/marketing/footers/footer-section.tsx +532 -0
  39. package/src/blocks/marketing/navbars/navbar-public-api.test.tsx +57 -0
  40. package/src/blocks/marketing/navbars/navbar.mega.test.tsx +149 -0
  41. package/src/blocks/marketing/navbars/navbar.test.tsx +155 -0
  42. package/src/blocks/marketing/navbars/navbar.tsx +735 -0
  43. package/src/blocks/marketing/testimonials/testimonial-section.test.tsx +171 -0
  44. package/src/blocks/marketing/testimonials/testimonial-section.tsx +237 -0
  45. package/src/index.ts +80 -81
  46. package/src/lib/contracts-curated.test.ts +5 -5
  47. package/src/lib/contracts.test.ts +55 -0
  48. package/src/lib/contracts.ts +64 -2
  49. package/tsup.config.ts +1 -1
  50. package/src/blocks/application/login-forms/login-page-1.tsx +0 -246
  51. package/src/blocks/application/login-forms/login-page-2.tsx +0 -207
  52. package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +0 -260
  53. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +0 -222
  54. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +0 -921
  55. package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +0 -57
  56. package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +0 -124
  57. package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +0 -170
  58. package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +0 -52
  59. package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +0 -57
  60. package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +0 -68
  61. package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +0 -298
  62. package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +0 -47
  63. package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +0 -9
  64. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +0 -96
  65. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +0 -252
  66. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +0 -188
  67. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +0 -36
  68. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +0 -216
  69. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +0 -338
  70. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +0 -130
  71. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +0 -175
  72. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +0 -190
  73. package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +0 -312
  74. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +0 -91
  75. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +0 -110
  76. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +0 -137
  77. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +0 -68
  78. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +0 -79
  79. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +0 -172
  80. package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +0 -392
  81. package/src/blocks/marketing/footers/footer-section-1.test.tsx +0 -117
  82. package/src/blocks/marketing/footers/footer-section-1.tsx +0 -178
  83. package/src/blocks/marketing/footers/footer-section-3.tsx +0 -192
  84. package/src/blocks/marketing/footers/footer-section-4.tsx +0 -355
  85. package/src/blocks/marketing/navbars/navbar-1.tsx +0 -267
  86. package/src/blocks/marketing/navbars/navbar-2.tsx +0 -351
  87. package/src/blocks/marketing/navbars/navbar-3.tsx +0 -303
  88. package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +0 -177
  89. package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +0 -219
  90. package/src/blocks/marketing/testimonials/testimonial-1.tsx +0 -141
  91. package/src/blocks/marketing/testimonials/testimonial-2.tsx +0 -108
@@ -26,6 +26,12 @@ export interface ProductCardProps {
26
26
  /** Host-resolved wishlist membership; reflects the rich presentation heart. */
27
27
  wishlisted?: boolean
28
28
  onAddToCart?: (item: ProductPresentation) => void
29
+ /**
30
+ * Hides the add-to-cart control for listings whose only action is opening the
31
+ * product. Without this a host with no cart callback would render an inert
32
+ * button.
33
+ */
34
+ hideAddToCart?: boolean
29
35
  onProductClick?: (item: ProductPresentation) => void
30
36
  /**
31
37
  * Host-owned wishlist toggle; reports the item and the next state from the
@@ -99,6 +105,7 @@ function RatingRow({ value }: { value: number }) {
99
105
 
100
106
  function ClassicPresentation({
101
107
  item,
108
+ hideAddToCart = false,
102
109
  onAddToCart,
103
110
  onProductClick,
104
111
  addToCartLabel,
@@ -138,7 +145,9 @@ function ClassicPresentation({
138
145
  width={400}
139
146
  height={400}
140
147
  />
141
- ) : null}
148
+ ) : (
149
+ <div data-slot='product-image-placeholder' aria-hidden='true' className='bg-muted size-full rounded-md' />
150
+ )}
142
151
  </div>
143
152
 
144
153
  <div className='flex flex-1 flex-col'>
@@ -164,18 +173,20 @@ function ClassicPresentation({
164
173
  </CardContent>
165
174
 
166
175
  <CardFooter className='bg-transparent px-3 pt-0 border-t-0 md:px-4'>
167
- <Button
168
- variant='outline'
169
- size='sm'
170
- className='h-8 w-full cursor-pointer px-3 text-sm text-xs'
171
- disabled={!available}
172
- onClick={(e) => {
173
- e.stopPropagation()
174
- onAddToCart?.(item)
175
- }}
176
- >
177
- {addToCartLabel}
178
- </Button>
176
+ {hideAddToCart ? null : (
177
+ <Button
178
+ variant='outline'
179
+ size='sm'
180
+ className='h-8 w-full cursor-pointer px-3 text-sm text-xs'
181
+ disabled={!available}
182
+ onClick={(e) => {
183
+ e.stopPropagation()
184
+ onAddToCart?.(item)
185
+ }}
186
+ >
187
+ {addToCartLabel}
188
+ </Button>
189
+ )}
179
190
  </CardFooter>
180
191
  </Card>
181
192
  </motion.div>
@@ -184,6 +195,7 @@ function ClassicPresentation({
184
195
 
185
196
  function MinimalPresentation({
186
197
  item,
198
+ hideAddToCart = false,
187
199
  onAddToCart,
188
200
  onProductClick,
189
201
  addToCartLabel,
@@ -216,7 +228,9 @@ function MinimalPresentation({
216
228
  className='size-full object-cover transition-transform duration-300 group-hover:scale-105'
217
229
  loading='lazy'
218
230
  />
219
- ) : null}
231
+ ) : (
232
+ <div data-slot='product-image-placeholder' aria-hidden='true' className='bg-muted size-full' />
233
+ )}
220
234
  </div>
221
235
 
222
236
  <div className='flex flex-col gap-2'>
@@ -224,19 +238,21 @@ function MinimalPresentation({
224
238
  {rating !== undefined && <RatingRow value={rating} />}
225
239
  <div className='flex items-center justify-between gap-2'>
226
240
  <PriceRow item={item} unavailablePriceLabel={unavailablePriceLabel} />
227
- <Button
228
- variant='ghost'
229
- size='icon'
230
- className='size-8 shrink-0 cursor-pointer'
231
- aria-label={addToCartLabel}
232
- disabled={!available}
233
- onClick={(e) => {
234
- e.stopPropagation()
235
- onAddToCart?.(item)
236
- }}
237
- >
238
- <ShoppingCart className='size-4' aria-hidden='true' />
239
- </Button>
241
+ {hideAddToCart ? null : (
242
+ <Button
243
+ variant='ghost'
244
+ size='icon'
245
+ className='size-8 shrink-0 cursor-pointer'
246
+ aria-label={addToCartLabel}
247
+ disabled={!available}
248
+ onClick={(e) => {
249
+ e.stopPropagation()
250
+ onAddToCart?.(item)
251
+ }}
252
+ >
253
+ <ShoppingCart className='size-4' aria-hidden='true' />
254
+ </Button>
255
+ )}
240
256
  </div>
241
257
  </div>
242
258
  </CardContent>
@@ -283,6 +299,7 @@ function WishlistHeart({
283
299
 
284
300
  function RichPresentation({
285
301
  item,
302
+ hideAddToCart = false,
286
303
  wishlisted = false,
287
304
  onAddToCart,
288
305
  onProductClick,
@@ -314,7 +331,9 @@ function RichPresentation({
314
331
  className='size-full object-cover'
315
332
  loading='lazy'
316
333
  />
317
- ) : null}
334
+ ) : (
335
+ <div data-slot='product-image-placeholder' aria-hidden='true' className='bg-muted size-full' />
336
+ )}
318
337
 
319
338
  {item.badge && (
320
339
  <Badge className='absolute start-2 top-2 z-20 flex items-center gap-1 px-2 py-1 font-semibold text-white backdrop-blur-xs'>
@@ -361,18 +380,20 @@ function RichPresentation({
361
380
  <div className='flex flex-col gap-2 p-4'>
362
381
  <h3 className='font-medium text-balance'>{item.title}</h3>
363
382
  <PriceRow item={item} unavailablePriceLabel={unavailablePriceLabel} />
364
- <Button
365
- variant='outline'
366
- size='sm'
367
- className='mt-2 w-full cursor-pointer text-xs'
368
- disabled={!available}
369
- onClick={(e) => {
370
- e.stopPropagation()
371
- onAddToCart?.(item)
372
- }}
373
- >
374
- {addToCartLabel}
375
- </Button>
383
+ {hideAddToCart ? null : (
384
+ <Button
385
+ variant='outline'
386
+ size='sm'
387
+ className='mt-2 w-full cursor-pointer text-xs'
388
+ disabled={!available}
389
+ onClick={(e) => {
390
+ e.stopPropagation()
391
+ onAddToCart?.(item)
392
+ }}
393
+ >
394
+ {addToCartLabel}
395
+ </Button>
396
+ )}
376
397
  </div>
377
398
  </CardContent>
378
399
  </Card>
@@ -391,6 +412,7 @@ export function ProductCard({
391
412
  item,
392
413
  presentation = 'classic',
393
414
  wishlisted,
415
+ hideAddToCart,
394
416
  onAddToCart,
395
417
  onProductClick,
396
418
  onToggleWishlist,
@@ -405,6 +427,7 @@ export function ProductCard({
405
427
  item,
406
428
  presentation,
407
429
  wishlisted,
430
+ hideAddToCart,
408
431
  onAddToCart,
409
432
  onProductClick,
410
433
  onToggleWishlist,
@@ -191,8 +191,8 @@ describe('ShoppingCart — truthful evidence only', () => {
191
191
  promotions: [],
192
192
  totals: { subtotal: eur('110.00'), discount: eur('10.00'), tax: eur('8.00'), shipping: eur('0'), total: eur('108.00') },
193
193
  })
194
- expect(html).toMatch(/EUR\s110\.00/)
195
- expect(html).toMatch(/EUR\s108\.00/)
194
+ expect(html).toMatch(/(?:EUR|€)\s?110\.00/)
195
+ expect(html).toMatch(/(?:EUR|€)\s?108\.00/)
196
196
  })
197
197
 
198
198
  it('renders host-supplied promotion progress and activated copy from data', () => {
@@ -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
+ })
@@ -0,0 +1,127 @@
1
+ // @vitest-environment jsdom
2
+ import { fireEvent, render, screen } from '@testing-library/react'
3
+ import { AtSign } from 'lucide-react'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+
6
+ import {
7
+ FooterSection,
8
+ type FooterSectionProps,
9
+ type FooterSocialLink,
10
+ } from './footer-section'
11
+
12
+ const baseProps = {
13
+ presentation: 'contact',
14
+ brandName: 'Thor Provider',
15
+ linkColumns: [],
16
+ } satisfies FooterSectionProps
17
+
18
+ const instagram = {
19
+ label: 'Instagram',
20
+ href: '/redes/instagram',
21
+ icon: 'instagram',
22
+ } satisfies FooterSocialLink
23
+
24
+ describe('FooterSection serializable social content and list identities', () => {
25
+ it('round-trips social identifiers as plain JSON data', () => {
26
+ const serialized = JSON.stringify([instagram])
27
+
28
+ expect(serialized).toBe(
29
+ '[{"label":"Instagram","href":"/redes/instagram","icon":"instagram"}]',
30
+ )
31
+ expect(JSON.parse(serialized)).toEqual([instagram])
32
+ })
33
+
34
+ it('renders a host-bound icon for its serializable identifier', () => {
35
+ render(
36
+ <FooterSection
37
+ {...baseProps}
38
+ socialLinks={[instagram]}
39
+ socialIcons={{ instagram: AtSign }}
40
+ onSocialClick={() => undefined}
41
+ />,
42
+ )
43
+
44
+ const socialLink = screen.getByRole('link', { name: 'Instagram' })
45
+ expect(socialLink.getAttribute('href')).toBe('/redes/instagram')
46
+ expect(socialLink.querySelector('svg')).not.toBeNull()
47
+ })
48
+
49
+ it('uses an optional accessible name without changing the raw social label', () => {
50
+ const onSocialClick = vi.fn()
51
+ const socialLink = { ...instagram, ariaLabel: 'Seguir en Instagram' }
52
+
53
+ render(
54
+ <FooterSection
55
+ {...baseProps}
56
+ socialLinks={[socialLink]}
57
+ socialIcons={{ instagram: AtSign }}
58
+ onSocialClick={onSocialClick}
59
+ />,
60
+ )
61
+
62
+ const control = screen.getByRole('link', { name: 'Seguir en Instagram' })
63
+ expect(control.textContent).toContain('Instagram')
64
+ fireEvent.click(control)
65
+ expect(onSocialClick).toHaveBeenCalledWith('Instagram')
66
+ })
67
+
68
+ it('keeps the provider label visible when its icon identifier is unmapped', () => {
69
+ render(
70
+ <FooterSection
71
+ {...baseProps}
72
+ socialLinks={[instagram]}
73
+ socialIcons={{}}
74
+ onSocialClick={() => undefined}
75
+ />,
76
+ )
77
+
78
+ const socialLink = screen.getByRole('link', { name: 'Instagram' })
79
+ expect(socialLink.textContent).toContain('Instagram')
80
+ expect(socialLink.querySelector('svg')).toBeNull()
81
+ })
82
+
83
+ it('rejects duplicate column identities instead of emitting duplicate keys', () => {
84
+ expect(() =>
85
+ render(
86
+ <FooterSection
87
+ {...baseProps}
88
+ linkColumns={[
89
+ { title: 'Servicios', links: [{ label: 'Talleres' }] },
90
+ { title: 'Servicios', links: [{ label: 'Repuestos' }] },
91
+ ]}
92
+ />,
93
+ ),
94
+ ).toThrow(/column.*keys must be unique/i)
95
+ })
96
+
97
+ it('rejects duplicate link identities within a column', () => {
98
+ expect(() =>
99
+ render(
100
+ <FooterSection
101
+ {...baseProps}
102
+ linkColumns={[
103
+ {
104
+ title: 'Servicios',
105
+ links: [
106
+ { label: 'Talleres', href: '/talleres' },
107
+ { label: 'Talleres', href: '/talleres' },
108
+ ],
109
+ },
110
+ ]}
111
+ />,
112
+ ),
113
+ ).toThrow(/link.*keys must be unique/i)
114
+ })
115
+
116
+ it('rejects duplicate social identities instead of emitting duplicate keys', () => {
117
+ expect(() =>
118
+ render(
119
+ <FooterSection
120
+ {...baseProps}
121
+ socialLinks={[instagram, instagram]}
122
+ onSocialClick={() => undefined}
123
+ />,
124
+ ),
125
+ ).toThrow(/social.*keys must be unique/i)
126
+ })
127
+ })
@@ -0,0 +1,100 @@
1
+ // @vitest-environment jsdom
2
+ import { render, screen } from '@testing-library/react'
3
+ import { AtSign } from 'lucide-react'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+
6
+ import { FooterSection, type FooterSectionProps } from './footer-section'
7
+
8
+ const nativeProps = {
9
+ presentation: 'brand-columns',
10
+ linkBehavior: 'native',
11
+ brandName: 'Thor Provider',
12
+ linkColumns: [
13
+ {
14
+ title: 'Repuestos',
15
+ links: [{ label: 'Catálogo de repuestos', href: '/repuestos' }],
16
+ },
17
+ ],
18
+ socialLinks: [
19
+ { label: 'Instagram', href: '/redes/instagram', icon: 'instagram' },
20
+ ],
21
+ socialIcons: { instagram: AtSign },
22
+ }
23
+
24
+ describe('FooterSection native link compatibility', () => {
25
+ it('keeps native column hrefs separate from host-controlled social links', () => {
26
+ render(
27
+ <FooterSection
28
+ {...(nativeProps as unknown as FooterSectionProps)}
29
+ />,
30
+ )
31
+
32
+ expect(
33
+ screen
34
+ .getByRole('link', { name: 'Catálogo de repuestos' })
35
+ .getAttribute('href'),
36
+ ).toBe('/repuestos')
37
+ expect(screen.getByText('Instagram').closest('a')).toBeNull()
38
+ })
39
+
40
+ it('notifies native column callbacks without preventing browser navigation', () => {
41
+ const onLinkClick = vi.fn()
42
+ const props = {
43
+ ...nativeProps,
44
+ onLinkClick,
45
+ } as unknown as FooterSectionProps
46
+
47
+ render(<FooterSection {...props} />)
48
+
49
+ const columnLink = screen.getByRole('link', {
50
+ name: 'Catálogo de repuestos',
51
+ })
52
+ const columnEvent = new MouseEvent('click', {
53
+ bubbles: true,
54
+ cancelable: true,
55
+ })
56
+ columnLink.dispatchEvent(columnEvent)
57
+
58
+ expect(onLinkClick).toHaveBeenCalledWith(
59
+ nativeProps.linkColumns[0]?.links[0],
60
+ )
61
+ expect(columnEvent.defaultPrevented).toBe(false)
62
+ expect(screen.getByText('Instagram').closest('a')).toBeNull()
63
+ })
64
+
65
+ it('keeps native social hrefs separate from host-controlled column links', () => {
66
+ const props = {
67
+ ...nativeProps,
68
+ linkBehavior: 'host',
69
+ socialLinkBehavior: 'native',
70
+ } as unknown as FooterSectionProps
71
+
72
+ render(<FooterSection {...props} />)
73
+
74
+ expect(screen.getByText('Catálogo de repuestos').closest('a')).toBeNull()
75
+ const socialLink = screen.getByRole('link', { name: 'Instagram' })
76
+ expect(socialLink.getAttribute('href')).toBe('/redes/instagram')
77
+ })
78
+
79
+ it('notifies native social callbacks without preventing browser navigation', () => {
80
+ const onSocialClick = vi.fn()
81
+ const props = {
82
+ ...nativeProps,
83
+ linkBehavior: 'host',
84
+ socialLinkBehavior: 'native',
85
+ onSocialClick,
86
+ } as unknown as FooterSectionProps
87
+
88
+ render(<FooterSection {...props} />)
89
+
90
+ const socialLink = screen.getByRole('link', { name: 'Instagram' })
91
+ const socialEvent = new MouseEvent('click', {
92
+ bubbles: true,
93
+ cancelable: true,
94
+ })
95
+ socialLink.dispatchEvent(socialEvent)
96
+
97
+ expect(onSocialClick).toHaveBeenCalledWith('Instagram')
98
+ expect(socialEvent.defaultPrevented).toBe(false)
99
+ })
100
+ })
@@ -0,0 +1,11 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import * as blockExports from '../../../index'
3
+
4
+ describe('Blocks footer package exports', () => {
5
+ it('exposes the canonical footer and omits retired numbered adapters', () => {
6
+ expect(blockExports.FooterSection).toEqual(expect.any(Function))
7
+ expect('FooterSection1' in blockExports).toBe(false)
8
+ expect('FooterSection3' in blockExports).toBe(false)
9
+ expect('FooterSection4' in blockExports).toBe(false)
10
+ })
11
+ })