@thorprovider/blocks 0.1.2 → 1.0.0

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 (111) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +108 -23
  3. package/docs/standards/data-contracts.md +13 -2
  4. package/package.json +26 -10
  5. package/scripts/fix-esm-extensions.mjs +40 -0
  6. package/src/blocks/e-commerce/navigation/back-to-top.test.tsx +27 -0
  7. package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
  8. package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
  9. package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
  10. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
  11. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
  12. package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
  13. package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
  14. package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
  15. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  16. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  17. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  18. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  19. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  20. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  21. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  22. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  23. package/src/blocks/e-commerce/product-list/product-card.tsx +424 -0
  24. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  25. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  26. package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
  27. package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
  28. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  29. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  30. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  31. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  32. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  33. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  34. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  35. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  36. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +204 -109
  37. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  38. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  39. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  40. package/src/blocks/marketing/features/feature.tsx +195 -0
  41. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +56 -0
  42. package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
  43. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +150 -0
  44. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +102 -9
  45. package/src/blocks/marketing/navbars/navbar-4.test.tsx +154 -2
  46. package/src/blocks/marketing/navbars/navbar-4.tsx +152 -32
  47. package/src/index.ts +93 -46
  48. package/src/lib/contracts-curated.test.ts +42 -24
  49. package/src/lib/contracts.test.ts +143 -2
  50. package/src/lib/contracts.ts +395 -13
  51. package/src/ui/button-group.tsx +1 -1
  52. package/src/ui/carousel.tsx +1 -1
  53. package/src/ui/dialog.tsx +1 -1
  54. package/src/ui/field.tsx +2 -2
  55. package/src/ui/input-group.tsx +3 -3
  56. package/src/ui/pagination.tsx +1 -1
  57. package/src/ui/sidebar.tsx +7 -7
  58. package/src/ui/toggle-group.tsx +1 -1
  59. package/tsconfig.build.json +12 -0
  60. package/tsup.config.ts +11 -7
  61. package/.turbo/turbo-build.log +0 -24
  62. package/.turbo/turbo-test.log +0 -37
  63. package/.turbo/turbo-type-check.log +0 -0
  64. package/dist/.tsbuildinfo +0 -1
  65. package/dist/contracts.d.mts +0 -1584
  66. package/dist/contracts.d.ts +0 -1584
  67. package/dist/contracts.js +0 -390
  68. package/dist/contracts.js.map +0 -1
  69. package/dist/contracts.mjs +0 -334
  70. package/dist/contracts.mjs.map +0 -1
  71. package/dist/index.d.mts +0 -2786
  72. package/dist/index.d.ts +0 -2786
  73. package/dist/index.js +0 -17139
  74. package/dist/index.js.map +0 -1
  75. package/dist/index.mjs +0 -17059
  76. package/dist/index.mjs.map +0 -1
  77. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  78. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  79. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  80. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  81. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  82. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  83. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -131
  84. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  85. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  86. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  87. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  88. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  89. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  90. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  91. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  92. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  93. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  94. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  95. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  96. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  97. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  98. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  99. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  100. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  101. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  102. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  103. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  104. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -137
  105. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -65
  106. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  107. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  108. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  109. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  110. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  111. package/src/blocks/marketing/features/feature-2.tsx +0 -82
@@ -1,65 +1,48 @@
1
1
  'use client'
2
2
 
3
- import { useState } from 'react'
4
- import { ShoppingCart, Zap, Clock, CreditCard, Wallet, Minus, Plus, X } from 'lucide-react'
3
+ import { useId, useState } from 'react'
4
+ import { ShoppingCart as ShoppingCartIcon, Zap, Clock, CreditCard, Wallet, Minus, Plus, X } from 'lucide-react'
5
5
  import type { LucideIcon } from 'lucide-react'
6
6
  import type { Money } from '@thorprovider/types'
7
7
  import { Card, CardContent, CardHeader, CardTitle } from '../../../ui/card'
8
8
  import { Button } from '../../../ui/button'
9
+ import { Input } from '../../../ui/input'
9
10
  import { Separator } from '../../../ui/separator'
10
11
  import { Progress } from '../../../ui/progress'
11
12
  import { cn } from '../../../lib/utils'
12
13
  import { formatMoney, isAvailableMoney } from '../../../lib/money'
14
+ import type { CartTotals, ShoppingCartItem, ShoppingCartSuggestion } from '../../../lib/contracts'
13
15
 
14
- export interface ShoppingCart4Item {
15
- /** Backend cart line-item id — the stable identity for update/removal. */
16
- id: string
17
- name: string
18
- price: number
19
- quantity: number
20
- /** Optional; an absent image is omitted rather than serialized as `""`. */
21
- image?: string
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
35
- }
36
-
37
- export type ShoppingCart4TotalsState = 'idle' | 'pending' | 'error'
38
-
39
- export interface ShoppingCart4Promotion {
16
+ /** Host-supplied promotion definition. Icons are React-side only. */
17
+ export interface ShoppingCartPromotion {
40
18
  threshold: number
41
19
  reward: string
42
20
  icon: LucideIcon
43
21
  }
44
22
 
45
- export interface ShoppingCart4RecentlyViewed {
46
- id: string
47
- name: string
48
- price: number
49
- image?: string
50
- }
23
+ export type ShoppingCartTotalsState = 'idle' | 'pending' | 'error'
51
24
 
52
- export interface ShoppingCart4Props {
53
- items: ShoppingCart4Item[]
54
- promotions: ShoppingCart4Promotion[]
55
- recentlyViewed?: ShoppingCart4RecentlyViewed[]
25
+ export type ShoppingCartPresentation = 'compact' | 'page'
26
+
27
+ export interface ShoppingCartProps {
28
+ /** Canonical cart lines keyed by backend cart line-item id. */
29
+ items: ShoppingCartItem[]
30
+ promotions?: ShoppingCartPromotion[]
31
+ recentlyViewed?: ShoppingCartSuggestion[]
56
32
  /**
57
- * Backend-authoritative totals. The curated cart renders these verbatim; it
58
- * never computes subtotal/tax/shipping/total locally.
33
+ * Layout presentation. `compact` (default) is the collapsible quick-cart
34
+ * card; `page` is a wide, always-expanded review layout with a primary
35
+ * heading, no collapse control, and normal page scrolling. Both use the same
36
+ * line controls and authoritative monetary rendering.
59
37
  */
60
- totals?: ShoppingCart4Totals
38
+ presentation?: ShoppingCartPresentation
39
+ /**
40
+ * Backend-authoritative totals. The cart renders these verbatim; it never
41
+ * computes subtotal/tax/shipping/total locally.
42
+ */
43
+ totals?: CartTotals
61
44
  /** Controlled state of the totals fetch/mutation. */
62
- totalsState?: ShoppingCart4TotalsState
45
+ totalsState?: ShoppingCartTotalsState
63
46
  totalsErrorLabel?: string
64
47
  /** Shown when a required total amount is absent or malformed (never as zero). */
65
48
  totalsUnavailableLabel?: string
@@ -69,10 +52,6 @@ export interface ShoppingCart4Props {
69
52
  onRetryTotals?: () => void
70
53
  /** Override money formatting (locale, currency). Defaults to `Money.currencyCode`. */
71
54
  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
76
55
  // labels (Spanish defaults):
77
56
  titleLabel?: string // 'Carrito de compras'
78
57
  subtotalLabel?: string // 'Subtotal'
@@ -91,6 +70,9 @@ export interface ShoppingCart4Props {
91
70
  estimatedDeliveryLabel?: string
92
71
  secureCheckoutLabel?: string // 'Pago seguro • SSL cifrado'
93
72
  recentlyViewedLabel?: string // 'Vistos recientemente'
73
+ promoPromptLabel?: string // '¿Tienes un código de descuento?'
74
+ promoPlaceholderLabel?: string // 'Introduce tu código'
75
+ promoApplyLabel?: string // 'Aplicar'
94
76
  collapseLabel?: string // 'Contraer carrito'
95
77
  expandLabel?: string // 'Expandir carrito'
96
78
  checkoutLabel?: string // 'Ver carrito y finalizar'
@@ -100,6 +82,12 @@ export interface ShoppingCart4Props {
100
82
  removeLabel?: string
101
83
  decreaseLabel?: string
102
84
  increaseLabel?: string
85
+ continueShoppingLabel?: string // 'Seguir comprando'
86
+ // action availability:
87
+ /** Suppress the checkout action (e.g. the checkout feature is not installed). */
88
+ checkoutUnavailable?: boolean
89
+ /** Disable checkout while the cart is not ready (pending/error/invalid totals). */
90
+ checkoutDisabled?: boolean
103
91
  // callbacks:
104
92
  /**
105
93
  * ABSOLUTE quantity for a backend cart line-item id (distinct from the
@@ -112,10 +100,19 @@ export interface ShoppingCart4Props {
112
100
  onQuickCheckout?: () => void
113
101
  onPayWithApple?: () => void
114
102
  onPayWithGoogle?: () => void
103
+ /**
104
+ * Report an entered promotion code to the host. The block never validates
105
+ * or resolves codes; the authoritative result arrives through `totals`
106
+ * (discount) and `promotions`. When absent, the promo affordance is not
107
+ * rendered.
108
+ */
109
+ onApplyPromo?: (code: string) => void
115
110
  onRecentlyViewedClick?: (id: string) => void
111
+ /** Page presentation only: navigate back to browsing. */
112
+ onContinueShopping?: () => void
116
113
  }
117
114
 
118
- const usdFormatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
115
+ const fallbackFormatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
119
116
 
120
117
  const interpolate = (template: string, values: Record<string, string>) =>
121
118
  template.replace(/\$\{(\w+)\}|\{(\w+)\}/g, (match, dollarKey, braceKey) => {
@@ -123,10 +120,11 @@ const interpolate = (template: string, values: Record<string, string>) =>
123
120
  return values[key] ?? match
124
121
  })
125
122
 
126
- export function ShoppingCart4({
123
+ export function ShoppingCart({
127
124
  items,
128
125
  promotions,
129
126
  recentlyViewed,
127
+ presentation = 'compact',
130
128
  totals,
131
129
  totalsState = 'idle',
132
130
  totalsErrorLabel = 'No se pudieron actualizar los totales.',
@@ -135,7 +133,6 @@ export function ShoppingCart4({
135
133
  retryLabel = 'Reintentar',
136
134
  onRetryTotals,
137
135
  formatMoney: formatMoneyProp,
138
- formatPrice,
139
136
  titleLabel = 'Carrito de compras',
140
137
  subtotalLabel = 'Subtotal',
141
138
  discountLabel = 'Descuento',
@@ -153,6 +150,9 @@ export function ShoppingCart4({
153
150
  estimatedDeliveryLabel,
154
151
  secureCheckoutLabel = 'Pago seguro • SSL cifrado',
155
152
  recentlyViewedLabel = 'Vistos recientemente',
153
+ promoPromptLabel = '¿Tienes un código de descuento?',
154
+ promoPlaceholderLabel = 'Introduce tu código',
155
+ promoApplyLabel = 'Aplicar',
156
156
  collapseLabel = 'Contraer carrito',
157
157
  expandLabel = 'Expandir carrito',
158
158
  checkoutLabel = 'Ver carrito y finalizar',
@@ -162,24 +162,47 @@ export function ShoppingCart4({
162
162
  removeLabel = 'Quitar',
163
163
  decreaseLabel = 'Disminuir cantidad',
164
164
  increaseLabel = 'Aumentar cantidad',
165
+ continueShoppingLabel = 'Seguir comprando',
166
+ checkoutUnavailable,
167
+ checkoutDisabled,
165
168
  onQuantityChange,
166
169
  onRemove,
167
170
  onCheckout,
168
171
  onQuickCheckout,
169
172
  onPayWithApple,
170
173
  onPayWithGoogle,
174
+ onApplyPromo,
171
175
  onRecentlyViewedClick,
172
- }: ShoppingCart4Props) {
176
+ onContinueShopping,
177
+ }: ShoppingCartProps) {
173
178
  const [isExpanded, setIsExpanded] = useState(true)
174
179
  const [quickCheckout, setQuickCheckout] = useState(false)
175
-
176
- const format = formatPrice ?? (value => usdFormatter.format(value))
180
+ const [promoCode, setPromoCode] = useState('')
181
+ const promoInputId = useId()
182
+
183
+ const isPage = presentation === 'page'
184
+ // The page presentation is always expanded; only the compact card collapses.
185
+ const expanded = isPage ? true : isExpanded
186
+ // Display currency derives from the authoritative Money subtotal; the USD
187
+ // formatter is only the fallback when no authoritative currency exists.
188
+ const format = formatMoneyProp
189
+ ? (value: number) => {
190
+ const money: Money = { amount: String(value), currencyCode: totals?.subtotal?.currencyCode ?? 'USD' }
191
+ return formatMoneyProp(money)
192
+ }
193
+ : totals?.subtotal?.currencyCode && totals.subtotal.currencyCode !== 'USD'
194
+ ? (value: number) =>
195
+ new Intl.NumberFormat('en-US', { style: 'currency', currency: totals.subtotal.currencyCode }).format(value)
196
+ : (value: number) => fallbackFormatter.format(value)
177
197
  const money = (value: Money | null | undefined) => formatMoney(value, formatMoneyProp)
178
198
  const subtotalAmount = isAvailableMoney(totals?.subtotal) ? Number(totals.subtotal.amount) : 0
179
199
  const itemCount = items.reduce((sum, item) => sum + item.quantity, 0)
180
200
  const hasExpress = Boolean(onQuickCheckout || onPayWithApple || onPayWithGoogle)
201
+ // While totals/mutations are pending, keep confirmed data visible but block
202
+ // conflicting edits and checkout until the backend responds.
203
+ const mutationsDisabled = totalsState === 'pending'
181
204
 
182
- const sortedPromotions = [...promotions].sort((a, b) => a.threshold - b.threshold)
205
+ const sortedPromotions = [...(promotions ?? [])].sort((a, b) => a.threshold - b.threshold)
183
206
  const currentPromo = (() => {
184
207
  if (!isAvailableMoney(totals?.subtotal)) return null
185
208
  for (let i = sortedPromotions.length - 1; i >= 0; i--) {
@@ -195,11 +218,12 @@ export function ShoppingCart4({
195
218
  ? Math.min(100, (subtotalAmount / currentPromo.threshold) * 100)
196
219
  : 100
197
220
 
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)
221
+ const lineAmount = (item: ShoppingCartItem): string | null => {
222
+ // The backend-authoritative line total renders verbatim. A malformed amount
223
+ // is unavailable (never a fabricated zero); `unitPrice` is never multiplied
224
+ // locally — the cart contract forbids local `unit × qty` arithmetic.
225
+ if (item.lineTotal === undefined) return null
226
+ return money(item.lineTotal)
203
227
  }
204
228
 
205
229
  const renderTotals = () => {
@@ -277,33 +301,73 @@ export function ShoppingCart4({
277
301
  )
278
302
  }
279
303
 
304
+ const renderPromoEntry = () =>
305
+ onApplyPromo ? (
306
+ <form
307
+ className='flex flex-col gap-2'
308
+ onSubmit={event => {
309
+ event.preventDefault()
310
+ const code = promoCode.trim()
311
+ if (!code) return
312
+ onApplyPromo(code)
313
+ setPromoCode('')
314
+ }}
315
+ >
316
+ <label className='text-sm' htmlFor={promoInputId}>
317
+ {promoPromptLabel}
318
+ </label>
319
+ <div className='flex gap-2'>
320
+ <Input
321
+ id={promoInputId}
322
+ value={promoCode}
323
+ onChange={event => setPromoCode(event.target.value)}
324
+ placeholder={promoPlaceholderLabel}
325
+ className='h-9 flex-1'
326
+ />
327
+ <Button type='submit' variant='outline' size='sm' className='h-9' disabled={mutationsDisabled}>
328
+ {promoApplyLabel}
329
+ </Button>
330
+ </div>
331
+ </form>
332
+ ) : null
333
+
280
334
  return (
281
335
  <section className='py-8'>
282
- <div className='mx-auto max-w-md px-4'>
283
- <Card className='shadow-lg'>
336
+ <div className={cn('mx-auto px-4', isPage ? 'max-w-3xl' : 'max-w-md')}>
337
+ {isPage ? (
338
+ <h1 className='mb-6 text-2xl font-semibold tracking-tight'>{titleLabel}</h1>
339
+ ) : null}
340
+ <Card className={isPage ? 'shadow-sm' : 'shadow-lg'}>
284
341
  {/* Header */}
285
342
  <CardHeader className='flex flex-row items-center justify-between space-y-0 pb-3'>
286
343
  <CardTitle className='flex items-center gap-2'>
287
- <ShoppingCart className='size-5' />
344
+ <ShoppingCartIcon className='size-5' />
288
345
  {titleLabel} ({itemCount})
289
346
  </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>
347
+ {!isPage ? (
348
+ <Button
349
+ variant='ghost'
350
+ className='h-8 cursor-pointer px-3 text-xs'
351
+ size='sm'
352
+ onClick={() => setIsExpanded(!isExpanded)}
353
+ aria-expanded={isExpanded}
354
+ aria-label={isExpanded ? collapseLabel : expandLabel}
355
+ >
356
+ {isExpanded ? <X /> : <Plus />}
357
+ </Button>
358
+ ) : null}
300
359
  </CardHeader>
301
360
 
302
361
  <CardContent className='flex flex-col gap-6'>
303
362
  {items.length === 0 ? (
304
- <div className='flex flex-col items-center justify-center gap-2 py-6 text-center'>
305
- <ShoppingCart className='text-muted-foreground/50 size-10' />
363
+ <div className='flex flex-col items-center justify-center gap-4 py-6 text-center'>
364
+ <ShoppingCartIcon className='text-muted-foreground/50 size-10' />
306
365
  <p className='text-muted-foreground text-sm'>{emptyLabel}</p>
366
+ {isPage && onContinueShopping ? (
367
+ <Button variant='outline' onClick={onContinueShopping}>
368
+ {continueShoppingLabel}
369
+ </Button>
370
+ ) : null}
307
371
  </div>
308
372
  ) : (
309
373
  <>
@@ -337,10 +401,16 @@ export function ShoppingCart4({
337
401
  </div>
338
402
  ) : null}
339
403
 
340
- {isExpanded && (
404
+ {expanded && (
341
405
  <>
342
406
  {/* 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'>
407
+ <div
408
+ className={cn(
409
+ 'flex flex-col gap-4',
410
+ !isPage &&
411
+ 'scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent max-h-64 overflow-y-auto',
412
+ )}
413
+ >
344
414
  {items.map(item => (
345
415
  <div key={item.id} className='flex items-start gap-3'>
346
416
  {item.image ? (
@@ -366,7 +436,7 @@ export function ShoppingCart4({
366
436
  ? () => onQuantityChange(item.id, item.quantity - 1)
367
437
  : undefined
368
438
  }
369
- disabled={item.quantity <= 1}
439
+ disabled={item.quantity <= 1 || mutationsDisabled}
370
440
  aria-label={`${decreaseLabel}, ${item.name}`}
371
441
  >
372
442
  <Minus />
@@ -382,6 +452,7 @@ export function ShoppingCart4({
382
452
  : undefined
383
453
  }
384
454
  aria-label={`${increaseLabel}, ${item.name}`}
455
+ disabled={mutationsDisabled}
385
456
  >
386
457
  <Plus />
387
458
  </Button>
@@ -390,6 +461,7 @@ export function ShoppingCart4({
390
461
  size='sm'
391
462
  className='text-destructive hover:text-destructive ms-2 size-6 h-8 cursor-pointer p-0 px-3 text-xs'
392
463
  onClick={onRemove ? () => onRemove(item.id) : undefined}
464
+ disabled={mutationsDisabled}
393
465
  aria-label={`${removeLabel} ${item.name}`}
394
466
  >
395
467
  <X />
@@ -406,6 +478,9 @@ export function ShoppingCart4({
406
478
  {/* Order Summary — backend-authoritative only */}
407
479
  <div className='flex flex-col gap-2'>{renderTotals()}</div>
408
480
 
481
+ {/* Promo code entry — reports to the host, never resolves locally */}
482
+ {renderPromoEntry()}
483
+
409
484
  {/* Quick Actions */}
410
485
  <div className='flex flex-col gap-3'>
411
486
  {/* Express Checkout Toggle — only when the host supplies a handler */}
@@ -445,13 +520,28 @@ export function ShoppingCart4({
445
520
  ) : null}
446
521
 
447
522
  {/* 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>
523
+ {!checkoutUnavailable ? (
524
+ <Button
525
+ size='lg'
526
+ className='h-10 w-full cursor-pointer px-8'
527
+ onClick={hasExpress && quickCheckout ? onQuickCheckout : onCheckout}
528
+ disabled={checkoutDisabled || totalsState === 'pending'}
529
+ >
530
+ {hasExpress && quickCheckout ? quickCheckoutLabel : checkoutLabel}
531
+ </Button>
532
+ ) : null}
533
+
534
+ {/* Continue shopping — page presentation only */}
535
+ {isPage && onContinueShopping ? (
536
+ <Button
537
+ variant='outline'
538
+ size='lg'
539
+ className='h-10 w-full cursor-pointer px-8'
540
+ onClick={onContinueShopping}
541
+ >
542
+ {continueShoppingLabel}
543
+ </Button>
544
+ ) : null}
455
545
 
456
546
  {/* Estimated Delivery — only when supplied, never fabricated */}
457
547
  {estimatedDeliveryLabel ? (
@@ -473,7 +563,7 @@ export function ShoppingCart4({
473
563
  )}
474
564
 
475
565
  {/* Collapsed Summary */}
476
- {!isExpanded && (
566
+ {!expanded && (
477
567
  <div className='flex flex-col gap-3'>
478
568
  <div className='flex items-center justify-between'>
479
569
  <span className='text-muted-foreground text-sm'>
@@ -490,34 +580,39 @@ export function ShoppingCart4({
490
580
  </div>
491
581
  )}
492
582
 
493
- {/* Recently Viewed */}
494
- {isExpanded && recentlyViewed && recentlyViewed.length > 0 && (
583
+ {/* Recently Viewed — host-supplied suggestions only */}
584
+ {expanded && recentlyViewed && recentlyViewed.length > 0 && (
495
585
  <div className='flex flex-col gap-3'>
496
586
  <h4 className='text-sm font-medium'>{recentlyViewedLabel}</h4>
497
587
  <div className='grid grid-cols-3 gap-2'>
498
- {recentlyViewed.map(item => (
499
- <button
500
- key={item.id}
501
- type='button'
502
- className={cn(
503
- 'group text-start',
504
- onRecentlyViewedClick && 'cursor-pointer',
505
- )}
506
- onClick={onRecentlyViewedClick ? () => onRecentlyViewedClick(item.id) : undefined}
507
- >
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
- />
588
+ {recentlyViewed.map(item => {
589
+ const suggestionPrice = money(item.unitPrice)
590
+ return (
591
+ <button
592
+ key={item.id}
593
+ type='button'
594
+ className={cn(
595
+ 'group text-start',
596
+ onRecentlyViewedClick && 'cursor-pointer',
597
+ )}
598
+ onClick={onRecentlyViewedClick ? () => onRecentlyViewedClick(item.id) : undefined}
599
+ >
600
+ <div className='bg-muted mb-1 aspect-square overflow-hidden'>
601
+ {item.image ? (
602
+ <img
603
+ src={item.image || undefined}
604
+ alt={item.name}
605
+ className='size-full rounded-md object-cover'
606
+ />
607
+ ) : null}
608
+ </div>
609
+ <p className='text-muted-foreground truncate text-xs'>{item.name}</p>
610
+ {suggestionPrice !== null ? (
611
+ <p className='text-xs font-medium tabular-nums'>{suggestionPrice}</p>
515
612
  ) : null}
516
- </div>
517
- <p className='text-muted-foreground truncate text-xs'>{item.name}</p>
518
- <p className='text-xs font-medium'>${item.price.toFixed(2)}</p>
519
- </button>
520
- ))}
613
+ </button>
614
+ )
615
+ })}
521
616
  </div>
522
617
  </div>
523
618
  )}
@@ -530,4 +625,4 @@ export function ShoppingCart4({
530
625
  )
531
626
  }
532
627
 
533
- export default ShoppingCart4
628
+ export default ShoppingCart