@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.
Files changed (41) hide show
  1. package/.turbo/turbo-build.log +15 -15
  2. package/.turbo/turbo-test.log +37 -27
  3. package/README.md +42 -1
  4. package/dist/.tsbuildinfo +1 -1
  5. package/dist/contracts.d.mts +162 -14
  6. package/dist/contracts.d.ts +162 -14
  7. package/dist/contracts.js +36 -3
  8. package/dist/contracts.js.map +1 -1
  9. package/dist/contracts.mjs +34 -3
  10. package/dist/contracts.mjs.map +1 -1
  11. package/dist/index.d.mts +294 -12
  12. package/dist/index.d.ts +294 -12
  13. package/dist/index.js +3577 -2882
  14. package/dist/index.js.map +1 -1
  15. package/dist/index.mjs +3431 -2750
  16. package/dist/index.mjs.map +1 -1
  17. package/package.json +2 -2
  18. package/src/blocks/e-commerce/navigation/back-to-top.test.tsx +27 -0
  19. package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
  20. package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
  21. package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
  22. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
  23. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
  24. package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
  25. package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
  26. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +88 -0
  27. package/src/blocks/e-commerce/product-list/product-card-1.tsx +141 -80
  28. package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
  29. package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
  30. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +92 -0
  31. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +86 -25
  32. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +7 -0
  33. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +1 -1
  34. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +38 -0
  35. package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
  36. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +150 -0
  37. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +102 -9
  38. package/src/blocks/marketing/navbars/navbar-4.test.tsx +121 -0
  39. package/src/blocks/marketing/navbars/navbar-4.tsx +134 -32
  40. package/src/index.ts +18 -0
  41. 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-md px-4'>
283
- <Card className='shadow-lg'>
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
- <Button
291
- variant='ghost'
292
- className='h-8 cursor-pointer px-3 text-xs'
293
- size='sm'
294
- onClick={() => setIsExpanded(!isExpanded)}
295
- aria-expanded={isExpanded}
296
- aria-label={isExpanded ? collapseLabel : expandLabel}
297
- >
298
- {isExpanded ? <X /> : <Plus />}
299
- </Button>
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-2 py-6 text-center'>
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
- {isExpanded && (
378
+ {expanded && (
341
379
  <>
342
380
  {/* Cart Items */}
343
- <div className='scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent flex max-h-64 flex-col gap-4 overflow-y-auto'>
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
- <Button
449
- size='lg'
450
- className='h-10 w-full cursor-pointer px-8'
451
- onClick={hasExpress && quickCheckout ? onQuickCheckout : onCheckout}
452
- >
453
- {hasExpress && quickCheckout ? quickCheckoutLabel : checkoutLabel}
454
- </Button>
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
- {!isExpanded && (
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
- {isExpanded && recentlyViewed && recentlyViewed.length > 0 && (
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 = 'Comprar ahora',
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
+ })