@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.
Files changed (111) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +108 -23
  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/e-commerce/navigation/back-to-top.test.tsx +27 -0
  7. package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
  8. package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
  9. package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
  10. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
  11. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
  12. package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
  13. package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
  14. package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
  15. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  16. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  17. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  18. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  19. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  20. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  21. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  22. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  23. package/src/blocks/e-commerce/product-list/product-card.tsx +424 -0
  24. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  25. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  26. package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
  27. package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
  28. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  29. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  30. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  31. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  32. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  33. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  34. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  35. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  36. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +204 -109
  37. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  38. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  39. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  40. package/src/blocks/marketing/features/feature.tsx +195 -0
  41. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +56 -0
  42. package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
  43. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +150 -0
  44. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +102 -9
  45. package/src/blocks/marketing/navbars/navbar-4.test.tsx +154 -2
  46. package/src/blocks/marketing/navbars/navbar-4.tsx +152 -32
  47. package/src/index.ts +93 -46
  48. package/src/lib/contracts-curated.test.ts +42 -24
  49. package/src/lib/contracts.test.ts +143 -2
  50. package/src/lib/contracts.ts +395 -13
  51. package/src/ui/button-group.tsx +1 -1
  52. package/src/ui/carousel.tsx +1 -1
  53. package/src/ui/dialog.tsx +1 -1
  54. package/src/ui/field.tsx +2 -2
  55. package/src/ui/input-group.tsx +3 -3
  56. package/src/ui/pagination.tsx +1 -1
  57. package/src/ui/sidebar.tsx +7 -7
  58. package/src/ui/toggle-group.tsx +1 -1
  59. package/tsconfig.build.json +12 -0
  60. package/tsup.config.ts +11 -7
  61. package/.turbo/turbo-build.log +0 -24
  62. package/.turbo/turbo-test.log +0 -37
  63. package/.turbo/turbo-type-check.log +0 -0
  64. package/dist/.tsbuildinfo +0 -1
  65. package/dist/contracts.d.mts +0 -1584
  66. package/dist/contracts.d.ts +0 -1584
  67. package/dist/contracts.js +0 -390
  68. package/dist/contracts.js.map +0 -1
  69. package/dist/contracts.mjs +0 -334
  70. package/dist/contracts.mjs.map +0 -1
  71. package/dist/index.d.mts +0 -2786
  72. package/dist/index.d.ts +0 -2786
  73. package/dist/index.js +0 -17139
  74. package/dist/index.js.map +0 -1
  75. package/dist/index.mjs +0 -17059
  76. package/dist/index.mjs.map +0 -1
  77. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  78. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  79. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  80. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  81. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  82. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  83. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -131
  84. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  85. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  86. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  87. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  88. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  89. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  90. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  91. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  92. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  93. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  94. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  95. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  96. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  97. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  98. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  99. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  100. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  101. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  102. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  103. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  104. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -137
  105. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -65
  106. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  107. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  108. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  109. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  110. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  111. 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: string
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
- <DropdownMenuItem className='gap-2.5 px-2 py-2' onSelect={onHelp}>
107
- <HelpCircle className='text-muted-foreground size-4' />
108
- Help & support
109
- </DropdownMenuItem>
110
- <DropdownMenuItem variant='destructive' className='gap-2.5 px-2 py-2' onSelect={onSignOut}>
111
- <LogOut className='size-4' />
112
- Sign out
113
- </DropdownMenuItem>
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
+ })