x
This commit is contained in:
1 parent
46968782b6
commit
08045eabff
18 files changed
+943
-104
No files matched your search
@@ -1,7 +1,9 @@
|
||||
import ProductCard from './ProductCard';
|
||||
import ProductSwatchCard from './ProductSwatchCard';
|
||||
import { useRef } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getLocalizedField } from '../utils/localization';
|
||||
import { getSectionPaddingClasses } from '../utils/spacing';
|
||||
|
||||
@@ -16,6 +18,11 @@ export default function DynamicProductCarousel({ payload }: DynamicProductCarous
|
||||
const { products = [] } = p;
|
||||
const title = getLocalizedField(p.title, lang);
|
||||
const description = getLocalizedField(p.description, lang);
|
||||
const cardStyle = p.cardStyle || 'standard';
|
||||
const isMinimal = cardStyle === 'minimal_swatch';
|
||||
const viewAllLink = p.viewAllLink || '/products';
|
||||
const viewAllText = getLocalizedField(p.viewAllText, lang) || 'View All';
|
||||
const badgeText = getLocalizedField(p.badgeText, lang);
|
||||
|
||||
const carouselRef = useRef<HTMLDivElement>(null);
|
||||
const scrollLeft = () => carouselRef.current?.scrollBy({ left: -300, behavior: 'smooth' });
|
||||
@@ -23,12 +30,25 @@ export default function DynamicProductCarousel({ payload }: DynamicProductCarous
|
||||
|
||||
return (
|
||||
<section className={`bg-pop-bg ${getSectionPaddingClasses(p)}`}>
|
||||
<div className="ui-section-inner">
|
||||
{(title || description) && (
|
||||
<div className="text-center mb-12">
|
||||
{title && <h2 className="text-3xl md:text-5xl font-serif text-pop-black mb-4">{title}</h2>}
|
||||
{description && <p className="text-lg text-pop-black/70 max-w-2xl mx-auto">{description}</p>}
|
||||
<div className="ui-section-inner space-y-6">
|
||||
{isMinimal ? (
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between border-b border-black/10 pb-5">
|
||||
<div className="flex flex-col md:flex-row md:items-baseline md:gap-4">
|
||||
{title && <h2 className="text-2xl md:text-4xl font-serif text-pop-black tracking-tight">{title}</h2>}
|
||||
{description && <p className="text-xs md:text-sm text-pop-black/60 font-normal mt-1 md:mt-0">{description}</p>}
|
||||
</div>
|
||||
<Link to={viewAllLink} className="inline-flex items-center gap-1.5 text-xs font-semibold text-pop-black hover:text-brand-primary transition-colors hover:underline self-start md:self-auto">
|
||||
<span>{viewAllText}</span>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
(title || description) && (
|
||||
<div className="text-center mb-12">
|
||||
{title && <h2 className="text-3xl md:text-5xl font-serif text-pop-black mb-4">{title}</h2>}
|
||||
{description && <p className="text-lg text-pop-black/70 max-w-2xl mx-auto">{description}</p>}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{products.length === 0 ? (
|
||||
@@ -39,24 +59,35 @@ export default function DynamicProductCarousel({ payload }: DynamicProductCarous
|
||||
<div className="relative -mx-4 px-4 sm:mx-0 sm:px-0">
|
||||
<button
|
||||
onClick={scrollLeft}
|
||||
className="hidden md:flex bg-surface-card rounded-full shadow-card hover:text-brand-primary h-10 w-10 items-center justify-center absolute z-10 top-1/2 -translate-y-1/2 -left-5"
|
||||
className="hidden md:flex bg-white rounded-full shadow-md hover:text-brand-primary h-9 w-9 items-center justify-center absolute z-10 top-1/2 -translate-y-1/2 -left-4 border border-black/10"
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<ChevronLeft className="h-6 w-6" />
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<div ref={carouselRef} className="flex overflow-x-auto snap-x snap-mandatory hide-scrollbar gap-4 pb-12">
|
||||
{products.map((p: any) => (
|
||||
<div key={p.id} className="w-[85vw] sm:w-[300px] flex-none snap-start">
|
||||
<ProductCard product={p} enableQuickShop={true} />
|
||||
<div ref={carouselRef} className="flex overflow-x-auto snap-x snap-mandatory hide-scrollbar gap-4 md:gap-5 pb-6">
|
||||
{products.map((prod: any) => (
|
||||
<div
|
||||
key={prod.id}
|
||||
className={
|
||||
isMinimal
|
||||
? "w-[42vw] sm:w-[200px] md:w-[220px] lg:w-[240px] flex-none snap-start"
|
||||
: "w-[85vw] sm:w-[300px] flex-none snap-start"
|
||||
}
|
||||
>
|
||||
{isMinimal ? (
|
||||
<ProductSwatchCard product={prod} badgeText={badgeText} />
|
||||
) : (
|
||||
<ProductCard product={prod} enableQuickShop={true} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={scrollRight}
|
||||
className="hidden md:flex bg-surface-card rounded-full shadow-card hover:text-brand-primary h-10 w-10 items-center justify-center absolute z-10 top-1/2 -translate-y-1/2 -right-5"
|
||||
className="hidden md:flex bg-white rounded-full shadow-md hover:text-brand-primary h-9 w-9 items-center justify-center absolute z-10 top-1/2 -translate-y-1/2 -right-4 border border-black/10"
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<ChevronRight className="h-6 w-6" />
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import type { Product, TranslatableString } from '../types';
|
||||
import { useState, useMemo } from 'react';
|
||||
import type { Product, Variant, TranslatableString } from '../types';
|
||||
import { getCategoryImage, formatPrice } from '../types';
|
||||
import { Heart, ShoppingBag, Layers } from 'lucide-react';
|
||||
import { useWishlistStore } from '../store/wishlistStore';
|
||||
@@ -43,7 +43,24 @@ export default function ProductCard({ product, enableQuickShop = false, category
|
||||
const { addToCart } = useCartStore();
|
||||
const { i18n, t } = useTranslation();
|
||||
const isWished = isInWishlist(product.id);
|
||||
const [selectedVariant, _setSelectedVariant] = useState(product.variants?.[0] || null);
|
||||
const [selectedVariant, setSelectedVariant] = useState(product.variants?.[0] || null);
|
||||
|
||||
// Extract unique color swatches from variants
|
||||
const colorSwatches = useMemo(() => {
|
||||
const swatches: Array<{ colorCode: string; variant: Variant; label: string }> = [];
|
||||
const seenColors = new Set<string>();
|
||||
|
||||
if (Array.isArray(product.variants)) {
|
||||
for (const v of product.variants) {
|
||||
if (v.colorCode && !seenColors.has(v.colorCode.toLowerCase())) {
|
||||
seenColors.add(v.colorCode.toLowerCase());
|
||||
const label = [v.option1, v.option2, v.option3].filter(Boolean).join(' / ');
|
||||
swatches.push({ colorCode: v.colorCode, variant: v, label });
|
||||
}
|
||||
}
|
||||
}
|
||||
return swatches;
|
||||
}, [product.variants]);
|
||||
const displayName = resolveStr(product.name, i18n.language) || (product.brand ? product.brand.name : '');
|
||||
const displayCategoryName = categoryNameOverride || resolveStr(product.category.name, i18n.language);
|
||||
const fallbackImage = getCategoryImage(displayCategoryName);
|
||||
@@ -131,14 +148,50 @@ export default function ProductCard({ product, enableQuickShop = false, category
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{enableQuickShop && product.variants && product.variants.length > 1 && (
|
||||
<div className="mb-1 mt-2 flex items-center">
|
||||
{colorSwatches.length > 0 || (enableQuickShop && product.variants && product.variants.length > 1) ? (
|
||||
colorSwatches.length > 0 ? (
|
||||
<div className="mb-1 mt-2 flex items-center gap-1.5 flex-wrap" onClick={(e) => e.stopPropagation()}>
|
||||
{colorSwatches.slice(0, 5).map(({ colorCode, variant, label }) => {
|
||||
const isSelected = selectedVariant?.id === variant.id || selectedVariant?.colorCode === colorCode;
|
||||
return (
|
||||
<button
|
||||
key={variant.id || colorCode}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSelectedVariant(variant);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (variant.imageUrl) {
|
||||
setSelectedVariant(variant);
|
||||
}
|
||||
}}
|
||||
className={`relative h-4 w-4 rounded-full border border-black/15 transition-all duration-200 hover:scale-125 focus:outline-none ${
|
||||
isSelected
|
||||
? 'ring-2 ring-pop-black ring-offset-2 scale-110 shadow-sm'
|
||||
: 'opacity-85 hover:opacity-100'
|
||||
}`}
|
||||
style={{ backgroundColor: colorCode }}
|
||||
title={label || colorCode}
|
||||
aria-label={label || `Color ${colorCode}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{colorSwatches.length > 5 && (
|
||||
<span className="text-[10px] font-semibold text-pop-black/60 pl-0.5">
|
||||
+{colorSwatches.length - 5}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-1 mt-2 flex items-center">
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-pop-black/5 px-2 py-1 text-[10px] font-semibold text-pop-black/70 border border-pop-black/10 shadow-sm transition-colors group-hover:bg-pop-black/10">
|
||||
<Layers className="h-3 w-3 opacity-70" />
|
||||
{product.variants.length} {t('product.variantsAvailable', 'Options')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
)) : null}
|
||||
|
||||
<div className="mt-auto flex flex-col gap-2 pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { Product, Variant, TranslatableString } from '../types';
|
||||
import { formatPrice } from '../types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function resolveStr(v: TranslatableString | null | undefined, lang: string): string {
|
||||
if (!v) return ''
|
||||
if (typeof v === 'string') return v
|
||||
return v[lang] ?? v[lang.slice(0, 2)] ?? v['th'] ?? v['en'] ?? Object.values(v)[0] ?? ''
|
||||
}
|
||||
|
||||
interface ProductSwatchCardProps {
|
||||
product: Product;
|
||||
badgeText?: string | null;
|
||||
}
|
||||
|
||||
export default function ProductSwatchCard({ product, badgeText }: ProductSwatchCardProps) {
|
||||
const { i18n } = useTranslation();
|
||||
const displayName = resolveStr(product.name, i18n.language) || (product.brand ? product.brand.name : 'Product');
|
||||
|
||||
// Find all unique color swatches from variants
|
||||
const colorSwatches = useMemo(() => {
|
||||
const swatches: { colorCode: string; variant: Variant }[] = [];
|
||||
const seenColors = new Set<string>();
|
||||
|
||||
if (Array.isArray(product.variants)) {
|
||||
for (const v of product.variants) {
|
||||
if (v.colorCode && !seenColors.has(v.colorCode.toLowerCase())) {
|
||||
seenColors.add(v.colorCode.toLowerCase());
|
||||
swatches.push({ colorCode: v.colorCode, variant: v });
|
||||
}
|
||||
}
|
||||
}
|
||||
return swatches;
|
||||
}, [product.variants]);
|
||||
|
||||
// Active selected variant
|
||||
const [activeVariant, setActiveVariant] = useState<Variant | null>(() => {
|
||||
return colorSwatches[0]?.variant || product.variants?.[0] || null;
|
||||
});
|
||||
|
||||
// Display Image: variant image if available, else first product image
|
||||
const displayImageUrl = useMemo(() => {
|
||||
if (activeVariant?.imageUrl) return activeVariant.imageUrl;
|
||||
if (product.images && product.images.length > 0 && product.images[0].url) {
|
||||
return product.images[0].url;
|
||||
}
|
||||
return product.category?.imageUrl || '/assets/hero.png';
|
||||
}, [activeVariant, product.images, product.category]);
|
||||
|
||||
const displayPrice = activeVariant?.price ?? product.variants?.[0]?.price ?? 0;
|
||||
|
||||
return (
|
||||
<div className="group flex flex-col h-full select-none">
|
||||
{/* 1:1 Image Frame */}
|
||||
<Link
|
||||
to={`/product/${product.id}`}
|
||||
className="relative aspect-square w-full rounded-2xl overflow-hidden bg-[#F6F5F2] border border-black/[0.04] transition-all duration-300 group-hover:shadow-md block"
|
||||
>
|
||||
{/* Badge (e.g. NEW) */}
|
||||
{badgeText && (
|
||||
<div className="absolute top-2.5 right-2.5 z-10">
|
||||
<span className="inline-block px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-widest bg-[#485645] text-[#F0F3EE] shadow-sm">
|
||||
{badgeText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Product Image */}
|
||||
<div className="relative w-full h-full p-3 flex items-center justify-center">
|
||||
<img
|
||||
src={displayImageUrl}
|
||||
alt={displayName}
|
||||
className="w-full h-full object-contain p-2 transition-transform duration-500 ease-out group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Meta (Title, Price, Swatches) */}
|
||||
<div className="pt-2.5 pb-1 flex flex-col gap-1">
|
||||
{/* Product Title */}
|
||||
<Link to={`/product/${product.id}`}>
|
||||
<h3 className="text-xs md:text-sm font-medium text-pop-black line-clamp-1 hover:underline transition-colors">
|
||||
{displayName}
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
{/* Price */}
|
||||
<div className="text-xs md:text-sm font-bold text-pop-black">
|
||||
{formatPrice(displayPrice)}
|
||||
</div>
|
||||
|
||||
{/* Color Swatch Dots */}
|
||||
{colorSwatches.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 pt-1">
|
||||
{colorSwatches.map(({ colorCode, variant }) => {
|
||||
const isActive = activeVariant?.colorCode === colorCode;
|
||||
return (
|
||||
<button
|
||||
key={colorCode}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setActiveVariant(variant);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (variant.imageUrl) {
|
||||
setActiveVariant(variant);
|
||||
}
|
||||
}}
|
||||
className={`relative w-3.5 h-3.5 rounded-full border border-black/15 transition-all duration-200 hover:scale-125 focus:outline-none ${
|
||||
isActive ? 'ring-2 ring-neutral-700 ring-offset-1 scale-110' : 'opacity-85 hover:opacity-100'
|
||||
}`}
|
||||
style={{ backgroundColor: colorCode }}
|
||||
title={variant.option1 || variant.option2 || colorCode}
|
||||
aria-label={`Color ${colorCode}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -304,7 +304,15 @@ export default function ProductDetailPage() {
|
||||
: 'bg-spa-light text-spa-stone border-spa-stone/20 hover:border-spa-forest hover:text-spa-forest shadow-sm'
|
||||
}`}
|
||||
>
|
||||
{[v.option1, v.option2, v.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / ')}
|
||||
<span className="inline-flex items-center gap-1.5 justify-center">
|
||||
{v.colorCode && (
|
||||
<span
|
||||
className="inline-block w-3 h-3 rounded-full border border-black/20 shadow-xs shrink-0"
|
||||
style={{ backgroundColor: v.colorCode }}
|
||||
/>
|
||||
)}
|
||||
<span>{[v.option1, v.option2, v.option3].filter(Boolean).map(opt => resolveOptionValue(opt, product.options, i18n.language)).join(' / ')}</span>
|
||||
</span>
|
||||
<span className={`block text-[10px] mt-0.5 ${selectedVariant === i ? 'text-spa-light' : 'text-spa-stone/60'}`}>
|
||||
{formatPrice(v.price)}
|
||||
</span>
|
||||
|
||||
Reference in new issue
Block a user