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