@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.
- package/.turbo/turbo-build.log +15 -15
- package/.turbo/turbo-test.log +42 -5
- package/AGENTS.md +1 -1
- package/README.md +94 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/contracts.d.mts +1266 -97
- package/dist/contracts.d.ts +1266 -97
- package/dist/contracts.js +225 -9
- package/dist/contracts.js.map +1 -1
- package/dist/contracts.mjs +207 -10
- package/dist/contracts.mjs.map +1 -1
- package/dist/index.d.mts +756 -96
- package/dist/index.d.ts +756 -96
- package/dist/index.js +5114 -3702
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4939 -3551
- package/dist/index.mjs.map +1 -1
- package/docs/standards/theming.md +32 -5
- package/package.json +2 -2
- package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +136 -93
- package/src/blocks/e-commerce/category-filters/category-filter-2.test.tsx +513 -0
- package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +728 -148
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +14 -8
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.test.tsx +136 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +145 -99
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +215 -102
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +6 -2
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +51 -2
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +216 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +114 -11
- package/src/blocks/e-commerce/navigation/back-to-top.test.tsx +27 -0
- package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
- package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
- package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
- package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
- package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
- package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +42 -2
- package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +10 -8
- package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
- package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
- package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +51 -0
- package/src/blocks/e-commerce/product-categories/product-category-2.tsx +56 -36
- package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +88 -0
- package/src/blocks/e-commerce/product-list/product-card-1.tsx +141 -80
- package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
- package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
- package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +182 -0
- package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +298 -129
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +229 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +313 -110
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +72 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +20 -1
- package/src/blocks/marketing/footers/footer-section-1.test.tsx +117 -0
- package/src/blocks/marketing/footers/footer-section-1.tsx +46 -9
- package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +38 -0
- package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +204 -0
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +144 -21
- package/src/blocks/marketing/navbars/navbar-4.test.tsx +233 -0
- package/src/blocks/marketing/navbars/navbar-4.tsx +235 -60
- package/src/index.ts +56 -0
- package/src/lib/cart.test.ts +15 -0
- package/src/lib/cart.ts +11 -0
- package/src/lib/contracts-curated.test.ts +207 -0
- package/src/lib/contracts-server-boundary.test.ts +52 -0
- package/src/lib/contracts.ts +234 -6
- package/src/lib/money.test.ts +64 -0
- package/src/lib/money.ts +65 -0
- package/tsup.config.ts +0 -1
- 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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
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 (
|
|
214
|
+
if (subtotalAmount >= sortedPromotions[i].threshold) {
|
|
123
215
|
return { ...sortedPromotions[i], achieved: true }
|
|
124
216
|
}
|
|
125
217
|
}
|
|
126
|
-
const next = sortedPromotions.find(p =>
|
|
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, (
|
|
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-
|
|
137
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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-
|
|
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'>
|
|
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:
|
|
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
|
-
{
|
|
378
|
+
{expanded && (
|
|
195
379
|
<>
|
|
196
380
|
{/* Cart Items */}
|
|
197
|
-
<div
|
|
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
|
-
|
|
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'
|
|
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={
|
|
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={
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
283
|
-
|
|
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='
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
aria-pressed={quickCheckout}
|
|
509
|
+
size='lg'
|
|
510
|
+
className='h-10 w-full cursor-pointer px-8'
|
|
511
|
+
onClick={onContinueShopping}
|
|
290
512
|
>
|
|
291
|
-
|
|
292
|
-
{quickCheckout ? standardLabel : expressLabel}
|
|
513
|
+
{continueShoppingLabel}
|
|
293
514
|
</Button>
|
|
294
|
-
|
|
515
|
+
) : null}
|
|
295
516
|
|
|
296
|
-
{
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
{!
|
|
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
|
-
|
|
343
|
-
<
|
|
344
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|