@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
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from 'react'
|
|
4
|
+
import { ChevronLeft, ChevronRight, Star } from 'lucide-react'
|
|
5
|
+
import { motion, useReducedMotion } from 'motion/react'
|
|
6
|
+
|
|
7
|
+
import { Button } from '../../../ui/button'
|
|
8
|
+
import { Card, CardContent, CardFooter } from '../../../ui/card'
|
|
9
|
+
import { formatMoney } from '../../../lib/money'
|
|
10
|
+
import type { ProductPresentation } from '../../../lib/contracts'
|
|
11
|
+
|
|
12
|
+
export interface ProductShowcase1Props {
|
|
13
|
+
products: ProductPresentation[]
|
|
14
|
+
heading?: string
|
|
15
|
+
description?: string
|
|
16
|
+
pageSize?: number
|
|
17
|
+
prevLabel?: string
|
|
18
|
+
nextLabel?: string
|
|
19
|
+
/** Label for the current-page status, e.g. "Página". */
|
|
20
|
+
pageLabel?: string
|
|
21
|
+
unavailablePriceLabel?: string
|
|
22
|
+
emptyLabel?: string
|
|
23
|
+
addToCartLabel?: string
|
|
24
|
+
onAddToCart?: (product: ProductPresentation) => void
|
|
25
|
+
onProductClick?: (product: ProductPresentation) => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEFAULT_PAGE_SIZE = 6
|
|
29
|
+
const SWIPE_THRESHOLD = 48
|
|
30
|
+
|
|
31
|
+
function chunk<T>(items: readonly T[], size: number): T[][] {
|
|
32
|
+
const pages: T[][] = []
|
|
33
|
+
for (let index = 0; index < items.length; index += size) {
|
|
34
|
+
pages.push(items.slice(index, index + size))
|
|
35
|
+
}
|
|
36
|
+
return pages
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function compareAtText(product: ProductPresentation): string | undefined {
|
|
40
|
+
const compareAt = product.compareAtPrice
|
|
41
|
+
if (!compareAt) return undefined
|
|
42
|
+
const formatted = formatMoney(compareAt)
|
|
43
|
+
const price = formatMoney(product.price)
|
|
44
|
+
if (!formatted || !price) return undefined
|
|
45
|
+
if (Number(compareAt.amount) <= Number(product.price.amount)) return undefined
|
|
46
|
+
return formatted
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Combined grid-and-carousel product presentation: fixed-size grid pages
|
|
51
|
+
* navigated manually (prev/next) with a visible current-page indication. There
|
|
52
|
+
* is no autoplay, no auto-advance, and no infinite loop. Controls are real
|
|
53
|
+
* buttons (keyboard operable and visible at every breakpoint); touch swipe is an
|
|
54
|
+
* addition, never the only way to navigate. Below-fold images are lazy.
|
|
55
|
+
*
|
|
56
|
+
* Data access lives in the host: this block only renders the props it receives
|
|
57
|
+
* and reports interactions through callbacks (no backend/router/measurement
|
|
58
|
+
* imports).
|
|
59
|
+
*/
|
|
60
|
+
export function ProductShowcase1({
|
|
61
|
+
products,
|
|
62
|
+
heading,
|
|
63
|
+
description,
|
|
64
|
+
pageSize = DEFAULT_PAGE_SIZE,
|
|
65
|
+
prevLabel = 'Anterior',
|
|
66
|
+
nextLabel = 'Siguiente',
|
|
67
|
+
pageLabel = 'Página',
|
|
68
|
+
unavailablePriceLabel = 'Precio no disponible',
|
|
69
|
+
emptyLabel = 'No hay productos para mostrar.',
|
|
70
|
+
addToCartLabel = 'Agregar al carrito',
|
|
71
|
+
onAddToCart,
|
|
72
|
+
onProductClick,
|
|
73
|
+
}: ProductShowcase1Props) {
|
|
74
|
+
const reduceMotion = useReducedMotion()
|
|
75
|
+
const size = Number.isInteger(pageSize) && pageSize > 0 ? pageSize : DEFAULT_PAGE_SIZE
|
|
76
|
+
const pages = useMemo(() => chunk(products, size), [products, size])
|
|
77
|
+
const pageCount = pages.length
|
|
78
|
+
const [page, setPage] = useState(0)
|
|
79
|
+
const [touchStart, setTouchStart] = useState<number | null>(null)
|
|
80
|
+
const safePage = pageCount === 0 ? 0 : Math.min(page, pageCount - 1)
|
|
81
|
+
|
|
82
|
+
const goTo = (next: number) => {
|
|
83
|
+
if (pageCount === 0) return
|
|
84
|
+
if (next < 0 || next >= pageCount) return
|
|
85
|
+
setPage(next)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (pageCount === 0) {
|
|
89
|
+
return (
|
|
90
|
+
<section className='py-12'>
|
|
91
|
+
<div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
|
|
92
|
+
{heading && (
|
|
93
|
+
<h2 className='mb-4 text-center text-2xl font-bold text-balance md:text-3xl'>
|
|
94
|
+
{heading}
|
|
95
|
+
</h2>
|
|
96
|
+
)}
|
|
97
|
+
<p className='text-muted-foreground text-center'>{emptyLabel}</p>
|
|
98
|
+
</div>
|
|
99
|
+
</section>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const visible = pages[safePage]
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<section
|
|
107
|
+
className='py-12'
|
|
108
|
+
aria-roledescription='carousel'
|
|
109
|
+
onKeyDown={(event) => {
|
|
110
|
+
if (event.key === 'ArrowLeft') {
|
|
111
|
+
event.preventDefault()
|
|
112
|
+
goTo(safePage - 1)
|
|
113
|
+
} else if (event.key === 'ArrowRight') {
|
|
114
|
+
event.preventDefault()
|
|
115
|
+
goTo(safePage + 1)
|
|
116
|
+
}
|
|
117
|
+
}}
|
|
118
|
+
>
|
|
119
|
+
<div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
|
|
120
|
+
{(heading || description) && (
|
|
121
|
+
<div className='mb-8 text-center'>
|
|
122
|
+
{heading && (
|
|
123
|
+
<h2 className='text-2xl font-bold text-balance md:text-3xl'>{heading}</h2>
|
|
124
|
+
)}
|
|
125
|
+
{description && <p className='text-muted-foreground mt-2 text-balance'>{description}</p>}
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
|
|
129
|
+
<div
|
|
130
|
+
onTouchStart={(event) => setTouchStart(event.touches[0]?.clientX ?? null)}
|
|
131
|
+
onTouchEnd={(event) => {
|
|
132
|
+
if (touchStart === null) return
|
|
133
|
+
const delta = (event.changedTouches[0]?.clientX ?? touchStart) - touchStart
|
|
134
|
+
if (delta <= -SWIPE_THRESHOLD) goTo(safePage + 1)
|
|
135
|
+
else if (delta >= SWIPE_THRESHOLD) goTo(safePage - 1)
|
|
136
|
+
setTouchStart(null)
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
<motion.div
|
|
140
|
+
key={safePage}
|
|
141
|
+
initial={reduceMotion ? false : { opacity: 0 }}
|
|
142
|
+
animate={{ opacity: 1 }}
|
|
143
|
+
transition={{ duration: 0.3, ease: 'easeOut' }}
|
|
144
|
+
className='grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:gap-6'
|
|
145
|
+
>
|
|
146
|
+
{visible.map((product) => {
|
|
147
|
+
const compareAt = compareAtText(product)
|
|
148
|
+
return (
|
|
149
|
+
<Card
|
|
150
|
+
key={product.id}
|
|
151
|
+
className={`flex h-full flex-col gap-4 overflow-hidden rounded-lg py-4 shadow-none transition-shadow duration-300 hover:shadow-md ${
|
|
152
|
+
onProductClick ? 'cursor-pointer' : ''
|
|
153
|
+
}`}
|
|
154
|
+
onClick={onProductClick ? () => onProductClick(product) : undefined}
|
|
155
|
+
>
|
|
156
|
+
<CardContent className='flex flex-1 flex-col gap-4 px-4'>
|
|
157
|
+
<div className='relative aspect-square overflow-hidden rounded-md'>
|
|
158
|
+
{product.badge && (
|
|
159
|
+
<span className='bg-destructive text-destructive-foreground absolute top-2 left-2 z-10 rounded-full px-2 py-0.5 text-xs font-semibold'>
|
|
160
|
+
{product.badge}
|
|
161
|
+
</span>
|
|
162
|
+
)}
|
|
163
|
+
<img
|
|
164
|
+
src={product.image || undefined}
|
|
165
|
+
alt={product.imageAlt ?? product.title}
|
|
166
|
+
className='size-full rounded-md object-contain'
|
|
167
|
+
loading='lazy'
|
|
168
|
+
width={400}
|
|
169
|
+
height={400}
|
|
170
|
+
/>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<div className='flex flex-1 flex-col'>
|
|
174
|
+
<h3 className='mb-1 font-medium text-balance'>
|
|
175
|
+
{onProductClick ? (
|
|
176
|
+
<button
|
|
177
|
+
type='button'
|
|
178
|
+
onClick={(event) => {
|
|
179
|
+
event.stopPropagation()
|
|
180
|
+
onProductClick(product)
|
|
181
|
+
}}
|
|
182
|
+
className='hover:text-primary cursor-pointer text-left transition-colors'
|
|
183
|
+
>
|
|
184
|
+
{product.title}
|
|
185
|
+
</button>
|
|
186
|
+
) : (
|
|
187
|
+
product.title
|
|
188
|
+
)}
|
|
189
|
+
</h3>
|
|
190
|
+
{typeof product.rating === 'number' && Number.isFinite(product.rating) && (
|
|
191
|
+
<div className='text-muted-foreground mb-1 flex items-center gap-1 text-xs'>
|
|
192
|
+
<Star className='size-3.5 fill-current' aria-hidden='true' />
|
|
193
|
+
<span>{product.rating.toFixed(1)}</span>
|
|
194
|
+
</div>
|
|
195
|
+
)}
|
|
196
|
+
<div className='mt-auto flex items-baseline gap-2'>
|
|
197
|
+
<p className='font-semibold'>
|
|
198
|
+
{formatMoney(product.price) ?? unavailablePriceLabel}
|
|
199
|
+
</p>
|
|
200
|
+
{compareAt && (
|
|
201
|
+
<p className='text-muted-foreground text-sm line-through'>{compareAt}</p>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
</CardContent>
|
|
206
|
+
|
|
207
|
+
<CardFooter className='bg-transparent border-t-0 px-3 pt-0 md:px-4'>
|
|
208
|
+
<Button
|
|
209
|
+
variant='outline'
|
|
210
|
+
size='sm'
|
|
211
|
+
className='h-8 w-full cursor-pointer px-3 text-sm'
|
|
212
|
+
onClick={(event) => {
|
|
213
|
+
event.stopPropagation()
|
|
214
|
+
onAddToCart?.(product)
|
|
215
|
+
}}
|
|
216
|
+
>
|
|
217
|
+
{addToCartLabel}
|
|
218
|
+
</Button>
|
|
219
|
+
</CardFooter>
|
|
220
|
+
</Card>
|
|
221
|
+
)
|
|
222
|
+
})}
|
|
223
|
+
</motion.div>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
{pageCount > 1 && (
|
|
227
|
+
<div className='mt-6 flex items-center justify-center gap-3'>
|
|
228
|
+
<Button
|
|
229
|
+
type='button'
|
|
230
|
+
variant='outline'
|
|
231
|
+
size='icon-sm'
|
|
232
|
+
className='cursor-pointer'
|
|
233
|
+
aria-label={prevLabel}
|
|
234
|
+
disabled={safePage === 0}
|
|
235
|
+
onClick={() => goTo(safePage - 1)}
|
|
236
|
+
>
|
|
237
|
+
<ChevronLeft className='size-4' aria-hidden='true' />
|
|
238
|
+
</Button>
|
|
239
|
+
|
|
240
|
+
<div className='flex items-center gap-2'>
|
|
241
|
+
{pages.map((_, index) => (
|
|
242
|
+
<button
|
|
243
|
+
key={index}
|
|
244
|
+
type='button'
|
|
245
|
+
aria-label={`${pageLabel} ${index + 1}`}
|
|
246
|
+
aria-current={index === safePage ? 'true' : undefined}
|
|
247
|
+
className={`size-2 rounded-full transition-colors ${
|
|
248
|
+
index === safePage ? 'bg-primary' : 'bg-muted-foreground/40'
|
|
249
|
+
}`}
|
|
250
|
+
onClick={() => goTo(index)}
|
|
251
|
+
/>
|
|
252
|
+
))}
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<Button
|
|
256
|
+
type='button'
|
|
257
|
+
variant='outline'
|
|
258
|
+
size='icon-sm'
|
|
259
|
+
className='cursor-pointer'
|
|
260
|
+
aria-label={nextLabel}
|
|
261
|
+
disabled={safePage === pageCount - 1}
|
|
262
|
+
onClick={() => goTo(safePage + 1)}
|
|
263
|
+
>
|
|
264
|
+
<ChevronRight className='size-4' aria-hidden='true' />
|
|
265
|
+
</Button>
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
|
|
269
|
+
<p aria-live='polite' className='sr-only'>
|
|
270
|
+
{pageLabel} {safePage + 1} / {pageCount}
|
|
271
|
+
</p>
|
|
272
|
+
</div>
|
|
273
|
+
</section>
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export default ProductShowcase1
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import ProductOverview1, {
|
|
4
|
+
galleryImages,
|
|
5
|
+
resolveAddToCartPayload,
|
|
6
|
+
variantLabelOf,
|
|
7
|
+
type ProductDetailProduct,
|
|
8
|
+
} from './product-overview-1'
|
|
9
|
+
|
|
10
|
+
const product: ProductDetailProduct = {
|
|
11
|
+
id: 'p1',
|
|
12
|
+
handle: 'taladro',
|
|
13
|
+
title: 'Taladro inalámbrico',
|
|
14
|
+
description: 'Un taladro',
|
|
15
|
+
vendor: 'Acme',
|
|
16
|
+
options: [{ id: 'o1', name: 'Color', values: ['Rojo', 'Azul'] }],
|
|
17
|
+
variants: [
|
|
18
|
+
{
|
|
19
|
+
id: 'v-red',
|
|
20
|
+
title: 'Rojo',
|
|
21
|
+
availableForSale: true,
|
|
22
|
+
selectedOptions: [{ name: 'Color', value: 'Rojo' }],
|
|
23
|
+
price: { amount: '10.00', currencyCode: 'USD' },
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'v-blue',
|
|
27
|
+
title: 'Azul',
|
|
28
|
+
availableForSale: true,
|
|
29
|
+
selectedOptions: [{ name: 'Color', value: 'Azul' }],
|
|
30
|
+
price: { amount: '25.50', currencyCode: 'USD' },
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'v-sold',
|
|
34
|
+
title: 'Verde',
|
|
35
|
+
availableForSale: false,
|
|
36
|
+
selectedOptions: [{ name: 'Color', value: 'Verde' }],
|
|
37
|
+
price: { amount: '9.00', currencyCode: 'USD' },
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: 'v-noprice',
|
|
41
|
+
title: 'Gris',
|
|
42
|
+
availableForSale: true,
|
|
43
|
+
selectedOptions: [{ name: 'Color', value: 'Gris' }],
|
|
44
|
+
price: null,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'v-free',
|
|
48
|
+
title: 'Blanco',
|
|
49
|
+
availableForSale: true,
|
|
50
|
+
selectedOptions: [{ name: 'Color', value: 'Blanco' }],
|
|
51
|
+
price: { amount: '0', currencyCode: 'USD' },
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
images: [],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const formatPrice = (money: { amount: string; currencyCode: string }) =>
|
|
58
|
+
`${money.currencyCode} ${money.amount}`
|
|
59
|
+
|
|
60
|
+
describe('ProductOverview1 — explicit, controlled variant selection', () => {
|
|
61
|
+
it('does not default to the first variant and prompts for an explicit selection', () => {
|
|
62
|
+
const html = renderToStaticMarkup(
|
|
63
|
+
<ProductOverview1 product={product} formatPrice={formatPrice} onAddToCart={() => {}} />,
|
|
64
|
+
)
|
|
65
|
+
expect(html).toContain('Selecciona una variante')
|
|
66
|
+
expect(html).not.toContain('USD 10.00')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('shows the selected non-default variant at its current price', () => {
|
|
70
|
+
const html = renderToStaticMarkup(
|
|
71
|
+
<ProductOverview1
|
|
72
|
+
product={product}
|
|
73
|
+
selectedVariantId='v-blue'
|
|
74
|
+
formatPrice={formatPrice}
|
|
75
|
+
onAddToCart={() => {}}
|
|
76
|
+
/>,
|
|
77
|
+
)
|
|
78
|
+
expect(html).toContain('USD 25.50')
|
|
79
|
+
expect(html).not.toContain('USD 10.00')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('reports a missing price as unavailable instead of zero', () => {
|
|
83
|
+
const html = renderToStaticMarkup(
|
|
84
|
+
<ProductOverview1 product={product} selectedVariantId='v-noprice' formatPrice={formatPrice} />,
|
|
85
|
+
)
|
|
86
|
+
expect(html).toContain('Precio no disponible')
|
|
87
|
+
expect(html).not.toContain('USD 0')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('preserves a backend-resolved valid zero', () => {
|
|
91
|
+
const html = renderToStaticMarkup(
|
|
92
|
+
<ProductOverview1 product={product} selectedVariantId='v-free' formatPrice={formatPrice} />,
|
|
93
|
+
)
|
|
94
|
+
expect(html).toContain('USD 0')
|
|
95
|
+
expect(html).not.toContain('Precio no disponible')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('enables purchase only for an available, priced, explicitly selected variant', () => {
|
|
99
|
+
const onlyAvailable: ProductDetailProduct = {
|
|
100
|
+
...product,
|
|
101
|
+
variants: [product.variants[0]],
|
|
102
|
+
}
|
|
103
|
+
const enabled = renderToStaticMarkup(
|
|
104
|
+
<ProductOverview1
|
|
105
|
+
product={onlyAvailable}
|
|
106
|
+
selectedVariantId='v-red'
|
|
107
|
+
formatPrice={formatPrice}
|
|
108
|
+
onAddToCart={() => {}}
|
|
109
|
+
/>,
|
|
110
|
+
)
|
|
111
|
+
expect(enabled).not.toContain('disabled=""')
|
|
112
|
+
|
|
113
|
+
const noSelection = renderToStaticMarkup(
|
|
114
|
+
<ProductOverview1 product={onlyAvailable} formatPrice={formatPrice} onAddToCart={() => {}} />,
|
|
115
|
+
)
|
|
116
|
+
expect(noSelection).toContain('disabled=""')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('ProductOverview1 — truthful optional data', () => {
|
|
121
|
+
it('renders no rating, delivery estimate, or shipping when the backend supplies none', () => {
|
|
122
|
+
const html = renderToStaticMarkup(<ProductOverview1 product={product} formatPrice={formatPrice} />)
|
|
123
|
+
expect(html).not.toContain('Reseñas')
|
|
124
|
+
expect(html).not.toContain('Entrega estimada')
|
|
125
|
+
expect(html).not.toContain('Envío')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('renders rating, delivery estimate, and shipping only when supplied', () => {
|
|
129
|
+
const html = renderToStaticMarkup(
|
|
130
|
+
<ProductOverview1
|
|
131
|
+
product={product}
|
|
132
|
+
formatPrice={formatPrice}
|
|
133
|
+
rating={{ value: 4, count: 12 }}
|
|
134
|
+
deliveryEstimate='3-5 días'
|
|
135
|
+
shipping={{ amount: '4.99', currencyCode: 'USD' }}
|
|
136
|
+
/>,
|
|
137
|
+
)
|
|
138
|
+
expect(html).toContain('Reseñas')
|
|
139
|
+
expect(html).toContain('3-5 días')
|
|
140
|
+
expect(html).toContain('USD 4.99')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('never emits an empty image src when the product has no images', () => {
|
|
144
|
+
const html = renderToStaticMarkup(<ProductOverview1 product={product} formatPrice={formatPrice} />)
|
|
145
|
+
expect(html).not.toMatch(/src=""/)
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe('resolveAddToCartPayload', () => {
|
|
150
|
+
const variant = product.variants[1]
|
|
151
|
+
|
|
152
|
+
it('returns null when no variant is selected (no first-variant shortcut)', () => {
|
|
153
|
+
expect(resolveAddToCartPayload(null, 2)).toBeNull()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('uses the selected backend variant id and defaults the quantity to 1', () => {
|
|
157
|
+
expect(resolveAddToCartPayload(variant, undefined)).toEqual({ variantId: 'v-blue', quantity: 1 })
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('carries the requested quantity', () => {
|
|
161
|
+
expect(resolveAddToCartPayload(variant, 3)).toEqual({ variantId: 'v-blue', quantity: 3 })
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('variant helpers', () => {
|
|
166
|
+
it('prefers the variant title', () => {
|
|
167
|
+
expect(variantLabelOf(product.variants[0])).toBe('Rojo')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('falls back to option values when a variant has no title', () => {
|
|
171
|
+
expect(variantLabelOf({ ...product.variants[0], title: '' })).toBe('Rojo')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('uses the product images when present and never a fabricated URL', () => {
|
|
175
|
+
const withImages: ProductDetailProduct = {
|
|
176
|
+
...product,
|
|
177
|
+
images: [{ url: 'https://cdn.example/p1.jpg', altText: 'p1' }],
|
|
178
|
+
}
|
|
179
|
+
expect(galleryImages(withImages, 'FALLBACK')).toEqual(['https://cdn.example/p1.jpg'])
|
|
180
|
+
expect(galleryImages(product, 'FALLBACK')).toEqual(['FALLBACK'])
|
|
181
|
+
})
|
|
182
|
+
})
|