@thorprovider/blocks 0.1.3 → 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 (99) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +73 -29
  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/product-categories/category-card-grid.test.tsx +122 -0
  7. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  8. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  9. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  10. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  11. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  12. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  13. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  14. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  15. package/src/blocks/e-commerce/product-list/product-card.tsx +424 -0
  16. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  17. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  18. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  19. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  20. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  21. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  22. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  23. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  24. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  25. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  26. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +119 -85
  27. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  28. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  29. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  30. package/src/blocks/marketing/features/feature.tsx +195 -0
  31. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +27 -9
  32. package/src/blocks/marketing/navbars/navbar-4.test.tsx +33 -2
  33. package/src/blocks/marketing/navbars/navbar-4.tsx +29 -11
  34. package/src/index.ts +77 -48
  35. package/src/lib/contracts-curated.test.ts +42 -24
  36. package/src/lib/contracts.test.ts +143 -2
  37. package/src/lib/contracts.ts +346 -14
  38. package/src/ui/button-group.tsx +1 -1
  39. package/src/ui/carousel.tsx +1 -1
  40. package/src/ui/dialog.tsx +1 -1
  41. package/src/ui/field.tsx +2 -2
  42. package/src/ui/input-group.tsx +3 -3
  43. package/src/ui/pagination.tsx +1 -1
  44. package/src/ui/sidebar.tsx +7 -7
  45. package/src/ui/toggle-group.tsx +1 -1
  46. package/tsconfig.build.json +12 -0
  47. package/tsup.config.ts +11 -7
  48. package/.turbo/turbo-build.log +0 -24
  49. package/.turbo/turbo-test.log +0 -47
  50. package/.turbo/turbo-type-check.log +0 -0
  51. package/dist/.tsbuildinfo +0 -1
  52. package/dist/contracts.d.mts +0 -1732
  53. package/dist/contracts.d.ts +0 -1732
  54. package/dist/contracts.js +0 -423
  55. package/dist/contracts.js.map +0 -1
  56. package/dist/contracts.mjs +0 -365
  57. package/dist/contracts.mjs.map +0 -1
  58. package/dist/index.d.mts +0 -3068
  59. package/dist/index.d.ts +0 -3068
  60. package/dist/index.js +0 -17834
  61. package/dist/index.js.map +0 -1
  62. package/dist/index.mjs +0 -17740
  63. package/dist/index.mjs.map +0 -1
  64. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  65. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  66. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  67. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  68. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  69. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  70. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +0 -88
  71. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -192
  72. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  73. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  74. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  75. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  76. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  77. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  78. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  79. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  80. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  81. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  82. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  83. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  84. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  85. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  86. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  87. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  88. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  89. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  90. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  91. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  92. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -229
  93. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -72
  94. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  95. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  96. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  97. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  98. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  99. 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
@@ -1,5 +1,6 @@
1
1
  import { Children, isValidElement, type ReactNode } from 'react'
2
- import { describe, expect, it } from 'vitest'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { describe, expect, it, vi } from 'vitest'
3
4
  import { Navbar4AccountMenu } from './navbar-4-account-menu'
4
5
  import { DropdownMenuItem } from '../../../ui/dropdown-menu'
5
6
 
@@ -16,23 +17,40 @@ function menuItems(node: ReactNode): Record<string, unknown>[] {
16
17
 
17
18
  const user = { name: 'Ada', email: 'ada@example.test', initials: 'A' }
18
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
+
19
35
  describe('Navbar4AccountMenu consumer actions', () => {
20
36
  it('omits help and sign-out when no consumer actions exist', () => {
21
37
  expect(menuItems(Navbar4AccountMenu({ user }))).toEqual([])
22
38
  })
23
39
 
24
40
  it('dispatches sign-out to its consumer', () => {
25
- let calls = 0
26
- const [item] = menuItems(Navbar4AccountMenu({ user, onSignOut: () => { calls++ } }))
41
+ const onSignOut = vi.fn()
42
+ const [item] = menuItems(Navbar4AccountMenu({ user, onSignOut }))
27
43
  ;(item.onSelect as () => void)()
28
- expect(calls).toBe(1)
44
+ expect(onSignOut).toHaveBeenCalledTimes(1)
29
45
  })
30
46
 
31
- it('rejects further sign-out selection while the consumer is pending', () => {
32
- let calls = 0
33
- const [item] = menuItems(Navbar4AccountMenu({ user, pending: true, onSignOut: () => { calls++ } }))
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 }))
34
50
  expect(item.disabled).toBe(true)
35
- ;(item.onSelect as () => void)()
36
- expect(calls).toBe(0)
51
+ const select = item.onSelect as () => void
52
+ select()
53
+ select()
54
+ expect(onSignOut).not.toHaveBeenCalled()
37
55
  })
38
56
  })
@@ -1,6 +1,7 @@
1
+ import type { ReactElement } from 'react'
1
2
  import { renderToStaticMarkup } from 'react-dom/server'
2
- import { describe, expect, it } from 'vitest'
3
- import { Navbar4 } from './navbar-4'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { Navbar4, Navbar4AccountRetry } from './navbar-4'
4
5
  import { Navbar4AccountMenu } from './navbar-4-account-menu'
5
6
  import { navbar4PropsSchema } from '../../../lib/contracts'
6
7
 
@@ -26,6 +27,13 @@ describe('Navbar4 — account affordance', () => {
26
27
  expect(html).not.toContain('src=""')
27
28
  })
28
29
 
30
+ it('renders the fallback initials when the identity has no avatar, never an empty image source', () => {
31
+ const html = renderToStaticMarkup(<Navbar4 {...base} user={user} />)
32
+ expect(html).toContain('>AL<')
33
+ expect(html).not.toContain('src=""')
34
+ expect(html).not.toContain('<img')
35
+ })
36
+
29
37
  it('renders no account control without identity or destination', () => {
30
38
  const html = renderToStaticMarkup(<Navbar4 {...base} />)
31
39
  expect(html).not.toContain('aria-label="Account')
@@ -57,6 +65,29 @@ describe('Navbar4 — account affordance', () => {
57
65
  })
58
66
  })
59
67
 
68
+ describe('Navbar4 — consumer-controlled session retry', () => {
69
+ type RetryProps = { onClick?: () => void; disabled?: boolean }
70
+
71
+ it('dispatches the consumer retry action from the unavailable affordance', () => {
72
+ const onRetry = vi.fn()
73
+ const control = Navbar4AccountRetry({ onRetry }) as ReactElement<RetryProps>
74
+ expect(control.props.disabled).toBe(false)
75
+ control.props.onClick?.()
76
+ expect(onRetry).toHaveBeenCalledTimes(1)
77
+ })
78
+
79
+ it('holds retry inert while reconciliation is pending', () => {
80
+ const onRetry = vi.fn()
81
+ const control = Navbar4AccountRetry({ pending: true, onRetry }) as ReactElement<RetryProps>
82
+ expect(control.props.disabled).toBe(true)
83
+ })
84
+
85
+ it('holds retry inert when no consumer callback is bound (no empty affordance)', () => {
86
+ const control = Navbar4AccountRetry({}) as ReactElement<RetryProps>
87
+ expect(control.props.disabled).toBe(true)
88
+ })
89
+ })
90
+
60
91
  describe('Navbar4 — optional logo accent', () => {
61
92
  it('renders the wordmark unchanged when no accent is supplied', () => {
62
93
  const html = renderToStaticMarkup(<Navbar4 {...base} />)
@@ -161,6 +161,34 @@ function CartBadge({ count }: CartBadgeProps) {
161
161
  )
162
162
  }
163
163
 
164
+ export interface Navbar4AccountRetryProps {
165
+ /** Consumer-owned reconciliation in flight; holds the affordance inert. */
166
+ pending?: boolean
167
+ /** Consumer-owned recovery action. Without it the affordance is inert. */
168
+ onRetry?: () => void
169
+ }
170
+
171
+ /**
172
+ * Recovery affordance for an unavailable session. The block never resolves the
173
+ * session itself: it surfaces the state and dispatches the consumer's retry.
174
+ * Extracted (hooks-free) so the dispatch is testable without a DOM.
175
+ */
176
+ export function Navbar4AccountRetry({ pending = false, onRetry }: Navbar4AccountRetryProps) {
177
+ return (
178
+ <Button
179
+ variant='ghost'
180
+ size='icon'
181
+ className='size-11'
182
+ disabled={pending || !onRetry}
183
+ aria-label='Retry account session'
184
+ title='Account session unavailable'
185
+ onClick={onRetry}
186
+ >
187
+ <RotateCcw aria-hidden='true' />
188
+ </Button>
189
+ )
190
+ }
191
+
164
192
  export interface Navbar4Props {
165
193
  storeCategories: StoreCategory[]
166
194
  /**
@@ -489,17 +517,7 @@ export function Navbar4({
489
517
  ) : null}
490
518
 
491
519
  {accountUnavailable ? (
492
- <Button
493
- variant='ghost'
494
- size='icon'
495
- className='size-11'
496
- disabled={accountPending || !onAccountRetry}
497
- aria-label='Retry account session'
498
- title='Account session unavailable'
499
- onClick={onAccountRetry}
500
- >
501
- <RotateCcw aria-hidden='true' />
502
- </Button>
520
+ <Navbar4AccountRetry pending={accountPending} onRetry={onAccountRetry} />
503
521
  ) : user ? (
504
522
  <Navbar4AccountMenu
505
523
  user={user}