@thorprovider/blocks 0.1.2 → 1.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 (111) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +108 -23
  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/e-commerce/navigation/back-to-top.test.tsx +27 -0
  7. package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
  8. package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
  9. package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
  10. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
  11. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
  12. package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
  13. package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
  14. package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
  15. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  16. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  17. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  18. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  19. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  20. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  21. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  22. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  23. package/src/blocks/e-commerce/product-list/product-card.tsx +424 -0
  24. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  25. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  26. package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
  27. package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
  28. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  29. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  30. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  31. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  32. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  33. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  34. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  35. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  36. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +204 -109
  37. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  38. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  39. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  40. package/src/blocks/marketing/features/feature.tsx +195 -0
  41. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +56 -0
  42. package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
  43. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +150 -0
  44. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +102 -9
  45. package/src/blocks/marketing/navbars/navbar-4.test.tsx +154 -2
  46. package/src/blocks/marketing/navbars/navbar-4.tsx +152 -32
  47. package/src/index.ts +93 -46
  48. package/src/lib/contracts-curated.test.ts +42 -24
  49. package/src/lib/contracts.test.ts +143 -2
  50. package/src/lib/contracts.ts +395 -13
  51. package/src/ui/button-group.tsx +1 -1
  52. package/src/ui/carousel.tsx +1 -1
  53. package/src/ui/dialog.tsx +1 -1
  54. package/src/ui/field.tsx +2 -2
  55. package/src/ui/input-group.tsx +3 -3
  56. package/src/ui/pagination.tsx +1 -1
  57. package/src/ui/sidebar.tsx +7 -7
  58. package/src/ui/toggle-group.tsx +1 -1
  59. package/tsconfig.build.json +12 -0
  60. package/tsup.config.ts +11 -7
  61. package/.turbo/turbo-build.log +0 -24
  62. package/.turbo/turbo-test.log +0 -37
  63. package/.turbo/turbo-type-check.log +0 -0
  64. package/dist/.tsbuildinfo +0 -1
  65. package/dist/contracts.d.mts +0 -1584
  66. package/dist/contracts.d.ts +0 -1584
  67. package/dist/contracts.js +0 -390
  68. package/dist/contracts.js.map +0 -1
  69. package/dist/contracts.mjs +0 -334
  70. package/dist/contracts.mjs.map +0 -1
  71. package/dist/index.d.mts +0 -2786
  72. package/dist/index.d.ts +0 -2786
  73. package/dist/index.js +0 -17139
  74. package/dist/index.js.map +0 -1
  75. package/dist/index.mjs +0 -17059
  76. package/dist/index.mjs.map +0 -1
  77. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  78. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  79. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  80. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  81. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  82. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  83. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -131
  84. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  85. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  86. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  87. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  88. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  89. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  90. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  91. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  92. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  93. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  94. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  95. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  96. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  97. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  98. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  99. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  100. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  101. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  102. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  103. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  104. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -137
  105. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -65
  106. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  107. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  108. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  109. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  110. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  111. package/src/blocks/marketing/features/feature-2.tsx +0 -82
@@ -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,9 @@
1
+ import type { ReactElement } from 'react'
1
2
  import { renderToStaticMarkup } from 'react-dom/server'
2
- import { describe, expect, it } from 'vitest'
3
- import { Navbar4 } from './navbar-4'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { Navbar4, Navbar4AccountRetry } from './navbar-4'
5
+ import { Navbar4AccountMenu } from './navbar-4-account-menu'
6
+ import { navbar4PropsSchema } from '../../../lib/contracts'
4
7
 
5
8
  const base = {
6
9
  storeCategories: [],
@@ -9,6 +12,82 @@ const base = {
9
12
  logoText: 'ElectroCuba',
10
13
  }
11
14
 
15
+ describe('Navbar4 — account affordance', () => {
16
+ const user = { name: 'Ada Lovelace', email: 'ada@example.test', initials: 'AL' }
17
+
18
+ it('offers a visible account destination to anonymous shoppers', () => {
19
+ const html = renderToStaticMarkup(<Navbar4 {...base} accountHref="/account" />)
20
+ expect(html).toMatch(/<a[^>]*href="\/account"[^>]*aria-label="Account"/)
21
+ })
22
+
23
+ it('renders the identity trigger without a desktop-only wrapper or empty image', () => {
24
+ const html = renderToStaticMarkup(<Navbar4 {...base} user={user} />)
25
+ expect(html).toContain('Account menu for Ada Lovelace')
26
+ expect(html).not.toMatch(/<div class="hidden lg:block"><button[^>]*aria-label="Account/)
27
+ expect(html).not.toContain('src=""')
28
+ })
29
+
30
+ it('renders the fallback initials when the identity has no avatar, never an empty image source', () => {
31
+ const html = renderToStaticMarkup(<Navbar4 {...base} user={user} />)
32
+ expect(html).toContain('>AL<')
33
+ expect(html).not.toContain('src=""')
34
+ expect(html).not.toContain('<img')
35
+ })
36
+
37
+ it('renders no account control without identity or destination', () => {
38
+ const html = renderToStaticMarkup(<Navbar4 {...base} />)
39
+ expect(html).not.toContain('aria-label="Account')
40
+ expect(renderToStaticMarkup(<Navbar4AccountMenu />)).toBe('')
41
+ })
42
+
43
+ it('disables the identity trigger while a session operation is pending', () => {
44
+ const html = renderToStaticMarkup(<Navbar4 {...base} user={user} accountPending />)
45
+ expect(html).toMatch(/<button[^>]*disabled=""[^>]*aria-label="Account menu for Ada Lovelace"/)
46
+ })
47
+
48
+ it('distinguishes unavailable identity from anonymous and offers retry', () => {
49
+ const html = renderToStaticMarkup(
50
+ <Navbar4 {...base} accountHref="/account" accountUnavailable onAccountRetry={() => {}} />,
51
+ )
52
+ expect(html).toContain('aria-label="Retry account session"')
53
+ expect(html).not.toContain('aria-label="Account"')
54
+ })
55
+
56
+ it('keeps serializable identity and feedback while stripping callbacks', () => {
57
+ const parsed = navbar4PropsSchema.parse({
58
+ ...base, user, accountHref: '/account', accountMenuItems: [{ label: 'Account', href: '/account' }],
59
+ accountPending: true, accountUnavailable: true, onSignOut: () => {},
60
+ })
61
+ expect(parsed.user).toEqual(user)
62
+ expect(parsed.accountPending).toBe(true)
63
+ expect(parsed.accountUnavailable).toBe(true)
64
+ expect(parsed).not.toHaveProperty('onSignOut')
65
+ })
66
+ })
67
+
68
+ describe('Navbar4 — consumer-controlled session retry', () => {
69
+ type RetryProps = { onClick?: () => void; disabled?: boolean }
70
+
71
+ it('dispatches the consumer retry action from the unavailable affordance', () => {
72
+ const onRetry = vi.fn()
73
+ const control = Navbar4AccountRetry({ onRetry }) as ReactElement<RetryProps>
74
+ expect(control.props.disabled).toBe(false)
75
+ control.props.onClick?.()
76
+ expect(onRetry).toHaveBeenCalledTimes(1)
77
+ })
78
+
79
+ it('holds retry inert while reconciliation is pending', () => {
80
+ const onRetry = vi.fn()
81
+ const control = Navbar4AccountRetry({ pending: true, onRetry }) as ReactElement<RetryProps>
82
+ expect(control.props.disabled).toBe(true)
83
+ })
84
+
85
+ it('holds retry inert when no consumer callback is bound (no empty affordance)', () => {
86
+ const control = Navbar4AccountRetry({}) as ReactElement<RetryProps>
87
+ expect(control.props.disabled).toBe(true)
88
+ })
89
+ })
90
+
12
91
  describe('Navbar4 — optional logo accent', () => {
13
92
  it('renders the wordmark unchanged when no accent is supplied', () => {
14
93
  const html = renderToStaticMarkup(<Navbar4 {...base} />)
@@ -47,6 +126,53 @@ describe('Navbar4 — optional logo accent', () => {
47
126
  })
48
127
  })
49
128
 
129
+ describe('Navbar4 — optional utility strip', () => {
130
+ // The desktop utility strip is the only element with this inner track class.
131
+ const strip = (html: string) => html.includes('h-9 w-full max-w-7xl')
132
+
133
+ it('renders no utility strip when the note is empty and there are no utility links', () => {
134
+ const html = renderToStaticMarkup(
135
+ <Navbar4 storeCategories={[]} shippingNote='' utilityLinks={[]} logoText='ElectroCuba' />,
136
+ )
137
+ expect(strip(html)).toBe(false)
138
+ })
139
+
140
+ it('renders no utility strip when the note is omitted', () => {
141
+ const html = renderToStaticMarkup(
142
+ <Navbar4 storeCategories={[]} utilityLinks={[]} logoText='ElectroCuba' />,
143
+ )
144
+ expect(strip(html)).toBe(false)
145
+ })
146
+
147
+ it('treats a whitespace-only note as empty', () => {
148
+ const html = renderToStaticMarkup(
149
+ <Navbar4 storeCategories={[]} shippingNote=' ' utilityLinks={[]} logoText='ElectroCuba' />,
150
+ )
151
+ expect(strip(html)).toBe(false)
152
+ })
153
+
154
+ it('renders the utility strip with a non-empty note', () => {
155
+ const html = renderToStaticMarkup(
156
+ <Navbar4 storeCategories={[]} shippingNote='Entrega segura' utilityLinks={[]} logoText='ElectroCuba' />,
157
+ )
158
+ expect(strip(html)).toBe(true)
159
+ expect(html).toContain('Entrega segura')
160
+ })
161
+
162
+ it('renders the utility strip when there is a utility link even with an empty note', () => {
163
+ const html = renderToStaticMarkup(
164
+ <Navbar4
165
+ storeCategories={[]}
166
+ shippingNote=''
167
+ utilityLinks={[{ label: 'Ayuda', href: '/help' }]}
168
+ logoText='ElectroCuba'
169
+ />,
170
+ )
171
+ expect(strip(html)).toBe(true)
172
+ expect(html).toContain('Ayuda')
173
+ })
174
+ })
175
+
50
176
  const category = { href: '/herramientas', label: 'Herramientas' }
51
177
 
52
178
  // The desktop nav link (the mobile drawer also emits an anchor for the same
@@ -86,6 +212,32 @@ describe('Navbar4 — optional outer surface className', () => {
86
212
  })
87
213
  })
88
214
 
215
+ const cartTrigger = (html: string) =>
216
+ html.match(/<button[^>]*aria-label="Cart,[^"]*"[^>]*>/)?.[0] ?? ''
217
+
218
+ describe('Navbar4 — controlled cart sheet visibility', () => {
219
+ it('keeps the uncontrolled sheet closed by default (legacy consumers unaffected)', () => {
220
+ const html = renderToStaticMarkup(<Navbar4 {...base} />)
221
+ expect(cartTrigger(html)).toContain('data-state="closed"')
222
+ })
223
+
224
+ it('reflects a controlled open state on the trigger', () => {
225
+ const html = renderToStaticMarkup(
226
+ <Navbar4 {...base} cartOpen={true} onCartOpenChange={() => {}} />,
227
+ )
228
+ expect(cartTrigger(html)).toContain('data-state="open"')
229
+ expect(cartTrigger(html)).toContain('aria-expanded="true"')
230
+ })
231
+
232
+ it('reflects a controlled closed state on the trigger', () => {
233
+ const html = renderToStaticMarkup(
234
+ <Navbar4 {...base} cartOpen={false} onCartOpenChange={() => {}} />,
235
+ )
236
+ expect(cartTrigger(html)).toContain('data-state="closed"')
237
+ expect(cartTrigger(html)).toContain('aria-expanded="false"')
238
+ })
239
+ })
240
+
89
241
  describe('Navbar4 — optional showSearch (E2 task 5.4)', () => {
90
242
  it('renders the search control by default (legacy consumers unaffected)', () => {
91
243
  const html = renderToStaticMarkup(<Navbar4 {...base} />)
@@ -2,14 +2,14 @@
2
2
 
3
3
  import { useEffect, useMemo, useRef, useState } from 'react'
4
4
  import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
5
- import { Menu, Search, ShoppingBag, X } from 'lucide-react'
5
+ import { Menu, Search, ShoppingBag, UserRound, RotateCcw, X } from 'lucide-react'
6
6
 
7
7
  import { Button } from '../../../ui/button'
8
8
  import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '../../../ui/sheet'
9
9
  import { cn } from '../../../lib/utils'
10
10
 
11
11
  import { Navbar4AccountMenu, type AccountMenuItem, type StoreUser } from './navbar-4-account-menu'
12
- import { Navbar4CartSheet, type CartItem } from './navbar-4-cart-sheet'
12
+ import { Navbar4CartSheet, type CartItem, type CartSheetState } from './navbar-4-cart-sheet'
13
13
  import type { UtilityLink } from './navbar-4-mobile-nav'
14
14
  import { Navbar4SearchBar } from './navbar-4-search-bar'
15
15
  import { MegaMenu } from '../../e-commerce/navigation/mega-menu'
@@ -161,9 +161,42 @@ function CartBadge({ count }: CartBadgeProps) {
161
161
  )
162
162
  }
163
163
 
164
+ export interface Navbar4AccountRetryProps {
165
+ /** Consumer-owned reconciliation in flight; holds the affordance inert. */
166
+ pending?: boolean
167
+ /** Consumer-owned recovery action. Without it the affordance is inert. */
168
+ onRetry?: () => void
169
+ }
170
+
171
+ /**
172
+ * Recovery affordance for an unavailable session. The block never resolves the
173
+ * session itself: it surfaces the state and dispatches the consumer's retry.
174
+ * Extracted (hooks-free) so the dispatch is testable without a DOM.
175
+ */
176
+ export function Navbar4AccountRetry({ pending = false, onRetry }: Navbar4AccountRetryProps) {
177
+ return (
178
+ <Button
179
+ variant='ghost'
180
+ size='icon'
181
+ className='size-11'
182
+ disabled={pending || !onRetry}
183
+ aria-label='Retry account session'
184
+ title='Account session unavailable'
185
+ onClick={onRetry}
186
+ >
187
+ <RotateCcw aria-hidden='true' />
188
+ </Button>
189
+ )
190
+ }
191
+
164
192
  export interface Navbar4Props {
165
193
  storeCategories: StoreCategory[]
166
- shippingNote: string
194
+ /**
195
+ * Optional note shown in the desktop utility strip. When omitted (or empty)
196
+ * and there are no `utilityLinks`, the strip is not rendered at all, so a
197
+ * block with no optional content paints no empty decorative chrome.
198
+ */
199
+ shippingNote?: string
167
200
  utilityLinks: UtilityLink[]
168
201
  logoText: string
169
202
  logoHref?: string
@@ -173,7 +206,7 @@ export interface Navbar4Props {
173
206
  * `logoText`, the wordmark renders unchanged.
174
207
  */
175
208
  logoAccent?: LogoAccent
176
- /** When provided, a mobile-only "Account" link is appended to the utility links. */
209
+ /** Account destination for anonymous shoppers on every viewport. */
177
210
  accountHref?: string
178
211
  /**
179
212
  * When `false`, no search control is rendered at all — neither the desktop
@@ -182,6 +215,11 @@ export interface Navbar4Props {
182
215
  * storefront passes `false` while no verified global search contract exists
183
216
  * (E2 task 5.4), so no global search is presented as working.
184
217
  */
218
+ /**
219
+ * The storefront's current route path. When it matches a destination the link
220
+ * is marked as the current page (accessibly, beyond color alone).
221
+ */
222
+ currentPath?: string
185
223
  showSearch?: boolean
186
224
  searchQuery?: string
187
225
  onSearchChange?: (value: string) => void
@@ -204,8 +242,40 @@ export interface Navbar4Props {
204
242
  accountMenuItems?: AccountMenuItem[]
205
243
  onHelp?: () => void
206
244
  onSignOut?: () => void
245
+ accountPending?: boolean
246
+ accountUnavailable?: boolean
247
+ onAccountRetry?: () => void
207
248
  onCheckout?: () => void
208
249
  onViewCart?: () => void
250
+ /**
251
+ * Controlled visibility for the cart sheet. When provided, the sheet opens
252
+ * and closes from this value and `onCartOpenChange` instead of internal
253
+ * state, so a host can share one cart surface across the navbar trigger and
254
+ * successful add-to-cart. Omit both to keep the legacy uncontrolled sheet.
255
+ */
256
+ cartOpen?: boolean
257
+ /** Change handler paired with `cartOpen` for a controlled sheet. */
258
+ onCartOpenChange?: (open: boolean) => void
259
+ /**
260
+ * Backend-authoritative cart rendering for the sheet. When `true`, line
261
+ * amounts use each item's canonical `lineTotal` Money and never fall back to
262
+ * numeric `price * qty`. Omit for the legacy numeric rendering.
263
+ */
264
+ canonicalCart?: boolean
265
+ /** Cart read/mutation lifecycle for the sheet. */
266
+ cartState?: CartSheetState
267
+ /** Message shown when `cartState` is `error`. */
268
+ cartErrorLabel?: string
269
+ /** Recovery action label for the error state. */
270
+ cartRetryLabel?: string
271
+ /** Recovery action invoked from the error state. */
272
+ onCartRetry?: () => void
273
+ /** When `true`, the sheet's checkout action is suppressed (checkout not installed). */
274
+ checkoutUnavailable?: boolean
275
+ /** When `true`, the sheet's checkout action is disabled (cart not ready). */
276
+ checkoutDisabled?: boolean
277
+ /** When `true`, the sheet's review action is suppressed (cart view not installed). */
278
+ viewCartUnavailable?: boolean
209
279
  /**
210
280
  * Consumer-supplied classes merged onto the outer `<header>` as the final
211
281
  * argument, so a semantic surface such as `bg-brand-nav
@@ -217,12 +287,13 @@ export interface Navbar4Props {
217
287
 
218
288
  export function Navbar4({
219
289
  storeCategories,
220
- shippingNote,
290
+ shippingNote = '',
221
291
  utilityLinks,
222
292
  logoText,
223
293
  logoHref,
224
294
  logoAccent,
225
295
  accountHref,
296
+ currentPath,
226
297
  showSearch = true,
227
298
  searchQuery: searchQueryProp,
228
299
  onSearchChange,
@@ -236,8 +307,21 @@ export function Navbar4({
236
307
  accountMenuItems,
237
308
  onHelp,
238
309
  onSignOut,
310
+ accountPending = false,
311
+ accountUnavailable = false,
312
+ onAccountRetry,
239
313
  onCheckout,
240
314
  onViewCart,
315
+ cartOpen,
316
+ onCartOpenChange,
317
+ canonicalCart,
318
+ cartState,
319
+ cartErrorLabel,
320
+ cartRetryLabel,
321
+ onCartRetry,
322
+ checkoutUnavailable,
323
+ checkoutDisabled,
324
+ viewCartUnavailable,
241
325
  className,
242
326
  }: Navbar4Props) {
243
327
  /**
@@ -300,6 +384,13 @@ export function Navbar4({
300
384
  // default foreground (and the default hover surface).
301
385
  const navLinkColor =
302
386
  className !== undefined ? 'text-inherit hover:text-foreground' : 'text-foreground'
387
+ // The current page is marked beyond color: an accessible current state plus a
388
+ // visible weight/underline, and nothing when no destination matches.
389
+ const isCurrent = (href?: string) => href !== undefined && href === currentPath
390
+
391
+ // The desktop utility strip is decorative chrome: it is painted only when it
392
+ // has content (a non-empty note or at least one utility link), never empty.
393
+ const hasUtilityContent = shippingNote.trim().length > 0 || utilityLinks.length > 0
303
394
 
304
395
  return (
305
396
  <header
@@ -308,23 +399,25 @@ export function Navbar4({
308
399
  className ?? 'bg-background/95 supports-backdrop-filter:bg-background/80',
309
400
  )}
310
401
  >
311
- <div className='bg-foreground text-background hidden lg:block'>
312
- <div className='mx-auto flex h-9 w-full max-w-7xl items-center justify-between px-4 text-xs tracking-wide sm:px-6 lg:px-8'>
313
- <p className='text-background/80'>{shippingNote}</p>
314
- <ul className='flex items-center gap-5'>
315
- {utilityLinks.map(link => (
316
- <li key={`${link.href}:${link.label}`}>
317
- <a
318
- href={link.href}
319
- className='text-background/80 hover:text-background transition-colors duration-150'
320
- >
321
- {link.label}
322
- </a>
323
- </li>
324
- ))}
325
- </ul>
402
+ {hasUtilityContent ? (
403
+ <div className='bg-foreground text-background hidden lg:block'>
404
+ <div className='mx-auto flex h-9 w-full max-w-7xl items-center justify-between px-4 text-xs tracking-wide sm:px-6 lg:px-8'>
405
+ <p className='text-background/80'>{shippingNote}</p>
406
+ <ul className='flex items-center gap-5'>
407
+ {utilityLinks.map(link => (
408
+ <li key={`${link.href}:${link.label}`}>
409
+ <a
410
+ href={link.href}
411
+ className='text-background/80 hover:text-background transition-colors duration-150'
412
+ >
413
+ {link.label}
414
+ </a>
415
+ </li>
416
+ ))}
417
+ </ul>
418
+ </div>
326
419
  </div>
327
- </div>
420
+ ) : null}
328
421
 
329
422
  <div className='mx-auto flex h-16 w-full max-w-7xl items-center gap-3 px-4 sm:px-6 lg:px-8'>
330
423
  <Button
@@ -361,9 +454,11 @@ export function Navbar4({
361
454
  <li key={category.href} className='group relative'>
362
455
  <a
363
456
  href={category.href}
457
+ aria-current={isCurrent(category.href) ? 'page' : undefined}
364
458
  className={cn(
365
459
  'hover:bg-muted focus-visible:ring-ring rounded-md px-3 py-2 text-sm font-medium transition-colors duration-150 focus-visible:ring-2 focus-visible:outline-none',
366
460
  category.emphasis ? 'text-destructive' : navLinkColor,
461
+ isCurrent(category.href) && 'font-semibold underline underline-offset-4',
367
462
  )}
368
463
  >
369
464
  {category.label}
@@ -381,9 +476,11 @@ export function Navbar4({
381
476
  <li key={category.href}>
382
477
  <a
383
478
  href={category.href}
479
+ aria-current={isCurrent(category.href) ? 'page' : undefined}
384
480
  className={cn(
385
481
  'hover:bg-muted focus-visible:ring-ring rounded-md px-3 py-2 text-sm font-medium transition-colors duration-150 focus-visible:ring-2 focus-visible:outline-none',
386
482
  category.emphasis ? 'text-destructive' : navLinkColor,
483
+ isCurrent(category.href) && 'font-semibold underline underline-offset-4',
387
484
  )}
388
485
  >
389
486
  {category.label}
@@ -419,18 +516,33 @@ export function Navbar4({
419
516
  </>
420
517
  ) : null}
421
518
 
422
- {user ? (
423
- <div className='hidden lg:block'>
424
- <Navbar4AccountMenu
425
- user={user}
426
- items={accountMenuItems}
427
- onHelp={onHelp}
428
- onSignOut={onSignOut}
429
- />
430
- </div>
519
+ {accountUnavailable ? (
520
+ <Navbar4AccountRetry pending={accountPending} onRetry={onAccountRetry} />
521
+ ) : user ? (
522
+ <Navbar4AccountMenu
523
+ user={user}
524
+ items={accountMenuItems}
525
+ onHelp={onHelp}
526
+ onSignOut={onSignOut}
527
+ pending={accountPending}
528
+ />
529
+ ) : accountHref ? (
530
+ accountPending ? (
531
+ <Button variant='ghost' size='icon' className='size-11' disabled aria-label='Loading account session'>
532
+ <UserRound aria-hidden='true' />
533
+ </Button>
534
+ ) : (
535
+ <Button variant='ghost' size='icon' className='size-11' asChild>
536
+ <a href={accountHref} aria-label='Account' aria-current={isCurrent(accountHref) ? 'page' : undefined}>
537
+ <UserRound aria-hidden='true' />
538
+ </a>
539
+ </Button>
540
+ )
431
541
  ) : null}
432
542
 
433
- <Sheet>
543
+ <Sheet
544
+ {...(cartOpen !== undefined ? { open: cartOpen, onOpenChange: onCartOpenChange } : {})}
545
+ >
434
546
  <SheetTrigger asChild>
435
547
  <Button
436
548
  variant='ghost'
@@ -457,6 +569,14 @@ export function Navbar4({
457
569
  formatMoney={formatMoney}
458
570
  onCheckout={onCheckout}
459
571
  onViewCart={onViewCart}
572
+ canonical={canonicalCart}
573
+ state={cartState}
574
+ errorLabel={cartErrorLabel}
575
+ retryLabel={cartRetryLabel}
576
+ onRetry={onCartRetry}
577
+ checkoutUnavailable={checkoutUnavailable}
578
+ checkoutDisabled={checkoutDisabled}
579
+ viewCartUnavailable={viewCartUnavailable}
460
580
  />
461
581
  </SheetContent>
462
582
  </Sheet>
@@ -481,4 +601,4 @@ export function Navbar4({
481
601
  )
482
602
  }
483
603
 
484
- export default Navbar4
604
+ export default Navbar4