@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,532 @@
1
+ 'use client'
2
+
3
+ import { useId, useState, type FormEvent, type ReactNode } from 'react'
4
+ import { ArrowRight, type LucideIcon } from 'lucide-react'
5
+ import { cn } from '../../../lib/utils'
6
+ import { Button } from '../../../ui/button'
7
+ import { Input } from '../../../ui/input'
8
+
9
+ export type FooterPresentation = 'brand-columns' | 'newsletter-grid' | 'contact'
10
+ export type FooterLinkBehavior = 'host' | 'native'
11
+
12
+ export interface FooterBrandAccent {
13
+ text: string
14
+ className?: string
15
+ }
16
+
17
+ export interface FooterLink {
18
+ label: string
19
+ href?: string
20
+ }
21
+
22
+ export interface FooterLinkColumn {
23
+ title: string
24
+ links: FooterLink[]
25
+ }
26
+
27
+ export interface FooterSocialLink {
28
+ label: string
29
+ href?: string
30
+ icon: string
31
+ ariaLabel?: string
32
+ }
33
+
34
+ export interface FooterNewsletter {
35
+ title?: string
36
+ placeholder?: string
37
+ buttonLabel: string
38
+ }
39
+
40
+ export interface FooterContactItem {
41
+ label: string
42
+ href?: string
43
+ }
44
+
45
+ export interface FooterContact {
46
+ heading?: string
47
+ address?: FooterContactItem
48
+ phone?: FooterContactItem
49
+ email?: FooterContactItem
50
+ }
51
+
52
+ export interface FooterSectionProps {
53
+ presentation: FooterPresentation
54
+ brandName: string
55
+ linkColumns: FooterLinkColumn[]
56
+ brandAccent?: FooterBrandAccent
57
+ brandDescription?: string
58
+ newsletter?: FooterNewsletter
59
+ contact?: FooterContact
60
+ legalLinks?: FooterLink[]
61
+ socialLinks?: FooterSocialLink[]
62
+ copyright?: string
63
+ logo?: ReactNode
64
+ socialIcons?: Record<string, LucideIcon>
65
+ className?: string
66
+ linkBehavior?: FooterLinkBehavior
67
+ socialLinkBehavior?: FooterLinkBehavior
68
+ onNewsletterSubmit?: (email: string) => void
69
+ onLinkClick?: (link: FooterLink) => void
70
+ onSocialClick?: (label: string) => void
71
+ }
72
+
73
+ const footerPresentations: readonly FooterPresentation[] = [
74
+ 'brand-columns',
75
+ 'newsletter-grid',
76
+ 'contact',
77
+ ]
78
+
79
+ function DefaultFooterMark() {
80
+ return (
81
+ <svg
82
+ aria-hidden="true"
83
+ className="size-8 shrink-0 text-primary"
84
+ viewBox="0 0 32 32"
85
+ fill="none"
86
+ >
87
+ <path
88
+ d="M4 5h6v18h17M7 11h18l-4 8H7V11Z"
89
+ stroke="currentColor"
90
+ strokeLinecap="round"
91
+ strokeLinejoin="round"
92
+ strokeWidth="2"
93
+ />
94
+ <circle cx="11" cy="27" r="1.5" fill="currentColor" />
95
+ <circle cx="23" cy="27" r="1.5" fill="currentColor" />
96
+ </svg>
97
+ )
98
+ }
99
+
100
+ function FooterBrand({
101
+ brandName,
102
+ brandAccent,
103
+ brandDescription,
104
+ logo,
105
+ }: Pick<
106
+ FooterSectionProps,
107
+ 'brandName' | 'brandAccent' | 'brandDescription' | 'logo'
108
+ >) {
109
+ const accent = brandAccent?.text ?? ''
110
+ const hasTrailingAccent = Boolean(accent && brandName.endsWith(accent))
111
+ const brandPrefix = hasTrailingAccent
112
+ ? brandName.slice(0, brandName.length - accent.length)
113
+ : brandName
114
+
115
+ return (
116
+ <div className="flex max-w-sm flex-col gap-4">
117
+ <div className="flex items-center gap-3">
118
+ {logo ?? <DefaultFooterMark />}
119
+ <h2 className="text-2xl font-bold text-balance">
120
+ {brandPrefix}
121
+ {hasTrailingAccent ? (
122
+ <span className={cn('text-primary', brandAccent?.className)}>
123
+ {accent}
124
+ </span>
125
+ ) : null}
126
+ </h2>
127
+ </div>
128
+ {brandDescription ? (
129
+ <p className="text-muted-foreground text-sm leading-relaxed text-balance">
130
+ {brandDescription}
131
+ </p>
132
+ ) : null}
133
+ </div>
134
+ )
135
+ }
136
+
137
+ function FooterContactDetails({ contact }: { contact?: FooterContact }) {
138
+ if (!contact) return null
139
+
140
+ const items = [contact.address, contact.phone, contact.email].filter(
141
+ (item): item is FooterContactItem => Boolean(item),
142
+ )
143
+
144
+ if (items.length === 0 && !contact.heading) return null
145
+
146
+ return (
147
+ <address className="not-italic">
148
+ {contact.heading ? (
149
+ <h3 className="mb-4 text-base font-semibold text-balance">
150
+ {contact.heading}
151
+ </h3>
152
+ ) : null}
153
+ <ul className="flex flex-col gap-3">
154
+ {items.map((item) => (
155
+ <li key={JSON.stringify([item.label, item.href ?? null])}>
156
+ {item.href ? (
157
+ <a
158
+ href={item.href}
159
+ className="text-muted-foreground hover:text-foreground text-sm transition-colors"
160
+ >
161
+ {item.label}
162
+ </a>
163
+ ) : (
164
+ <span className="text-muted-foreground text-sm">
165
+ {item.label}
166
+ </span>
167
+ )}
168
+ </li>
169
+ ))}
170
+ </ul>
171
+ </address>
172
+ )
173
+ }
174
+
175
+ function assertUniqueKeys<T>(
176
+ items: readonly T[],
177
+ getTuple: (item: T) => readonly (string | null)[],
178
+ listName: string,
179
+ ) {
180
+ const keys = new Set<string>()
181
+
182
+ for (const item of items) {
183
+ const key = JSON.stringify(getTuple(item))
184
+
185
+ if (typeof key !== 'string') {
186
+ throw new Error(`FooterSection could not derive a ${listName} key.`)
187
+ }
188
+
189
+ if (keys.has(key)) {
190
+ throw new Error(`FooterSection ${listName} keys must be unique.`)
191
+ }
192
+
193
+ keys.add(key)
194
+ }
195
+ }
196
+
197
+ function FooterNewsletterForm({
198
+ newsletter,
199
+ onSubmit,
200
+ }: {
201
+ newsletter: FooterNewsletter
202
+ onSubmit?: (email: string) => void
203
+ }) {
204
+ const [email, setEmail] = useState('')
205
+ const inputId = useId()
206
+
207
+ function handleSubmit(event: FormEvent<HTMLFormElement>) {
208
+ event.preventDefault()
209
+ if (!onSubmit) return
210
+
211
+ onSubmit(email)
212
+ }
213
+
214
+ return (
215
+ <section aria-label={newsletter.title ?? 'Newsletter'} className="w-full">
216
+ {newsletter.title ? (
217
+ <h3 className="mb-4 text-base font-semibold text-balance">
218
+ {newsletter.title}
219
+ </h3>
220
+ ) : null}
221
+ <form className="flex w-full max-w-xl gap-2" onSubmit={handleSubmit}>
222
+ <label htmlFor={inputId} className="sr-only">
223
+ {newsletter.placeholder ?? newsletter.title ?? newsletter.buttonLabel}
224
+ </label>
225
+ <Input
226
+ id={inputId}
227
+ type="email"
228
+ autoComplete="email"
229
+ placeholder={newsletter.placeholder}
230
+ value={email}
231
+ onChange={(event) => setEmail(event.currentTarget.value)}
232
+ disabled={!onSubmit}
233
+ />
234
+ <Button type="submit" disabled={!onSubmit}>
235
+ {newsletter.buttonLabel}
236
+ <ArrowRight aria-hidden="true" />
237
+ </Button>
238
+ </form>
239
+ </section>
240
+ )
241
+ }
242
+
243
+ function FooterSocialLinks({
244
+ socialLinks,
245
+ socialIcons,
246
+ onSocialClick,
247
+ socialLinkBehavior,
248
+ }: {
249
+ socialLinks?: FooterSocialLink[]
250
+ socialIcons?: Record<string, LucideIcon>
251
+ onSocialClick?: (label: string) => void
252
+ socialLinkBehavior: FooterLinkBehavior
253
+ }) {
254
+ if (!socialLinks || socialLinks.length === 0) return null
255
+
256
+ return (
257
+ <nav aria-label="Social media">
258
+ <ul className="flex flex-wrap items-center gap-3">
259
+ {socialLinks.map((social) => {
260
+ const Icon = socialIcons?.[social.icon]
261
+ const content = Icon ? (
262
+ <>
263
+ <Icon aria-hidden="true" className="size-5" />
264
+ <span className="sr-only">{social.label}</span>
265
+ </>
266
+ ) : (
267
+ social.label
268
+ )
269
+
270
+ return (
271
+ <li key={JSON.stringify([social.label, social.href ?? null, social.icon])}>
272
+ {social.href &&
273
+ (socialLinkBehavior === 'native' || onSocialClick) ? (
274
+ <a
275
+ href={social.href}
276
+ aria-label={social.ariaLabel ?? social.label}
277
+ onClick={(event) => {
278
+ if (socialLinkBehavior === 'host') {
279
+ event.preventDefault()
280
+ }
281
+ onSocialClick?.(social.label)
282
+ }}
283
+ className="inline-flex min-h-10 min-w-10 items-center justify-center rounded-full bg-accent p-2 text-muted-foreground transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-hidden"
284
+ >
285
+ {content}
286
+ </a>
287
+ ) : (
288
+ <span className="text-muted-foreground text-sm">{content}</span>
289
+ )}
290
+ </li>
291
+ )
292
+ })}
293
+ </ul>
294
+ </nav>
295
+ )
296
+ }
297
+
298
+ function FooterLegalLinks({
299
+ links,
300
+ onLinkClick,
301
+ linkBehavior,
302
+ }: {
303
+ links?: FooterLink[]
304
+ onLinkClick?: (link: FooterLink) => void
305
+ linkBehavior: FooterLinkBehavior
306
+ }) {
307
+ if (!links || links.length === 0) return null
308
+
309
+ return (
310
+ <nav aria-label="Legal information">
311
+ <ul className="flex flex-wrap gap-x-5 gap-y-2">
312
+ {links.map((link) => (
313
+ <li key={JSON.stringify([link.label, link.href ?? null])}>
314
+ {link.href && (linkBehavior === 'native' || onLinkClick) ? (
315
+ <a
316
+ href={link.href}
317
+ onClick={(event) => {
318
+ if (linkBehavior === 'host') {
319
+ event.preventDefault()
320
+ }
321
+ onLinkClick?.(link)
322
+ }}
323
+ className="text-muted-foreground hover:text-foreground text-sm transition-colors"
324
+ >
325
+ {link.label}
326
+ </a>
327
+ ) : (
328
+ <span className="text-muted-foreground text-sm">
329
+ {link.label}
330
+ </span>
331
+ )}
332
+ </li>
333
+ ))}
334
+ </ul>
335
+ </nav>
336
+ )
337
+ }
338
+
339
+ function FooterLinkColumns({
340
+ columns,
341
+ onLinkClick,
342
+ linkBehavior,
343
+ className,
344
+ }: {
345
+ columns: FooterLinkColumn[]
346
+ onLinkClick?: (link: FooterLink) => void
347
+ linkBehavior: FooterLinkBehavior
348
+ className?: string
349
+ }) {
350
+ if (columns.length === 0) return null
351
+
352
+ return (
353
+ <div className={cn('grid grid-cols-1 gap-8 sm:grid-cols-2', className)}>
354
+ {columns.map((column) => (
355
+ <nav key={JSON.stringify([column.title])} aria-label={column.title}>
356
+ <h3 className="mb-4 text-base font-semibold text-balance">
357
+ {column.title}
358
+ </h3>
359
+ <ul className="flex flex-col gap-3">
360
+ {column.links.map((link) => (
361
+ <li key={JSON.stringify([link.label, link.href ?? null])}>
362
+ {link.href && (linkBehavior === 'native' || onLinkClick) ? (
363
+ <a
364
+ href={link.href}
365
+ onClick={(event) => {
366
+ if (linkBehavior === 'host') {
367
+ event.preventDefault()
368
+ }
369
+ onLinkClick?.(link)
370
+ }}
371
+ className="text-muted-foreground hover:text-foreground focus-visible:ring-ring rounded-sm text-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden"
372
+ >
373
+ {link.label}
374
+ </a>
375
+ ) : (
376
+ <span className="text-muted-foreground text-sm">
377
+ {link.label}
378
+ </span>
379
+ )}
380
+ </li>
381
+ ))}
382
+ </ul>
383
+ </nav>
384
+ ))}
385
+ </div>
386
+ )
387
+ }
388
+
389
+ export function FooterSection({
390
+ presentation,
391
+ brandName,
392
+ brandAccent,
393
+ brandDescription,
394
+ linkColumns,
395
+ newsletter,
396
+ contact,
397
+ legalLinks,
398
+ socialLinks,
399
+ socialIcons,
400
+ copyright,
401
+ logo,
402
+ className,
403
+ onNewsletterSubmit,
404
+ onLinkClick,
405
+ onSocialClick,
406
+ linkBehavior = 'host',
407
+ socialLinkBehavior = 'host',
408
+ }: FooterSectionProps) {
409
+ if (!footerPresentations.includes(presentation)) {
410
+ throw new Error(
411
+ `FooterSection requires a supported presentation; received ${String(presentation)}.`,
412
+ )
413
+ }
414
+
415
+ assertUniqueKeys(linkColumns, (column) => [column.title], 'column')
416
+
417
+ for (const column of linkColumns) {
418
+ assertUniqueKeys(
419
+ column.links,
420
+ (link) => [link.label, link.href ?? null],
421
+ 'link',
422
+ )
423
+ }
424
+
425
+ assertUniqueKeys(
426
+ legalLinks ?? [],
427
+ (link) => [link.label, link.href ?? null],
428
+ 'legal link',
429
+ )
430
+ assertUniqueKeys(
431
+ socialLinks ?? [],
432
+ (social) => [social.label, social.href ?? null, social.icon],
433
+ 'social',
434
+ )
435
+
436
+ const brand = (
437
+ <FooterBrand
438
+ brandName={brandName}
439
+ brandAccent={brandAccent}
440
+ brandDescription={brandDescription}
441
+ logo={logo}
442
+ />
443
+ )
444
+ const columns = (
445
+ <FooterLinkColumns
446
+ columns={linkColumns}
447
+ onLinkClick={onLinkClick}
448
+ linkBehavior={linkBehavior}
449
+ />
450
+ )
451
+ const contactDetails = <FooterContactDetails contact={contact} />
452
+ const newsletterForm = newsletter ? (
453
+ <FooterNewsletterForm
454
+ newsletter={newsletter}
455
+ onSubmit={onNewsletterSubmit}
456
+ />
457
+ ) : null
458
+ const legal = (
459
+ <FooterLegalLinks
460
+ links={legalLinks}
461
+ onLinkClick={onLinkClick}
462
+ linkBehavior={linkBehavior}
463
+ />
464
+ )
465
+ const socials = (
466
+ <FooterSocialLinks
467
+ socialLinks={socialLinks}
468
+ socialIcons={socialIcons}
469
+ onSocialClick={onSocialClick}
470
+ socialLinkBehavior={socialLinkBehavior}
471
+ />
472
+ )
473
+
474
+ return (
475
+ <footer
476
+ role="contentinfo"
477
+ aria-label={`${brandName} footer`}
478
+ className={cn(
479
+ 'w-full border-t border-border bg-background text-foreground',
480
+ className,
481
+ )}
482
+ >
483
+ <div className="mx-auto w-full max-w-7xl px-4 py-12 sm:px-6 sm:py-16 lg:px-8">
484
+ {presentation === 'brand-columns' ? (
485
+ <div className="grid gap-12 md:grid-cols-[minmax(0,1fr)_2fr]">
486
+ <div className="flex flex-col gap-8">
487
+ {brand}
488
+ {newsletterForm}
489
+ {contactDetails}
490
+ </div>
491
+ <div className="flex flex-col gap-8">
492
+ {columns}
493
+ {legal}
494
+ </div>
495
+ </div>
496
+ ) : null}
497
+
498
+ {presentation === 'newsletter-grid' ? (
499
+ <div className="flex flex-col gap-12">
500
+ <div className="grid gap-8 border-b border-border pb-10 md:grid-cols-[minmax(0,1fr)_2fr] md:items-end">
501
+ {brand}
502
+ {contactDetails}
503
+ </div>
504
+ {newsletterForm}
505
+ {columns}
506
+ {legal}
507
+ </div>
508
+ ) : null}
509
+
510
+ {presentation === 'contact' ? (
511
+ <div className="grid gap-10 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_2fr]">
512
+ {brand}
513
+ {contactDetails}
514
+ <div className="flex flex-col gap-8">
515
+ {newsletterForm}
516
+ {columns}
517
+ {legal}
518
+ </div>
519
+ </div>
520
+ ) : null}
521
+
522
+ {socials ? <div className="mt-10">{socials}</div> : null}
523
+
524
+ {copyright ? (
525
+ <div className="mt-10 border-t border-border pt-6">
526
+ <p className="text-muted-foreground text-sm">{copyright}</p>
527
+ </div>
528
+ ) : null}
529
+ </div>
530
+ </footer>
531
+ )
532
+ }
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import * as blocks from '../../../index'
4
+ import {
5
+ Navbar,
6
+ StorefrontNavbar,
7
+ navbarPropsSchema,
8
+ storefrontNavbarPropsSchema,
9
+ type NavbarPresentation,
10
+ type NavbarProps,
11
+ type NavbarSpecProps,
12
+ type StorefrontNavbarProps,
13
+ type StorefrontNavbarSpecProps,
14
+ } from '../../../index'
15
+
16
+ const specProps: NavbarSpecProps = {
17
+ presentation: 'mega',
18
+ logoText: 'Acme',
19
+ links: [{ label: 'Shop', href: '/shop' }],
20
+ }
21
+ const parsedProps = navbarPropsSchema.parse(specProps) as NavbarProps
22
+ const presentation: NavbarPresentation = parsedProps.presentation ?? 'bar'
23
+
24
+ const shellSpecProps: StorefrontNavbarSpecProps = {
25
+ storeCategories: [{ label: 'Catalog', href: '/catalog' }],
26
+ utilityLinks: [],
27
+ logoText: 'Acme',
28
+ }
29
+ const shellProps: StorefrontNavbarProps = {
30
+ ...storefrontNavbarPropsSchema.parse(shellSpecProps),
31
+ showSearch: false,
32
+ }
33
+
34
+ describe('Blocks navigation package exports', () => {
35
+ it('exposes the canonical navigation components and their contracts from the package root', () => {
36
+ expect(Navbar).toEqual(expect.any(Function))
37
+ expect(StorefrontNavbar).toEqual(expect.any(Function))
38
+ expect(navbarPropsSchema.parse).toEqual(expect.any(Function))
39
+ expect(storefrontNavbarPropsSchema.parse).toEqual(expect.any(Function))
40
+ expect({ presentation, shellProps }).toBeDefined()
41
+ })
42
+
43
+ it('no longer exposes the retired numbered navigation surface', () => {
44
+ const retired = [
45
+ 'Navbar1',
46
+ 'Navbar2',
47
+ 'Navbar3',
48
+ 'Navbar4',
49
+ 'navbar4PropsSchema',
50
+ 'Navbar4SpecProps',
51
+ ] as const
52
+
53
+ for (const name of retired) {
54
+ expect((blocks as unknown as Record<string, unknown>)[name]).toBeUndefined()
55
+ }
56
+ })
57
+ })