@thorprovider/blocks 0.1.1 → 0.1.3
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/.turbo/turbo-build.log +15 -15
- package/.turbo/turbo-test.log +42 -5
- package/AGENTS.md +1 -1
- package/README.md +94 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/contracts.d.mts +1266 -97
- package/dist/contracts.d.ts +1266 -97
- package/dist/contracts.js +225 -9
- package/dist/contracts.js.map +1 -1
- package/dist/contracts.mjs +207 -10
- package/dist/contracts.mjs.map +1 -1
- package/dist/index.d.mts +756 -96
- package/dist/index.d.ts +756 -96
- package/dist/index.js +5114 -3702
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4939 -3551
- package/dist/index.mjs.map +1 -1
- package/docs/standards/theming.md +32 -5
- package/package.json +2 -2
- package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +136 -93
- package/src/blocks/e-commerce/category-filters/category-filter-2.test.tsx +513 -0
- package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +728 -148
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +14 -8
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.test.tsx +136 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +145 -99
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +215 -102
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +6 -2
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +51 -2
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +216 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +114 -11
- 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/mobile-category-drawer.test.tsx +42 -2
- package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +10 -8
- 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/product-category-2.test.tsx +51 -0
- package/src/blocks/e-commerce/product-categories/product-category-2.tsx +56 -36
- package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +88 -0
- package/src/blocks/e-commerce/product-list/product-card-1.tsx +141 -80
- 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-1.test.tsx +182 -0
- package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +298 -129
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +229 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +313 -110
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +72 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +20 -1
- package/src/blocks/marketing/footers/footer-section-1.test.tsx +117 -0
- package/src/blocks/marketing/footers/footer-section-1.tsx +46 -9
- package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +38 -0
- package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +204 -0
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +144 -21
- package/src/blocks/marketing/navbars/navbar-4.test.tsx +233 -0
- package/src/blocks/marketing/navbars/navbar-4.tsx +235 -60
- package/src/index.ts +56 -0
- package/src/lib/cart.test.ts +15 -0
- package/src/lib/cart.ts +11 -0
- package/src/lib/contracts-curated.test.ts +207 -0
- package/src/lib/contracts-server-boundary.test.ts +52 -0
- package/src/lib/contracts.ts +234 -6
- package/src/lib/money.test.ts +64 -0
- package/src/lib/money.ts +65 -0
- package/tsup.config.ts +0 -1
- package/vitest.config.ts +9 -1
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import StorefrontHero1, { type FeaturedProduct } from './storefront-hero-1'
|
|
4
|
+
|
|
5
|
+
const product: FeaturedProduct = {
|
|
6
|
+
id: 'p1',
|
|
7
|
+
name: 'Taladro percutor',
|
|
8
|
+
brand: 'INGCO',
|
|
9
|
+
price: 69.99,
|
|
10
|
+
originalPrice: 99.99,
|
|
11
|
+
rating: 4.8,
|
|
12
|
+
reviewCount: 236,
|
|
13
|
+
inStock: true,
|
|
14
|
+
isNew: false,
|
|
15
|
+
isOnSale: true,
|
|
16
|
+
image: '/products/drill.png',
|
|
17
|
+
colors: ['#111111'],
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const base = {
|
|
21
|
+
title: 'Potencia para cada proyecto',
|
|
22
|
+
description: 'Herramientas profesionales',
|
|
23
|
+
products: [],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('StorefrontHero1 — optional editorial image', () => {
|
|
27
|
+
it('renders no editorial image when none is supplied', () => {
|
|
28
|
+
const html = renderToStaticMarkup(<StorefrontHero1 {...base} />)
|
|
29
|
+
expect(html).not.toContain('<img')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('renders a native editorial image with its src and alt text', () => {
|
|
33
|
+
const html = renderToStaticMarkup(
|
|
34
|
+
<StorefrontHero1 {...base} heroImage={{ src: '/hero-tools.png', alt: 'Taladro inalámbrico y accesorios' }} />,
|
|
35
|
+
)
|
|
36
|
+
expect(html).toContain('src="/hero-tools.png"')
|
|
37
|
+
expect(html).toContain('alt="Taladro inalámbrico y accesorios"')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('never renders an empty image src', () => {
|
|
41
|
+
const blank = renderToStaticMarkup(
|
|
42
|
+
<StorefrontHero1 {...base} heroImage={{ src: ' ', alt: 'Vacío' }} />,
|
|
43
|
+
)
|
|
44
|
+
expect(blank).not.toContain('<img')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('preserves the product grid when the editorial image is omitted', () => {
|
|
48
|
+
const html = renderToStaticMarkup(<StorefrontHero1 {...base} products={[product]} />)
|
|
49
|
+
expect(html).toContain('Taladro percutor')
|
|
50
|
+
expect(html).toContain('src="/products/drill.png"')
|
|
51
|
+
expect(html).not.toContain('/hero-tools.png')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('keeps the product grid when the editorial image is supplied', () => {
|
|
55
|
+
const html = renderToStaticMarkup(
|
|
56
|
+
<StorefrontHero1
|
|
57
|
+
{...base}
|
|
58
|
+
products={[product]}
|
|
59
|
+
heroImage={{ src: '/hero-tools.png', alt: 'Taladro inalámbrico' }}
|
|
60
|
+
/>,
|
|
61
|
+
)
|
|
62
|
+
expect(html).toContain('src="/hero-tools.png"')
|
|
63
|
+
expect(html).toContain('Taladro percutor')
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('StorefrontHero1 — product card secondary action label', () => {
|
|
68
|
+
it('defaults the second product-card action to "Ver producto"', () => {
|
|
69
|
+
const html = renderToStaticMarkup(<StorefrontHero1 {...base} products={[product]} />)
|
|
70
|
+
expect(html).toContain('Ver producto')
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -23,6 +23,11 @@ export interface FeaturedProduct {
|
|
|
23
23
|
colors: string[]
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export interface HeroImage {
|
|
27
|
+
src: string
|
|
28
|
+
alt: string
|
|
29
|
+
}
|
|
30
|
+
|
|
26
31
|
export interface StorefrontHero1Props {
|
|
27
32
|
title: string
|
|
28
33
|
description: string
|
|
@@ -31,6 +36,12 @@ export interface StorefrontHero1Props {
|
|
|
31
36
|
badge?: string
|
|
32
37
|
primaryCta?: CTA
|
|
33
38
|
secondaryCta?: CTA
|
|
39
|
+
/**
|
|
40
|
+
* Optional editorial image. Rendered as a native `<img>` when `src` is a
|
|
41
|
+
* non-empty string; omitted entirely when absent or blank (never an empty
|
|
42
|
+
* `src`). The product grid is unaffected.
|
|
43
|
+
*/
|
|
44
|
+
heroImage?: HeroImage
|
|
34
45
|
onAddToCart?: (product: FeaturedProduct) => void
|
|
35
46
|
onBuyNow?: (product: FeaturedProduct) => void
|
|
36
47
|
onSelectColor?: (product: FeaturedProduct, index: number, color: string) => void
|
|
@@ -82,11 +93,12 @@ function StorefrontHero1({
|
|
|
82
93
|
badge,
|
|
83
94
|
primaryCta,
|
|
84
95
|
secondaryCta,
|
|
96
|
+
heroImage,
|
|
85
97
|
onAddToCart,
|
|
86
98
|
onBuyNow,
|
|
87
99
|
onSelectColor,
|
|
88
100
|
addToCartLabel = 'Agregar al carrito',
|
|
89
|
-
buyNowLabel = '
|
|
101
|
+
buyNowLabel = 'Ver producto',
|
|
90
102
|
newLabel = 'Nuevo',
|
|
91
103
|
outOfStockLabel = 'Agotado',
|
|
92
104
|
colorsLabel = 'Colores:',
|
|
@@ -137,6 +149,13 @@ function StorefrontHero1({
|
|
|
137
149
|
)}
|
|
138
150
|
</div>
|
|
139
151
|
)}
|
|
152
|
+
{heroImage && heroImage.src.trim().length > 0 ? (
|
|
153
|
+
<img
|
|
154
|
+
src={heroImage.src}
|
|
155
|
+
alt={heroImage.alt}
|
|
156
|
+
className='mt-10 aspect-video w-full rounded-lg object-cover'
|
|
157
|
+
/>
|
|
158
|
+
) : null}
|
|
140
159
|
</motion.div>
|
|
141
160
|
|
|
142
161
|
<div className='w-full max-w-2xl lg:mt-0'>
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from 'react'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { FooterSection1 } from './footer-section-1'
|
|
5
|
+
|
|
6
|
+
const base = {
|
|
7
|
+
brandName: 'ElectroCuba',
|
|
8
|
+
linkColumns: [],
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type SubmitHandler = (event: { preventDefault: () => void }) => void
|
|
12
|
+
|
|
13
|
+
function findForm(node: ReactNode): ReactElement<{ onSubmit?: SubmitHandler }> | undefined {
|
|
14
|
+
if (node === null || node === undefined || typeof node !== 'object') return undefined
|
|
15
|
+
if (Array.isArray(node)) {
|
|
16
|
+
for (const child of node) {
|
|
17
|
+
const found = findForm(child)
|
|
18
|
+
if (found) return found
|
|
19
|
+
}
|
|
20
|
+
return undefined
|
|
21
|
+
}
|
|
22
|
+
const element = node as ReactElement<{ children?: ReactNode }>
|
|
23
|
+
if (element.type === 'form') return element as ReactElement<{ onSubmit?: SubmitHandler }>
|
|
24
|
+
return findForm(element.props?.children)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('FooterSection1 — optional brand accent', () => {
|
|
28
|
+
it('renders the brand name unchanged when no accent is supplied', () => {
|
|
29
|
+
const html = renderToStaticMarkup(<FooterSection1 {...base} />)
|
|
30
|
+
expect(html).toContain('>ElectroCuba</span>')
|
|
31
|
+
expect(html).not.toContain('text-brand-promo')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('accents a matched trailing segment without duplicating the brand name', () => {
|
|
35
|
+
const html = renderToStaticMarkup(
|
|
36
|
+
<FooterSection1 {...base} brandAccent={{ text: 'Cuba', className: 'text-brand-promo' }} />,
|
|
37
|
+
)
|
|
38
|
+
expect(html).toContain('Electro<span class="text-brand-promo">Cuba</span>')
|
|
39
|
+
expect(html.match(/Electro/g)).toHaveLength(1)
|
|
40
|
+
expect(html.match(/Cuba/g)).toHaveLength(1)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('accents the full brand name when the accent covers all of it', () => {
|
|
44
|
+
const html = renderToStaticMarkup(
|
|
45
|
+
<FooterSection1 {...base} brandAccent={{ text: 'ElectroCuba', className: 'text-brand-promo' }} />,
|
|
46
|
+
)
|
|
47
|
+
expect(html).toContain('<span class="text-brand-promo">ElectroCuba</span>')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('does not accent when the segment is not a trailing match', () => {
|
|
51
|
+
const html = renderToStaticMarkup(<FooterSection1 {...base} brandAccent={{ text: 'Electro' }} />)
|
|
52
|
+
expect(html).toContain('>ElectroCuba</span>')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('FooterSection1 — newsletter requires a submit callback', () => {
|
|
57
|
+
it('renders no form, input, or button when the newsletter has no onSubmit', () => {
|
|
58
|
+
const html = renderToStaticMarkup(
|
|
59
|
+
<FooterSection1 {...base} newsletter={{ buttonLabel: 'Suscribirse', placeholder: 'tu@correo.com' }} />,
|
|
60
|
+
)
|
|
61
|
+
expect(html).not.toContain('<form')
|
|
62
|
+
expect(html).not.toContain('<input')
|
|
63
|
+
expect(html).not.toContain('<button')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('renders the interactive form when a submit handler is supplied', () => {
|
|
67
|
+
const html = renderToStaticMarkup(
|
|
68
|
+
<FooterSection1 {...base} newsletter={{ buttonLabel: 'Suscribirse', onSubmit: () => {} }} />,
|
|
69
|
+
)
|
|
70
|
+
expect(html).toContain('<form')
|
|
71
|
+
expect(html).toContain('<input')
|
|
72
|
+
expect(html).toContain('<button')
|
|
73
|
+
expect(html).toContain('Suscribirse')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('retains the supplied submit handler and forwards the email', () => {
|
|
77
|
+
const onSubmit = vi.fn()
|
|
78
|
+
const tree = FooterSection1({
|
|
79
|
+
...base,
|
|
80
|
+
newsletter: { buttonLabel: 'Suscribirse', onSubmit },
|
|
81
|
+
})
|
|
82
|
+
const form = findForm(tree)
|
|
83
|
+
expect(form).toBeDefined()
|
|
84
|
+
|
|
85
|
+
vi.stubGlobal(
|
|
86
|
+
'FormData',
|
|
87
|
+
class {
|
|
88
|
+
get() {
|
|
89
|
+
return 'shopper@example.com'
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
)
|
|
93
|
+
try {
|
|
94
|
+
form?.props.onSubmit?.({ preventDefault: () => {} })
|
|
95
|
+
} finally {
|
|
96
|
+
vi.unstubAllGlobals()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
expect(onSubmit).toHaveBeenCalledWith('shopper@example.com')
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('FooterSection1 — optional outer surface className', () => {
|
|
104
|
+
it('keeps the default surface when className is omitted', () => {
|
|
105
|
+
const html = renderToStaticMarkup(<FooterSection1 {...base} />)
|
|
106
|
+
expect(html).toContain('bg-secondary')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('merges a branded surface onto the footer and drops the default background', () => {
|
|
110
|
+
const html = renderToStaticMarkup(
|
|
111
|
+
<FooterSection1 {...base} className='bg-brand-nav text-brand-nav-foreground' />,
|
|
112
|
+
)
|
|
113
|
+
expect(html).toContain('bg-brand-nav')
|
|
114
|
+
expect(html).toContain('text-brand-nav-foreground')
|
|
115
|
+
expect(html).not.toContain('bg-secondary')
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { Input } from '../../../ui/input'
|
|
4
4
|
import { Button } from '../../../ui/button'
|
|
5
5
|
import { ArrowRight } from 'lucide-react'
|
|
6
|
+
import { cn } from '../../../lib/utils'
|
|
6
7
|
|
|
7
8
|
export interface FooterLink {
|
|
8
9
|
label: string
|
|
@@ -17,24 +18,59 @@ export interface FooterLinkColumn {
|
|
|
17
18
|
export interface FooterNewsletter {
|
|
18
19
|
placeholder?: string
|
|
19
20
|
buttonLabel: string
|
|
21
|
+
/**
|
|
22
|
+
* Submit handler. The interactive form (input + submit button) renders only
|
|
23
|
+
* when this is supplied. A newsletter without `onSubmit` renders no form,
|
|
24
|
+
* input, or button — never a fake form.
|
|
25
|
+
*/
|
|
20
26
|
onSubmit?: (email: string) => void
|
|
21
27
|
}
|
|
22
28
|
|
|
29
|
+
export interface BrandAccent {
|
|
30
|
+
/** Trailing segment of `brandName` to render with the accent styling. */
|
|
31
|
+
text: string
|
|
32
|
+
/** Consumer-supplied classes for the accent span (e.g. a brand color token). */
|
|
33
|
+
className?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
23
36
|
export interface FooterSection1Props {
|
|
24
37
|
brandName: string
|
|
38
|
+
/**
|
|
39
|
+
* Optional accent for a trailing segment of `brandName` (e.g. the "Cuba" in
|
|
40
|
+
* "ElectroCuba"). When omitted, or when `text` is not a trailing segment of
|
|
41
|
+
* `brandName`, the brand name renders unchanged.
|
|
42
|
+
*/
|
|
43
|
+
brandAccent?: BrandAccent
|
|
25
44
|
brandDescription?: string
|
|
26
45
|
linkColumns: FooterLinkColumn[]
|
|
27
46
|
newsletter?: FooterNewsletter
|
|
47
|
+
/**
|
|
48
|
+
* Consumer-supplied classes merged onto the outer `<footer>` as the final
|
|
49
|
+
* argument, so a semantic surface such as `bg-brand-nav
|
|
50
|
+
* text-brand-nav-foreground` overrides the default `bg-secondary`. Purely
|
|
51
|
+
* presentational: no layout, structure, or behavior changes.
|
|
52
|
+
*/
|
|
53
|
+
className?: string
|
|
28
54
|
}
|
|
29
55
|
|
|
30
56
|
export function FooterSection1({
|
|
31
57
|
brandName,
|
|
58
|
+
brandAccent,
|
|
32
59
|
brandDescription,
|
|
33
60
|
linkColumns,
|
|
34
61
|
newsletter,
|
|
62
|
+
className,
|
|
35
63
|
}: FooterSection1Props) {
|
|
64
|
+
const accentText = brandAccent?.text ?? ''
|
|
65
|
+
const hasAccent = accentText.length > 0 && brandName.endsWith(accentText)
|
|
66
|
+
const baseName = hasAccent ? brandName.slice(0, brandName.length - accentText.length) : brandName
|
|
67
|
+
const newsletterSubmit = newsletter?.onSubmit
|
|
68
|
+
|
|
36
69
|
return (
|
|
37
|
-
<footer
|
|
70
|
+
<footer
|
|
71
|
+
className={cn('bg-secondary w-full py-16', className)}
|
|
72
|
+
aria-label='Pie de página'
|
|
73
|
+
>
|
|
38
74
|
<div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
|
|
39
75
|
<div className='grid grid-cols-1 gap-8 lg:grid-cols-12'>
|
|
40
76
|
{/* Left Section */}
|
|
@@ -66,23 +102,24 @@ export function FooterSection1({
|
|
|
66
102
|
<circle cx='13.1' cy='26.1' r='1.7' stroke='currentColor' />
|
|
67
103
|
<circle cx='22.1' cy='26.1' r='1.7' stroke='currentColor' />
|
|
68
104
|
</svg>
|
|
69
|
-
<span className='text-xl font-semibold'>
|
|
105
|
+
<span className='text-xl font-semibold'>
|
|
106
|
+
{baseName}
|
|
107
|
+
{hasAccent ? <span className={brandAccent?.className}>{accentText}</span> : null}
|
|
108
|
+
</span>
|
|
70
109
|
</div>
|
|
71
110
|
{brandDescription && (
|
|
72
111
|
<p className='text-muted-foreground mb-8 max-w-sm text-sm leading-relaxed text-balance'>
|
|
73
112
|
{brandDescription}
|
|
74
113
|
</p>
|
|
75
114
|
)}
|
|
76
|
-
{newsletter && (
|
|
115
|
+
{newsletter && newsletterSubmit ? (
|
|
77
116
|
<form
|
|
78
117
|
className='relative'
|
|
79
118
|
onSubmit={e => {
|
|
80
119
|
e.preventDefault()
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
newsletter.onSubmit(email)
|
|
85
|
-
}
|
|
120
|
+
const email = new FormData(e.currentTarget).get('email')
|
|
121
|
+
if (typeof email === 'string') {
|
|
122
|
+
newsletterSubmit(email)
|
|
86
123
|
}
|
|
87
124
|
}}
|
|
88
125
|
>
|
|
@@ -103,7 +140,7 @@ export function FooterSection1({
|
|
|
103
140
|
<ArrowRight />
|
|
104
141
|
</Button>
|
|
105
142
|
</form>
|
|
106
|
-
)}
|
|
143
|
+
) : null}
|
|
107
144
|
</div>
|
|
108
145
|
|
|
109
146
|
{/* Right Section */}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Children, isValidElement, type ReactNode } from 'react'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { Navbar4AccountMenu } from './navbar-4-account-menu'
|
|
4
|
+
import { DropdownMenuItem } from '../../../ui/dropdown-menu'
|
|
5
|
+
|
|
6
|
+
// Inspect the block-owned menu items without mounting Radix's browser portal.
|
|
7
|
+
function menuItems(node: ReactNode): Record<string, unknown>[] {
|
|
8
|
+
return Children.toArray(node).flatMap(child => {
|
|
9
|
+
if (!isValidElement<{ children?: ReactNode }>(child)) return []
|
|
10
|
+
return [
|
|
11
|
+
...(child.type === DropdownMenuItem ? [child.props as Record<string, unknown>] : []),
|
|
12
|
+
...menuItems(child.props.children),
|
|
13
|
+
]
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const user = { name: 'Ada', email: 'ada@example.test', initials: 'A' }
|
|
18
|
+
|
|
19
|
+
describe('Navbar4AccountMenu consumer actions', () => {
|
|
20
|
+
it('omits help and sign-out when no consumer actions exist', () => {
|
|
21
|
+
expect(menuItems(Navbar4AccountMenu({ user }))).toEqual([])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('dispatches sign-out to its consumer', () => {
|
|
25
|
+
let calls = 0
|
|
26
|
+
const [item] = menuItems(Navbar4AccountMenu({ user, onSignOut: () => { calls++ } }))
|
|
27
|
+
;(item.onSelect as () => void)()
|
|
28
|
+
expect(calls).toBe(1)
|
|
29
|
+
})
|
|
30
|
+
|
|
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++ } }))
|
|
34
|
+
expect(item.disabled).toBe(true)
|
|
35
|
+
;(item.onSelect as () => void)()
|
|
36
|
+
expect(calls).toBe(0)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { Navbar4CartSheet, type CartItem } from './navbar-4-cart-sheet'
|
|
4
|
+
|
|
5
|
+
const items: CartItem[] = [
|
|
6
|
+
{ id: 'line_1', name: 'Taladro', variant: 'Rojo', price: 45, qty: 2, image: 'https://cdn.example/t.jpg' },
|
|
7
|
+
]
|
|
8
|
+
|
|
9
|
+
const formatMoney = (money: { amount: string; currencyCode: string }) =>
|
|
10
|
+
`${money.currencyCode} ${money.amount}`
|
|
11
|
+
|
|
12
|
+
const noop = () => {}
|
|
13
|
+
|
|
14
|
+
describe('Navbar4CartSheet — authoritative subtotal', () => {
|
|
15
|
+
it('never renders a fake zero for a supplied but absent/malformed subtotal', () => {
|
|
16
|
+
const html = renderToStaticMarkup(
|
|
17
|
+
<Navbar4CartSheet
|
|
18
|
+
items={items}
|
|
19
|
+
onQtyChange={noop}
|
|
20
|
+
onRemove={noop}
|
|
21
|
+
subtotal={{ amount: '', currencyCode: 'USD' }}
|
|
22
|
+
formatMoney={formatMoney}
|
|
23
|
+
/>,
|
|
24
|
+
)
|
|
25
|
+
expect(html).not.toContain('USD 0')
|
|
26
|
+
expect(html).toContain('—')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('preserves a legitimate zero subtotal', () => {
|
|
30
|
+
const html = renderToStaticMarkup(
|
|
31
|
+
<Navbar4CartSheet
|
|
32
|
+
items={items}
|
|
33
|
+
onQtyChange={noop}
|
|
34
|
+
onRemove={noop}
|
|
35
|
+
subtotal={{ amount: '0', currencyCode: 'USD' }}
|
|
36
|
+
formatMoney={formatMoney}
|
|
37
|
+
/>,
|
|
38
|
+
)
|
|
39
|
+
expect(html).toContain('USD 0')
|
|
40
|
+
expect(html).not.toContain('—')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('falls back to the legacy local subtotal only when none is supplied', () => {
|
|
44
|
+
const html = renderToStaticMarkup(
|
|
45
|
+
<Navbar4CartSheet
|
|
46
|
+
items={items}
|
|
47
|
+
onQtyChange={noop}
|
|
48
|
+
onRemove={noop}
|
|
49
|
+
formatPrice={value => `$${value.toFixed(2)}`}
|
|
50
|
+
/>,
|
|
51
|
+
)
|
|
52
|
+
expect(html).toContain('$90.00')
|
|
53
|
+
})
|
|
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
|
+
})
|