@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.
Files changed (70) hide show
  1. package/.turbo/turbo-build.log +15 -15
  2. package/.turbo/turbo-test.log +42 -5
  3. package/AGENTS.md +1 -1
  4. package/README.md +94 -1
  5. package/dist/.tsbuildinfo +1 -1
  6. package/dist/contracts.d.mts +1266 -97
  7. package/dist/contracts.d.ts +1266 -97
  8. package/dist/contracts.js +225 -9
  9. package/dist/contracts.js.map +1 -1
  10. package/dist/contracts.mjs +207 -10
  11. package/dist/contracts.mjs.map +1 -1
  12. package/dist/index.d.mts +756 -96
  13. package/dist/index.d.ts +756 -96
  14. package/dist/index.js +5114 -3702
  15. package/dist/index.js.map +1 -1
  16. package/dist/index.mjs +4939 -3551
  17. package/dist/index.mjs.map +1 -1
  18. package/docs/standards/theming.md +32 -5
  19. package/package.json +2 -2
  20. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +136 -93
  21. package/src/blocks/e-commerce/category-filters/category-filter-2.test.tsx +513 -0
  22. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +728 -148
  23. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +14 -8
  24. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.test.tsx +136 -0
  25. package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +145 -99
  26. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +215 -102
  27. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +6 -2
  28. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +51 -2
  29. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +216 -0
  30. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +114 -11
  31. package/src/blocks/e-commerce/navigation/back-to-top.test.tsx +27 -0
  32. package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
  33. package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
  34. package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
  35. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
  36. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
  37. package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +42 -2
  38. package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +10 -8
  39. package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
  40. package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
  41. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +51 -0
  42. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +56 -36
  43. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +88 -0
  44. package/src/blocks/e-commerce/product-list/product-card-1.tsx +141 -80
  45. package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
  46. package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
  47. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +182 -0
  48. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +298 -129
  49. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +229 -0
  50. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +313 -110
  51. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +72 -0
  52. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +20 -1
  53. package/src/blocks/marketing/footers/footer-section-1.test.tsx +117 -0
  54. package/src/blocks/marketing/footers/footer-section-1.tsx +46 -9
  55. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +38 -0
  56. package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
  57. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +204 -0
  58. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +144 -21
  59. package/src/blocks/marketing/navbars/navbar-4.test.tsx +233 -0
  60. package/src/blocks/marketing/navbars/navbar-4.tsx +235 -60
  61. package/src/index.ts +56 -0
  62. package/src/lib/cart.test.ts +15 -0
  63. package/src/lib/cart.ts +11 -0
  64. package/src/lib/contracts-curated.test.ts +207 -0
  65. package/src/lib/contracts-server-boundary.test.ts +52 -0
  66. package/src/lib/contracts.ts +234 -6
  67. package/src/lib/money.test.ts +64 -0
  68. package/src/lib/money.ts +65 -0
  69. package/tsup.config.ts +0 -1
  70. 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
+ })