@thorprovider/blocks 0.1.1 → 0.1.3

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 (70) hide show
  1. package/.turbo/turbo-build.log +15 -15
  2. package/.turbo/turbo-test.log +42 -5
  3. package/AGENTS.md +1 -1
  4. package/README.md +94 -1
  5. package/dist/.tsbuildinfo +1 -1
  6. package/dist/contracts.d.mts +1266 -97
  7. package/dist/contracts.d.ts +1266 -97
  8. package/dist/contracts.js +225 -9
  9. package/dist/contracts.js.map +1 -1
  10. package/dist/contracts.mjs +207 -10
  11. package/dist/contracts.mjs.map +1 -1
  12. package/dist/index.d.mts +756 -96
  13. package/dist/index.d.ts +756 -96
  14. package/dist/index.js +5114 -3702
  15. package/dist/index.js.map +1 -1
  16. package/dist/index.mjs +4939 -3551
  17. package/dist/index.mjs.map +1 -1
  18. package/docs/standards/theming.md +32 -5
  19. package/package.json +2 -2
  20. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +136 -93
  21. package/src/blocks/e-commerce/category-filters/category-filter-2.test.tsx +513 -0
  22. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +728 -148
  23. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +14 -8
  24. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.test.tsx +136 -0
  25. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +145 -99
  26. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +215 -102
  27. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +6 -2
  28. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +51 -2
  29. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +216 -0
  30. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +114 -11
  31. package/src/blocks/e-commerce/navigation/back-to-top.test.tsx +27 -0
  32. package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
  33. package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
  34. package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
  35. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
  36. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
  37. package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +42 -2
  38. package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +10 -8
  39. package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
  40. package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
  41. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +51 -0
  42. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +56 -36
  43. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +88 -0
  44. package/src/blocks/e-commerce/product-list/product-card-1.tsx +141 -80
  45. package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
  46. package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
  47. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +182 -0
  48. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +298 -129
  49. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +229 -0
  50. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +313 -110
  51. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +72 -0
  52. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +20 -1
  53. package/src/blocks/marketing/footers/footer-section-1.test.tsx +117 -0
  54. package/src/blocks/marketing/footers/footer-section-1.tsx +46 -9
  55. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +38 -0
  56. package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
  57. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +204 -0
  58. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +144 -21
  59. package/src/blocks/marketing/navbars/navbar-4.test.tsx +233 -0
  60. package/src/blocks/marketing/navbars/navbar-4.tsx +235 -60
  61. package/src/index.ts +56 -0
  62. package/src/lib/cart.test.ts +15 -0
  63. package/src/lib/cart.ts +11 -0
  64. package/src/lib/contracts-curated.test.ts +207 -0
  65. package/src/lib/contracts-server-boundary.test.ts +52 -0
  66. package/src/lib/contracts.ts +234 -6
  67. package/src/lib/money.test.ts +64 -0
  68. package/src/lib/money.ts +65 -0
  69. package/tsup.config.ts +0 -1
  70. package/vitest.config.ts +9 -1
@@ -0,0 +1,72 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import StorefrontHero1, { type FeaturedProduct } from './storefront-hero-1'
4
+
5
+ const product: FeaturedProduct = {
6
+ id: 'p1',
7
+ name: 'Taladro percutor',
8
+ brand: 'INGCO',
9
+ price: 69.99,
10
+ originalPrice: 99.99,
11
+ rating: 4.8,
12
+ reviewCount: 236,
13
+ inStock: true,
14
+ isNew: false,
15
+ isOnSale: true,
16
+ image: '/products/drill.png',
17
+ colors: ['#111111'],
18
+ }
19
+
20
+ const base = {
21
+ title: 'Potencia para cada proyecto',
22
+ description: 'Herramientas profesionales',
23
+ products: [],
24
+ }
25
+
26
+ describe('StorefrontHero1 — optional editorial image', () => {
27
+ it('renders no editorial image when none is supplied', () => {
28
+ const html = renderToStaticMarkup(<StorefrontHero1 {...base} />)
29
+ expect(html).not.toContain('<img')
30
+ })
31
+
32
+ it('renders a native editorial image with its src and alt text', () => {
33
+ const html = renderToStaticMarkup(
34
+ <StorefrontHero1 {...base} heroImage={{ src: '/hero-tools.png', alt: 'Taladro inalámbrico y accesorios' }} />,
35
+ )
36
+ expect(html).toContain('src="/hero-tools.png"')
37
+ expect(html).toContain('alt="Taladro inalámbrico y accesorios"')
38
+ })
39
+
40
+ it('never renders an empty image src', () => {
41
+ const blank = renderToStaticMarkup(
42
+ <StorefrontHero1 {...base} heroImage={{ src: ' ', alt: 'Vacío' }} />,
43
+ )
44
+ expect(blank).not.toContain('<img')
45
+ })
46
+
47
+ it('preserves the product grid when the editorial image is omitted', () => {
48
+ const html = renderToStaticMarkup(<StorefrontHero1 {...base} products={[product]} />)
49
+ expect(html).toContain('Taladro percutor')
50
+ expect(html).toContain('src="/products/drill.png"')
51
+ expect(html).not.toContain('/hero-tools.png')
52
+ })
53
+
54
+ it('keeps the product grid when the editorial image is supplied', () => {
55
+ const html = renderToStaticMarkup(
56
+ <StorefrontHero1
57
+ {...base}
58
+ products={[product]}
59
+ heroImage={{ src: '/hero-tools.png', alt: 'Taladro inalámbrico' }}
60
+ />,
61
+ )
62
+ expect(html).toContain('src="/hero-tools.png"')
63
+ expect(html).toContain('Taladro percutor')
64
+ })
65
+ })
66
+
67
+ describe('StorefrontHero1 — product card secondary action label', () => {
68
+ it('defaults the second product-card action to "Ver producto"', () => {
69
+ const html = renderToStaticMarkup(<StorefrontHero1 {...base} products={[product]} />)
70
+ expect(html).toContain('Ver producto')
71
+ })
72
+ })
@@ -23,6 +23,11 @@ export interface FeaturedProduct {
23
23
  colors: string[]
24
24
  }
25
25
 
26
+ export interface HeroImage {
27
+ src: string
28
+ alt: string
29
+ }
30
+
26
31
  export interface StorefrontHero1Props {
27
32
  title: string
28
33
  description: string
@@ -31,6 +36,12 @@ export interface StorefrontHero1Props {
31
36
  badge?: string
32
37
  primaryCta?: CTA
33
38
  secondaryCta?: CTA
39
+ /**
40
+ * Optional editorial image. Rendered as a native `<img>` when `src` is a
41
+ * non-empty string; omitted entirely when absent or blank (never an empty
42
+ * `src`). The product grid is unaffected.
43
+ */
44
+ heroImage?: HeroImage
34
45
  onAddToCart?: (product: FeaturedProduct) => void
35
46
  onBuyNow?: (product: FeaturedProduct) => void
36
47
  onSelectColor?: (product: FeaturedProduct, index: number, color: string) => void
@@ -82,11 +93,12 @@ function StorefrontHero1({
82
93
  badge,
83
94
  primaryCta,
84
95
  secondaryCta,
96
+ heroImage,
85
97
  onAddToCart,
86
98
  onBuyNow,
87
99
  onSelectColor,
88
100
  addToCartLabel = 'Agregar al carrito',
89
- buyNowLabel = 'Comprar ahora',
101
+ buyNowLabel = 'Ver producto',
90
102
  newLabel = 'Nuevo',
91
103
  outOfStockLabel = 'Agotado',
92
104
  colorsLabel = 'Colores:',
@@ -137,6 +149,13 @@ function StorefrontHero1({
137
149
  )}
138
150
  </div>
139
151
  )}
152
+ {heroImage && heroImage.src.trim().length > 0 ? (
153
+ <img
154
+ src={heroImage.src}
155
+ alt={heroImage.alt}
156
+ className='mt-10 aspect-video w-full rounded-lg object-cover'
157
+ />
158
+ ) : null}
140
159
  </motion.div>
141
160
 
142
161
  <div className='w-full max-w-2xl lg:mt-0'>
@@ -0,0 +1,117 @@
1
+ import type { ReactElement, ReactNode } from 'react'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { FooterSection1 } from './footer-section-1'
5
+
6
+ const base = {
7
+ brandName: 'ElectroCuba',
8
+ linkColumns: [],
9
+ }
10
+
11
+ type SubmitHandler = (event: { preventDefault: () => void }) => void
12
+
13
+ function findForm(node: ReactNode): ReactElement<{ onSubmit?: SubmitHandler }> | undefined {
14
+ if (node === null || node === undefined || typeof node !== 'object') return undefined
15
+ if (Array.isArray(node)) {
16
+ for (const child of node) {
17
+ const found = findForm(child)
18
+ if (found) return found
19
+ }
20
+ return undefined
21
+ }
22
+ const element = node as ReactElement<{ children?: ReactNode }>
23
+ if (element.type === 'form') return element as ReactElement<{ onSubmit?: SubmitHandler }>
24
+ return findForm(element.props?.children)
25
+ }
26
+
27
+ describe('FooterSection1 — optional brand accent', () => {
28
+ it('renders the brand name unchanged when no accent is supplied', () => {
29
+ const html = renderToStaticMarkup(<FooterSection1 {...base} />)
30
+ expect(html).toContain('>ElectroCuba</span>')
31
+ expect(html).not.toContain('text-brand-promo')
32
+ })
33
+
34
+ it('accents a matched trailing segment without duplicating the brand name', () => {
35
+ const html = renderToStaticMarkup(
36
+ <FooterSection1 {...base} brandAccent={{ text: 'Cuba', className: 'text-brand-promo' }} />,
37
+ )
38
+ expect(html).toContain('Electro<span class="text-brand-promo">Cuba</span>')
39
+ expect(html.match(/Electro/g)).toHaveLength(1)
40
+ expect(html.match(/Cuba/g)).toHaveLength(1)
41
+ })
42
+
43
+ it('accents the full brand name when the accent covers all of it', () => {
44
+ const html = renderToStaticMarkup(
45
+ <FooterSection1 {...base} brandAccent={{ text: 'ElectroCuba', className: 'text-brand-promo' }} />,
46
+ )
47
+ expect(html).toContain('<span class="text-brand-promo">ElectroCuba</span>')
48
+ })
49
+
50
+ it('does not accent when the segment is not a trailing match', () => {
51
+ const html = renderToStaticMarkup(<FooterSection1 {...base} brandAccent={{ text: 'Electro' }} />)
52
+ expect(html).toContain('>ElectroCuba</span>')
53
+ })
54
+ })
55
+
56
+ describe('FooterSection1 — newsletter requires a submit callback', () => {
57
+ it('renders no form, input, or button when the newsletter has no onSubmit', () => {
58
+ const html = renderToStaticMarkup(
59
+ <FooterSection1 {...base} newsletter={{ buttonLabel: 'Suscribirse', placeholder: 'tu@correo.com' }} />,
60
+ )
61
+ expect(html).not.toContain('<form')
62
+ expect(html).not.toContain('<input')
63
+ expect(html).not.toContain('<button')
64
+ })
65
+
66
+ it('renders the interactive form when a submit handler is supplied', () => {
67
+ const html = renderToStaticMarkup(
68
+ <FooterSection1 {...base} newsletter={{ buttonLabel: 'Suscribirse', onSubmit: () => {} }} />,
69
+ )
70
+ expect(html).toContain('<form')
71
+ expect(html).toContain('<input')
72
+ expect(html).toContain('<button')
73
+ expect(html).toContain('Suscribirse')
74
+ })
75
+
76
+ it('retains the supplied submit handler and forwards the email', () => {
77
+ const onSubmit = vi.fn()
78
+ const tree = FooterSection1({
79
+ ...base,
80
+ newsletter: { buttonLabel: 'Suscribirse', onSubmit },
81
+ })
82
+ const form = findForm(tree)
83
+ expect(form).toBeDefined()
84
+
85
+ vi.stubGlobal(
86
+ 'FormData',
87
+ class {
88
+ get() {
89
+ return 'shopper@example.com'
90
+ }
91
+ },
92
+ )
93
+ try {
94
+ form?.props.onSubmit?.({ preventDefault: () => {} })
95
+ } finally {
96
+ vi.unstubAllGlobals()
97
+ }
98
+
99
+ expect(onSubmit).toHaveBeenCalledWith('shopper@example.com')
100
+ })
101
+ })
102
+
103
+ describe('FooterSection1 — optional outer surface className', () => {
104
+ it('keeps the default surface when className is omitted', () => {
105
+ const html = renderToStaticMarkup(<FooterSection1 {...base} />)
106
+ expect(html).toContain('bg-secondary')
107
+ })
108
+
109
+ it('merges a branded surface onto the footer and drops the default background', () => {
110
+ const html = renderToStaticMarkup(
111
+ <FooterSection1 {...base} className='bg-brand-nav text-brand-nav-foreground' />,
112
+ )
113
+ expect(html).toContain('bg-brand-nav')
114
+ expect(html).toContain('text-brand-nav-foreground')
115
+ expect(html).not.toContain('bg-secondary')
116
+ })
117
+ })
@@ -3,6 +3,7 @@
3
3
  import { Input } from '../../../ui/input'
4
4
  import { Button } from '../../../ui/button'
5
5
  import { ArrowRight } from 'lucide-react'
6
+ import { cn } from '../../../lib/utils'
6
7
 
7
8
  export interface FooterLink {
8
9
  label: string
@@ -17,24 +18,59 @@ export interface FooterLinkColumn {
17
18
  export interface FooterNewsletter {
18
19
  placeholder?: string
19
20
  buttonLabel: string
21
+ /**
22
+ * Submit handler. The interactive form (input + submit button) renders only
23
+ * when this is supplied. A newsletter without `onSubmit` renders no form,
24
+ * input, or button — never a fake form.
25
+ */
20
26
  onSubmit?: (email: string) => void
21
27
  }
22
28
 
29
+ export interface BrandAccent {
30
+ /** Trailing segment of `brandName` to render with the accent styling. */
31
+ text: string
32
+ /** Consumer-supplied classes for the accent span (e.g. a brand color token). */
33
+ className?: string
34
+ }
35
+
23
36
  export interface FooterSection1Props {
24
37
  brandName: string
38
+ /**
39
+ * Optional accent for a trailing segment of `brandName` (e.g. the "Cuba" in
40
+ * "ElectroCuba"). When omitted, or when `text` is not a trailing segment of
41
+ * `brandName`, the brand name renders unchanged.
42
+ */
43
+ brandAccent?: BrandAccent
25
44
  brandDescription?: string
26
45
  linkColumns: FooterLinkColumn[]
27
46
  newsletter?: FooterNewsletter
47
+ /**
48
+ * Consumer-supplied classes merged onto the outer `<footer>` as the final
49
+ * argument, so a semantic surface such as `bg-brand-nav
50
+ * text-brand-nav-foreground` overrides the default `bg-secondary`. Purely
51
+ * presentational: no layout, structure, or behavior changes.
52
+ */
53
+ className?: string
28
54
  }
29
55
 
30
56
  export function FooterSection1({
31
57
  brandName,
58
+ brandAccent,
32
59
  brandDescription,
33
60
  linkColumns,
34
61
  newsletter,
62
+ className,
35
63
  }: FooterSection1Props) {
64
+ const accentText = brandAccent?.text ?? ''
65
+ const hasAccent = accentText.length > 0 && brandName.endsWith(accentText)
66
+ const baseName = hasAccent ? brandName.slice(0, brandName.length - accentText.length) : brandName
67
+ const newsletterSubmit = newsletter?.onSubmit
68
+
36
69
  return (
37
- <footer className='bg-secondary w-full py-16' aria-label='Pie de página'>
70
+ <footer
71
+ className={cn('bg-secondary w-full py-16', className)}
72
+ aria-label='Pie de página'
73
+ >
38
74
  <div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
39
75
  <div className='grid grid-cols-1 gap-8 lg:grid-cols-12'>
40
76
  {/* Left Section */}
@@ -66,23 +102,24 @@ export function FooterSection1({
66
102
  <circle cx='13.1' cy='26.1' r='1.7' stroke='currentColor' />
67
103
  <circle cx='22.1' cy='26.1' r='1.7' stroke='currentColor' />
68
104
  </svg>
69
- <span className='text-xl font-semibold'>{brandName}</span>
105
+ <span className='text-xl font-semibold'>
106
+ {baseName}
107
+ {hasAccent ? <span className={brandAccent?.className}>{accentText}</span> : null}
108
+ </span>
70
109
  </div>
71
110
  {brandDescription && (
72
111
  <p className='text-muted-foreground mb-8 max-w-sm text-sm leading-relaxed text-balance'>
73
112
  {brandDescription}
74
113
  </p>
75
114
  )}
76
- {newsletter && (
115
+ {newsletter && newsletterSubmit ? (
77
116
  <form
78
117
  className='relative'
79
118
  onSubmit={e => {
80
119
  e.preventDefault()
81
- if (newsletter.onSubmit) {
82
- const email = new FormData(e.currentTarget).get('email')
83
- if (typeof email === 'string') {
84
- newsletter.onSubmit(email)
85
- }
120
+ const email = new FormData(e.currentTarget).get('email')
121
+ if (typeof email === 'string') {
122
+ newsletterSubmit(email)
86
123
  }
87
124
  }}
88
125
  >
@@ -103,7 +140,7 @@ export function FooterSection1({
103
140
  <ArrowRight />
104
141
  </Button>
105
142
  </form>
106
- )}
143
+ ) : null}
107
144
  </div>
108
145
 
109
146
  {/* Right Section */}
@@ -0,0 +1,38 @@
1
+ import { Children, isValidElement, type ReactNode } from 'react'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { Navbar4AccountMenu } from './navbar-4-account-menu'
4
+ import { DropdownMenuItem } from '../../../ui/dropdown-menu'
5
+
6
+ // Inspect the block-owned menu items without mounting Radix's browser portal.
7
+ function menuItems(node: ReactNode): Record<string, unknown>[] {
8
+ return Children.toArray(node).flatMap(child => {
9
+ if (!isValidElement<{ children?: ReactNode }>(child)) return []
10
+ return [
11
+ ...(child.type === DropdownMenuItem ? [child.props as Record<string, unknown>] : []),
12
+ ...menuItems(child.props.children),
13
+ ]
14
+ })
15
+ }
16
+
17
+ const user = { name: 'Ada', email: 'ada@example.test', initials: 'A' }
18
+
19
+ describe('Navbar4AccountMenu consumer actions', () => {
20
+ it('omits help and sign-out when no consumer actions exist', () => {
21
+ expect(menuItems(Navbar4AccountMenu({ user }))).toEqual([])
22
+ })
23
+
24
+ it('dispatches sign-out to its consumer', () => {
25
+ let calls = 0
26
+ const [item] = menuItems(Navbar4AccountMenu({ user, onSignOut: () => { calls++ } }))
27
+ ;(item.onSelect as () => void)()
28
+ expect(calls).toBe(1)
29
+ })
30
+
31
+ it('rejects further sign-out selection while the consumer is pending', () => {
32
+ let calls = 0
33
+ const [item] = menuItems(Navbar4AccountMenu({ user, pending: true, onSignOut: () => { calls++ } }))
34
+ expect(item.disabled).toBe(true)
35
+ ;(item.onSelect as () => void)()
36
+ expect(calls).toBe(0)
37
+ })
38
+ })
@@ -15,7 +15,7 @@ import {
15
15
  export interface StoreUser {
16
16
  name: string
17
17
  email: string
18
- avatar: string
18
+ avatar?: string
19
19
  initials: string
20
20
  }
21
21
 
@@ -33,6 +33,7 @@ export interface Navbar4AccountMenuProps {
33
33
  items?: AccountMenuItem[]
34
34
  onHelp?: () => void
35
35
  onSignOut?: () => void
36
+ pending?: boolean
36
37
  }
37
38
 
38
39
  const accountIcons = {
@@ -47,6 +48,7 @@ export function Navbar4AccountMenu({
47
48
  items = [],
48
49
  onHelp,
49
50
  onSignOut,
51
+ pending = false,
50
52
  }: Navbar4AccountMenuProps) {
51
53
  if (!user) {
52
54
  return null
@@ -58,7 +60,8 @@ export function Navbar4AccountMenu({
58
60
  <Button
59
61
  variant='ghost'
60
62
  size='icon'
61
- className='rounded-full'
63
+ className='size-11 rounded-full'
64
+ disabled={pending}
62
65
  aria-label={`Account menu for ${user.name}`}
63
66
  >
64
67
  <Avatar size='sm' className='pointer-events-none size-8'>
@@ -85,11 +88,11 @@ export function Navbar4AccountMenu({
85
88
  </div>
86
89
  </div>
87
90
  </DropdownMenuLabel>
88
- <DropdownMenuSeparator />
91
+ {items.length > 0 ? <DropdownMenuSeparator /> : null}
89
92
  {items.map(item => {
90
93
  const Icon = item.icon ? accountIcons[item.icon] : Package
91
94
  return (
92
- <DropdownMenuItem key={item.href} asChild className='gap-2.5 px-2 py-2'>
95
+ <DropdownMenuItem key={item.href} asChild disabled={pending} className='gap-2.5 px-2 py-2'>
93
96
  <a href={item.href}>
94
97
  <Icon className='text-muted-foreground size-4' />
95
98
  <span className='flex min-w-0 flex-col'>
@@ -102,17 +105,30 @@ export function Navbar4AccountMenu({
102
105
  </DropdownMenuItem>
103
106
  )
104
107
  })}
105
- <DropdownMenuSeparator />
106
- <DropdownMenuItem className='gap-2.5 px-2 py-2' onSelect={onHelp}>
107
- <HelpCircle className='text-muted-foreground size-4' />
108
- Help & support
109
- </DropdownMenuItem>
110
- <DropdownMenuItem variant='destructive' className='gap-2.5 px-2 py-2' onSelect={onSignOut}>
111
- <LogOut className='size-4' />
112
- Sign out
113
- </DropdownMenuItem>
108
+ {onHelp || onSignOut ? <DropdownMenuSeparator /> : null}
109
+ {onHelp ? (
110
+ <DropdownMenuItem
111
+ disabled={pending}
112
+ className='gap-2.5 px-2 py-2'
113
+ onSelect={() => { if (!pending) onHelp() }}
114
+ >
115
+ <HelpCircle className='text-muted-foreground size-4' />
116
+ Help & support
117
+ </DropdownMenuItem>
118
+ ) : null}
119
+ {onSignOut ? (
120
+ <DropdownMenuItem
121
+ disabled={pending}
122
+ variant='destructive'
123
+ className='gap-2.5 px-2 py-2'
124
+ onSelect={() => { if (!pending) onSignOut() }}
125
+ >
126
+ <LogOut className='size-4' />
127
+ Sign out
128
+ </DropdownMenuItem>
129
+ ) : null}
114
130
  </DropdownMenuGroup>
115
131
  </DropdownMenuContent>
116
132
  </DropdownMenu>
117
133
  )
118
- }
134
+ }
@@ -0,0 +1,204 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { Navbar4CartSheet, type CartItem } from './navbar-4-cart-sheet'
4
+
5
+ const items: CartItem[] = [
6
+ { id: 'line_1', name: 'Taladro', variant: 'Rojo', price: 45, qty: 2, image: 'https://cdn.example/t.jpg' },
7
+ ]
8
+
9
+ const formatMoney = (money: { amount: string; currencyCode: string }) =>
10
+ `${money.currencyCode} ${money.amount}`
11
+
12
+ const noop = () => {}
13
+
14
+ describe('Navbar4CartSheet — authoritative subtotal', () => {
15
+ it('never renders a fake zero for a supplied but absent/malformed subtotal', () => {
16
+ const html = renderToStaticMarkup(
17
+ <Navbar4CartSheet
18
+ items={items}
19
+ onQtyChange={noop}
20
+ onRemove={noop}
21
+ subtotal={{ amount: '', currencyCode: 'USD' }}
22
+ formatMoney={formatMoney}
23
+ />,
24
+ )
25
+ expect(html).not.toContain('USD 0')
26
+ expect(html).toContain('—')
27
+ })
28
+
29
+ it('preserves a legitimate zero subtotal', () => {
30
+ const html = renderToStaticMarkup(
31
+ <Navbar4CartSheet
32
+ items={items}
33
+ onQtyChange={noop}
34
+ onRemove={noop}
35
+ subtotal={{ amount: '0', currencyCode: 'USD' }}
36
+ formatMoney={formatMoney}
37
+ />,
38
+ )
39
+ expect(html).toContain('USD 0')
40
+ expect(html).not.toContain('—')
41
+ })
42
+
43
+ it('falls back to the legacy local subtotal only when none is supplied', () => {
44
+ const html = renderToStaticMarkup(
45
+ <Navbar4CartSheet
46
+ items={items}
47
+ onQtyChange={noop}
48
+ onRemove={noop}
49
+ formatPrice={value => `$${value.toFixed(2)}`}
50
+ />,
51
+ )
52
+ expect(html).toContain('$90.00')
53
+ })
54
+ })
55
+
56
+ describe('Navbar4CartSheet — canonical line amounts', () => {
57
+ it('renders the canonical backend line amount in its currency, not price times qty', () => {
58
+ const html = renderToStaticMarkup(
59
+ <Navbar4CartSheet
60
+ items={[{ ...items[0], price: 0, lineTotal: { amount: '90.00', currencyCode: 'EUR' } }]}
61
+ onQtyChange={noop}
62
+ onRemove={noop}
63
+ canonical
64
+ formatMoney={formatMoney}
65
+ />,
66
+ )
67
+ expect(html).toContain('EUR 90.00')
68
+ expect(html).not.toContain('USD 0')
69
+ })
70
+
71
+ it('never falls back to a numeric amount when a canonical line amount is missing', () => {
72
+ const html = renderToStaticMarkup(
73
+ <Navbar4CartSheet
74
+ items={[{ ...items[0], price: 45, lineTotal: undefined }]}
75
+ onQtyChange={noop}
76
+ onRemove={noop}
77
+ canonical
78
+ formatPrice={value => `$${value.toFixed(2)}`}
79
+ />,
80
+ )
81
+ expect(html).not.toContain('$90.00')
82
+ expect(html).toContain('—')
83
+ })
84
+
85
+ it('shows the unavailable label for a malformed canonical line amount', () => {
86
+ const html = renderToStaticMarkup(
87
+ <Navbar4CartSheet
88
+ items={[{ ...items[0], lineTotal: { amount: '', currencyCode: 'USD' } }]}
89
+ onQtyChange={noop}
90
+ onRemove={noop}
91
+ canonical
92
+ formatMoney={formatMoney}
93
+ />,
94
+ )
95
+ expect(html).not.toContain('USD 0')
96
+ expect(html).toContain('—')
97
+ })
98
+
99
+ it('preserves legacy numeric rendering when canonical mode is omitted', () => {
100
+ const html = renderToStaticMarkup(
101
+ <Navbar4CartSheet
102
+ items={[{ ...items[0], lineTotal: { amount: '90.00', currencyCode: 'EUR' } }]}
103
+ onQtyChange={noop}
104
+ onRemove={noop}
105
+ formatPrice={value => `$${value.toFixed(2)}`}
106
+ />,
107
+ )
108
+ expect(html).toContain('$90.00')
109
+ expect(html).not.toContain('EUR 90.00')
110
+ })
111
+ })
112
+
113
+ describe('Navbar4CartSheet — read/mutation lifecycle', () => {
114
+ it('shows a pending state instead of claiming an empty cart', () => {
115
+ const html = renderToStaticMarkup(
116
+ <Navbar4CartSheet items={[]} onQtyChange={noop} onRemove={noop} state='pending' />,
117
+ )
118
+ expect(html).toContain('Actualizando el carrito')
119
+ expect(html).not.toContain('Your cart is empty')
120
+ })
121
+
122
+ it('shows a recoverable error state with its retry action', () => {
123
+ const html = renderToStaticMarkup(
124
+ <Navbar4CartSheet
125
+ items={items}
126
+ onQtyChange={noop}
127
+ onRemove={noop}
128
+ state='error'
129
+ errorLabel='No se pudo leer el carrito.'
130
+ retryLabel='Reintentar'
131
+ onRetry={noop}
132
+ />,
133
+ )
134
+ expect(html).toContain('No se pudo leer el carrito.')
135
+ expect(html).toContain('Reintentar')
136
+ expect(html).toContain('role="alert"')
137
+ })
138
+
139
+ it('disables mutation controls while pending', () => {
140
+ const html = renderToStaticMarkup(
141
+ <Navbar4CartSheet items={items} onQtyChange={noop} onRemove={noop} state='pending' />,
142
+ )
143
+ expect(html).toContain('aria-label="Increase quantity, Taladro"')
144
+ expect(html).toContain('disabled')
145
+ })
146
+ })
147
+
148
+ describe('Navbar4CartSheet — action availability', () => {
149
+ it('suppresses the checkout action when checkout is not installed', () => {
150
+ const html = renderToStaticMarkup(
151
+ <Navbar4CartSheet
152
+ items={items}
153
+ onQtyChange={noop}
154
+ onRemove={noop}
155
+ checkoutUnavailable
156
+ onCheckout={noop}
157
+ />,
158
+ )
159
+ expect(html).not.toContain('>Checkout</button>')
160
+ expect(html).toContain('View cart')
161
+ })
162
+
163
+ it('disables the checkout action while the cart is not ready', () => {
164
+ const html = renderToStaticMarkup(
165
+ <Navbar4CartSheet items={items} onQtyChange={noop} onRemove={noop} checkoutDisabled onCheckout={noop} />,
166
+ )
167
+ expect(html).toContain('>Checkout</button>')
168
+ expect(html).toMatch(/disabled[^>]*>Checkout<\/button>/)
169
+ })
170
+
171
+ it('suppresses the review action when the cart view is not installed', () => {
172
+ const html = renderToStaticMarkup(
173
+ <Navbar4CartSheet
174
+ items={items}
175
+ onQtyChange={noop}
176
+ onRemove={noop}
177
+ viewCartUnavailable
178
+ onViewCart={noop}
179
+ />,
180
+ )
181
+ expect(html).not.toContain('View cart')
182
+ })
183
+ })
184
+
185
+ describe('Navbar4CartSheet — accessibility', () => {
186
+ it('names the line mutation controls', () => {
187
+ const html = renderToStaticMarkup(
188
+ <Navbar4CartSheet items={items} onQtyChange={noop} onRemove={noop} />,
189
+ )
190
+ expect(html).toMatch(/aria-label="[^"]*(Increase|Decrease|Aumentar|Disminuir)[^"]*"/)
191
+ expect(html).toMatch(/aria-label="[^"]*(Remove|Quitar)[^"]*"/)
192
+ })
193
+
194
+ it('announces pending reads politely and errors as an alert', () => {
195
+ const pending = renderToStaticMarkup(
196
+ <Navbar4CartSheet items={[]} onQtyChange={noop} onRemove={noop} state='pending' />,
197
+ )
198
+ expect(pending).toMatch(/role="status"[^>]*aria-live="polite"/)
199
+ const error = renderToStaticMarkup(
200
+ <Navbar4CartSheet items={items} onQtyChange={noop} onRemove={noop} state='error' />,
201
+ )
202
+ expect(error).toMatch(/role="alert"/)
203
+ })
204
+ })