@thorprovider/blocks 0.1.2 → 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 (41) hide show
  1. package/.turbo/turbo-build.log +15 -15
  2. package/.turbo/turbo-test.log +37 -27
  3. package/README.md +42 -1
  4. package/dist/.tsbuildinfo +1 -1
  5. package/dist/contracts.d.mts +162 -14
  6. package/dist/contracts.d.ts +162 -14
  7. package/dist/contracts.js +36 -3
  8. package/dist/contracts.js.map +1 -1
  9. package/dist/contracts.mjs +34 -3
  10. package/dist/contracts.mjs.map +1 -1
  11. package/dist/index.d.mts +294 -12
  12. package/dist/index.d.ts +294 -12
  13. package/dist/index.js +3577 -2882
  14. package/dist/index.js.map +1 -1
  15. package/dist/index.mjs +3431 -2750
  16. package/dist/index.mjs.map +1 -1
  17. package/package.json +2 -2
  18. package/src/blocks/e-commerce/navigation/back-to-top.test.tsx +27 -0
  19. package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
  20. package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
  21. package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
  22. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
  23. package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
  24. package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
  25. package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
  26. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +88 -0
  27. package/src/blocks/e-commerce/product-list/product-card-1.tsx +141 -80
  28. package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
  29. package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
  30. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +92 -0
  31. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +86 -25
  32. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +7 -0
  33. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +1 -1
  34. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +38 -0
  35. package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
  36. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +150 -0
  37. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +102 -9
  38. package/src/blocks/marketing/navbars/navbar-4.test.tsx +121 -0
  39. package/src/blocks/marketing/navbars/navbar-4.tsx +134 -32
  40. package/src/index.ts +18 -0
  41. package/src/lib/contracts.ts +53 -3
@@ -0,0 +1,88 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { ProductCard1, type ProductCard1Product } from './product-card-1'
4
+
5
+ const legacy: ProductCard1Product = {
6
+ id: 'legacy-1',
7
+ title: 'Taladro percutor',
8
+ price: 49.99,
9
+ originalPrice: 69.99,
10
+ image: '/demo/drill.svg',
11
+ }
12
+
13
+ const canonical: ProductCard1Product = {
14
+ id: 'sku-1',
15
+ title: 'Llave de impacto',
16
+ price: { amount: '129.50', currencyCode: 'EUR' },
17
+ compareAtPrice: { amount: '159.00', currencyCode: 'EUR' },
18
+ image: '/demo/impact.svg',
19
+ imageAlt: 'Llave de impacto inalámbrica',
20
+ rating: 4.5,
21
+ badge: 'Nuevo',
22
+ }
23
+
24
+ const render = (products: ProductCard1Product[], extra: Record<string, unknown> = {}) =>
25
+ renderToStaticMarkup(<ProductCard1 products={products} {...extra} />)
26
+
27
+ describe('ProductCard1 — legacy numeric path unchanged', () => {
28
+ it('keeps the legacy dollar output and originalPrice', () => {
29
+ const html = render([legacy])
30
+ expect(html).toContain('$49.99')
31
+ expect(html).toContain('$69.99')
32
+ })
33
+ })
34
+
35
+ describe('ProductCard1 — canonical Money path', () => {
36
+ it('formats the canonical price in its own currency (no dollars)', () => {
37
+ const html = render([canonical])
38
+ expect(html).not.toContain('$')
39
+ expect(html).toContain('129')
40
+ })
41
+
42
+ it('renders a comparison price only when it is strictly higher', () => {
43
+ expect(render([canonical])).toContain('159')
44
+ const equal = render([
45
+ { ...canonical, compareAtPrice: { amount: '129.50', currencyCode: 'EUR' } },
46
+ ])
47
+ const lower = render([
48
+ { ...canonical, compareAtPrice: { amount: '99.00', currencyCode: 'EUR' } },
49
+ ])
50
+ expect(equal).not.toContain('159')
51
+ expect(lower).not.toContain('99')
52
+ })
53
+
54
+ it('treats a valid zero as a real price', () => {
55
+ const html = render([
56
+ { ...canonical, price: { amount: '0.00', currencyCode: 'USD' }, compareAtPrice: undefined },
57
+ ])
58
+ expect(html).toContain('$0.00')
59
+ expect(html).not.toContain('Precio no disponible')
60
+ })
61
+
62
+ it('shows the unavailable label for an unresolved price (never $0)', () => {
63
+ const html = render([
64
+ { ...canonical, price: { amount: '', currencyCode: '' }, compareAtPrice: undefined },
65
+ ])
66
+ expect(html).toContain('Precio no disponible')
67
+ expect(html).not.toContain('$0')
68
+ })
69
+
70
+ it('renders the rating and badge only from real data', () => {
71
+ expect(render([canonical])).toContain('4.5')
72
+ expect(render([canonical])).toContain('Nuevo')
73
+ const withoutRating = render([{ ...canonical, rating: undefined, badge: undefined }])
74
+ expect(withoutRating).not.toContain('4.5')
75
+ expect(withoutRating).not.toContain('Nuevo')
76
+ })
77
+
78
+ it('omits the image source entirely when there is no image (no empty src)', () => {
79
+ const html = render([{ ...canonical, image: undefined }])
80
+ expect(html).not.toContain('src=""')
81
+ expect(html).toContain('Llave de impacto inalámbrica')
82
+ })
83
+
84
+ it('falls back to the title when no image alt is provided', () => {
85
+ const html = render([{ ...canonical, imageAlt: undefined }])
86
+ expect(html).toContain('alt="Llave de impacto"')
87
+ })
88
+ })
@@ -1,9 +1,12 @@
1
1
  'use client'
2
2
 
3
+ import { Star } from 'lucide-react'
3
4
  import { motion, useReducedMotion } from 'motion/react'
4
5
 
5
6
  import { Button } from '../../../ui/button'
6
7
  import { Card, CardContent, CardFooter } from '../../../ui/card'
8
+ import { formatMoney } from '../../../lib/money'
9
+ import type { ProductPresentation } from '../../../lib/contracts'
7
10
 
8
11
  export interface Product {
9
12
  id: string | number
@@ -14,13 +17,60 @@ export interface Product {
14
17
  badge?: string
15
18
  }
16
19
 
20
+ /**
21
+ * A card entry is either the legacy numeric-price product or the canonical
22
+ * `Money`-price presentation. The block renders both without breaking legacy
23
+ * consumers: a numeric `price` keeps the legacy `$` output unchanged, while a
24
+ * canonical `Money` price is formatted in its own currency and locale.
25
+ */
26
+ export type ProductCard1Product = Product | ProductPresentation
27
+
17
28
  export interface ProductCard1Props {
18
- products: Product[]
29
+ products: ProductCard1Product[]
19
30
  heading?: string
20
31
  maxItems?: number
21
- onAddToCart?: (product: Product) => void
22
- onProductClick?: (product: Product) => void
32
+ onAddToCart?: (product: ProductCard1Product) => void
33
+ onProductClick?: (product: ProductCard1Product) => void
23
34
  addToCartLabel?: string
35
+ /** Shown when a canonical price is present but unavailable (never `$0`). */
36
+ unavailablePriceLabel?: string
37
+ }
38
+
39
+ function isPresentation(product: ProductCard1Product): product is ProductPresentation {
40
+ return typeof product.price === 'object'
41
+ }
42
+
43
+ function priceText(product: ProductCard1Product, unavailablePriceLabel: string): string {
44
+ if (isPresentation(product)) {
45
+ return formatMoney(product.price) ?? unavailablePriceLabel
46
+ }
47
+ return `$${product.price.toFixed(2)}`
48
+ }
49
+
50
+ function compareAtText(product: ProductCard1Product): string | undefined {
51
+ if (isPresentation(product)) {
52
+ const compareAt = product.compareAtPrice
53
+ if (!compareAt) return undefined
54
+ const formatted = formatMoney(compareAt)
55
+ const price = formatMoney(product.price)
56
+ // Render the comparison price only when both are valid and it is higher.
57
+ if (!formatted || !price) return undefined
58
+ if (Number(compareAt.amount) <= Number(product.price.amount)) return undefined
59
+ return formatted
60
+ }
61
+ // Legacy behavior is unchanged: truthiness only.
62
+ return product.originalPrice ? `$${product.originalPrice.toFixed(2)}` : undefined
63
+ }
64
+
65
+ function imageAlt(product: ProductCard1Product): string {
66
+ return isPresentation(product) ? (product.imageAlt ?? product.title) : product.title
67
+ }
68
+
69
+ function rating(product: ProductCard1Product): number | undefined {
70
+ if (!isPresentation(product)) return undefined
71
+ return typeof product.rating === 'number' && Number.isFinite(product.rating)
72
+ ? product.rating
73
+ : undefined
24
74
  }
25
75
 
26
76
  export function ProductCard1({
@@ -30,6 +80,7 @@ export function ProductCard1({
30
80
  onAddToCart,
31
81
  onProductClick,
32
82
  addToCartLabel = 'Agregar al carrito',
83
+ unavailablePriceLabel = 'Precio no disponible',
33
84
  }: ProductCard1Props) {
34
85
  const reduceMotion = useReducedMotion()
35
86
  const displayedProducts = products.slice(0, maxItems)
@@ -42,90 +93,100 @@ export function ProductCard1({
42
93
  )}
43
94
 
44
95
  <div className='grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:gap-6'>
45
- {displayedProducts.map((product, index) => (
46
- <motion.div
47
- key={product.id}
48
- initial={reduceMotion ? false : { opacity: 0, y: 16 }}
49
- whileInView={{ opacity: 1, y: 0 }}
50
- viewport={{ once: true, margin: '-40px' }}
51
- whileHover={reduceMotion ? undefined : { y: -4 }}
52
- transition={{
53
- duration: 0.35,
54
- delay: reduceMotion ? 0 : index * 0.05,
55
- ease: 'easeOut',
56
- }}
57
- >
58
- <Card
59
- className={`flex h-full flex-col gap-4 overflow-hidden rounded-lg py-4 shadow-none transition-shadow duration-300 hover:shadow-md ${
60
- onProductClick ? 'cursor-pointer' : ''
61
- }`}
62
- onClick={onProductClick ? () => onProductClick(product) : undefined}
96
+ {displayedProducts.map((product, index) => {
97
+ const compareAt = compareAtText(product)
98
+ const ratingValue = rating(product)
99
+ return (
100
+ <motion.div
101
+ key={product.id}
102
+ initial={reduceMotion ? false : { opacity: 0, y: 16 }}
103
+ whileInView={{ opacity: 1, y: 0 }}
104
+ viewport={{ once: true, margin: '-40px' }}
105
+ whileHover={reduceMotion ? undefined : { y: -4 }}
106
+ transition={{
107
+ duration: 0.35,
108
+ delay: reduceMotion ? 0 : index * 0.05,
109
+ ease: 'easeOut',
110
+ }}
63
111
  >
64
- <CardContent className='flex flex-1 flex-col gap-4 px-4 '>
65
- <div className='relative aspect-square overflow-hidden rounded-md'>
66
- {product.badge && (
67
- <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'>
68
- {product.badge}
69
- </span>
70
- )}
71
- <img
72
- src={product.image || undefined}
73
- alt={product.title}
74
- className='size-full rounded-md object-contain'
75
- loading='lazy'
76
- width={400}
77
- height={400}
78
- />
79
- </div>
80
-
81
- <div className='flex flex-1 flex-col'>
82
- <h2 className='mb-1 font-medium text-balance'>
83
- {onProductClick ? (
84
- <button
85
- type='button'
86
- onClick={(e) => {
87
- e.stopPropagation()
88
- onProductClick(product)
89
- }}
90
- className='hover:text-primary cursor-pointer text-left transition-colors'
91
- >
92
- {product.title}
93
- </button>
94
- ) : (
95
- product.title
112
+ <Card
113
+ className={`flex h-full flex-col gap-4 overflow-hidden rounded-lg py-4 shadow-none transition-shadow duration-300 hover:shadow-md ${
114
+ onProductClick ? 'cursor-pointer' : ''
115
+ }`}
116
+ onClick={onProductClick ? () => onProductClick(product) : undefined}
117
+ >
118
+ <CardContent className='flex flex-1 flex-col gap-4 px-4 '>
119
+ <div className='relative aspect-square overflow-hidden rounded-md'>
120
+ {product.badge && (
121
+ <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'>
122
+ {product.badge}
123
+ </span>
96
124
  )}
97
- </h2>
98
- <div className='mt-auto flex items-baseline gap-2'>
99
- <p className='font-semibold'>${product.price.toFixed(2)}</p>
100
- {product.originalPrice && (
101
- <p className='text-muted-foreground text-sm line-through md:text-base xl:text-sm 2xl:text-base'>
102
- ${product.originalPrice.toFixed(2)}
103
- </p>
125
+ <img
126
+ src={product.image || undefined}
127
+ alt={imageAlt(product)}
128
+ className='size-full rounded-md object-contain'
129
+ loading='lazy'
130
+ width={400}
131
+ height={400}
132
+ />
133
+ </div>
134
+
135
+ <div className='flex flex-1 flex-col'>
136
+ <h2 className='mb-1 font-medium text-balance'>
137
+ {onProductClick ? (
138
+ <button
139
+ type='button'
140
+ onClick={(e) => {
141
+ e.stopPropagation()
142
+ onProductClick(product)
143
+ }}
144
+ className='hover:text-primary cursor-pointer text-left transition-colors'
145
+ >
146
+ {product.title}
147
+ </button>
148
+ ) : (
149
+ product.title
150
+ )}
151
+ </h2>
152
+ {ratingValue !== undefined && (
153
+ <div className='text-muted-foreground mb-1 flex items-center gap-1 text-xs'>
154
+ <Star className='size-3.5 fill-current' aria-hidden='true' />
155
+ <span>{ratingValue.toFixed(1)}</span>
156
+ </div>
104
157
  )}
158
+ <div className='mt-auto flex items-baseline gap-2'>
159
+ <p className='font-semibold'>{priceText(product, unavailablePriceLabel)}</p>
160
+ {compareAt && (
161
+ <p className='text-muted-foreground text-sm line-through md:text-base xl:text-sm 2xl:text-base'>
162
+ {compareAt}
163
+ </p>
164
+ )}
165
+ </div>
105
166
  </div>
106
- </div>
107
- </CardContent>
108
-
109
- <CardFooter className='bg-transparent px-3 pt-0 border-t-0 md:px-4'>
110
- <Button
111
- variant='outline'
112
- size='sm'
113
- className='h-8 w-full cursor-pointer px-3 text-sm text-xs'
114
- onClick={(e) => {
115
- e.stopPropagation()
116
- onAddToCart?.(product)
117
- }}
118
- >
119
- {addToCartLabel}
120
- </Button>
121
- </CardFooter>
122
- </Card>
123
- </motion.div>
124
- ))}
167
+ </CardContent>
168
+
169
+ <CardFooter className='bg-transparent px-3 pt-0 border-t-0 md:px-4'>
170
+ <Button
171
+ variant='outline'
172
+ size='sm'
173
+ className='h-8 w-full cursor-pointer px-3 text-sm text-xs'
174
+ onClick={(e) => {
175
+ e.stopPropagation()
176
+ onAddToCart?.(product)
177
+ }}
178
+ >
179
+ {addToCartLabel}
180
+ </Button>
181
+ </CardFooter>
182
+ </Card>
183
+ </motion.div>
184
+ )
185
+ })}
125
186
  </div>
126
187
  </div>
127
188
  </section>
128
189
  )
129
190
  }
130
191
 
131
- export default ProductCard1
192
+ export default ProductCard1
@@ -0,0 +1,78 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { ProductShowcase1, type ProductShowcase1Props } from './product-showcase-1'
4
+ import type { ProductPresentation } from '../../../lib/contracts'
5
+
6
+ const product = (index: number): ProductPresentation => ({
7
+ id: `sku-${index}`,
8
+ title: `Producto ${index}`,
9
+ price: { amount: `${index}.00`, currencyCode: 'EUR' },
10
+ image: `/demo/${index}.svg`,
11
+ })
12
+
13
+ const seven = Array.from({ length: 7 }, (_, i) => product(i + 1))
14
+
15
+ const render = (props: Partial<ProductShowcase1Props> = {}) =>
16
+ renderToStaticMarkup(<ProductShowcase1 products={seven} pageSize={3} {...props} />)
17
+
18
+ describe('ProductShowcase1 — manual paging', () => {
19
+ it('renders only the first page of products', () => {
20
+ const html = render()
21
+ expect(html).toContain('Producto 1')
22
+ expect(html).toContain('Producto 3')
23
+ expect(html).not.toContain('Producto 4')
24
+ })
25
+
26
+ it('renders visible prev/next controls with accessible labels', () => {
27
+ const html = render()
28
+ expect(html).toContain('aria-label="Anterior"')
29
+ expect(html).toContain('aria-label="Siguiente"')
30
+ expect(html).toContain('disabled')
31
+ })
32
+
33
+ it('renders one page indicator per page with the current page marked', () => {
34
+ const html = render()
35
+ const dots = html.match(/aria-label="Página \d+"/g) ?? []
36
+ expect(dots).toHaveLength(3)
37
+ expect(html).toContain('aria-current="true"')
38
+ })
39
+
40
+ it('exposes a polite live region with the current page position', () => {
41
+ const html = render()
42
+ expect(html).toContain('aria-live="polite"')
43
+ expect(html).toContain('Página 1 / 3')
44
+ })
45
+
46
+ it('never autoplays: no timer is scheduled on render', () => {
47
+ const originalSetInterval = globalThis.setInterval
48
+ let calls = 0
49
+ globalThis.setInterval = ((...args: Parameters<typeof setInterval>) => {
50
+ calls += 1
51
+ return originalSetInterval(...args)
52
+ }) as typeof setInterval
53
+ try {
54
+ render()
55
+ } finally {
56
+ globalThis.setInterval = originalSetInterval
57
+ }
58
+ expect(calls).toBe(0)
59
+ })
60
+ })
61
+
62
+ describe('ProductShowcase1 — truthful states', () => {
63
+ it('renders an empty state instead of fabricated products', () => {
64
+ const html = renderToStaticMarkup(<ProductShowcase1 products={[]} emptyLabel='Sin productos' />)
65
+ expect(html).toContain('Sin productos')
66
+ expect(html).not.toContain('Producto 1')
67
+ })
68
+
69
+ it('renders the unavailable price label for an unresolved price', () => {
70
+ const html = renderToStaticMarkup(
71
+ <ProductShowcase1
72
+ products={[{ id: 'x', title: 'X', price: { amount: '', currencyCode: '' } }]}
73
+ />,
74
+ )
75
+ expect(html).toContain('Precio no disponible')
76
+ expect(html).not.toContain('$0')
77
+ })
78
+ })