@thorprovider/blocks 0.1.2 → 1.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 +107 -0
- package/README.md +108 -23
- package/docs/standards/data-contracts.md +13 -2
- package/package.json +26 -10
- package/scripts/fix-esm-extensions.mjs +40 -0
- package/src/blocks/e-commerce/navigation/back-to-top.test.tsx +27 -0
- package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
- package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
- package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
- package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
- package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
- package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
- package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
- package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
- package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
- package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
- package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
- package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
- package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
- package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
- package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
- package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
- package/src/blocks/e-commerce/product-list/product-card.tsx +424 -0
- package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
- package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
- package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
- package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
- package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
- package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
- package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
- package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
- package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
- package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
- package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
- package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
- package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +204 -109
- package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
- package/src/blocks/marketing/features/feature.test.tsx +153 -0
- package/src/blocks/marketing/features/feature.tsx +195 -0
- package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +56 -0
- package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +150 -0
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +102 -9
- package/src/blocks/marketing/navbars/navbar-4.test.tsx +154 -2
- package/src/blocks/marketing/navbars/navbar-4.tsx +152 -32
- package/src/index.ts +93 -46
- package/src/lib/contracts-curated.test.ts +42 -24
- package/src/lib/contracts.test.ts +143 -2
- package/src/lib/contracts.ts +395 -13
- package/src/ui/button-group.tsx +1 -1
- package/src/ui/carousel.tsx +1 -1
- package/src/ui/dialog.tsx +1 -1
- package/src/ui/field.tsx +2 -2
- package/src/ui/input-group.tsx +3 -3
- package/src/ui/pagination.tsx +1 -1
- package/src/ui/sidebar.tsx +7 -7
- package/src/ui/toggle-group.tsx +1 -1
- package/tsconfig.build.json +12 -0
- package/tsup.config.ts +11 -7
- package/.turbo/turbo-build.log +0 -24
- package/.turbo/turbo-test.log +0 -37
- package/.turbo/turbo-type-check.log +0 -0
- package/dist/.tsbuildinfo +0 -1
- package/dist/contracts.d.mts +0 -1584
- package/dist/contracts.d.ts +0 -1584
- package/dist/contracts.js +0 -390
- package/dist/contracts.js.map +0 -1
- package/dist/contracts.mjs +0 -334
- package/dist/contracts.mjs.map +0 -1
- package/dist/index.d.mts +0 -2786
- package/dist/index.d.ts +0 -2786
- package/dist/index.js +0 -17139
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -17059
- package/dist/index.mjs.map +0 -1
- package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
- package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
- package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
- package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
- package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
- package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
- package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -131
- package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
- package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
- package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
- package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
- package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
- package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
- package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
- package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
- package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
- package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
- package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -137
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -65
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
- package/src/blocks/marketing/features/feature-1.tsx +0 -97
- package/src/blocks/marketing/features/feature-2.tsx +0 -82
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Zap } from 'lucide-react'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
import {
|
|
5
|
+
featureCardsSpecSchema,
|
|
6
|
+
featurePropsSchema,
|
|
7
|
+
featureSplitSpecSchema,
|
|
8
|
+
} from '../../../lib/contracts'
|
|
9
|
+
import Feature, { type FeatureItem, type FeatureProps } from './feature'
|
|
10
|
+
|
|
11
|
+
const items: FeatureItem[] = [
|
|
12
|
+
{ id: 'f1', title: 'Fast', description: 'Ships quickly.', icon: Zap },
|
|
13
|
+
{ id: 'f2', title: 'Safe', description: 'Built to last.', icon: Zap },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
const specItems = [
|
|
17
|
+
{ id: 'f1', title: 'Fast', description: 'Ships quickly.', icon: 'Zap' },
|
|
18
|
+
{ id: 'f2', title: 'Safe', description: 'Built to last.', icon: 'Zap' },
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
const render = (props: FeatureProps) => renderToStaticMarkup(<Feature {...props} />)
|
|
22
|
+
|
|
23
|
+
describe('Feature — canonical render', () => {
|
|
24
|
+
it('renders the cards structure by default (former Feature1)', () => {
|
|
25
|
+
const html = render({
|
|
26
|
+
items,
|
|
27
|
+
badge: 'Why us',
|
|
28
|
+
title: 'Benefits',
|
|
29
|
+
description: 'Everything you need',
|
|
30
|
+
learnMoreLabel: 'Learn more',
|
|
31
|
+
ctaLabel: 'See all',
|
|
32
|
+
onCtaClick: () => {},
|
|
33
|
+
onLearnMore: () => {},
|
|
34
|
+
})
|
|
35
|
+
expect(html).toContain('Why us')
|
|
36
|
+
expect(html).toContain('Benefits')
|
|
37
|
+
expect(html).toContain('Fast')
|
|
38
|
+
expect(html).toContain('Learn more')
|
|
39
|
+
expect(html).toContain('See all')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('renders the split structure with presentation: split (former Feature2)', () => {
|
|
43
|
+
const html = render({
|
|
44
|
+
items,
|
|
45
|
+
presentation: 'split',
|
|
46
|
+
title: 'Benefits',
|
|
47
|
+
description: 'Everything you need',
|
|
48
|
+
image: { src: 'https://cdn.example/img.jpg', alt: 'Workshop' },
|
|
49
|
+
className: 'bg-background',
|
|
50
|
+
})
|
|
51
|
+
expect(html).toContain('Benefits')
|
|
52
|
+
expect(html).toContain('Fast')
|
|
53
|
+
expect(html).toContain('https://cdn.example/img.jpg')
|
|
54
|
+
expect(html).toContain('grayscale')
|
|
55
|
+
expect(html).toContain('bg-background')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('omits the badge and per-item learn-more in the split presentation', () => {
|
|
59
|
+
const html = render({
|
|
60
|
+
items,
|
|
61
|
+
presentation: 'split',
|
|
62
|
+
badge: 'Why us',
|
|
63
|
+
title: 'Benefits',
|
|
64
|
+
learnMoreLabel: 'Learn more',
|
|
65
|
+
onLearnMore: () => {},
|
|
66
|
+
image: { src: 'https://cdn.example/img.jpg', alt: 'Workshop' },
|
|
67
|
+
} as unknown as FeatureProps)
|
|
68
|
+
expect(html).not.toContain('Why us')
|
|
69
|
+
expect(html).not.toContain('Learn more')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('renders no empty chrome when optional content is absent (cards)', () => {
|
|
73
|
+
const html = render({ items })
|
|
74
|
+
expect(html).not.toContain('badge')
|
|
75
|
+
expect(html).not.toContain('Learn more')
|
|
76
|
+
expect(html).not.toContain('See all')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('rejects split without an image at the serialized boundary', () => {
|
|
80
|
+
expect(() =>
|
|
81
|
+
featureSplitSpecSchema.parse({ items: specItems, presentation: 'split' }),
|
|
82
|
+
).toThrow()
|
|
83
|
+
expect(() =>
|
|
84
|
+
featurePropsSchema.parse({ items: specItems, presentation: 'split' }),
|
|
85
|
+
).toThrow()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('rejects split with a blank image source', () => {
|
|
89
|
+
expect(() =>
|
|
90
|
+
featureSplitSpecSchema.parse({
|
|
91
|
+
items: specItems,
|
|
92
|
+
presentation: 'split',
|
|
93
|
+
image: { src: ' ', alt: 'Workshop' },
|
|
94
|
+
}),
|
|
95
|
+
).toThrow()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('accepts cards without an image at the serialized boundary', () => {
|
|
99
|
+
expect(() => featurePropsSchema.parse({ items: specItems })).not.toThrow()
|
|
100
|
+
expect(() =>
|
|
101
|
+
featurePropsSchema.parse({ items: specItems, presentation: 'cards' }),
|
|
102
|
+
).not.toThrow()
|
|
103
|
+
expect(() =>
|
|
104
|
+
featureCardsSpecSchema.parse({ items: specItems }),
|
|
105
|
+
).not.toThrow()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('throws a configuration error for untyped split callers without an image', () => {
|
|
109
|
+
expect(() =>
|
|
110
|
+
render({ items, presentation: 'split' } as unknown as FeatureProps),
|
|
111
|
+
).toThrow(/presentation="split".*image/)
|
|
112
|
+
expect(() =>
|
|
113
|
+
render({
|
|
114
|
+
items,
|
|
115
|
+
presentation: 'split',
|
|
116
|
+
image: { src: ' ', alt: 'Workshop' },
|
|
117
|
+
} as unknown as FeatureProps),
|
|
118
|
+
).toThrow(/presentation="split".*image/)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('renders one image with the supplied src and alt and never emits an empty src', () => {
|
|
122
|
+
const html = render({
|
|
123
|
+
items,
|
|
124
|
+
presentation: 'split',
|
|
125
|
+
image: { src: 'https://cdn.example/img.jpg', alt: 'Workshop' },
|
|
126
|
+
})
|
|
127
|
+
expect(html).toContain('<img')
|
|
128
|
+
expect(html).toContain('src="https://cdn.example/img.jpg"')
|
|
129
|
+
expect(html).toContain('alt="Workshop"')
|
|
130
|
+
expect(html.match(/<img/g)).toHaveLength(1)
|
|
131
|
+
expect(html).not.toContain('src=""')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('renders an empty items list without item chrome or errors', () => {
|
|
135
|
+
const html = render({ items: [], title: 'Benefits' })
|
|
136
|
+
expect(html).toContain('Benefits')
|
|
137
|
+
expect(html).not.toContain('<article')
|
|
138
|
+
expect(html).not.toContain('<h3')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('validates a serialized props object against featurePropsSchema', () => {
|
|
142
|
+
const spec = {
|
|
143
|
+
items: [
|
|
144
|
+
{ id: 'f1', title: 'Fast', description: 'Ships quickly.', icon: 'Zap' },
|
|
145
|
+
],
|
|
146
|
+
presentation: 'split',
|
|
147
|
+
title: 'Benefits',
|
|
148
|
+
image: { src: 'https://cdn.example/img.jpg', alt: 'Workshop' },
|
|
149
|
+
}
|
|
150
|
+
const parsed = featurePropsSchema.parse(spec)
|
|
151
|
+
expect(parsed.items[0]?.icon).toBe('Zap')
|
|
152
|
+
})
|
|
153
|
+
})
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { Badge } from '../../../ui/badge'
|
|
2
|
+
import { Button } from '../../../ui/button'
|
|
3
|
+
import { Card, CardContent } from '../../../ui/card'
|
|
4
|
+
import { ArrowRight } from 'lucide-react'
|
|
5
|
+
import type { LucideIcon } from 'lucide-react'
|
|
6
|
+
import { cn } from '../../../lib/utils'
|
|
7
|
+
|
|
8
|
+
export interface FeatureItem {
|
|
9
|
+
id: string
|
|
10
|
+
title: string
|
|
11
|
+
description: string
|
|
12
|
+
icon: LucideIcon
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The bounded presentation surface. `cards` is the card grid with a centered
|
|
17
|
+
* header, per-item learn-more and a section CTA; `split` is the editorial
|
|
18
|
+
* split layout with a required image column and deliberately ignores the
|
|
19
|
+
* badge and per-item learn-more. The value changes structure, never the item
|
|
20
|
+
* contract.
|
|
21
|
+
*/
|
|
22
|
+
export type FeaturePresentation = 'cards' | 'split'
|
|
23
|
+
|
|
24
|
+
interface FeatureBaseProps {
|
|
25
|
+
items: FeatureItem[]
|
|
26
|
+
title?: string
|
|
27
|
+
description?: string
|
|
28
|
+
className?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FeatureCardsProps extends FeatureBaseProps {
|
|
32
|
+
/** Bounded presentation option; defaults to `cards`. */
|
|
33
|
+
presentation?: 'cards'
|
|
34
|
+
/** `cards`-only header chrome. */
|
|
35
|
+
badge?: string
|
|
36
|
+
/** Section CTA (`cards` only). */
|
|
37
|
+
ctaLabel?: string
|
|
38
|
+
onCtaClick?: () => void
|
|
39
|
+
/** Per-item link (`cards` only). */
|
|
40
|
+
learnMoreLabel?: string
|
|
41
|
+
onLearnMore?: (item: FeatureItem) => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface FeatureSplitProps extends FeatureBaseProps {
|
|
45
|
+
presentation: 'split'
|
|
46
|
+
/** Required editorial image; a non-empty `src` is part of the contract. */
|
|
47
|
+
image: { src: string; alt: string }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type FeatureProps = FeatureCardsProps | FeatureSplitProps
|
|
51
|
+
|
|
52
|
+
function SplitFeature({ items, title, description, image, className }: FeatureSplitProps) {
|
|
53
|
+
if (!image?.src || image.src.trim() === '') {
|
|
54
|
+
throw new Error(
|
|
55
|
+
'Feature: presentation="split" requires a non-empty image.src. Pass { image: { src, alt } } or use presentation="cards".',
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<section
|
|
61
|
+
className={cn('mx-auto w-full max-w-7xl px-4 py-12 sm:px-6 md:py-16 lg:px-8', className)}
|
|
62
|
+
>
|
|
63
|
+
<div className='grid gap-12 lg:grid-cols-2 lg:gap-16'>
|
|
64
|
+
<div className='flex flex-col gap-10'>
|
|
65
|
+
{(title || description) && (
|
|
66
|
+
<header className='flex flex-col gap-4'>
|
|
67
|
+
{title && (
|
|
68
|
+
<h2 className='text-3xl font-bold tracking-tight text-balance sm:text-4xl lg:text-5xl'>
|
|
69
|
+
{title}
|
|
70
|
+
</h2>
|
|
71
|
+
)}
|
|
72
|
+
{description && (
|
|
73
|
+
<p className='text-muted-foreground text-base text-pretty md:text-lg'>
|
|
74
|
+
{description}
|
|
75
|
+
</p>
|
|
76
|
+
)}
|
|
77
|
+
</header>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
<div className='grid gap-6 sm:grid-cols-2 sm:gap-8'>
|
|
81
|
+
{items.map(item => (
|
|
82
|
+
<div key={item.id} className='group flex flex-col items-start gap-3 rounded-lg'>
|
|
83
|
+
<div className='bg-secondary text-secondary-foreground inline-flex size-10 items-center justify-center rounded-full'>
|
|
84
|
+
<item.icon className='size-5' aria-hidden='true' />
|
|
85
|
+
</div>
|
|
86
|
+
<div className='flex flex-col gap-1.5'>
|
|
87
|
+
<h3 className='text-foreground text-base font-semibold md:text-lg'>{item.title}</h3>
|
|
88
|
+
<p className='text-muted-foreground text-sm text-balance'>{item.description}</p>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
))}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div className='relative'>
|
|
96
|
+
<div className='bg-muted relative z-10 overflow-hidden rounded-lg shadow-sm transition-all duration-300 hover:shadow-md'>
|
|
97
|
+
<div className='aspect-[4/3] w-full'>
|
|
98
|
+
<img
|
|
99
|
+
src={image.src}
|
|
100
|
+
alt={image.alt}
|
|
101
|
+
className='size-full object-cover grayscale'
|
|
102
|
+
loading='lazy'
|
|
103
|
+
decoding='async'
|
|
104
|
+
/>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</section>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function CardsFeature({
|
|
114
|
+
items,
|
|
115
|
+
badge,
|
|
116
|
+
title,
|
|
117
|
+
description,
|
|
118
|
+
ctaLabel,
|
|
119
|
+
onCtaClick,
|
|
120
|
+
learnMoreLabel,
|
|
121
|
+
onLearnMore,
|
|
122
|
+
className,
|
|
123
|
+
}: FeatureCardsProps) {
|
|
124
|
+
return (
|
|
125
|
+
<section
|
|
126
|
+
className={cn('mx-auto w-full max-w-7xl px-4 py-12 sm:px-6 md:py-16 lg:px-8', className)}
|
|
127
|
+
>
|
|
128
|
+
{(badge || title || description) && (
|
|
129
|
+
<header className='mx-auto mb-12 max-w-2xl text-center'>
|
|
130
|
+
{badge && (
|
|
131
|
+
<Badge variant='secondary' className='h-auto mb-4 rounded-full px-4 py-1 font-medium'>
|
|
132
|
+
{badge}
|
|
133
|
+
</Badge>
|
|
134
|
+
)}
|
|
135
|
+
{title && (
|
|
136
|
+
<h2 className='mb-4 text-3xl font-bold tracking-tight sm:text-4xl'>{title}</h2>
|
|
137
|
+
)}
|
|
138
|
+
{description && (
|
|
139
|
+
<p className='text-muted-foreground text-pretty'>{description}</p>
|
|
140
|
+
)}
|
|
141
|
+
</header>
|
|
142
|
+
)}
|
|
143
|
+
|
|
144
|
+
<div className='grid gap-4 sm:grid-cols-2 sm:gap-6 md:grid-cols-3'>
|
|
145
|
+
{items.map(feature => (
|
|
146
|
+
<article key={feature.id} className='group'>
|
|
147
|
+
<Card className='relative h-full overflow-hidden transition-all hover:shadow-md'>
|
|
148
|
+
<CardContent className='px-6'>
|
|
149
|
+
<div className='bg-secondary text-secondary-foreground mb-4 inline-flex size-12 items-center justify-center rounded-xl'>
|
|
150
|
+
<feature.icon className='size-5' aria-hidden='true' />
|
|
151
|
+
</div>
|
|
152
|
+
<h3 className='mb-2 text-lg font-semibold'>{feature.title}</h3>
|
|
153
|
+
<p className='text-muted-foreground mb-4 text-sm'>{feature.description}</p>
|
|
154
|
+
|
|
155
|
+
{learnMoreLabel && (
|
|
156
|
+
<Button
|
|
157
|
+
variant='link'
|
|
158
|
+
size='sm'
|
|
159
|
+
className='h-8 px-3 text-xs text-muted-foreground hover:text-foreground h-auto cursor-pointer !p-0 text-sm'
|
|
160
|
+
onClick={onLearnMore ? () => onLearnMore(feature) : undefined}
|
|
161
|
+
>
|
|
162
|
+
{learnMoreLabel}
|
|
163
|
+
<ArrowRight />
|
|
164
|
+
</Button>
|
|
165
|
+
)}
|
|
166
|
+
</CardContent>
|
|
167
|
+
</Card>
|
|
168
|
+
</article>
|
|
169
|
+
))}
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
{ctaLabel && (
|
|
173
|
+
<div className='mt-12 text-center'>
|
|
174
|
+
<Button
|
|
175
|
+
size='lg'
|
|
176
|
+
className='h-10 cursor-pointer rounded-lg !px-8'
|
|
177
|
+
onClick={onCtaClick}
|
|
178
|
+
>
|
|
179
|
+
{ctaLabel}
|
|
180
|
+
<ArrowRight />
|
|
181
|
+
</Button>
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
</section>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function Feature(props: FeatureProps) {
|
|
189
|
+
if (props.presentation === 'split') {
|
|
190
|
+
return <SplitFeature {...props} />
|
|
191
|
+
}
|
|
192
|
+
return <CardsFeature {...props} />
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export default Feature
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Children, isValidElement, type ReactNode } from 'react'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { Navbar4AccountMenu } from './navbar-4-account-menu'
|
|
5
|
+
import { DropdownMenuItem } from '../../../ui/dropdown-menu'
|
|
6
|
+
|
|
7
|
+
// Inspect the block-owned menu items without mounting Radix's browser portal.
|
|
8
|
+
function menuItems(node: ReactNode): Record<string, unknown>[] {
|
|
9
|
+
return Children.toArray(node).flatMap(child => {
|
|
10
|
+
if (!isValidElement<{ children?: ReactNode }>(child)) return []
|
|
11
|
+
return [
|
|
12
|
+
...(child.type === DropdownMenuItem ? [child.props as Record<string, unknown>] : []),
|
|
13
|
+
...menuItems(child.props.children),
|
|
14
|
+
]
|
|
15
|
+
})
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const user = { name: 'Ada', email: 'ada@example.test', initials: 'A' }
|
|
19
|
+
|
|
20
|
+
describe('Navbar4AccountMenu absent avatar', () => {
|
|
21
|
+
it('renders the fallback initials, never an empty image source, when avatar is absent', () => {
|
|
22
|
+
const html = renderToStaticMarkup(<Navbar4AccountMenu user={user} />)
|
|
23
|
+
expect(html).toContain('>A<')
|
|
24
|
+
expect(html).not.toContain('src=""')
|
|
25
|
+
expect(html).not.toContain('<img')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('treats an empty avatar string as absent rather than an empty source', () => {
|
|
29
|
+
const html = renderToStaticMarkup(<Navbar4AccountMenu user={{ ...user, avatar: '' }} />)
|
|
30
|
+
expect(html).toContain('>A<')
|
|
31
|
+
expect(html).not.toContain('src=""')
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('Navbar4AccountMenu consumer actions', () => {
|
|
36
|
+
it('omits help and sign-out when no consumer actions exist', () => {
|
|
37
|
+
expect(menuItems(Navbar4AccountMenu({ user }))).toEqual([])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('dispatches sign-out to its consumer', () => {
|
|
41
|
+
const onSignOut = vi.fn()
|
|
42
|
+
const [item] = menuItems(Navbar4AccountMenu({ user, onSignOut }))
|
|
43
|
+
;(item.onSelect as () => void)()
|
|
44
|
+
expect(onSignOut).toHaveBeenCalledTimes(1)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('prevents duplicate sign-out while the consumer reports the logout pending', () => {
|
|
48
|
+
const onSignOut = vi.fn()
|
|
49
|
+
const [item] = menuItems(Navbar4AccountMenu({ user, pending: true, onSignOut }))
|
|
50
|
+
expect(item.disabled).toBe(true)
|
|
51
|
+
const select = item.onSelect as () => void
|
|
52
|
+
select()
|
|
53
|
+
select()
|
|
54
|
+
expect(onSignOut).not.toHaveBeenCalled()
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
export interface StoreUser {
|
|
16
16
|
name: string
|
|
17
17
|
email: string
|
|
18
|
-
avatar
|
|
18
|
+
avatar?: string
|
|
19
19
|
initials: string
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -33,6 +33,7 @@ export interface Navbar4AccountMenuProps {
|
|
|
33
33
|
items?: AccountMenuItem[]
|
|
34
34
|
onHelp?: () => void
|
|
35
35
|
onSignOut?: () => void
|
|
36
|
+
pending?: boolean
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
const accountIcons = {
|
|
@@ -47,6 +48,7 @@ export function Navbar4AccountMenu({
|
|
|
47
48
|
items = [],
|
|
48
49
|
onHelp,
|
|
49
50
|
onSignOut,
|
|
51
|
+
pending = false,
|
|
50
52
|
}: Navbar4AccountMenuProps) {
|
|
51
53
|
if (!user) {
|
|
52
54
|
return null
|
|
@@ -58,7 +60,8 @@ export function Navbar4AccountMenu({
|
|
|
58
60
|
<Button
|
|
59
61
|
variant='ghost'
|
|
60
62
|
size='icon'
|
|
61
|
-
className='rounded-full'
|
|
63
|
+
className='size-11 rounded-full'
|
|
64
|
+
disabled={pending}
|
|
62
65
|
aria-label={`Account menu for ${user.name}`}
|
|
63
66
|
>
|
|
64
67
|
<Avatar size='sm' className='pointer-events-none size-8'>
|
|
@@ -85,11 +88,11 @@ export function Navbar4AccountMenu({
|
|
|
85
88
|
</div>
|
|
86
89
|
</div>
|
|
87
90
|
</DropdownMenuLabel>
|
|
88
|
-
<DropdownMenuSeparator />
|
|
91
|
+
{items.length > 0 ? <DropdownMenuSeparator /> : null}
|
|
89
92
|
{items.map(item => {
|
|
90
93
|
const Icon = item.icon ? accountIcons[item.icon] : Package
|
|
91
94
|
return (
|
|
92
|
-
<DropdownMenuItem key={item.href} asChild className='gap-2.5 px-2 py-2'>
|
|
95
|
+
<DropdownMenuItem key={item.href} asChild disabled={pending} className='gap-2.5 px-2 py-2'>
|
|
93
96
|
<a href={item.href}>
|
|
94
97
|
<Icon className='text-muted-foreground size-4' />
|
|
95
98
|
<span className='flex min-w-0 flex-col'>
|
|
@@ -102,17 +105,30 @@ export function Navbar4AccountMenu({
|
|
|
102
105
|
</DropdownMenuItem>
|
|
103
106
|
)
|
|
104
107
|
})}
|
|
105
|
-
<DropdownMenuSeparator />
|
|
106
|
-
|
|
107
|
-
<
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
108
|
+
{onHelp || onSignOut ? <DropdownMenuSeparator /> : null}
|
|
109
|
+
{onHelp ? (
|
|
110
|
+
<DropdownMenuItem
|
|
111
|
+
disabled={pending}
|
|
112
|
+
className='gap-2.5 px-2 py-2'
|
|
113
|
+
onSelect={() => { if (!pending) onHelp() }}
|
|
114
|
+
>
|
|
115
|
+
<HelpCircle className='text-muted-foreground size-4' />
|
|
116
|
+
Help & support
|
|
117
|
+
</DropdownMenuItem>
|
|
118
|
+
) : null}
|
|
119
|
+
{onSignOut ? (
|
|
120
|
+
<DropdownMenuItem
|
|
121
|
+
disabled={pending}
|
|
122
|
+
variant='destructive'
|
|
123
|
+
className='gap-2.5 px-2 py-2'
|
|
124
|
+
onSelect={() => { if (!pending) onSignOut() }}
|
|
125
|
+
>
|
|
126
|
+
<LogOut className='size-4' />
|
|
127
|
+
Sign out
|
|
128
|
+
</DropdownMenuItem>
|
|
129
|
+
) : null}
|
|
114
130
|
</DropdownMenuGroup>
|
|
115
131
|
</DropdownMenuContent>
|
|
116
132
|
</DropdownMenu>
|
|
117
133
|
)
|
|
118
|
-
}
|
|
134
|
+
}
|
|
@@ -52,3 +52,153 @@ describe('Navbar4CartSheet — authoritative subtotal', () => {
|
|
|
52
52
|
expect(html).toContain('$90.00')
|
|
53
53
|
})
|
|
54
54
|
})
|
|
55
|
+
|
|
56
|
+
describe('Navbar4CartSheet — canonical line amounts', () => {
|
|
57
|
+
it('renders the canonical backend line amount in its currency, not price times qty', () => {
|
|
58
|
+
const html = renderToStaticMarkup(
|
|
59
|
+
<Navbar4CartSheet
|
|
60
|
+
items={[{ ...items[0], price: 0, lineTotal: { amount: '90.00', currencyCode: 'EUR' } }]}
|
|
61
|
+
onQtyChange={noop}
|
|
62
|
+
onRemove={noop}
|
|
63
|
+
canonical
|
|
64
|
+
formatMoney={formatMoney}
|
|
65
|
+
/>,
|
|
66
|
+
)
|
|
67
|
+
expect(html).toContain('EUR 90.00')
|
|
68
|
+
expect(html).not.toContain('USD 0')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('never falls back to a numeric amount when a canonical line amount is missing', () => {
|
|
72
|
+
const html = renderToStaticMarkup(
|
|
73
|
+
<Navbar4CartSheet
|
|
74
|
+
items={[{ ...items[0], price: 45, lineTotal: undefined }]}
|
|
75
|
+
onQtyChange={noop}
|
|
76
|
+
onRemove={noop}
|
|
77
|
+
canonical
|
|
78
|
+
formatPrice={value => `$${value.toFixed(2)}`}
|
|
79
|
+
/>,
|
|
80
|
+
)
|
|
81
|
+
expect(html).not.toContain('$90.00')
|
|
82
|
+
expect(html).toContain('—')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('shows the unavailable label for a malformed canonical line amount', () => {
|
|
86
|
+
const html = renderToStaticMarkup(
|
|
87
|
+
<Navbar4CartSheet
|
|
88
|
+
items={[{ ...items[0], lineTotal: { amount: '', currencyCode: 'USD' } }]}
|
|
89
|
+
onQtyChange={noop}
|
|
90
|
+
onRemove={noop}
|
|
91
|
+
canonical
|
|
92
|
+
formatMoney={formatMoney}
|
|
93
|
+
/>,
|
|
94
|
+
)
|
|
95
|
+
expect(html).not.toContain('USD 0')
|
|
96
|
+
expect(html).toContain('—')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('preserves legacy numeric rendering when canonical mode is omitted', () => {
|
|
100
|
+
const html = renderToStaticMarkup(
|
|
101
|
+
<Navbar4CartSheet
|
|
102
|
+
items={[{ ...items[0], lineTotal: { amount: '90.00', currencyCode: 'EUR' } }]}
|
|
103
|
+
onQtyChange={noop}
|
|
104
|
+
onRemove={noop}
|
|
105
|
+
formatPrice={value => `$${value.toFixed(2)}`}
|
|
106
|
+
/>,
|
|
107
|
+
)
|
|
108
|
+
expect(html).toContain('$90.00')
|
|
109
|
+
expect(html).not.toContain('EUR 90.00')
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('Navbar4CartSheet — read/mutation lifecycle', () => {
|
|
114
|
+
it('shows a pending state instead of claiming an empty cart', () => {
|
|
115
|
+
const html = renderToStaticMarkup(
|
|
116
|
+
<Navbar4CartSheet items={[]} onQtyChange={noop} onRemove={noop} state='pending' />,
|
|
117
|
+
)
|
|
118
|
+
expect(html).toContain('Actualizando el carrito')
|
|
119
|
+
expect(html).not.toContain('Your cart is empty')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('shows a recoverable error state with its retry action', () => {
|
|
123
|
+
const html = renderToStaticMarkup(
|
|
124
|
+
<Navbar4CartSheet
|
|
125
|
+
items={items}
|
|
126
|
+
onQtyChange={noop}
|
|
127
|
+
onRemove={noop}
|
|
128
|
+
state='error'
|
|
129
|
+
errorLabel='No se pudo leer el carrito.'
|
|
130
|
+
retryLabel='Reintentar'
|
|
131
|
+
onRetry={noop}
|
|
132
|
+
/>,
|
|
133
|
+
)
|
|
134
|
+
expect(html).toContain('No se pudo leer el carrito.')
|
|
135
|
+
expect(html).toContain('Reintentar')
|
|
136
|
+
expect(html).toContain('role="alert"')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('disables mutation controls while pending', () => {
|
|
140
|
+
const html = renderToStaticMarkup(
|
|
141
|
+
<Navbar4CartSheet items={items} onQtyChange={noop} onRemove={noop} state='pending' />,
|
|
142
|
+
)
|
|
143
|
+
expect(html).toContain('aria-label="Increase quantity, Taladro"')
|
|
144
|
+
expect(html).toContain('disabled')
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('Navbar4CartSheet — action availability', () => {
|
|
149
|
+
it('suppresses the checkout action when checkout is not installed', () => {
|
|
150
|
+
const html = renderToStaticMarkup(
|
|
151
|
+
<Navbar4CartSheet
|
|
152
|
+
items={items}
|
|
153
|
+
onQtyChange={noop}
|
|
154
|
+
onRemove={noop}
|
|
155
|
+
checkoutUnavailable
|
|
156
|
+
onCheckout={noop}
|
|
157
|
+
/>,
|
|
158
|
+
)
|
|
159
|
+
expect(html).not.toContain('>Checkout</button>')
|
|
160
|
+
expect(html).toContain('View cart')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('disables the checkout action while the cart is not ready', () => {
|
|
164
|
+
const html = renderToStaticMarkup(
|
|
165
|
+
<Navbar4CartSheet items={items} onQtyChange={noop} onRemove={noop} checkoutDisabled onCheckout={noop} />,
|
|
166
|
+
)
|
|
167
|
+
expect(html).toContain('>Checkout</button>')
|
|
168
|
+
expect(html).toMatch(/disabled[^>]*>Checkout<\/button>/)
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('suppresses the review action when the cart view is not installed', () => {
|
|
172
|
+
const html = renderToStaticMarkup(
|
|
173
|
+
<Navbar4CartSheet
|
|
174
|
+
items={items}
|
|
175
|
+
onQtyChange={noop}
|
|
176
|
+
onRemove={noop}
|
|
177
|
+
viewCartUnavailable
|
|
178
|
+
onViewCart={noop}
|
|
179
|
+
/>,
|
|
180
|
+
)
|
|
181
|
+
expect(html).not.toContain('View cart')
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
describe('Navbar4CartSheet — accessibility', () => {
|
|
186
|
+
it('names the line mutation controls', () => {
|
|
187
|
+
const html = renderToStaticMarkup(
|
|
188
|
+
<Navbar4CartSheet items={items} onQtyChange={noop} onRemove={noop} />,
|
|
189
|
+
)
|
|
190
|
+
expect(html).toMatch(/aria-label="[^"]*(Increase|Decrease|Aumentar|Disminuir)[^"]*"/)
|
|
191
|
+
expect(html).toMatch(/aria-label="[^"]*(Remove|Quitar)[^"]*"/)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('announces pending reads politely and errors as an alert', () => {
|
|
195
|
+
const pending = renderToStaticMarkup(
|
|
196
|
+
<Navbar4CartSheet items={[]} onQtyChange={noop} onRemove={noop} state='pending' />,
|
|
197
|
+
)
|
|
198
|
+
expect(pending).toMatch(/role="status"[^>]*aria-live="polite"/)
|
|
199
|
+
const error = renderToStaticMarkup(
|
|
200
|
+
<Navbar4CartSheet items={items} onQtyChange={noop} onRemove={noop} state='error' />,
|
|
201
|
+
)
|
|
202
|
+
expect(error).toMatch(/role="alert"/)
|
|
203
|
+
})
|
|
204
|
+
})
|