@thorprovider/blocks 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.turbo/turbo-build.log +15 -15
  2. package/.turbo/turbo-test.log +42 -5
  3. package/AGENTS.md +1 -1
  4. package/README.md +94 -1
  5. package/dist/.tsbuildinfo +1 -1
  6. package/dist/contracts.d.mts +1266 -97
  7. package/dist/contracts.d.ts +1266 -97
  8. package/dist/contracts.js +225 -9
  9. package/dist/contracts.js.map +1 -1
  10. package/dist/contracts.mjs +207 -10
  11. package/dist/contracts.mjs.map +1 -1
  12. package/dist/index.d.mts +756 -96
  13. package/dist/index.d.ts +756 -96
  14. package/dist/index.js +5114 -3702
  15. package/dist/index.js.map +1 -1
  16. package/dist/index.mjs +4939 -3551
  17. package/dist/index.mjs.map +1 -1
  18. package/docs/standards/theming.md +32 -5
  19. package/package.json +2 -2
  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/back-to-top.test.tsx +27 -0
  32. package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
  33. package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
  34. package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
  35. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
  36. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
  37. package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +42 -2
  38. package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +10 -8
  39. package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
  40. package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
  41. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +51 -0
  42. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +56 -36
  43. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +88 -0
  44. package/src/blocks/e-commerce/product-list/product-card-1.tsx +141 -80
  45. package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
  46. package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
  47. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +182 -0
  48. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +298 -129
  49. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +229 -0
  50. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +313 -110
  51. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +72 -0
  52. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +20 -1
  53. package/src/blocks/marketing/footers/footer-section-1.test.tsx +117 -0
  54. package/src/blocks/marketing/footers/footer-section-1.tsx +46 -9
  55. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +38 -0
  56. package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
  57. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +204 -0
  58. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +144 -21
  59. package/src/blocks/marketing/navbars/navbar-4.test.tsx +233 -0
  60. package/src/blocks/marketing/navbars/navbar-4.tsx +235 -60
  61. package/src/index.ts +56 -0
  62. package/src/lib/cart.test.ts +15 -0
  63. package/src/lib/cart.ts +11 -0
  64. package/src/lib/contracts-curated.test.ts +207 -0
  65. package/src/lib/contracts-server-boundary.test.ts +52 -0
  66. package/src/lib/contracts.ts +234 -6
  67. package/src/lib/money.test.ts +64 -0
  68. package/src/lib/money.ts +65 -0
  69. package/tsup.config.ts +0 -1
  70. 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,58 @@ 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
 
52
+ export type ShoppingCart4Presentation = 'compact' | 'page'
53
+
35
54
  export interface ShoppingCart4Props {
36
55
  items: ShoppingCart4Item[]
37
56
  promotions: ShoppingCart4Promotion[]
38
57
  recentlyViewed?: ShoppingCart4RecentlyViewed[]
39
- // business rules externalized:
40
- taxRate?: number // default 0.08
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
65
+ /**
66
+ * Backend-authoritative totals. The curated cart renders these verbatim; it
67
+ * never computes subtotal/tax/shipping/total locally.
68
+ */
69
+ totals?: ShoppingCart4Totals
70
+ /** Controlled state of the totals fetch/mutation. */
71
+ totalsState?: ShoppingCart4TotalsState
72
+ totalsErrorLabel?: string
73
+ /** Shown when a required total amount is absent or malformed (never as zero). */
74
+ totalsUnavailableLabel?: string
75
+ /** Shown for a line whose supplied line total is absent or malformed. */
76
+ amountUnavailableLabel?: string
77
+ retryLabel?: string
78
+ onRetryTotals?: () => void
79
+ /** Override money formatting (locale, currency). Defaults to `Money.currencyCode`. */
80
+ formatMoney?: (money: Money) => string
81
+ /** @deprecated legacy numeric formatter, used only when `totals` is absent. */
82
+ formatPrice?: (value: number) => string
83
+ /** @deprecated legacy local tax rate, used only when `totals` is absent. */
84
+ taxRate?: number
41
85
  // labels (Spanish defaults):
42
86
  titleLabel?: string // 'Carrito de compras'
43
87
  subtotalLabel?: string // 'Subtotal'
88
+ discountLabel?: string
44
89
  taxLabel?: string // 'Impuestos'
45
90
  shippingLabel?: string // 'Envío'
46
91
  shippingFreeLabel?: string // 'Gratis'
47
92
  totalLabel?: string // 'Total'
93
+ totalsPendingLabel?: string
48
94
  awayFromLabel?: string // 'Te faltan ${amount} para'
49
95
  activatedLabel?: string // '¡{reward} activada!'
50
96
  itemsLabel?: string // 'artículos'
51
97
  expressCheckoutLabel?: string // 'Compra rápida'
52
98
  expressLabel?: string // 'Rápido'
53
99
  standardLabel?: string // 'Estándar'
54
- estimatedDeliveryLabel?: string // 'Entrega estimada: 2-3 días hábiles'
100
+ estimatedDeliveryLabel?: string
55
101
  secureCheckoutLabel?: string // 'Pago seguro • SSL cifrado'
56
102
  recentlyViewedLabel?: string // 'Vistos recientemente'
57
103
  collapseLabel?: string // 'Contraer carrito'
@@ -59,16 +105,35 @@ export interface ShoppingCart4Props {
59
105
  checkoutLabel?: string // 'Ver carrito y finalizar'
60
106
  quickCheckoutLabel?: string // 'Compra rápida'
61
107
  checkoutNowLabel?: string // 'Finalizar ahora'
108
+ emptyLabel?: string
109
+ removeLabel?: string
110
+ decreaseLabel?: string
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
62
118
  // callbacks:
119
+ /**
120
+ * ABSOLUTE quantity for a backend cart line-item id (distinct from the
121
+ * Navbar's DELTA). The boundary mutates the cart by line-item id.
122
+ */
63
123
  onQuantityChange?: (id: string, quantity: number) => void
124
+ /** Remove the backend cart line-item id. */
64
125
  onRemove?: (id: string) => void
65
126
  onCheckout?: () => void
66
- onQuickCheckout?: () => void // Express mode checkout button
127
+ onQuickCheckout?: () => void
67
128
  onPayWithApple?: () => void
68
129
  onPayWithGoogle?: () => void
69
130
  onRecentlyViewedClick?: (id: string) => void
131
+ /** Page presentation only: navigate back to browsing. */
132
+ onContinueShopping?: () => void
70
133
  }
71
134
 
135
+ const usdFormatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
136
+
72
137
  const interpolate = (template: string, values: Record<string, string>) =>
73
138
  template.replace(/\$\{(\w+)\}|\{(\w+)\}/g, (match, dollarKey, braceKey) => {
74
139
  const key = dollarKey ?? braceKey
@@ -79,20 +144,31 @@ export function ShoppingCart4({
79
144
  items,
80
145
  promotions,
81
146
  recentlyViewed,
82
- taxRate = 0.08,
147
+ presentation = 'compact',
148
+ totals,
149
+ totalsState = 'idle',
150
+ totalsErrorLabel = 'No se pudieron actualizar los totales.',
151
+ totalsUnavailableLabel = 'Totales no disponibles.',
152
+ amountUnavailableLabel = '—',
153
+ retryLabel = 'Reintentar',
154
+ onRetryTotals,
155
+ formatMoney: formatMoneyProp,
156
+ formatPrice,
83
157
  titleLabel = 'Carrito de compras',
84
158
  subtotalLabel = 'Subtotal',
159
+ discountLabel = 'Descuento',
85
160
  taxLabel = 'Impuestos',
86
161
  shippingLabel = 'Envío',
87
162
  shippingFreeLabel = 'Gratis',
88
163
  totalLabel = 'Total',
164
+ totalsPendingLabel = 'Actualizando totales…',
89
165
  awayFromLabel = 'Te faltan ${amount} para',
90
166
  activatedLabel = '¡{reward} activada!',
91
167
  itemsLabel = 'artículos',
92
168
  expressCheckoutLabel = 'Compra rápida',
93
169
  expressLabel = 'Rápido',
94
170
  standardLabel = 'Estándar',
95
- estimatedDeliveryLabel = 'Entrega estimada: 2-3 días hábiles',
171
+ estimatedDeliveryLabel,
96
172
  secureCheckoutLabel = 'Pago seguro • SSL cifrado',
97
173
  recentlyViewedLabel = 'Vistos recientemente',
98
174
  collapseLabel = 'Contraer carrito',
@@ -100,6 +176,13 @@ export function ShoppingCart4({
100
176
  checkoutLabel = 'Ver carrito y finalizar',
101
177
  quickCheckoutLabel = 'Compra rápida',
102
178
  checkoutNowLabel = 'Finalizar ahora',
179
+ emptyLabel = 'Tu carrito está vacío',
180
+ removeLabel = 'Quitar',
181
+ decreaseLabel = 'Disminuir cantidad',
182
+ increaseLabel = 'Aumentar cantidad',
183
+ continueShoppingLabel = 'Seguir comprando',
184
+ checkoutUnavailable,
185
+ checkoutDisabled,
103
186
  onQuantityChange,
104
187
  onRemove,
105
188
  onCheckout,
@@ -107,57 +190,158 @@ export function ShoppingCart4({
107
190
  onPayWithApple,
108
191
  onPayWithGoogle,
109
192
  onRecentlyViewedClick,
193
+ onContinueShopping,
110
194
  }: ShoppingCart4Props) {
111
195
  const [isExpanded, setIsExpanded] = useState(true)
112
196
  const [quickCheckout, setQuickCheckout] = useState(false)
113
197
 
198
+ const isPage = presentation === 'page'
199
+ // The page presentation is always expanded; only the compact card collapses.
200
+ const expanded = isPage ? true : isExpanded
201
+ const format = formatPrice ?? (value => usdFormatter.format(value))
202
+ const money = (value: Money | null | undefined) => formatMoney(value, formatMoneyProp)
203
+ const subtotalAmount = isAvailableMoney(totals?.subtotal) ? Number(totals.subtotal.amount) : 0
114
204
  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
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'
118
209
 
119
210
  const sortedPromotions = [...promotions].sort((a, b) => a.threshold - b.threshold)
120
211
  const currentPromo = (() => {
212
+ if (!isAvailableMoney(totals?.subtotal)) return null
121
213
  for (let i = sortedPromotions.length - 1; i >= 0; i--) {
122
- if (subtotal >= sortedPromotions[i].threshold) {
214
+ if (subtotalAmount >= sortedPromotions[i].threshold) {
123
215
  return { ...sortedPromotions[i], achieved: true }
124
216
  }
125
217
  }
126
- const next = sortedPromotions.find(p => subtotal < p.threshold)
218
+ const next = sortedPromotions.find(p => subtotalAmount < p.threshold)
127
219
  return next ? { ...next, achieved: false } : null
128
220
  })()
129
221
  const progressPercentage =
130
222
  currentPromo && !currentPromo.achieved
131
- ? Math.min(100, (subtotal / currentPromo.threshold) * 100)
223
+ ? Math.min(100, (subtotalAmount / currentPromo.threshold) * 100)
132
224
  : 100
133
225
 
226
+ const lineAmount = (item: ShoppingCart4Item): string | null => {
227
+ // A supplied line total that is absent/malformed must not fall back to a
228
+ // numeric guess; only an entirely absent line total uses the legacy field.
229
+ if (item.lineTotal !== undefined) return money(item.lineTotal)
230
+ return format(item.price * item.quantity)
231
+ }
232
+
233
+ const renderTotals = () => {
234
+ if (totalsState === 'pending') {
235
+ return (
236
+ <div className='flex flex-col gap-2' aria-live='polite'>
237
+ <p className='text-muted-foreground text-sm'>{totalsPendingLabel}</p>
238
+ </div>
239
+ )
240
+ }
241
+ if (totalsState === 'error') {
242
+ return (
243
+ <div className='flex flex-col gap-2' role='alert'>
244
+ <p className='text-destructive text-sm'>{totalsErrorLabel}</p>
245
+ {onRetryTotals ? (
246
+ <Button variant='outline' size='sm' className='w-fit' onClick={onRetryTotals}>
247
+ {retryLabel}
248
+ </Button>
249
+ ) : null}
250
+ </div>
251
+ )
252
+ }
253
+ if (!totals) return null
254
+
255
+ const subtotalText = money(totals.subtotal)
256
+ const totalText = money(totals.total)
257
+ // Required totals: if either is absent/malformed, show an explicit
258
+ // unavailable state (with recovery) rather than a fabricated zero.
259
+ if (subtotalText === null || totalText === null) {
260
+ return (
261
+ <div className='flex flex-col gap-2' role='status'>
262
+ <p className='text-muted-foreground text-sm'>{totalsUnavailableLabel}</p>
263
+ {onRetryTotals ? (
264
+ <Button variant='outline' size='sm' className='w-fit' onClick={onRetryTotals}>
265
+ {retryLabel}
266
+ </Button>
267
+ ) : null}
268
+ </div>
269
+ )
270
+ }
271
+
272
+ const shippingText = money(totals.shipping)
273
+ return (
274
+ <>
275
+ <div className='flex justify-between text-sm'>
276
+ <span>{subtotalLabel}</span>
277
+ <span className='tabular-nums'>{subtotalText}</span>
278
+ </div>
279
+ {isAvailableMoney(totals.discount) ? (
280
+ <div className='flex justify-between text-sm'>
281
+ <span>{discountLabel}</span>
282
+ <span className='tabular-nums'>{money(totals.discount)}</span>
283
+ </div>
284
+ ) : null}
285
+ {isAvailableMoney(totals.tax) ? (
286
+ <div className='flex justify-between text-sm'>
287
+ <span>{taxLabel}</span>
288
+ <span className='tabular-nums'>{money(totals.tax)}</span>
289
+ </div>
290
+ ) : null}
291
+ {shippingText !== null ? (
292
+ <div className='flex justify-between text-sm text-success'>
293
+ <span>{shippingLabel}</span>
294
+ <span className='tabular-nums'>
295
+ {Number(totals.shipping?.amount) === 0 ? shippingFreeLabel : shippingText}
296
+ </span>
297
+ </div>
298
+ ) : null}
299
+ <Separator />
300
+ <div className='flex justify-between font-semibold'>
301
+ <span>{totalLabel}</span>
302
+ <span className='tabular-nums'>{totalText}</span>
303
+ </div>
304
+ </>
305
+ )
306
+ }
307
+
134
308
  return (
135
309
  <section className='py-8'>
136
- <div className='mx-auto max-w-md px-4'>
137
- <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'}>
138
315
  {/* Header */}
139
316
  <CardHeader className='flex flex-row items-center justify-between space-y-0 pb-3'>
140
317
  <CardTitle className='flex items-center gap-2'>
141
318
  <ShoppingCart className='size-5' />
142
319
  {titleLabel} ({itemCount})
143
320
  </CardTitle>
144
- <Button
145
- variant='ghost'
146
- className='h-8 cursor-pointer px-3 text-xs'
147
- size='sm'
148
- onClick={() => setIsExpanded(!isExpanded)}
149
- aria-expanded={isExpanded}
150
- aria-label={isExpanded ? collapseLabel : expandLabel}
151
- >
152
- {isExpanded ? <X /> : <Plus />}
153
- </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}
154
333
  </CardHeader>
155
334
 
156
335
  <CardContent className='flex flex-col gap-6'>
157
336
  {items.length === 0 ? (
158
- <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'>
159
338
  <ShoppingCart className='text-muted-foreground/50 size-10' />
160
- <p className='text-muted-foreground text-sm'>Tu carrito está vacío</p>
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}
161
345
  </div>
162
346
  ) : (
163
347
  <>
@@ -167,7 +351,7 @@ export function ShoppingCart4({
167
351
  <div className='flex items-center justify-between text-sm'>
168
352
  <span className='text-muted-foreground'>
169
353
  {interpolate(awayFromLabel, {
170
- amount: `$${(currentPromo.threshold - subtotal).toFixed(2)}`,
354
+ amount: format(currentPromo.threshold - subtotalAmount),
171
355
  })}
172
356
  </span>
173
357
  <div className='flex items-center gap-1'>
@@ -191,20 +375,30 @@ export function ShoppingCart4({
191
375
  </div>
192
376
  ) : null}
193
377
 
194
- {isExpanded && (
378
+ {expanded && (
195
379
  <>
196
380
  {/* Cart Items */}
197
- <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
+ >
198
388
  {items.map(item => (
199
389
  <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' />
390
+ {item.image ? (
391
+ <img src={item.image || undefined} alt='' className='h-12 w-12 rounded-md object-cover' />
392
+ ) : (
393
+ <div aria-hidden='true' className='bg-muted h-12 w-12 shrink-0 rounded-md' />
394
+ )}
201
395
 
202
396
  <div className='flex flex-1 flex-col gap-1'>
203
397
  <h3 className='line-clamp-2 text-sm font-medium'>{item.name}</h3>
204
398
  {item.variant ? <p className='text-muted-foreground text-xs'>{item.variant}</p> : null}
205
399
 
206
400
  <div className='flex items-center justify-between'>
207
- <span className='text-sm font-semibold'>${item.price.toFixed(2)}</span>
401
+ <span className='text-sm font-semibold'>{lineAmount(item) ?? amountUnavailableLabel}</span>
208
402
 
209
403
  <div className='flex items-center'>
210
404
  <Button
@@ -216,8 +410,8 @@ export function ShoppingCart4({
216
410
  ? () => onQuantityChange(item.id, item.quantity - 1)
217
411
  : undefined
218
412
  }
219
- disabled={item.quantity <= 1}
220
- aria-label={`Disminuir cantidad de ${item.name}`}
413
+ disabled={item.quantity <= 1 || mutationsDisabled}
414
+ aria-label={`${decreaseLabel}, ${item.name}`}
221
415
  >
222
416
  <Minus />
223
417
  </Button>
@@ -231,16 +425,18 @@ export function ShoppingCart4({
231
425
  ? () => onQuantityChange(item.id, item.quantity + 1)
232
426
  : undefined
233
427
  }
234
- aria-label={`Aumentar cantidad de ${item.name}`}
428
+ aria-label={`${increaseLabel}, ${item.name}`}
429
+ disabled={mutationsDisabled}
235
430
  >
236
431
  <Plus />
237
432
  </Button>
238
433
  <Button
239
434
  variant='ghost'
240
435
  size='sm'
241
- className='ms-2 size-6 h-8 cursor-pointer p-0 px-3 text-xs text-destructive hover:text-destructive'
436
+ className='text-destructive hover:text-destructive ms-2 size-6 h-8 cursor-pointer p-0 px-3 text-xs'
242
437
  onClick={onRemove ? () => onRemove(item.id) : undefined}
243
- aria-label={`Quitar ${item.name}`}
438
+ disabled={mutationsDisabled}
439
+ aria-label={`${removeLabel} ${item.name}`}
244
440
  >
245
441
  <X />
246
442
  </Button>
@@ -253,73 +449,78 @@ export function ShoppingCart4({
253
449
 
254
450
  <Separator />
255
451
 
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>
452
+ {/* Order Summary — backend-authoritative only */}
453
+ <div className='flex flex-col gap-2'>{renderTotals()}</div>
278
454
 
279
455
  {/* Quick Actions */}
280
456
  <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>
457
+ {/* Express Checkout Toggle — only when the host supplies a handler */}
458
+ {hasExpress ? (
459
+ <>
460
+ <div className='flex items-center justify-between text-sm'>
461
+ <span>{expressCheckoutLabel}</span>
462
+ <Button
463
+ variant='outline'
464
+ size='sm'
465
+ onClick={() => setQuickCheckout(!quickCheckout)}
466
+ className='h-8 cursor-pointer'
467
+ aria-pressed={quickCheckout}
468
+ >
469
+ <Zap />
470
+ {quickCheckout ? standardLabel : expressLabel}
471
+ </Button>
472
+ </div>
473
+
474
+ {quickCheckout ? (
475
+ <div className='grid grid-cols-2 gap-2'>
476
+ {onPayWithApple ? (
477
+ <Button size='sm' variant='outline' className='h-8 cursor-pointer px-3 text-xs' onClick={onPayWithApple}>
478
+ <Wallet />
479
+ Apple Pay
480
+ </Button>
481
+ ) : null}
482
+ {onPayWithGoogle ? (
483
+ <Button size='sm' variant='outline' className='h-8 cursor-pointer px-3 text-xs' onClick={onPayWithGoogle}>
484
+ <CreditCard />
485
+ Google Pay
486
+ </Button>
487
+ ) : null}
488
+ </div>
489
+ ) : null}
490
+ </>
491
+ ) : null}
492
+
493
+ {/* Checkout 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 ? (
284
507
  <Button
285
508
  variant='outline'
286
- size='sm'
287
- onClick={() => setQuickCheckout(!quickCheckout)}
288
- className='h-8 cursor-pointer'
289
- aria-pressed={quickCheckout}
509
+ size='lg'
510
+ className='h-10 w-full cursor-pointer px-8'
511
+ onClick={onContinueShopping}
290
512
  >
291
- <Zap />
292
- {quickCheckout ? standardLabel : expressLabel}
513
+ {continueShoppingLabel}
293
514
  </Button>
294
- </div>
515
+ ) : null}
295
516
 
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>
517
+ {/* Estimated Delivery — only when supplied, never fabricated */}
518
+ {estimatedDeliveryLabel ? (
519
+ <div className='text-muted-foreground flex items-center justify-center gap-2 text-xs'>
520
+ <Clock className='size-3' />
521
+ <span>{estimatedDeliveryLabel}</span>
306
522
  </div>
307
- )}
308
-
309
- {/* Checkout Button */}
310
- <Button
311
- size='lg'
312
- className='h-10 w-full cursor-pointer px-8'
313
- onClick={quickCheckout ? onQuickCheckout : onCheckout}
314
- >
315
- {quickCheckout ? quickCheckoutLabel : checkoutLabel}
316
- </Button>
317
-
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>
523
+ ) : null}
323
524
  </div>
324
525
 
325
526
  {/* Security Badge */}
@@ -333,15 +534,15 @@ export function ShoppingCart4({
333
534
  )}
334
535
 
335
536
  {/* Collapsed Summary */}
336
- {!isExpanded && (
537
+ {!expanded && (
337
538
  <div className='flex flex-col gap-3'>
338
539
  <div className='flex items-center justify-between'>
339
540
  <span className='text-muted-foreground text-sm'>
340
541
  {itemCount} {itemsLabel}
341
542
  </span>
342
- <span className='font-semibold'>
343
- <ShoppingCart4SpinningCounter value={total} decimals={2} prefix='$' />
344
- </span>
543
+ {isAvailableMoney(totals?.total) ? (
544
+ <span className='font-semibold tabular-nums'>{money(totals.total)}</span>
545
+ ) : null}
345
546
  </div>
346
547
 
347
548
  <Button size='sm' className='h-8 w-full cursor-pointer px-3 text-xs' onClick={onCheckout}>
@@ -351,7 +552,7 @@ export function ShoppingCart4({
351
552
  )}
352
553
 
353
554
  {/* Recently Viewed */}
354
- {isExpanded && recentlyViewed && recentlyViewed.length > 0 && (
555
+ {expanded && recentlyViewed && recentlyViewed.length > 0 && (
355
556
  <div className='flex flex-col gap-3'>
356
557
  <h4 className='text-sm font-medium'>{recentlyViewedLabel}</h4>
357
558
  <div className='grid grid-cols-3 gap-2'>
@@ -365,12 +566,14 @@ export function ShoppingCart4({
365
566
  )}
366
567
  onClick={onRecentlyViewedClick ? () => onRecentlyViewedClick(item.id) : undefined}
367
568
  >
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
- />
569
+ <div className='bg-muted mb-1 aspect-square overflow-hidden'>
570
+ {item.image ? (
571
+ <img
572
+ src={item.image || undefined}
573
+ alt={item.name}
574
+ className='size-full rounded-md object-cover'
575
+ />
576
+ ) : null}
374
577
  </div>
375
578
  <p className='text-muted-foreground truncate text-xs'>{item.name}</p>
376
579
  <p className='text-xs font-medium'>${item.price.toFixed(2)}</p>
@@ -388,4 +591,4 @@ export function ShoppingCart4({
388
591
  )
389
592
  }
390
593
 
391
- export default ShoppingCart4
594
+ export default ShoppingCart4