@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.
- package/CHANGELOG.md +155 -0
- package/README.md +228 -68
- package/docs/standards/data-contracts.md +13 -2
- package/package.json +26 -10
- package/scripts/fix-esm-extensions.mjs +40 -0
- package/src/blocks/application/login-forms/login-page.test.tsx +190 -0
- package/src/blocks/application/login-forms/login-page.tsx +240 -0
- package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +6 -8
- package/src/blocks/application/signup-forms/signup-page-1.tsx +127 -129
- package/src/blocks/application/verification/verify-1.tsx +2 -2
- package/src/blocks/application/viewport-ownership.test.tsx +41 -0
- package/src/blocks/e-commerce/category-filters/category-listing-filter-fields.tsx +218 -0
- package/src/blocks/e-commerce/category-filters/{category-filter-2.test.tsx → category-listing.test.tsx} +287 -199
- package/src/blocks/e-commerce/category-filters/category-listing.tsx +934 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-contact-step.tsx +149 -0
- package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-country-field.tsx → checkout-country-field.tsx} +10 -8
- package/src/blocks/e-commerce/checkout-forms/checkout-order-summary.tsx +230 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-payment-step.tsx +100 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-progress.tsx +62 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-review-step.tsx +59 -0
- package/src/blocks/e-commerce/checkout-forms/{checkout-form-1-shipping-step.tsx → checkout-shipping-step.tsx} +65 -41
- package/src/blocks/e-commerce/checkout-forms/checkout.test.tsx +478 -0
- package/src/blocks/e-commerce/checkout-forms/checkout.tsx +550 -0
- package/src/blocks/e-commerce/navigation/storefront-navbar-account-menu.test.tsx +56 -0
- package/src/blocks/{marketing/navbars/navbar-4-account-menu.tsx → e-commerce/navigation/storefront-navbar-account-menu.tsx} +3 -3
- package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.test.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.test.tsx} +22 -22
- package/src/blocks/{marketing/navbars/navbar-4-cart-sheet.tsx → e-commerce/navigation/storefront-navbar-cart-sheet.tsx} +3 -3
- package/src/blocks/{marketing/navbars/navbar-4-mobile-nav.tsx → e-commerce/navigation/storefront-navbar-mobile-nav.tsx} +4 -12
- package/src/blocks/{marketing/navbars/navbar-4-search-bar.tsx → e-commerce/navigation/storefront-navbar-search-bar.tsx} +3 -3
- package/src/blocks/{marketing/navbars/navbar-4.test.tsx → e-commerce/navigation/storefront-navbar.test.tsx} +67 -36
- package/src/blocks/{marketing/navbars/navbar-4.tsx → e-commerce/navigation/storefront-navbar.tsx} +43 -25
- package/src/blocks/e-commerce/product-categories/category-card-grid.test.tsx +122 -0
- package/src/blocks/e-commerce/product-categories/category-card.test.tsx +68 -0
- package/src/blocks/e-commerce/product-categories/category-card.tsx +172 -0
- package/src/blocks/e-commerce/product-categories/category-grid.test.tsx +67 -0
- package/src/blocks/e-commerce/product-categories/category-shared.tsx +109 -0
- package/src/blocks/e-commerce/product-categories/product-category-grid.tsx +170 -0
- package/src/blocks/e-commerce/product-categories/product-category.test.tsx +266 -0
- package/src/blocks/e-commerce/product-categories/product-category.tsx +462 -0
- package/src/blocks/e-commerce/product-list/product-card.test.tsx +263 -0
- package/src/blocks/e-commerce/product-list/product-card.tsx +447 -0
- package/src/blocks/e-commerce/product-list/product-grid.test.tsx +162 -0
- package/src/blocks/e-commerce/product-list/product-grid.tsx +114 -0
- package/src/blocks/e-commerce/product-overview/product-overview-gallery.tsx +71 -0
- package/src/blocks/e-commerce/product-overview/{product-overview-5-lightbox-tab.tsx → product-overview-lightbox-tab.tsx} +35 -23
- package/src/blocks/e-commerce/product-overview/{product-overview-5-reviews-tab.tsx → product-overview-reviews-tab.tsx} +40 -17
- package/src/blocks/e-commerce/product-overview/{product-overview-5-tabs.tsx → product-overview-tabs.tsx} +8 -7
- package/src/blocks/e-commerce/product-overview/product-overview.test.tsx +283 -0
- package/src/blocks/e-commerce/product-overview/product-overview.tsx +1186 -0
- package/src/blocks/e-commerce/reviews/review-rating-2.tsx +5 -6
- package/src/blocks/e-commerce/shopping-carts/shopping-cart.test.tsx +399 -0
- package/src/blocks/e-commerce/shopping-carts/{shopping-cart-4.tsx → shopping-cart.tsx} +119 -85
- package/src/blocks/e-commerce/storefront-hero/storefront-hero.test.tsx +412 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero.tsx +1283 -0
- package/src/blocks/marketing/features/feature.test.tsx +153 -0
- package/src/blocks/marketing/features/feature.tsx +195 -0
- package/src/blocks/marketing/footers/footer-section-accessibility.test.tsx +118 -0
- package/src/blocks/marketing/footers/footer-section-actions.test.tsx +131 -0
- package/src/blocks/marketing/footers/footer-section-data.test.tsx +127 -0
- package/src/blocks/marketing/footers/footer-section-native-links.test.tsx +100 -0
- package/src/blocks/marketing/footers/footer-section-public-api.test.tsx +11 -0
- package/src/blocks/marketing/footers/footer-section.test.tsx +46 -0
- package/src/blocks/marketing/footers/footer-section.tsx +532 -0
- package/src/blocks/marketing/navbars/navbar-public-api.test.tsx +57 -0
- package/src/blocks/marketing/navbars/navbar.mega.test.tsx +149 -0
- package/src/blocks/marketing/navbars/navbar.test.tsx +155 -0
- package/src/blocks/marketing/navbars/navbar.tsx +735 -0
- package/src/blocks/marketing/testimonials/testimonial-section.test.tsx +171 -0
- package/src/blocks/marketing/testimonials/testimonial-section.tsx +237 -0
- package/src/index.ts +156 -128
- package/src/lib/contracts-curated.test.ts +47 -29
- package/src/lib/contracts.test.ts +198 -2
- package/src/lib/contracts.ts +410 -16
- package/src/ui/button-group.tsx +1 -1
- package/src/ui/carousel.tsx +1 -1
- package/src/ui/dialog.tsx +1 -1
- package/src/ui/field.tsx +2 -2
- package/src/ui/input-group.tsx +3 -3
- package/src/ui/pagination.tsx +1 -1
- package/src/ui/sidebar.tsx +7 -7
- package/src/ui/toggle-group.tsx +1 -1
- package/tsconfig.build.json +12 -0
- package/tsup.config.ts +11 -7
- package/.turbo/turbo-build.log +0 -24
- package/.turbo/turbo-test.log +0 -47
- package/.turbo/turbo-type-check.log +0 -0
- package/dist/.tsbuildinfo +0 -1
- package/dist/contracts.d.mts +0 -1732
- package/dist/contracts.d.ts +0 -1732
- package/dist/contracts.js +0 -423
- package/dist/contracts.js.map +0 -1
- package/dist/contracts.mjs +0 -365
- package/dist/contracts.mjs.map +0 -1
- package/dist/index.d.mts +0 -3068
- package/dist/index.d.ts +0 -3068
- package/dist/index.js +0 -17834
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -17740
- package/dist/index.mjs.map +0 -1
- package/src/blocks/application/login-forms/login-page-1.tsx +0 -246
- package/src/blocks/application/login-forms/login-page-2.tsx +0 -207
- package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +0 -260
- package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +0 -222
- package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +0 -921
- package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +0 -57
- package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +0 -124
- package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +0 -170
- package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +0 -52
- package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +0 -57
- package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +0 -68
- package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +0 -298
- package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +0 -47
- package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +0 -9
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +0 -96
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +0 -252
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +0 -188
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +0 -36
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +0 -216
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +0 -338
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +0 -130
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +0 -175
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +0 -190
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +0 -312
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +0 -91
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +0 -110
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +0 -137
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +0 -68
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +0 -79
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +0 -172
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +0 -392
- package/src/blocks/e-commerce/product-categories/avatar-group.tsx +0 -86
- package/src/blocks/e-commerce/product-categories/product-category-1.tsx +0 -126
- package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +0 -51
- package/src/blocks/e-commerce/product-categories/product-category-2.tsx +0 -120
- package/src/blocks/e-commerce/product-categories/product-category-4.tsx +0 -129
- package/src/blocks/e-commerce/product-categories/product-category-5.tsx +0 -218
- package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +0 -88
- package/src/blocks/e-commerce/product-list/product-card-1.tsx +0 -192
- package/src/blocks/e-commerce/product-list/product-card-2.tsx +0 -71
- package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +0 -166
- package/src/blocks/e-commerce/product-list/product-card-3.tsx +0 -215
- package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +0 -98
- package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +0 -171
- package/src/blocks/e-commerce/product-list/product-card-5.tsx +0 -367
- package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +0 -182
- package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +0 -370
- package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +0 -272
- package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +0 -59
- package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +0 -274
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +0 -142
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +0 -291
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +0 -110
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +0 -51
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +0 -146
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +0 -295
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +0 -142
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +0 -342
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +0 -146
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +0 -229
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +0 -72
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +0 -263
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +0 -283
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +0 -318
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +0 -205
- package/src/blocks/marketing/features/feature-1.tsx +0 -97
- package/src/blocks/marketing/features/feature-2.tsx +0 -82
- package/src/blocks/marketing/footers/footer-section-1.test.tsx +0 -117
- package/src/blocks/marketing/footers/footer-section-1.tsx +0 -178
- package/src/blocks/marketing/footers/footer-section-3.tsx +0 -192
- package/src/blocks/marketing/footers/footer-section-4.tsx +0 -355
- package/src/blocks/marketing/navbars/navbar-1.tsx +0 -267
- package/src/blocks/marketing/navbars/navbar-2.tsx +0 -351
- package/src/blocks/marketing/navbars/navbar-3.tsx +0 -303
- package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +0 -38
- package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +0 -177
- package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +0 -219
- package/src/blocks/marketing/testimonials/testimonial-1.tsx +0 -141
- 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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
}:
|
|
28
|
+
}: ProductOverviewLightboxTabProps) {
|
|
23
29
|
const [modalImageIndex, setModalImageIndex] = useState(0)
|
|
24
30
|
|
|
25
|
-
const nextImage = () => setModalImageIndex(prev => (prev + 1) %
|
|
26
|
-
const prevImage = () => setModalImageIndex(prev => (prev - 1 +
|
|
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
|
-
{
|
|
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={`${
|
|
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={
|
|
49
|
-
alt={`${
|
|
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
|
-
{
|
|
63
|
+
{images.length > 1 ? (
|
|
57
64
|
<>
|
|
58
65
|
<Button
|
|
59
66
|
variant='ghost'
|
|
60
67
|
size='icon'
|
|
61
|
-
className='
|
|
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='
|
|
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
|
-
{
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
8
|
-
|
|
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
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
}:
|
|
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'>{
|
|
35
|
-
<div className='mt-2 flex justify-center'>{renderStars(
|
|
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(
|
|
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 =
|
|
43
|
-
const percentage = (count /
|
|
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
|
-
{
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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
|
|
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
|
|
16
|
+
export function ProductOverviewTabs(props: TabsPrimitive.Root.Props) {
|
|
16
17
|
return <TabsPrimitive.Root data-slot='tabs' {...props} />
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
export function
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|