@thorprovider/blocks 0.1.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/.turbo/turbo-build.log +24 -0
- package/.turbo/turbo-test.log +10 -0
- package/.turbo/turbo-type-check.log +0 -0
- package/AGENTS.md +1 -0
- package/README.md +161 -0
- package/components.json +33 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/contracts.d.mts +563 -0
- package/dist/contracts.d.ts +563 -0
- package/dist/contracts.js +207 -0
- package/dist/contracts.js.map +1 -0
- package/dist/contracts.mjs +168 -0
- package/dist/contracts.mjs.map +1 -0
- package/dist/index.d.mts +2408 -0
- package/dist/index.d.ts +2408 -0
- package/dist/index.js +16422 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +16352 -0
- package/dist/index.mjs.map +1 -0
- package/docs/standards/data-contracts.md +70 -0
- package/docs/standards/theming.md +85 -0
- package/package.json +62 -0
- package/src/blocks/application/app-shells/app-shell-1-sidebar.tsx +198 -0
- package/src/blocks/application/app-shells/app-shell-1.tsx +196 -0
- package/src/blocks/application/login-forms/login-page-1.tsx +246 -0
- package/src/blocks/application/login-forms/login-page-2.tsx +207 -0
- package/src/blocks/application/password-recovery/forgot-password-page-1-brand-mark.tsx +38 -0
- package/src/blocks/application/password-recovery/forgot-password-page-1.tsx +320 -0
- package/src/blocks/application/signup-forms/signup-page-1.tsx +231 -0
- package/src/blocks/application/verification/verify-1.tsx +307 -0
- package/src/blocks/application/verification/verify-confetti.tsx +192 -0
- package/src/blocks/e-commerce/category-filters/category-filter-1.tsx +260 -0
- package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +179 -0
- package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +341 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-chip-rail.tsx +57 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-drawer.tsx +124 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-fields.tsx +170 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-sidebar.tsx +52 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-sort-sheet.tsx +57 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4-state.ts +68 -0
- package/src/blocks/e-commerce/category-filters/category-filter-4.tsx +298 -0
- package/src/blocks/e-commerce/checkout-forms/card-formatting.test.ts +47 -0
- package/src/blocks/e-commerce/checkout-forms/card-formatting.ts +9 -0
- package/src/blocks/e-commerce/checkout-forms/checkbox-check.tsx +74 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +189 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +283 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-field-feedback.tsx +29 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-contact-step.tsx +96 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-country-field.tsx +51 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +139 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +184 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-progress.tsx +36 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +107 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +235 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-order-summary.tsx +130 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-payment-card.tsx +175 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2-shipping-card.tsx +190 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-2.tsx +312 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-information-step.tsx +91 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-order-summary.tsx +110 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-payment-step.tsx +137 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-progress.tsx +68 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-review-step.tsx +79 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3-shipping-step.tsx +172 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-3.tsx +392 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-types.ts +7 -0
- package/src/blocks/e-commerce/navigation/category-icon.test.tsx +54 -0
- package/src/blocks/e-commerce/navigation/category-icon.tsx +94 -0
- package/src/blocks/e-commerce/navigation/mega-menu.test.tsx +139 -0
- package/src/blocks/e-commerce/navigation/mega-menu.tsx +271 -0
- package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +152 -0
- package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +276 -0
- package/src/blocks/e-commerce/order-history/order-history-1.tsx +159 -0
- package/src/blocks/e-commerce/order-history/order-history-2.tsx +222 -0
- package/src/blocks/e-commerce/product-categories/avatar-group.tsx +86 -0
- package/src/blocks/e-commerce/product-categories/product-category-1.tsx +126 -0
- package/src/blocks/e-commerce/product-categories/product-category-2.tsx +100 -0
- package/src/blocks/e-commerce/product-categories/product-category-4.tsx +129 -0
- package/src/blocks/e-commerce/product-categories/product-category-5.tsx +218 -0
- package/src/blocks/e-commerce/product-list/product-card-1.tsx +131 -0
- package/src/blocks/e-commerce/product-list/product-card-2.tsx +71 -0
- package/src/blocks/e-commerce/product-list/product-card-3-reaction-toggle.tsx +166 -0
- package/src/blocks/e-commerce/product-list/product-card-3.tsx +215 -0
- package/src/blocks/e-commerce/product-list/product-card-5-checkbox-check.tsx +98 -0
- package/src/blocks/e-commerce/product-list/product-card-5-reaction-toggle.tsx +171 -0
- package/src/blocks/e-commerce/product-list/product-card-5.tsx +367 -0
- package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +201 -0
- package/src/blocks/e-commerce/product-overview/product-overview-2.tsx +272 -0
- package/src/blocks/e-commerce/product-overview/product-overview-5-gallery.tsx +59 -0
- package/src/blocks/e-commerce/product-overview/product-overview-5-lightbox-tab.tsx +104 -0
- package/src/blocks/e-commerce/product-overview/product-overview-5-reviews-tab.tsx +82 -0
- package/src/blocks/e-commerce/product-overview/product-overview-5-tabs.tsx +69 -0
- package/src/blocks/e-commerce/product-overview/product-overview-5.tsx +274 -0
- package/src/blocks/e-commerce/product-overview/product-quick-view-card.tsx +135 -0
- package/src/blocks/e-commerce/product-overview/product-quick-view-dialog.tsx +393 -0
- package/src/blocks/e-commerce/product-overview/product-quick-view.tsx +136 -0
- package/src/blocks/e-commerce/reviews/review-rating-1-review-form.tsx +133 -0
- package/src/blocks/e-commerce/reviews/review-rating-1.tsx +178 -0
- package/src/blocks/e-commerce/reviews/review-rating-2.tsx +246 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1-spinning-counter.tsx +142 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-1.tsx +291 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-item-row.tsx +110 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-recommendations.tsx +51 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2-spinning-counter.tsx +146 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-2.tsx +295 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3-spinning-counter.tsx +142 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-3.tsx +342 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4-spinning-counter.tsx +146 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +391 -0
- package/src/blocks/e-commerce/store-containers/store-container-1.tsx +29 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +244 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-2.tsx +283 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-5.tsx +318 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-6.tsx +205 -0
- package/src/blocks/marketing/banners/avatar-group.tsx +69 -0
- package/src/blocks/marketing/banners/banner-1.tsx +188 -0
- package/src/blocks/marketing/cta/cta-section-1.tsx +112 -0
- package/src/blocks/marketing/faq/faq-block-1.tsx +45 -0
- package/src/blocks/marketing/features/feature-1.tsx +97 -0
- package/src/blocks/marketing/features/feature-2.tsx +82 -0
- package/src/blocks/marketing/footers/footer-section-1.tsx +141 -0
- package/src/blocks/marketing/footers/footer-section-3.tsx +192 -0
- package/src/blocks/marketing/footers/footer-section-4.tsx +355 -0
- package/src/blocks/marketing/hero-sections/avatar-group.tsx +86 -0
- package/src/blocks/marketing/hero-sections/hero-section-1.tsx +135 -0
- package/src/blocks/marketing/logo-clouds/logo-cloud-1-logo-icon.tsx +52 -0
- package/src/blocks/marketing/logo-clouds/logo-cloud-1.tsx +56 -0
- package/src/blocks/marketing/navbars/navbar-1.tsx +267 -0
- package/src/blocks/marketing/navbars/navbar-2.tsx +351 -0
- package/src/blocks/marketing/navbars/navbar-3.tsx +303 -0
- package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +118 -0
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +248 -0
- package/src/blocks/marketing/navbars/navbar-4-like-button.tsx +177 -0
- package/src/blocks/marketing/navbars/navbar-4-mobile-nav.tsx +104 -0
- package/src/blocks/marketing/navbars/navbar-4-product-grid.tsx +219 -0
- package/src/blocks/marketing/navbars/navbar-4-search-bar.tsx +52 -0
- package/src/blocks/marketing/navbars/navbar-4.tsx +411 -0
- package/src/blocks/marketing/pricing/pricing-section-1.tsx +94 -0
- package/src/blocks/marketing/statistics/statistic-1.tsx +93 -0
- package/src/blocks/marketing/testimonials/testimonial-1.tsx +141 -0
- package/src/blocks/marketing/testimonials/testimonial-2.tsx +108 -0
- package/src/blocks/shared/reaction-toggle.tsx +145 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/index.ts +200 -0
- package/src/lib/contracts.test.ts +32 -0
- package/src/lib/contracts.ts +235 -0
- package/src/lib/types/menu.ts +43 -0
- package/src/lib/types.ts +4 -0
- package/src/lib/utils.ts +10 -0
- package/src/ui/accordion.tsx +65 -0
- package/src/ui/avatar.tsx +108 -0
- package/src/ui/badge.tsx +47 -0
- package/src/ui/breadcrumb.tsx +108 -0
- package/src/ui/button-group.tsx +83 -0
- package/src/ui/button.tsx +63 -0
- package/src/ui/card.tsx +91 -0
- package/src/ui/carousel.tsx +241 -0
- package/src/ui/dialog.tsx +158 -0
- package/src/ui/drawer.tsx +134 -0
- package/src/ui/dropdown-menu.tsx +256 -0
- package/src/ui/empty.tsx +103 -0
- package/src/ui/field.tsx +248 -0
- package/src/ui/input-group.tsx +170 -0
- package/src/ui/input-otp.tsx +76 -0
- package/src/ui/input.tsx +20 -0
- package/src/ui/label.tsx +23 -0
- package/src/ui/navigation-menu.tsx +167 -0
- package/src/ui/pagination.tsx +127 -0
- package/src/ui/progress.tsx +30 -0
- package/src/ui/radio-group.tsx +44 -0
- package/src/ui/select.tsx +189 -0
- package/src/ui/separator.tsx +27 -0
- package/src/ui/sheet.tsx +142 -0
- package/src/ui/sidebar.tsx +726 -0
- package/src/ui/skeleton.tsx +13 -0
- package/src/ui/table.tsx +115 -0
- package/src/ui/tabs.tsx +90 -0
- package/src/ui/textarea.tsx +17 -0
- package/src/ui/toggle-group.tsx +83 -0
- package/src/ui/toggle.tsx +46 -0
- package/src/ui/tooltip.tsx +56 -0
- package/tsconfig.json +20 -0
- package/tsup.config.ts +16 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react'
|
|
4
|
+
import { Button } from '../../../ui/button'
|
|
5
|
+
import { cn } from '../../../lib/utils'
|
|
6
|
+
import { Heart, ShoppingBag, Truck, Star, Check } from 'lucide-react'
|
|
7
|
+
import { Carousel, CarouselContent, CarouselItem, type CarouselApi } from '../../../ui/carousel'
|
|
8
|
+
import { ReactionToggle } from '../../shared/reaction-toggle'
|
|
9
|
+
|
|
10
|
+
export interface ProductOverview2ProductImage {
|
|
11
|
+
src: string
|
|
12
|
+
alt: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ProductOverview2ProductColor {
|
|
16
|
+
name: string
|
|
17
|
+
value: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ProductOverview2ProductSize {
|
|
21
|
+
eu: number
|
|
22
|
+
uk: number
|
|
23
|
+
us: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ProductOverview2Product {
|
|
27
|
+
id: string
|
|
28
|
+
brand: string
|
|
29
|
+
name: string
|
|
30
|
+
sku: string
|
|
31
|
+
price: number
|
|
32
|
+
currency: string
|
|
33
|
+
rating: number
|
|
34
|
+
reviews: number
|
|
35
|
+
images: ProductOverview2ProductImage[]
|
|
36
|
+
sizes: ProductOverview2ProductSize[]
|
|
37
|
+
colors: ProductOverview2ProductColor[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ProductOverview2Props {
|
|
41
|
+
product: ProductOverview2Product
|
|
42
|
+
sizesLabel?: string
|
|
43
|
+
colorLabel?: string
|
|
44
|
+
reviewsLabel?: string
|
|
45
|
+
addToCartLabel?: string
|
|
46
|
+
removeFromWishlistLabel?: string
|
|
47
|
+
addToWishlistLabel?: string
|
|
48
|
+
sizeGuideLabel?: string
|
|
49
|
+
freeDeliveryLabel?: string
|
|
50
|
+
onAddToCart?: (product: ProductOverview2Product) => void
|
|
51
|
+
onToggleWishlist?: (product: ProductOverview2Product, isWishlisted: boolean) => void
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default function ProductOverview2({
|
|
55
|
+
product,
|
|
56
|
+
sizesLabel = 'Tallas',
|
|
57
|
+
colorLabel = 'Color',
|
|
58
|
+
reviewsLabel = 'Reseñas',
|
|
59
|
+
addToCartLabel = 'Agregar al carrito',
|
|
60
|
+
removeFromWishlistLabel = 'Quitar de favoritos',
|
|
61
|
+
addToWishlistLabel = 'Agregar a favoritos',
|
|
62
|
+
sizeGuideLabel = 'Guía de tallas',
|
|
63
|
+
freeDeliveryLabel = 'Envío gratis en pedidos superiores a $30.00',
|
|
64
|
+
onAddToCart,
|
|
65
|
+
onToggleWishlist,
|
|
66
|
+
}: ProductOverview2Props) {
|
|
67
|
+
const [selectedImage, setSelectedImage] = useState(0)
|
|
68
|
+
const [selectedSize, setSelectedSize] = useState<number | null>(product.sizes[1]?.eu ?? null)
|
|
69
|
+
const [selectedColor, setSelectedColor] = useState(product.colors[0])
|
|
70
|
+
const [isWishlisted, setIsWishlisted] = useState(false)
|
|
71
|
+
const [sizeType, setSizeType] = useState<'eu' | 'uk' | 'us'>('eu')
|
|
72
|
+
const [carouselApi, setCarouselApi] = useState<CarouselApi>()
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!carouselApi) return
|
|
76
|
+
|
|
77
|
+
// Set carousel to the selected image
|
|
78
|
+
carouselApi.scrollTo(selectedImage)
|
|
79
|
+
|
|
80
|
+
// Update selected image when carousel changes
|
|
81
|
+
const handleSelect = () => {
|
|
82
|
+
const currentIndex = carouselApi.selectedScrollSnap()
|
|
83
|
+
setSelectedImage(currentIndex)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
carouselApi.on('select', handleSelect)
|
|
87
|
+
return () => {
|
|
88
|
+
carouselApi.off('select', handleSelect)
|
|
89
|
+
}
|
|
90
|
+
}, [carouselApi, selectedImage])
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<section className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
|
|
94
|
+
<div className='grid grid-cols-1 gap-8 py-4 md:py-6 lg:grid-cols-2 lg:py-12'>
|
|
95
|
+
{/* Product Gallery */}
|
|
96
|
+
<div className='flex flex-col gap-4'>
|
|
97
|
+
{/* Main Image - Carousel */}
|
|
98
|
+
<Carousel setApi={setCarouselApi} className='w-full'>
|
|
99
|
+
<CarouselContent>
|
|
100
|
+
{product.images.map((image, index) => (
|
|
101
|
+
<CarouselItem key={index}>
|
|
102
|
+
<div className='flex h-130 w-full items-center justify-center overflow-hidden rounded-lg bg-muted'>
|
|
103
|
+
<img src={image.src || undefined} alt={image.alt} className='h-full w-full object-cover' />
|
|
104
|
+
</div>
|
|
105
|
+
</CarouselItem>
|
|
106
|
+
))}
|
|
107
|
+
</CarouselContent>
|
|
108
|
+
</Carousel>
|
|
109
|
+
|
|
110
|
+
{/* Thumbnails */}
|
|
111
|
+
<div className='flex flex-wrap gap-4'>
|
|
112
|
+
{product.images.map((image, index) => (
|
|
113
|
+
<button
|
|
114
|
+
type='button'
|
|
115
|
+
key={index}
|
|
116
|
+
onClick={() => setSelectedImage(index)}
|
|
117
|
+
aria-label={`Mostrar imagen ${index + 1} de ${product.name}`}
|
|
118
|
+
className='aspect-square size-16 cursor-pointer'
|
|
119
|
+
>
|
|
120
|
+
<img
|
|
121
|
+
src={image.src || undefined}
|
|
122
|
+
alt={image.alt}
|
|
123
|
+
className={cn(
|
|
124
|
+
'size-full cursor-pointer rounded-md object-cover ring-offset-2 transition-all',
|
|
125
|
+
selectedImage === index ? 'ring-foreground ring-2' : '',
|
|
126
|
+
)}
|
|
127
|
+
/>
|
|
128
|
+
</button>
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{/* Product Details */}
|
|
134
|
+
<div className='flex flex-col gap-6'>
|
|
135
|
+
{/* Product Header */}
|
|
136
|
+
<header className='flex flex-col gap-4'>
|
|
137
|
+
<div className='flex items-center'>
|
|
138
|
+
<Button variant='ghost' size='icon' className='bg-primary size-6 scale-75 cursor-pointer rounded-full'>
|
|
139
|
+
<Check className='text-primary-foreground size-4' />
|
|
140
|
+
</Button>
|
|
141
|
+
<span className='ms-2 font-medium'>{product.brand}</span>
|
|
142
|
+
<span className='text-muted-foreground ms-auto'>{product.sku}</span>
|
|
143
|
+
</div>
|
|
144
|
+
<h1 className='text-xl font-bold text-balance xl:text-3xl'>{product.name}</h1>
|
|
145
|
+
|
|
146
|
+
{/* Ratings */}
|
|
147
|
+
<div className='flex items-center gap-2'>
|
|
148
|
+
<div className='flex'>
|
|
149
|
+
{[...Array(5)].map((_, i) => (
|
|
150
|
+
<Star
|
|
151
|
+
key={i}
|
|
152
|
+
className={cn(
|
|
153
|
+
'size-5',
|
|
154
|
+
i < product.rating ? 'fill-foreground text-foreground' : 'text-muted-foreground',
|
|
155
|
+
)}
|
|
156
|
+
/>
|
|
157
|
+
))}
|
|
158
|
+
</div>
|
|
159
|
+
<span className='text-muted-foreground text-sm'>
|
|
160
|
+
{product.reviews} {reviewsLabel}
|
|
161
|
+
</span>
|
|
162
|
+
</div>
|
|
163
|
+
</header>
|
|
164
|
+
|
|
165
|
+
{/* Price */}
|
|
166
|
+
<p className='text-4xl font-bold'>
|
|
167
|
+
{product.currency}
|
|
168
|
+
{product.price.toFixed(2)}
|
|
169
|
+
</p>
|
|
170
|
+
{/* Color Selection */}
|
|
171
|
+
<div>
|
|
172
|
+
<div className='mb-4 flex items-center text-sm'>
|
|
173
|
+
<span className='font-medium'>{colorLabel}:</span>
|
|
174
|
+
<span className='text-muted-foreground ms-1'>{selectedColor.name}</span>
|
|
175
|
+
</div>
|
|
176
|
+
<div className='flex gap-3'>
|
|
177
|
+
{product.colors.map(color => (
|
|
178
|
+
<Button
|
|
179
|
+
key={color.name}
|
|
180
|
+
onClick={() => setSelectedColor(color)}
|
|
181
|
+
className={cn(
|
|
182
|
+
'h-9 px-4 py-2',
|
|
183
|
+
'ring-offset-background size-8 cursor-pointer rounded-sm ring-offset-2 transition-all',
|
|
184
|
+
selectedColor.name === color.name && 'ring-foreground ring-2',
|
|
185
|
+
selectedColor.name !== color.name &&
|
|
186
|
+
['Black', 'White'].includes(color.name) &&
|
|
187
|
+
'outline-muted outline-solid',
|
|
188
|
+
)}
|
|
189
|
+
style={{ backgroundColor: color.value }}
|
|
190
|
+
aria-label={`Seleccionar color ${color.name}`}
|
|
191
|
+
aria-selected={selectedColor.name === color.name}
|
|
192
|
+
/>
|
|
193
|
+
))}
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
{/* Size Selection */}
|
|
198
|
+
<div>
|
|
199
|
+
<div className='mb-4 flex items-center justify-between'>
|
|
200
|
+
<div className='flex items-center'>
|
|
201
|
+
<span className='me-4 text-sm font-medium'>{sizesLabel}</span>
|
|
202
|
+
<div className='border-input flex rounded-sm border p-1'>
|
|
203
|
+
{['eu', 'uk', 'us'].map(type => (
|
|
204
|
+
<Button
|
|
205
|
+
key={type}
|
|
206
|
+
variant='ghost'
|
|
207
|
+
size='sm'
|
|
208
|
+
onClick={() => setSizeType(type as 'eu' | 'uk' | 'us')}
|
|
209
|
+
className={cn(
|
|
210
|
+
'cursor-pointer rounded-sm px-2 text-xs',
|
|
211
|
+
sizeType === type && 'bg-secondary text-secondary-foreground',
|
|
212
|
+
)}
|
|
213
|
+
aria-label={`Seleccionar talla ${type.toUpperCase()}`}
|
|
214
|
+
aria-selected={sizeType === type}
|
|
215
|
+
>
|
|
216
|
+
{type.toUpperCase()}
|
|
217
|
+
</Button>
|
|
218
|
+
))}
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
<Button variant='ghost' className='h-9 px-4 py-2 text-primary cursor-pointer text-sm hover:underline'>
|
|
222
|
+
{sizeGuideLabel}
|
|
223
|
+
</Button>
|
|
224
|
+
</div>
|
|
225
|
+
<div className='grid grid-cols-4 gap-2 xl:grid-cols-5'>
|
|
226
|
+
{product.sizes.map(size => (
|
|
227
|
+
<Button
|
|
228
|
+
key={size.eu}
|
|
229
|
+
variant={selectedSize === size.eu ? 'secondary' : 'outline'}
|
|
230
|
+
onClick={() => setSelectedSize(size.eu)}
|
|
231
|
+
className='h-12 cursor-pointer rounded-full shadow-none'
|
|
232
|
+
>
|
|
233
|
+
{sizeType === 'eu' ? size.eu : sizeType === 'uk' ? size.uk : size.us}
|
|
234
|
+
</Button>
|
|
235
|
+
))}
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
{/* Add to Cart */}
|
|
239
|
+
<div>
|
|
240
|
+
<div className='mb-4 flex gap-4'>
|
|
241
|
+
<Button
|
|
242
|
+
className='h-12 flex-1 cursor-pointer rounded-md'
|
|
243
|
+
size='lg'
|
|
244
|
+
onClick={onAddToCart ? () => onAddToCart(product) : undefined}
|
|
245
|
+
>
|
|
246
|
+
<ShoppingBag />
|
|
247
|
+
{addToCartLabel}
|
|
248
|
+
</Button>
|
|
249
|
+
<Button
|
|
250
|
+
variant='outline'
|
|
251
|
+
size='icon'
|
|
252
|
+
className='border-input size-12 cursor-pointer rounded-md transition-colors'
|
|
253
|
+
onClick={() => {
|
|
254
|
+
const next = !isWishlisted
|
|
255
|
+
setIsWishlisted(next)
|
|
256
|
+
onToggleWishlist?.(product, next)
|
|
257
|
+
}}
|
|
258
|
+
aria-label={isWishlisted ? removeFromWishlistLabel : addToWishlistLabel}
|
|
259
|
+
>
|
|
260
|
+
<ReactionToggle active={isWishlisted} icon={Heart} iconClassName='size-5' activeColorClassName='text-primary' />
|
|
261
|
+
</Button>
|
|
262
|
+
</div>
|
|
263
|
+
<div className='text-muted-foreground flex items-center justify-center gap-2 text-sm'>
|
|
264
|
+
<Truck className='size-4' />
|
|
265
|
+
<span>{freeDeliveryLabel}</span>
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
</section>
|
|
271
|
+
)
|
|
272
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Camera } from 'lucide-react'
|
|
2
|
+
import { Badge } from '../../../ui/badge'
|
|
3
|
+
import { cn } from '../../../lib/utils'
|
|
4
|
+
|
|
5
|
+
import type { ProductOverview5Product } from './product-overview-5'
|
|
6
|
+
|
|
7
|
+
export interface ProductOverview5GalleryProps {
|
|
8
|
+
product: ProductOverview5Product
|
|
9
|
+
badgeLabel?: string
|
|
10
|
+
selectedImage: number
|
|
11
|
+
onSelectImage: (index: number) => void
|
|
12
|
+
photosLabel?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function ProductOverview5Gallery({
|
|
16
|
+
product,
|
|
17
|
+
badgeLabel,
|
|
18
|
+
selectedImage,
|
|
19
|
+
onSelectImage,
|
|
20
|
+
photosLabel = 'fotos',
|
|
21
|
+
}: ProductOverview5GalleryProps) {
|
|
22
|
+
return (
|
|
23
|
+
<div>
|
|
24
|
+
<div className='bg-muted relative aspect-square overflow-hidden rounded-xl'>
|
|
25
|
+
<img src={product.mainImages[selectedImage] || undefined} alt={product.name} className='size-full object-cover' />
|
|
26
|
+
{badgeLabel && (
|
|
27
|
+
<Badge className='px-2.5 py-0.5 font-semibold absolute top-4 left-4'>
|
|
28
|
+
{badgeLabel}
|
|
29
|
+
</Badge>
|
|
30
|
+
)}
|
|
31
|
+
<div className='absolute right-4 bottom-4 rounded-full bg-black/60 px-3 py-1.5 text-sm text-white backdrop-blur-sm'>
|
|
32
|
+
<Camera className='mr-1 inline size-4' />
|
|
33
|
+
{product.mainImages.length} {photosLabel}
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
{product.mainImages.length > 1 && (
|
|
38
|
+
<div className='mt-4 flex gap-3 overflow-x-auto pb-2'>
|
|
39
|
+
{product.mainImages.map((image, index) => (
|
|
40
|
+
<button
|
|
41
|
+
type='button'
|
|
42
|
+
key={index}
|
|
43
|
+
onClick={() => onSelectImage(index)}
|
|
44
|
+
className={cn(
|
|
45
|
+
'relative aspect-square size-20 flex-shrink-0 cursor-pointer overflow-hidden rounded-lg border-2 transition-all',
|
|
46
|
+
selectedImage === index ? 'border-primary ring-primary/20 shadow-md ring-2' : 'border-muted hover:border-muted-foreground/50',
|
|
47
|
+
)}
|
|
48
|
+
aria-label={`Mostrar imagen ${index + 1} de ${product.name}`}
|
|
49
|
+
>
|
|
50
|
+
<img src={image || undefined} alt={`${product.name} ${index + 1}`} className='size-full object-cover' />
|
|
51
|
+
</button>
|
|
52
|
+
))}
|
|
53
|
+
</div>
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default ProductOverview5Gallery
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { ChevronLeft, ChevronRight, X } from 'lucide-react'
|
|
5
|
+
import { Button } from '../../../ui/button'
|
|
6
|
+
import { Card, CardContent } from '../../../ui/card'
|
|
7
|
+
import { Dialog, DialogClose, DialogContent, DialogTitle, DialogTrigger } from '../../../ui/dialog'
|
|
8
|
+
import { cn } from '../../../lib/utils'
|
|
9
|
+
|
|
10
|
+
import type { ProductOverview5Product } from './product-overview-5'
|
|
11
|
+
|
|
12
|
+
export interface ProductOverview5LightboxTabProps {
|
|
13
|
+
product: ProductOverview5Product
|
|
14
|
+
galleryLabel?: string
|
|
15
|
+
ofCounterLabel?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function ProductOverview5LightboxTab({
|
|
19
|
+
product,
|
|
20
|
+
galleryLabel = 'Galería',
|
|
21
|
+
ofCounterLabel = '{current} de {total}',
|
|
22
|
+
}: ProductOverview5LightboxTabProps) {
|
|
23
|
+
const [modalImageIndex, setModalImageIndex] = useState(0)
|
|
24
|
+
|
|
25
|
+
const nextImage = () => setModalImageIndex(prev => (prev + 1) % product.galleryImages.length)
|
|
26
|
+
const prevImage = () => setModalImageIndex(prev => (prev - 1 + product.galleryImages.length) % product.galleryImages.length)
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<Card className='border shadow-none'>
|
|
30
|
+
<CardContent className='p-8 py-0'>
|
|
31
|
+
<h3 className='mb-6 text-xl font-semibold text-balance'>{galleryLabel}</h3>
|
|
32
|
+
<div className='grid gap-4 sm:grid-cols-2 lg:grid-cols-3'>
|
|
33
|
+
{product.galleryImages.map((image, index) => (
|
|
34
|
+
<Dialog key={index}>
|
|
35
|
+
<DialogTrigger asChild>
|
|
36
|
+
<button
|
|
37
|
+
type='button'
|
|
38
|
+
className='block aspect-square cursor-pointer overflow-hidden rounded-xl'
|
|
39
|
+
onClick={() => setModalImageIndex(index)}
|
|
40
|
+
>
|
|
41
|
+
<img src={image || undefined} alt={`${product.name} ${index + 1}`} className='size-full object-cover' />
|
|
42
|
+
</button>
|
|
43
|
+
</DialogTrigger>
|
|
44
|
+
<DialogContent className='max-w-4xl border-0 bg-transparent p-0' showCloseButton={false}>
|
|
45
|
+
<DialogTitle className='sr-only'>Imagen de galería {modalImageIndex + 1}</DialogTitle>
|
|
46
|
+
<div className='relative'>
|
|
47
|
+
<img
|
|
48
|
+
src={product.galleryImages[modalImageIndex] || undefined}
|
|
49
|
+
alt={`${product.name} ${modalImageIndex + 1}`}
|
|
50
|
+
className='max-h-[80vh] w-full object-contain'
|
|
51
|
+
/>
|
|
52
|
+
<DialogClose className='absolute top-4 right-4 rounded-full bg-black/20 p-2 text-white transition-colors hover:bg-black/30'>
|
|
53
|
+
<X className='size-4' />
|
|
54
|
+
</DialogClose>
|
|
55
|
+
|
|
56
|
+
{product.galleryImages.length > 1 && (
|
|
57
|
+
<>
|
|
58
|
+
<Button
|
|
59
|
+
variant='ghost'
|
|
60
|
+
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'
|
|
62
|
+
onClick={prevImage}
|
|
63
|
+
>
|
|
64
|
+
<ChevronLeft className='size-4' />
|
|
65
|
+
</Button>
|
|
66
|
+
<Button
|
|
67
|
+
variant='ghost'
|
|
68
|
+
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'
|
|
70
|
+
onClick={nextImage}
|
|
71
|
+
>
|
|
72
|
+
<ChevronRight className='size-4' />
|
|
73
|
+
</Button>
|
|
74
|
+
</>
|
|
75
|
+
)}
|
|
76
|
+
|
|
77
|
+
<div className='absolute bottom-4 left-1/2 flex -translate-x-1/2 gap-2'>
|
|
78
|
+
{product.galleryImages.map((_, idx) => (
|
|
79
|
+
<button
|
|
80
|
+
type='button'
|
|
81
|
+
key={idx}
|
|
82
|
+
aria-label={`Mostrar imagen ${idx + 1}`}
|
|
83
|
+
className={cn('size-2 rounded-full transition-colors', idx === modalImageIndex ? 'bg-white' : 'bg-white/50')}
|
|
84
|
+
onClick={() => setModalImageIndex(idx)}
|
|
85
|
+
/>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<div className='absolute right-4 bottom-4 rounded-full bg-black/20 px-3 py-1 text-sm text-white'>
|
|
90
|
+
{ofCounterLabel
|
|
91
|
+
.replace('{current}', String(modalImageIndex + 1))
|
|
92
|
+
.replace('{total}', String(product.galleryImages.length))}
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</DialogContent>
|
|
96
|
+
</Dialog>
|
|
97
|
+
))}
|
|
98
|
+
</div>
|
|
99
|
+
</CardContent>
|
|
100
|
+
</Card>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export default ProductOverview5LightboxTab
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Star } from 'lucide-react'
|
|
2
|
+
import { Card, CardContent } from '../../../ui/card'
|
|
3
|
+
import { Progress } from '../../../ui/progress'
|
|
4
|
+
|
|
5
|
+
import type { ProductOverview5Product } from './product-overview-5'
|
|
6
|
+
|
|
7
|
+
export interface ProductOverview5ReviewsTabProps {
|
|
8
|
+
product: ProductOverview5Product
|
|
9
|
+
basedOnReviewsLabel?: string
|
|
10
|
+
foundHelpfulLabel?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function renderStars(rating: number) {
|
|
14
|
+
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' : ''}`} />
|
|
16
|
+
))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function formatDate(dateString: string) {
|
|
20
|
+
return new Date(dateString).toLocaleDateString('es-ES', { year: 'numeric', month: 'short', day: 'numeric' })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ProductOverview5ReviewsTab({
|
|
24
|
+
product,
|
|
25
|
+
basedOnReviewsLabel = 'Basado en {count} reseñas',
|
|
26
|
+
foundHelpfulLabel = '{count} personas encontraron esto útil',
|
|
27
|
+
}: ProductOverview5ReviewsTabProps) {
|
|
28
|
+
return (
|
|
29
|
+
<div className='flex flex-col gap-6'>
|
|
30
|
+
<Card className='border shadow-none'>
|
|
31
|
+
<CardContent className='p-8 py-0'>
|
|
32
|
+
<div className='flex flex-col items-start gap-8 md:flex-row md:items-center'>
|
|
33
|
+
<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>
|
|
36
|
+
<div className='text-muted-foreground mt-2 text-sm'>
|
|
37
|
+
{basedOnReviewsLabel.replace('{count}', String(product.userReviews.length))}
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
<div className='flex-1'>
|
|
41
|
+
{[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
|
|
44
|
+
return (
|
|
45
|
+
<div key={stars} className='mb-3 flex items-center gap-3'>
|
|
46
|
+
<span className='w-8 text-sm font-medium'>{stars}★</span>
|
|
47
|
+
<Progress value={percentage} className='h-2 flex-1 rounded-full' />
|
|
48
|
+
<span className='text-muted-foreground w-8 text-sm'>{count}</span>
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
})}
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
</CardContent>
|
|
55
|
+
</Card>
|
|
56
|
+
|
|
57
|
+
<div className='flex flex-col gap-4'>
|
|
58
|
+
{product.userReviews.map(review => (
|
|
59
|
+
<Card key={review.id} className='border shadow-none'>
|
|
60
|
+
<CardContent className='p-6 py-0'>
|
|
61
|
+
<div className='mb-4 flex items-start justify-between'>
|
|
62
|
+
<div>
|
|
63
|
+
<div className='text-sm font-medium'>{review.author}</div>
|
|
64
|
+
<div className='mt-2 flex items-center gap-2'>
|
|
65
|
+
<div className='flex'>{renderStars(review.rating)}</div>
|
|
66
|
+
<span className='text-muted-foreground text-sm'>{formatDate(review.date)}</span>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
<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>
|
|
74
|
+
</CardContent>
|
|
75
|
+
</Card>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export default ProductOverview5ReviewsTab
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Tabs as TabsPrimitive } from '@base-ui/react/tabs'
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../../lib/utils'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The full tab set this block renders on.
|
|
7
|
+
*
|
|
8
|
+
* The block's classes target Base UI state attributes (`data-active:bg-transparent` on the
|
|
9
|
+
* triggers), the indicator reads Base UI positioning CSS variables (`--active-tab-*`), and the
|
|
10
|
+
* content fade relies on Base UI unmounting the inactive panel - so the whole tabs area runs on
|
|
11
|
+
* Base UI rather than the Radix-backed shadcn `tabs`. It lives here rather than in the shared ui
|
|
12
|
+
* `tabs` so the block installs standalone: the shadcn `tabs` component you get from the registry
|
|
13
|
+
* has no indicator or fading panel.
|
|
14
|
+
*/
|
|
15
|
+
export function ProductOverview5Tabs(props: TabsPrimitive.Root.Props) {
|
|
16
|
+
return <TabsPrimitive.Root data-slot='tabs' {...props} />
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ProductOverview5TabsList({ className, ...props }: TabsPrimitive.List.Props) {
|
|
20
|
+
return <TabsPrimitive.List data-slot='tabs-list' className={className} {...props} />
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Base UI's tab button carries no layout of its own, so the inline-flex centring that the
|
|
25
|
+
* Radix-backed shadcn trigger used to supply is re-stated here.
|
|
26
|
+
*/
|
|
27
|
+
export function ProductOverview5TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
|
28
|
+
return (
|
|
29
|
+
<TabsPrimitive.Tab
|
|
30
|
+
data-slot='tabs-trigger'
|
|
31
|
+
className={cn('relative inline-flex items-center justify-center whitespace-nowrap', className)}
|
|
32
|
+
{...props}
|
|
33
|
+
/>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Panel with a fade-in on every switch. Base UI unmounts the inactive panel by default, so each
|
|
39
|
+
* activation is a fresh mount - `animate-in` plays automatically without needing a transition-state
|
|
40
|
+
* data attribute to gate it.
|
|
41
|
+
*/
|
|
42
|
+
export function ProductOverview5TabsContentFade({ className, ...props }: TabsPrimitive.Panel.Props) {
|
|
43
|
+
return (
|
|
44
|
+
<TabsPrimitive.Panel
|
|
45
|
+
className={cn('animate-in fade-in-0 duration-200 motion-reduce:animate-none', className)}
|
|
46
|
+
{...props}
|
|
47
|
+
/>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Sliding pill that tracks the active trigger. Render as the first child of TabsList, and pair
|
|
53
|
+
* with `data-active:bg-transparent` on the triggers so their own active background doesn't double
|
|
54
|
+
* up with the pill.
|
|
55
|
+
*/
|
|
56
|
+
export function ProductOverview5TabsIndicator({ className, ...props }: TabsPrimitive.Indicator.Props) {
|
|
57
|
+
return (
|
|
58
|
+
<TabsPrimitive.Indicator
|
|
59
|
+
data-slot='tabs-indicator'
|
|
60
|
+
className={cn(
|
|
61
|
+
'absolute z-0 rounded-md bg-background shadow-sm transition-[left,top,width,height] duration-[250ms] ease-[cubic-bezier(0.22,1,0.36,1)] dark:bg-input/30',
|
|
62
|
+
'left-(--active-tab-left) top-(--active-tab-top) h-(--active-tab-height) w-(--active-tab-width)',
|
|
63
|
+
'motion-reduce:transition-none',
|
|
64
|
+
className
|
|
65
|
+
)}
|
|
66
|
+
{...props}
|
|
67
|
+
/>
|
|
68
|
+
)
|
|
69
|
+
}
|