This commit is contained in:
Your Name committed 2026-10-06 19:40:15 +07:00
1 parent ce4502d123
commit acee55a633
14 files changed
+634 -104

No files matched your search

@@ -19,18 +19,57 @@ export default function DynamicProductCarousel({ payload }: DynamicProductCarous
const title = getLocalizedField(p.title, lang);
const description = getLocalizedField(p.description, lang);
const cardStyle = p.cardStyle || 'standard';
const isMinimal = cardStyle === 'minimal_swatch';
const isMinimal = cardStyle === 'minimal' || cardStyle === 'swatch' || cardStyle === 'minimal_swatch';
const viewAllLink = p.viewAllLink || '/products';
const viewAllText = getLocalizedField(p.viewAllText, lang) || 'View All';
const badgeText = getLocalizedField(p.badgeText, lang);
const layout = p.layout || 'carousel';
const containerWidth = p.containerWidth || 'standard';
const cardSize = p.cardSize || 'medium';
const getContainerWidthClass = (width: string) => {
switch (width) {
case 'full':
return 'w-full max-w-none';
case 'wide':
return 'w-full max-w-[1600px] mx-auto';
case 'standard':
default:
return 'ui-section-inner';
}
};
const getCarouselCardWidth = () => {
switch (cardSize) {
case 'compact':
return 'w-[46vw] sm:w-[calc((100%-32px)/3)] md:w-[calc((100%-60px)/4)] lg:w-[calc((100%-100px)/6)] flex-none snap-start';
case 'large':
return 'w-[78vw] sm:w-[calc((100%-16px)/2)] md:w-[calc((100%-20px)/2)] lg:w-[calc((100%-40px)/3)] flex-none snap-start';
case 'medium':
default:
return 'w-[68vw] sm:w-[calc((100%-16px)/2)] md:w-[calc((100%-40px)/3)] lg:w-[calc((100%-60px)/4)] flex-none snap-start';
}
};
const getGridColsClass = () => {
switch (cardSize) {
case 'compact':
return 'grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6';
case 'large':
return 'grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3';
case 'medium':
default:
return 'grid grid-cols-2 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4';
}
};
const carouselRef = useRef<HTMLDivElement>(null);
const scrollLeft = () => carouselRef.current?.scrollBy({ left: -300, behavior: 'smooth' });
const scrollRight = () => carouselRef.current?.scrollBy({ left: 300, behavior: 'smooth' });
return (
<section className={`bg-pop-bg ${getSectionPaddingClasses(p)}`}>
<div className="ui-section-inner space-y-6">
<section className={`bg-pop-bg px-4 sm:px-6 md:px-8 overflow-hidden ${getSectionPaddingClasses(p)}`}>
<div className={`${getContainerWidthClass(containerWidth)} 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">
@@ -55,6 +94,18 @@ export default function DynamicProductCarousel({ payload }: DynamicProductCarous
<div className="text-center py-12 text-pop-black/50 italic">
No products to display.
</div>
) : layout === 'grid' ? (
<div className={getGridColsClass()}>
{products.map((prod: any) => (
<ProductCard
key={prod.id}
product={prod}
cardStyle={cardStyle}
badgeText={badgeText}
enableQuickShop={cardStyle === 'standard'}
/>
))}
</div>
) : (
<div className="relative -mx-4 px-4 sm:mx-0 sm:px-0">
<button
@@ -68,17 +119,14 @@ export default function DynamicProductCarousel({ payload }: DynamicProductCarous
{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"
}
className={getCarouselCardWidth()}
>
{isMinimal ? (
<ProductSwatchCard product={prod} badgeText={badgeText} />
) : (
<ProductCard product={prod} enableQuickShop={true} />
)}
<ProductCard
product={prod}
cardStyle={cardStyle}
badgeText={badgeText}
enableQuickShop={cardStyle === 'standard'}
/>
</div>
))}
</div>
+44 -4
View File
@@ -20,13 +20,53 @@ export default function DynamicProducts({ payload, categories = [] }: DynamicPro
const subtitle = getLocalizedField(payload.subtitle, lang);
const categoryLookup = new Map(categories.map((category) => [category.id, category]));
const containerWidth = payload.containerWidth || 'standard';
const getContainerWidthClass = (width: string) => {
switch (width) {
case 'full':
return 'w-full max-w-none';
case 'wide':
return 'w-full max-w-[1600px] mx-auto';
case 'standard':
default:
return 'ui-section-inner';
}
};
const cardSize = payload.cardSize || 'medium';
const getCarouselCardWidth = () => {
switch (cardSize) {
case 'compact':
return 'w-[46vw] sm:w-[calc((100%-32px)/3)] md:w-[calc((100%-60px)/4)] lg:w-[calc((100%-100px)/6)] flex-none snap-start';
case 'large':
return 'w-[78vw] sm:w-[calc((100%-16px)/2)] md:w-[calc((100%-20px)/2)] lg:w-[calc((100%-40px)/3)] flex-none snap-start';
case 'medium':
default:
return 'w-[68vw] sm:w-[calc((100%-16px)/2)] md:w-[calc((100%-40px)/3)] lg:w-[calc((100%-60px)/4)] flex-none snap-start';
}
};
const getGridColsClass = () => {
switch (cardSize) {
case 'compact':
return 'grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6';
case 'large':
return 'grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3';
case 'medium':
default:
return 'grid grid-cols-2 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4';
}
};
const carouselRef = useRef<HTMLDivElement>(null);
const scrollLeft = () => carouselRef.current?.scrollBy({ left: -300, behavior: 'smooth' });
const scrollRight = () => carouselRef.current?.scrollBy({ left: 300, behavior: 'smooth' });
return (
<section className={`relative bg-pop-bg ${getSectionPaddingClasses(payload)}`}>
<div className="ui-section-inner relative z-10 space-y-12">
<section className={`relative bg-pop-bg px-4 sm:px-6 md:px-8 overflow-hidden ${getSectionPaddingClasses(payload)}`}>
<div className={`${getContainerWidthClass(containerWidth)} relative z-10 space-y-12`}>
<div className="flex flex-col md:flex-row items-start md:items-end justify-between gap-6 border-b border-pop-black/10 pb-6">
<div>
<h2 className="text-4xl font-bold text-pop-black md:text-5xl">
@@ -60,7 +100,7 @@ export default function DynamicProducts({ payload, categories = [] }: DynamicPro
</button>
<div ref={carouselRef} className="flex overflow-x-auto snap-x snap-mandatory hide-scrollbar gap-4 pb-12">
{products.map((p) => (
<div key={p.id} className="w-[85vw] sm:w-[300px] flex-none snap-start">
<div key={p.id} className={getCarouselCardWidth()}>
<ProductCard
product={p}
enableQuickShop={enableQuickShop}
@@ -78,7 +118,7 @@ export default function DynamicProducts({ payload, categories = [] }: DynamicPro
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className={getGridColsClass()}>
{products.map((p) => (
<div key={p.id}>
<ProductCard
+131 -2
View File
@@ -7,6 +7,8 @@ import { useWishlistStore } from '../store/wishlistStore';
import { useCartStore } from '../store/cartStore';
import { useTranslation } from 'react-i18next';
import ProductSwatchCard from './ProductSwatchCard';
function resolveStr(v: TranslatableString | null | undefined, lang: string): string {
if (!v) return ''
if (typeof v === 'string') return v
@@ -35,9 +37,17 @@ interface ProductCardProps {
product: Product;
enableQuickShop?: boolean;
categoryNameOverride?: string;
cardStyle?: 'standard' | 'minimal' | 'swatch' | 'minimal_swatch';
badgeText?: string | null;
}
export default function ProductCard({ product, enableQuickShop = false, categoryNameOverride }: ProductCardProps) {
export default function ProductCard({
product,
enableQuickShop = false,
categoryNameOverride,
cardStyle = 'standard',
badgeText
}: ProductCardProps) {
const navigate = useNavigate();
const { addItem, removeItem, isInWishlist } = useWishlistStore();
const { addToCart } = useCartStore();
@@ -62,12 +72,14 @@ export default function ProductCard({ product, enableQuickShop = false, category
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 displayCategoryName = categoryNameOverride || resolveStr(product.category?.name, i18n.language);
const fallbackImage = getCategoryImage(displayCategoryName);
const images = (product.images?.length ?? 0) > 0
? product.images!.slice(0, 3).map(img => img.url)
: [fallbackImage];
const hasMultipleImages = images.length > 1;
const displayPrice = selectedVariant?.price ?? product.variants?.[0]?.price ?? 0;
const totalStock = product.variants?.reduce((sum, v) => sum + (v.stock ?? 0), 0) ?? 0;
const handleWishlistClick = (e: React.MouseEvent) => {
e.preventDefault();
@@ -83,6 +95,123 @@ export default function ProductCard({ product, enableQuickShop = false, category
navigate(`/products/${product.id}`);
};
if (cardStyle === 'swatch' || cardStyle === 'minimal_swatch') {
return <ProductSwatchCard product={product} badgeText={badgeText} />;
}
if (cardStyle === 'minimal') {
return (
<div
onClick={handleCardClick}
className="group flex flex-col h-full select-none cursor-pointer"
>
{/* 3:4 Image Frame */}
<div className="relative aspect-[3/4] overflow-hidden rounded-lg bg-pop-black/5 group/image">
{hasMultipleImages ? (
<>
{/* Desktop Hover Effect */}
<div className="hidden md:block absolute inset-0 w-full h-full">
<img src={images[0]} alt={displayName} className="absolute inset-0 w-full h-full object-cover group-hover/image:opacity-0 transition-opacity duration-700" />
<img src={images[1]} alt={displayName} className="absolute inset-0 w-full h-full object-cover opacity-0 group-hover/image:opacity-100 group-hover/image:scale-105 transition-all duration-700" />
</div>
{/* Mobile Native Slider */}
<div className="md:hidden relative w-full h-full" onClick={(e) => e.stopPropagation()}>
<div className="flex w-full h-full overflow-x-auto snap-x snap-mandatory hide-scrollbar">
{images.map((img, idx) => (
<div key={idx} className="relative w-full h-full flex-none snap-center snap-always block" onClick={handleCardClick}>
<img src={img} alt={`${displayName} ${idx + 1}`} className="absolute inset-0 w-full h-full object-cover" />
</div>
))}
</div>
<div className="absolute bottom-2 left-0 right-0 flex justify-center gap-1.5 z-10 pointer-events-none">
{images.map((_, idx) => (
<div key={idx} className="w-1.5 h-1.5 rounded-full bg-white/80 shadow-[0_1px_2px_rgba(0,0,0,0.3)]" />
))}
</div>
</div>
</>
) : (
<img
src={images[0]}
className="w-full h-full object-cover group-hover/image:scale-105 transition-transform duration-700"
alt={displayName}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
)}
{badgeText && (
<span className="absolute top-2.5 left-2.5 z-20 text-[9px] font-bold uppercase tracking-widest px-2 py-0.5 rounded-full bg-black/70 text-white backdrop-blur-sm">
{badgeText}
</span>
)}
</div>
{/* Content Info (Fashion Minimal / Pomelo Style) */}
<div className="pt-2 pb-1 flex flex-col gap-1">
{/* Row 1: Brand Name + Wishlist */}
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-pop-black uppercase tracking-wider truncate">
{product.brand?.name || 'Brand'}
</span>
<button
type="button"
onClick={handleWishlistClick}
className="p-1 text-pop-black/40 hover:text-pop-pink transition-colors cursor-pointer -mr-1"
aria-label="Wishlist"
>
<Heart
className={`w-3.5 h-3.5 transition-colors ${isWished ? 'fill-pop-pink text-pop-pink' : 'text-pop-black/40 hover:text-pop-pink'}`}
/>
</button>
</div>
{/* Row 2: Product Name */}
<h3 className="line-clamp-1 text-xs md:text-sm font-normal text-pop-black transition-colors group-hover:text-pop-pink">
{displayName}
</h3>
{/* Row 3: Price + Status */}
<div className="flex items-center justify-between text-xs pt-0.5">
<span className="font-bold text-pop-black md:text-sm">
{formatPrice(displayPrice)}
</span>
{totalStock > 0 ? (
<span className="text-[10px] text-pop-black/50">
{t('product.inStock', 'In Stock')}
</span>
) : (
<span className="text-[10px] text-rose-500 font-semibold">
{t('product.outOfStock', 'Out of Stock')}
</span>
)}
</div>
{/* Row 4: Swatch Dots */}
{colorSwatches.length > 0 && (
<div className="flex items-center gap-1.5 pt-0.5 flex-wrap">
{colorSwatches.slice(0, 5).map(({ colorCode, variant, label }) => (
<span
key={variant.id || colorCode}
className="relative h-3 w-3 rounded-full border border-black/15 shadow-sm shrink-0"
style={{ backgroundColor: colorCode }}
title={label || colorCode}
/>
))}
{colorSwatches.length > 5 && (
<span className="text-[10px] font-semibold text-pop-black/60 pl-0.5">
+{colorSwatches.length - 5}
</span>
)}
</div>
)}
</div>
</div>
);
}
return (
<div
onClick={handleCardClick}
+15 -27
View File
@@ -91,34 +91,22 @@ export default function ProductSwatchCard({ product, badgeText }: ProductSwatchC
{formatPrice(displayPrice)}
</div>
{/* Color Swatch Dots */}
{/* Color Swatch Dots (Display Only) */}
{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 className="flex items-center gap-1.5 pt-1 flex-wrap">
{colorSwatches.slice(0, 5).map(({ colorCode, variant }) => (
<span
key={colorCode}
className="relative w-3.5 h-3.5 rounded-full border border-black/15 shadow-sm shrink-0"
style={{ backgroundColor: colorCode }}
title={variant.option1 || variant.option2 || colorCode}
/>
))}
{colorSwatches.length > 5 && (
<span className="text-[10px] font-semibold text-pop-black/60 pl-0.5">
+{colorSwatches.length - 5}
</span>
)}
</div>
)}
</div>