Files
moalem-shop/src/components/HeroSlider.tsx
T
2026-09-02 17:03:23 +03:30

115 lines
4.5 KiB
TypeScript

"use client";
import Image from "next/image";
import Link from "next/link";
import type { CSSProperties } from "react";
import { Autoplay, Navigation, Pagination } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import "swiper/css/pagination";
import { ChevronLeft, ChevronRight } from "./icons";
import { toCssAspectRatio } from "@/lib/format";
import type { StaticImageSlot } from "@/lib/types";
export interface Slide {
id: string;
image: string;
mobileImage?: string;
href: string;
alt: string;
}
// Default aspect ratios match the fallback banner assets: 700x400 mobile art,
// 1296x366 desktop, so neither crop loses the headline. Kept in sync with the
// "slider" entry in src/app/meshkee/static-image-slots/route.ts — see the
// note there: if this ratio ever changes, that entry must change with it.
const FALLBACK_ASPECT_CLASSES = "aspect-[7/4] md:aspect-[1296/366]";
// `slot` is the "slider" static-image slot (kind: list, duplicatable) — when
// it has images they win over the `slides` fallback prop entirely. A CMS
// slide is one image for every breakpoint (not a separate mobile crop), and
// slot.aspectRatio (if set) drives the frame instead of the built-in default.
export default function HeroSlider({ slot, slides = [] }: { slot?: StaticImageSlot; slides?: Slide[] }) {
const fromCms = (slot?.images.length ?? 0) > 0;
const items: Slide[] = fromCms
? slot!.images.map((img, i) => ({
id: img.id,
image: img.url,
href: img.linkUrl || "/products",
alt: img.titleFa || img.titleEn || `بنر فروشگاه موبایل معلم ${i + 1}`,
}))
: slides;
if (items.length === 0) return null;
const cssAspect = fromCms ? toCssAspectRatio(slot?.aspectRatio) : undefined;
const aspectStyle = cssAspect ? ({ aspectRatio: cssAspect } as CSSProperties) : undefined;
return (
<section className="boxed mt-5" aria-label="بنرهای اصلی">
<div className="relative overflow-hidden rounded-2xl">
<Swiper
modules={[Pagination, Navigation, Autoplay]}
dir="rtl"
loop={items.length > 1}
autoplay={{ delay: 5000, disableOnInteraction: false }}
pagination={{ clickable: true, el: ".hero-dots" }}
navigation={{ prevEl: ".hero-prev", nextEl: ".hero-next" }}
className={`w-full ${aspectStyle ? "" : FALLBACK_ASPECT_CLASSES}`}
style={aspectStyle}
>
{items.map((slide) => (
<SwiperSlide key={slide.id}>
<Link href={slide.href} className="relative block size-full">
{fromCms ? (
<Image src={slide.image} alt={slide.alt} fill priority sizes="100vw" className="object-cover" />
) : (
<>
<Image
src={slide.mobileImage || slide.image}
alt={slide.alt}
fill
priority
sizes="100vw"
className="object-cover md:hidden"
/>
<Image
src={slide.image}
alt={slide.alt}
fill
priority
sizes="1200px"
className="hidden object-cover md:block"
/>
</>
)}
</Link>
</SwiperSlide>
))}
</Swiper>
<div className="absolute bottom-4 left-4 z-10 flex items-center gap-2">
<button
className="hero-prev flex size-9 items-center justify-center rounded-full bg-white/90 text-ink shadow transition hover:bg-white"
aria-label="اسلاید قبلی"
>
<ChevronRight className="size-5" />
</button>
<button
className="hero-next flex size-9 items-center justify-center rounded-full bg-white/90 text-ink shadow transition hover:bg-white"
aria-label="اسلاید بعدی"
>
<ChevronLeft className="size-5" />
</button>
</div>
{/* Swiper's own .swiper-pagination rules set left:0/width:100% on this
element, so it is centred with justify-content rather than a
translate — a translate would fight those and push it off-canvas. */}
<div className="hero-dots absolute bottom-5 z-10 flex w-full justify-center gap-1.5" />
</div>
</section>
);
}