228 lines
7.9 KiB
TypeScript
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>
|
|
);
|
|
};
|