654 lines
22 KiB
TypeScript
654 lines
22 KiB
TypeScript
'use client';
|
||
|
||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||
import Link from 'next/link';
|
||
import { ChevronLeft, ChevronRight, Star } from 'lucide-react';
|
||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||
import { Autoplay, A11y, Navigation } from 'swiper/modules';
|
||
import type { Swiper as SwiperType } from 'swiper';
|
||
import { Product } from '../types';
|
||
import { getHotDealProduct } from '../utils/productMapper';
|
||
import { formatCurrency, getImageUrl } from '../lib/utils';
|
||
import { getSectionMeta, getSectionTitle } from '../lib/homepageDefaults';
|
||
|
||
import BlurHashImage from '../components/ui/BlurHashImage';
|
||
|
||
import 'swiper/css';
|
||
import 'swiper/css/navigation';
|
||
|
||
type TimerShape = { days: number; hours: number; minutes: number; seconds: number };
|
||
|
||
/** Remaining time until local end of today (true “deal of the day” window). */
|
||
function endOfTodayTimer(): TimerShape {
|
||
const now = new Date();
|
||
const end = new Date(now);
|
||
end.setHours(23, 59, 59, 999);
|
||
const diff = Math.max(0, Math.floor((end.getTime() - now.getTime()) / 1000));
|
||
return {
|
||
days: 0,
|
||
hours: Math.floor(diff / 3600),
|
||
minutes: Math.floor((diff % 3600) / 60),
|
||
seconds: diff % 60,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Deal of the Day countdown:
|
||
* - Prefer end of today (same-day deal).
|
||
* - Honor CMS `countdown_end` only when it falls within the next 24 hours.
|
||
* - Ignore far-future CMS dates (e.g. year-end) that produced 100+ day timers.
|
||
*/
|
||
function resolveDealOfDayTimer(iso?: string | null): TimerShape {
|
||
const today = endOfTodayTimer();
|
||
if (!iso) return today;
|
||
|
||
const endMs = new Date(iso).getTime();
|
||
if (Number.isNaN(endMs)) return today;
|
||
|
||
const diffSec = Math.floor((endMs - Date.now()) / 1000);
|
||
if (diffSec <= 0) return today;
|
||
// More than one day away → not a same-day deal; clamp to today
|
||
if (diffSec > 86400) return today;
|
||
|
||
return {
|
||
days: 0,
|
||
hours: Math.floor(diffSec / 3600),
|
||
minutes: Math.floor((diffSec % 3600) / 60),
|
||
seconds: diffSec % 60,
|
||
};
|
||
}
|
||
|
||
/** TechShop proportions — Hot Deals rail + 2×3 featured grid */
|
||
const FEAT_CARD_H = 148;
|
||
const FEAT_GAP = 14;
|
||
const FEAT_HEADER = 40;
|
||
const FEAT_TOP_GAP = 14;
|
||
const BLOCK_H = FEAT_HEADER + FEAT_TOP_GAP + FEAT_CARD_H * 2 + FEAT_GAP;
|
||
const HOT_IMG = 140;
|
||
|
||
function slugFromCmsProduct(p: any, idx: number): string {
|
||
if (p?.slug) return String(p.slug);
|
||
const link = String(p?.link || p?.url || p?.href || '');
|
||
const match = link.match(/\/products\/([^/?#]+)/i);
|
||
if (match?.[1]) return decodeURIComponent(match[1]);
|
||
return String(p?.id || `deal-${idx + 1}`);
|
||
}
|
||
|
||
interface HotDealsSectionProps {
|
||
config?: any;
|
||
products?: Product[];
|
||
loading?: boolean;
|
||
error?: boolean;
|
||
}
|
||
|
||
function SectionNav({
|
||
onPrev,
|
||
onNext,
|
||
}: {
|
||
onPrev: () => void;
|
||
onNext: () => void;
|
||
}) {
|
||
return (
|
||
<div className="flex items-center gap-0.5 text-[#999] shrink-0">
|
||
<button
|
||
type="button"
|
||
onClick={onPrev}
|
||
className="w-6 h-6 flex items-center justify-center hover:text-[#333] cursor-pointer transition-colors"
|
||
aria-label="Previous"
|
||
>
|
||
<ChevronLeft size={16} strokeWidth={2} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onNext}
|
||
className="w-6 h-6 flex items-center justify-center hover:text-[#333] cursor-pointer transition-colors"
|
||
aria-label="Next"
|
||
>
|
||
<ChevronRight size={16} strokeWidth={2} />
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Stars({ rating }: { rating: number }) {
|
||
const filled = Math.min(5, Math.max(0, Math.round(rating)));
|
||
return (
|
||
<div className="flex items-center gap-0.5">
|
||
{Array.from({ length: 5 }).map((_, i) => (
|
||
<Star
|
||
key={i}
|
||
size={12}
|
||
className={
|
||
i < filled
|
||
? 'fill-[#f5a623] text-[#f5a623]'
|
||
: 'fill-none text-[#d0d0d0]'
|
||
}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Pink box countdown — TechShop Hot Deals style (no colon separators) */
|
||
function HotCountdown({
|
||
days = 0,
|
||
hours = 0,
|
||
minutes = 0,
|
||
seconds = 0,
|
||
resetKey,
|
||
}: {
|
||
days?: number;
|
||
hours?: number;
|
||
minutes?: number;
|
||
seconds?: number;
|
||
resetKey: string;
|
||
}) {
|
||
const [remaining, setRemaining] = useState(
|
||
days * 86400 + hours * 3600 + minutes * 60 + seconds
|
||
);
|
||
|
||
useEffect(() => {
|
||
setRemaining(days * 86400 + hours * 3600 + minutes * 60 + seconds);
|
||
}, [days, hours, minutes, seconds, resetKey]);
|
||
|
||
useEffect(() => {
|
||
if (remaining <= 0) return;
|
||
const t = setInterval(() => setRemaining((s) => (s > 0 ? s - 1 : 0)), 1000);
|
||
return () => clearInterval(t);
|
||
}, [remaining]);
|
||
|
||
const d = Math.floor(remaining / 86400);
|
||
const h = Math.floor((remaining % 86400) / 3600);
|
||
const m = Math.floor((remaining % 3600) / 60);
|
||
const s = remaining % 60;
|
||
const pad = (n: number) => String(n).padStart(2, '0');
|
||
|
||
const units = [
|
||
{ val: pad(d), label: 'DAYS' },
|
||
{ val: pad(h), label: 'HRS' },
|
||
{ val: pad(m), label: 'MIN' },
|
||
{ val: pad(s), label: 'SEC' },
|
||
];
|
||
|
||
return (
|
||
<div className="flex items-center justify-center gap-1.5">
|
||
{units.map((u) => (
|
||
<div key={u.label} className="flex flex-col items-center">
|
||
<span className="min-w-[36px] h-8 px-1.5 bg-[#fff0f3] rounded-[3px] text-[14px] font-bold text-[#e53935] flex items-center justify-center tabular-nums">
|
||
{u.val}
|
||
</span>
|
||
<span className="text-[8px] font-semibold text-[#f48fb1] uppercase tracking-wide mt-1">
|
||
{u.label}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Full deal card — slides as one unit when multiple deals exist */
|
||
function DealCard({
|
||
product,
|
||
title,
|
||
timer,
|
||
}: {
|
||
product: Product;
|
||
title: string;
|
||
timer: TimerShape;
|
||
}) {
|
||
const discount =
|
||
product.discount ||
|
||
(product.oldPrice && product.oldPrice > product.price
|
||
? Math.round(
|
||
((product.oldPrice - product.price) / product.oldPrice) * 100
|
||
)
|
||
: 0);
|
||
|
||
return (
|
||
<div
|
||
className="w-full h-full bg-white border border-[#a8cce8] rounded-[4px] flex flex-col"
|
||
style={{ minHeight: BLOCK_H }}
|
||
>
|
||
<div
|
||
className="flex items-center justify-between px-3.5 border-b border-[#ececec] shrink-0"
|
||
style={{ height: FEAT_HEADER }}
|
||
>
|
||
<h3 className="text-[15px] font-bold text-[#222] leading-none">
|
||
{title}
|
||
</h3>
|
||
</div>
|
||
|
||
<div className="relative flex flex-col flex-1 px-4 pt-3 pb-4">
|
||
{discount > 0 ? (
|
||
<span className="absolute top-3 right-3.5 z-10 bg-primary text-white text-[10px] font-bold px-1.5 py-[3px] rounded-[2px] leading-none">
|
||
-{discount}%
|
||
</span>
|
||
) : null}
|
||
|
||
<Link
|
||
href={`/products/${product.slug || product.id}`}
|
||
className="flex items-center justify-center shrink-0 mx-auto w-full"
|
||
style={{ height: HOT_IMG }}
|
||
>
|
||
{product.image ? (
|
||
<div className="relative w-full h-full flex items-center justify-center">
|
||
<BlurHashImage
|
||
src={getImageUrl(product.image)}
|
||
blurHash={(product as any).blur_hash || null}
|
||
alt={product.name}
|
||
fill
|
||
sizes="(max-width: 768px) 100vw, 300px"
|
||
className="max-h-full max-w-[90%] object-contain"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="w-28 h-28 bg-gray-100" />
|
||
)}
|
||
</Link>
|
||
|
||
<div className="flex justify-center my-3 shrink-0">
|
||
<HotCountdown
|
||
resetKey={String(product.id || product.slug)}
|
||
days={timer.days}
|
||
hours={timer.hours}
|
||
minutes={timer.minutes}
|
||
seconds={timer.seconds}
|
||
/>
|
||
</div>
|
||
|
||
<div className="text-center mt-auto shrink-0">
|
||
<Link href={`/products/${product.slug || product.id}`}>
|
||
<h4 className="text-[13px] font-bold text-[#222] leading-snug hover:text-primary transition-colors mb-1.5 line-clamp-2 px-1">
|
||
{product.name}
|
||
</h4>
|
||
</Link>
|
||
<div className="flex justify-center mb-1.5">
|
||
<Stars rating={product.rating ?? 5} />
|
||
</div>
|
||
<div className="flex items-center justify-center gap-2">
|
||
{product.oldPrice != null && product.oldPrice > product.price ? (
|
||
<span className="text-[12px] text-gray-400 line-through">
|
||
{formatCurrency(Number(product.oldPrice))}
|
||
</span>
|
||
) : null}
|
||
<span className="text-[15px] font-bold text-[#e53935]">
|
||
{formatCurrency(Number(product.price))}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export const HotDealsSection: React.FC<HotDealsSectionProps> = ({
|
||
config,
|
||
products,
|
||
error,
|
||
}) => {
|
||
const [hotPool, setHotPool] = useState<Product[]>([]);
|
||
const [featuredPool, setFeaturedPool] = useState<Product[]>([]);
|
||
const [featuredPage, setFeaturedPage] = useState(0);
|
||
const dealSwiperRef = useRef<SwiperType | null>(null);
|
||
|
||
const meta = getSectionMeta(config);
|
||
const sectionTitle = getSectionTitle(config, 'deal_of_the_day', 'Hot Deals');
|
||
const featuredTitle =
|
||
(typeof meta.featured_title === 'string' && meta.featured_title.trim()) ||
|
||
'Monthly Featured Item';
|
||
const dealTimer = resolveDealOfDayTimer(meta.countdown_end);
|
||
|
||
useEffect(() => {
|
||
const pinnedSlugs: string[] = meta.pinned_product_slugs || [];
|
||
const featuredSlugs: string[] =
|
||
meta.featured_product_slugs || meta.featured_slugs || [];
|
||
const cmsProducts: any[] = Array.isArray(meta.products) ? meta.products : [];
|
||
const rawFeaturedItems: any[] = Array.isArray(meta.featuredItems)
|
||
? meta.featuredItems
|
||
: Array.isArray(meta.featured_items)
|
||
? meta.featured_items
|
||
: [];
|
||
|
||
const mapCmsProduct = (p: any, idx: number): Product => {
|
||
const oldVal = Number(p.oldPrice || p.originalPrice || 0);
|
||
const newVal = Number(p.dealPrice ?? p.price ?? 0);
|
||
const slug = slugFromCmsProduct(p, idx);
|
||
const catalogMatch = products?.find(
|
||
(c) => c.slug === slug || c.id === slug || c.id === p.id
|
||
);
|
||
return {
|
||
id: String(p.id || catalogMatch?.id || `deal-${idx + 1}`),
|
||
name: p.title || p.name || catalogMatch?.name || 'Deal item',
|
||
price: newVal || Number(catalogMatch?.price) || 0,
|
||
oldPrice: oldVal || catalogMatch?.oldPrice || undefined,
|
||
discount:
|
||
p.discount ||
|
||
(oldVal > newVal
|
||
? Math.round(((oldVal - newVal) / oldVal) * 100)
|
||
: catalogMatch?.discount || 0),
|
||
image:
|
||
getImageUrl(p.image || p.image_url) || catalogMatch?.image || '',
|
||
rating: p.rating || catalogMatch?.rating || 5.0,
|
||
reviewsCount: p.reviewsCount || catalogMatch?.reviewsCount || 20,
|
||
soldCount: p.soldCount || catalogMatch?.soldCount || 45,
|
||
totalStock: p.totalStock || catalogMatch?.totalStock || 100,
|
||
timeRemaining: dealTimer,
|
||
hasTimer: true,
|
||
category: catalogMatch?.category || 'Accessories',
|
||
slug,
|
||
brand: p.brand || catalogMatch?.brand || 'iFixKart',
|
||
features: catalogMatch?.features,
|
||
};
|
||
};
|
||
|
||
const resolveFromSlugs = (slugs: string[]): Product[] => {
|
||
if (!slugs.length || !products?.length) return [];
|
||
return slugs
|
||
.map((slug) => products.find((p) => p.slug === slug || p.id === slug))
|
||
.filter(Boolean) as Product[];
|
||
};
|
||
|
||
/** Deal timer only on the Deals Of The Day card — never mutate shared catalog products. */
|
||
const asDealCards = (list: Product[]) =>
|
||
list.map((p) => ({
|
||
...p,
|
||
hasTimer: true,
|
||
timeRemaining: dealTimer,
|
||
}));
|
||
|
||
/** Monthly Featured / other grids: no deal countdown. */
|
||
const withoutDealTimer = (list: Product[]) =>
|
||
list.map((p) => ({
|
||
...p,
|
||
hasTimer: false,
|
||
timeRemaining: undefined,
|
||
}));
|
||
|
||
const backfillFeatured = (
|
||
seed: Product[],
|
||
excludeIds: Set<string> = new Set()
|
||
): Product[] => {
|
||
if (seed.length >= 6) return seed.slice(0, 12);
|
||
if (!products || products.length === 0) return seed;
|
||
const taken = new Set([
|
||
...excludeIds,
|
||
...seed.map((p) => p.id || p.slug),
|
||
]);
|
||
const extras = products.filter(
|
||
(p) => !taken.has(p.id) && !taken.has(p.slug || '')
|
||
);
|
||
return [...seed, ...extras].slice(0, 12);
|
||
};
|
||
|
||
// ── Deal cards (slider when > 1) ──
|
||
let nextHot: Product[] = [];
|
||
const pinnedDeals = resolveFromSlugs(pinnedSlugs);
|
||
const hasSeparateFeatured = featuredSlugs.length > 0;
|
||
|
||
if (cmsProducts.length > 0) {
|
||
nextHot = asDealCards(cmsProducts.map(mapCmsProduct).slice(0, 5));
|
||
} else if (pinnedDeals.length > 0 && hasSeparateFeatured) {
|
||
nextHot = asDealCards(pinnedDeals.slice(0, 5));
|
||
} else if (pinnedDeals.length > 0) {
|
||
nextHot = asDealCards([pinnedDeals[0]]);
|
||
} else if (meta.hotProduct) {
|
||
nextHot = asDealCards([mapCmsProduct(meta.hotProduct, 0)]);
|
||
} else if (products && products.length > 0) {
|
||
const discounted = products.filter(
|
||
(p) => p.discount || (p.oldPrice && p.oldPrice > p.price) || p.isHot
|
||
);
|
||
const hot = getHotDealProduct(products);
|
||
const pool =
|
||
discounted.length > 0
|
||
? discounted
|
||
: hot
|
||
? [hot, ...products.filter((p) => p.id !== hot.id)].slice(0, 8)
|
||
: products.slice(0, 8);
|
||
nextHot = asDealCards(pool.slice(0, 5));
|
||
}
|
||
|
||
setHotPool(nextHot);
|
||
|
||
// ── Featured grid ──
|
||
const hotIds = new Set<string>(
|
||
nextHot.map((p) => String(p.id || p.slug || '')).filter(Boolean)
|
||
);
|
||
let nextFeatured: Product[] = [];
|
||
|
||
const fromFeaturedSlugs = resolveFromSlugs(featuredSlugs);
|
||
if (fromFeaturedSlugs.length > 0) {
|
||
nextFeatured = fromFeaturedSlugs;
|
||
} else if (pinnedDeals.length > 1) {
|
||
nextFeatured = pinnedDeals.slice(1);
|
||
} else if (rawFeaturedItems.length > 0) {
|
||
nextFeatured = rawFeaturedItems.map((p: any, idx: number) =>
|
||
mapCmsProduct(p, idx)
|
||
);
|
||
} else if (cmsProducts.length > 1 && nextHot.length <= 1) {
|
||
nextFeatured = cmsProducts.map(mapCmsProduct).slice(1);
|
||
} else if (products && products.length > 0) {
|
||
const featured = products.filter((p) => p.isFeatured);
|
||
nextFeatured = featured.length > 0 ? featured : [];
|
||
}
|
||
|
||
setFeaturedPool(
|
||
withoutDealTimer(backfillFeatured(nextFeatured, hotIds))
|
||
);
|
||
setFeaturedPage(0);
|
||
}, [config, products, error]);
|
||
|
||
const featuredPageCount = Math.max(1, Math.ceil(featuredPool.length / 6));
|
||
const featuredProducts = useMemo(() => {
|
||
const start = featuredPage * 6;
|
||
return featuredPool.slice(start, start + 6);
|
||
}, [featuredPool, featuredPage]);
|
||
|
||
const defaultTimer = dealTimer;
|
||
const multiDeal = hotPool.length > 1;
|
||
const canSwitchFeatured = featuredPool.length > 6;
|
||
|
||
if (hotPool.length === 0) {
|
||
return (
|
||
<section className="py-8 bg-white font-sans">
|
||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-[1400px]">
|
||
<div className="border border-[#e5e5e5] rounded-[4px] p-10 text-center">
|
||
<h3 className="text-[15px] font-bold text-[#222]">{sectionTitle}</h3>
|
||
<p className="text-[13px] text-[#777] mt-2">
|
||
Deal products will appear here once they are available in the catalog.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<section className="py-8 bg-white font-sans">
|
||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-[1400px]">
|
||
<div className="flex flex-col lg:flex-row gap-5 items-stretch">
|
||
{/* ════════ Hot Deals — whole-card slider when > 1 ════════ */}
|
||
<div className="relative w-full lg:w-[300px] xl:w-[310px] shrink-0">
|
||
{multiDeal ? (
|
||
<>
|
||
<div className="absolute top-2 right-2 z-20 flex items-center gap-0.5">
|
||
<button
|
||
type="button"
|
||
aria-label="Previous deal"
|
||
onClick={() => dealSwiperRef.current?.slidePrev()}
|
||
className="w-7 h-7 rounded-full bg-white/95 border border-[#a8cce8] text-[#555] hover:text-primary flex items-center justify-center shadow-sm cursor-pointer"
|
||
>
|
||
<ChevronLeft size={16} strokeWidth={2} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-label="Next deal"
|
||
onClick={() => dealSwiperRef.current?.slideNext()}
|
||
className="w-7 h-7 rounded-full bg-white/95 border border-[#a8cce8] text-[#555] hover:text-primary flex items-center justify-center shadow-sm cursor-pointer"
|
||
>
|
||
<ChevronRight size={16} strokeWidth={2} />
|
||
</button>
|
||
</div>
|
||
<Swiper
|
||
modules={[Autoplay, A11y, Navigation]}
|
||
onSwiper={(sw) => {
|
||
dealSwiperRef.current = sw;
|
||
}}
|
||
slidesPerView={1}
|
||
spaceBetween={0}
|
||
speed={550}
|
||
loop={hotPool.length > 1}
|
||
autoplay={{
|
||
delay: 4500,
|
||
disableOnInteraction: false,
|
||
pauseOnMouseEnter: true,
|
||
}}
|
||
className="hot-deal-swiper !overflow-hidden rounded-[4px]"
|
||
style={{ minHeight: BLOCK_H }}
|
||
>
|
||
{hotPool.map((product) => {
|
||
const timer =
|
||
(product.timeRemaining as TimerShape | undefined) ||
|
||
defaultTimer;
|
||
return (
|
||
<SwiperSlide
|
||
key={product.id || product.slug}
|
||
className="!h-auto"
|
||
>
|
||
<DealCard
|
||
product={product}
|
||
title={sectionTitle}
|
||
timer={timer}
|
||
/>
|
||
</SwiperSlide>
|
||
);
|
||
})}
|
||
</Swiper>
|
||
</>
|
||
) : (
|
||
<DealCard
|
||
product={hotPool[0]}
|
||
title={sectionTitle}
|
||
timer={
|
||
(hotPool[0].timeRemaining as TimerShape | undefined) ||
|
||
defaultTimer
|
||
}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* ════════ Monthly Featured Item ════════ */}
|
||
<div
|
||
className="flex-1 min-w-0 flex flex-col"
|
||
style={{ minHeight: BLOCK_H }}
|
||
>
|
||
<div
|
||
className="flex items-center justify-between gap-3 shrink-0 border-b border-[#e5e5e5]"
|
||
style={{ height: FEAT_HEADER }}
|
||
>
|
||
<h3 className="text-[15px] font-bold text-[#222] leading-none whitespace-nowrap">
|
||
{featuredTitle}
|
||
</h3>
|
||
{canSwitchFeatured ? (
|
||
<SectionNav
|
||
onPrev={() =>
|
||
setFeaturedPage((p) =>
|
||
p === 0 ? featuredPageCount - 1 : p - 1
|
||
)
|
||
}
|
||
onNext={() =>
|
||
setFeaturedPage((p) =>
|
||
p === featuredPageCount - 1 ? 0 : p + 1
|
||
)
|
||
}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
|
||
<div
|
||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 flex-1"
|
||
style={{
|
||
gap: FEAT_GAP,
|
||
marginTop: FEAT_TOP_GAP,
|
||
gridAutoRows: `${FEAT_CARD_H}px`,
|
||
}}
|
||
>
|
||
{featuredProducts.map((product) => (
|
||
<FeaturedCard key={product.id} product={product} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</section>
|
||
);
|
||
};
|
||
|
||
const FeaturedCard: React.FC<{ product: Product }> = ({ product }) => {
|
||
const imageUrl = getImageUrl(product.image);
|
||
const features =
|
||
product.features && product.features.length > 0
|
||
? product.features
|
||
: [
|
||
product.brand && product.brand !== 'iFixKart'
|
||
? String(product.brand)
|
||
: 'Genuine quality parts',
|
||
'Fast dispatch available',
|
||
];
|
||
const line1 = features[0] || '';
|
||
const line2 = features.slice(1, 3).join(' ') || features[1] || '';
|
||
|
||
return (
|
||
<Link
|
||
href={`/products/${product.slug || product.id}`}
|
||
className="flex items-center gap-3 px-3 bg-white border border-[#e8e8e8] rounded-[4px] hover:border-[#a8cce8] transition-colors group text-left overflow-hidden h-full"
|
||
>
|
||
<div
|
||
className="shrink-0 flex items-center justify-center overflow-hidden"
|
||
style={{ width: 96, height: 96 }}
|
||
>
|
||
{imageUrl ? (
|
||
<div className="relative w-full h-full">
|
||
<BlurHashImage
|
||
src={imageUrl}
|
||
blurHash={(product as any).blur_hash || null}
|
||
alt={product.name}
|
||
fill
|
||
sizes="96px"
|
||
className="max-h-full max-w-full object-contain group-hover:scale-105 transition-transform duration-300"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="w-full h-full bg-gray-100" />
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex-1 min-w-0 flex flex-col justify-center gap-1 py-1">
|
||
<h5
|
||
className="text-[13px] font-bold text-[#222] line-clamp-1 leading-tight group-hover:text-primary transition-colors"
|
||
title={product.name}
|
||
>
|
||
{product.name}
|
||
</h5>
|
||
|
||
<Stars rating={product.rating ?? 5} />
|
||
|
||
<div className="flex items-center gap-1.5 flex-wrap">
|
||
{product.oldPrice != null && product.oldPrice > product.price ? (
|
||
<span className="text-[11px] text-gray-400 line-through">
|
||
{formatCurrency(Number(product.oldPrice))}
|
||
</span>
|
||
) : null}
|
||
<span className="text-[13px] font-bold text-[#e53935]">
|
||
{formatCurrency(Number(product.price))}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="text-[10px] text-gray-400 leading-[1.45]">
|
||
{line1 ? <p className="truncate">{line1}</p> : null}
|
||
{line2 ? <p className="truncate">{line2}</p> : null}
|
||
</div>
|
||
</div>
|
||
</Link>
|
||
);
|
||
};
|