ifixkart-storefront/sections/HotDealsSection.tsx

654 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
);
};