@thorprovider/blocks 0.1.2 → 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 (41) hide show
  1. package/.turbo/turbo-build.log +15 -15
  2. package/.turbo/turbo-test.log +37 -27
  3. package/README.md +42 -1
  4. package/dist/.tsbuildinfo +1 -1
  5. package/dist/contracts.d.mts +162 -14
  6. package/dist/contracts.d.ts +162 -14
  7. package/dist/contracts.js +36 -3
  8. package/dist/contracts.js.map +1 -1
  9. package/dist/contracts.mjs +34 -3
  10. package/dist/contracts.mjs.map +1 -1
  11. package/dist/index.d.mts +294 -12
  12. package/dist/index.d.ts +294 -12
  13. package/dist/index.js +3577 -2882
  14. package/dist/index.js.map +1 -1
  15. package/dist/index.mjs +3431 -2750
  16. package/dist/index.mjs.map +1 -1
  17. package/package.json +2 -2
  18. package/src/blocks/e-commerce/navigation/back-to-top.test.tsx +27 -0
  19. package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
  20. package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
  21. package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
  22. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
  23. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
  24. package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
  25. package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
  26. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +88 -0
  27. package/src/blocks/e-commerce/product-list/product-card-1.tsx +141 -80
  28. package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
  29. package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
  30. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +92 -0
  31. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +86 -25
  32. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +7 -0
  33. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +1 -1
  34. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +38 -0
  35. package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
  36. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +150 -0
  37. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +102 -9
  38. package/src/blocks/marketing/navbars/navbar-4.test.tsx +121 -0
  39. package/src/blocks/marketing/navbars/navbar-4.tsx +134 -32
  40. package/src/index.ts +18 -0
  41. package/src/lib/contracts.ts +53 -3
@@ -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
+ }
@@ -52,3 +52,153 @@ describe('Navbar4CartSheet — authoritative subtotal', () => {
52
52
  expect(html).toContain('$90.00')
53
53
  })
54
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
+ })
@@ -27,8 +27,16 @@ export interface CartItem {
27
27
  qty: number
28
28
  /** Optional; an absent image is omitted rather than serialized as `""`. */
29
29
  image?: string
30
+ /**
31
+ * Canonical backend line amount. Required for truthful rendering in
32
+ * `canonical` mode; when absent/invalid the line shows the unavailable
33
+ * label rather than a numeric fallback.
34
+ */
35
+ lineTotal?: Money
30
36
  }
31
37
 
38
+ export type CartSheetState = 'idle' | 'pending' | 'error'
39
+
32
40
  export interface Navbar4CartSheetProps {
33
41
  items: CartItem[]
34
42
  /**
@@ -49,6 +57,31 @@ export interface Navbar4CartSheetProps {
49
57
  formatPrice?: (value: number) => string
50
58
  onCheckout?: () => void
51
59
  onViewCart?: () => void
60
+ /**
61
+ * Backend-authoritative rendering. When `true`, each line amount comes from
62
+ * the item's canonical `lineTotal` Money and the sheet never recomputes
63
+ * `price * qty`. When omitted/false, legacy numeric rendering is preserved
64
+ * for standalone consumers.
65
+ */
66
+ canonical?: boolean
67
+ /** Shown in `canonical` mode when a line amount is missing or malformed. */
68
+ lineUnavailableLabel?: string
69
+ /** Cart read/mutation lifecycle. Pending retains confirmed lines; error is recoverable. */
70
+ state?: CartSheetState
71
+ /** Message shown when `state` is `error`. */
72
+ errorLabel?: string
73
+ /** Label for the recovery action in the error state. */
74
+ retryLabel?: string
75
+ /** Recovery action invoked from the error state. */
76
+ onRetry?: () => void
77
+ /** Label shown while `state` is `pending`. */
78
+ pendingLabel?: string
79
+ /** When `true`, the checkout action is suppressed (checkout not installed). */
80
+ checkoutUnavailable?: boolean
81
+ /** When `true`, the checkout action is disabled (cart not ready / invalid amounts). */
82
+ checkoutDisabled?: boolean
83
+ /** When `true`, the review action is suppressed (cart view not installed). */
84
+ viewCartUnavailable?: boolean
52
85
  }
53
86
 
54
87
  const usdFormatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
@@ -81,12 +114,24 @@ export function Navbar4CartSheet({
81
114
  formatPrice,
82
115
  onCheckout,
83
116
  onViewCart,
117
+ canonical = false,
118
+ lineUnavailableLabel = '—',
119
+ state = 'idle',
120
+ errorLabel = 'No se pudo actualizar el carrito.',
121
+ retryLabel = 'Reintentar',
122
+ onRetry,
123
+ pendingLabel = 'Actualizando el carrito…',
124
+ checkoutUnavailable = false,
125
+ checkoutDisabled = false,
126
+ viewCartUnavailable = false,
84
127
  }: Navbar4CartSheetProps) {
85
128
  const reduceMotion = useReducedMotion()
86
129
  const format = formatPrice ?? (value => usdFormatter.format(value))
87
130
  const localSubtotal = items.reduce((total, item) => total + item.price * item.qty, 0)
88
131
  const authoritativeSubtotal = subtotalProp !== undefined ? formatMoney(subtotalProp, formatMoneyProp) : null
89
132
  const isEmpty = items.length === 0
133
+ const isPending = state === 'pending'
134
+ const isError = state === 'error'
90
135
  /** Wait for the last cart row's exit before revealing empty copy. */
91
136
  const [showEmpty, setShowEmpty] = useState(isEmpty)
92
137
 
@@ -96,9 +141,46 @@ export function Navbar4CartSheet({
96
141
  }
97
142
  }, [isEmpty])
98
143
 
144
+ const lineAmount = (item: CartItem): string => {
145
+ if (canonical) {
146
+ if (item.lineTotal === undefined) {
147
+ return lineUnavailableLabel
148
+ }
149
+ return formatMoney(item.lineTotal, formatMoneyProp) ?? lineUnavailableLabel
150
+ }
151
+ return format(item.price * item.qty)
152
+ }
153
+
154
+ // A confirmed empty cart is only claimed when no read/mutation is in flight
155
+ // and no read error is outstanding; otherwise the sheet shows loading/error.
156
+ const showConfirmedEmpty = showEmpty && !isPending && !isError
157
+ const mutationsDisabled = isPending
158
+
99
159
  return (
100
160
  <>
101
161
  <div className='relative flex min-h-0 flex-1 flex-col overflow-hidden'>
162
+ {isPending ? (
163
+ <p
164
+ role='status'
165
+ aria-live='polite'
166
+ className='border-border text-muted-foreground border-b px-4 py-2 text-sm'
167
+ >
168
+ {pendingLabel}
169
+ </p>
170
+ ) : null}
171
+ {isError ? (
172
+ <div
173
+ role='alert'
174
+ className='border-border flex items-center justify-between gap-2 border-b px-4 py-2 text-sm'
175
+ >
176
+ <span className='text-destructive'>{errorLabel}</span>
177
+ {onRetry ? (
178
+ <Button variant='ghost' size='sm' onClick={onRetry}>
179
+ {retryLabel}
180
+ </Button>
181
+ ) : null}
182
+ </div>
183
+ ) : null}
102
184
  <ul className='flex-1 overflow-y-auto px-4'>
103
185
  <AnimatePresence
104
186
  initial={false}
@@ -139,7 +221,7 @@ export function Navbar4CartSheet({
139
221
  <p className='text-muted-foreground text-sm'>{item.variant}</p>
140
222
  ) : null}
141
223
  </div>
142
- <p className='text-sm font-medium'>{format(item.price * item.qty)}</p>
224
+ <p className='text-sm font-medium'>{lineAmount(item)}</p>
143
225
  </div>
144
226
 
145
227
  <div className='mt-auto flex items-center justify-between pt-2'>
@@ -149,6 +231,7 @@ export function Navbar4CartSheet({
149
231
  size='icon'
150
232
  className='size-8 rounded-r-none'
151
233
  aria-label={`Decrease quantity, ${item.name}`}
234
+ disabled={mutationsDisabled}
152
235
  onClick={() => onQtyChange(item.id, -1)}
153
236
  >
154
237
  <Minus />
@@ -159,6 +242,7 @@ export function Navbar4CartSheet({
159
242
  size='icon'
160
243
  className='size-8 rounded-l-none'
161
244
  aria-label={`Increase quantity, ${item.name}`}
245
+ disabled={mutationsDisabled}
162
246
  onClick={() => onQtyChange(item.id, 1)}
163
247
  >
164
248
  <Plus />
@@ -169,6 +253,7 @@ export function Navbar4CartSheet({
169
253
  size='sm'
170
254
  className='text-muted-foreground hover:text-foreground'
171
255
  aria-label={`Remove ${item.name} from cart`}
256
+ disabled={mutationsDisabled}
172
257
  onClick={() => onRemove(item.id)}
173
258
  >
174
259
  Remove
@@ -181,7 +266,7 @@ export function Navbar4CartSheet({
181
266
  </ul>
182
267
 
183
268
  <AnimatePresence>
184
- {showEmpty ? (
269
+ {showConfirmedEmpty ? (
185
270
  <motion.div
186
271
  key='empty'
187
272
  initial={false}
@@ -257,19 +342,27 @@ export function Navbar4CartSheet({
257
342
  <div className='flex items-center justify-between text-sm'>
258
343
  <span className='text-muted-foreground'>Subtotal</span>
259
344
  <span className='font-medium tabular-nums'>
260
- {subtotalProp !== undefined
345
+ {canonical || subtotalProp !== undefined
261
346
  ? authoritativeSubtotal ?? subtotalUnavailableLabel
262
347
  : format(localSubtotal)}
263
348
  </span>
264
349
  </div>
265
350
  <p className='text-muted-foreground text-xs'>Shipping and taxes are calculated at checkout.</p>
266
351
  <div className='flex flex-col gap-2'>
267
- <Button className='w-full' onClick={onCheckout}>
268
- Checkout
269
- </Button>
270
- <Button variant='ghost' className='w-full' onClick={onViewCart}>
271
- View cart
272
- </Button>
352
+ {checkoutUnavailable ? null : (
353
+ <Button
354
+ className='w-full'
355
+ disabled={checkoutDisabled || isPending}
356
+ onClick={onCheckout}
357
+ >
358
+ Checkout
359
+ </Button>
360
+ )}
361
+ {viewCartUnavailable ? null : (
362
+ <Button variant='ghost' className='w-full' onClick={onViewCart}>
363
+ View cart
364
+ </Button>
365
+ )}
273
366
  </div>
274
367
  </div>
275
368
  ) : null}
@@ -1,6 +1,8 @@
1
1
  import { renderToStaticMarkup } from 'react-dom/server'
2
2
  import { describe, expect, it } from 'vitest'
3
3
  import { Navbar4 } from './navbar-4'
4
+ import { Navbar4AccountMenu } from './navbar-4-account-menu'
5
+ import { navbar4PropsSchema } from '../../../lib/contracts'
4
6
 
5
7
  const base = {
6
8
  storeCategories: [],
@@ -9,6 +11,52 @@ const base = {
9
11
  logoText: 'ElectroCuba',
10
12
  }
11
13
 
14
+ describe('Navbar4 — account affordance', () => {
15
+ const user = { name: 'Ada Lovelace', email: 'ada@example.test', initials: 'AL' }
16
+
17
+ it('offers a visible account destination to anonymous shoppers', () => {
18
+ const html = renderToStaticMarkup(<Navbar4 {...base} accountHref="/account" />)
19
+ expect(html).toMatch(/<a[^>]*href="\/account"[^>]*aria-label="Account"/)
20
+ })
21
+
22
+ it('renders the identity trigger without a desktop-only wrapper or empty image', () => {
23
+ const html = renderToStaticMarkup(<Navbar4 {...base} user={user} />)
24
+ expect(html).toContain('Account menu for Ada Lovelace')
25
+ expect(html).not.toMatch(/<div class="hidden lg:block"><button[^>]*aria-label="Account/)
26
+ expect(html).not.toContain('src=""')
27
+ })
28
+
29
+ it('renders no account control without identity or destination', () => {
30
+ const html = renderToStaticMarkup(<Navbar4 {...base} />)
31
+ expect(html).not.toContain('aria-label="Account')
32
+ expect(renderToStaticMarkup(<Navbar4AccountMenu />)).toBe('')
33
+ })
34
+
35
+ it('disables the identity trigger while a session operation is pending', () => {
36
+ const html = renderToStaticMarkup(<Navbar4 {...base} user={user} accountPending />)
37
+ expect(html).toMatch(/<button[^>]*disabled=""[^>]*aria-label="Account menu for Ada Lovelace"/)
38
+ })
39
+
40
+ it('distinguishes unavailable identity from anonymous and offers retry', () => {
41
+ const html = renderToStaticMarkup(
42
+ <Navbar4 {...base} accountHref="/account" accountUnavailable onAccountRetry={() => {}} />,
43
+ )
44
+ expect(html).toContain('aria-label="Retry account session"')
45
+ expect(html).not.toContain('aria-label="Account"')
46
+ })
47
+
48
+ it('keeps serializable identity and feedback while stripping callbacks', () => {
49
+ const parsed = navbar4PropsSchema.parse({
50
+ ...base, user, accountHref: '/account', accountMenuItems: [{ label: 'Account', href: '/account' }],
51
+ accountPending: true, accountUnavailable: true, onSignOut: () => {},
52
+ })
53
+ expect(parsed.user).toEqual(user)
54
+ expect(parsed.accountPending).toBe(true)
55
+ expect(parsed.accountUnavailable).toBe(true)
56
+ expect(parsed).not.toHaveProperty('onSignOut')
57
+ })
58
+ })
59
+
12
60
  describe('Navbar4 — optional logo accent', () => {
13
61
  it('renders the wordmark unchanged when no accent is supplied', () => {
14
62
  const html = renderToStaticMarkup(<Navbar4 {...base} />)
@@ -47,6 +95,53 @@ describe('Navbar4 — optional logo accent', () => {
47
95
  })
48
96
  })
49
97
 
98
+ describe('Navbar4 — optional utility strip', () => {
99
+ // The desktop utility strip is the only element with this inner track class.
100
+ const strip = (html: string) => html.includes('h-9 w-full max-w-7xl')
101
+
102
+ it('renders no utility strip when the note is empty and there are no utility links', () => {
103
+ const html = renderToStaticMarkup(
104
+ <Navbar4 storeCategories={[]} shippingNote='' utilityLinks={[]} logoText='ElectroCuba' />,
105
+ )
106
+ expect(strip(html)).toBe(false)
107
+ })
108
+
109
+ it('renders no utility strip when the note is omitted', () => {
110
+ const html = renderToStaticMarkup(
111
+ <Navbar4 storeCategories={[]} utilityLinks={[]} logoText='ElectroCuba' />,
112
+ )
113
+ expect(strip(html)).toBe(false)
114
+ })
115
+
116
+ it('treats a whitespace-only note as empty', () => {
117
+ const html = renderToStaticMarkup(
118
+ <Navbar4 storeCategories={[]} shippingNote=' ' utilityLinks={[]} logoText='ElectroCuba' />,
119
+ )
120
+ expect(strip(html)).toBe(false)
121
+ })
122
+
123
+ it('renders the utility strip with a non-empty note', () => {
124
+ const html = renderToStaticMarkup(
125
+ <Navbar4 storeCategories={[]} shippingNote='Entrega segura' utilityLinks={[]} logoText='ElectroCuba' />,
126
+ )
127
+ expect(strip(html)).toBe(true)
128
+ expect(html).toContain('Entrega segura')
129
+ })
130
+
131
+ it('renders the utility strip when there is a utility link even with an empty note', () => {
132
+ const html = renderToStaticMarkup(
133
+ <Navbar4
134
+ storeCategories={[]}
135
+ shippingNote=''
136
+ utilityLinks={[{ label: 'Ayuda', href: '/help' }]}
137
+ logoText='ElectroCuba'
138
+ />,
139
+ )
140
+ expect(strip(html)).toBe(true)
141
+ expect(html).toContain('Ayuda')
142
+ })
143
+ })
144
+
50
145
  const category = { href: '/herramientas', label: 'Herramientas' }
51
146
 
52
147
  // The desktop nav link (the mobile drawer also emits an anchor for the same
@@ -86,6 +181,32 @@ describe('Navbar4 — optional outer surface className', () => {
86
181
  })
87
182
  })
88
183
 
184
+ const cartTrigger = (html: string) =>
185
+ html.match(/<button[^>]*aria-label="Cart,[^"]*"[^>]*>/)?.[0] ?? ''
186
+
187
+ describe('Navbar4 — controlled cart sheet visibility', () => {
188
+ it('keeps the uncontrolled sheet closed by default (legacy consumers unaffected)', () => {
189
+ const html = renderToStaticMarkup(<Navbar4 {...base} />)
190
+ expect(cartTrigger(html)).toContain('data-state="closed"')
191
+ })
192
+
193
+ it('reflects a controlled open state on the trigger', () => {
194
+ const html = renderToStaticMarkup(
195
+ <Navbar4 {...base} cartOpen={true} onCartOpenChange={() => {}} />,
196
+ )
197
+ expect(cartTrigger(html)).toContain('data-state="open"')
198
+ expect(cartTrigger(html)).toContain('aria-expanded="true"')
199
+ })
200
+
201
+ it('reflects a controlled closed state on the trigger', () => {
202
+ const html = renderToStaticMarkup(
203
+ <Navbar4 {...base} cartOpen={false} onCartOpenChange={() => {}} />,
204
+ )
205
+ expect(cartTrigger(html)).toContain('data-state="closed"')
206
+ expect(cartTrigger(html)).toContain('aria-expanded="false"')
207
+ })
208
+ })
209
+
89
210
  describe('Navbar4 — optional showSearch (E2 task 5.4)', () => {
90
211
  it('renders the search control by default (legacy consumers unaffected)', () => {
91
212
  const html = renderToStaticMarkup(<Navbar4 {...base} />)