@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,114 @@
1
+ 'use client'
2
+
3
+ import type { ReactNode } from 'react'
4
+
5
+ import { Skeleton } from '../../../ui/skeleton'
6
+ import type { ProductGridSpecProps, ProductPresentation } from '../../../lib/contracts'
7
+ import { ProductCard } from './product-card'
8
+
9
+ /**
10
+ * Props for the canonical `ProductGrid` listing. The serializable spec props
11
+ * (`ProductGridSpecProps`) plus the runtime-only action callbacks and label
12
+ * strings, which the grid forwards to each cell.
13
+ */
14
+ export interface ProductGridProps extends ProductGridSpecProps {
15
+ onAddToCart?: (item: ProductPresentation) => void
16
+ onProductClick?: (item: ProductPresentation) => void
17
+ /** Host-owned wishlist toggle; reports the item and the next state. */
18
+ onToggleWishlist?: (item: ProductPresentation, nextState: boolean) => void
19
+ onViewSimilar?: (item: ProductPresentation) => void
20
+ addToCartLabel?: string
21
+ /** Shown when a price is present but unavailable (never a zero amount). */
22
+ unavailablePriceLabel?: string
23
+ addToWishlistLabel?: string
24
+ removeFromWishlistLabel?: string
25
+ viewSimilarLabel?: string
26
+ }
27
+
28
+ const GRID_CLASSES = 'grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:gap-6'
29
+
30
+ function SkeletonCell() {
31
+ return (
32
+ <div className='flex flex-col gap-3'>
33
+ <Skeleton className='aspect-square w-full rounded-md' />
34
+ <Skeleton className='h-4 w-3/4' />
35
+ <Skeleton className='h-4 w-1/3' />
36
+ </div>
37
+ )
38
+ }
39
+
40
+ /**
41
+ * Canonical product grid — the listing that renders a collection of product
42
+ * items as a grid of `ProductCard` item cells. It owns the section chrome
43
+ * (heading), the responsive layout, the item cap, wishlist membership
44
+ * resolution, and the loading/empty/ready states; the host owns data errors.
45
+ */
46
+ export function ProductGrid({
47
+ items,
48
+ presentation,
49
+ heading,
50
+ maxItems,
51
+ wishlistedIds,
52
+ loading = false,
53
+ skeletonCount = 6,
54
+ emptyLabel = 'No hay productos para mostrar.',
55
+ onAddToCart,
56
+ onProductClick,
57
+ onToggleWishlist,
58
+ onViewSimilar,
59
+ addToCartLabel,
60
+ unavailablePriceLabel,
61
+ addToWishlistLabel,
62
+ removeFromWishlistLabel,
63
+ viewSimilarLabel,
64
+ }: ProductGridProps) {
65
+ const displayedItems = maxItems !== undefined ? items.slice(0, maxItems) : items
66
+
67
+ let body: ReactNode
68
+ if (loading) {
69
+ body = (
70
+ <div role='status' aria-busy='true' className={GRID_CLASSES}>
71
+ {Array.from({ length: skeletonCount }, (_, index) => (
72
+ <SkeletonCell key={index} />
73
+ ))}
74
+ </div>
75
+ )
76
+ } else if (displayedItems.length === 0) {
77
+ body = <p className='text-muted-foreground text-center'>{emptyLabel}</p>
78
+ } else {
79
+ body = (
80
+ <div className={GRID_CLASSES}>
81
+ {displayedItems.map((item) => (
82
+ <ProductCard
83
+ key={item.id}
84
+ item={item}
85
+ presentation={presentation}
86
+ wishlisted={wishlistedIds ? wishlistedIds.includes(item.id) : undefined}
87
+ onAddToCart={onAddToCart}
88
+ onProductClick={onProductClick}
89
+ onToggleWishlist={onToggleWishlist}
90
+ onViewSimilar={onViewSimilar}
91
+ addToCartLabel={addToCartLabel}
92
+ unavailablePriceLabel={unavailablePriceLabel}
93
+ addToWishlistLabel={addToWishlistLabel}
94
+ removeFromWishlistLabel={removeFromWishlistLabel}
95
+ viewSimilarLabel={viewSimilarLabel}
96
+ />
97
+ ))}
98
+ </div>
99
+ )
100
+ }
101
+
102
+ return (
103
+ <section className='py-12'>
104
+ <div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
105
+ {heading && (
106
+ <h2 className='mb-8 text-center text-2xl font-bold text-balance md:text-3xl'>{heading}</h2>
107
+ )}
108
+ {body}
109
+ </div>
110
+ </section>
111
+ )
112
+ }
113
+
114
+ export default ProductGrid
@@ -0,0 +1,71 @@
1
+ import { Camera } from 'lucide-react'
2
+ import { Badge } from '../../../ui/badge'
3
+ import { cn } from '../../../lib/utils'
4
+
5
+ export interface ProductOverviewGalleryProps {
6
+ /** Resolved image URLs for the product (never empty strings; a fallback is allowed). */
7
+ images: string[]
8
+ /** Product title, used for alt text. */
9
+ title: string
10
+ /** Optional badge label; omitted when absent. */
11
+ badgeLabel?: string
12
+ selectedImage: number
13
+ onSelectImage: (index: number) => void
14
+ photosLabel?: string
15
+ }
16
+
17
+ /**
18
+ * Image-forward gallery used by the canonical ProductOverview `rich` presentation: a large
19
+ * primary image with an optional badge and a photo count, plus a thumbnail strip. Operates on
20
+ * already-resolved URLs so it carries no product-shape assumption (no `mainImages`/`galleryImages`
21
+ * split, no sector fields).
22
+ */
23
+ export function ProductOverviewGallery({
24
+ images,
25
+ title,
26
+ badgeLabel,
27
+ selectedImage,
28
+ onSelectImage,
29
+ photosLabel = 'fotos',
30
+ }: ProductOverviewGalleryProps) {
31
+ const activeImage = images[Math.min(selectedImage, images.length - 1)]
32
+
33
+ return (
34
+ <div>
35
+ <div className='bg-muted relative aspect-square overflow-hidden rounded-xl'>
36
+ <img src={activeImage || undefined} alt={title} className='size-full object-cover' />
37
+ {badgeLabel ? (
38
+ <Badge className='absolute top-4 left-4 px-2.5 py-0.5 font-semibold'>{badgeLabel}</Badge>
39
+ ) : null}
40
+ <div className='absolute right-4 bottom-4 rounded-full bg-black/60 px-3 py-1.5 text-sm text-white backdrop-blur-sm'>
41
+ <Camera className='mr-1 inline size-4' />
42
+ {images.length} {photosLabel}
43
+ </div>
44
+ </div>
45
+
46
+ {images.length > 1 ? (
47
+ <div className='mt-4 flex gap-3 overflow-x-auto pb-2'>
48
+ {images.map((image, index) => (
49
+ <button
50
+ type='button'
51
+ key={`${title}:gallery:${index}`}
52
+ onClick={() => onSelectImage(index)}
53
+ className={cn(
54
+ 'relative aspect-square size-20 flex-shrink-0 cursor-pointer overflow-hidden rounded-lg border-2 transition-all',
55
+ selectedImage === index
56
+ ? 'border-primary ring-primary/20 shadow-md ring-2'
57
+ : 'border-muted hover:border-muted-foreground/50',
58
+ )}
59
+ aria-label={`Mostrar imagen ${index + 1} de ${title}`}
60
+ aria-pressed={selectedImage === index}
61
+ >
62
+ <img src={image || undefined} alt={`${title} ${index + 1}`} className='size-full object-cover' />
63
+ </button>
64
+ ))}
65
+ </div>
66
+ ) : null}
67
+ </div>
68
+ )
69
+ }
70
+
71
+ export default ProductOverviewGallery
@@ -7,80 +7,92 @@ import { Card, CardContent } from '../../../ui/card'
7
7
  import { Dialog, DialogClose, DialogContent, DialogTitle, DialogTrigger } from '../../../ui/dialog'
8
8
  import { cn } from '../../../lib/utils'
9
9
 
10
- import type { ProductOverview5Product } from './product-overview-5'
11
-
12
- export interface ProductOverview5LightboxTabProps {
13
- product: ProductOverview5Product
10
+ export interface ProductOverviewLightboxTabProps {
11
+ /** Resolved image URLs for the product. */
12
+ images: string[]
13
+ /** Product title, used for alt text. */
14
+ title: string
14
15
  galleryLabel?: string
15
16
  ofCounterLabel?: string
16
17
  }
17
18
 
18
- export function ProductOverview5LightboxTab({
19
- product,
19
+ /**
20
+ * Gallery panel for the `rich` presentation: a thumbnail grid that opens a lightbox with
21
+ * prev/next navigation. Operates on already-resolved URLs so it carries no sector assumption.
22
+ */
23
+ export function ProductOverviewLightboxTab({
24
+ images,
25
+ title,
20
26
  galleryLabel = 'Galería',
21
27
  ofCounterLabel = '{current} de {total}',
22
- }: ProductOverview5LightboxTabProps) {
28
+ }: ProductOverviewLightboxTabProps) {
23
29
  const [modalImageIndex, setModalImageIndex] = useState(0)
24
30
 
25
- const nextImage = () => setModalImageIndex(prev => (prev + 1) % product.galleryImages.length)
26
- const prevImage = () => setModalImageIndex(prev => (prev - 1 + product.galleryImages.length) % product.galleryImages.length)
31
+ const nextImage = () => setModalImageIndex(prev => (prev + 1) % images.length)
32
+ const prevImage = () => setModalImageIndex(prev => (prev - 1 + images.length) % images.length)
27
33
 
28
34
  return (
29
35
  <Card className='border shadow-none'>
30
36
  <CardContent className='p-8 py-0'>
31
37
  <h3 className='mb-6 text-xl font-semibold text-balance'>{galleryLabel}</h3>
32
38
  <div className='grid gap-4 sm:grid-cols-2 lg:grid-cols-3'>
33
- {product.galleryImages.map((image, index) => (
34
- <Dialog key={index}>
39
+ {images.map((image, index) => (
40
+ <Dialog key={`${title}:lightbox:${index}`}>
35
41
  <DialogTrigger asChild>
36
42
  <button
37
43
  type='button'
38
44
  className='block aspect-square cursor-pointer overflow-hidden rounded-xl'
39
45
  onClick={() => setModalImageIndex(index)}
46
+ aria-label={`Abrir imagen ${index + 1} de ${title}`}
40
47
  >
41
- <img src={image || undefined} alt={`${product.name} ${index + 1}`} className='size-full object-cover' />
48
+ <img src={image || undefined} alt={`${title} ${index + 1}`} className='size-full object-cover' />
42
49
  </button>
43
50
  </DialogTrigger>
44
51
  <DialogContent className='max-w-4xl border-0 bg-transparent p-0' showCloseButton={false}>
45
52
  <DialogTitle className='sr-only'>Imagen de galería {modalImageIndex + 1}</DialogTitle>
46
53
  <div className='relative'>
47
54
  <img
48
- src={product.galleryImages[modalImageIndex] || undefined}
49
- alt={`${product.name} ${modalImageIndex + 1}`}
55
+ src={images[modalImageIndex] || undefined}
56
+ alt={`${title} ${modalImageIndex + 1}`}
50
57
  className='max-h-[80vh] w-full object-contain'
51
58
  />
52
59
  <DialogClose className='absolute top-4 right-4 rounded-full bg-black/20 p-2 text-white transition-colors hover:bg-black/30'>
53
60
  <X className='size-4' />
54
61
  </DialogClose>
55
62
 
56
- {product.galleryImages.length > 1 && (
63
+ {images.length > 1 ? (
57
64
  <>
58
65
  <Button
59
66
  variant='ghost'
60
67
  size='icon'
61
- className='size-9 absolute top-1/2 left-4 -translate-y-1/2 cursor-pointer rounded-full bg-black/20 text-white hover:bg-black/30'
68
+ className='absolute top-1/2 left-4 size-9 -translate-y-1/2 cursor-pointer rounded-full bg-black/20 text-white hover:bg-black/30'
62
69
  onClick={prevImage}
70
+ aria-label='Imagen anterior'
63
71
  >
64
72
  <ChevronLeft className='size-4' />
65
73
  </Button>
66
74
  <Button
67
75
  variant='ghost'
68
76
  size='icon'
69
- className='size-9 absolute top-1/2 right-4 -translate-y-1/2 cursor-pointer rounded-full bg-black/20 text-white hover:bg-black/30'
77
+ className='absolute top-1/2 right-4 size-9 -translate-y-1/2 cursor-pointer rounded-full bg-black/20 text-white hover:bg-black/30'
70
78
  onClick={nextImage}
79
+ aria-label='Imagen siguiente'
71
80
  >
72
81
  <ChevronRight className='size-4' />
73
82
  </Button>
74
83
  </>
75
- )}
84
+ ) : null}
76
85
 
77
86
  <div className='absolute bottom-4 left-1/2 flex -translate-x-1/2 gap-2'>
78
- {product.galleryImages.map((_, idx) => (
87
+ {images.map((_, idx) => (
79
88
  <button
80
89
  type='button'
81
- key={idx}
90
+ key={`${title}:dot:${idx}`}
82
91
  aria-label={`Mostrar imagen ${idx + 1}`}
83
- className={cn('size-2 rounded-full transition-colors', idx === modalImageIndex ? 'bg-white' : 'bg-white/50')}
92
+ className={cn(
93
+ 'size-2 rounded-full transition-colors',
94
+ idx === modalImageIndex ? 'bg-white' : 'bg-white/50',
95
+ )}
84
96
  onClick={() => setModalImageIndex(idx)}
85
97
  />
86
98
  ))}
@@ -89,7 +101,7 @@ export function ProductOverview5LightboxTab({
89
101
  <div className='absolute right-4 bottom-4 rounded-full bg-black/20 px-3 py-1 text-sm text-white'>
90
102
  {ofCounterLabel
91
103
  .replace('{current}', String(modalImageIndex + 1))
92
- .replace('{total}', String(product.galleryImages.length))}
104
+ .replace('{total}', String(images.length))}
93
105
  </div>
94
106
  </div>
95
107
  </DialogContent>
@@ -101,4 +113,4 @@ export function ProductOverview5LightboxTab({
101
113
  )
102
114
  }
103
115
 
104
- export default ProductOverview5LightboxTab
116
+ export default ProductOverviewLightboxTab
@@ -2,17 +2,33 @@ import { Star } from 'lucide-react'
2
2
  import { Card, CardContent } from '../../../ui/card'
3
3
  import { Progress } from '../../../ui/progress'
4
4
 
5
- import type { ProductOverview5Product } from './product-overview-5'
5
+ /**
6
+ * Canonical review entry for the ProductOverview `rich` presentation. Reviews are supplied by the
7
+ * host (optional block data), never seeded by the block.
8
+ */
9
+ export interface ProductOverviewReview {
10
+ id: number
11
+ author: string
12
+ rating: number
13
+ comment: string
14
+ date: string
15
+ helpful?: number
16
+ }
6
17
 
7
- export interface ProductOverview5ReviewsTabProps {
8
- product: ProductOverview5Product
18
+ export interface ProductOverviewReviewsTabProps {
19
+ /** Aggregate rating (0–5); rendered only when the host supplies reviews. */
20
+ rating: number
21
+ reviews: ProductOverviewReview[]
9
22
  basedOnReviewsLabel?: string
10
23
  foundHelpfulLabel?: string
11
24
  }
12
25
 
13
26
  function renderStars(rating: number) {
14
27
  return Array.from({ length: 5 }, (_, i) => (
15
- <Star key={i} className={`size-4 ${i < Math.floor(rating) ? 'fill-foreground text-foreground' : i < rating ? 'text-foreground' : ''}`} />
28
+ <Star
29
+ key={i}
30
+ className={`size-4 ${i < Math.floor(rating) ? 'fill-foreground text-foreground' : i < rating ? 'text-foreground' : ''}`}
31
+ />
16
32
  ))
17
33
  }
18
34
 
@@ -20,27 +36,32 @@ function formatDate(dateString: string) {
20
36
  return new Date(dateString).toLocaleDateString('es-ES', { year: 'numeric', month: 'short', day: 'numeric' })
21
37
  }
22
38
 
23
- export function ProductOverview5ReviewsTab({
24
- product,
39
+ /**
40
+ * Reviews panel for the `rich` presentation: an aggregate summary plus the review list. Rendered
41
+ * only when the host supplies reviews, so it never fabricates an empty review surface.
42
+ */
43
+ export function ProductOverviewReviewsTab({
44
+ rating,
45
+ reviews,
25
46
  basedOnReviewsLabel = 'Basado en {count} reseñas',
26
47
  foundHelpfulLabel = '{count} personas encontraron esto útil',
27
- }: ProductOverview5ReviewsTabProps) {
48
+ }: ProductOverviewReviewsTabProps) {
28
49
  return (
29
50
  <div className='flex flex-col gap-6'>
30
51
  <Card className='border shadow-none'>
31
52
  <CardContent className='p-8 py-0'>
32
53
  <div className='flex flex-col items-start gap-8 md:flex-row md:items-center'>
33
54
  <div className='text-center'>
34
- <div className='text-4xl font-bold'>{product.rating}</div>
35
- <div className='mt-2 flex justify-center'>{renderStars(product.rating)}</div>
55
+ <div className='text-4xl font-bold'>{rating}</div>
56
+ <div className='mt-2 flex justify-center'>{renderStars(rating)}</div>
36
57
  <div className='text-muted-foreground mt-2 text-sm'>
37
- {basedOnReviewsLabel.replace('{count}', String(product.userReviews.length))}
58
+ {basedOnReviewsLabel.replace('{count}', String(reviews.length))}
38
59
  </div>
39
60
  </div>
40
61
  <div className='flex-1'>
41
62
  {[5, 4, 3, 2, 1].map(stars => {
42
- const count = product.userReviews.filter(review => Math.floor(review.rating) === stars).length
43
- const percentage = (count / product.userReviews.length) * 100
63
+ const count = reviews.filter(review => Math.floor(review.rating) === stars).length
64
+ const percentage = reviews.length > 0 ? (count / reviews.length) * 100 : 0
44
65
  return (
45
66
  <div key={stars} className='mb-3 flex items-center gap-3'>
46
67
  <span className='w-8 text-sm font-medium'>{stars}★</span>
@@ -55,7 +76,7 @@ export function ProductOverview5ReviewsTab({
55
76
  </Card>
56
77
 
57
78
  <div className='flex flex-col gap-4'>
58
- {product.userReviews.map(review => (
79
+ {reviews.map(review => (
59
80
  <Card key={review.id} className='border shadow-none'>
60
81
  <CardContent className='p-6 py-0'>
61
82
  <div className='mb-4 flex items-start justify-between'>
@@ -68,9 +89,11 @@ export function ProductOverview5ReviewsTab({
68
89
  </div>
69
90
  </div>
70
91
  <p className='text-muted-foreground mb-3 text-sm leading-relaxed'>{review.comment}</p>
71
- <div className='text-muted-foreground text-xs'>
72
- {foundHelpfulLabel.replace('{count}', String(review.helpful))}
73
- </div>
92
+ {typeof review.helpful === 'number' ? (
93
+ <div className='text-muted-foreground text-xs'>
94
+ {foundHelpfulLabel.replace('{count}', String(review.helpful))}
95
+ </div>
96
+ ) : null}
74
97
  </CardContent>
75
98
  </Card>
76
99
  ))}
@@ -79,4 +102,4 @@ export function ProductOverview5ReviewsTab({
79
102
  )
80
103
  }
81
104
 
82
- export default ProductOverview5ReviewsTab
105
+ export default ProductOverviewReviewsTab
@@ -3,7 +3,8 @@ import { Tabs as TabsPrimitive } from '@base-ui/react/tabs'
3
3
  import { cn } from '../../../lib/utils'
4
4
 
5
5
  /**
6
- * The full tab set this block renders on.
6
+ * The full tab set the canonical ProductOverview renders on for its `rich`
7
+ * presentation.
7
8
  *
8
9
  * The block's classes target Base UI state attributes (`data-active:bg-transparent` on the
9
10
  * triggers), the indicator reads Base UI positioning CSS variables (`--active-tab-*`), and the
@@ -12,11 +13,11 @@ import { cn } from '../../../lib/utils'
12
13
  * `tabs` so the block installs standalone: the shadcn `tabs` component you get from the registry
13
14
  * has no indicator or fading panel.
14
15
  */
15
- export function ProductOverview5Tabs(props: TabsPrimitive.Root.Props) {
16
+ export function ProductOverviewTabs(props: TabsPrimitive.Root.Props) {
16
17
  return <TabsPrimitive.Root data-slot='tabs' {...props} />
17
18
  }
18
19
 
19
- export function ProductOverview5TabsList({ className, ...props }: TabsPrimitive.List.Props) {
20
+ export function ProductOverviewTabsList({ className, ...props }: TabsPrimitive.List.Props) {
20
21
  return <TabsPrimitive.List data-slot='tabs-list' className={className} {...props} />
21
22
  }
22
23
 
@@ -24,7 +25,7 @@ export function ProductOverview5TabsList({ className, ...props }: TabsPrimitive.
24
25
  * Base UI's tab button carries no layout of its own, so the inline-flex centring that the
25
26
  * Radix-backed shadcn trigger used to supply is re-stated here.
26
27
  */
27
- export function ProductOverview5TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
28
+ export function ProductOverviewTabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
28
29
  return (
29
30
  <TabsPrimitive.Tab
30
31
  data-slot='tabs-trigger'
@@ -39,7 +40,7 @@ export function ProductOverview5TabsTrigger({ className, ...props }: TabsPrimiti
39
40
  * activation is a fresh mount - `animate-in` plays automatically without needing a transition-state
40
41
  * data attribute to gate it.
41
42
  */
42
- export function ProductOverview5TabsContentFade({ className, ...props }: TabsPrimitive.Panel.Props) {
43
+ export function ProductOverviewTabsContentFade({ className, ...props }: TabsPrimitive.Panel.Props) {
43
44
  return (
44
45
  <TabsPrimitive.Panel
45
46
  className={cn('animate-in fade-in-0 duration-200 motion-reduce:animate-none', className)}
@@ -53,7 +54,7 @@ export function ProductOverview5TabsContentFade({ className, ...props }: TabsPri
53
54
  * with `data-active:bg-transparent` on the triggers so their own active background doesn't double
54
55
  * up with the pill.
55
56
  */
56
- export function ProductOverview5TabsIndicator({ className, ...props }: TabsPrimitive.Indicator.Props) {
57
+ export function ProductOverviewTabsIndicator({ className, ...props }: TabsPrimitive.Indicator.Props) {
57
58
  return (
58
59
  <TabsPrimitive.Indicator
59
60
  data-slot='tabs-indicator'
@@ -66,4 +67,4 @@ export function ProductOverview5TabsIndicator({ className, ...props }: TabsPrimi
66
67
  {...props}
67
68
  />
68
69
  )
69
- }
70
+ }