@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.
Files changed (57) hide show
  1. package/.turbo/turbo-build.log +15 -15
  2. package/.turbo/turbo-test.log +32 -5
  3. package/AGENTS.md +1 -1
  4. package/README.md +52 -0
  5. package/dist/.tsbuildinfo +1 -1
  6. package/dist/contracts.d.mts +1111 -90
  7. package/dist/contracts.d.ts +1111 -90
  8. package/dist/contracts.js +189 -6
  9. package/dist/contracts.js.map +1 -1
  10. package/dist/contracts.mjs +173 -7
  11. package/dist/contracts.mjs.map +1 -1
  12. package/dist/index.d.mts +467 -89
  13. package/dist/index.d.ts +467 -89
  14. package/dist/index.js +2967 -2250
  15. package/dist/index.js.map +1 -1
  16. package/dist/index.mjs +2989 -2282
  17. package/dist/index.mjs.map +1 -1
  18. package/docs/standards/theming.md +32 -5
  19. package/package.json +4 -3
  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/mobile-category-drawer.test.tsx +42 -2
  32. package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +10 -8
  33. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +51 -0
  34. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +56 -36
  35. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +182 -0
  36. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +298 -129
  37. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +137 -0
  38. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +231 -89
  39. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +65 -0
  40. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +19 -0
  41. package/src/blocks/marketing/footers/footer-section-1.test.tsx +117 -0
  42. package/src/blocks/marketing/footers/footer-section-1.tsx +46 -9
  43. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +54 -0
  44. package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +43 -13
  45. package/src/blocks/marketing/navbars/navbar-4.test.tsx +112 -0
  46. package/src/blocks/marketing/navbars/navbar-4.tsx +101 -28
  47. package/src/index.ts +38 -0
  48. package/src/lib/cart.test.ts +15 -0
  49. package/src/lib/cart.ts +11 -0
  50. package/src/lib/contracts-curated.test.ts +207 -0
  51. package/src/lib/contracts-server-boundary.test.ts +52 -0
  52. package/src/lib/contracts.ts +182 -4
  53. package/src/lib/money.test.ts +64 -0
  54. package/src/lib/money.ts +65 -0
  55. package/styles.css +1 -0
  56. package/tsup.config.ts +0 -1
  57. package/vitest.config.ts +9 -1
@@ -9,7 +9,12 @@ export interface ProductCategory2Category {
9
9
  handle?: string
10
10
  name: string
11
11
  description: string
12
- itemCount: number
12
+ /**
13
+ * Number of items in the category. Optional: when omitted, or when it is not
14
+ * a finite non-negative number, no count is rendered — an unknown projection
15
+ * must not be shown as a fabricated `0`. A real `0` is preserved.
16
+ */
17
+ itemCount?: number
13
18
  image: string
14
19
  trending: boolean
15
20
  }
@@ -22,6 +27,18 @@ export interface ProductCategory2Props {
22
27
  onBrowse?: (category: ProductCategory2Category) => void
23
28
  }
24
29
 
30
+ /**
31
+ * Formats a category item count only when it is a supplied, finite,
32
+ * non-negative number. Returns `undefined` for unknown or invalid values so the
33
+ * caller omits the count entirely (a real `0` is preserved).
34
+ */
35
+ function formatItemCount(itemCount: number | undefined): string | undefined {
36
+ if (itemCount === undefined || !Number.isFinite(itemCount) || itemCount < 0) {
37
+ return undefined
38
+ }
39
+ return `${itemCount.toLocaleString()} items`
40
+ }
41
+
25
42
  export function ProductCategory2({
26
43
  title,
27
44
  description,
@@ -42,44 +59,47 @@ export function ProductCategory2({
42
59
  )}
43
60
 
44
61
  <div className='grid gap-6 sm:grid-cols-2 lg:grid-cols-3'>
45
- {categories.map(category => (
46
- <Card
47
- key={category.id}
48
- className='group cursor-pointer overflow-hidden py-0 transition-all duration-500 hover:shadow-lg'
49
- >
50
- <div className='relative aspect-[5/4] overflow-hidden'>
51
- <img
52
- src={category.image || undefined}
53
- alt={category.name}
54
- className='size-full object-cover transition-transform duration-500 group-hover:scale-105'
55
- />
56
- <div className='absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent' />
62
+ {categories.map(category => {
63
+ const itemCountLabel = formatItemCount(category.itemCount)
64
+ return (
65
+ <Card
66
+ key={category.id}
67
+ className='group cursor-pointer overflow-hidden py-0 transition-all duration-500 hover:shadow-lg'
68
+ >
69
+ <div className='relative aspect-[5/4] overflow-hidden'>
70
+ <img
71
+ src={category.image || undefined}
72
+ alt={category.name}
73
+ className='size-full object-cover transition-transform duration-500 group-hover:scale-105'
74
+ />
75
+ <div className='absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent' />
57
76
 
58
- {category.trending ? (
59
- <Badge className='absolute top-4 left-4 rounded-sm px-2.5 py-0.5 font-semibold'>
60
- Trending
61
- </Badge>
62
- ) : null}
77
+ {category.trending ? (
78
+ <Badge className='absolute top-4 left-4 rounded-sm px-2.5 py-0.5 font-semibold'>
79
+ Trending
80
+ </Badge>
81
+ ) : null}
63
82
 
64
- <div className='absolute right-0 bottom-0 left-0 p-6 text-white'>
65
- <h3 className='mb-1 text-xl font-bold'>{category.name}</h3>
66
- <p className='mb-3 text-sm text-white/90'>{category.description}</p>
67
- <div className='flex items-center justify-between'>
68
- <span className='text-sm'>{category.itemCount.toLocaleString()} items</span>
69
- <Button
70
- size='sm'
71
- variant='secondary'
72
- className='h-8 cursor-pointer border-white/30 bg-white/20 px-3 text-xs text-white backdrop-blur-sm hover:bg-white/30'
73
- onClick={onBrowse ? () => onBrowse(category) : undefined}
74
- >
75
- Browse
76
- <ArrowRight />
77
- </Button>
83
+ <div className='absolute right-0 bottom-0 left-0 p-6 text-white'>
84
+ <h3 className='mb-1 text-xl font-bold'>{category.name}</h3>
85
+ <p className='mb-3 text-sm text-white/90'>{category.description}</p>
86
+ <div className='flex items-center justify-between'>
87
+ {itemCountLabel ? <span className='text-sm'>{itemCountLabel}</span> : null}
88
+ <Button
89
+ size='sm'
90
+ variant='secondary'
91
+ className='ms-auto h-8 cursor-pointer border-white/30 bg-white/20 px-3 text-xs text-white backdrop-blur-sm hover:bg-white/30'
92
+ onClick={onBrowse ? () => onBrowse(category) : undefined}
93
+ >
94
+ Browse
95
+ <ArrowRight />
96
+ </Button>
97
+ </div>
78
98
  </div>
79
99
  </div>
80
- </div>
81
- </Card>
82
- ))}
100
+ </Card>
101
+ )
102
+ })}
83
103
  </div>
84
104
 
85
105
  {cta ? (
@@ -97,4 +117,4 @@ export function ProductCategory2({
97
117
  )
98
118
  }
99
119
 
100
- export default ProductCategory2
120
+ export default ProductCategory2
@@ -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
+ })