@thorprovider/blocks 0.1.3 → 2.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 (178) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/README.md +228 -68
  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/application/login-forms/login-page.test.tsx +190 -0
  7. package/src/blocks/application/login-forms/login-page.tsx +240 -0
  8. package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +6 -8
  9. package/src/blocks/application/signup-forms/signup-page-1.tsx +127 -129
  10. package/src/blocks/application/verification/verify-1.tsx +2 -2
  11. package/src/blocks/application/viewport-ownership.test.tsx +41 -0
  12. package/src/blocks/e-commerce/category-filters/category-listing-filter-fields.tsx +218 -0
  13. package/src/blocks/e-commerce/category-filters/{category-filter-2.test.tsx → category-listing.test.tsx} +287 -199
  14. package/src/blocks/e-commerce/category-filters/category-listing.tsx +934 -0
  15. package/src/blocks/e-commerce/checkout-forms/checkout-contact-step.tsx +149 -0
  16. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-country-field.tsx → checkout-country-field.tsx} +10 -8
  17. package/src/blocks/e-commerce/checkout-forms/checkout-order-summary.tsx +230 -0
  18. package/src/blocks/e-commerce/checkout-forms/checkout-payment-step.tsx +100 -0
  19. package/src/blocks/e-commerce/checkout-forms/checkout-progress.tsx +62 -0
  20. package/src/blocks/e-commerce/checkout-forms/checkout-review-step.tsx +59 -0
  21. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-shipping-step.tsx → checkout-shipping-step.tsx} +65 -41
  22. package/src/blocks/e-commerce/checkout-forms/checkout.test.tsx +478 -0
  23. package/src/blocks/e-commerce/checkout-forms/checkout.tsx +550 -0
  24. package/src/blocks/e-commerce/navigation/storefront-navbar-account-menu.test.tsx +56 -0
  25. package/src/blocks/{marketing/navbars/navbar-4-account-menu.tsx → e-commerce/navigation/storefront-navbar-account-menu.tsx} +3 -3
  26. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.test.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.test.tsx} +22 -22
  27. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.tsx} +3 -3
  28. package/src/blocks/{marketing/navbars/navbar-4-mobile-nav.tsx → e-commerce/navigation/storefront-navbar-mobile-nav.tsx} +4 -12
  29. package/src/blocks/{marketing/navbars/navbar-4-search-bar.tsx → e-commerce/navigation/storefront-navbar-search-bar.tsx} +3 -3
  30. package/src/blocks/{marketing/navbars/navbar-4.test.tsx → e-commerce/navigation/storefront-navbar.test.tsx} +67 -36
  31. package/src/blocks/{marketing/navbars/navbar-4.tsx → e-commerce/navigation/storefront-navbar.tsx} +43 -25
  32. package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
  33. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  34. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  35. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  36. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  37. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  38. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  39. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  40. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  41. package/src/blocks/e-commerce/product-list/product-card.tsx +447 -0
  42. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  43. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  44. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  45. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  46. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  47. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  48. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  49. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  50. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  51. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  52. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +119 -85
  53. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  54. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  55. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  56. package/src/blocks/marketing/features/feature.tsx +195 -0
  57. package/src/blocks/marketing/footers/footer-section-accessibility.test.tsx +118 -0
  58. package/src/blocks/marketing/footers/footer-section-actions.test.tsx +131 -0
  59. package/src/blocks/marketing/footers/footer-section-data.test.tsx +127 -0
  60. package/src/blocks/marketing/footers/footer-section-native-links.test.tsx +100 -0
  61. package/src/blocks/marketing/footers/footer-section-public-api.test.tsx +11 -0
  62. package/src/blocks/marketing/footers/footer-section.test.tsx +46 -0
  63. package/src/blocks/marketing/footers/footer-section.tsx +532 -0
  64. package/src/blocks/marketing/navbars/navbar-public-api.test.tsx +57 -0
  65. package/src/blocks/marketing/navbars/navbar.mega.test.tsx +149 -0
  66. package/src/blocks/marketing/navbars/navbar.test.tsx +155 -0
  67. package/src/blocks/marketing/navbars/navbar.tsx +735 -0
  68. package/src/blocks/marketing/testimonials/testimonial-section.test.tsx +171 -0
  69. package/src/blocks/marketing/testimonials/testimonial-section.tsx +237 -0
  70. package/src/index.ts +156 -128
  71. package/src/lib/contracts-curated.test.ts +47 -29
  72. package/src/lib/contracts.test.ts +198 -2
  73. package/src/lib/contracts.ts +410 -16
  74. package/src/ui/button-group.tsx +1 -1
  75. package/src/ui/carousel.tsx +1 -1
  76. package/src/ui/dialog.tsx +1 -1
  77. package/src/ui/field.tsx +2 -2
  78. package/src/ui/input-group.tsx +3 -3
  79. package/src/ui/pagination.tsx +1 -1
  80. package/src/ui/sidebar.tsx +7 -7
  81. package/src/ui/toggle-group.tsx +1 -1
  82. package/tsconfig.build.json +12 -0
  83. package/tsup.config.ts +11 -7
  84. package/.turbo/turbo-build.log +0 -24
  85. package/.turbo/turbo-test.log +0 -47
  86. package/.turbo/turbo-type-check.log +0 -0
  87. package/dist/.tsbuildinfo +0 -1
  88. package/dist/contracts.d.mts +0 -1732
  89. package/dist/contracts.d.ts +0 -1732
  90. package/dist/contracts.js +0 -423
  91. package/dist/contracts.js.map +0 -1
  92. package/dist/contracts.mjs +0 -365
  93. package/dist/contracts.mjs.map +0 -1
  94. package/dist/index.d.mts +0 -3068
  95. package/dist/index.d.ts +0 -3068
  96. package/dist/index.js +0 -17834
  97. package/dist/index.js.map +0 -1
  98. package/dist/index.mjs +0 -17740
  99. package/dist/index.mjs.map +0 -1
  100. package/src/blocks/application/login-forms/login-page-1.tsx +0 -246
  101. package/src/blocks/application/login-forms/login-page-2.tsx +0 -207
  102. package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +0 -260
  103. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +0 -222
  104. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +0 -921
  105. package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +0 -57
  106. package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +0 -124
  107. package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +0 -170
  108. package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +0 -52
  109. package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +0 -57
  110. package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +0 -68
  111. package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +0 -298
  112. package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +0 -47
  113. package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +0 -9
  114. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +0 -96
  115. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +0 -252
  116. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +0 -188
  117. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +0 -36
  118. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +0 -216
  119. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +0 -338
  120. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +0 -130
  121. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +0 -175
  122. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +0 -190
  123. package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +0 -312
  124. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +0 -91
  125. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +0 -110
  126. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +0 -137
  127. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +0 -68
  128. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +0 -79
  129. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +0 -172
  130. package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +0 -392
  131. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  132. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  133. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  134. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  135. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  136. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  137. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +0 -88
  138. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -192
  139. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  140. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  141. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  142. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  143. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  144. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  145. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  146. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  147. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  148. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  149. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  150. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  151. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  152. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  153. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  154. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  155. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  156. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  157. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  158. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  159. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -229
  160. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -72
  161. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  162. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  163. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  164. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  165. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  166. package/src/blocks/marketing/features/feature-2.tsx +0 -82
  167. package/src/blocks/marketing/footers/footer-section-1.test.tsx +0 -117
  168. package/src/blocks/marketing/footers/footer-section-1.tsx +0 -178
  169. package/src/blocks/marketing/footers/footer-section-3.tsx +0 -192
  170. package/src/blocks/marketing/footers/footer-section-4.tsx +0 -355
  171. package/src/blocks/marketing/navbars/navbar-1.tsx +0 -267
  172. package/src/blocks/marketing/navbars/navbar-2.tsx +0 -351
  173. package/src/blocks/marketing/navbars/navbar-3.tsx +0 -303
  174. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +0 -38
  175. package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +0 -177
  176. package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +0 -219
  177. package/src/blocks/marketing/testimonials/testimonial-1.tsx +0 -141
  178. package/src/blocks/marketing/testimonials/testimonial-2.tsx +0 -108
@@ -1,74 +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 type ShoppingCart4Presentation = 'compact' | 'page'
25
+ export type ShoppingCartPresentation = 'compact' | 'page'
53
26
 
54
- export interface ShoppingCart4Props {
55
- items: ShoppingCart4Item[]
56
- promotions: ShoppingCart4Promotion[]
57
- recentlyViewed?: ShoppingCart4RecentlyViewed[]
27
+ export interface ShoppingCartProps {
28
+ /** Canonical cart lines keyed by backend cart line-item id. */
29
+ items: ShoppingCartItem[]
30
+ promotions?: ShoppingCartPromotion[]
31
+ recentlyViewed?: ShoppingCartSuggestion[]
58
32
  /**
59
33
  * Layout presentation. `compact` (default) is the collapsible quick-cart
60
34
  * card; `page` is a wide, always-expanded review layout with a primary
61
35
  * heading, no collapse control, and normal page scrolling. Both use the same
62
36
  * line controls and authoritative monetary rendering.
63
37
  */
64
- presentation?: ShoppingCart4Presentation
38
+ presentation?: ShoppingCartPresentation
65
39
  /**
66
- * Backend-authoritative totals. The curated cart renders these verbatim; it
67
- * never computes subtotal/tax/shipping/total locally.
40
+ * Backend-authoritative totals. The cart renders these verbatim; it never
41
+ * computes subtotal/tax/shipping/total locally.
68
42
  */
69
- totals?: ShoppingCart4Totals
43
+ totals?: CartTotals
70
44
  /** Controlled state of the totals fetch/mutation. */
71
- totalsState?: ShoppingCart4TotalsState
45
+ totalsState?: ShoppingCartTotalsState
72
46
  totalsErrorLabel?: string
73
47
  /** Shown when a required total amount is absent or malformed (never as zero). */
74
48
  totalsUnavailableLabel?: string
@@ -78,10 +52,6 @@ export interface ShoppingCart4Props {
78
52
  onRetryTotals?: () => void
79
53
  /** Override money formatting (locale, currency). Defaults to `Money.currencyCode`. */
80
54
  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
85
55
  // labels (Spanish defaults):
86
56
  titleLabel?: string // 'Carrito de compras'
87
57
  subtotalLabel?: string // 'Subtotal'
@@ -100,6 +70,9 @@ export interface ShoppingCart4Props {
100
70
  estimatedDeliveryLabel?: string
101
71
  secureCheckoutLabel?: string // 'Pago seguro • SSL cifrado'
102
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'
103
76
  collapseLabel?: string // 'Contraer carrito'
104
77
  expandLabel?: string // 'Expandir carrito'
105
78
  checkoutLabel?: string // 'Ver carrito y finalizar'
@@ -127,12 +100,19 @@ export interface ShoppingCart4Props {
127
100
  onQuickCheckout?: () => void
128
101
  onPayWithApple?: () => void
129
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
130
110
  onRecentlyViewedClick?: (id: string) => void
131
111
  /** Page presentation only: navigate back to browsing. */
132
112
  onContinueShopping?: () => void
133
113
  }
134
114
 
135
- const usdFormatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
115
+ const fallbackFormatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
136
116
 
137
117
  const interpolate = (template: string, values: Record<string, string>) =>
138
118
  template.replace(/\$\{(\w+)\}|\{(\w+)\}/g, (match, dollarKey, braceKey) => {
@@ -140,7 +120,7 @@ const interpolate = (template: string, values: Record<string, string>) =>
140
120
  return values[key] ?? match
141
121
  })
142
122
 
143
- export function ShoppingCart4({
123
+ export function ShoppingCart({
144
124
  items,
145
125
  promotions,
146
126
  recentlyViewed,
@@ -153,7 +133,6 @@ export function ShoppingCart4({
153
133
  retryLabel = 'Reintentar',
154
134
  onRetryTotals,
155
135
  formatMoney: formatMoneyProp,
156
- formatPrice,
157
136
  titleLabel = 'Carrito de compras',
158
137
  subtotalLabel = 'Subtotal',
159
138
  discountLabel = 'Descuento',
@@ -171,6 +150,9 @@ export function ShoppingCart4({
171
150
  estimatedDeliveryLabel,
172
151
  secureCheckoutLabel = 'Pago seguro • SSL cifrado',
173
152
  recentlyViewedLabel = 'Vistos recientemente',
153
+ promoPromptLabel = '¿Tienes un código de descuento?',
154
+ promoPlaceholderLabel = 'Introduce tu código',
155
+ promoApplyLabel = 'Aplicar',
174
156
  collapseLabel = 'Contraer carrito',
175
157
  expandLabel = 'Expandir carrito',
176
158
  checkoutLabel = 'Ver carrito y finalizar',
@@ -189,16 +171,29 @@ export function ShoppingCart4({
189
171
  onQuickCheckout,
190
172
  onPayWithApple,
191
173
  onPayWithGoogle,
174
+ onApplyPromo,
192
175
  onRecentlyViewedClick,
193
176
  onContinueShopping,
194
- }: ShoppingCart4Props) {
177
+ }: ShoppingCartProps) {
195
178
  const [isExpanded, setIsExpanded] = useState(true)
196
179
  const [quickCheckout, setQuickCheckout] = useState(false)
180
+ const [promoCode, setPromoCode] = useState('')
181
+ const promoInputId = useId()
197
182
 
198
183
  const isPage = presentation === 'page'
199
184
  // The page presentation is always expanded; only the compact card collapses.
200
185
  const expanded = isPage ? true : isExpanded
201
- const format = formatPrice ?? (value => usdFormatter.format(value))
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)
202
197
  const money = (value: Money | null | undefined) => formatMoney(value, formatMoneyProp)
203
198
  const subtotalAmount = isAvailableMoney(totals?.subtotal) ? Number(totals.subtotal.amount) : 0
204
199
  const itemCount = items.reduce((sum, item) => sum + item.quantity, 0)
@@ -207,7 +202,7 @@ export function ShoppingCart4({
207
202
  // conflicting edits and checkout until the backend responds.
208
203
  const mutationsDisabled = totalsState === 'pending'
209
204
 
210
- const sortedPromotions = [...promotions].sort((a, b) => a.threshold - b.threshold)
205
+ const sortedPromotions = [...(promotions ?? [])].sort((a, b) => a.threshold - b.threshold)
211
206
  const currentPromo = (() => {
212
207
  if (!isAvailableMoney(totals?.subtotal)) return null
213
208
  for (let i = sortedPromotions.length - 1; i >= 0; i--) {
@@ -223,11 +218,12 @@ export function ShoppingCart4({
223
218
  ? Math.min(100, (subtotalAmount / currentPromo.threshold) * 100)
224
219
  : 100
225
220
 
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)
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)
231
227
  }
232
228
 
233
229
  const renderTotals = () => {
@@ -305,6 +301,36 @@ export function ShoppingCart4({
305
301
  )
306
302
  }
307
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
+
308
334
  return (
309
335
  <section className='py-8'>
310
336
  <div className={cn('mx-auto px-4', isPage ? 'max-w-3xl' : 'max-w-md')}>
@@ -315,7 +341,7 @@ export function ShoppingCart4({
315
341
  {/* Header */}
316
342
  <CardHeader className='flex flex-row items-center justify-between space-y-0 pb-3'>
317
343
  <CardTitle className='flex items-center gap-2'>
318
- <ShoppingCart className='size-5' />
344
+ <ShoppingCartIcon className='size-5' />
319
345
  {titleLabel} ({itemCount})
320
346
  </CardTitle>
321
347
  {!isPage ? (
@@ -335,7 +361,7 @@ export function ShoppingCart4({
335
361
  <CardContent className='flex flex-col gap-6'>
336
362
  {items.length === 0 ? (
337
363
  <div className='flex flex-col items-center justify-center gap-4 py-6 text-center'>
338
- <ShoppingCart className='text-muted-foreground/50 size-10' />
364
+ <ShoppingCartIcon className='text-muted-foreground/50 size-10' />
339
365
  <p className='text-muted-foreground text-sm'>{emptyLabel}</p>
340
366
  {isPage && onContinueShopping ? (
341
367
  <Button variant='outline' onClick={onContinueShopping}>
@@ -452,6 +478,9 @@ export function ShoppingCart4({
452
478
  {/* Order Summary — backend-authoritative only */}
453
479
  <div className='flex flex-col gap-2'>{renderTotals()}</div>
454
480
 
481
+ {/* Promo code entry — reports to the host, never resolves locally */}
482
+ {renderPromoEntry()}
483
+
455
484
  {/* Quick Actions */}
456
485
  <div className='flex flex-col gap-3'>
457
486
  {/* Express Checkout Toggle — only when the host supplies a handler */}
@@ -551,34 +580,39 @@ export function ShoppingCart4({
551
580
  </div>
552
581
  )}
553
582
 
554
- {/* Recently Viewed */}
583
+ {/* Recently Viewed — host-supplied suggestions only */}
555
584
  {expanded && recentlyViewed && recentlyViewed.length > 0 && (
556
585
  <div className='flex flex-col gap-3'>
557
586
  <h4 className='text-sm font-medium'>{recentlyViewedLabel}</h4>
558
587
  <div className='grid grid-cols-3 gap-2'>
559
- {recentlyViewed.map(item => (
560
- <button
561
- key={item.id}
562
- type='button'
563
- className={cn(
564
- 'group text-start',
565
- onRecentlyViewedClick && 'cursor-pointer',
566
- )}
567
- onClick={onRecentlyViewedClick ? () => onRecentlyViewedClick(item.id) : undefined}
568
- >
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
- />
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>
576
612
  ) : null}
577
- </div>
578
- <p className='text-muted-foreground truncate text-xs'>{item.name}</p>
579
- <p className='text-xs font-medium'>${item.price.toFixed(2)}</p>
580
- </button>
581
- ))}
613
+ </button>
614
+ )
615
+ })}
582
616
  </div>
583
617
  </div>
584
618
  )}
@@ -591,4 +625,4 @@ export function ShoppingCart4({
591
625
  )
592
626
  }
593
627
 
594
- export default ShoppingCart4
628
+ export default ShoppingCart