@thorprovider/blocks 0.1.3 → 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 (178) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/README.md +228 -68
  3. package/docs/standards/data-contracts.md +13 -2
  4. package/package.json +26 -10
  5. package/scripts/fix-esm-extensions.mjs +40 -0
  6. package/src/blocks/application/login-forms/login-page.test.tsx +190 -0
  7. package/src/blocks/application/login-forms/login-page.tsx +240 -0
  8. package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +6 -8
  9. package/src/blocks/application/signup-forms/signup-page-1.tsx +127 -129
  10. package/src/blocks/application/verification/verify-1.tsx +2 -2
  11. package/src/blocks/application/viewport-ownership.test.tsx +41 -0
  12. package/src/blocks/e-commerce/category-filters/category-listing-filter-fields.tsx +218 -0
  13. package/src/blocks/e-commerce/category-filters/{category-filter-2.test.tsx → category-listing.test.tsx} +287 -199
  14. package/src/blocks/e-commerce/category-filters/category-listing.tsx +934 -0
  15. package/src/blocks/e-commerce/checkout-forms/checkout-contact-step.tsx +149 -0
  16. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-country-field.tsx → checkout-country-field.tsx} +10 -8
  17. package/src/blocks/e-commerce/checkout-forms/checkout-order-summary.tsx +230 -0
  18. package/src/blocks/e-commerce/checkout-forms/checkout-payment-step.tsx +100 -0
  19. package/src/blocks/e-commerce/checkout-forms/checkout-progress.tsx +62 -0
  20. package/src/blocks/e-commerce/checkout-forms/checkout-review-step.tsx +59 -0
  21. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-shipping-step.tsx → checkout-shipping-step.tsx} +65 -41
  22. package/src/blocks/e-commerce/checkout-forms/checkout.test.tsx +478 -0
  23. package/src/blocks/e-commerce/checkout-forms/checkout.tsx +550 -0
  24. package/src/blocks/e-commerce/navigation/storefront-navbar-account-menu.test.tsx +56 -0
  25. package/src/blocks/{marketing/navbars/navbar-4-account-menu.tsx → e-commerce/navigation/storefront-navbar-account-menu.tsx} +3 -3
  26. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.test.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.test.tsx} +22 -22
  27. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.tsx} +3 -3
  28. package/src/blocks/{marketing/navbars/navbar-4-mobile-nav.tsx → e-commerce/navigation/storefront-navbar-mobile-nav.tsx} +4 -12
  29. package/src/blocks/{marketing/navbars/navbar-4-search-bar.tsx → e-commerce/navigation/storefront-navbar-search-bar.tsx} +3 -3
  30. package/src/blocks/{marketing/navbars/navbar-4.test.tsx → e-commerce/navigation/storefront-navbar.test.tsx} +67 -36
  31. package/src/blocks/{marketing/navbars/navbar-4.tsx → e-commerce/navigation/storefront-navbar.tsx} +43 -25
  32. package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
  33. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  34. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  35. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  36. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  37. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  38. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  39. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  40. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  41. package/src/blocks/e-commerce/product-list/product-card.tsx +447 -0
  42. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  43. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  44. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  45. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  46. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  47. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  48. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  49. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  50. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  51. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  52. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +119 -85
  53. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  54. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  55. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  56. package/src/blocks/marketing/features/feature.tsx +195 -0
  57. package/src/blocks/marketing/footers/footer-section-accessibility.test.tsx +118 -0
  58. package/src/blocks/marketing/footers/footer-section-actions.test.tsx +131 -0
  59. package/src/blocks/marketing/footers/footer-section-data.test.tsx +127 -0
  60. package/src/blocks/marketing/footers/footer-section-native-links.test.tsx +100 -0
  61. package/src/blocks/marketing/footers/footer-section-public-api.test.tsx +11 -0
  62. package/src/blocks/marketing/footers/footer-section.test.tsx +46 -0
  63. package/src/blocks/marketing/footers/footer-section.tsx +532 -0
  64. package/src/blocks/marketing/navbars/navbar-public-api.test.tsx +57 -0
  65. package/src/blocks/marketing/navbars/navbar.mega.test.tsx +149 -0
  66. package/src/blocks/marketing/navbars/navbar.test.tsx +155 -0
  67. package/src/blocks/marketing/navbars/navbar.tsx +735 -0
  68. package/src/blocks/marketing/testimonials/testimonial-section.test.tsx +171 -0
  69. package/src/blocks/marketing/testimonials/testimonial-section.tsx +237 -0
  70. package/src/index.ts +156 -128
  71. package/src/lib/contracts-curated.test.ts +47 -29
  72. package/src/lib/contracts.test.ts +198 -2
  73. package/src/lib/contracts.ts +410 -16
  74. package/src/ui/button-group.tsx +1 -1
  75. package/src/ui/carousel.tsx +1 -1
  76. package/src/ui/dialog.tsx +1 -1
  77. package/src/ui/field.tsx +2 -2
  78. package/src/ui/input-group.tsx +3 -3
  79. package/src/ui/pagination.tsx +1 -1
  80. package/src/ui/sidebar.tsx +7 -7
  81. package/src/ui/toggle-group.tsx +1 -1
  82. package/tsconfig.build.json +12 -0
  83. package/tsup.config.ts +11 -7
  84. package/.turbo/turbo-build.log +0 -24
  85. package/.turbo/turbo-test.log +0 -47
  86. package/.turbo/turbo-type-check.log +0 -0
  87. package/dist/.tsbuildinfo +0 -1
  88. package/dist/contracts.d.mts +0 -1732
  89. package/dist/contracts.d.ts +0 -1732
  90. package/dist/contracts.js +0 -423
  91. package/dist/contracts.js.map +0 -1
  92. package/dist/contracts.mjs +0 -365
  93. package/dist/contracts.mjs.map +0 -1
  94. package/dist/index.d.mts +0 -3068
  95. package/dist/index.d.ts +0 -3068
  96. package/dist/index.js +0 -17834
  97. package/dist/index.js.map +0 -1
  98. package/dist/index.mjs +0 -17740
  99. package/dist/index.mjs.map +0 -1
  100. package/src/blocks/application/login-forms/login-page-1.tsx +0 -246
  101. package/src/blocks/application/login-forms/login-page-2.tsx +0 -207
  102. package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +0 -260
  103. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +0 -222
  104. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +0 -921
  105. package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +0 -57
  106. package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +0 -124
  107. package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +0 -170
  108. package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +0 -52
  109. package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +0 -57
  110. package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +0 -68
  111. package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +0 -298
  112. package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +0 -47
  113. package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +0 -9
  114. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +0 -96
  115. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +0 -252
  116. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +0 -188
  117. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +0 -36
  118. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +0 -216
  119. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +0 -338
  120. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +0 -130
  121. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +0 -175
  122. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +0 -190
  123. package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +0 -312
  124. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +0 -91
  125. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +0 -110
  126. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +0 -137
  127. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +0 -68
  128. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +0 -79
  129. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +0 -172
  130. package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +0 -392
  131. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  132. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  133. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  134. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  135. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  136. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  137. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +0 -88
  138. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -192
  139. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  140. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  141. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  142. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  143. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  144. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  145. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  146. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  147. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  148. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  149. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  150. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  151. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  152. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  153. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  154. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  155. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  156. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  157. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  158. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  159. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -229
  160. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -72
  161. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  162. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  163. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  164. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  165. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  166. package/src/blocks/marketing/features/feature-2.tsx +0 -82
  167. package/src/blocks/marketing/footers/footer-section-1.test.tsx +0 -117
  168. package/src/blocks/marketing/footers/footer-section-1.tsx +0 -178
  169. package/src/blocks/marketing/footers/footer-section-3.tsx +0 -192
  170. package/src/blocks/marketing/footers/footer-section-4.tsx +0 -355
  171. package/src/blocks/marketing/navbars/navbar-1.tsx +0 -267
  172. package/src/blocks/marketing/navbars/navbar-2.tsx +0 -351
  173. package/src/blocks/marketing/navbars/navbar-3.tsx +0 -303
  174. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +0 -38
  175. package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +0 -177
  176. package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +0 -219
  177. package/src/blocks/marketing/testimonials/testimonial-1.tsx +0 -141
  178. 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