@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.
- package/CHANGELOG.md +48 -0
- package/README.md +156 -40
- package/package.json +1 -1
- package/src/blocks/application/login-forms/login-page.test.tsx +190 -0
- package/src/blocks/application/login-forms/login-page.tsx +240 -0
- package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +6 -8
- package/src/blocks/application/signup-forms/signup-page-1.tsx +127 -129
- package/src/blocks/application/verification/verify-1.tsx +2 -2
- package/src/blocks/application/viewport-ownership.test.tsx +41 -0
- package/src/blocks/e-commerce/category-filters/category-listing-filter-fields.tsx +218 -0
- package/src/blocks/e-commerce/category-filters/{category-filter-2.test.tsx → category-listing.test.tsx} +287 -199
- package/src/blocks/e-commerce/category-filters/category-listing.tsx +934 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-contact-step.tsx +149 -0
- package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-country-field.tsx → checkout-country-field.tsx} +10 -8
- package/src/blocks/e-commerce/checkout-forms/checkout-order-summary.tsx +230 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-payment-step.tsx +100 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-progress.tsx +62 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-review-step.tsx +59 -0
- package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-shipping-step.tsx → checkout-shipping-step.tsx} +65 -41
- package/src/blocks/e-commerce/checkout-forms/checkout.test.tsx +478 -0
- package/src/blocks/e-commerce/checkout-forms/checkout.tsx +550 -0
- package/src/blocks/{marketing/navbars/navbar-4-account-menu.test.tsx → e-commerce/navigation/storefront-navbar-account-menu.test.tsx} +8 -8
- package/src/blocks/{marketing/navbars/navbar-4-account-menu.tsx → e-commerce/navigation/storefront-navbar-account-menu.tsx} +3 -3
- package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.test.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.test.tsx} +22 -22
- package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.tsx} +3 -3
- package/src/blocks/{marketing/navbars/navbar-4-mobile-nav.tsx → e-commerce/navigation/storefront-navbar-mobile-nav.tsx} +4 -12
- package/src/blocks/{marketing/navbars/navbar-4-search-bar.tsx → e-commerce/navigation/storefront-navbar-search-bar.tsx} +3 -3
- package/src/blocks/{marketing/navbars/navbar-4.test.tsx → e-commerce/navigation/storefront-navbar.test.tsx} +40 -40
- package/src/blocks/{marketing/navbars/navbar-4.tsx → e-commerce/navigation/storefront-navbar.tsx} +17 -17
- package/src/blocks/e-commerce/product-list/product-card.tsx +63 -40
- package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +2 -2
- package/src/blocks/marketing/footers/footer-section-accessibility.test.tsx +118 -0
- package/src/blocks/marketing/footers/footer-section-actions.test.tsx +131 -0
- package/src/blocks/marketing/footers/footer-section-data.test.tsx +127 -0
- package/src/blocks/marketing/footers/footer-section-native-links.test.tsx +100 -0
- package/src/blocks/marketing/footers/footer-section-public-api.test.tsx +11 -0
- package/src/blocks/marketing/footers/footer-section.test.tsx +46 -0
- package/src/blocks/marketing/footers/footer-section.tsx +532 -0
- package/src/blocks/marketing/navbars/navbar-public-api.test.tsx +57 -0
- package/src/blocks/marketing/navbars/navbar.mega.test.tsx +149 -0
- package/src/blocks/marketing/navbars/navbar.test.tsx +155 -0
- package/src/blocks/marketing/navbars/navbar.tsx +735 -0
- package/src/blocks/marketing/testimonials/testimonial-section.test.tsx +171 -0
- package/src/blocks/marketing/testimonials/testimonial-section.tsx +237 -0
- package/src/index.ts +80 -81
- package/src/lib/contracts-curated.test.ts +5 -5
- package/src/lib/contracts.test.ts +55 -0
- package/src/lib/contracts.ts +64 -2
- package/tsup.config.ts +1 -1
- package/src/blocks/application/login-forms/login-page-1.tsx +0 -246
- package/src/blocks/application/login-forms/login-page-2.tsx +0 -207
- package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +0 -260
- package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +0 -222
- package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +0 -921
- package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +0 -57
- package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +0 -124
- package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +0 -170
- package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +0 -52
- package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +0 -57
- package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +0 -68
- package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +0 -298
- package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +0 -47
- package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +0 -9
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +0 -96
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +0 -252
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +0 -188
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +0 -36
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +0 -216
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +0 -338
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +0 -130
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +0 -175
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +0 -190
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +0 -312
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +0 -91
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +0 -110
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +0 -137
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +0 -68
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +0 -79
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +0 -172
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +0 -392
- package/src/blocks/marketing/footers/footer-section-1.test.tsx +0 -117
- package/src/blocks/marketing/footers/footer-section-1.tsx +0 -178
- package/src/blocks/marketing/footers/footer-section-3.tsx +0 -192
- package/src/blocks/marketing/footers/footer-section-4.tsx +0 -355
- package/src/blocks/marketing/navbars/navbar-1.tsx +0 -267
- package/src/blocks/marketing/navbars/navbar-2.tsx +0 -351
- package/src/blocks/marketing/navbars/navbar-3.tsx +0 -303
- package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +0 -177
- package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +0 -219
- package/src/blocks/marketing/testimonials/testimonial-1.tsx +0 -141
- package/src/blocks/marketing/testimonials/testimonial-2.tsx +0 -108
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { FooterSection, type FooterSectionProps } from './footer-section'
|
|
6
|
+
|
|
7
|
+
const presentations = ['brand-columns', 'newsletter-grid', 'contact'] as const
|
|
8
|
+
|
|
9
|
+
const sharedProps = {
|
|
10
|
+
brandName: 'Thor Provider',
|
|
11
|
+
brandDescription: 'Repuestos y soluciones para talleres.',
|
|
12
|
+
linkColumns: [
|
|
13
|
+
{
|
|
14
|
+
title: 'Repuestos',
|
|
15
|
+
links: [{ label: 'Catálogo de repuestos', href: '/repuestos' }],
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
copyright: '© 2026 Thor Provider',
|
|
19
|
+
} satisfies Omit<FooterSectionProps, 'presentation'>
|
|
20
|
+
|
|
21
|
+
describe('FooterSection', () => {
|
|
22
|
+
it('rejects a missing presentation instead of silently choosing a layout', () => {
|
|
23
|
+
expect(() =>
|
|
24
|
+
render(
|
|
25
|
+
<FooterSection
|
|
26
|
+
{...sharedProps}
|
|
27
|
+
presentation={undefined as unknown as FooterSectionProps['presentation']}
|
|
28
|
+
/>,
|
|
29
|
+
),
|
|
30
|
+
).toThrow(/presentation/i)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it.each(presentations)(
|
|
34
|
+
'renders the shared page-level footer responsibilities for %s',
|
|
35
|
+
(presentation) => {
|
|
36
|
+
render(<FooterSection {...sharedProps} presentation={presentation} />)
|
|
37
|
+
|
|
38
|
+
expect(screen.getByRole('contentinfo')).toBeTruthy()
|
|
39
|
+
expect(screen.getByText('Thor Provider')).toBeTruthy()
|
|
40
|
+
expect(screen.getByText('Repuestos y soluciones para talleres.')).toBeTruthy()
|
|
41
|
+
expect(screen.getByText('Repuestos')).toBeTruthy()
|
|
42
|
+
expect(screen.getByText('Catálogo de repuestos')).toBeTruthy()
|
|
43
|
+
expect(screen.getByText('© 2026 Thor Provider')).toBeTruthy()
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
})
|
|
@@ -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
|
+
})
|