@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.
- package/.turbo/turbo-build.log +15 -15
- package/.turbo/turbo-test.log +37 -27
- package/README.md +42 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/contracts.d.mts +162 -14
- package/dist/contracts.d.ts +162 -14
- package/dist/contracts.js +36 -3
- package/dist/contracts.js.map +1 -1
- package/dist/contracts.mjs +34 -3
- package/dist/contracts.mjs.map +1 -1
- package/dist/index.d.mts +294 -12
- package/dist/index.d.ts +294 -12
- package/dist/index.js +3577 -2882
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +3431 -2750
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- 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-list/product-card-1.test.tsx +88 -0
- package/src/blocks/e-commerce/product-list/product-card-1.tsx +141 -80
- 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/shopping-carts/shopping-cart-4.test.tsx +92 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +86 -25
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +7 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +1 -1
- package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +38 -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 +121 -0
- package/src/blocks/marketing/navbars/navbar-4.tsx +134 -32
- package/src/index.ts +18 -0
- 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
|
|
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
|
-
|
|
107
|
-
<
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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'>{
|
|
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,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} />)
|