@thorprovider/blocks 0.1.2 → 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 +37 -27
- package/README.md +42 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/contracts.d.mts +162 -14
- package/dist/contracts.d.ts +162 -14
- package/dist/contracts.js +36 -3
- package/dist/contracts.js.map +1 -1
- package/dist/contracts.mjs +34 -3
- package/dist/contracts.mjs.map +1 -1
- package/dist/index.d.mts +294 -12
- package/dist/index.d.ts +294 -12
- package/dist/index.js +3577 -2882
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +3431 -2750
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/blocks/e-commerce/navigation/back-to-top.test.tsx +27 -0
- package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
- package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
- package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
- package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
- package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
- package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
- package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
- package/src/blocks/e-commerce/product-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/shopping-carts/shopping-cart-4.test.tsx +92 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +86 -25
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +7 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +1 -1
- 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 +150 -0
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +102 -9
- package/src/blocks/marketing/navbars/navbar-4.test.tsx +121 -0
- package/src/blocks/marketing/navbars/navbar-4.tsx +134 -32
- package/src/index.ts +18 -0
- package/src/lib/contracts.ts +53 -3
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from 'react'
|
|
4
|
+
import { ChevronLeft, ChevronRight, Star } from 'lucide-react'
|
|
5
|
+
import { motion, useReducedMotion } from 'motion/react'
|
|
6
|
+
|
|
7
|
+
import { Button } from '../../../ui/button'
|
|
8
|
+
import { Card, CardContent, CardFooter } from '../../../ui/card'
|
|
9
|
+
import { formatMoney } from '../../../lib/money'
|
|
10
|
+
import type { ProductPresentation } from '../../../lib/contracts'
|
|
11
|
+
|
|
12
|
+
export interface ProductShowcase1Props {
|
|
13
|
+
products: ProductPresentation[]
|
|
14
|
+
heading?: string
|
|
15
|
+
description?: string
|
|
16
|
+
pageSize?: number
|
|
17
|
+
prevLabel?: string
|
|
18
|
+
nextLabel?: string
|
|
19
|
+
/** Label for the current-page status, e.g. "Página". */
|
|
20
|
+
pageLabel?: string
|
|
21
|
+
unavailablePriceLabel?: string
|
|
22
|
+
emptyLabel?: string
|
|
23
|
+
addToCartLabel?: string
|
|
24
|
+
onAddToCart?: (product: ProductPresentation) => void
|
|
25
|
+
onProductClick?: (product: ProductPresentation) => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEFAULT_PAGE_SIZE = 6
|
|
29
|
+
const SWIPE_THRESHOLD = 48
|
|
30
|
+
|
|
31
|
+
function chunk<T>(items: readonly T[], size: number): T[][] {
|
|
32
|
+
const pages: T[][] = []
|
|
33
|
+
for (let index = 0; index < items.length; index += size) {
|
|
34
|
+
pages.push(items.slice(index, index + size))
|
|
35
|
+
}
|
|
36
|
+
return pages
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function compareAtText(product: ProductPresentation): string | undefined {
|
|
40
|
+
const compareAt = product.compareAtPrice
|
|
41
|
+
if (!compareAt) return undefined
|
|
42
|
+
const formatted = formatMoney(compareAt)
|
|
43
|
+
const price = formatMoney(product.price)
|
|
44
|
+
if (!formatted || !price) return undefined
|
|
45
|
+
if (Number(compareAt.amount) <= Number(product.price.amount)) return undefined
|
|
46
|
+
return formatted
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Combined grid-and-carousel product presentation: fixed-size grid pages
|
|
51
|
+
* navigated manually (prev/next) with a visible current-page indication. There
|
|
52
|
+
* is no autoplay, no auto-advance, and no infinite loop. Controls are real
|
|
53
|
+
* buttons (keyboard operable and visible at every breakpoint); touch swipe is an
|
|
54
|
+
* addition, never the only way to navigate. Below-fold images are lazy.
|
|
55
|
+
*
|
|
56
|
+
* Data access lives in the host: this block only renders the props it receives
|
|
57
|
+
* and reports interactions through callbacks (no backend/router/measurement
|
|
58
|
+
* imports).
|
|
59
|
+
*/
|
|
60
|
+
export function ProductShowcase1({
|
|
61
|
+
products,
|
|
62
|
+
heading,
|
|
63
|
+
description,
|
|
64
|
+
pageSize = DEFAULT_PAGE_SIZE,
|
|
65
|
+
prevLabel = 'Anterior',
|
|
66
|
+
nextLabel = 'Siguiente',
|
|
67
|
+
pageLabel = 'Página',
|
|
68
|
+
unavailablePriceLabel = 'Precio no disponible',
|
|
69
|
+
emptyLabel = 'No hay productos para mostrar.',
|
|
70
|
+
addToCartLabel = 'Agregar al carrito',
|
|
71
|
+
onAddToCart,
|
|
72
|
+
onProductClick,
|
|
73
|
+
}: ProductShowcase1Props) {
|
|
74
|
+
const reduceMotion = useReducedMotion()
|
|
75
|
+
const size = Number.isInteger(pageSize) && pageSize > 0 ? pageSize : DEFAULT_PAGE_SIZE
|
|
76
|
+
const pages = useMemo(() => chunk(products, size), [products, size])
|
|
77
|
+
const pageCount = pages.length
|
|
78
|
+
const [page, setPage] = useState(0)
|
|
79
|
+
const [touchStart, setTouchStart] = useState<number | null>(null)
|
|
80
|
+
const safePage = pageCount === 0 ? 0 : Math.min(page, pageCount - 1)
|
|
81
|
+
|
|
82
|
+
const goTo = (next: number) => {
|
|
83
|
+
if (pageCount === 0) return
|
|
84
|
+
if (next < 0 || next >= pageCount) return
|
|
85
|
+
setPage(next)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (pageCount === 0) {
|
|
89
|
+
return (
|
|
90
|
+
<section className='py-12'>
|
|
91
|
+
<div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
|
|
92
|
+
{heading && (
|
|
93
|
+
<h2 className='mb-4 text-center text-2xl font-bold text-balance md:text-3xl'>
|
|
94
|
+
{heading}
|
|
95
|
+
</h2>
|
|
96
|
+
)}
|
|
97
|
+
<p className='text-muted-foreground text-center'>{emptyLabel}</p>
|
|
98
|
+
</div>
|
|
99
|
+
</section>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const visible = pages[safePage]
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<section
|
|
107
|
+
className='py-12'
|
|
108
|
+
aria-roledescription='carousel'
|
|
109
|
+
onKeyDown={(event) => {
|
|
110
|
+
if (event.key === 'ArrowLeft') {
|
|
111
|
+
event.preventDefault()
|
|
112
|
+
goTo(safePage - 1)
|
|
113
|
+
} else if (event.key === 'ArrowRight') {
|
|
114
|
+
event.preventDefault()
|
|
115
|
+
goTo(safePage + 1)
|
|
116
|
+
}
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
119
|
+
<div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
|
|
120
|
+
{(heading || description) && (
|
|
121
|
+
<div className='mb-8 text-center'>
|
|
122
|
+
{heading && (
|
|
123
|
+
<h2 className='text-2xl font-bold text-balance md:text-3xl'>{heading}</h2>
|
|
124
|
+
)}
|
|
125
|
+
{description && <p className='text-muted-foreground mt-2 text-balance'>{description}</p>}
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
<div
|
|
130
|
+
onTouchStart={(event) => setTouchStart(event.touches[0]?.clientX ?? null)}
|
|
131
|
+
onTouchEnd={(event) => {
|
|
132
|
+
if (touchStart === null) return
|
|
133
|
+
const delta = (event.changedTouches[0]?.clientX ?? touchStart) - touchStart
|
|
134
|
+
if (delta <= -SWIPE_THRESHOLD) goTo(safePage + 1)
|
|
135
|
+
else if (delta >= SWIPE_THRESHOLD) goTo(safePage - 1)
|
|
136
|
+
setTouchStart(null)
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
<motion.div
|
|
140
|
+
key={safePage}
|
|
141
|
+
initial={reduceMotion ? false : { opacity: 0 }}
|
|
142
|
+
animate={{ opacity: 1 }}
|
|
143
|
+
transition={{ duration: 0.3, ease: 'easeOut' }}
|
|
144
|
+
className='grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:gap-6'
|
|
145
|
+
>
|
|
146
|
+
{visible.map((product) => {
|
|
147
|
+
const compareAt = compareAtText(product)
|
|
148
|
+
return (
|
|
149
|
+
<Card
|
|
150
|
+
key={product.id}
|
|
151
|
+
className={`flex h-full flex-col gap-4 overflow-hidden rounded-lg py-4 shadow-none transition-shadow duration-300 hover:shadow-md ${
|
|
152
|
+
onProductClick ? 'cursor-pointer' : ''
|
|
153
|
+
}`}
|
|
154
|
+
onClick={onProductClick ? () => onProductClick(product) : undefined}
|
|
155
|
+
>
|
|
156
|
+
<CardContent className='flex flex-1 flex-col gap-4 px-4'>
|
|
157
|
+
<div className='relative aspect-square overflow-hidden rounded-md'>
|
|
158
|
+
{product.badge && (
|
|
159
|
+
<span className='bg-destructive text-destructive-foreground absolute top-2 left-2 z-10 rounded-full px-2 py-0.5 text-xs font-semibold'>
|
|
160
|
+
{product.badge}
|
|
161
|
+
</span>
|
|
162
|
+
)}
|
|
163
|
+
<img
|
|
164
|
+
src={product.image || undefined}
|
|
165
|
+
alt={product.imageAlt ?? product.title}
|
|
166
|
+
className='size-full rounded-md object-contain'
|
|
167
|
+
loading='lazy'
|
|
168
|
+
width={400}
|
|
169
|
+
height={400}
|
|
170
|
+
/>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<div className='flex flex-1 flex-col'>
|
|
174
|
+
<h3 className='mb-1 font-medium text-balance'>
|
|
175
|
+
{onProductClick ? (
|
|
176
|
+
<button
|
|
177
|
+
type='button'
|
|
178
|
+
onClick={(event) => {
|
|
179
|
+
event.stopPropagation()
|
|
180
|
+
onProductClick(product)
|
|
181
|
+
}}
|
|
182
|
+
className='hover:text-primary cursor-pointer text-left transition-colors'
|
|
183
|
+
>
|
|
184
|
+
{product.title}
|
|
185
|
+
</button>
|
|
186
|
+
) : (
|
|
187
|
+
product.title
|
|
188
|
+
)}
|
|
189
|
+
</h3>
|
|
190
|
+
{typeof product.rating === 'number' && Number.isFinite(product.rating) && (
|
|
191
|
+
<div className='text-muted-foreground mb-1 flex items-center gap-1 text-xs'>
|
|
192
|
+
<Star className='size-3.5 fill-current' aria-hidden='true' />
|
|
193
|
+
<span>{product.rating.toFixed(1)}</span>
|
|
194
|
+
</div>
|
|
195
|
+
)}
|
|
196
|
+
<div className='mt-auto flex items-baseline gap-2'>
|
|
197
|
+
<p className='font-semibold'>
|
|
198
|
+
{formatMoney(product.price) ?? unavailablePriceLabel}
|
|
199
|
+
</p>
|
|
200
|
+
{compareAt && (
|
|
201
|
+
<p className='text-muted-foreground text-sm line-through'>{compareAt}</p>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
</CardContent>
|
|
206
|
+
|
|
207
|
+
<CardFooter className='bg-transparent border-t-0 px-3 pt-0 md:px-4'>
|
|
208
|
+
<Button
|
|
209
|
+
variant='outline'
|
|
210
|
+
size='sm'
|
|
211
|
+
className='h-8 w-full cursor-pointer px-3 text-sm'
|
|
212
|
+
onClick={(event) => {
|
|
213
|
+
event.stopPropagation()
|
|
214
|
+
onAddToCart?.(product)
|
|
215
|
+
}}
|
|
216
|
+
>
|
|
217
|
+
{addToCartLabel}
|
|
218
|
+
</Button>
|
|
219
|
+
</CardFooter>
|
|
220
|
+
</Card>
|
|
221
|
+
)
|
|
222
|
+
})}
|
|
223
|
+
</motion.div>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
{pageCount > 1 && (
|
|
227
|
+
<div className='mt-6 flex items-center justify-center gap-3'>
|
|
228
|
+
<Button
|
|
229
|
+
type='button'
|
|
230
|
+
variant='outline'
|
|
231
|
+
size='icon-sm'
|
|
232
|
+
className='cursor-pointer'
|
|
233
|
+
aria-label={prevLabel}
|
|
234
|
+
disabled={safePage === 0}
|
|
235
|
+
onClick={() => goTo(safePage - 1)}
|
|
236
|
+
>
|
|
237
|
+
<ChevronLeft className='size-4' aria-hidden='true' />
|
|
238
|
+
</Button>
|
|
239
|
+
|
|
240
|
+
<div className='flex items-center gap-2'>
|
|
241
|
+
{pages.map((_, index) => (
|
|
242
|
+
<button
|
|
243
|
+
key={index}
|
|
244
|
+
type='button'
|
|
245
|
+
aria-label={`${pageLabel} ${index + 1}`}
|
|
246
|
+
aria-current={index === safePage ? 'true' : undefined}
|
|
247
|
+
className={`size-2 rounded-full transition-colors ${
|
|
248
|
+
index === safePage ? 'bg-primary' : 'bg-muted-foreground/40'
|
|
249
|
+
}`}
|
|
250
|
+
onClick={() => goTo(index)}
|
|
251
|
+
/>
|
|
252
|
+
))}
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<Button
|
|
256
|
+
type='button'
|
|
257
|
+
variant='outline'
|
|
258
|
+
size='icon-sm'
|
|
259
|
+
className='cursor-pointer'
|
|
260
|
+
aria-label={nextLabel}
|
|
261
|
+
disabled={safePage === pageCount - 1}
|
|
262
|
+
onClick={() => goTo(safePage + 1)}
|
|
263
|
+
>
|
|
264
|
+
<ChevronRight className='size-4' aria-hidden='true' />
|
|
265
|
+
</Button>
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
|
|
269
|
+
<p aria-live='polite' className='sr-only'>
|
|
270
|
+
{pageLabel} {safePage + 1} / {pageCount}
|
|
271
|
+
</p>
|
|
272
|
+
</div>
|
|
273
|
+
</section>
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export default ProductShowcase1
|
|
@@ -135,3 +135,95 @@ describe('ShoppingCart4 — backend-authoritative totals', () => {
|
|
|
135
135
|
expect(html).toContain('>1<')
|
|
136
136
|
})
|
|
137
137
|
})
|
|
138
|
+
|
|
139
|
+
describe('ShoppingCart4 — presentation and action availability', () => {
|
|
140
|
+
it('defaults to the compact collapsible card', () => {
|
|
141
|
+
const html = renderToStaticMarkup(
|
|
142
|
+
<ShoppingCart4 items={items} promotions={[]} totals={totals} formatMoney={formatMoney} />,
|
|
143
|
+
)
|
|
144
|
+
expect(html).toContain('max-w-md')
|
|
145
|
+
expect(html).toContain('Contraer carrito')
|
|
146
|
+
expect(html).toContain('max-h-64')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('renders page presentation as a wide, always-expanded review with a heading', () => {
|
|
150
|
+
const html = renderToStaticMarkup(
|
|
151
|
+
<ShoppingCart4
|
|
152
|
+
items={items}
|
|
153
|
+
promotions={[]}
|
|
154
|
+
totals={totals}
|
|
155
|
+
formatMoney={formatMoney}
|
|
156
|
+
presentation='page'
|
|
157
|
+
/>,
|
|
158
|
+
)
|
|
159
|
+
expect(html).toContain('max-w-3xl')
|
|
160
|
+
expect(html).toContain('<h1')
|
|
161
|
+
expect(html).not.toContain('Contraer carrito')
|
|
162
|
+
expect(html).not.toContain('max-h-64')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('offers a continue-shopping action in page presentation', () => {
|
|
166
|
+
const html = renderToStaticMarkup(
|
|
167
|
+
<ShoppingCart4
|
|
168
|
+
items={items}
|
|
169
|
+
promotions={[]}
|
|
170
|
+
totals={totals}
|
|
171
|
+
formatMoney={formatMoney}
|
|
172
|
+
presentation='page'
|
|
173
|
+
onContinueShopping={() => {}}
|
|
174
|
+
/>,
|
|
175
|
+
)
|
|
176
|
+
expect(html).toContain('Seguir comprando')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('suppresses checkout when the checkout feature is unavailable', () => {
|
|
180
|
+
const html = renderToStaticMarkup(
|
|
181
|
+
<ShoppingCart4
|
|
182
|
+
items={items}
|
|
183
|
+
promotions={[]}
|
|
184
|
+
totals={totals}
|
|
185
|
+
formatMoney={formatMoney}
|
|
186
|
+
presentation='page'
|
|
187
|
+
checkoutUnavailable
|
|
188
|
+
/>,
|
|
189
|
+
)
|
|
190
|
+
expect(html).not.toContain('Ver carrito y finalizar')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('disables checkout while the cart is not ready', () => {
|
|
194
|
+
const html = renderToStaticMarkup(
|
|
195
|
+
<ShoppingCart4
|
|
196
|
+
items={items}
|
|
197
|
+
promotions={[]}
|
|
198
|
+
totals={totals}
|
|
199
|
+
formatMoney={formatMoney}
|
|
200
|
+
presentation='page'
|
|
201
|
+
checkoutDisabled
|
|
202
|
+
/>,
|
|
203
|
+
)
|
|
204
|
+
expect(html).toMatch(/<button[^>]*disabled[^>]*>[^<]*Ver carrito y finalizar/)
|
|
205
|
+
})
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
describe('ShoppingCart4 — accessibility', () => {
|
|
209
|
+
it('names every line mutation control', () => {
|
|
210
|
+
const html = renderToStaticMarkup(
|
|
211
|
+
<ShoppingCart4 items={items} promotions={[]} totals={totals} formatMoney={formatMoney} />,
|
|
212
|
+
)
|
|
213
|
+
expect(html).toContain('aria-label="Disminuir cantidad, Taladro"')
|
|
214
|
+
expect(html).toContain('aria-label="Aumentar cantidad, Taladro"')
|
|
215
|
+
expect(html).toContain('aria-label="Quitar Taladro"')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('announces pending totals politely and errors as an alert', () => {
|
|
219
|
+
const pending = renderToStaticMarkup(
|
|
220
|
+
<ShoppingCart4 items={items} promotions={[]} totalsState='pending' />,
|
|
221
|
+
)
|
|
222
|
+
expect(pending).toContain('aria-live="polite"')
|
|
223
|
+
expect(pending).toContain('Actualizando totales')
|
|
224
|
+
const error = renderToStaticMarkup(
|
|
225
|
+
<ShoppingCart4 items={items} promotions={[]} totalsState='error' />,
|
|
226
|
+
)
|
|
227
|
+
expect(error).toMatch(/role="alert"/)
|
|
228
|
+
})
|
|
229
|
+
})
|
|
@@ -49,10 +49,19 @@ export interface ShoppingCart4RecentlyViewed {
|
|
|
49
49
|
image?: string
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
export type ShoppingCart4Presentation = 'compact' | 'page'
|
|
53
|
+
|
|
52
54
|
export interface ShoppingCart4Props {
|
|
53
55
|
items: ShoppingCart4Item[]
|
|
54
56
|
promotions: ShoppingCart4Promotion[]
|
|
55
57
|
recentlyViewed?: ShoppingCart4RecentlyViewed[]
|
|
58
|
+
/**
|
|
59
|
+
* Layout presentation. `compact` (default) is the collapsible quick-cart
|
|
60
|
+
* card; `page` is a wide, always-expanded review layout with a primary
|
|
61
|
+
* heading, no collapse control, and normal page scrolling. Both use the same
|
|
62
|
+
* line controls and authoritative monetary rendering.
|
|
63
|
+
*/
|
|
64
|
+
presentation?: ShoppingCart4Presentation
|
|
56
65
|
/**
|
|
57
66
|
* Backend-authoritative totals. The curated cart renders these verbatim; it
|
|
58
67
|
* never computes subtotal/tax/shipping/total locally.
|
|
@@ -100,6 +109,12 @@ export interface ShoppingCart4Props {
|
|
|
100
109
|
removeLabel?: string
|
|
101
110
|
decreaseLabel?: string
|
|
102
111
|
increaseLabel?: string
|
|
112
|
+
continueShoppingLabel?: string // 'Seguir comprando'
|
|
113
|
+
// action availability:
|
|
114
|
+
/** Suppress the checkout action (e.g. the checkout feature is not installed). */
|
|
115
|
+
checkoutUnavailable?: boolean
|
|
116
|
+
/** Disable checkout while the cart is not ready (pending/error/invalid totals). */
|
|
117
|
+
checkoutDisabled?: boolean
|
|
103
118
|
// callbacks:
|
|
104
119
|
/**
|
|
105
120
|
* ABSOLUTE quantity for a backend cart line-item id (distinct from the
|
|
@@ -113,6 +128,8 @@ export interface ShoppingCart4Props {
|
|
|
113
128
|
onPayWithApple?: () => void
|
|
114
129
|
onPayWithGoogle?: () => void
|
|
115
130
|
onRecentlyViewedClick?: (id: string) => void
|
|
131
|
+
/** Page presentation only: navigate back to browsing. */
|
|
132
|
+
onContinueShopping?: () => void
|
|
116
133
|
}
|
|
117
134
|
|
|
118
135
|
const usdFormatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
|
|
@@ -127,6 +144,7 @@ export function ShoppingCart4({
|
|
|
127
144
|
items,
|
|
128
145
|
promotions,
|
|
129
146
|
recentlyViewed,
|
|
147
|
+
presentation = 'compact',
|
|
130
148
|
totals,
|
|
131
149
|
totalsState = 'idle',
|
|
132
150
|
totalsErrorLabel = 'No se pudieron actualizar los totales.',
|
|
@@ -162,6 +180,9 @@ export function ShoppingCart4({
|
|
|
162
180
|
removeLabel = 'Quitar',
|
|
163
181
|
decreaseLabel = 'Disminuir cantidad',
|
|
164
182
|
increaseLabel = 'Aumentar cantidad',
|
|
183
|
+
continueShoppingLabel = 'Seguir comprando',
|
|
184
|
+
checkoutUnavailable,
|
|
185
|
+
checkoutDisabled,
|
|
165
186
|
onQuantityChange,
|
|
166
187
|
onRemove,
|
|
167
188
|
onCheckout,
|
|
@@ -169,15 +190,22 @@ export function ShoppingCart4({
|
|
|
169
190
|
onPayWithApple,
|
|
170
191
|
onPayWithGoogle,
|
|
171
192
|
onRecentlyViewedClick,
|
|
193
|
+
onContinueShopping,
|
|
172
194
|
}: ShoppingCart4Props) {
|
|
173
195
|
const [isExpanded, setIsExpanded] = useState(true)
|
|
174
196
|
const [quickCheckout, setQuickCheckout] = useState(false)
|
|
175
197
|
|
|
198
|
+
const isPage = presentation === 'page'
|
|
199
|
+
// The page presentation is always expanded; only the compact card collapses.
|
|
200
|
+
const expanded = isPage ? true : isExpanded
|
|
176
201
|
const format = formatPrice ?? (value => usdFormatter.format(value))
|
|
177
202
|
const money = (value: Money | null | undefined) => formatMoney(value, formatMoneyProp)
|
|
178
203
|
const subtotalAmount = isAvailableMoney(totals?.subtotal) ? Number(totals.subtotal.amount) : 0
|
|
179
204
|
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0)
|
|
180
205
|
const hasExpress = Boolean(onQuickCheckout || onPayWithApple || onPayWithGoogle)
|
|
206
|
+
// While totals/mutations are pending, keep confirmed data visible but block
|
|
207
|
+
// conflicting edits and checkout until the backend responds.
|
|
208
|
+
const mutationsDisabled = totalsState === 'pending'
|
|
181
209
|
|
|
182
210
|
const sortedPromotions = [...promotions].sort((a, b) => a.threshold - b.threshold)
|
|
183
211
|
const currentPromo = (() => {
|
|
@@ -279,31 +307,41 @@ export function ShoppingCart4({
|
|
|
279
307
|
|
|
280
308
|
return (
|
|
281
309
|
<section className='py-8'>
|
|
282
|
-
<div className='mx-auto max-w-
|
|
283
|
-
|
|
310
|
+
<div className={cn('mx-auto px-4', isPage ? 'max-w-3xl' : 'max-w-md')}>
|
|
311
|
+
{isPage ? (
|
|
312
|
+
<h1 className='mb-6 text-2xl font-semibold tracking-tight'>{titleLabel}</h1>
|
|
313
|
+
) : null}
|
|
314
|
+
<Card className={isPage ? 'shadow-sm' : 'shadow-lg'}>
|
|
284
315
|
{/* Header */}
|
|
285
316
|
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-3'>
|
|
286
317
|
<CardTitle className='flex items-center gap-2'>
|
|
287
318
|
<ShoppingCart className='size-5' />
|
|
288
319
|
{titleLabel} ({itemCount})
|
|
289
320
|
</CardTitle>
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
321
|
+
{!isPage ? (
|
|
322
|
+
<Button
|
|
323
|
+
variant='ghost'
|
|
324
|
+
className='h-8 cursor-pointer px-3 text-xs'
|
|
325
|
+
size='sm'
|
|
326
|
+
onClick={() => setIsExpanded(!isExpanded)}
|
|
327
|
+
aria-expanded={isExpanded}
|
|
328
|
+
aria-label={isExpanded ? collapseLabel : expandLabel}
|
|
329
|
+
>
|
|
330
|
+
{isExpanded ? <X /> : <Plus />}
|
|
331
|
+
</Button>
|
|
332
|
+
) : null}
|
|
300
333
|
</CardHeader>
|
|
301
334
|
|
|
302
335
|
<CardContent className='flex flex-col gap-6'>
|
|
303
336
|
{items.length === 0 ? (
|
|
304
|
-
<div className='flex flex-col items-center justify-center gap-
|
|
337
|
+
<div className='flex flex-col items-center justify-center gap-4 py-6 text-center'>
|
|
305
338
|
<ShoppingCart className='text-muted-foreground/50 size-10' />
|
|
306
339
|
<p className='text-muted-foreground text-sm'>{emptyLabel}</p>
|
|
340
|
+
{isPage && onContinueShopping ? (
|
|
341
|
+
<Button variant='outline' onClick={onContinueShopping}>
|
|
342
|
+
{continueShoppingLabel}
|
|
343
|
+
</Button>
|
|
344
|
+
) : null}
|
|
307
345
|
</div>
|
|
308
346
|
) : (
|
|
309
347
|
<>
|
|
@@ -337,10 +375,16 @@ export function ShoppingCart4({
|
|
|
337
375
|
</div>
|
|
338
376
|
) : null}
|
|
339
377
|
|
|
340
|
-
{
|
|
378
|
+
{expanded && (
|
|
341
379
|
<>
|
|
342
380
|
{/* Cart Items */}
|
|
343
|
-
<div
|
|
381
|
+
<div
|
|
382
|
+
className={cn(
|
|
383
|
+
'flex flex-col gap-4',
|
|
384
|
+
!isPage &&
|
|
385
|
+
'scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent max-h-64 overflow-y-auto',
|
|
386
|
+
)}
|
|
387
|
+
>
|
|
344
388
|
{items.map(item => (
|
|
345
389
|
<div key={item.id} className='flex items-start gap-3'>
|
|
346
390
|
{item.image ? (
|
|
@@ -366,7 +410,7 @@ export function ShoppingCart4({
|
|
|
366
410
|
? () => onQuantityChange(item.id, item.quantity - 1)
|
|
367
411
|
: undefined
|
|
368
412
|
}
|
|
369
|
-
disabled={item.quantity <= 1}
|
|
413
|
+
disabled={item.quantity <= 1 || mutationsDisabled}
|
|
370
414
|
aria-label={`${decreaseLabel}, ${item.name}`}
|
|
371
415
|
>
|
|
372
416
|
<Minus />
|
|
@@ -382,6 +426,7 @@ export function ShoppingCart4({
|
|
|
382
426
|
: undefined
|
|
383
427
|
}
|
|
384
428
|
aria-label={`${increaseLabel}, ${item.name}`}
|
|
429
|
+
disabled={mutationsDisabled}
|
|
385
430
|
>
|
|
386
431
|
<Plus />
|
|
387
432
|
</Button>
|
|
@@ -390,6 +435,7 @@ export function ShoppingCart4({
|
|
|
390
435
|
size='sm'
|
|
391
436
|
className='text-destructive hover:text-destructive ms-2 size-6 h-8 cursor-pointer p-0 px-3 text-xs'
|
|
392
437
|
onClick={onRemove ? () => onRemove(item.id) : undefined}
|
|
438
|
+
disabled={mutationsDisabled}
|
|
393
439
|
aria-label={`${removeLabel} ${item.name}`}
|
|
394
440
|
>
|
|
395
441
|
<X />
|
|
@@ -445,13 +491,28 @@ export function ShoppingCart4({
|
|
|
445
491
|
) : null}
|
|
446
492
|
|
|
447
493
|
{/* Checkout Button */}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
494
|
+
{!checkoutUnavailable ? (
|
|
495
|
+
<Button
|
|
496
|
+
size='lg'
|
|
497
|
+
className='h-10 w-full cursor-pointer px-8'
|
|
498
|
+
onClick={hasExpress && quickCheckout ? onQuickCheckout : onCheckout}
|
|
499
|
+
disabled={checkoutDisabled || totalsState === 'pending'}
|
|
500
|
+
>
|
|
501
|
+
{hasExpress && quickCheckout ? quickCheckoutLabel : checkoutLabel}
|
|
502
|
+
</Button>
|
|
503
|
+
) : null}
|
|
504
|
+
|
|
505
|
+
{/* Continue shopping — page presentation only */}
|
|
506
|
+
{isPage && onContinueShopping ? (
|
|
507
|
+
<Button
|
|
508
|
+
variant='outline'
|
|
509
|
+
size='lg'
|
|
510
|
+
className='h-10 w-full cursor-pointer px-8'
|
|
511
|
+
onClick={onContinueShopping}
|
|
512
|
+
>
|
|
513
|
+
{continueShoppingLabel}
|
|
514
|
+
</Button>
|
|
515
|
+
) : null}
|
|
455
516
|
|
|
456
517
|
{/* Estimated Delivery — only when supplied, never fabricated */}
|
|
457
518
|
{estimatedDeliveryLabel ? (
|
|
@@ -473,7 +534,7 @@ export function ShoppingCart4({
|
|
|
473
534
|
)}
|
|
474
535
|
|
|
475
536
|
{/* Collapsed Summary */}
|
|
476
|
-
{!
|
|
537
|
+
{!expanded && (
|
|
477
538
|
<div className='flex flex-col gap-3'>
|
|
478
539
|
<div className='flex items-center justify-between'>
|
|
479
540
|
<span className='text-muted-foreground text-sm'>
|
|
@@ -491,7 +552,7 @@ export function ShoppingCart4({
|
|
|
491
552
|
)}
|
|
492
553
|
|
|
493
554
|
{/* Recently Viewed */}
|
|
494
|
-
{
|
|
555
|
+
{expanded && recentlyViewed && recentlyViewed.length > 0 && (
|
|
495
556
|
<div className='flex flex-col gap-3'>
|
|
496
557
|
<h4 className='text-sm font-medium'>{recentlyViewedLabel}</h4>
|
|
497
558
|
<div className='grid grid-cols-3 gap-2'>
|
|
@@ -63,3 +63,10 @@ describe('StorefrontHero1 — optional editorial image', () => {
|
|
|
63
63
|
expect(html).toContain('Taladro percutor')
|
|
64
64
|
})
|
|
65
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
|
+
})
|
|
@@ -98,7 +98,7 @@ function StorefrontHero1({
|
|
|
98
98
|
onBuyNow,
|
|
99
99
|
onSelectColor,
|
|
100
100
|
addToCartLabel = 'Agregar al carrito',
|
|
101
|
-
buyNowLabel = '
|
|
101
|
+
buyNowLabel = 'Ver producto',
|
|
102
102
|
newLabel = 'Nuevo',
|
|
103
103
|
outOfStockLabel = 'Agotado',
|
|
104
104
|
colorsLabel = 'Colores:',
|
|
@@ -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
|
+
})
|