@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.
- package/CHANGELOG.md +107 -0
- package/README.md +108 -23
- package/docs/standards/data-contracts.md +13 -2
- package/package.json +26 -10
- package/scripts/fix-esm-extensions.mjs +40 -0
- package/src/blocks/e-commerce/navigation/back-to-top.test.tsx +27 -0
- package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
- package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
- package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
- package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
- package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
- package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
- package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
- package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
- package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
- package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
- package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
- package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
- package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
- package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
- package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
- package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
- package/src/blocks/e-commerce/product-list/product-card.tsx +424 -0
- package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
- package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
- package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
- package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
- package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
- package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
- package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
- package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
- package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
- package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
- package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
- package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
- package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +204 -109
- package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
- package/src/blocks/marketing/features/feature.test.tsx +153 -0
- package/src/blocks/marketing/features/feature.tsx +195 -0
- package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +56 -0
- package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +150 -0
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +102 -9
- package/src/blocks/marketing/navbars/navbar-4.test.tsx +154 -2
- package/src/blocks/marketing/navbars/navbar-4.tsx +152 -32
- package/src/index.ts +93 -46
- package/src/lib/contracts-curated.test.ts +42 -24
- package/src/lib/contracts.test.ts +143 -2
- package/src/lib/contracts.ts +395 -13
- package/src/ui/button-group.tsx +1 -1
- package/src/ui/carousel.tsx +1 -1
- package/src/ui/dialog.tsx +1 -1
- package/src/ui/field.tsx +2 -2
- package/src/ui/input-group.tsx +3 -3
- package/src/ui/pagination.tsx +1 -1
- package/src/ui/sidebar.tsx +7 -7
- package/src/ui/toggle-group.tsx +1 -1
- package/tsconfig.build.json +12 -0
- package/tsup.config.ts +11 -7
- package/.turbo/turbo-build.log +0 -24
- package/.turbo/turbo-test.log +0 -37
- package/.turbo/turbo-type-check.log +0 -0
- package/dist/.tsbuildinfo +0 -1
- package/dist/contracts.d.mts +0 -1584
- package/dist/contracts.d.ts +0 -1584
- package/dist/contracts.js +0 -390
- package/dist/contracts.js.map +0 -1
- package/dist/contracts.mjs +0 -334
- package/dist/contracts.mjs.map +0 -1
- package/dist/index.d.mts +0 -2786
- package/dist/index.d.ts +0 -2786
- package/dist/index.js +0 -17139
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -17059
- package/dist/index.mjs.map +0 -1
- package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
- package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
- package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
- package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
- package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
- package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
- package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -131
- package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
- package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
- package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
- package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
- package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
- package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
- package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
- package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
- package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
- package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
- package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -137
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -65
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
- package/src/blocks/marketing/features/feature-1.tsx +0 -97
- 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'>{
|
|
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
|
-
{
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
312
|
-
<div className='
|
|
313
|
-
<
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
<
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
423
|
-
<
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|