@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
@@ -0,0 +1,1186 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { Camera, Check, Heart, Info, MessageSquare, Minus, Package, Plus, Share2, ShoppingCart, Star, Truck } from 'lucide-react'
5
+ import type { Image, Money, ProductOption, SelectedOption } from '@thorprovider/types'
6
+ import { Button } from '../../../ui/button'
7
+ import { Card, CardContent } from '../../../ui/card'
8
+ import { Separator } from '../../../ui/separator'
9
+ import { cn } from '../../../lib/utils'
10
+ import { PRODUCT_IMAGE_FALLBACK } from '../../../lib/contracts'
11
+ import { formatMoney, isAvailableMoney } from '../../../lib/money'
12
+ import { ReactionToggle } from '../../shared/reaction-toggle'
13
+ import { Carousel, CarouselContent, CarouselItem, type CarouselApi } from '../../../ui/carousel'
14
+ import { ProductOverviewGallery } from './product-overview-gallery'
15
+ import { ProductOverviewLightboxTab } from './product-overview-lightbox-tab'
16
+ import { ProductOverviewReviewsTab, type ProductOverviewReview } from './product-overview-reviews-tab'
17
+ import {
18
+ ProductOverviewTabs,
19
+ ProductOverviewTabsContentFade,
20
+ ProductOverviewTabsIndicator,
21
+ ProductOverviewTabsList,
22
+ ProductOverviewTabsTrigger,
23
+ } from './product-overview-tabs'
24
+
25
+ /**
26
+ * Canonical product-detail projection.
27
+ *
28
+ * The block consumes the canonical `@thorprovider/types` shapes (`Money`,
29
+ * `ProductOption`, `SelectedOption`, `Image`) so adapter output maps without a
30
+ * second, drifting product type. The only deviation is `price`: the canon marks
31
+ * it required, but a missing/unresolved backend price is a real, distinct state
32
+ * that must disable purchase — so the projection allows `price` to be absent
33
+ * and the block treats absence as "unavailable", never as zero.
34
+ */
35
+ export interface ProductDetailVariant {
36
+ id: string
37
+ title: string
38
+ sku?: string
39
+ availableForSale: boolean
40
+ selectedOptions: SelectedOption[]
41
+ /** Canonical Money; omit/null when the backend has no resolved price. */
42
+ price?: Money | null
43
+ compareAtPrice?: Money
44
+ image?: Image
45
+ }
46
+
47
+ export interface ProductDetailProduct {
48
+ id: string
49
+ handle?: string
50
+ title: string
51
+ description?: string
52
+ vendor?: string
53
+ options: ProductOption[]
54
+ variants: ProductDetailVariant[]
55
+ images: Image[]
56
+ featuredImage?: Image
57
+ }
58
+
59
+ export interface ProductOverviewRating {
60
+ value: number
61
+ count: number
62
+ }
63
+
64
+ /**
65
+ * The bounded presentation surface. `standard` is the straightforward detail
66
+ * layout, `gallery` is image-forward, and `rich` adds tabbed overview/specs/
67
+ * reviews/gallery. The value changes structure, never the data contract.
68
+ */
69
+ export type ProductOverviewPresentation = 'standard' | 'gallery' | 'rich'
70
+
71
+ export interface ProductOverviewProps {
72
+ product: ProductDetailProduct
73
+ /** Bounded presentation option; defaults to `standard`. */
74
+ presentation?: ProductOverviewPresentation
75
+ /** Controlled selection: the backend variant id, or null/undefined when nothing is explicitly selected. */
76
+ selectedVariantId?: string | null
77
+ onSelectVariant?: (variantId: string) => void
78
+ /** Controlled quantity for the add-to-cart action; defaults to 1 when omitted. */
79
+ quantity?: number
80
+ onQuantityChange?: (quantity: number) => void
81
+ /** Add-to-cart identity is the selected backend variant id, with the requested quantity. */
82
+ onAddToCart?: (variantId: string, quantity: number) => void
83
+ isWishlisted?: boolean
84
+ onToggleWishlist?: (variantId: string, isWishlisted: boolean) => void
85
+ /** Optional share action (used by the `rich` presentation). */
86
+ onShare?: () => void
87
+ /** Rendered only when the backend supplies it. */
88
+ rating?: ProductOverviewRating
89
+ /** Rendered only when the backend supplies it. */
90
+ deliveryEstimate?: string
91
+ /** Rendered only when the backend supplies it. */
92
+ shipping?: Money
93
+ formatPrice?: (money: Money) => string
94
+ /** `rich`-only optional data. Omitted sections render no chrome. */
95
+ specifications?: Record<string, string>
96
+ /** `rich`-only optional data. Omitted sections render no chrome. */
97
+ features?: string[]
98
+ /** `rich`-only optional data. Omitted sections render no chrome. */
99
+ reviews?: ProductOverviewReview[]
100
+ sizesLabel?: string
101
+ variantLabel?: string
102
+ reviewsLabel?: string
103
+ addToCartLabel?: string
104
+ outOfStockLabel?: string
105
+ unavailablePriceLabel?: string
106
+ selectVariantLabel?: string
107
+ quantityLabel?: string
108
+ deliveryEstimateLabel?: string
109
+ shippingLabel?: string
110
+ removeFromWishlistLabel?: string
111
+ addToWishlistLabel?: string
112
+ imageFallback?: string
113
+ /** `gallery`/`rich` labels. */
114
+ badgeLabel?: string
115
+ availabilityLabel?: string
116
+ skuLabel?: string
117
+ overviewLabel?: string
118
+ specsLabel?: string
119
+ featuresLabel?: string
120
+ specificationsLabel?: string
121
+ galleryLabel?: string
122
+ photosLabel?: string
123
+ saveLabel?: string
124
+ savedLabel?: string
125
+ shareLabel?: string
126
+ basedOnReviewsLabel?: string
127
+ foundHelpfulLabel?: string
128
+ ofCounterLabel?: string
129
+ freeDeliveryLabel?: string
130
+ }
131
+
132
+ export function variantLabelOf(variant: ProductDetailVariant): string {
133
+ if (variant.title.trim() !== '') return variant.title
134
+ return variant.selectedOptions.map(option => option.value).join(' / ')
135
+ }
136
+
137
+ /**
138
+ * The add-to-cart payload contract: the explicitly selected backend variant id
139
+ * and a positive quantity (defaults to 1). Returns `null` when no variant has
140
+ * been selected — the block never substitutes the first variant.
141
+ */
142
+ export function resolveAddToCartPayload(
143
+ variant: ProductDetailVariant | null,
144
+ quantity: number | undefined,
145
+ ): { variantId: string; quantity: number } | null {
146
+ if (!variant) return null
147
+ return {
148
+ variantId: variant.id,
149
+ quantity: typeof quantity === 'number' && quantity > 0 ? quantity : 1,
150
+ }
151
+ }
152
+
153
+ export function galleryImages(product: ProductDetailProduct, fallback: string): string[] {
154
+ const images = product.images
155
+ .map(image => image.url)
156
+ .filter((url): url is string => typeof url === 'string' && url.trim() !== '')
157
+ if (images.length > 0) return images
158
+ const featured = product.featuredImage?.url
159
+ if (typeof featured === 'string' && featured.trim() !== '') return [featured]
160
+ return [fallback]
161
+ }
162
+
163
+ interface ResolvedSelection {
164
+ selectedVariant: ProductDetailVariant | null
165
+ selectedPrice: Money | null | undefined
166
+ selectedCompareAt: Money | undefined
167
+ priceAvailable: boolean
168
+ compareAtAvailable: boolean
169
+ canPurchase: boolean
170
+ quantityValue: number
171
+ addPayload: { variantId: string; quantity: number } | null
172
+ }
173
+
174
+ export default function ProductOverview({
175
+ product,
176
+ presentation = 'standard',
177
+ selectedVariantId,
178
+ onSelectVariant,
179
+ quantity,
180
+ onQuantityChange,
181
+ onAddToCart,
182
+ isWishlisted = false,
183
+ onToggleWishlist,
184
+ onShare,
185
+ rating,
186
+ deliveryEstimate,
187
+ shipping,
188
+ formatPrice,
189
+ specifications,
190
+ features,
191
+ reviews,
192
+ sizesLabel = 'Variantes',
193
+ variantLabel = 'Variante',
194
+ reviewsLabel = 'Reseñas',
195
+ addToCartLabel = 'Agregar al carrito',
196
+ outOfStockLabel = 'Agotado',
197
+ unavailablePriceLabel = 'Precio no disponible',
198
+ selectVariantLabel = 'Selecciona una variante',
199
+ quantityLabel = 'Cantidad',
200
+ deliveryEstimateLabel = 'Entrega estimada',
201
+ shippingLabel = 'Envío',
202
+ removeFromWishlistLabel = 'Quitar de favoritos',
203
+ addToWishlistLabel = 'Agregar a favoritos',
204
+ imageFallback = PRODUCT_IMAGE_FALLBACK,
205
+ badgeLabel,
206
+ availabilityLabel = 'Disponible',
207
+ skuLabel = 'SKU',
208
+ overviewLabel = 'Resumen',
209
+ specsLabel = 'Especificaciones',
210
+ featuresLabel = 'Características clave',
211
+ specificationsLabel = 'Especificaciones técnicas',
212
+ galleryLabel = 'Galería',
213
+ photosLabel = 'fotos',
214
+ saveLabel = 'Guardar',
215
+ savedLabel = 'Guardado',
216
+ shareLabel = 'Compartir',
217
+ basedOnReviewsLabel = 'Basado en {count} reseñas',
218
+ foundHelpfulLabel = '{count} personas encontraron esto útil',
219
+ ofCounterLabel = '{current} de {total}',
220
+ freeDeliveryLabel,
221
+ }: ProductOverviewProps) {
222
+ const [selectedImage, setSelectedImage] = useState(0)
223
+ const [carouselApi, setCarouselApi] = useState<CarouselApi>()
224
+
225
+ useEffect(() => {
226
+ if (!carouselApi) return
227
+ carouselApi.scrollTo(selectedImage)
228
+ const handleSelect = () => setSelectedImage(carouselApi.selectedScrollSnap())
229
+ carouselApi.on('select', handleSelect)
230
+ return () => {
231
+ carouselApi.off('select', handleSelect)
232
+ }
233
+ }, [carouselApi, selectedImage])
234
+
235
+ const selectedVariant =
236
+ selectedVariantId != null
237
+ ? product.variants.find(variant => variant.id === selectedVariantId) ?? null
238
+ : null
239
+
240
+ const selectedPrice = selectedVariant?.price
241
+ const selectedCompareAt = selectedVariant?.compareAtPrice
242
+ const priceAvailable = isAvailableMoney(selectedPrice)
243
+ const compareAtAvailable = isAvailableMoney(selectedCompareAt)
244
+ const canPurchase = Boolean(selectedVariant?.availableForSale) && priceAvailable
245
+ const quantityValue = quantity ?? 1
246
+ const addPayload = resolveAddToCartPayload(selectedVariant, quantityValue)
247
+
248
+ const images = galleryImages(product, imageFallback)
249
+ const hasRealImages = images[0] !== imageFallback || product.featuredImage != null
250
+ const activeImage = images[Math.min(selectedImage, images.length - 1)]
251
+
252
+ const selection: ResolvedSelection = {
253
+ selectedVariant,
254
+ selectedPrice,
255
+ selectedCompareAt,
256
+ priceAvailable,
257
+ compareAtAvailable,
258
+ canPurchase,
259
+ quantityValue,
260
+ addPayload,
261
+ }
262
+
263
+ const handleAddToCart = () => {
264
+ if (onAddToCart && addPayload) onAddToCart(addPayload.variantId, addPayload.quantity)
265
+ }
266
+ const handleToggleWishlist = () => {
267
+ if (!selectedVariant || !onToggleWishlist) return
268
+ onToggleWishlist(selectedVariant.id, !isWishlisted)
269
+ }
270
+
271
+ if (presentation === 'gallery') {
272
+ return (
273
+ <GalleryPresentation
274
+ product={product}
275
+ selection={selection}
276
+ images={images}
277
+ activeImage={activeImage}
278
+ selectedImage={selectedImage}
279
+ onSelectImage={setSelectedImage}
280
+ setCarouselApi={setCarouselApi}
281
+ onSelectVariant={onSelectVariant}
282
+ onQuantityChange={onQuantityChange}
283
+ onAddToCart={handleAddToCart}
284
+ isWishlisted={isWishlisted}
285
+ onToggleWishlist={onToggleWishlist ? handleToggleWishlist : undefined}
286
+ rating={rating}
287
+ formatPrice={formatPrice}
288
+ labels={{
289
+ reviewsLabel,
290
+ addToCartLabel,
291
+ outOfStockLabel,
292
+ unavailablePriceLabel,
293
+ selectVariantLabel,
294
+ quantityLabel,
295
+ removeFromWishlistLabel,
296
+ addToWishlistLabel,
297
+ freeDeliveryLabel,
298
+ }}
299
+ />
300
+ )
301
+ }
302
+
303
+ if (presentation === 'rich') {
304
+ return (
305
+ <RichPresentation
306
+ product={product}
307
+ selection={selection}
308
+ images={images}
309
+ selectedImage={selectedImage}
310
+ onSelectImage={setSelectedImage}
311
+ onSelectVariant={onSelectVariant}
312
+ onQuantityChange={onQuantityChange}
313
+ onAddToCart={handleAddToCart}
314
+ isWishlisted={isWishlisted}
315
+ onToggleWishlist={onToggleWishlist ? handleToggleWishlist : undefined}
316
+ onShare={onShare}
317
+ rating={rating}
318
+ formatPrice={formatPrice}
319
+ specifications={specifications}
320
+ features={features}
321
+ reviews={reviews}
322
+ labels={{
323
+ reviewsLabel,
324
+ addToCartLabel,
325
+ outOfStockLabel,
326
+ unavailablePriceLabel,
327
+ selectVariantLabel,
328
+ quantityLabel,
329
+ removeFromWishlistLabel,
330
+ addToWishlistLabel,
331
+ badgeLabel,
332
+ availabilityLabel,
333
+ skuLabel,
334
+ overviewLabel,
335
+ specsLabel,
336
+ featuresLabel,
337
+ specificationsLabel,
338
+ galleryLabel,
339
+ photosLabel,
340
+ saveLabel,
341
+ savedLabel,
342
+ shareLabel,
343
+ basedOnReviewsLabel,
344
+ foundHelpfulLabel,
345
+ ofCounterLabel,
346
+ }}
347
+ />
348
+ )
349
+ }
350
+
351
+ return (
352
+ <StandardPresentation
353
+ product={product}
354
+ selection={selection}
355
+ images={images}
356
+ activeImage={activeImage}
357
+ selectedImage={selectedImage}
358
+ onSelectImage={setSelectedImage}
359
+ onSelectVariant={onSelectVariant}
360
+ onQuantityChange={onQuantityChange}
361
+ onAddToCart={handleAddToCart}
362
+ isWishlisted={isWishlisted}
363
+ onToggleWishlist={onToggleWishlist ? handleToggleWishlist : undefined}
364
+ rating={rating}
365
+ deliveryEstimate={deliveryEstimate}
366
+ shipping={shipping}
367
+ formatPrice={formatPrice}
368
+ hasRealImages={hasRealImages}
369
+ labels={{
370
+ sizesLabel,
371
+ variantLabel,
372
+ reviewsLabel,
373
+ addToCartLabel,
374
+ outOfStockLabel,
375
+ unavailablePriceLabel,
376
+ selectVariantLabel,
377
+ quantityLabel,
378
+ deliveryEstimateLabel,
379
+ shippingLabel,
380
+ removeFromWishlistLabel,
381
+ addToWishlistLabel,
382
+ }}
383
+ />
384
+ )
385
+ }
386
+
387
+ interface SharedLabels {
388
+ reviewsLabel: string
389
+ addToCartLabel: string
390
+ outOfStockLabel: string
391
+ unavailablePriceLabel: string
392
+ selectVariantLabel: string
393
+ quantityLabel: string
394
+ removeFromWishlistLabel: string
395
+ addToWishlistLabel: string
396
+ }
397
+
398
+ function VariantSelector({
399
+ product,
400
+ selectedVariantId,
401
+ onSelectVariant,
402
+ sizesLabel,
403
+ variantLabel,
404
+ outOfStockLabel,
405
+ }: {
406
+ product: ProductDetailProduct
407
+ selectedVariantId?: string | null
408
+ onSelectVariant?: (variantId: string) => void
409
+ sizesLabel: string
410
+ variantLabel: string
411
+ outOfStockLabel: string
412
+ }) {
413
+ if (product.variants.length === 0) return null
414
+ return (
415
+ <div className='flex flex-col gap-2'>
416
+ <h3 className='font-bold'>{sizesLabel}</h3>
417
+ <div className='flex flex-wrap gap-3'>
418
+ {product.variants.map(variant => {
419
+ const isSelected = variant.id === selectedVariantId
420
+ return (
421
+ <Button
422
+ key={variant.id}
423
+ variant={isSelected ? 'default' : 'outline'}
424
+ onClick={onSelectVariant ? () => onSelectVariant(variant.id) : undefined}
425
+ disabled={!variant.availableForSale}
426
+ aria-pressed={isSelected}
427
+ className='h-9 cursor-pointer rounded-full px-4'
428
+ >
429
+ {variantLabelOf(variant)}
430
+ {!variant.availableForSale ? (
431
+ <span className='text-muted-foreground ms-2 text-xs font-normal'>{outOfStockLabel}</span>
432
+ ) : null}
433
+ </Button>
434
+ )
435
+ })}
436
+ </div>
437
+ {variantLabel ? (
438
+ <span className='sr-only'>{variantLabel}</span>
439
+ ) : null}
440
+ </div>
441
+ )
442
+ }
443
+
444
+ function QuantityStepper({
445
+ quantityValue,
446
+ quantityLabel,
447
+ onQuantityChange,
448
+ }: {
449
+ quantityValue: number
450
+ quantityLabel: string
451
+ onQuantityChange: (quantity: number) => void
452
+ }) {
453
+ return (
454
+ <div className='flex flex-col gap-2'>
455
+ <h3 className='font-bold'>{quantityLabel}</h3>
456
+ <div className='border-border flex w-fit items-center rounded-md border'>
457
+ <Button
458
+ type='button'
459
+ variant='ghost'
460
+ size='icon'
461
+ className='size-9 rounded-r-none'
462
+ aria-label={`${quantityLabel} -1`}
463
+ onClick={() => onQuantityChange(Math.max(1, quantityValue - 1))}
464
+ disabled={quantityValue <= 1}
465
+ >
466
+ <Minus />
467
+ </Button>
468
+ <span className='w-10 text-center text-sm tabular-nums'>{quantityValue}</span>
469
+ <Button
470
+ type='button'
471
+ variant='ghost'
472
+ size='icon'
473
+ className='size-9 rounded-l-none'
474
+ aria-label={`${quantityLabel} +1`}
475
+ onClick={() => onQuantityChange(quantityValue + 1)}
476
+ >
477
+ <Plus />
478
+ </Button>
479
+ </div>
480
+ </div>
481
+ )
482
+ }
483
+
484
+ function WishlistButton({
485
+ isWishlisted,
486
+ disabled,
487
+ onToggleWishlist,
488
+ removeFromWishlistLabel,
489
+ addToWishlistLabel,
490
+ className,
491
+ }: {
492
+ isWishlisted: boolean
493
+ disabled: boolean
494
+ onToggleWishlist?: () => void
495
+ removeFromWishlistLabel: string
496
+ addToWishlistLabel: string
497
+ className?: string
498
+ }) {
499
+ // Purpose-gated: no wishlist handler means no wishlist affordance. A visible
500
+ // button that does nothing is a dead control, so it is not rendered at all.
501
+ if (!onToggleWishlist) return null
502
+ return (
503
+ <Button
504
+ variant='outline'
505
+ size='icon'
506
+ className={cn('cursor-pointer', className)}
507
+ disabled={disabled}
508
+ onClick={onToggleWishlist}
509
+ aria-pressed={isWishlisted}
510
+ aria-label={isWishlisted ? removeFromWishlistLabel : addToWishlistLabel}
511
+ >
512
+ <ReactionToggle
513
+ active={isWishlisted}
514
+ icon={Heart}
515
+ iconClassName='size-5'
516
+ activeColorClassName='text-primary'
517
+ />
518
+ </Button>
519
+ )
520
+ }
521
+
522
+ function StandardPresentation({
523
+ product,
524
+ selection,
525
+ images,
526
+ activeImage,
527
+ selectedImage,
528
+ onSelectImage,
529
+ onSelectVariant,
530
+ onQuantityChange,
531
+ onAddToCart,
532
+ isWishlisted,
533
+ onToggleWishlist,
534
+ rating,
535
+ deliveryEstimate,
536
+ shipping,
537
+ formatPrice,
538
+ hasRealImages,
539
+ labels,
540
+ }: {
541
+ product: ProductDetailProduct
542
+ selection: ResolvedSelection
543
+ images: string[]
544
+ activeImage: string
545
+ selectedImage: number
546
+ onSelectImage: (index: number) => void
547
+ onSelectVariant?: (variantId: string) => void
548
+ onQuantityChange?: (quantity: number) => void
549
+ onAddToCart: () => void
550
+ isWishlisted: boolean
551
+ onToggleWishlist?: () => void
552
+ rating?: ProductOverviewRating
553
+ deliveryEstimate?: string
554
+ shipping?: Money
555
+ formatPrice?: (money: Money) => string
556
+ hasRealImages: boolean
557
+ labels: SharedLabels & {
558
+ sizesLabel: string
559
+ variantLabel: string
560
+ deliveryEstimateLabel: string
561
+ shippingLabel: string
562
+ }
563
+ }) {
564
+ const { selectedVariant, selectedPrice, selectedCompareAt, priceAvailable, compareAtAvailable, canPurchase, quantityValue } =
565
+ selection
566
+
567
+ return (
568
+ <div>
569
+ <section className='@container mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
570
+ <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'>
571
+ <div className='flex flex-col justify-between gap-6 lg:gap-8'>
572
+ <div className='flex flex-col gap-2 lg:gap-4'>
573
+ {product.vendor ? (
574
+ <span className='text-sm font-semibold tracking-wide uppercase'>{product.vendor}</span>
575
+ ) : null}
576
+ <h2 className='text-xl font-bold tracking-tight text-balance lg:text-3xl'>{product.title}</h2>
577
+ {product.description ? (
578
+ <p className='text-muted-foreground text-balance'>{product.description}</p>
579
+ ) : null}
580
+
581
+ {selectedVariant ? (
582
+ priceAvailable ? (
583
+ <p className='text-2xl font-bold tracking-tight'>
584
+ {formatMoney(selectedPrice, formatPrice)}
585
+ {compareAtAvailable ? (
586
+ <span className='text-muted-foreground ms-2 text-base font-normal line-through'>
587
+ {formatMoney(selectedCompareAt, formatPrice)}
588
+ </span>
589
+ ) : null}
590
+ </p>
591
+ ) : (
592
+ <p className='text-muted-foreground text-sm'>{labels.unavailablePriceLabel}</p>
593
+ )
594
+ ) : (
595
+ <p className='text-muted-foreground text-sm'>{labels.selectVariantLabel}</p>
596
+ )}
597
+ </div>
598
+
599
+ {hasRealImages ? (
600
+ <div className='flex flex-wrap gap-4'>
601
+ {images.map((url, index) => (
602
+ <button
603
+ key={`${product.id}:image:${index}`}
604
+ type='button'
605
+ onClick={() => onSelectImage(index)}
606
+ aria-label={`${product.title} ${index + 1}`}
607
+ aria-pressed={selectedImage === index}
608
+ className={cn(
609
+ 'ring-offset-background size-16 cursor-pointer overflow-hidden rounded-sm ring-offset-2 transition-all lg:size-18',
610
+ selectedImage === index && 'ring-foreground ring-2',
611
+ )}
612
+ >
613
+ <img src={url || undefined} alt='' className='size-full object-cover' />
614
+ </button>
615
+ ))}
616
+ </div>
617
+ ) : null}
618
+ </div>
619
+
620
+ <div className='row-span-2 row-start-1 lg:col-start-2'>
621
+ <div className='bg-muted aspect-square w-full overflow-hidden rounded-lg'>
622
+ <img src={activeImage || undefined} alt={product.title} className='h-90 w-full object-cover' />
623
+ </div>
624
+ </div>
625
+
626
+ <div className='flex flex-col gap-6 lg:gap-10'>
627
+ <VariantSelector
628
+ product={product}
629
+ selectedVariantId={selectedVariant?.id}
630
+ onSelectVariant={onSelectVariant}
631
+ sizesLabel={labels.sizesLabel}
632
+ variantLabel={labels.variantLabel}
633
+ outOfStockLabel={labels.outOfStockLabel}
634
+ />
635
+
636
+ {selectedVariant && selectedVariant.selectedOptions.length > 0 ? (
637
+ <p className='text-muted-foreground text-sm'>
638
+ {selectedVariant.selectedOptions
639
+ .map((option: SelectedOption) => `${option.name}: ${option.value}`)
640
+ .join(' · ')}
641
+ </p>
642
+ ) : null}
643
+
644
+ {rating ? (
645
+ <div className='flex flex-col gap-2'>
646
+ <h3 className='font-bold'>{labels.reviewsLabel}</h3>
647
+ <div className='flex items-center gap-2'>
648
+ <div className='flex gap-1' aria-hidden='true'>
649
+ {[0, 1, 2, 3, 4].map(index => (
650
+ <Star
651
+ key={index}
652
+ className='text-foreground size-5'
653
+ fill={index < rating.value ? 'currentColor' : 'none'}
654
+ />
655
+ ))}
656
+ </div>
657
+ <span className='text-muted-foreground text-sm tabular-nums'>
658
+ {`${rating.value} (${rating.count})`}
659
+ </span>
660
+ </div>
661
+ </div>
662
+ ) : null}
663
+
664
+ {onQuantityChange ? (
665
+ <QuantityStepper
666
+ quantityValue={quantityValue}
667
+ quantityLabel={labels.quantityLabel}
668
+ onQuantityChange={onQuantityChange}
669
+ />
670
+ ) : null}
671
+
672
+ {deliveryEstimate || shipping ? (
673
+ <dl className='text-muted-foreground flex flex-col gap-1 text-sm'>
674
+ {deliveryEstimate ? (
675
+ <div className='flex gap-2'>
676
+ <dt>{labels.deliveryEstimateLabel}</dt>
677
+ <dd className='text-foreground'>{deliveryEstimate}</dd>
678
+ </div>
679
+ ) : null}
680
+ {isAvailableMoney(shipping) ? (
681
+ <div className='flex gap-2'>
682
+ <dt>{labels.shippingLabel}</dt>
683
+ <dd className='text-foreground'>{formatMoney(shipping, formatPrice)}</dd>
684
+ </div>
685
+ ) : null}
686
+ </dl>
687
+ ) : null}
688
+
689
+ <div className='flex gap-4'>
690
+ <Button
691
+ className='h-10 flex-1 cursor-pointer rounded-full px-8'
692
+ size='lg'
693
+ disabled={!canPurchase}
694
+ onClick={onAddToCart}
695
+ >
696
+ {labels.addToCartLabel}
697
+ </Button>
698
+ <WishlistButton
699
+ isWishlisted={isWishlisted}
700
+ disabled={!selectedVariant}
701
+ onToggleWishlist={onToggleWishlist}
702
+ removeFromWishlistLabel={labels.removeFromWishlistLabel}
703
+ addToWishlistLabel={labels.addToWishlistLabel}
704
+ className='size-9 rounded-full'
705
+ />
706
+ </div>
707
+ </div>
708
+ </div>
709
+ </section>
710
+ </div>
711
+ )
712
+ }
713
+
714
+ function GalleryPresentation({
715
+ product,
716
+ selection,
717
+ images,
718
+ activeImage,
719
+ selectedImage,
720
+ onSelectImage,
721
+ setCarouselApi,
722
+ onSelectVariant,
723
+ onQuantityChange,
724
+ onAddToCart,
725
+ isWishlisted,
726
+ onToggleWishlist,
727
+ rating,
728
+ formatPrice,
729
+ labels,
730
+ }: {
731
+ product: ProductDetailProduct
732
+ selection: ResolvedSelection
733
+ images: string[]
734
+ activeImage: string
735
+ selectedImage: number
736
+ onSelectImage: (index: number) => void
737
+ setCarouselApi: (api: CarouselApi | undefined) => void
738
+ onSelectVariant?: (variantId: string) => void
739
+ onQuantityChange?: (quantity: number) => void
740
+ onAddToCart: () => void
741
+ isWishlisted: boolean
742
+ onToggleWishlist?: () => void
743
+ rating?: ProductOverviewRating
744
+ formatPrice?: (money: Money) => string
745
+ labels: SharedLabels & { freeDeliveryLabel?: string }
746
+ }) {
747
+ const { selectedVariant, selectedPrice, selectedCompareAt, priceAvailable, compareAtAvailable, canPurchase, quantityValue } =
748
+ selection
749
+
750
+ return (
751
+ <section className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
752
+ <div className='grid grid-cols-1 gap-8 py-4 md:py-6 lg:grid-cols-2 lg:py-12'>
753
+ <div className='flex flex-col gap-4'>
754
+ <Carousel setApi={setCarouselApi} className='w-full'>
755
+ <CarouselContent>
756
+ {images.map((url, index) => (
757
+ <CarouselItem key={`${product.id}:carousel:${index}`}>
758
+ <div className='bg-muted flex h-130 w-full items-center justify-center overflow-hidden rounded-lg'>
759
+ <img src={url || undefined} alt={`${product.title} ${index + 1}`} className='h-full w-full object-cover' />
760
+ </div>
761
+ </CarouselItem>
762
+ ))}
763
+ </CarouselContent>
764
+ </Carousel>
765
+
766
+ <div className='flex flex-wrap gap-4'>
767
+ {images.map((url, index) => (
768
+ <button
769
+ type='button'
770
+ key={`${product.id}:thumb:${index}`}
771
+ onClick={() => onSelectImage(index)}
772
+ aria-label={`Mostrar imagen ${index + 1} de ${product.title}`}
773
+ aria-pressed={selectedImage === index}
774
+ className='aspect-square size-16 cursor-pointer'
775
+ >
776
+ <img
777
+ src={url || undefined}
778
+ alt={`${product.title} ${index + 1}`}
779
+ className={cn(
780
+ 'size-full cursor-pointer rounded-md object-cover ring-offset-2 transition-all',
781
+ selectedImage === index ? 'ring-foreground ring-2' : '',
782
+ )}
783
+ />
784
+ </button>
785
+ ))}
786
+ </div>
787
+ </div>
788
+
789
+ <div className='flex flex-col gap-6'>
790
+ <header className='flex flex-col gap-4'>
791
+ <div className='flex items-center'>
792
+ <Button variant='ghost' size='icon' className='bg-primary size-6 scale-75 cursor-pointer rounded-full'>
793
+ <Check className='text-primary-foreground size-4' />
794
+ </Button>
795
+ {product.vendor ? <span className='ms-2 font-medium'>{product.vendor}</span> : null}
796
+ {selectedVariant?.sku ? (
797
+ <span className='text-muted-foreground ms-auto'>{selectedVariant.sku}</span>
798
+ ) : null}
799
+ </div>
800
+ <h1 className='text-xl font-bold text-balance xl:text-3xl'>{product.title}</h1>
801
+
802
+ {rating ? (
803
+ <div className='flex items-center gap-2'>
804
+ <div className='flex'>
805
+ {[0, 1, 2, 3, 4].map(i => (
806
+ <Star
807
+ key={i}
808
+ className={cn(
809
+ 'size-5',
810
+ i < rating.value ? 'fill-foreground text-foreground' : 'text-muted-foreground',
811
+ )}
812
+ />
813
+ ))}
814
+ </div>
815
+ <span className='text-muted-foreground text-sm'>
816
+ {rating.count} {labels.reviewsLabel}
817
+ </span>
818
+ </div>
819
+ ) : null}
820
+ </header>
821
+
822
+ {selectedVariant ? (
823
+ priceAvailable ? (
824
+ <p className='text-4xl font-bold'>
825
+ {formatMoney(selectedPrice, formatPrice)}
826
+ {compareAtAvailable ? (
827
+ <span className='text-muted-foreground ms-2 text-xl font-normal line-through'>
828
+ {formatMoney(selectedCompareAt, formatPrice)}
829
+ </span>
830
+ ) : null}
831
+ </p>
832
+ ) : (
833
+ <p className='text-muted-foreground text-sm'>{labels.unavailablePriceLabel}</p>
834
+ )
835
+ ) : (
836
+ <p className='text-muted-foreground text-sm'>{labels.selectVariantLabel}</p>
837
+ )}
838
+
839
+ <VariantSelector
840
+ product={product}
841
+ selectedVariantId={selectedVariant?.id}
842
+ onSelectVariant={onSelectVariant}
843
+ sizesLabel={labels.selectVariantLabel}
844
+ variantLabel=''
845
+ outOfStockLabel={labels.outOfStockLabel}
846
+ />
847
+
848
+ {onQuantityChange ? (
849
+ <QuantityStepper
850
+ quantityValue={quantityValue}
851
+ quantityLabel={labels.quantityLabel}
852
+ onQuantityChange={onQuantityChange}
853
+ />
854
+ ) : null}
855
+
856
+ <div className='flex gap-4'>
857
+ <Button className='h-12 flex-1 cursor-pointer rounded-md' size='lg' disabled={!canPurchase} onClick={onAddToCart}>
858
+ <ShoppingCart />
859
+ {labels.addToCartLabel}
860
+ </Button>
861
+ <WishlistButton
862
+ isWishlisted={isWishlisted}
863
+ disabled={!selectedVariant}
864
+ onToggleWishlist={onToggleWishlist}
865
+ removeFromWishlistLabel={labels.removeFromWishlistLabel}
866
+ addToWishlistLabel={labels.addToWishlistLabel}
867
+ className='border-input size-12 rounded-md'
868
+ />
869
+ </div>
870
+
871
+ {labels.freeDeliveryLabel ? (
872
+ <div className='text-muted-foreground flex items-center justify-center gap-2 text-sm'>
873
+ <Truck className='size-4' />
874
+ <span>{labels.freeDeliveryLabel}</span>
875
+ </div>
876
+ ) : null}
877
+ </div>
878
+ </div>
879
+ </section>
880
+ )
881
+ }
882
+
883
+ function RichPresentation({
884
+ product,
885
+ selection,
886
+ images,
887
+ selectedImage,
888
+ onSelectImage,
889
+ onSelectVariant,
890
+ onQuantityChange,
891
+ onAddToCart,
892
+ isWishlisted,
893
+ onToggleWishlist,
894
+ onShare,
895
+ rating,
896
+ formatPrice,
897
+ specifications,
898
+ features,
899
+ reviews,
900
+ labels,
901
+ }: {
902
+ product: ProductDetailProduct
903
+ selection: ResolvedSelection
904
+ images: string[]
905
+ selectedImage: number
906
+ onSelectImage: (index: number) => void
907
+ onSelectVariant?: (variantId: string) => void
908
+ onQuantityChange?: (quantity: number) => void
909
+ onAddToCart: () => void
910
+ isWishlisted: boolean
911
+ onToggleWishlist?: () => void
912
+ onShare?: () => void
913
+ rating?: ProductOverviewRating
914
+ formatPrice?: (money: Money) => string
915
+ specifications?: Record<string, string>
916
+ features?: string[]
917
+ reviews?: ProductOverviewReview[]
918
+ labels: SharedLabels & {
919
+ badgeLabel?: string
920
+ availabilityLabel: string
921
+ skuLabel: string
922
+ overviewLabel: string
923
+ specsLabel: string
924
+ featuresLabel: string
925
+ specificationsLabel: string
926
+ galleryLabel: string
927
+ photosLabel: string
928
+ saveLabel: string
929
+ savedLabel: string
930
+ shareLabel: string
931
+ basedOnReviewsLabel: string
932
+ foundHelpfulLabel: string
933
+ ofCounterLabel: string
934
+ }
935
+ }) {
936
+ const { selectedVariant, selectedPrice, selectedCompareAt, priceAvailable, compareAtAvailable, canPurchase, quantityValue } =
937
+ selection
938
+
939
+ const featureList = features && features.length > 0 ? features : undefined
940
+ const specEntries = specifications ? Object.entries(specifications) : []
941
+ const hasSpecs = specEntries.length > 0
942
+ const hasReviews = Boolean(reviews && reviews.length > 0)
943
+ const hasGallery = images.length > 1
944
+ const hasTabs = Boolean(featureList) || hasSpecs || hasReviews || hasGallery
945
+ const reviewCount = reviews?.length ?? 0
946
+
947
+ // The initial tab is computed once and frozen: Base UI's uncontrolled Tabs warns if
948
+ // `defaultValue` changes after mount, and this expression tracks the data-derived tab set
949
+ // (which changes when optional props like `specifications` appear or disappear).
950
+ const [defaultTab] = useState(() =>
951
+ featureList ? 'overview' : hasSpecs ? 'specs' : hasReviews ? 'reviews' : 'gallery',
952
+ )
953
+
954
+ return (
955
+ <section className='py-12'>
956
+ <div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
957
+ <div className='grid gap-12 lg:grid-cols-2'>
958
+ <ProductOverviewGallery
959
+ images={images}
960
+ title={product.title}
961
+ badgeLabel={labels.badgeLabel}
962
+ selectedImage={selectedImage}
963
+ onSelectImage={onSelectImage}
964
+ photosLabel={labels.photosLabel}
965
+ />
966
+
967
+ <div className='flex flex-col gap-8'>
968
+ <div>
969
+ <h1 className='text-3xl leading-tight font-bold text-balance'>{product.title}</h1>
970
+ <div className='mt-4 flex flex-wrap items-center gap-4'>
971
+ {rating ? (
972
+ <div className='flex items-center gap-2'>
973
+ <div className='flex'>
974
+ {[0, 1, 2, 3, 4].map(i => (
975
+ <Star
976
+ key={i}
977
+ className={cn(
978
+ 'size-4',
979
+ i < rating.value ? 'fill-foreground text-foreground' : 'text-muted-foreground',
980
+ )}
981
+ />
982
+ ))}
983
+ </div>
984
+ <span className='text-sm font-medium'>{rating.value}</span>
985
+ </div>
986
+ ) : null}
987
+ {hasReviews ? (
988
+ <span className='text-muted-foreground text-sm'>
989
+ {reviewCount} {labels.reviewsLabel.toLowerCase()}
990
+ </span>
991
+ ) : null}
992
+ {selectedVariant?.sku ? (
993
+ <>
994
+ <Separator orientation='vertical' className='h-4' />
995
+ <span className='text-muted-foreground text-sm'>
996
+ {labels.skuLabel}: {selectedVariant.sku}
997
+ </span>
998
+ </>
999
+ ) : null}
1000
+ </div>
1001
+ </div>
1002
+
1003
+ {selectedVariant ? (
1004
+ priceAvailable ? (
1005
+ <div className='flex items-baseline gap-3'>
1006
+ <span className='text-4xl font-bold'>{formatMoney(selectedPrice, formatPrice)}</span>
1007
+ {compareAtAvailable ? (
1008
+ <span className='text-muted-foreground text-xl line-through'>
1009
+ {formatMoney(selectedCompareAt, formatPrice)}
1010
+ </span>
1011
+ ) : null}
1012
+ </div>
1013
+ ) : (
1014
+ <p className='text-muted-foreground text-sm'>{labels.unavailablePriceLabel}</p>
1015
+ )
1016
+ ) : (
1017
+ <p className='text-muted-foreground text-sm'>{labels.selectVariantLabel}</p>
1018
+ )}
1019
+
1020
+ {selectedVariant?.availableForSale ? (
1021
+ <div className='bg-success/10 inline-flex w-fit items-center gap-2 rounded-full px-3 py-1.5'>
1022
+ <div className='bg-success size-2 rounded-full' />
1023
+ <span className='text-success text-sm font-medium'>{labels.availabilityLabel}</span>
1024
+ </div>
1025
+ ) : null}
1026
+
1027
+ {product.description ? <p className='text-muted-foreground leading-relaxed'>{product.description}</p> : null}
1028
+
1029
+ <VariantSelector
1030
+ product={product}
1031
+ selectedVariantId={selectedVariant?.id}
1032
+ onSelectVariant={onSelectVariant}
1033
+ sizesLabel={labels.selectVariantLabel}
1034
+ variantLabel=''
1035
+ outOfStockLabel={labels.outOfStockLabel}
1036
+ />
1037
+
1038
+ {onQuantityChange ? (
1039
+ <QuantityStepper
1040
+ quantityValue={quantityValue}
1041
+ quantityLabel={labels.quantityLabel}
1042
+ onQuantityChange={onQuantityChange}
1043
+ />
1044
+ ) : null}
1045
+
1046
+ <div className='flex flex-col gap-4'>
1047
+ <Button size='lg' className='h-12 w-full cursor-pointer gap-2' disabled={!canPurchase} onClick={onAddToCart}>
1048
+ <ShoppingCart />
1049
+ {labels.addToCartLabel}
1050
+ </Button>
1051
+ <div className='flex gap-3'>
1052
+ <WishlistButton
1053
+ isWishlisted={isWishlisted}
1054
+ disabled={!selectedVariant}
1055
+ onToggleWishlist={onToggleWishlist}
1056
+ removeFromWishlistLabel={labels.removeFromWishlistLabel}
1057
+ addToWishlistLabel={labels.addToWishlistLabel}
1058
+ className='h-12 flex-1 gap-2 rounded-md'
1059
+ />
1060
+ {onShare ? (
1061
+ <Button
1062
+ variant='outline'
1063
+ size='lg'
1064
+ className='h-12 flex-1 cursor-pointer gap-2'
1065
+ onClick={onShare}
1066
+ >
1067
+ <Share2 />
1068
+ {labels.shareLabel}
1069
+ </Button>
1070
+ ) : null}
1071
+ </div>
1072
+ </div>
1073
+ </div>
1074
+ </div>
1075
+
1076
+ {hasTabs ? (
1077
+ <div className='mt-16'>
1078
+ <ProductOverviewTabs defaultValue={defaultTab} className='w-full'>
1079
+ <ProductOverviewTabsList className='bg-muted/50 relative grid !h-12 w-full grid-cols-4 rounded-xl p-1'>
1080
+ <ProductOverviewTabsIndicator />
1081
+ {featureList ? (
1082
+ <ProductOverviewTabsTrigger
1083
+ value='overview'
1084
+ className='z-10 h-10 gap-2 text-sm font-medium data-active:bg-transparent data-active:shadow-none dark:data-active:bg-transparent'
1085
+ >
1086
+ <Info className='size-4 shrink-0' />
1087
+ <span className='hidden sm:inline'>{labels.overviewLabel}</span>
1088
+ </ProductOverviewTabsTrigger>
1089
+ ) : null}
1090
+ {hasSpecs ? (
1091
+ <ProductOverviewTabsTrigger
1092
+ value='specs'
1093
+ className='z-10 h-10 gap-2 text-sm font-medium data-active:bg-transparent data-active:shadow-none dark:data-active:bg-transparent'
1094
+ >
1095
+ <Package className='size-4 shrink-0' />
1096
+ <span className='hidden sm:inline'>{labels.specsLabel}</span>
1097
+ </ProductOverviewTabsTrigger>
1098
+ ) : null}
1099
+ {hasReviews ? (
1100
+ <ProductOverviewTabsTrigger
1101
+ value='reviews'
1102
+ className='z-10 h-10 gap-2 text-sm font-medium data-active:bg-transparent data-active:shadow-none dark:data-active:bg-transparent'
1103
+ >
1104
+ <MessageSquare className='size-4 shrink-0' />
1105
+ <span className='hidden sm:inline'>
1106
+ {labels.reviewsLabel} ({reviewCount})
1107
+ </span>
1108
+ </ProductOverviewTabsTrigger>
1109
+ ) : null}
1110
+ {hasGallery ? (
1111
+ <ProductOverviewTabsTrigger
1112
+ value='gallery'
1113
+ className='z-10 h-10 gap-2 text-sm font-medium data-active:bg-transparent data-active:shadow-none dark:data-active:bg-transparent'
1114
+ >
1115
+ <Camera className='size-4 shrink-0' />
1116
+ <span className='hidden sm:inline'>{labels.galleryLabel}</span>
1117
+ </ProductOverviewTabsTrigger>
1118
+ ) : null}
1119
+ </ProductOverviewTabsList>
1120
+ {featureList ? (
1121
+ <ProductOverviewTabsContentFade value='overview' className='mt-8'>
1122
+ <Card className='border shadow-none'>
1123
+ <CardContent className='p-8 py-0'>
1124
+ <h3 className='mb-6 text-xl font-semibold text-balance'>{labels.featuresLabel}</h3>
1125
+ <div className='grid gap-4 sm:grid-cols-2'>
1126
+ {featureList.map(feature => (
1127
+ <div key={feature} className='group flex items-center gap-3'>
1128
+ <div className='bg-primary/20 size-2 rounded-full transition-colors' />
1129
+ <span className='text-sm'>{feature}</span>
1130
+ </div>
1131
+ ))}
1132
+ </div>
1133
+ </CardContent>
1134
+ </Card>
1135
+ </ProductOverviewTabsContentFade>
1136
+ ) : null}
1137
+ {hasSpecs ? (
1138
+ <ProductOverviewTabsContentFade value='specs' className='mt-8'>
1139
+ <Card className='border shadow-none'>
1140
+ <CardContent className='p-8 py-0'>
1141
+ <h3 className='mb-6 text-xl font-semibold text-balance'>{labels.specificationsLabel}</h3>
1142
+ <div className='flex flex-col gap-4'>
1143
+ {specEntries.map(([key, value]) => (
1144
+ <div
1145
+ key={key}
1146
+ className='border-muted grid grid-cols-1 gap-2 border-b py-3 last:border-b-0 md:grid-cols-3 md:gap-4'
1147
+ >
1148
+ <span className='text-muted-foreground text-sm font-medium'>{key}</span>
1149
+ <span className='col-span-1 text-sm md:col-span-2'>{value}</span>
1150
+ </div>
1151
+ ))}
1152
+ </div>
1153
+ </CardContent>
1154
+ </Card>
1155
+ </ProductOverviewTabsContentFade>
1156
+ ) : null}
1157
+ {hasReviews && reviews ? (
1158
+ <ProductOverviewTabsContentFade value='reviews' className='mt-8'>
1159
+ <ProductOverviewReviewsTab
1160
+ rating={rating?.value ?? 0}
1161
+ reviews={reviews}
1162
+ basedOnReviewsLabel={labels.basedOnReviewsLabel}
1163
+ foundHelpfulLabel={labels.foundHelpfulLabel}
1164
+ />
1165
+ </ProductOverviewTabsContentFade>
1166
+ ) : null}
1167
+ {hasGallery ? (
1168
+ <ProductOverviewTabsContentFade value='gallery' className='mt-8'>
1169
+ <ProductOverviewLightboxTab
1170
+ images={images}
1171
+ title={product.title}
1172
+ galleryLabel={labels.galleryLabel}
1173
+ ofCounterLabel={labels.ofCounterLabel}
1174
+ />
1175
+ </ProductOverviewTabsContentFade>
1176
+ ) : null}
1177
+ </ProductOverviewTabs>
1178
+ </div>
1179
+ ) : null}
1180
+ </div>
1181
+ </section>
1182
+ )
1183
+ }
1184
+
1185
+ export type { ProductOverviewReview }
1186
+ export { ProductOverviewGallery, ProductOverviewLightboxTab, ProductOverviewReviewsTab }