@thorprovider/blocks 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +156 -40
  3. package/package.json +1 -1
  4. package/src/blocks/application/login-forms/login-page.test.tsx +190 -0
  5. package/src/blocks/application/login-forms/login-page.tsx +240 -0
  6. package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +6 -8
  7. package/src/blocks/application/signup-forms/signup-page-1.tsx +127 -129
  8. package/src/blocks/application/verification/verify-1.tsx +2 -2
  9. package/src/blocks/application/viewport-ownership.test.tsx +41 -0
  10. package/src/blocks/e-commerce/category-filters/category-listing-filter-fields.tsx +218 -0
  11. package/src/blocks/e-commerce/category-filters/{category-filter-2.test.tsx → category-listing.test.tsx} +287 -199
  12. package/src/blocks/e-commerce/category-filters/category-listing.tsx +934 -0
  13. package/src/blocks/e-commerce/checkout-forms/checkout-contact-step.tsx +149 -0
  14. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-country-field.tsx → checkout-country-field.tsx} +10 -8
  15. package/src/blocks/e-commerce/checkout-forms/checkout-order-summary.tsx +230 -0
  16. package/src/blocks/e-commerce/checkout-forms/checkout-payment-step.tsx +100 -0
  17. package/src/blocks/e-commerce/checkout-forms/checkout-progress.tsx +62 -0
  18. package/src/blocks/e-commerce/checkout-forms/checkout-review-step.tsx +59 -0
  19. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-shipping-step.tsx → checkout-shipping-step.tsx} +65 -41
  20. package/src/blocks/e-commerce/checkout-forms/checkout.test.tsx +478 -0
  21. package/src/blocks/e-commerce/checkout-forms/checkout.tsx +550 -0
  22. package/src/blocks/{marketing/navbars/navbar-4-account-menu.test.tsx → e-commerce/navigation/storefront-navbar-account-menu.test.tsx} +8 -8
  23. package/src/blocks/{marketing/navbars/navbar-4-account-menu.tsx → e-commerce/navigation/storefront-navbar-account-menu.tsx} +3 -3
  24. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.test.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.test.tsx} +22 -22
  25. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.tsx} +3 -3
  26. package/src/blocks/{marketing/navbars/navbar-4-mobile-nav.tsx → e-commerce/navigation/storefront-navbar-mobile-nav.tsx} +4 -12
  27. package/src/blocks/{marketing/navbars/navbar-4-search-bar.tsx → e-commerce/navigation/storefront-navbar-search-bar.tsx} +3 -3
  28. package/src/blocks/{marketing/navbars/navbar-4.test.tsx → e-commerce/navigation/storefront-navbar.test.tsx} +40 -40
  29. package/src/blocks/{marketing/navbars/navbar-4.tsx → e-commerce/navigation/storefront-navbar.tsx} +17 -17
  30. package/src/blocks/e-commerce/product-list/product-card.tsx +63 -40
  31. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +2 -2
  32. package/src/blocks/marketing/footers/footer-section-accessibility.test.tsx +118 -0
  33. package/src/blocks/marketing/footers/footer-section-actions.test.tsx +131 -0
  34. package/src/blocks/marketing/footers/footer-section-data.test.tsx +127 -0
  35. package/src/blocks/marketing/footers/footer-section-native-links.test.tsx +100 -0
  36. package/src/blocks/marketing/footers/footer-section-public-api.test.tsx +11 -0
  37. package/src/blocks/marketing/footers/footer-section.test.tsx +46 -0
  38. package/src/blocks/marketing/footers/footer-section.tsx +532 -0
  39. package/src/blocks/marketing/navbars/navbar-public-api.test.tsx +57 -0
  40. package/src/blocks/marketing/navbars/navbar.mega.test.tsx +149 -0
  41. package/src/blocks/marketing/navbars/navbar.test.tsx +155 -0
  42. package/src/blocks/marketing/navbars/navbar.tsx +735 -0
  43. package/src/blocks/marketing/testimonials/testimonial-section.test.tsx +171 -0
  44. package/src/blocks/marketing/testimonials/testimonial-section.tsx +237 -0
  45. package/src/index.ts +80 -81
  46. package/src/lib/contracts-curated.test.ts +5 -5
  47. package/src/lib/contracts.test.ts +55 -0
  48. package/src/lib/contracts.ts +64 -2
  49. package/tsup.config.ts +1 -1
  50. package/src/blocks/application/login-forms/login-page-1.tsx +0 -246
  51. package/src/blocks/application/login-forms/login-page-2.tsx +0 -207
  52. package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +0 -260
  53. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +0 -222
  54. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +0 -921
  55. package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +0 -57
  56. package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +0 -124
  57. package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +0 -170
  58. package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +0 -52
  59. package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +0 -57
  60. package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +0 -68
  61. package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +0 -298
  62. package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +0 -47
  63. package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +0 -9
  64. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +0 -96
  65. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +0 -252
  66. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +0 -188
  67. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +0 -36
  68. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +0 -216
  69. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +0 -338
  70. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +0 -130
  71. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +0 -175
  72. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +0 -190
  73. package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +0 -312
  74. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +0 -91
  75. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +0 -110
  76. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +0 -137
  77. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +0 -68
  78. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +0 -79
  79. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +0 -172
  80. package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +0 -392
  81. package/src/blocks/marketing/footers/footer-section-1.test.tsx +0 -117
  82. package/src/blocks/marketing/footers/footer-section-1.tsx +0 -178
  83. package/src/blocks/marketing/footers/footer-section-3.tsx +0 -192
  84. package/src/blocks/marketing/footers/footer-section-4.tsx +0 -355
  85. package/src/blocks/marketing/navbars/navbar-1.tsx +0 -267
  86. package/src/blocks/marketing/navbars/navbar-2.tsx +0 -351
  87. package/src/blocks/marketing/navbars/navbar-3.tsx +0 -303
  88. package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +0 -177
  89. package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +0 -219
  90. package/src/blocks/marketing/testimonials/testimonial-1.tsx +0 -141
  91. package/src/blocks/marketing/testimonials/testimonial-2.tsx +0 -108
@@ -0,0 +1,735 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState, type ComponentProps, type ComponentType } from 'react'
4
+ import { ArrowRight, Menu } from 'lucide-react'
5
+
6
+ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../../../ui/accordion'
7
+ import { Button } from '../../../ui/button'
8
+ import {
9
+ NavigationMenu,
10
+ NavigationMenuContent,
11
+ NavigationMenuItem,
12
+ NavigationMenuLink,
13
+ NavigationMenuList,
14
+ NavigationMenuTrigger,
15
+ navigationMenuTriggerStyle,
16
+ } from '../../../ui/navigation-menu'
17
+ import { Separator } from '../../../ui/separator'
18
+ import {
19
+ Sheet,
20
+ SheetClose,
21
+ SheetContent,
22
+ SheetHeader,
23
+ SheetTitle,
24
+ SheetTrigger,
25
+ } from '../../../ui/sheet'
26
+ import { cn } from '../../../lib/utils'
27
+ import type { CTA } from '../../../lib/types'
28
+
29
+ /** Structural presentations of the page-level header. */
30
+ export type NavbarPresentation = 'bar' | 'mega' | 'floating'
31
+
32
+ /** Icon component bound by the host at render time. */
33
+ export type NavbarIcon = ComponentType<{ className?: string }>
34
+
35
+ /** Maps a serializable icon key to a host-bound icon component. */
36
+ export type NavbarIconMap = Record<string, NavbarIcon>
37
+
38
+ export interface NavbarItem {
39
+ label: string
40
+ href: string
41
+ description?: string
42
+ /** Key resolved through the consumer's `iconMap`. Omit for entries without an icon. */
43
+ icon?: string
44
+ }
45
+
46
+ export interface NavbarGroup {
47
+ /** Optional column heading. */
48
+ title?: string
49
+ items: NavbarItem[]
50
+ }
51
+
52
+ export interface NavbarLink {
53
+ label: string
54
+ /**
55
+ * Destination for a plain link. Ignored when `groups` is present, because a grouped
56
+ * entry opens a flyout instead of navigating.
57
+ */
58
+ href?: string
59
+ /** Grouped entries rendered as a multi-column flyout (and an accordion on mobile). */
60
+ groups?: NavbarGroup[]
61
+ /** Renders the link with extra prominence in the flat presentations. */
62
+ emphasis?: boolean
63
+ }
64
+
65
+ export interface NavbarFeatured {
66
+ eyebrow: string
67
+ title: string
68
+ description?: string
69
+ href: string
70
+ linkLabel?: string
71
+ image?: string
72
+ imageAlt?: string
73
+ }
74
+
75
+ export interface NavbarProps {
76
+ /** Structural presentation. Omit for the static `bar`. */
77
+ presentation?: NavbarPresentation
78
+ /** Brand text shown next to the logo mark. */
79
+ logoText: string
80
+ logoHref?: string
81
+ /** Current location, used to indicate the active entry. */
82
+ currentPath?: string
83
+ links: NavbarLink[]
84
+ /** Promo card shown beside grouped flyout entries. */
85
+ featured?: NavbarFeatured
86
+ /** Sign-in action. Rendered as a link when `href` is set, otherwise as a button. */
87
+ signIn?: CTA
88
+ /** Primary call to action. Rendered as a link when `href` is set, otherwise as a button. */
89
+ cta?: CTA
90
+ /** Host-bound icons for grouped entries that declare an `icon` key. */
91
+ iconMap?: NavbarIconMap
92
+ /** Fired when the sign-in action has no `href`. */
93
+ onSignIn?: () => void
94
+ /** Fired when the primary call to action has no `href`. */
95
+ onCtaClick?: () => void
96
+ /** Classes merged onto the header surface, so a brand can restyle it. */
97
+ className?: string
98
+ }
99
+
100
+ const FOCUS_RING =
101
+ 'focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none'
102
+
103
+ function Logo({ text, href }: { text: string; href?: string }) {
104
+ const className = cn(
105
+ FOCUS_RING,
106
+ 'flex items-center gap-2 rounded-sm focus-visible:ring-offset-2'
107
+ )
108
+
109
+ const content = (
110
+ <>
111
+ <svg viewBox='0 0 32 32' aria-hidden='true' className='text-primary size-7' fill='none' xmlns='http://www.w3.org/2000/svg'>
112
+ <path
113
+ d='M26 24.75C26.4142 24.75 26.75 24.4142 26.75 24C26.75 23.5858 26.4142 23.25 26 23.25V24.75ZM26 23.25H11V24.75H26V23.25ZM8.75 21V15H7.25V21H8.75ZM11 23.25C9.75736 23.25 8.75 22.2426 8.75 21H7.25C7.25 23.0711 8.92893 24.75 11 24.75V23.25Z'
114
+ fill='currentColor'
115
+ />
116
+ <path
117
+ d='M1.5 3.25C1.08579 3.25 0.75 3.58579 0.75 4C0.75 4.41421 1.08579 4.75 1.5 4.75V3.25ZM1.5 4.75H6V3.25H1.5V4.75ZM7.25 6V21H8.75V6H7.25ZM6 4.75C6.69036 4.75 7.25 5.30964 7.25 6H8.75C8.75 4.48122 7.51878 3.25 6 3.25V4.75Z'
118
+ fill='currentColor'
119
+ />
120
+ <path
121
+ d='M22 21.75C22.4142 21.75 22.75 21.4142 22.75 21C22.75 20.5858 22.4142 20.25 22 20.25V21.75ZM22 20.25H11V21.75H22V20.25ZM8.75 18V12H7.25V18H8.75ZM11 20.25C9.75736 20.25 8.75 19.2426 8.75 18H7.25C7.25 20.0711 8.92893 21.75 11 21.75V20.25Z'
122
+ fill='currentColor'
123
+ />
124
+ <path
125
+ d='M27.2057 19.754C27.0654 20.1438 26.6357 20.346 26.246 20.2057C25.8562 20.0654 25.654 19.6357 25.7943 19.246L27.2057 19.754ZM30.0361 9.67744L29.3305 9.4234L29.3305 9.4234L30.0361 9.67744ZM25.7943 19.246L29.3305 9.4234L30.7418 9.93148L27.2057 19.754L25.7943 19.246ZM28.1543 7.75L8 7.75V6.25L28.1543 6.25V7.75ZM29.3305 9.4234C29.6237 8.60882 29.0201 7.75 28.1543 7.75V6.25C30.059 6.25 31.3869 8.13941 30.7418 9.93148L29.3305 9.4234Z'
126
+ fill='currentColor'
127
+ />
128
+ <path
129
+ d='M13.5 21.75C13.0858 21.75 12.75 21.4142 12.75 21C12.75 20.5858 13.0858 20.25 13.5 20.25V21.75ZM26.7111 19.009L27.4174 19.2613L27.4174 19.2613L26.7111 19.009ZM13.5 20.25H23.8858V21.75H13.5V20.25ZM26.0048 18.7568L27.7937 13.7477L29.2063 14.2523L27.4174 19.2613L26.0048 18.7568ZM23.8858 20.25C24.8367 20.25 25.6849 19.6522 26.0048 18.7568L27.4174 19.2613C26.8843 20.7537 25.4706 21.75 23.8858 21.75V20.25Z'
130
+ fill='currentColor'
131
+ />
132
+ <path d='M21.1694 10.5806L14.5651 17.1849' stroke='currentColor' strokeWidth='1.5' strokeLinecap='round' />
133
+ <path d='M22.1694 14.5806L18.5632 18.1868' stroke='currentColor' strokeWidth='1.5' strokeLinecap='round' />
134
+ <circle cx='13.1' cy='26.1' r='1.7' stroke='currentColor' strokeWidth='1.2' />
135
+ <circle cx='22.1' cy='26.1' r='1.7' stroke='currentColor' strokeWidth='1.2' />
136
+ </svg>
137
+ <span className='text-base font-semibold tracking-tight'>{text}</span>
138
+ </>
139
+ )
140
+
141
+ if (!href) {
142
+ return <span className={className}>{content}</span>
143
+ }
144
+
145
+ return (
146
+ <a href={href} className={className}>
147
+ {content}
148
+ </a>
149
+ )
150
+ }
151
+
152
+ function isCurrent(href: string | undefined, currentPath: string | undefined) {
153
+ return href !== undefined && href === currentPath
154
+ }
155
+
156
+ /** Non-color cue so the active entry does not rely on color alone. */
157
+ const CURRENT_CUE = 'font-semibold underline decoration-2 underline-offset-8'
158
+
159
+ function ActionButton({
160
+ action,
161
+ onClick,
162
+ variant,
163
+ className,
164
+ rounded,
165
+ }: {
166
+ action: CTA
167
+ onClick?: () => void
168
+ variant?: ComponentProps<typeof Button>['variant']
169
+ className?: string
170
+ rounded?: boolean
171
+ }) {
172
+ const classes = cn(rounded && 'rounded-full', className)
173
+
174
+ if (action.href && !onClick) {
175
+ return (
176
+ <Button variant={variant} size='sm' className={classes} asChild>
177
+ <a href={action.href}>{action.label}</a>
178
+ </Button>
179
+ )
180
+ }
181
+
182
+ return (
183
+ <Button variant={variant} size='sm' className={classes} onClick={onClick}>
184
+ {action.label}
185
+ </Button>
186
+ )
187
+ }
188
+
189
+ function EntryIcon({ name, iconMap }: { name?: string; iconMap?: NavbarIconMap }) {
190
+ if (!name) {
191
+ return null
192
+ }
193
+
194
+ const Icon = iconMap?.[name]
195
+ if (!Icon) {
196
+ return null
197
+ }
198
+
199
+ return (
200
+ <span
201
+ aria-hidden='true'
202
+ className='bg-muted text-foreground flex size-8 shrink-0 items-center justify-center rounded-md'
203
+ >
204
+ <Icon className='size-4' />
205
+ </span>
206
+ )
207
+ }
208
+
209
+ function FeaturedCard({ featured }: { featured: NavbarFeatured }) {
210
+ return (
211
+ <a
212
+ href={featured.href}
213
+ className={cn(
214
+ 'border-border group relative flex h-full min-h-56 flex-col justify-end overflow-hidden rounded-md border',
215
+ FOCUS_RING
216
+ )}
217
+ >
218
+ {featured.image ? (
219
+ <img
220
+ src={featured.image}
221
+ alt={featured.imageAlt ?? ''}
222
+ className='absolute inset-0 size-full object-cover transition-transform duration-500 ease-out group-hover:scale-105'
223
+ />
224
+ ) : null}
225
+ {/* Token-driven scrim: follows the consumer theme instead of a hardcoded dark overlay. */}
226
+ <div
227
+ aria-hidden='true'
228
+ className='from-background via-background/70 absolute inset-0 bg-gradient-to-t to-transparent'
229
+ />
230
+ <div className='relative z-10 flex flex-col p-4'>
231
+ <p className='text-muted-foreground text-xs font-semibold tracking-wide uppercase'>
232
+ {featured.eyebrow}
233
+ </p>
234
+ <p className='text-foreground mt-1 text-sm font-medium'>{featured.title}</p>
235
+ {featured.description ? (
236
+ <p className='text-muted-foreground mt-1 text-sm'>{featured.description}</p>
237
+ ) : null}
238
+ {featured.linkLabel ? (
239
+ <span className='text-foreground mt-3 inline-flex items-center gap-1 text-sm font-medium'>
240
+ {featured.linkLabel}
241
+ <ArrowRight className='size-3.5 transition-transform duration-200 ease-out group-hover:translate-x-0.5' />
242
+ </span>
243
+ ) : null}
244
+ </div>
245
+ </a>
246
+ )
247
+ }
248
+
249
+ function MegaEntry({
250
+ link,
251
+ featured,
252
+ currentPath,
253
+ iconMap,
254
+ }: {
255
+ link: NavbarLink
256
+ featured?: NavbarFeatured
257
+ currentPath?: string
258
+ iconMap?: NavbarIconMap
259
+ }) {
260
+ const groups = link.groups ?? []
261
+ const hasCurrentChild = groups.some(group =>
262
+ group.items.some(item => isCurrent(item.href, currentPath))
263
+ )
264
+
265
+ return (
266
+ <NavigationMenuItem>
267
+ <NavigationMenuTrigger className={cn(hasCurrentChild && CURRENT_CUE)}>
268
+ {link.label}
269
+ </NavigationMenuTrigger>
270
+ <NavigationMenuContent>
271
+ <div
272
+ className={cn(
273
+ 'grid gap-3 p-2',
274
+ featured
275
+ ? 'w-[760px] grid-cols-[1fr_248px]'
276
+ : groups.length > 1
277
+ ? 'w-[760px] grid-cols-2'
278
+ : 'w-[360px]'
279
+ )}
280
+ >
281
+ <div className={cn('grid gap-3', groups.length > 1 && 'grid-cols-2')}>
282
+ {groups.map((group, groupIndex) => (
283
+ <div key={group.title ?? `group-${groupIndex}`}>
284
+ {group.title ? (
285
+ <p className='text-muted-foreground px-3 pt-1 pb-1 text-xs font-semibold tracking-wide uppercase'>
286
+ {group.title}
287
+ </p>
288
+ ) : null}
289
+ <ul className='grid gap-1'>
290
+ {group.items.map(item => {
291
+ const active = isCurrent(item.href, currentPath)
292
+
293
+ return (
294
+ <li key={item.href}>
295
+ <NavigationMenuLink
296
+ href={item.href}
297
+ aria-current={active ? 'page' : undefined}
298
+ className={cn('items-start gap-3 p-3', active && CURRENT_CUE)}
299
+ >
300
+ <EntryIcon name={item.icon} iconMap={iconMap} />
301
+ <span className='flex flex-col gap-0.5'>
302
+ <span className='text-sm font-medium'>{item.label}</span>
303
+ {item.description ? (
304
+ <span className='text-muted-foreground line-clamp-2 text-sm leading-5'>
305
+ {item.description}
306
+ </span>
307
+ ) : null}
308
+ </span>
309
+ </NavigationMenuLink>
310
+ </li>
311
+ )
312
+ })}
313
+ </ul>
314
+ </div>
315
+ ))}
316
+ </div>
317
+ {featured ? <FeaturedCard featured={featured} /> : null}
318
+ </div>
319
+ </NavigationMenuContent>
320
+ </NavigationMenuItem>
321
+ )
322
+ }
323
+
324
+ function MobileMenu({
325
+ links,
326
+ currentPath,
327
+ signIn,
328
+ cta,
329
+ onSignIn,
330
+ onCtaClick,
331
+ }: {
332
+ links: NavbarLink[]
333
+ currentPath?: string
334
+ signIn?: CTA
335
+ cta?: CTA
336
+ onSignIn?: () => void
337
+ onCtaClick?: () => void
338
+ }) {
339
+ const [open, setOpen] = useState(false)
340
+ const groupedLinks = links.filter(link => (link.groups?.length ?? 0) > 0)
341
+ const plainLinks = links.filter(link => (link.groups?.length ?? 0) === 0)
342
+
343
+ // Nothing to navigate to and no action to offer: no drawer, no dead trigger.
344
+ if (links.length === 0 && !signIn && !cta) {
345
+ return null
346
+ }
347
+
348
+ return (
349
+ <Sheet open={open} onOpenChange={setOpen}>
350
+ <SheetTrigger asChild>
351
+ <Button variant='ghost' size='icon' aria-label='Open menu' className='lg:hidden'>
352
+ <Menu />
353
+ </Button>
354
+ </SheetTrigger>
355
+ <SheetContent
356
+ side='right'
357
+ className={cn(
358
+ 'w-full max-w-xs p-0',
359
+ groupedLinks.length > 0 && 'max-w-sm gap-0 overflow-y-auto'
360
+ )}
361
+ >
362
+ <SheetHeader className='border-border border-b'>
363
+ <SheetTitle className='text-left text-base'>Menu</SheetTitle>
364
+ </SheetHeader>
365
+
366
+ <nav aria-label='Mobile' className='px-2 py-2'>
367
+ {groupedLinks.length > 0 ? (
368
+ <Accordion type='multiple'>
369
+ {groupedLinks.map(link => (
370
+ <AccordionItem
371
+ key={`${link.label}:${link.href ?? 'group'}`}
372
+ value={link.label}
373
+ className='border-none'
374
+ >
375
+ <AccordionTrigger className='px-3 py-3 text-base font-medium hover:no-underline'>
376
+ {link.label}
377
+ </AccordionTrigger>
378
+ <AccordionContent className='pb-1'>
379
+ {(link.groups ?? []).map((group, groupIndex) => (
380
+ <div key={group.title ?? `group-${groupIndex}`}>
381
+ {group.title ? (
382
+ <p className='text-muted-foreground px-3 pt-1 pb-1 text-xs font-semibold tracking-wide uppercase'>
383
+ {group.title}
384
+ </p>
385
+ ) : null}
386
+ <ul className='flex flex-col'>
387
+ {group.items.map(item => {
388
+ const active = isCurrent(item.href, currentPath)
389
+
390
+ return (
391
+ <li key={item.href}>
392
+ <SheetClose asChild>
393
+ <a
394
+ href={item.href}
395
+ aria-current={active ? 'page' : undefined}
396
+ className={cn(
397
+ 'hover:bg-accent hover:text-accent-foreground flex items-start gap-3 rounded-md px-3 py-3 transition-colors duration-150 ease-out',
398
+ FOCUS_RING,
399
+ active ? 'text-foreground' : 'text-muted-foreground',
400
+ active && CURRENT_CUE
401
+ )}
402
+ >
403
+ <EntryIcon name={item.icon} />
404
+ <span className='flex flex-col gap-0.5'>
405
+ <span className='text-sm font-medium'>{item.label}</span>
406
+ {item.description ? (
407
+ <span className='text-muted-foreground text-sm'>
408
+ {item.description}
409
+ </span>
410
+ ) : null}
411
+ </span>
412
+ </a>
413
+ </SheetClose>
414
+ </li>
415
+ )
416
+ })}
417
+ </ul>
418
+ </div>
419
+ ))}
420
+ </AccordionContent>
421
+ </AccordionItem>
422
+ ))}
423
+ </Accordion>
424
+ ) : null}
425
+
426
+ {plainLinks.length > 0 ? (
427
+ <div className={cn('flex flex-col', groupedLinks.length > 0 && 'mt-1')}>
428
+ {plainLinks.map(link => {
429
+ const active = isCurrent(link.href, currentPath)
430
+ const className = cn(
431
+ 'hover:bg-accent hover:text-accent-foreground rounded-md px-3 py-3 text-base font-medium transition-colors duration-150 ease-out',
432
+ FOCUS_RING,
433
+ active ? 'text-foreground' : 'text-muted-foreground',
434
+ active && CURRENT_CUE
435
+ )
436
+
437
+ return (
438
+ <SheetClose key={`${link.label}:${link.href ?? 'plain'}`} asChild>
439
+ {link.href ? (
440
+ <a href={link.href} aria-current={active ? 'page' : undefined} className={className}>
441
+ {link.label}
442
+ </a>
443
+ ) : (
444
+ <button type='button' className={className}>
445
+ {link.label}
446
+ </button>
447
+ )}
448
+ </SheetClose>
449
+ )
450
+ })}
451
+ </div>
452
+ ) : null}
453
+ </nav>
454
+
455
+ {signIn || cta ? (
456
+ <div className='mt-auto flex flex-col gap-4 p-4'>
457
+ <Separator />
458
+ <div className='flex flex-col gap-2'>
459
+ {signIn ? (
460
+ <SheetClose asChild>
461
+ <ActionButton
462
+ action={signIn}
463
+ onClick={onSignIn}
464
+ variant='outline'
465
+ className='w-full'
466
+ />
467
+ </SheetClose>
468
+ ) : null}
469
+ {cta ? (
470
+ <SheetClose asChild>
471
+ <ActionButton action={cta} onClick={onCtaClick} className='w-full' />
472
+ </SheetClose>
473
+ ) : null}
474
+ </div>
475
+ </div>
476
+ ) : null}
477
+ </SheetContent>
478
+ </Sheet>
479
+ )
480
+ }
481
+
482
+ function FlatLinks({
483
+ links,
484
+ currentPath,
485
+ rounded,
486
+ }: {
487
+ links: NavbarLink[]
488
+ currentPath?: string
489
+ rounded?: boolean
490
+ }) {
491
+ if (links.length === 0) {
492
+ return null
493
+ }
494
+
495
+ return (
496
+ <nav aria-label='Main' className='hidden lg:block'>
497
+ <ul className='flex items-center gap-1'>
498
+ {links.map(link => {
499
+ const active = isCurrent(link.href, currentPath)
500
+ const className = cn(
501
+ 'hover:text-foreground text-sm font-medium transition-colors duration-150 ease-out',
502
+ FOCUS_RING,
503
+ rounded ? 'rounded-full px-3 py-2' : 'rounded-md px-3 py-2',
504
+ active ? 'text-foreground' : 'text-muted-foreground',
505
+ active && CURRENT_CUE,
506
+ !active && link.emphasis && 'text-foreground'
507
+ )
508
+
509
+ return (
510
+ <li key={`${link.label}:${link.href ?? 'plain'}`}>
511
+ {link.href ? (
512
+ <a href={link.href} aria-current={active ? 'page' : undefined} className={className}>
513
+ {link.label}
514
+ </a>
515
+ ) : (
516
+ <span className={className}>{link.label}</span>
517
+ )}
518
+ </li>
519
+ )
520
+ })}
521
+ </ul>
522
+ </nav>
523
+ )
524
+ }
525
+
526
+ function MegaLinks({
527
+ links,
528
+ featured,
529
+ currentPath,
530
+ iconMap,
531
+ }: {
532
+ links: NavbarLink[]
533
+ featured?: NavbarFeatured
534
+ currentPath?: string
535
+ iconMap?: NavbarIconMap
536
+ }) {
537
+ if (links.length === 0) {
538
+ return null
539
+ }
540
+
541
+ return (
542
+ <NavigationMenu className='hidden lg:flex' aria-label='Main'>
543
+ <NavigationMenuList>
544
+ {links.map(link => {
545
+ const grouped = (link.groups?.length ?? 0) > 0
546
+
547
+ if (grouped) {
548
+ return (
549
+ <MegaEntry
550
+ key={`${link.label}:group`}
551
+ link={link}
552
+ featured={featured}
553
+ currentPath={currentPath}
554
+ iconMap={iconMap}
555
+ />
556
+ )
557
+ }
558
+
559
+ const active = isCurrent(link.href, currentPath)
560
+
561
+ return (
562
+ <NavigationMenuItem key={`${link.label}:${link.href ?? 'plain'}`}>
563
+ <NavigationMenuLink
564
+ href={link.href}
565
+ aria-current={active ? 'page' : undefined}
566
+ className={cn(
567
+ navigationMenuTriggerStyle(),
568
+ active && CURRENT_CUE,
569
+ !active && link.emphasis && 'text-foreground font-semibold'
570
+ )}
571
+ >
572
+ {link.label}
573
+ </NavigationMenuLink>
574
+ </NavigationMenuItem>
575
+ )
576
+ })}
577
+ </NavigationMenuList>
578
+ </NavigationMenu>
579
+ )
580
+ }
581
+
582
+ /**
583
+ * Page-level navigation header.
584
+ *
585
+ * Responsibilities end at navigation: the header renders no cart, account, or search
586
+ * affordance — those belong to the commerce shell (`StorefrontNavbar`).
587
+ *
588
+ * `floating` keeps the header in normal document flow (`sticky`), so it reserves its own
589
+ * space and the consumer adds no compensating padding or scroll offset. A `sticky` header
590
+ * is defeated by an `overflow` ancestor that scrolls; use `bar` there.
591
+ */
592
+ export function Navbar({
593
+ presentation = 'bar',
594
+ logoText,
595
+ logoHref,
596
+ currentPath,
597
+ links,
598
+ featured,
599
+ signIn,
600
+ cta,
601
+ iconMap,
602
+ onSignIn,
603
+ onCtaClick,
604
+ className,
605
+ }: NavbarProps) {
606
+ const sentinelRef = useRef<HTMLDivElement>(null)
607
+ const [scrolled, setScrolled] = useState(false)
608
+
609
+ /**
610
+ * Scroll detection:
611
+ * - Inside a preview with an overflow ancestor the scroller is not the viewport, so
612
+ * `scrollTop` on the nearest scrollable ancestor is the reliable signal.
613
+ * - On a normal page, an IntersectionObserver on the 8px sentinel gives hysteresis
614
+ * without flickering at a hard `scrollY > 8` threshold.
615
+ */
616
+ useEffect(() => {
617
+ if (presentation !== 'floating') {
618
+ return
619
+ }
620
+
621
+ const sentinel = sentinelRef.current
622
+ if (!sentinel) {
623
+ return
624
+ }
625
+
626
+ let ancestor: HTMLElement | null = sentinel.parentElement
627
+ while (ancestor) {
628
+ const { overflowY } = getComputedStyle(ancestor)
629
+ if (
630
+ (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') &&
631
+ ancestor.scrollHeight > ancestor.clientHeight + 1
632
+ ) {
633
+ const scroller = ancestor
634
+ const update = () => setScrolled(scroller.scrollTop > 8)
635
+ update()
636
+ scroller.addEventListener('scroll', update, { passive: true })
637
+ return () => scroller.removeEventListener('scroll', update)
638
+ }
639
+ ancestor = ancestor.parentElement
640
+ }
641
+
642
+ const observer = new IntersectionObserver(([entry]) => setScrolled(!entry.isIntersecting), {
643
+ threshold: 0,
644
+ })
645
+
646
+ observer.observe(sentinel)
647
+ return () => observer.disconnect()
648
+ }, [presentation])
649
+
650
+ if (presentation === 'floating') {
651
+ return (
652
+ <>
653
+ {/*
654
+ Out of flow: gives the observer an 8px hysteresis target without painting a
655
+ blank strip under the transparent unscrolled header.
656
+ */}
657
+ <div
658
+ ref={sentinelRef}
659
+ aria-hidden='true'
660
+ className='pointer-events-none absolute inset-x-0 top-0 h-2'
661
+ />
662
+
663
+ <header className={cn('sticky top-0 z-50 w-full', className)}>
664
+ <div
665
+ className={cn(
666
+ 'mx-auto flex h-14 items-center justify-between gap-4 transition-all duration-300 ease-out motion-reduce:transition-none',
667
+ scrolled
668
+ ? 'border-border/60 bg-background/80 max-w-5xl rounded-full border px-4 shadow-sm backdrop-blur-md sm:px-5'
669
+ : 'max-w-7xl border border-transparent px-4 sm:px-6 lg:px-8'
670
+ )}
671
+ >
672
+ <Logo text={logoText} href={logoHref} />
673
+
674
+ <FlatLinks links={links} currentPath={currentPath} rounded />
675
+
676
+ <div className='hidden items-center gap-2 lg:flex'>
677
+ {signIn ? (
678
+ <ActionButton action={signIn} onClick={onSignIn} variant='ghost' rounded />
679
+ ) : null}
680
+ {cta ? <ActionButton action={cta} onClick={onCtaClick} rounded /> : null}
681
+ </div>
682
+
683
+ <MobileMenu
684
+ links={links}
685
+ currentPath={currentPath}
686
+ signIn={signIn}
687
+ cta={cta}
688
+ onSignIn={onSignIn}
689
+ onCtaClick={onCtaClick}
690
+ />
691
+ </div>
692
+ </header>
693
+ </>
694
+ )
695
+ }
696
+
697
+ return (
698
+ <header className={cn('border-border bg-background w-full border-b', className)}>
699
+ <div className='mx-auto flex h-16 w-full max-w-7xl items-center justify-between gap-4 px-4 sm:px-6 lg:px-8'>
700
+ {presentation === 'mega' ? (
701
+ <div className='flex items-center gap-6'>
702
+ <Logo text={logoText} href={logoHref} />
703
+ <MegaLinks
704
+ links={links}
705
+ featured={featured}
706
+ currentPath={currentPath}
707
+ iconMap={iconMap}
708
+ />
709
+ </div>
710
+ ) : (
711
+ <>
712
+ <Logo text={logoText} href={logoHref} />
713
+ <FlatLinks links={links} currentPath={currentPath} />
714
+ </>
715
+ )}
716
+
717
+ <div className='hidden items-center gap-2 lg:flex'>
718
+ {signIn ? <ActionButton action={signIn} onClick={onSignIn} variant='ghost' /> : null}
719
+ {cta ? <ActionButton action={cta} onClick={onCtaClick} /> : null}
720
+ </div>
721
+
722
+ <MobileMenu
723
+ links={links}
724
+ currentPath={currentPath}
725
+ signIn={signIn}
726
+ cta={cta}
727
+ onSignIn={onSignIn}
728
+ onCtaClick={onCtaClick}
729
+ />
730
+ </div>
731
+ </header>
732
+ )
733
+ }
734
+
735
+ export default Navbar