@thorprovider/blocks 0.1.0 → 0.1.2
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 +32 -5
- package/AGENTS.md +1 -1
- package/README.md +52 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/contracts.d.mts +1111 -90
- package/dist/contracts.d.ts +1111 -90
- package/dist/contracts.js +189 -6
- package/dist/contracts.js.map +1 -1
- package/dist/contracts.mjs +173 -7
- package/dist/contracts.mjs.map +1 -1
- package/dist/index.d.mts +467 -89
- package/dist/index.d.ts +467 -89
- package/dist/index.js +2967 -2250
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2989 -2282
- package/dist/index.mjs.map +1 -1
- package/docs/standards/theming.md +32 -5
- package/package.json +4 -3
- 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/mobile-category-drawer.test.tsx +42 -2
- package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +10 -8
- 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-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 +137 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +231 -89
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +65 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +19 -0
- 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-cart-sheet.test.tsx +54 -0
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +43 -13
- package/src/blocks/marketing/navbars/navbar-4.test.tsx +112 -0
- package/src/blocks/marketing/navbars/navbar-4.tsx +101 -28
- package/src/index.ts +38 -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 +182 -4
- package/src/lib/money.test.ts +64 -0
- package/src/lib/money.ts +65 -0
- package/styles.css +1 -0
- package/tsup.config.ts +0 -1
- package/vitest.config.ts +9 -1
|
@@ -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,54 @@
|
|
|
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
|
+
})
|
|
@@ -15,21 +15,37 @@ import {
|
|
|
15
15
|
} from '../../../ui/empty'
|
|
16
16
|
import { Separator } from '../../../ui/separator'
|
|
17
17
|
import { SheetClose } from '../../../ui/sheet'
|
|
18
|
+
import type { Money } from '@thorprovider/types'
|
|
19
|
+
import { formatMoney } from '../../../lib/money'
|
|
18
20
|
|
|
19
21
|
export interface CartItem {
|
|
22
|
+
/** Backend cart line-item id — the stable identity for update/removal. */
|
|
20
23
|
id: string
|
|
21
24
|
name: string
|
|
22
|
-
variant
|
|
25
|
+
variant?: string
|
|
23
26
|
price: number
|
|
24
27
|
qty: number
|
|
25
|
-
image
|
|
28
|
+
/** Optional; an absent image is omitted rather than serialized as `""`. */
|
|
29
|
+
image?: string
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
export interface Navbar4CartSheetProps {
|
|
29
33
|
items: CartItem[]
|
|
34
|
+
/**
|
|
35
|
+
* Quantity DELTA for a backend cart line-item id. The boundary converts the
|
|
36
|
+
* delta into an absolute quantity against the authoritative current line
|
|
37
|
+
* quantity before mutating the cart.
|
|
38
|
+
*/
|
|
30
39
|
onQtyChange: (id: string, delta: number) => void
|
|
40
|
+
/** Remove the backend cart line-item id. */
|
|
31
41
|
onRemove: (id: string) => void
|
|
32
|
-
/**
|
|
42
|
+
/** Authoritative backend subtotal. Preferred over any local line math. */
|
|
43
|
+
subtotal?: Money
|
|
44
|
+
/** Shown when an authoritative subtotal is supplied but absent/malformed. */
|
|
45
|
+
subtotalUnavailableLabel?: string
|
|
46
|
+
/** Override money formatting (locale, currency). Defaults to `Money.currencyCode`. */
|
|
47
|
+
formatMoney?: (money: Money) => string
|
|
48
|
+
/** @deprecated legacy numeric formatter; ignored when `subtotal` is supplied. */
|
|
33
49
|
formatPrice?: (value: number) => string
|
|
34
50
|
onCheckout?: () => void
|
|
35
51
|
onViewCart?: () => void
|
|
@@ -59,13 +75,17 @@ export function Navbar4CartSheet({
|
|
|
59
75
|
items,
|
|
60
76
|
onQtyChange,
|
|
61
77
|
onRemove,
|
|
78
|
+
subtotal: subtotalProp,
|
|
79
|
+
subtotalUnavailableLabel = '—',
|
|
80
|
+
formatMoney: formatMoneyProp,
|
|
62
81
|
formatPrice,
|
|
63
82
|
onCheckout,
|
|
64
83
|
onViewCart,
|
|
65
84
|
}: Navbar4CartSheetProps) {
|
|
66
85
|
const reduceMotion = useReducedMotion()
|
|
67
86
|
const format = formatPrice ?? (value => usdFormatter.format(value))
|
|
68
|
-
const
|
|
87
|
+
const localSubtotal = items.reduce((total, item) => total + item.price * item.qty, 0)
|
|
88
|
+
const authoritativeSubtotal = subtotalProp !== undefined ? formatMoney(subtotalProp, formatMoneyProp) : null
|
|
69
89
|
const isEmpty = items.length === 0
|
|
70
90
|
/** Wait for the last cart row's exit before revealing empty copy. */
|
|
71
91
|
const [showEmpty, setShowEmpty] = useState(isEmpty)
|
|
@@ -100,18 +120,24 @@ export function Navbar4CartSheet({
|
|
|
100
120
|
className='border-border flex gap-4 border-b py-4 last:border-b-0'
|
|
101
121
|
style={{ willChange: 'transform, opacity, filter' }}
|
|
102
122
|
>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
123
|
+
{item.image ? (
|
|
124
|
+
<img
|
|
125
|
+
src={item.image || undefined}
|
|
126
|
+
alt=''
|
|
127
|
+
width={80}
|
|
128
|
+
height={96}
|
|
129
|
+
className='bg-muted size-20 shrink-0 rounded-md object-cover'
|
|
130
|
+
/>
|
|
131
|
+
) : (
|
|
132
|
+
<div aria-hidden='true' className='bg-muted size-20 shrink-0 rounded-md' />
|
|
133
|
+
)}
|
|
110
134
|
<div className='flex min-w-0 flex-1 flex-col'>
|
|
111
135
|
<div className='flex items-start justify-between gap-2'>
|
|
112
136
|
<div className='min-w-0'>
|
|
113
137
|
<p className='truncate text-sm font-medium'>{item.name}</p>
|
|
114
|
-
|
|
138
|
+
{item.variant ? (
|
|
139
|
+
<p className='text-muted-foreground text-sm'>{item.variant}</p>
|
|
140
|
+
) : null}
|
|
115
141
|
</div>
|
|
116
142
|
<p className='text-sm font-medium'>{format(item.price * item.qty)}</p>
|
|
117
143
|
</div>
|
|
@@ -230,7 +256,11 @@ export function Navbar4CartSheet({
|
|
|
230
256
|
<Separator />
|
|
231
257
|
<div className='flex items-center justify-between text-sm'>
|
|
232
258
|
<span className='text-muted-foreground'>Subtotal</span>
|
|
233
|
-
<span className='font-medium tabular-nums'>
|
|
259
|
+
<span className='font-medium tabular-nums'>
|
|
260
|
+
{subtotalProp !== undefined
|
|
261
|
+
? authoritativeSubtotal ?? subtotalUnavailableLabel
|
|
262
|
+
: format(localSubtotal)}
|
|
263
|
+
</span>
|
|
234
264
|
</div>
|
|
235
265
|
<p className='text-muted-foreground text-xs'>Shipping and taxes are calculated at checkout.</p>
|
|
236
266
|
<div className='flex flex-col gap-2'>
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { Navbar4 } from './navbar-4'
|
|
4
|
+
|
|
5
|
+
const base = {
|
|
6
|
+
storeCategories: [],
|
|
7
|
+
shippingNote: 'Entrega segura',
|
|
8
|
+
utilityLinks: [],
|
|
9
|
+
logoText: 'ElectroCuba',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('Navbar4 — optional logo accent', () => {
|
|
13
|
+
it('renders the wordmark unchanged when no accent is supplied', () => {
|
|
14
|
+
const html = renderToStaticMarkup(<Navbar4 {...base} />)
|
|
15
|
+
expect(html).toContain('>ElectroCuba</span>')
|
|
16
|
+
expect(html).not.toContain('text-brand-promo')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('accents a matched trailing segment without duplicating the wordmark', () => {
|
|
20
|
+
const html = renderToStaticMarkup(
|
|
21
|
+
<Navbar4 {...base} logoAccent={{ text: 'Cuba', className: 'text-brand-promo' }} />,
|
|
22
|
+
)
|
|
23
|
+
expect(html).toContain('Electro<span class="text-brand-promo">Cuba</span>')
|
|
24
|
+
expect(html.match(/Electro/g)).toHaveLength(1)
|
|
25
|
+
expect(html.match(/Cuba/g)).toHaveLength(1)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('accents the full wordmark when the accent covers all of it', () => {
|
|
29
|
+
const html = renderToStaticMarkup(
|
|
30
|
+
<Navbar4 {...base} logoAccent={{ text: 'ElectroCuba', className: 'text-brand-promo' }} />,
|
|
31
|
+
)
|
|
32
|
+
expect(html).toContain('<span class="text-brand-promo">ElectroCuba</span>')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('does not accent when the segment is not a trailing match', () => {
|
|
36
|
+
const html = renderToStaticMarkup(<Navbar4 {...base} logoAccent={{ text: 'Electro' }} />)
|
|
37
|
+
expect(html).toContain('>ElectroCuba</span>')
|
|
38
|
+
expect(html).not.toContain('<span class="">')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('keeps the wordmark as the accessible text of the logo link', () => {
|
|
42
|
+
const html = renderToStaticMarkup(
|
|
43
|
+
<Navbar4 {...base} logoHref='/' logoAccent={{ text: 'Cuba', className: 'text-brand-promo' }} />,
|
|
44
|
+
)
|
|
45
|
+
expect(html).toContain('href="/"')
|
|
46
|
+
expect(html).toContain('Electro<span class="text-brand-promo">Cuba</span>')
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const category = { href: '/herramientas', label: 'Herramientas' }
|
|
51
|
+
|
|
52
|
+
// The desktop nav link (the mobile drawer also emits an anchor for the same
|
|
53
|
+
// href, but only the desktop nav link carries `hover:bg-muted`).
|
|
54
|
+
const navLinkClass = (html: string) =>
|
|
55
|
+
html.match(/<a href="\/herramientas" class="([^"]*hover:bg-muted[^"]*)"/)?.[1] ?? ''
|
|
56
|
+
|
|
57
|
+
describe('Navbar4 — optional outer surface className', () => {
|
|
58
|
+
it('keeps the default surface (incl. backdrop-filter variant) and nav link color when className is omitted', () => {
|
|
59
|
+
const html = renderToStaticMarkup(<Navbar4 {...base} storeCategories={[category]} />)
|
|
60
|
+
expect(html).toContain('bg-background/95')
|
|
61
|
+
expect(html).toContain('supports-backdrop-filter:bg-background/80')
|
|
62
|
+
expect(navLinkClass(html)).toContain('text-foreground')
|
|
63
|
+
expect(navLinkClass(html)).not.toContain('hover:text-foreground')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('gives the consumer full surface ownership when className is supplied (no default background or variant)', () => {
|
|
67
|
+
const html = renderToStaticMarkup(
|
|
68
|
+
<Navbar4 {...base} storeCategories={[category]} className='bg-brand-nav text-brand-nav-foreground' />,
|
|
69
|
+
)
|
|
70
|
+
expect(html).toContain('bg-brand-nav')
|
|
71
|
+
expect(html).toContain('text-brand-nav-foreground')
|
|
72
|
+
expect(html).not.toContain('bg-background/95')
|
|
73
|
+
expect(html).not.toContain('supports-backdrop-filter:bg-background/80')
|
|
74
|
+
// Layout/border/sticky/backdrop classes stay internal and present.
|
|
75
|
+
expect(html).toContain('border-border')
|
|
76
|
+
expect(html).toContain('sticky')
|
|
77
|
+
expect(html).toContain('backdrop-blur-md')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('lets nav links inherit the branded surface text color with a correct hover foreground', () => {
|
|
81
|
+
const html = renderToStaticMarkup(
|
|
82
|
+
<Navbar4 {...base} storeCategories={[category]} className='bg-brand-nav text-brand-nav-foreground' />,
|
|
83
|
+
)
|
|
84
|
+
expect(navLinkClass(html)).toContain('text-inherit')
|
|
85
|
+
expect(navLinkClass(html)).toContain('hover:text-foreground')
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('Navbar4 — optional showSearch (E2 task 5.4)', () => {
|
|
90
|
+
it('renders the search control by default (legacy consumers unaffected)', () => {
|
|
91
|
+
const html = renderToStaticMarkup(<Navbar4 {...base} />)
|
|
92
|
+
expect(html).toContain('role="search"')
|
|
93
|
+
expect(html).toContain('type="search"')
|
|
94
|
+
expect(html).toContain('aria-label="Search products"')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('renders no search control when showSearch is false, keeping the rest of the chrome', () => {
|
|
98
|
+
const html = renderToStaticMarkup(<Navbar4 {...base} showSearch={false} />)
|
|
99
|
+
expect(html).not.toContain('role="search"')
|
|
100
|
+
expect(html).not.toContain('type="search"')
|
|
101
|
+
expect(html).not.toContain('aria-label="Search products"')
|
|
102
|
+
// Non-search chrome is unaffected: the wordmark and cart trigger remain.
|
|
103
|
+
expect(html).toContain('ElectroCuba')
|
|
104
|
+
expect(html).toContain('aria-label="Cart, 0 items"')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('renders the search control when showSearch is explicitly true', () => {
|
|
108
|
+
const html = renderToStaticMarkup(<Navbar4 {...base} showSearch={true} />)
|
|
109
|
+
expect(html).toContain('role="search"')
|
|
110
|
+
expect(html).toContain('type="search"')
|
|
111
|
+
})
|
|
112
|
+
})
|