@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
|
@@ -1,80 +1,168 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useState
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { Heart, Minus, Plus, Star } from 'lucide-react'
|
|
5
|
+
import type { Image, Money, ProductOption, SelectedOption } from '@thorprovider/types'
|
|
4
6
|
import { Button } from '../../../ui/button'
|
|
5
7
|
import { cn } from '../../../lib/utils'
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
+
import { PRODUCT_IMAGE_FALLBACK } from '../../../lib/contracts'
|
|
9
|
+
import { formatMoney, isAvailableMoney } from '../../../lib/money'
|
|
8
10
|
import { ReactionToggle } from '../../shared/reaction-toggle'
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Canonical product-detail projection.
|
|
14
|
+
*
|
|
15
|
+
* The block consumes the canonical `@thorprovider/types` shapes (`Money`,
|
|
16
|
+
* `ProductOption`, `SelectedOption`, `Image`) so adapter output maps without a
|
|
17
|
+
* second, drifting product type. The only deviation is `price`: the canon marks
|
|
18
|
+
* it required, but a missing/unresolved backend price is a real, distinct state
|
|
19
|
+
* that must disable purchase — so the projection allows `price` to be absent
|
|
20
|
+
* and the block treats absence as "unavailable", never as zero.
|
|
21
|
+
*/
|
|
22
|
+
export interface ProductDetailVariant {
|
|
23
|
+
id: string
|
|
24
|
+
title: string
|
|
25
|
+
sku?: string
|
|
26
|
+
availableForSale: boolean
|
|
27
|
+
selectedOptions: SelectedOption[]
|
|
28
|
+
/** Canonical Money; omit/null when the backend has no resolved price. */
|
|
29
|
+
price?: Money | null
|
|
30
|
+
compareAtPrice?: Money
|
|
31
|
+
image?: Image
|
|
14
32
|
}
|
|
15
33
|
|
|
16
|
-
export interface
|
|
17
|
-
|
|
18
|
-
|
|
34
|
+
export interface ProductDetailProduct {
|
|
35
|
+
id: string
|
|
36
|
+
handle?: string
|
|
37
|
+
title: string
|
|
38
|
+
description?: string
|
|
39
|
+
vendor?: string
|
|
40
|
+
options: ProductOption[]
|
|
41
|
+
variants: ProductDetailVariant[]
|
|
42
|
+
images: Image[]
|
|
43
|
+
featuredImage?: Image
|
|
19
44
|
}
|
|
20
45
|
|
|
21
|
-
export interface
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
name: string
|
|
25
|
-
description: string
|
|
26
|
-
price: number
|
|
27
|
-
currency: string
|
|
28
|
-
rating: number
|
|
29
|
-
images: ProductOverview1ProductImage[]
|
|
30
|
-
sizes: number[]
|
|
31
|
-
colors: ProductOverview1ProductColor[]
|
|
46
|
+
export interface ProductOverview1Rating {
|
|
47
|
+
value: number
|
|
48
|
+
count: number
|
|
32
49
|
}
|
|
33
50
|
|
|
34
51
|
export interface ProductOverview1Props {
|
|
35
|
-
product:
|
|
52
|
+
product: ProductDetailProduct
|
|
53
|
+
/** Controlled selection: the backend variant id, or null/undefined when nothing is explicitly selected. */
|
|
54
|
+
selectedVariantId?: string | null
|
|
55
|
+
onSelectVariant?: (variantId: string) => void
|
|
56
|
+
/** Controlled quantity for the add-to-cart action; defaults to 1 when omitted. */
|
|
57
|
+
quantity?: number
|
|
58
|
+
onQuantityChange?: (quantity: number) => void
|
|
59
|
+
/** Add-to-cart identity is the selected backend variant id, with the requested quantity. */
|
|
60
|
+
onAddToCart?: (variantId: string, quantity: number) => void
|
|
61
|
+
isWishlisted?: boolean
|
|
62
|
+
onToggleWishlist?: (variantId: string, isWishlisted: boolean) => void
|
|
63
|
+
/** Rendered only when the backend supplies it. */
|
|
64
|
+
rating?: ProductOverview1Rating
|
|
65
|
+
/** Rendered only when the backend supplies it. */
|
|
66
|
+
deliveryEstimate?: string
|
|
67
|
+
/** Rendered only when the backend supplies it. */
|
|
68
|
+
shipping?: Money
|
|
69
|
+
formatPrice?: (money: Money) => string
|
|
36
70
|
sizesLabel?: string
|
|
37
|
-
|
|
71
|
+
variantLabel?: string
|
|
38
72
|
reviewsLabel?: string
|
|
39
73
|
addToCartLabel?: string
|
|
74
|
+
outOfStockLabel?: string
|
|
75
|
+
unavailablePriceLabel?: string
|
|
76
|
+
selectVariantLabel?: string
|
|
77
|
+
quantityLabel?: string
|
|
78
|
+
deliveryEstimateLabel?: string
|
|
79
|
+
shippingLabel?: string
|
|
40
80
|
removeFromWishlistLabel?: string
|
|
41
81
|
addToWishlistLabel?: string
|
|
42
|
-
|
|
43
|
-
|
|
82
|
+
imageFallback?: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function variantLabelOf(variant: ProductDetailVariant): string {
|
|
86
|
+
if (variant.title.trim() !== '') return variant.title
|
|
87
|
+
return variant.selectedOptions.map(option => option.value).join(' / ')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The add-to-cart payload contract: the explicitly selected backend variant id
|
|
92
|
+
* and a positive quantity (defaults to 1). Returns `null` when no variant has
|
|
93
|
+
* been selected — the block never substitutes the first variant.
|
|
94
|
+
*/
|
|
95
|
+
export function resolveAddToCartPayload(
|
|
96
|
+
variant: ProductDetailVariant | null,
|
|
97
|
+
quantity: number | undefined,
|
|
98
|
+
): { variantId: string; quantity: number } | null {
|
|
99
|
+
if (!variant) return null
|
|
100
|
+
return {
|
|
101
|
+
variantId: variant.id,
|
|
102
|
+
quantity: typeof quantity === 'number' && quantity > 0 ? quantity : 1,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function galleryImages(product: ProductDetailProduct, fallback: string): string[] {
|
|
107
|
+
const images = product.images
|
|
108
|
+
.map(image => image.url)
|
|
109
|
+
.filter((url): url is string => typeof url === 'string' && url.trim() !== '')
|
|
110
|
+
if (images.length > 0) return images
|
|
111
|
+
const featured = product.featuredImage?.url
|
|
112
|
+
if (typeof featured === 'string' && featured.trim() !== '') return [featured]
|
|
113
|
+
return [fallback]
|
|
44
114
|
}
|
|
45
115
|
|
|
46
116
|
export default function ProductOverview1({
|
|
47
117
|
product,
|
|
48
|
-
|
|
49
|
-
|
|
118
|
+
selectedVariantId,
|
|
119
|
+
onSelectVariant,
|
|
120
|
+
quantity,
|
|
121
|
+
onQuantityChange,
|
|
122
|
+
onAddToCart,
|
|
123
|
+
isWishlisted = false,
|
|
124
|
+
onToggleWishlist,
|
|
125
|
+
rating,
|
|
126
|
+
deliveryEstimate,
|
|
127
|
+
shipping,
|
|
128
|
+
formatPrice,
|
|
129
|
+
sizesLabel = 'Variantes',
|
|
130
|
+
variantLabel = 'Variante',
|
|
50
131
|
reviewsLabel = 'Reseñas',
|
|
51
132
|
addToCartLabel = 'Agregar al carrito',
|
|
133
|
+
outOfStockLabel = 'Agotado',
|
|
134
|
+
unavailablePriceLabel = 'Precio no disponible',
|
|
135
|
+
selectVariantLabel = 'Selecciona una variante',
|
|
136
|
+
quantityLabel = 'Cantidad',
|
|
137
|
+
deliveryEstimateLabel = 'Entrega estimada',
|
|
138
|
+
shippingLabel = 'Envío',
|
|
52
139
|
removeFromWishlistLabel = 'Quitar de favoritos',
|
|
53
140
|
addToWishlistLabel = 'Agregar a favoritos',
|
|
54
|
-
|
|
55
|
-
onToggleWishlist,
|
|
141
|
+
imageFallback = PRODUCT_IMAGE_FALLBACK,
|
|
56
142
|
}: ProductOverview1Props) {
|
|
57
143
|
const [selectedImage, setSelectedImage] = useState(0)
|
|
58
|
-
const [selectedSize, setSelectedSize] = useState<number | null>(product.sizes[0])
|
|
59
|
-
const [selectedColor, setSelectedColor] = useState(product.colors[0])
|
|
60
|
-
const [isWishlisted, setIsWishlisted] = useState(false)
|
|
61
|
-
const [carouselApi, setCarouselApi] = useState<CarouselApi>()
|
|
62
144
|
|
|
63
|
-
|
|
64
|
-
|
|
145
|
+
const selectedVariant =
|
|
146
|
+
selectedVariantId != null
|
|
147
|
+
? product.variants.find(variant => variant.id === selectedVariantId) ?? null
|
|
148
|
+
: null
|
|
65
149
|
|
|
66
|
-
|
|
150
|
+
const selectedPrice = selectedVariant?.price
|
|
151
|
+
const selectedCompareAt = selectedVariant?.compareAtPrice
|
|
152
|
+
const priceAvailable = isAvailableMoney(selectedPrice)
|
|
153
|
+
const compareAtAvailable = isAvailableMoney(selectedCompareAt)
|
|
154
|
+
const canPurchase = Boolean(selectedVariant?.availableForSale) && priceAvailable
|
|
155
|
+
const quantityValue = quantity ?? 1
|
|
156
|
+
const addPayload = resolveAddToCartPayload(selectedVariant, quantityValue)
|
|
67
157
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
158
|
+
const images = galleryImages(product, imageFallback)
|
|
159
|
+
const hasRealImages = images[0] !== imageFallback || product.featuredImage != null
|
|
160
|
+
const activeImage = images[Math.min(selectedImage, images.length - 1)]
|
|
72
161
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}, [carouselApi, selectedImage])
|
|
162
|
+
const toggleWishlist = () => {
|
|
163
|
+
if (!selectedVariant || !onToggleWishlist) return
|
|
164
|
+
onToggleWishlist(selectedVariant.id, !isWishlisted)
|
|
165
|
+
}
|
|
78
166
|
|
|
79
167
|
return (
|
|
80
168
|
<div>
|
|
@@ -82,115 +170,194 @@ export default function ProductOverview1({
|
|
|
82
170
|
<div className='grid grid-cols-1 gap-6 py-4 lg:grid-cols-2 lg:gap-8 lg:py-6 xl:grid-cols-3 xl:gap-12 xl:py-12'>
|
|
83
171
|
<div className='flex flex-col justify-between gap-6 lg:gap-8'>
|
|
84
172
|
<div className='flex flex-col gap-2 lg:gap-4'>
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
<
|
|
89
|
-
|
|
90
|
-
{product.
|
|
91
|
-
|
|
92
|
-
</div>
|
|
173
|
+
{product.vendor ? (
|
|
174
|
+
<span className='text-sm font-semibold tracking-wide uppercase'>{product.vendor}</span>
|
|
175
|
+
) : null}
|
|
176
|
+
<h2 className='text-xl font-bold tracking-tight text-balance lg:text-3xl'>{product.title}</h2>
|
|
177
|
+
{product.description ? (
|
|
178
|
+
<p className='text-muted-foreground text-balance'>{product.description}</p>
|
|
179
|
+
) : null}
|
|
93
180
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
181
|
+
{selectedVariant ? (
|
|
182
|
+
priceAvailable ? (
|
|
183
|
+
<p className='text-2xl font-bold tracking-tight'>
|
|
184
|
+
{formatMoney(selectedPrice, formatPrice)}
|
|
185
|
+
{compareAtAvailable ? (
|
|
186
|
+
<span className='text-muted-foreground ms-2 text-base font-normal line-through'>
|
|
187
|
+
{formatMoney(selectedCompareAt, formatPrice)}
|
|
188
|
+
</span>
|
|
189
|
+
) : null}
|
|
190
|
+
</p>
|
|
191
|
+
) : (
|
|
192
|
+
<p className='text-muted-foreground text-sm'>{unavailablePriceLabel}</p>
|
|
193
|
+
)
|
|
194
|
+
) : (
|
|
195
|
+
<p className='text-muted-foreground text-sm'>{selectVariantLabel}</p>
|
|
196
|
+
)}
|
|
107
197
|
</div>
|
|
198
|
+
|
|
199
|
+
{hasRealImages ? (
|
|
200
|
+
<div className='flex flex-wrap gap-4'>
|
|
201
|
+
{images.map((url, index) => (
|
|
202
|
+
<button
|
|
203
|
+
key={`${product.id}:image:${index}`}
|
|
204
|
+
type='button'
|
|
205
|
+
onClick={() => setSelectedImage(index)}
|
|
206
|
+
aria-label={`${product.title} ${index + 1}`}
|
|
207
|
+
aria-pressed={selectedImage === index}
|
|
208
|
+
className={cn(
|
|
209
|
+
'ring-offset-background size-16 cursor-pointer overflow-hidden rounded-sm ring-offset-2 transition-all lg:size-18',
|
|
210
|
+
selectedImage === index && 'ring-foreground ring-2',
|
|
211
|
+
)}
|
|
212
|
+
>
|
|
213
|
+
<img src={url || undefined} alt='' className='size-full object-cover' />
|
|
214
|
+
</button>
|
|
215
|
+
))}
|
|
216
|
+
</div>
|
|
217
|
+
) : null}
|
|
108
218
|
</div>
|
|
109
219
|
|
|
110
220
|
<div className='row-span-2 row-start-1 lg:col-start-2'>
|
|
111
|
-
<
|
|
112
|
-
<
|
|
113
|
-
{
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
</CarouselContent>
|
|
119
|
-
</Carousel>
|
|
221
|
+
<div className='bg-muted aspect-square w-full overflow-hidden rounded-lg'>
|
|
222
|
+
<img
|
|
223
|
+
src={activeImage || undefined}
|
|
224
|
+
alt={product.title}
|
|
225
|
+
className='h-90 w-full object-cover'
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
120
228
|
</div>
|
|
121
229
|
|
|
122
230
|
<div className='flex flex-col gap-6 lg:gap-10'>
|
|
123
|
-
|
|
124
|
-
<
|
|
125
|
-
|
|
126
|
-
|
|
231
|
+
{product.variants.length > 0 ? (
|
|
232
|
+
<div className='flex flex-col gap-2'>
|
|
233
|
+
<h3 className='font-bold'>{sizesLabel}</h3>
|
|
234
|
+
<div className='flex flex-wrap gap-3'>
|
|
235
|
+
{product.variants.map(variant => {
|
|
236
|
+
const isSelected = variant.id === selectedVariantId
|
|
237
|
+
return (
|
|
238
|
+
<Button
|
|
239
|
+
key={variant.id}
|
|
240
|
+
variant={isSelected ? 'default' : 'outline'}
|
|
241
|
+
onClick={onSelectVariant ? () => onSelectVariant(variant.id) : undefined}
|
|
242
|
+
disabled={!variant.availableForSale}
|
|
243
|
+
aria-pressed={isSelected}
|
|
244
|
+
className='h-9 cursor-pointer rounded-full px-4'
|
|
245
|
+
>
|
|
246
|
+
{variantLabelOf(variant)}
|
|
247
|
+
{!variant.availableForSale ? (
|
|
248
|
+
<span className='text-muted-foreground ms-2 text-xs font-normal'>
|
|
249
|
+
{outOfStockLabel}
|
|
250
|
+
</span>
|
|
251
|
+
) : null}
|
|
252
|
+
</Button>
|
|
253
|
+
)
|
|
254
|
+
})}
|
|
255
|
+
</div>
|
|
256
|
+
{selectedVariant && selectedVariant.selectedOptions.length > 0 ? (
|
|
257
|
+
<p className='text-muted-foreground text-sm'>
|
|
258
|
+
{selectedVariant.selectedOptions
|
|
259
|
+
.map((option: SelectedOption) => `${option.name}: ${option.value}`)
|
|
260
|
+
.join(' · ')}
|
|
261
|
+
</p>
|
|
262
|
+
) : null}
|
|
263
|
+
</div>
|
|
264
|
+
) : null}
|
|
265
|
+
|
|
266
|
+
{rating ? (
|
|
267
|
+
<div className='flex flex-col gap-2'>
|
|
268
|
+
<h3 className='font-bold'>{reviewsLabel}</h3>
|
|
269
|
+
<div className='flex items-center gap-2'>
|
|
270
|
+
<div className='flex gap-1' aria-hidden='true'>
|
|
271
|
+
{[0, 1, 2, 3, 4].map(index => (
|
|
272
|
+
<Star
|
|
273
|
+
key={index}
|
|
274
|
+
className='text-foreground size-5'
|
|
275
|
+
fill={index < rating.value ? 'currentColor' : 'none'}
|
|
276
|
+
/>
|
|
277
|
+
))}
|
|
278
|
+
</div>
|
|
279
|
+
<span className='text-muted-foreground text-sm tabular-nums'>
|
|
280
|
+
{`${rating.value} (${rating.count})`}
|
|
281
|
+
</span>
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
) : null}
|
|
285
|
+
|
|
286
|
+
{onQuantityChange ? (
|
|
287
|
+
<div className='flex flex-col gap-2'>
|
|
288
|
+
<h3 className='font-bold'>{quantityLabel}</h3>
|
|
289
|
+
<div className='border-border flex w-fit items-center rounded-md border'>
|
|
127
290
|
<Button
|
|
128
|
-
|
|
129
|
-
variant=
|
|
130
|
-
|
|
131
|
-
className='
|
|
291
|
+
type='button'
|
|
292
|
+
variant='ghost'
|
|
293
|
+
size='icon'
|
|
294
|
+
className='size-9 rounded-r-none'
|
|
295
|
+
aria-label={`${quantityLabel} -1`}
|
|
296
|
+
onClick={() => onQuantityChange(Math.max(1, quantityValue - 1))}
|
|
297
|
+
disabled={quantityValue <= 1}
|
|
132
298
|
>
|
|
133
|
-
|
|
299
|
+
<Minus />
|
|
134
300
|
</Button>
|
|
135
|
-
|
|
136
|
-
</div>
|
|
137
|
-
</div>
|
|
138
|
-
|
|
139
|
-
<div className='flex flex-col gap-2'>
|
|
140
|
-
<h3 className='font-bold'>{colorLabel}</h3>
|
|
141
|
-
<div className='flex gap-3'>
|
|
142
|
-
{product.colors.map(color => (
|
|
301
|
+
<span className='w-10 text-center text-sm tabular-nums'>{quantityValue}</span>
|
|
143
302
|
<Button
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
style={{ backgroundColor: color.value }}
|
|
155
|
-
aria-label={`Seleccionar color ${color.name}`}
|
|
156
|
-
/>
|
|
157
|
-
))}
|
|
303
|
+
type='button'
|
|
304
|
+
variant='ghost'
|
|
305
|
+
size='icon'
|
|
306
|
+
className='size-9 rounded-l-none'
|
|
307
|
+
aria-label={`${quantityLabel} +1`}
|
|
308
|
+
onClick={() => onQuantityChange(quantityValue + 1)}
|
|
309
|
+
>
|
|
310
|
+
<Plus />
|
|
311
|
+
</Button>
|
|
312
|
+
</div>
|
|
158
313
|
</div>
|
|
159
|
-
|
|
314
|
+
) : null}
|
|
160
315
|
|
|
161
|
-
|
|
162
|
-
<
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
316
|
+
{deliveryEstimate || shipping ? (
|
|
317
|
+
<dl className='text-muted-foreground flex flex-col gap-1 text-sm'>
|
|
318
|
+
{deliveryEstimate ? (
|
|
319
|
+
<div className='flex gap-2'>
|
|
320
|
+
<dt>{deliveryEstimateLabel}</dt>
|
|
321
|
+
<dd className='text-foreground'>{deliveryEstimate}</dd>
|
|
322
|
+
</div>
|
|
323
|
+
) : null}
|
|
324
|
+
{isAvailableMoney(shipping) ? (
|
|
325
|
+
<div className='flex gap-2'>
|
|
326
|
+
<dt>{shippingLabel}</dt>
|
|
327
|
+
<dd className='text-foreground'>{formatMoney(shipping, formatPrice)}</dd>
|
|
328
|
+
</div>
|
|
329
|
+
) : null}
|
|
330
|
+
</dl>
|
|
331
|
+
) : null}
|
|
173
332
|
|
|
174
333
|
<div className='flex gap-4'>
|
|
175
334
|
<Button
|
|
176
|
-
className='h-10
|
|
335
|
+
className='h-10 flex-1 cursor-pointer rounded-full px-8'
|
|
177
336
|
size='lg'
|
|
178
|
-
|
|
337
|
+
disabled={!canPurchase}
|
|
338
|
+
onClick={
|
|
339
|
+
onAddToCart && addPayload
|
|
340
|
+
? () => onAddToCart(addPayload.variantId, addPayload.quantity)
|
|
341
|
+
: undefined
|
|
342
|
+
}
|
|
179
343
|
>
|
|
180
344
|
{addToCartLabel}
|
|
181
345
|
</Button>
|
|
182
346
|
<Button
|
|
183
347
|
variant='outline'
|
|
184
348
|
size='icon'
|
|
185
|
-
className='size-9 rounded-full
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
onToggleWishlist?.(product, next)
|
|
190
|
-
}}
|
|
349
|
+
className='size-9 cursor-pointer rounded-full'
|
|
350
|
+
disabled={!selectedVariant}
|
|
351
|
+
onClick={onToggleWishlist ? toggleWishlist : undefined}
|
|
352
|
+
aria-pressed={isWishlisted}
|
|
191
353
|
aria-label={isWishlisted ? removeFromWishlistLabel : addToWishlistLabel}
|
|
192
354
|
>
|
|
193
|
-
<ReactionToggle
|
|
355
|
+
<ReactionToggle
|
|
356
|
+
active={isWishlisted}
|
|
357
|
+
icon={Heart}
|
|
358
|
+
iconClassName='size-5'
|
|
359
|
+
activeColorClassName='text-primary'
|
|
360
|
+
/>
|
|
194
361
|
</Button>
|
|
195
362
|
</div>
|
|
196
363
|
</div>
|
|
@@ -198,4 +365,6 @@ export default function ProductOverview1({
|
|
|
198
365
|
</section>
|
|
199
366
|
</div>
|
|
200
367
|
)
|
|
201
|
-
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export { variantLabelOf, galleryImages }
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { ShoppingCart4, type ShoppingCart4Item, type ShoppingCart4Totals } from './shopping-cart-4'
|
|
4
|
+
|
|
5
|
+
const items: ShoppingCart4Item[] = [
|
|
6
|
+
{
|
|
7
|
+
id: 'line_1',
|
|
8
|
+
name: 'Taladro',
|
|
9
|
+
price: 45,
|
|
10
|
+
quantity: 2,
|
|
11
|
+
image: 'https://cdn.example/taladro.jpg',
|
|
12
|
+
lineTotal: { amount: '90.00', currencyCode: 'USD' },
|
|
13
|
+
},
|
|
14
|
+
{ id: 'line_2', name: 'Batería', price: 20, quantity: 1 },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
const totals: ShoppingCart4Totals = {
|
|
18
|
+
subtotal: { amount: '110.00', currencyCode: 'USD' },
|
|
19
|
+
discount: { amount: '10.00', currencyCode: 'USD' },
|
|
20
|
+
tax: { amount: '8.00', currencyCode: 'USD' },
|
|
21
|
+
shipping: { amount: '0', currencyCode: 'USD' },
|
|
22
|
+
total: { amount: '108.00', currencyCode: 'USD' },
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const formatMoney = (money: { amount: string; currencyCode: string }) =>
|
|
26
|
+
`${money.currencyCode} ${money.amount}`
|
|
27
|
+
|
|
28
|
+
describe('ShoppingCart4 — backend-authoritative totals', () => {
|
|
29
|
+
it('renders supplied backend totals verbatim (no local arithmetic)', () => {
|
|
30
|
+
const html = renderToStaticMarkup(
|
|
31
|
+
<ShoppingCart4 items={items} promotions={[]} totals={totals} formatMoney={formatMoney} />,
|
|
32
|
+
)
|
|
33
|
+
expect(html).toContain('USD 110.00')
|
|
34
|
+
expect(html).toContain('USD 10.00')
|
|
35
|
+
expect(html).toContain('USD 8.00')
|
|
36
|
+
expect(html).toContain('Gratis')
|
|
37
|
+
expect(html).toContain('USD 108.00')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('does not invent subtotal or total when none are supplied', () => {
|
|
41
|
+
const html = renderToStaticMarkup(
|
|
42
|
+
<ShoppingCart4 items={items} promotions={[]} formatMoney={formatMoney} />,
|
|
43
|
+
)
|
|
44
|
+
expect(html).not.toContain('Subtotal')
|
|
45
|
+
expect(html).not.toContain('Total')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('renders a controlled pending state', () => {
|
|
49
|
+
const html = renderToStaticMarkup(
|
|
50
|
+
<ShoppingCart4 items={items} promotions={[]} totalsState='pending' />,
|
|
51
|
+
)
|
|
52
|
+
expect(html).toContain('Actualizando totales')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('renders a controlled error state with a recovery action', () => {
|
|
56
|
+
const html = renderToStaticMarkup(
|
|
57
|
+
<ShoppingCart4
|
|
58
|
+
items={items}
|
|
59
|
+
promotions={[]}
|
|
60
|
+
totalsState='error'
|
|
61
|
+
onRetryTotals={() => {}}
|
|
62
|
+
/>,
|
|
63
|
+
)
|
|
64
|
+
expect(html).toContain('No se pudieron actualizar los totales')
|
|
65
|
+
expect(html).toContain('Reintentar')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('omits an absent line image without emitting an empty src', () => {
|
|
69
|
+
const html = renderToStaticMarkup(
|
|
70
|
+
<ShoppingCart4 items={items} promotions={[]} totals={totals} formatMoney={formatMoney} />,
|
|
71
|
+
)
|
|
72
|
+
expect(html).not.toMatch(/src=""/)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('does not fabricate a delivery estimate or express payment controls', () => {
|
|
76
|
+
const html = renderToStaticMarkup(
|
|
77
|
+
<ShoppingCart4 items={items} promotions={[]} totals={totals} formatMoney={formatMoney} />,
|
|
78
|
+
)
|
|
79
|
+
expect(html).not.toContain('Entrega estimada')
|
|
80
|
+
expect(html).not.toContain('Apple Pay')
|
|
81
|
+
expect(html).not.toContain('Google Pay')
|
|
82
|
+
expect(html).not.toContain('Compra rápida')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('never renders a fake zero for absent or malformed required totals', () => {
|
|
86
|
+
const html = renderToStaticMarkup(
|
|
87
|
+
<ShoppingCart4
|
|
88
|
+
items={items}
|
|
89
|
+
promotions={[]}
|
|
90
|
+
totals={{
|
|
91
|
+
subtotal: { amount: '', currencyCode: 'USD' },
|
|
92
|
+
total: { amount: 'abc', currencyCode: 'USD' },
|
|
93
|
+
}}
|
|
94
|
+
formatMoney={formatMoney}
|
|
95
|
+
/>,
|
|
96
|
+
)
|
|
97
|
+
expect(html).not.toContain('USD 0')
|
|
98
|
+
expect(html).toContain('Totales no disponibles')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('preserves a legitimate zero total', () => {
|
|
102
|
+
const html = renderToStaticMarkup(
|
|
103
|
+
<ShoppingCart4
|
|
104
|
+
items={items}
|
|
105
|
+
promotions={[]}
|
|
106
|
+
totals={{
|
|
107
|
+
subtotal: { amount: '0', currencyCode: 'USD' },
|
|
108
|
+
total: { amount: '0', currencyCode: 'USD' },
|
|
109
|
+
}}
|
|
110
|
+
formatMoney={formatMoney}
|
|
111
|
+
/>,
|
|
112
|
+
)
|
|
113
|
+
expect(html).toContain('USD 0')
|
|
114
|
+
expect(html).not.toContain('Totales no disponibles')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('shows an unavailable marker (not zero) for a supplied but malformed line total', () => {
|
|
118
|
+
const html = renderToStaticMarkup(
|
|
119
|
+
<ShoppingCart4
|
|
120
|
+
items={[{ id: 'line_x', name: 'Pieza', price: 10, quantity: 1, lineTotal: { amount: '', currencyCode: 'USD' } }]}
|
|
121
|
+
promotions={[]}
|
|
122
|
+
totals={totals}
|
|
123
|
+
formatMoney={formatMoney}
|
|
124
|
+
/>,
|
|
125
|
+
)
|
|
126
|
+
expect(html).toContain('—')
|
|
127
|
+
expect(html).not.toContain('USD 0')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('renders absolute quantities from props (cart contract, not a delta)', () => {
|
|
131
|
+
const html = renderToStaticMarkup(
|
|
132
|
+
<ShoppingCart4 items={items} promotions={[]} totals={totals} formatMoney={formatMoney} />,
|
|
133
|
+
)
|
|
134
|
+
expect(html).toContain('>2<')
|
|
135
|
+
expect(html).toContain('>1<')
|
|
136
|
+
})
|
|
137
|
+
})
|