@thorprovider/blocks 0.1.3 → 2.0.0

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 (178) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/README.md +228 -68
  3. package/docs/standards/data-contracts.md +13 -2
  4. package/package.json +26 -10
  5. package/scripts/fix-esm-extensions.mjs +40 -0
  6. package/src/blocks/application/login-forms/login-page.test.tsx +190 -0
  7. package/src/blocks/application/login-forms/login-page.tsx +240 -0
  8. package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +6 -8
  9. package/src/blocks/application/signup-forms/signup-page-1.tsx +127 -129
  10. package/src/blocks/application/verification/verify-1.tsx +2 -2
  11. package/src/blocks/application/viewport-ownership.test.tsx +41 -0
  12. package/src/blocks/e-commerce/category-filters/category-listing-filter-fields.tsx +218 -0
  13. package/src/blocks/e-commerce/category-filters/{category-filter-2.test.tsx → category-listing.test.tsx} +287 -199
  14. package/src/blocks/e-commerce/category-filters/category-listing.tsx +934 -0
  15. package/src/blocks/e-commerce/checkout-forms/checkout-contact-step.tsx +149 -0
  16. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-country-field.tsx → checkout-country-field.tsx} +10 -8
  17. package/src/blocks/e-commerce/checkout-forms/checkout-order-summary.tsx +230 -0
  18. package/src/blocks/e-commerce/checkout-forms/checkout-payment-step.tsx +100 -0
  19. package/src/blocks/e-commerce/checkout-forms/checkout-progress.tsx +62 -0
  20. package/src/blocks/e-commerce/checkout-forms/checkout-review-step.tsx +59 -0
  21. package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-shipping-step.tsx → checkout-shipping-step.tsx} +65 -41
  22. package/src/blocks/e-commerce/checkout-forms/checkout.test.tsx +478 -0
  23. package/src/blocks/e-commerce/checkout-forms/checkout.tsx +550 -0
  24. package/src/blocks/e-commerce/navigation/storefront-navbar-account-menu.test.tsx +56 -0
  25. package/src/blocks/{marketing/navbars/navbar-4-account-menu.tsx → e-commerce/navigation/storefront-navbar-account-menu.tsx} +3 -3
  26. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.test.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.test.tsx} +22 -22
  27. package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.tsx} +3 -3
  28. package/src/blocks/{marketing/navbars/navbar-4-mobile-nav.tsx → e-commerce/navigation/storefront-navbar-mobile-nav.tsx} +4 -12
  29. package/src/blocks/{marketing/navbars/navbar-4-search-bar.tsx → e-commerce/navigation/storefront-navbar-search-bar.tsx} +3 -3
  30. package/src/blocks/{marketing/navbars/navbar-4.test.tsx → e-commerce/navigation/storefront-navbar.test.tsx} +67 -36
  31. package/src/blocks/{marketing/navbars/navbar-4.tsx → e-commerce/navigation/storefront-navbar.tsx} +43 -25
  32. package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
  33. package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
  34. package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
  35. package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
  36. package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
  37. package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
  38. package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
  39. package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
  40. package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
  41. package/src/blocks/e-commerce/product-list/product-card.tsx +447 -0
  42. package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
  43. package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
  44. package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
  45. package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
  46. package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
  47. package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
  48. package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
  49. package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
  50. package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
  51. package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
  52. package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +119 -85
  53. package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
  54. package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
  55. package/src/blocks/marketing/features/feature.test.tsx +153 -0
  56. package/src/blocks/marketing/features/feature.tsx +195 -0
  57. package/src/blocks/marketing/footers/footer-section-accessibility.test.tsx +118 -0
  58. package/src/blocks/marketing/footers/footer-section-actions.test.tsx +131 -0
  59. package/src/blocks/marketing/footers/footer-section-data.test.tsx +127 -0
  60. package/src/blocks/marketing/footers/footer-section-native-links.test.tsx +100 -0
  61. package/src/blocks/marketing/footers/footer-section-public-api.test.tsx +11 -0
  62. package/src/blocks/marketing/footers/footer-section.test.tsx +46 -0
  63. package/src/blocks/marketing/footers/footer-section.tsx +532 -0
  64. package/src/blocks/marketing/navbars/navbar-public-api.test.tsx +57 -0
  65. package/src/blocks/marketing/navbars/navbar.mega.test.tsx +149 -0
  66. package/src/blocks/marketing/navbars/navbar.test.tsx +155 -0
  67. package/src/blocks/marketing/navbars/navbar.tsx +735 -0
  68. package/src/blocks/marketing/testimonials/testimonial-section.test.tsx +171 -0
  69. package/src/blocks/marketing/testimonials/testimonial-section.tsx +237 -0
  70. package/src/index.ts +156 -128
  71. package/src/lib/contracts-curated.test.ts +47 -29
  72. package/src/lib/contracts.test.ts +198 -2
  73. package/src/lib/contracts.ts +410 -16
  74. package/src/ui/button-group.tsx +1 -1
  75. package/src/ui/carousel.tsx +1 -1
  76. package/src/ui/dialog.tsx +1 -1
  77. package/src/ui/field.tsx +2 -2
  78. package/src/ui/input-group.tsx +3 -3
  79. package/src/ui/pagination.tsx +1 -1
  80. package/src/ui/sidebar.tsx +7 -7
  81. package/src/ui/toggle-group.tsx +1 -1
  82. package/tsconfig.build.json +12 -0
  83. package/tsup.config.ts +11 -7
  84. package/.turbo/turbo-build.log +0 -24
  85. package/.turbo/turbo-test.log +0 -47
  86. package/.turbo/turbo-type-check.log +0 -0
  87. package/dist/.tsbuildinfo +0 -1
  88. package/dist/contracts.d.mts +0 -1732
  89. package/dist/contracts.d.ts +0 -1732
  90. package/dist/contracts.js +0 -423
  91. package/dist/contracts.js.map +0 -1
  92. package/dist/contracts.mjs +0 -365
  93. package/dist/contracts.mjs.map +0 -1
  94. package/dist/index.d.mts +0 -3068
  95. package/dist/index.d.ts +0 -3068
  96. package/dist/index.js +0 -17834
  97. package/dist/index.js.map +0 -1
  98. package/dist/index.mjs +0 -17740
  99. package/dist/index.mjs.map +0 -1
  100. package/src/blocks/application/login-forms/login-page-1.tsx +0 -246
  101. package/src/blocks/application/login-forms/login-page-2.tsx +0 -207
  102. package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +0 -260
  103. package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +0 -222
  104. package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +0 -921
  105. package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +0 -57
  106. package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +0 -124
  107. package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +0 -170
  108. package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +0 -52
  109. package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +0 -57
  110. package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +0 -68
  111. package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +0 -298
  112. package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +0 -47
  113. package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +0 -9
  114. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +0 -96
  115. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +0 -252
  116. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +0 -188
  117. package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +0 -36
  118. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +0 -216
  119. package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +0 -338
  120. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +0 -130
  121. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +0 -175
  122. package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +0 -190
  123. package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +0 -312
  124. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +0 -91
  125. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +0 -110
  126. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +0 -137
  127. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +0 -68
  128. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +0 -79
  129. package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +0 -172
  130. package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +0 -392
  131. package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
  132. package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
  133. package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
  134. package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
  135. package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
  136. package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
  137. package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +0 -88
  138. package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -192
  139. package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
  140. package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
  141. package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
  142. package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
  143. package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
  144. package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
  145. package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
  146. package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
  147. package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
  148. package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
  149. package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
  150. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
  151. package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
  152. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
  153. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
  154. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
  155. package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
  156. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
  157. package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
  158. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
  159. package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -229
  160. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -72
  161. package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
  162. package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
  163. package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
  164. package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
  165. package/src/blocks/marketing/features/feature-1.tsx +0 -97
  166. package/src/blocks/marketing/features/feature-2.tsx +0 -82
  167. package/src/blocks/marketing/footers/footer-section-1.test.tsx +0 -117
  168. package/src/blocks/marketing/footers/footer-section-1.tsx +0 -178
  169. package/src/blocks/marketing/footers/footer-section-3.tsx +0 -192
  170. package/src/blocks/marketing/footers/footer-section-4.tsx +0 -355
  171. package/src/blocks/marketing/navbars/navbar-1.tsx +0 -267
  172. package/src/blocks/marketing/navbars/navbar-2.tsx +0 -351
  173. package/src/blocks/marketing/navbars/navbar-3.tsx +0 -303
  174. package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +0 -38
  175. package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +0 -177
  176. package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +0 -219
  177. package/src/blocks/marketing/testimonials/testimonial-1.tsx +0 -141
  178. package/src/blocks/marketing/testimonials/testimonial-2.tsx +0 -108
@@ -1,182 +0,0 @@
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
- })
@@ -1,370 +0,0 @@
1
- 'use client'
2
-
3
- import { useState } from 'react'
4
- import { Heart, Minus, Plus, Star } from 'lucide-react'
5
- import type { Image, Money, ProductOption, SelectedOption } from '@thorprovider/types'
6
- import { Button } from '../../../ui/button'
7
- import { cn } from '../../../lib/utils'
8
- import { PRODUCT_IMAGE_FALLBACK } from '../../../lib/contracts'
9
- import { formatMoney, isAvailableMoney } from '../../../lib/money'
10
- import { ReactionToggle } from '../../shared/reaction-toggle'
11
-
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
32
- }
33
-
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
44
- }
45
-
46
- export interface ProductOverview1Rating {
47
- value: number
48
- count: number
49
- }
50
-
51
- export interface ProductOverview1Props {
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
70
- sizesLabel?: string
71
- variantLabel?: string
72
- reviewsLabel?: string
73
- addToCartLabel?: string
74
- outOfStockLabel?: string
75
- unavailablePriceLabel?: string
76
- selectVariantLabel?: string
77
- quantityLabel?: string
78
- deliveryEstimateLabel?: string
79
- shippingLabel?: string
80
- removeFromWishlistLabel?: string
81
- addToWishlistLabel?: string
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]
114
- }
115
-
116
- export default function ProductOverview1({
117
- product,
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',
131
- reviewsLabel = 'Reseñas',
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',
139
- removeFromWishlistLabel = 'Quitar de favoritos',
140
- addToWishlistLabel = 'Agregar a favoritos',
141
- imageFallback = PRODUCT_IMAGE_FALLBACK,
142
- }: ProductOverview1Props) {
143
- const [selectedImage, setSelectedImage] = useState(0)
144
-
145
- const selectedVariant =
146
- selectedVariantId != null
147
- ? product.variants.find(variant => variant.id === selectedVariantId) ?? null
148
- : null
149
-
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)
157
-
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)]
161
-
162
- const toggleWishlist = () => {
163
- if (!selectedVariant || !onToggleWishlist) return
164
- onToggleWishlist(selectedVariant.id, !isWishlisted)
165
- }
166
-
167
- return (
168
- <div>
169
- <section className='@container mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
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'>
171
- <div className='flex flex-col justify-between gap-6 lg:gap-8'>
172
- <div className='flex flex-col gap-2 lg:gap-4'>
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}
180
-
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
- )}
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}
218
- </div>
219
-
220
- <div className='row-span-2 row-start-1 lg:col-start-2'>
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>
228
- </div>
229
-
230
- <div className='flex flex-col gap-6 lg:gap-10'>
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'>
290
- <Button
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}
298
- >
299
- <Minus />
300
- </Button>
301
- <span className='w-10 text-center text-sm tabular-nums'>{quantityValue}</span>
302
- <Button
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>
313
- </div>
314
- ) : null}
315
-
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}
332
-
333
- <div className='flex gap-4'>
334
- <Button
335
- className='h-10 flex-1 cursor-pointer rounded-full px-8'
336
- size='lg'
337
- disabled={!canPurchase}
338
- onClick={
339
- onAddToCart && addPayload
340
- ? () => onAddToCart(addPayload.variantId, addPayload.quantity)
341
- : undefined
342
- }
343
- >
344
- {addToCartLabel}
345
- </Button>
346
- <Button
347
- variant='outline'
348
- size='icon'
349
- className='size-9 cursor-pointer rounded-full'
350
- disabled={!selectedVariant}
351
- onClick={onToggleWishlist ? toggleWishlist : undefined}
352
- aria-pressed={isWishlisted}
353
- aria-label={isWishlisted ? removeFromWishlistLabel : addToWishlistLabel}
354
- >
355
- <ReactionToggle
356
- active={isWishlisted}
357
- icon={Heart}
358
- iconClassName='size-5'
359
- activeColorClassName='text-primary'
360
- />
361
- </Button>
362
- </div>
363
- </div>
364
- </div>
365
- </section>
366
- </div>
367
- )
368
- }
369
-
370
- export { variantLabelOf, galleryImages }