ifixkart-storefront/sections/PromoCardsSection.tsx

228 lines
7.9 KiB
TypeScript

"use client";
import React, { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Navigation, A11y } from 'swiper/modules';
import type { Swiper as SwiperType } from 'swiper';
import { getImageUrl } from '../lib/utils';
import 'swiper/css';
import 'swiper/css/navigation';
interface PromoCard {
id: string;
badge: string;
title: string;
subtitle: string;
priceLabel: string;
price: string;
image: string;
bgColor: string;
href: string;
}
/** TechShop-style promo tiles — used when CMS cards are empty */
const DEFAULT_CARDS: PromoCard[] = [];
interface PromoCardsSectionProps {
config?: any;
}
function mapConfigCards(raw: any[]): PromoCard[] {
return raw.map((card: any, idx: number) => {
const fallback = DEFAULT_CARDS[idx % DEFAULT_CARDS.length];
const priceText = String(card.priceText || card.price_text || '');
const priceMatch = priceText.match(/from\s*(.+)$/i);
const bg = card.bgColor || card.bg_color || fallback.bgColor;
return {
id: String(card.id || `promo-${idx + 1}`),
badge: String(card.badge || fallback.badge),
title: String(card.title || fallback.title),
subtitle: String(card.subtitle || fallback.subtitle),
priceLabel: priceMatch
? 'from'
: card.priceLabel || fallback.priceLabel,
price: priceMatch
? priceMatch[1].trim()
: String(card.price || priceText || fallback.price),
image: getImageUrl(card.image || card.image_url) || fallback.image,
bgColor: String(bg),
href: String(
card.link || card.buttonUrl || card.button_url || fallback.href
),
};
});
}
function PromoCardTile({ card }: { card: PromoCard }) {
const solidBg =
card.bgColor.startsWith('#') || card.bgColor.startsWith('rgb');
return (
<Link
href={card.href}
className="group relative flex flex-col sm:grid sm:grid-cols-[minmax(0,1fr)_minmax(150px,52%)] items-stretch min-h-[180px] md:min-h-[200px] h-full rounded-2xl overflow-hidden transition-all duration-300 hover:-translate-y-1 hover:shadow-[0_12px_28px_rgba(15,23,42,0.12)]"
style={{
backgroundColor: solidBg ? card.bgColor : undefined,
}}
>
{!solidBg ? (
<span className={`absolute inset-0 ${card.bgColor}`} aria-hidden />
) : null}
<div className="relative z-10 flex flex-col justify-center items-start text-left px-5 py-5 md:px-6 min-w-0">
{card.badge ? (
<span className="text-[12px] md:text-[13px] font-semibold text-[#e53935] mb-1">
{card.badge}
</span>
) : null}
<h3 className="text-[18px] md:text-[21px] font-bold text-[#222] leading-tight mb-1">
{card.title}
</h3>
{card.subtitle ? (
<p className="text-[12px] text-gray-500 mb-2 line-clamp-2">
{card.subtitle}
</p>
) : null}
{(card.priceLabel || card.price) && (
<p className="text-[13px] font-semibold text-[#222]">
{card.priceLabel ? (
<span className="font-normal text-gray-500 mr-1">
{card.priceLabel}
</span>
) : null}
{card.price}
</p>
)}
</div>
{card.image ? (
<div className="relative z-10 order-first sm:order-none h-[180px] sm:h-auto overflow-hidden bg-white/40">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={card.image}
alt={card.title}
className="absolute inset-0 w-full h-full object-cover object-center group-hover:scale-105 transition-transform duration-500 ease-out"
/>
</div>
) : (
<div className="relative z-10 bg-white/35" aria-hidden />
)}
</Link>
);
}
export const PromoCardsSection: React.FC<PromoCardsSectionProps> = ({
config,
}) => {
const fromConfig =
config?.metadata_json?.cards || config?.metadata?.cards || [];
const cards =
Array.isArray(fromConfig) && fromConfig.length > 0
? mapConfigCards(fromConfig)
: DEFAULT_CARDS;
if (cards.length === 0) return null;
const [canPrev, setCanPrev] = useState(false);
const [canNext, setCanNext] = useState(false);
const [showNav, setShowNav] = useState(false);
const swiperRef = useRef<SwiperType | null>(null);
const syncNav = (swiper: SwiperType) => {
const overflow = !swiper.isLocked && swiper.slides.length > 0;
setShowNav(overflow);
setCanPrev(overflow && !swiper.isBeginning);
setCanNext(overflow && !swiper.isEnd);
};
useEffect(() => {
const swiper = swiperRef.current;
if (!swiper) return;
requestAnimationFrame(() => syncNav(swiper));
}, [cards.length]);
// 3 or fewer: keep the original static 3-column grid
if (cards.length <= 3) {
return (
<section className="pt-4 pb-8 bg-white font-sans">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-[1400px]">
<div
className={`grid grid-cols-1 gap-4 lg:gap-5 ${
cards.length === 1
? 'md:grid-cols-1 max-w-xl'
: cards.length === 2
? 'md:grid-cols-2'
: 'md:grid-cols-3'
}`}
>
{cards.map((card) => (
<PromoCardTile key={card.id} card={card} />
))}
</div>
</div>
</section>
);
}
return (
<section className="pt-4 pb-8 bg-white font-sans">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-[1400px]">
<div className="relative group/promos">
{showNav && canPrev ? (
<button
type="button"
aria-label="Previous promo cards"
onClick={() => swiperRef.current?.slidePrev()}
className="hidden md:flex absolute -left-4 top-1/2 -translate-y-1/2 z-30 w-10 h-10 rounded-full items-center justify-center shadow-[0_4px_14px_rgba(25,118,243,0.35)] bg-primary text-white hover:bg-primary-hover cursor-pointer opacity-0 pointer-events-none transition-opacity duration-200 group-hover/promos:opacity-100 group-hover/promos:pointer-events-auto"
>
<ChevronLeft size={20} strokeWidth={2.5} />
</button>
) : null}
{showNav && canNext ? (
<button
type="button"
aria-label="Next promo cards"
onClick={() => swiperRef.current?.slideNext()}
className="hidden md:flex absolute -right-4 top-1/2 -translate-y-1/2 z-30 w-10 h-10 rounded-full items-center justify-center shadow-[0_4px_14px_rgba(25,118,243,0.35)] bg-primary text-white hover:bg-primary-hover cursor-pointer opacity-0 pointer-events-none transition-opacity duration-200 group-hover/promos:opacity-100 group-hover/promos:pointer-events-auto"
>
<ChevronRight size={20} strokeWidth={2.5} />
</button>
) : null}
<Swiper
modules={[Navigation, A11y]}
onSwiper={(swiper) => {
swiperRef.current = swiper;
requestAnimationFrame(() => syncNav(swiper));
}}
onSlideChange={syncNav}
onResize={syncNav}
onBreakpoint={syncNav}
onLock={() => {
setShowNav(false);
setCanPrev(false);
setCanNext(false);
}}
onUnlock={(swiper) => syncNav(swiper)}
watchOverflow
spaceBetween={16}
slidesPerView={1}
breakpoints={{
640: { slidesPerView: 2, spaceBetween: 16 },
1024: { slidesPerView: 3, spaceBetween: 20 },
}}
>
{cards.map((card) => (
<SwiperSlide key={card.id} className="!h-auto">
<PromoCardTile card={card} />
</SwiperSlide>
))}
</Swiper>
</div>
</div>
</section>
);
};