@thorprovider/blocks 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +15 -15
- package/.turbo/turbo-test.log +42 -5
- package/AGENTS.md +1 -1
- package/README.md +94 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/contracts.d.mts +1266 -97
- package/dist/contracts.d.ts +1266 -97
- package/dist/contracts.js +225 -9
- package/dist/contracts.js.map +1 -1
- package/dist/contracts.mjs +207 -10
- package/dist/contracts.mjs.map +1 -1
- package/dist/index.d.mts +756 -96
- package/dist/index.d.ts +756 -96
- package/dist/index.js +5114 -3702
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4939 -3551
- package/dist/index.mjs.map +1 -1
- package/docs/standards/theming.md +32 -5
- package/package.json +2 -2
- package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +136 -93
- package/src/blocks/e-commerce/category-filters/category-filter-2.test.tsx +513 -0
- package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +728 -148
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +14 -8
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.test.tsx +136 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +145 -99
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +215 -102
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +6 -2
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +51 -2
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +216 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +114 -11
- 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/mobile-category-drawer.test.tsx +42 -2
- package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +10 -8
- 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/product-category-2.test.tsx +51 -0
- package/src/blocks/e-commerce/product-categories/product-category-2.tsx +56 -36
- 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/product-overview/product-overview-1.test.tsx +182 -0
- package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +298 -129
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +229 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +313 -110
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +72 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +20 -1
- package/src/blocks/marketing/footers/footer-section-1.test.tsx +117 -0
- package/src/blocks/marketing/footers/footer-section-1.tsx +46 -9
- 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 +204 -0
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +144 -21
- package/src/blocks/marketing/navbars/navbar-4.test.tsx +233 -0
- package/src/blocks/marketing/navbars/navbar-4.tsx +235 -60
- package/src/index.ts +56 -0
- package/src/lib/cart.test.ts +15 -0
- package/src/lib/cart.ts +11 -0
- package/src/lib/contracts-curated.test.ts +207 -0
- package/src/lib/contracts-server-boundary.test.ts +52 -0
- package/src/lib/contracts.ts +234 -6
- package/src/lib/money.test.ts +64 -0
- package/src/lib/money.ts +65 -0
- package/tsup.config.ts +0 -1
- package/vitest.config.ts +9 -1
|
@@ -2,19 +2,20 @@
|
|
|
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'
|
|
16
16
|
import { MobileCategoryDrawer } from '../../e-commerce/navigation/mobile-category-drawer'
|
|
17
17
|
import type { MenuItem } from '../../../lib/types/menu'
|
|
18
|
+
import type { Money } from '@thorprovider/types'
|
|
18
19
|
import type { StoreCategory } from '../../../lib/contracts'
|
|
19
20
|
|
|
20
21
|
function toMenuItem(category: StoreCategory): MenuItem {
|
|
@@ -41,10 +42,21 @@ function uniqueById<T extends { id: string }>(items: T[]): T[] {
|
|
|
41
42
|
})
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
|
|
45
|
+
export interface LogoAccent {
|
|
46
|
+
/** Trailing segment of `logoText` to render with the accent styling. */
|
|
47
|
+
text: string
|
|
48
|
+
/** Consumer-supplied classes for the accent span (e.g. a brand color token). */
|
|
49
|
+
className?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function Logo({ text, href, accent }: { text: string; href?: string; accent?: LogoAccent }) {
|
|
45
53
|
const className =
|
|
46
54
|
'focus-visible:ring-ring flex items-center gap-2 rounded-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none'
|
|
47
55
|
|
|
56
|
+
const accentText = accent?.text ?? ''
|
|
57
|
+
const hasAccent = accentText.length > 0 && text.endsWith(accentText)
|
|
58
|
+
const baseText = hasAccent ? text.slice(0, text.length - accentText.length) : text
|
|
59
|
+
|
|
48
60
|
const content = (
|
|
49
61
|
<>
|
|
50
62
|
<svg viewBox='0 0 32 32' aria-hidden='true' className='text-primary size-7' fill='none' xmlns='http://www.w3.org/2000/svg'>
|
|
@@ -73,7 +85,10 @@ function Logo({ text, href }: { text: string; href?: string }) {
|
|
|
73
85
|
<circle cx='13.1' cy='26.1' r='1.7' stroke='currentColor' strokeWidth='1.2' />
|
|
74
86
|
<circle cx='22.1' cy='26.1' r='1.7' stroke='currentColor' strokeWidth='1.2' />
|
|
75
87
|
</svg>
|
|
76
|
-
<span className='text-base font-semibold tracking-tight'>
|
|
88
|
+
<span className='text-base font-semibold tracking-tight'>
|
|
89
|
+
{baseText}
|
|
90
|
+
{hasAccent ? <span className={accent?.className}>{accentText}</span> : null}
|
|
91
|
+
</span>
|
|
77
92
|
</>
|
|
78
93
|
)
|
|
79
94
|
|
|
@@ -148,43 +163,138 @@ function CartBadge({ count }: CartBadgeProps) {
|
|
|
148
163
|
|
|
149
164
|
export interface Navbar4Props {
|
|
150
165
|
storeCategories: StoreCategory[]
|
|
151
|
-
|
|
166
|
+
/**
|
|
167
|
+
* Optional note shown in the desktop utility strip. When omitted (or empty)
|
|
168
|
+
* and there are no `utilityLinks`, the strip is not rendered at all, so a
|
|
169
|
+
* block with no optional content paints no empty decorative chrome.
|
|
170
|
+
*/
|
|
171
|
+
shippingNote?: string
|
|
152
172
|
utilityLinks: UtilityLink[]
|
|
153
173
|
logoText: string
|
|
154
174
|
logoHref?: string
|
|
155
|
-
/**
|
|
175
|
+
/**
|
|
176
|
+
* Optional accent for a trailing segment of `logoText` (e.g. the "Cuba" in
|
|
177
|
+
* "ElectroCuba"). When omitted, or when `text` is not a trailing segment of
|
|
178
|
+
* `logoText`, the wordmark renders unchanged.
|
|
179
|
+
*/
|
|
180
|
+
logoAccent?: LogoAccent
|
|
181
|
+
/** Account destination for anonymous shoppers on every viewport. */
|
|
156
182
|
accountHref?: string
|
|
183
|
+
/**
|
|
184
|
+
* When `false`, no search control is rendered at all — neither the desktop
|
|
185
|
+
* input, the mobile trigger, nor the mobile panel. Defaults to `true` so
|
|
186
|
+
* legacy consumers keep the existing self-contained search. The generated
|
|
187
|
+
* storefront passes `false` while no verified global search contract exists
|
|
188
|
+
* (E2 task 5.4), so no global search is presented as working.
|
|
189
|
+
*/
|
|
190
|
+
/**
|
|
191
|
+
* The storefront's current route path. When it matches a destination the link
|
|
192
|
+
* is marked as the current page (accessibly, beyond color alone).
|
|
193
|
+
*/
|
|
194
|
+
currentPath?: string
|
|
195
|
+
showSearch?: boolean
|
|
157
196
|
searchQuery?: string
|
|
158
197
|
onSearchChange?: (value: string) => void
|
|
159
198
|
items?: CartItem[]
|
|
199
|
+
/**
|
|
200
|
+
* Quantity DELTA for a backend cart line-item id. The boundary converts the
|
|
201
|
+
* delta into an absolute quantity against the authoritative current line
|
|
202
|
+
* quantity before mutating the cart.
|
|
203
|
+
*/
|
|
160
204
|
onQtyChange?: (id: string, delta: number) => void
|
|
205
|
+
/** Remove the backend cart line-item id. */
|
|
161
206
|
onRemove?: (id: string) => void
|
|
207
|
+
/** Authoritative backend subtotal for the mini-cart. */
|
|
208
|
+
subtotal?: Money
|
|
209
|
+
/** Shown when an authoritative subtotal is supplied but absent/malformed. */
|
|
210
|
+
subtotalUnavailableLabel?: string
|
|
211
|
+
/** Override money formatting (locale, currency). Defaults to `Money.currencyCode`. */
|
|
212
|
+
formatMoney?: (money: Money) => string
|
|
162
213
|
user?: StoreUser
|
|
163
214
|
accountMenuItems?: AccountMenuItem[]
|
|
164
215
|
onHelp?: () => void
|
|
165
216
|
onSignOut?: () => void
|
|
217
|
+
accountPending?: boolean
|
|
218
|
+
accountUnavailable?: boolean
|
|
219
|
+
onAccountRetry?: () => void
|
|
166
220
|
onCheckout?: () => void
|
|
167
221
|
onViewCart?: () => void
|
|
222
|
+
/**
|
|
223
|
+
* Controlled visibility for the cart sheet. When provided, the sheet opens
|
|
224
|
+
* and closes from this value and `onCartOpenChange` instead of internal
|
|
225
|
+
* state, so a host can share one cart surface across the navbar trigger and
|
|
226
|
+
* successful add-to-cart. Omit both to keep the legacy uncontrolled sheet.
|
|
227
|
+
*/
|
|
228
|
+
cartOpen?: boolean
|
|
229
|
+
/** Change handler paired with `cartOpen` for a controlled sheet. */
|
|
230
|
+
onCartOpenChange?: (open: boolean) => void
|
|
231
|
+
/**
|
|
232
|
+
* Backend-authoritative cart rendering for the sheet. When `true`, line
|
|
233
|
+
* amounts use each item's canonical `lineTotal` Money and never fall back to
|
|
234
|
+
* numeric `price * qty`. Omit for the legacy numeric rendering.
|
|
235
|
+
*/
|
|
236
|
+
canonicalCart?: boolean
|
|
237
|
+
/** Cart read/mutation lifecycle for the sheet. */
|
|
238
|
+
cartState?: CartSheetState
|
|
239
|
+
/** Message shown when `cartState` is `error`. */
|
|
240
|
+
cartErrorLabel?: string
|
|
241
|
+
/** Recovery action label for the error state. */
|
|
242
|
+
cartRetryLabel?: string
|
|
243
|
+
/** Recovery action invoked from the error state. */
|
|
244
|
+
onCartRetry?: () => void
|
|
245
|
+
/** When `true`, the sheet's checkout action is suppressed (checkout not installed). */
|
|
246
|
+
checkoutUnavailable?: boolean
|
|
247
|
+
/** When `true`, the sheet's checkout action is disabled (cart not ready). */
|
|
248
|
+
checkoutDisabled?: boolean
|
|
249
|
+
/** When `true`, the sheet's review action is suppressed (cart view not installed). */
|
|
250
|
+
viewCartUnavailable?: boolean
|
|
251
|
+
/**
|
|
252
|
+
* Consumer-supplied classes merged onto the outer `<header>` as the final
|
|
253
|
+
* argument, so a semantic surface such as `bg-brand-nav
|
|
254
|
+
* text-brand-nav-foreground` overrides the default `bg-background`. Purely
|
|
255
|
+
* presentational: no layout, structure, or behavior changes.
|
|
256
|
+
*/
|
|
257
|
+
className?: string
|
|
168
258
|
}
|
|
169
259
|
|
|
170
260
|
export function Navbar4({
|
|
171
261
|
storeCategories,
|
|
172
|
-
shippingNote,
|
|
262
|
+
shippingNote = '',
|
|
173
263
|
utilityLinks,
|
|
174
264
|
logoText,
|
|
175
265
|
logoHref,
|
|
266
|
+
logoAccent,
|
|
176
267
|
accountHref,
|
|
268
|
+
currentPath,
|
|
269
|
+
showSearch = true,
|
|
177
270
|
searchQuery: searchQueryProp,
|
|
178
271
|
onSearchChange,
|
|
179
272
|
items: itemsProp,
|
|
180
273
|
onQtyChange: onQtyChangeProp,
|
|
181
274
|
onRemove: onRemoveProp,
|
|
275
|
+
subtotal,
|
|
276
|
+
subtotalUnavailableLabel,
|
|
277
|
+
formatMoney,
|
|
182
278
|
user,
|
|
183
279
|
accountMenuItems,
|
|
184
280
|
onHelp,
|
|
185
281
|
onSignOut,
|
|
282
|
+
accountPending = false,
|
|
283
|
+
accountUnavailable = false,
|
|
284
|
+
onAccountRetry,
|
|
186
285
|
onCheckout,
|
|
187
286
|
onViewCart,
|
|
287
|
+
cartOpen,
|
|
288
|
+
onCartOpenChange,
|
|
289
|
+
canonicalCart,
|
|
290
|
+
cartState,
|
|
291
|
+
cartErrorLabel,
|
|
292
|
+
cartRetryLabel,
|
|
293
|
+
onCartRetry,
|
|
294
|
+
checkoutUnavailable,
|
|
295
|
+
checkoutDisabled,
|
|
296
|
+
viewCartUnavailable,
|
|
297
|
+
className,
|
|
188
298
|
}: Navbar4Props) {
|
|
189
299
|
/**
|
|
190
300
|
* Integration seam
|
|
@@ -228,37 +338,58 @@ export function Navbar4({
|
|
|
228
338
|
const count = useMemo(() => items.reduce((total, item) => total + item.qty, 0), [items])
|
|
229
339
|
|
|
230
340
|
useEffect(() => {
|
|
231
|
-
if (!searchOpen) {
|
|
341
|
+
if (!showSearch || !searchOpen) {
|
|
232
342
|
return
|
|
233
343
|
}
|
|
234
344
|
|
|
235
345
|
document.getElementById('store-search-mobile')?.focus()
|
|
236
|
-
}, [searchOpen])
|
|
346
|
+
}, [searchOpen, showSearch])
|
|
237
347
|
|
|
238
348
|
const dismissSearch = () => {
|
|
239
349
|
setSearchOpen(false)
|
|
240
350
|
searchTriggerRef.current?.focus()
|
|
241
351
|
}
|
|
242
352
|
|
|
353
|
+
// When the consumer overrides the header surface via `className`, the nav
|
|
354
|
+
// links inherit its text color and need an explicit hover foreground so
|
|
355
|
+
// `hover:bg-muted` never paints white-on-light; otherwise they keep the
|
|
356
|
+
// default foreground (and the default hover surface).
|
|
357
|
+
const navLinkColor =
|
|
358
|
+
className !== undefined ? 'text-inherit hover:text-foreground' : 'text-foreground'
|
|
359
|
+
// The current page is marked beyond color: an accessible current state plus a
|
|
360
|
+
// visible weight/underline, and nothing when no destination matches.
|
|
361
|
+
const isCurrent = (href?: string) => href !== undefined && href === currentPath
|
|
362
|
+
|
|
363
|
+
// The desktop utility strip is decorative chrome: it is painted only when it
|
|
364
|
+
// has content (a non-empty note or at least one utility link), never empty.
|
|
365
|
+
const hasUtilityContent = shippingNote.trim().length > 0 || utilityLinks.length > 0
|
|
366
|
+
|
|
243
367
|
return (
|
|
244
|
-
<header
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
368
|
+
<header
|
|
369
|
+
className={cn(
|
|
370
|
+
'border-border sticky top-0 z-40 w-full border-b backdrop-blur-md',
|
|
371
|
+
className ?? 'bg-background/95 supports-backdrop-filter:bg-background/80',
|
|
372
|
+
)}
|
|
373
|
+
>
|
|
374
|
+
{hasUtilityContent ? (
|
|
375
|
+
<div className='bg-foreground text-background hidden lg:block'>
|
|
376
|
+
<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'>
|
|
377
|
+
<p className='text-background/80'>{shippingNote}</p>
|
|
378
|
+
<ul className='flex items-center gap-5'>
|
|
379
|
+
{utilityLinks.map(link => (
|
|
380
|
+
<li key={`${link.href}:${link.label}`}>
|
|
381
|
+
<a
|
|
382
|
+
href={link.href}
|
|
383
|
+
className='text-background/80 hover:text-background transition-colors duration-150'
|
|
384
|
+
>
|
|
385
|
+
{link.label}
|
|
386
|
+
</a>
|
|
387
|
+
</li>
|
|
388
|
+
))}
|
|
389
|
+
</ul>
|
|
390
|
+
</div>
|
|
260
391
|
</div>
|
|
261
|
-
|
|
392
|
+
) : null}
|
|
262
393
|
|
|
263
394
|
<div className='mx-auto flex h-16 w-full max-w-7xl items-center gap-3 px-4 sm:px-6 lg:px-8'>
|
|
264
395
|
<Button
|
|
@@ -285,7 +416,7 @@ export function Navbar4({
|
|
|
285
416
|
/>
|
|
286
417
|
|
|
287
418
|
<div className='flex-1 lg:flex-none'>
|
|
288
|
-
<Logo text={logoText} href={logoHref} />
|
|
419
|
+
<Logo text={logoText} href={logoHref} accent={logoAccent} />
|
|
289
420
|
</div>
|
|
290
421
|
|
|
291
422
|
<nav aria-label='Store' className='hidden flex-1 lg:block'>
|
|
@@ -295,9 +426,11 @@ export function Navbar4({
|
|
|
295
426
|
<li key={category.href} className='group relative'>
|
|
296
427
|
<a
|
|
297
428
|
href={category.href}
|
|
429
|
+
aria-current={isCurrent(category.href) ? 'page' : undefined}
|
|
298
430
|
className={cn(
|
|
299
431
|
'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',
|
|
300
|
-
category.emphasis ? 'text-destructive' :
|
|
432
|
+
category.emphasis ? 'text-destructive' : navLinkColor,
|
|
433
|
+
isCurrent(category.href) && 'font-semibold underline underline-offset-4',
|
|
301
434
|
)}
|
|
302
435
|
>
|
|
303
436
|
{category.label}
|
|
@@ -315,9 +448,11 @@ export function Navbar4({
|
|
|
315
448
|
<li key={category.href}>
|
|
316
449
|
<a
|
|
317
450
|
href={category.href}
|
|
451
|
+
aria-current={isCurrent(category.href) ? 'page' : undefined}
|
|
318
452
|
className={cn(
|
|
319
453
|
'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',
|
|
320
|
-
category.emphasis ? 'text-destructive' :
|
|
454
|
+
category.emphasis ? 'text-destructive' : navLinkColor,
|
|
455
|
+
isCurrent(category.href) && 'font-semibold underline underline-offset-4',
|
|
321
456
|
)}
|
|
322
457
|
>
|
|
323
458
|
{category.label}
|
|
@@ -329,38 +464,67 @@ export function Navbar4({
|
|
|
329
464
|
</nav>
|
|
330
465
|
|
|
331
466
|
<div className='flex items-center gap-1.5'>
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
467
|
+
{showSearch ? (
|
|
468
|
+
<>
|
|
469
|
+
<div className='hidden lg:block lg:w-64'>
|
|
470
|
+
<Navbar4SearchBar
|
|
471
|
+
id='store-search'
|
|
472
|
+
value={searchQuery}
|
|
473
|
+
onValueChange={setSearchQuery}
|
|
474
|
+
/>
|
|
475
|
+
</div>
|
|
339
476
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
{user ? (
|
|
353
|
-
<div className='hidden lg:block'>
|
|
354
|
-
<Navbar4AccountMenu
|
|
355
|
-
user={user}
|
|
356
|
-
items={accountMenuItems}
|
|
357
|
-
onHelp={onHelp}
|
|
358
|
-
onSignOut={onSignOut}
|
|
359
|
-
/>
|
|
360
|
-
</div>
|
|
477
|
+
<Button
|
|
478
|
+
ref={searchTriggerRef}
|
|
479
|
+
variant='ghost'
|
|
480
|
+
size='icon'
|
|
481
|
+
className='lg:hidden'
|
|
482
|
+
aria-label='Search products'
|
|
483
|
+
aria-expanded={searchOpen}
|
|
484
|
+
onClick={() => setSearchOpen(open => !open)}
|
|
485
|
+
>
|
|
486
|
+
{searchOpen ? <X /> : <Search />}
|
|
487
|
+
</Button>
|
|
488
|
+
</>
|
|
361
489
|
) : null}
|
|
362
490
|
|
|
363
|
-
|
|
491
|
+
{accountUnavailable ? (
|
|
492
|
+
<Button
|
|
493
|
+
variant='ghost'
|
|
494
|
+
size='icon'
|
|
495
|
+
className='size-11'
|
|
496
|
+
disabled={accountPending || !onAccountRetry}
|
|
497
|
+
aria-label='Retry account session'
|
|
498
|
+
title='Account session unavailable'
|
|
499
|
+
onClick={onAccountRetry}
|
|
500
|
+
>
|
|
501
|
+
<RotateCcw aria-hidden='true' />
|
|
502
|
+
</Button>
|
|
503
|
+
) : user ? (
|
|
504
|
+
<Navbar4AccountMenu
|
|
505
|
+
user={user}
|
|
506
|
+
items={accountMenuItems}
|
|
507
|
+
onHelp={onHelp}
|
|
508
|
+
onSignOut={onSignOut}
|
|
509
|
+
pending={accountPending}
|
|
510
|
+
/>
|
|
511
|
+
) : accountHref ? (
|
|
512
|
+
accountPending ? (
|
|
513
|
+
<Button variant='ghost' size='icon' className='size-11' disabled aria-label='Loading account session'>
|
|
514
|
+
<UserRound aria-hidden='true' />
|
|
515
|
+
</Button>
|
|
516
|
+
) : (
|
|
517
|
+
<Button variant='ghost' size='icon' className='size-11' asChild>
|
|
518
|
+
<a href={accountHref} aria-label='Account' aria-current={isCurrent(accountHref) ? 'page' : undefined}>
|
|
519
|
+
<UserRound aria-hidden='true' />
|
|
520
|
+
</a>
|
|
521
|
+
</Button>
|
|
522
|
+
)
|
|
523
|
+
) : null}
|
|
524
|
+
|
|
525
|
+
<Sheet
|
|
526
|
+
{...(cartOpen !== undefined ? { open: cartOpen, onOpenChange: onCartOpenChange } : {})}
|
|
527
|
+
>
|
|
364
528
|
<SheetTrigger asChild>
|
|
365
529
|
<Button
|
|
366
530
|
variant='ghost'
|
|
@@ -382,8 +546,19 @@ export function Navbar4({
|
|
|
382
546
|
items={items}
|
|
383
547
|
onQtyChange={changeQty}
|
|
384
548
|
onRemove={removeItem}
|
|
549
|
+
subtotal={subtotal}
|
|
550
|
+
subtotalUnavailableLabel={subtotalUnavailableLabel}
|
|
551
|
+
formatMoney={formatMoney}
|
|
385
552
|
onCheckout={onCheckout}
|
|
386
553
|
onViewCart={onViewCart}
|
|
554
|
+
canonical={canonicalCart}
|
|
555
|
+
state={cartState}
|
|
556
|
+
errorLabel={cartErrorLabel}
|
|
557
|
+
retryLabel={cartRetryLabel}
|
|
558
|
+
onRetry={onCartRetry}
|
|
559
|
+
checkoutUnavailable={checkoutUnavailable}
|
|
560
|
+
checkoutDisabled={checkoutDisabled}
|
|
561
|
+
viewCartUnavailable={viewCartUnavailable}
|
|
387
562
|
/>
|
|
388
563
|
</SheetContent>
|
|
389
564
|
</Sheet>
|
|
@@ -394,7 +569,7 @@ export function Navbar4({
|
|
|
394
569
|
{`Cart contains ${count} ${count === 1 ? 'item' : 'items'}`}
|
|
395
570
|
</p>
|
|
396
571
|
|
|
397
|
-
{searchOpen ? (
|
|
572
|
+
{showSearch && searchOpen ? (
|
|
398
573
|
<div className='border-border flex flex-col gap-0 border-t p-4 lg:hidden'>
|
|
399
574
|
<Navbar4SearchBar
|
|
400
575
|
id='store-search-mobile'
|
|
@@ -408,4 +583,4 @@ export function Navbar4({
|
|
|
408
583
|
)
|
|
409
584
|
}
|
|
410
585
|
|
|
411
|
-
export default Navbar4
|
|
586
|
+
export default Navbar4
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
1
3
|
export { cn } from './lib/utils'
|
|
2
4
|
export type { CTA } from './lib/types'
|
|
3
5
|
|
|
@@ -54,7 +56,19 @@ export { default as ProductCard1 } from './blocks/e-commerce/product-list/produc
|
|
|
54
56
|
export { default as ProductCard2 } from './blocks/e-commerce/product-list/product-card-2'
|
|
55
57
|
export { default as ProductCard3 } from './blocks/e-commerce/product-list/product-card-3'
|
|
56
58
|
export { default as ProductCard5 } from './blocks/e-commerce/product-list/product-card-5'
|
|
59
|
+
export { default as ProductShowcase1 } from './blocks/e-commerce/product-list/product-showcase-1'
|
|
60
|
+
export type {
|
|
61
|
+
ProductCard1Product,
|
|
62
|
+
ProductCard1Props,
|
|
63
|
+
} from './blocks/e-commerce/product-list/product-card-1'
|
|
64
|
+
export type { ProductShowcase1Props } from './blocks/e-commerce/product-list/product-showcase-1'
|
|
57
65
|
export { default as ProductOverview1 } from './blocks/e-commerce/product-overview/product-overview-1'
|
|
66
|
+
export type {
|
|
67
|
+
ProductOverview1Props,
|
|
68
|
+
ProductOverview1Rating,
|
|
69
|
+
ProductDetailProduct,
|
|
70
|
+
ProductDetailVariant,
|
|
71
|
+
} from './blocks/e-commerce/product-overview/product-overview-1'
|
|
58
72
|
export { default as ProductOverview2 } from './blocks/e-commerce/product-overview/product-overview-2'
|
|
59
73
|
export { default as ProductOverview5 } from './blocks/e-commerce/product-overview/product-overview-5'
|
|
60
74
|
export type { ProductOverview2Props, ProductOverview2Product } from './blocks/e-commerce/product-overview/product-overview-2'
|
|
@@ -79,18 +93,38 @@ export { MobileCategoryDrawer, mobileCategoryDrawerPropsSchema } from './blocks/
|
|
|
79
93
|
export type { MobileCategoryDrawerProps } from './blocks/e-commerce/navigation/mobile-category-drawer'
|
|
80
94
|
export { CategoryIcon, categoryIconNameSchema, categoryIconPropsSchema } from './blocks/e-commerce/navigation/category-icon'
|
|
81
95
|
export type { CategoryIconName, CategoryIconProps } from './blocks/e-commerce/navigation/category-icon'
|
|
96
|
+
export { Breadcrumb, breadcrumbItemSchema, breadcrumbPropsSchema } from './blocks/e-commerce/navigation/breadcrumb'
|
|
97
|
+
export type { BreadcrumbItemData, BreadcrumbProps } from './blocks/e-commerce/navigation/breadcrumb'
|
|
98
|
+
export { SkipToContent, skipToContentPropsSchema } from './blocks/e-commerce/navigation/skip-to-content'
|
|
99
|
+
export type { SkipToContentProps } from './blocks/e-commerce/navigation/skip-to-content'
|
|
100
|
+
export { BackToTop, backToTopPropsSchema, resolveScrollBehavior } from './blocks/e-commerce/navigation/back-to-top'
|
|
101
|
+
export type { BackToTopProps } from './blocks/e-commerce/navigation/back-to-top'
|
|
102
|
+
export { MobileBottomNav, mobileBottomNavItemSchema, mobileBottomNavPropsSchema } from './blocks/e-commerce/navigation/mobile-bottom-nav'
|
|
103
|
+
export type { MobileBottomNavItem, MobileBottomNavProps } from './blocks/e-commerce/navigation/mobile-bottom-nav'
|
|
82
104
|
export { menuItemSchema, collectionItemSchema } from './lib/types/menu'
|
|
83
105
|
export type { MenuItem, CollectionItem } from './lib/types/menu'
|
|
84
106
|
export { default as CategoryFilter1 } from './blocks/e-commerce/category-filters/category-filter-1'
|
|
107
|
+
export type {
|
|
108
|
+
CategoryFilter1Category,
|
|
109
|
+
CategoryFilter1PriceRange,
|
|
110
|
+
CategoryFilter1SortOption,
|
|
111
|
+
} from './blocks/e-commerce/category-filters/category-filter-1'
|
|
85
112
|
export { default as CategoryFilter2 } from './blocks/e-commerce/category-filters/category-filter-2'
|
|
113
|
+
export { CategoryFilter2StatusView } from './blocks/e-commerce/category-filters/category-filter-2'
|
|
86
114
|
export type {
|
|
87
115
|
FilterProduct,
|
|
88
116
|
CategoryFilter2Props,
|
|
89
117
|
CategoryFilter2Category,
|
|
118
|
+
CategoryFilter2Collection,
|
|
90
119
|
CategoryFilter2Color,
|
|
91
120
|
CategoryFilter2PriceRange,
|
|
92
121
|
CategoryFilter2SortOption,
|
|
93
122
|
CategoryFilter2Filters,
|
|
123
|
+
CategoryFilter2Status,
|
|
124
|
+
CategoryFilter2StatusViewProps,
|
|
125
|
+
CategoryFilter2QueryError,
|
|
126
|
+
CategoryFilter2ActiveFilter,
|
|
127
|
+
CategoryFilter2ActiveFilterKind,
|
|
94
128
|
} from './blocks/e-commerce/category-filters/category-filter-2'
|
|
95
129
|
export { default as CategoryFilter4 } from './blocks/e-commerce/category-filters/category-filter-4'
|
|
96
130
|
export type { CategoryFilter4Props, CategoryFilter4Product, CategoryFilter4Facets } from './blocks/e-commerce/category-filters/category-filter-4'
|
|
@@ -116,6 +150,9 @@ export type {
|
|
|
116
150
|
CheckoutForm1OrderSummary,
|
|
117
151
|
CheckoutForm1OrderSummaryItem,
|
|
118
152
|
CheckoutForm1CountryOption,
|
|
153
|
+
CheckoutForm1ShippingOption,
|
|
154
|
+
CheckoutForm1Totals,
|
|
155
|
+
CheckoutForm1TotalsState,
|
|
119
156
|
} from './blocks/e-commerce/checkout-forms/checkout-form-1'
|
|
120
157
|
export type {
|
|
121
158
|
CheckoutForm2Props,
|
|
@@ -168,9 +205,22 @@ export type { Verify1Props } from './blocks/application/verification/verify-1'
|
|
|
168
205
|
export type { CanonMoney, CanonProduct } from './lib/contracts'
|
|
169
206
|
export { PRODUCT_IMAGE_FALLBACK } from './lib/contracts'
|
|
170
207
|
export {
|
|
208
|
+
isCompleteFiniteDecimal,
|
|
209
|
+
isAvailableMoney,
|
|
210
|
+
formatMoney,
|
|
211
|
+
} from './lib/money'
|
|
212
|
+
export { resolveAbsoluteQuantity } from './lib/cart'
|
|
213
|
+
export {
|
|
214
|
+
moneySchema,
|
|
215
|
+
cartTotalsSchema,
|
|
216
|
+
productDetailProductSchema,
|
|
217
|
+
productDetailVariantSchema,
|
|
218
|
+
productOverview1PropsSchema,
|
|
171
219
|
buttonPropsSchema,
|
|
172
220
|
storeContainer1PropsSchema,
|
|
173
221
|
productCard1PropsSchema,
|
|
222
|
+
productPresentationSchema,
|
|
223
|
+
productShowcase1PropsSchema,
|
|
174
224
|
shoppingCart4PropsSchema,
|
|
175
225
|
navbar4PropsSchema,
|
|
176
226
|
paymentMethodSchema,
|
|
@@ -184,9 +234,15 @@ export {
|
|
|
184
234
|
accountMenuItemSchema,
|
|
185
235
|
} from './lib/contracts'
|
|
186
236
|
export type {
|
|
237
|
+
ProductDetailProductSpec,
|
|
238
|
+
ProductDetailVariantSpec,
|
|
239
|
+
ProductOverview1SpecProps,
|
|
240
|
+
CartTotals,
|
|
187
241
|
ButtonSpecProps,
|
|
188
242
|
StoreContainer1SpecProps,
|
|
189
243
|
ProductCard1SpecProps,
|
|
244
|
+
ProductPresentation,
|
|
245
|
+
ProductShowcase1SpecProps,
|
|
190
246
|
ShoppingCart4SpecProps,
|
|
191
247
|
Navbar4SpecProps,
|
|
192
248
|
CardProduct,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { resolveAbsoluteQuantity } from './cart'
|
|
3
|
+
|
|
4
|
+
describe('resolveAbsoluteQuantity', () => {
|
|
5
|
+
it('converts a Navbar DELTA into an ABSOLUTE quantity against the authoritative current quantity', () => {
|
|
6
|
+
expect(resolveAbsoluteQuantity(3, -1)).toBe(2)
|
|
7
|
+
expect(resolveAbsoluteQuantity(3, 1)).toBe(4)
|
|
8
|
+
expect(resolveAbsoluteQuantity(1, 1)).toBe(2)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('never drops below one — removal is a distinct callback, not a zero quantity', () => {
|
|
12
|
+
expect(resolveAbsoluteQuantity(1, -1)).toBe(1)
|
|
13
|
+
expect(resolveAbsoluteQuantity(0, -1)).toBe(1)
|
|
14
|
+
})
|
|
15
|
+
})
|
package/src/lib/cart.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cart line quantity normalization shared by the curated cart surfaces.
|
|
3
|
+
*
|
|
4
|
+
* The Navbar mini-cart expresses a quantity DELTA while the cart page reports
|
|
5
|
+
* an ABSOLUTE quantity. The boundary normalizes the delta against the
|
|
6
|
+
* authoritative current line quantity (from the backend cart) before mutating;
|
|
7
|
+
* it must never reinterpret one as the other.
|
|
8
|
+
*/
|
|
9
|
+
export function resolveAbsoluteQuantity(currentQuantity: number, delta: number): number {
|
|
10
|
+
return Math.max(1, currentQuantity + delta)
|
|
11
|
+
}
|