@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.
Files changed (57) hide show
  1. package/.turbo/turbo-build.log +15 -15
  2. package/.turbo/turbo-test.log +32 -5
  3. package/AGENTS.md +1 -1
  4. package/README.md +52 -0
  5. package/dist/.tsbuildinfo +1 -1
  6. package/dist/contracts.d.mts +1111 -90
  7. package/dist/contracts.d.ts +1111 -90
  8. package/dist/contracts.js +189 -6
  9. package/dist/contracts.js.map +1 -1
  10. package/dist/contracts.mjs +173 -7
  11. package/dist/contracts.mjs.map +1 -1
  12. package/dist/index.d.mts +467 -89
  13. package/dist/index.d.ts +467 -89
  14. package/dist/index.js +2967 -2250
  15. package/dist/index.js.map +1 -1
  16. package/dist/index.mjs +2989 -2282
  17. package/dist/index.mjs.map +1 -1
  18. package/docs/standards/theming.md +32 -5
  19. package/package.json +4 -3
  20. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +136 -93
  21. package/src/blocks/e-commerce/category-filters/category-filter-2.test.tsx +513 -0
  22. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +728 -148
  23. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +14 -8
  24. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.test.tsx +136 -0
  25. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +145 -99
  26. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +215 -102
  27. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +6 -2
  28. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +51 -2
  29. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +216 -0
  30. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +114 -11
  31. package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +42 -2
  32. package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +10 -8
  33. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +51 -0
  34. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +56 -36
  35. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +182 -0
  36. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +298 -129
  37. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +137 -0
  38. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +231 -89
  39. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +65 -0
  40. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +19 -0
  41. package/src/blocks/marketing/footers/footer-section-1.test.tsx +117 -0
  42. package/src/blocks/marketing/footers/footer-section-1.tsx +46 -9
  43. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +54 -0
  44. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +43 -13
  45. package/src/blocks/marketing/navbars/navbar-4.test.tsx +112 -0
  46. package/src/blocks/marketing/navbars/navbar-4.tsx +101 -28
  47. package/src/index.ts +38 -0
  48. package/src/lib/cart.test.ts +15 -0
  49. package/src/lib/cart.ts +11 -0
  50. package/src/lib/contracts-curated.test.ts +207 -0
  51. package/src/lib/contracts-server-boundary.test.ts +52 -0
  52. package/src/lib/contracts.ts +182 -4
  53. package/src/lib/money.test.ts +64 -0
  54. package/src/lib/money.ts +65 -0
  55. package/styles.css +1 -0
  56. package/tsup.config.ts +0 -1
  57. package/vitest.config.ts +9 -1
@@ -3,22 +3,39 @@
3
3
  import { useState } from 'react'
4
4
  import { ShoppingCart, Zap, Clock, CreditCard, Wallet, Minus, Plus, X } from 'lucide-react'
5
5
  import type { LucideIcon } from 'lucide-react'
6
+ import type { Money } from '@thorprovider/types'
6
7
  import { Card, CardContent, CardHeader, CardTitle } from '../../../ui/card'
7
8
  import { Button } from '../../../ui/button'
8
9
  import { Separator } from '../../../ui/separator'
9
10
  import { Progress } from '../../../ui/progress'
10
- import { ShoppingCart4SpinningCounter } from './shopping-cart-4-spinning-counter'
11
11
  import { cn } from '../../../lib/utils'
12
+ import { formatMoney, isAvailableMoney } from '../../../lib/money'
12
13
 
13
14
  export interface ShoppingCart4Item {
14
- id: string // serialization-friendly (seed used number)
15
+ /** Backend cart line-item id — the stable identity for update/removal. */
16
+ id: string
15
17
  name: string
16
18
  price: number
17
19
  quantity: number
18
- image: string
20
+ /** Optional; an absent image is omitted rather than serialized as `""`. */
21
+ image?: string
19
22
  variant?: string
23
+ /** Canonical backend per-unit price; preferred for display when present. */
24
+ unitPrice?: Money
25
+ /** Canonical backend line total; preferred over `price * quantity`. */
26
+ lineTotal?: Money
27
+ }
28
+
29
+ export interface ShoppingCart4Totals {
30
+ subtotal: Money
31
+ discount?: Money
32
+ tax?: Money
33
+ shipping?: Money
34
+ total: Money
20
35
  }
21
36
 
37
+ export type ShoppingCart4TotalsState = 'idle' | 'pending' | 'error'
38
+
22
39
  export interface ShoppingCart4Promotion {
23
40
  threshold: number
24
41
  reward: string
@@ -29,29 +46,49 @@ export interface ShoppingCart4RecentlyViewed {
29
46
  id: string
30
47
  name: string
31
48
  price: number
32
- image: string
49
+ image?: string
33
50
  }
34
51
 
35
52
  export interface ShoppingCart4Props {
36
53
  items: ShoppingCart4Item[]
37
54
  promotions: ShoppingCart4Promotion[]
38
55
  recentlyViewed?: ShoppingCart4RecentlyViewed[]
39
- // business rules externalized:
40
- taxRate?: number // default 0.08
56
+ /**
57
+ * Backend-authoritative totals. The curated cart renders these verbatim; it
58
+ * never computes subtotal/tax/shipping/total locally.
59
+ */
60
+ totals?: ShoppingCart4Totals
61
+ /** Controlled state of the totals fetch/mutation. */
62
+ totalsState?: ShoppingCart4TotalsState
63
+ totalsErrorLabel?: string
64
+ /** Shown when a required total amount is absent or malformed (never as zero). */
65
+ totalsUnavailableLabel?: string
66
+ /** Shown for a line whose supplied line total is absent or malformed. */
67
+ amountUnavailableLabel?: string
68
+ retryLabel?: string
69
+ onRetryTotals?: () => void
70
+ /** Override money formatting (locale, currency). Defaults to `Money.currencyCode`. */
71
+ formatMoney?: (money: Money) => string
72
+ /** @deprecated legacy numeric formatter, used only when `totals` is absent. */
73
+ formatPrice?: (value: number) => string
74
+ /** @deprecated legacy local tax rate, used only when `totals` is absent. */
75
+ taxRate?: number
41
76
  // labels (Spanish defaults):
42
77
  titleLabel?: string // 'Carrito de compras'
43
78
  subtotalLabel?: string // 'Subtotal'
79
+ discountLabel?: string
44
80
  taxLabel?: string // 'Impuestos'
45
81
  shippingLabel?: string // 'Envío'
46
82
  shippingFreeLabel?: string // 'Gratis'
47
83
  totalLabel?: string // 'Total'
84
+ totalsPendingLabel?: string
48
85
  awayFromLabel?: string // 'Te faltan ${amount} para'
49
86
  activatedLabel?: string // '¡{reward} activada!'
50
87
  itemsLabel?: string // 'artículos'
51
88
  expressCheckoutLabel?: string // 'Compra rápida'
52
89
  expressLabel?: string // 'Rápido'
53
90
  standardLabel?: string // 'Estándar'
54
- estimatedDeliveryLabel?: string // 'Entrega estimada: 2-3 días hábiles'
91
+ estimatedDeliveryLabel?: string
55
92
  secureCheckoutLabel?: string // 'Pago seguro • SSL cifrado'
56
93
  recentlyViewedLabel?: string // 'Vistos recientemente'
57
94
  collapseLabel?: string // 'Contraer carrito'
@@ -59,16 +96,27 @@ export interface ShoppingCart4Props {
59
96
  checkoutLabel?: string // 'Ver carrito y finalizar'
60
97
  quickCheckoutLabel?: string // 'Compra rápida'
61
98
  checkoutNowLabel?: string // 'Finalizar ahora'
99
+ emptyLabel?: string
100
+ removeLabel?: string
101
+ decreaseLabel?: string
102
+ increaseLabel?: string
62
103
  // callbacks:
104
+ /**
105
+ * ABSOLUTE quantity for a backend cart line-item id (distinct from the
106
+ * Navbar's DELTA). The boundary mutates the cart by line-item id.
107
+ */
63
108
  onQuantityChange?: (id: string, quantity: number) => void
109
+ /** Remove the backend cart line-item id. */
64
110
  onRemove?: (id: string) => void
65
111
  onCheckout?: () => void
66
- onQuickCheckout?: () => void // Express mode checkout button
112
+ onQuickCheckout?: () => void
67
113
  onPayWithApple?: () => void
68
114
  onPayWithGoogle?: () => void
69
115
  onRecentlyViewedClick?: (id: string) => void
70
116
  }
71
117
 
118
+ const usdFormatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
119
+
72
120
  const interpolate = (template: string, values: Record<string, string>) =>
73
121
  template.replace(/\$\{(\w+)\}|\{(\w+)\}/g, (match, dollarKey, braceKey) => {
74
122
  const key = dollarKey ?? braceKey
@@ -79,20 +127,30 @@ export function ShoppingCart4({
79
127
  items,
80
128
  promotions,
81
129
  recentlyViewed,
82
- taxRate = 0.08,
130
+ totals,
131
+ totalsState = 'idle',
132
+ totalsErrorLabel = 'No se pudieron actualizar los totales.',
133
+ totalsUnavailableLabel = 'Totales no disponibles.',
134
+ amountUnavailableLabel = '—',
135
+ retryLabel = 'Reintentar',
136
+ onRetryTotals,
137
+ formatMoney: formatMoneyProp,
138
+ formatPrice,
83
139
  titleLabel = 'Carrito de compras',
84
140
  subtotalLabel = 'Subtotal',
141
+ discountLabel = 'Descuento',
85
142
  taxLabel = 'Impuestos',
86
143
  shippingLabel = 'Envío',
87
144
  shippingFreeLabel = 'Gratis',
88
145
  totalLabel = 'Total',
146
+ totalsPendingLabel = 'Actualizando totales…',
89
147
  awayFromLabel = 'Te faltan ${amount} para',
90
148
  activatedLabel = '¡{reward} activada!',
91
149
  itemsLabel = 'artículos',
92
150
  expressCheckoutLabel = 'Compra rápida',
93
151
  expressLabel = 'Rápido',
94
152
  standardLabel = 'Estándar',
95
- estimatedDeliveryLabel = 'Entrega estimada: 2-3 días hábiles',
153
+ estimatedDeliveryLabel,
96
154
  secureCheckoutLabel = 'Pago seguro • SSL cifrado',
97
155
  recentlyViewedLabel = 'Vistos recientemente',
98
156
  collapseLabel = 'Contraer carrito',
@@ -100,6 +158,10 @@ export function ShoppingCart4({
100
158
  checkoutLabel = 'Ver carrito y finalizar',
101
159
  quickCheckoutLabel = 'Compra rápida',
102
160
  checkoutNowLabel = 'Finalizar ahora',
161
+ emptyLabel = 'Tu carrito está vacío',
162
+ removeLabel = 'Quitar',
163
+ decreaseLabel = 'Disminuir cantidad',
164
+ increaseLabel = 'Aumentar cantidad',
103
165
  onQuantityChange,
104
166
  onRemove,
105
167
  onCheckout,
@@ -111,26 +173,110 @@ export function ShoppingCart4({
111
173
  const [isExpanded, setIsExpanded] = useState(true)
112
174
  const [quickCheckout, setQuickCheckout] = useState(false)
113
175
 
176
+ const format = formatPrice ?? (value => usdFormatter.format(value))
177
+ const money = (value: Money | null | undefined) => formatMoney(value, formatMoneyProp)
178
+ const subtotalAmount = isAvailableMoney(totals?.subtotal) ? Number(totals.subtotal.amount) : 0
114
179
  const itemCount = items.reduce((sum, item) => sum + item.quantity, 0)
115
- const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0)
116
- const tax = subtotal * taxRate
117
- const total = subtotal + tax
180
+ const hasExpress = Boolean(onQuickCheckout || onPayWithApple || onPayWithGoogle)
118
181
 
119
182
  const sortedPromotions = [...promotions].sort((a, b) => a.threshold - b.threshold)
120
183
  const currentPromo = (() => {
184
+ if (!isAvailableMoney(totals?.subtotal)) return null
121
185
  for (let i = sortedPromotions.length - 1; i >= 0; i--) {
122
- if (subtotal >= sortedPromotions[i].threshold) {
186
+ if (subtotalAmount >= sortedPromotions[i].threshold) {
123
187
  return { ...sortedPromotions[i], achieved: true }
124
188
  }
125
189
  }
126
- const next = sortedPromotions.find(p => subtotal < p.threshold)
190
+ const next = sortedPromotions.find(p => subtotalAmount < p.threshold)
127
191
  return next ? { ...next, achieved: false } : null
128
192
  })()
129
193
  const progressPercentage =
130
194
  currentPromo && !currentPromo.achieved
131
- ? Math.min(100, (subtotal / currentPromo.threshold) * 100)
195
+ ? Math.min(100, (subtotalAmount / currentPromo.threshold) * 100)
132
196
  : 100
133
197
 
198
+ const lineAmount = (item: ShoppingCart4Item): string | null => {
199
+ // A supplied line total that is absent/malformed must not fall back to a
200
+ // numeric guess; only an entirely absent line total uses the legacy field.
201
+ if (item.lineTotal !== undefined) return money(item.lineTotal)
202
+ return format(item.price * item.quantity)
203
+ }
204
+
205
+ const renderTotals = () => {
206
+ if (totalsState === 'pending') {
207
+ return (
208
+ <div className='flex flex-col gap-2' aria-live='polite'>
209
+ <p className='text-muted-foreground text-sm'>{totalsPendingLabel}</p>
210
+ </div>
211
+ )
212
+ }
213
+ if (totalsState === 'error') {
214
+ return (
215
+ <div className='flex flex-col gap-2' role='alert'>
216
+ <p className='text-destructive text-sm'>{totalsErrorLabel}</p>
217
+ {onRetryTotals ? (
218
+ <Button variant='outline' size='sm' className='w-fit' onClick={onRetryTotals}>
219
+ {retryLabel}
220
+ </Button>
221
+ ) : null}
222
+ </div>
223
+ )
224
+ }
225
+ if (!totals) return null
226
+
227
+ const subtotalText = money(totals.subtotal)
228
+ const totalText = money(totals.total)
229
+ // Required totals: if either is absent/malformed, show an explicit
230
+ // unavailable state (with recovery) rather than a fabricated zero.
231
+ if (subtotalText === null || totalText === null) {
232
+ return (
233
+ <div className='flex flex-col gap-2' role='status'>
234
+ <p className='text-muted-foreground text-sm'>{totalsUnavailableLabel}</p>
235
+ {onRetryTotals ? (
236
+ <Button variant='outline' size='sm' className='w-fit' onClick={onRetryTotals}>
237
+ {retryLabel}
238
+ </Button>
239
+ ) : null}
240
+ </div>
241
+ )
242
+ }
243
+
244
+ const shippingText = money(totals.shipping)
245
+ return (
246
+ <>
247
+ <div className='flex justify-between text-sm'>
248
+ <span>{subtotalLabel}</span>
249
+ <span className='tabular-nums'>{subtotalText}</span>
250
+ </div>
251
+ {isAvailableMoney(totals.discount) ? (
252
+ <div className='flex justify-between text-sm'>
253
+ <span>{discountLabel}</span>
254
+ <span className='tabular-nums'>{money(totals.discount)}</span>
255
+ </div>
256
+ ) : null}
257
+ {isAvailableMoney(totals.tax) ? (
258
+ <div className='flex justify-between text-sm'>
259
+ <span>{taxLabel}</span>
260
+ <span className='tabular-nums'>{money(totals.tax)}</span>
261
+ </div>
262
+ ) : null}
263
+ {shippingText !== null ? (
264
+ <div className='flex justify-between text-sm text-success'>
265
+ <span>{shippingLabel}</span>
266
+ <span className='tabular-nums'>
267
+ {Number(totals.shipping?.amount) === 0 ? shippingFreeLabel : shippingText}
268
+ </span>
269
+ </div>
270
+ ) : null}
271
+ <Separator />
272
+ <div className='flex justify-between font-semibold'>
273
+ <span>{totalLabel}</span>
274
+ <span className='tabular-nums'>{totalText}</span>
275
+ </div>
276
+ </>
277
+ )
278
+ }
279
+
134
280
  return (
135
281
  <section className='py-8'>
136
282
  <div className='mx-auto max-w-md px-4'>
@@ -157,7 +303,7 @@ export function ShoppingCart4({
157
303
  {items.length === 0 ? (
158
304
  <div className='flex flex-col items-center justify-center gap-2 py-6 text-center'>
159
305
  <ShoppingCart className='text-muted-foreground/50 size-10' />
160
- <p className='text-muted-foreground text-sm'>Tu carrito está vacío</p>
306
+ <p className='text-muted-foreground text-sm'>{emptyLabel}</p>
161
307
  </div>
162
308
  ) : (
163
309
  <>
@@ -167,7 +313,7 @@ export function ShoppingCart4({
167
313
  <div className='flex items-center justify-between text-sm'>
168
314
  <span className='text-muted-foreground'>
169
315
  {interpolate(awayFromLabel, {
170
- amount: `$${(currentPromo.threshold - subtotal).toFixed(2)}`,
316
+ amount: format(currentPromo.threshold - subtotalAmount),
171
317
  })}
172
318
  </span>
173
319
  <div className='flex items-center gap-1'>
@@ -197,14 +343,18 @@ export function ShoppingCart4({
197
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'>
198
344
  {items.map(item => (
199
345
  <div key={item.id} className='flex items-start gap-3'>
200
- <img src={item.image || undefined} alt={item.name} className='h-12 w-12 rounded-md object-cover' />
346
+ {item.image ? (
347
+ <img src={item.image || undefined} alt='' className='h-12 w-12 rounded-md object-cover' />
348
+ ) : (
349
+ <div aria-hidden='true' className='bg-muted h-12 w-12 shrink-0 rounded-md' />
350
+ )}
201
351
 
202
352
  <div className='flex flex-1 flex-col gap-1'>
203
353
  <h3 className='line-clamp-2 text-sm font-medium'>{item.name}</h3>
204
354
  {item.variant ? <p className='text-muted-foreground text-xs'>{item.variant}</p> : null}
205
355
 
206
356
  <div className='flex items-center justify-between'>
207
- <span className='text-sm font-semibold'>${item.price.toFixed(2)}</span>
357
+ <span className='text-sm font-semibold'>{lineAmount(item) ?? amountUnavailableLabel}</span>
208
358
 
209
359
  <div className='flex items-center'>
210
360
  <Button
@@ -217,7 +367,7 @@ export function ShoppingCart4({
217
367
  : undefined
218
368
  }
219
369
  disabled={item.quantity <= 1}
220
- aria-label={`Disminuir cantidad de ${item.name}`}
370
+ aria-label={`${decreaseLabel}, ${item.name}`}
221
371
  >
222
372
  <Minus />
223
373
  </Button>
@@ -231,16 +381,16 @@ export function ShoppingCart4({
231
381
  ? () => onQuantityChange(item.id, item.quantity + 1)
232
382
  : undefined
233
383
  }
234
- aria-label={`Aumentar cantidad de ${item.name}`}
384
+ aria-label={`${increaseLabel}, ${item.name}`}
235
385
  >
236
386
  <Plus />
237
387
  </Button>
238
388
  <Button
239
389
  variant='ghost'
240
390
  size='sm'
241
- className='ms-2 size-6 h-8 cursor-pointer p-0 px-3 text-xs text-destructive hover:text-destructive'
391
+ className='text-destructive hover:text-destructive ms-2 size-6 h-8 cursor-pointer p-0 px-3 text-xs'
242
392
  onClick={onRemove ? () => onRemove(item.id) : undefined}
243
- aria-label={`Quitar ${item.name}`}
393
+ aria-label={`${removeLabel} ${item.name}`}
244
394
  >
245
395
  <X />
246
396
  </Button>
@@ -253,73 +403,63 @@ export function ShoppingCart4({
253
403
 
254
404
  <Separator />
255
405
 
256
- {/* Order Summary */}
257
- <div className='flex flex-col gap-2'>
258
- <div className='flex justify-between text-sm'>
259
- <span>{subtotalLabel}</span>
260
- <span>${subtotal.toFixed(2)}</span>
261
- </div>
262
- <div className='flex justify-between text-sm'>
263
- <span>{taxLabel}</span>
264
- <span>${tax.toFixed(2)}</span>
265
- </div>
266
- <div className='flex justify-between text-sm text-success'>
267
- <span>{shippingLabel}</span>
268
- <span>{shippingFreeLabel}</span>
269
- </div>
270
- <Separator />
271
- <div className='flex justify-between font-semibold'>
272
- <span>{totalLabel}</span>
273
- <span>
274
- <ShoppingCart4SpinningCounter value={total} decimals={2} prefix='$' />
275
- </span>
276
- </div>
277
- </div>
406
+ {/* Order Summary — backend-authoritative only */}
407
+ <div className='flex flex-col gap-2'>{renderTotals()}</div>
278
408
 
279
409
  {/* Quick Actions */}
280
410
  <div className='flex flex-col gap-3'>
281
- {/* Express Checkout Toggle */}
282
- <div className='flex items-center justify-between text-sm'>
283
- <span>{expressCheckoutLabel}</span>
284
- <Button
285
- variant='outline'
286
- size='sm'
287
- onClick={() => setQuickCheckout(!quickCheckout)}
288
- className='h-8 cursor-pointer'
289
- aria-pressed={quickCheckout}
290
- >
291
- <Zap />
292
- {quickCheckout ? standardLabel : expressLabel}
293
- </Button>
294
- </div>
411
+ {/* Express Checkout Toggle — only when the host supplies a handler */}
412
+ {hasExpress ? (
413
+ <>
414
+ <div className='flex items-center justify-between text-sm'>
415
+ <span>{expressCheckoutLabel}</span>
416
+ <Button
417
+ variant='outline'
418
+ size='sm'
419
+ onClick={() => setQuickCheckout(!quickCheckout)}
420
+ className='h-8 cursor-pointer'
421
+ aria-pressed={quickCheckout}
422
+ >
423
+ <Zap />
424
+ {quickCheckout ? standardLabel : expressLabel}
425
+ </Button>
426
+ </div>
295
427
 
296
- {quickCheckout && (
297
- <div className='grid grid-cols-2 gap-2'>
298
- <Button size='sm' variant='outline' className='h-8 cursor-pointer px-3 text-xs' onClick={onPayWithApple}>
299
- <Wallet />
300
- Apple Pay
301
- </Button>
302
- <Button size='sm' variant='outline' className='h-8 cursor-pointer px-3 text-xs' onClick={onPayWithGoogle}>
303
- <CreditCard />
304
- Google Pay
305
- </Button>
306
- </div>
307
- )}
428
+ {quickCheckout ? (
429
+ <div className='grid grid-cols-2 gap-2'>
430
+ {onPayWithApple ? (
431
+ <Button size='sm' variant='outline' className='h-8 cursor-pointer px-3 text-xs' onClick={onPayWithApple}>
432
+ <Wallet />
433
+ Apple Pay
434
+ </Button>
435
+ ) : null}
436
+ {onPayWithGoogle ? (
437
+ <Button size='sm' variant='outline' className='h-8 cursor-pointer px-3 text-xs' onClick={onPayWithGoogle}>
438
+ <CreditCard />
439
+ Google Pay
440
+ </Button>
441
+ ) : null}
442
+ </div>
443
+ ) : null}
444
+ </>
445
+ ) : null}
308
446
 
309
447
  {/* Checkout Button */}
310
448
  <Button
311
449
  size='lg'
312
450
  className='h-10 w-full cursor-pointer px-8'
313
- onClick={quickCheckout ? onQuickCheckout : onCheckout}
451
+ onClick={hasExpress && quickCheckout ? onQuickCheckout : onCheckout}
314
452
  >
315
- {quickCheckout ? quickCheckoutLabel : checkoutLabel}
453
+ {hasExpress && quickCheckout ? quickCheckoutLabel : checkoutLabel}
316
454
  </Button>
317
455
 
318
- {/* Estimated Delivery */}
319
- <div className='text-muted-foreground flex items-center justify-center gap-2 text-xs'>
320
- <Clock className='size-3' />
321
- <span>{estimatedDeliveryLabel}</span>
322
- </div>
456
+ {/* Estimated Delivery — only when supplied, never fabricated */}
457
+ {estimatedDeliveryLabel ? (
458
+ <div className='text-muted-foreground flex items-center justify-center gap-2 text-xs'>
459
+ <Clock className='size-3' />
460
+ <span>{estimatedDeliveryLabel}</span>
461
+ </div>
462
+ ) : null}
323
463
  </div>
324
464
 
325
465
  {/* Security Badge */}
@@ -339,9 +479,9 @@ export function ShoppingCart4({
339
479
  <span className='text-muted-foreground text-sm'>
340
480
  {itemCount} {itemsLabel}
341
481
  </span>
342
- <span className='font-semibold'>
343
- <ShoppingCart4SpinningCounter value={total} decimals={2} prefix='$' />
344
- </span>
482
+ {isAvailableMoney(totals?.total) ? (
483
+ <span className='font-semibold tabular-nums'>{money(totals.total)}</span>
484
+ ) : null}
345
485
  </div>
346
486
 
347
487
  <Button size='sm' className='h-8 w-full cursor-pointer px-3 text-xs' onClick={onCheckout}>
@@ -365,12 +505,14 @@ export function ShoppingCart4({
365
505
  )}
366
506
  onClick={onRecentlyViewedClick ? () => onRecentlyViewedClick(item.id) : undefined}
367
507
  >
368
- <div className='mb-1 aspect-square overflow-hidden'>
369
- <img
370
- src={item.image || undefined}
371
- alt={item.name}
372
- className='size-full rounded-md object-cover'
373
- />
508
+ <div className='bg-muted mb-1 aspect-square overflow-hidden'>
509
+ {item.image ? (
510
+ <img
511
+ src={item.image || undefined}
512
+ alt={item.name}
513
+ className='size-full rounded-md object-cover'
514
+ />
515
+ ) : null}
374
516
  </div>
375
517
  <p className='text-muted-foreground truncate text-xs'>{item.name}</p>
376
518
  <p className='text-xs font-medium'>${item.price.toFixed(2)}</p>
@@ -388,4 +530,4 @@ export function ShoppingCart4({
388
530
  )
389
531
  }
390
532
 
391
- export default ShoppingCart4
533
+ export default ShoppingCart4
@@ -0,0 +1,65 @@
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
+ })
@@ -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,6 +93,7 @@ function StorefrontHero1({
82
93
  badge,
83
94
  primaryCta,
84
95
  secondaryCta,
96
+ heroImage,
85
97
  onAddToCart,
86
98
  onBuyNow,
87
99
  onSelectColor,
@@ -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'>