Publish a slot catalog so the dashboard can import keys, and keep local placeholders when a slot is empty. Co-authored-by: Cursor <cursoragent@cursor.com>
246 lines
9.8 KiB
TypeScript
246 lines
9.8 KiB
TypeScript
"use client";
|
|
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { useEffect, useState } from "react";
|
|
import { SlotImage } from "@/components/SlotImage";
|
|
import { heroSides, heroSlides } from "@/lib/data";
|
|
import type { StaticImageWithUrl } from "@/lib/static-images";
|
|
import { slotAlt, slotAspectStyle } from "@/lib/static-images";
|
|
|
|
const overlayStyle = {
|
|
background:
|
|
"linear-gradient(to left, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.72) 42%, rgba(0,0,0,0.28) 72%, transparent 100%)",
|
|
};
|
|
|
|
type Props = {
|
|
images?: StaticImageWithUrl[];
|
|
aspectRatio?: string;
|
|
sideImages?: StaticImageWithUrl[];
|
|
sideAspectRatio?: string;
|
|
};
|
|
|
|
export function HeroSlider({
|
|
images = [],
|
|
aspectRatio,
|
|
sideImages = [],
|
|
sideAspectRatio,
|
|
}: Props) {
|
|
const slides = images;
|
|
const [index, setIndex] = useState(0);
|
|
const [paused, setPaused] = useState(false);
|
|
const count = slides.length > 0 ? slides.length : heroSlides.length;
|
|
|
|
useEffect(() => {
|
|
setIndex((current) => (count ? current % count : 0));
|
|
}, [count]);
|
|
|
|
useEffect(() => {
|
|
if (paused || count < 2) return;
|
|
const id = window.setInterval(() => {
|
|
setIndex((current) => (current + 1) % count);
|
|
}, 5000);
|
|
return () => window.clearInterval(id);
|
|
}, [paused, count]);
|
|
|
|
const sliderStyle = slotAspectStyle(aspectRatio, "21:8");
|
|
|
|
return (
|
|
<section className="container-site pt-5">
|
|
<div
|
|
className="relative overflow-hidden rounded-2xl min-h-[240px] md:min-h-[360px]"
|
|
style={sliderStyle ?? { aspectRatio: "21 / 8" }}
|
|
onMouseEnter={() => setPaused(true)}
|
|
onMouseLeave={() => setPaused(false)}
|
|
>
|
|
{slides.length > 0
|
|
? slides.map((slide, i) => {
|
|
const title = slide.titleFa?.trim();
|
|
const english = slide.titleEn?.trim();
|
|
const subtitle = slide.subtext?.trim();
|
|
const hasCopy = Boolean(title || english || subtitle);
|
|
return (
|
|
<div
|
|
key={slide.id}
|
|
aria-hidden={i !== index}
|
|
className={`absolute inset-0 transition-opacity duration-700 ease-in-out ${
|
|
i === index ? "opacity-100 z-[1]" : "opacity-0 z-0 pointer-events-none"
|
|
}`}
|
|
>
|
|
<SlotImage image={slide} fill alt={slotAlt(slide, `اسلاید ${i + 1}`)} />
|
|
{hasCopy ? (
|
|
<>
|
|
<div
|
|
className="pointer-events-none absolute inset-y-0 right-0 z-[2] w-[58%] md:w-[46%]"
|
|
style={overlayStyle}
|
|
/>
|
|
<div className="pointer-events-none absolute z-[3] right-6 md:right-12 top-1/2 -translate-y-1/2 max-w-[230px] sm:max-w-sm text-white text-right">
|
|
{title ? (
|
|
<p className="text-2xl sm:text-3xl md:text-4xl font-extrabold leading-none">{title}</p>
|
|
) : null}
|
|
{english ? (
|
|
<p
|
|
dir="ltr"
|
|
className="mt-0.5 font-carter text-[10px] sm:text-xs md:text-sm tracking-[0.42em] text-brand uppercase"
|
|
>
|
|
{english}
|
|
</p>
|
|
) : null}
|
|
{subtitle ? (
|
|
<p className="mt-1 text-lg sm:text-xl md:text-2xl font-medium leading-tight text-white/90">
|
|
{subtitle}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})
|
|
: heroSlides.map((slide, i) => (
|
|
<div
|
|
key={slide.title}
|
|
aria-hidden={i !== index}
|
|
className={`absolute inset-0 transition-opacity duration-700 ease-in-out ${
|
|
i === index ? "opacity-100 z-[1]" : "opacity-0 z-0 pointer-events-none"
|
|
}`}
|
|
>
|
|
<Image
|
|
src={slide.image}
|
|
alt={slide.title}
|
|
fill
|
|
sizes="1200px"
|
|
priority={i === 0}
|
|
className="object-cover object-[28%_center]"
|
|
/>
|
|
</div>
|
|
))}
|
|
|
|
{slides.length === 0 ? (
|
|
<>
|
|
<div
|
|
className="pointer-events-none absolute inset-y-0 right-0 z-[2] w-[58%] md:w-[46%]"
|
|
style={overlayStyle}
|
|
/>
|
|
{heroSlides.map((slide, i) => (
|
|
<div
|
|
key={`${slide.title}-copy`}
|
|
className={`absolute z-[3] right-6 md:right-12 top-1/2 -translate-y-1/2 max-w-[230px] sm:max-w-sm text-white text-right transition-opacity duration-700 ${
|
|
i === index ? "opacity-100" : "opacity-0 pointer-events-none"
|
|
}`}
|
|
>
|
|
<p className="text-2xl sm:text-3xl md:text-4xl font-extrabold leading-none">{slide.title}</p>
|
|
<p
|
|
dir="ltr"
|
|
className="mt-0.5 font-carter text-[10px] sm:text-xs md:text-sm tracking-[0.42em] text-brand uppercase"
|
|
>
|
|
{slide.english}
|
|
</p>
|
|
<p className="mt-1 text-lg sm:text-xl md:text-2xl font-medium leading-tight text-white/90">
|
|
{slide.subtitle}
|
|
</p>
|
|
<Link
|
|
href={slide.href}
|
|
className="mt-5 inline-flex items-center justify-center rounded-lg bg-brand px-5 py-2.5 text-sm sm:text-base font-bold text-white hover:bg-[#e01e1e]"
|
|
>
|
|
{slide.button}
|
|
</Link>
|
|
</div>
|
|
))}
|
|
</>
|
|
) : null}
|
|
|
|
{count > 1 ? (
|
|
<div className="absolute bottom-4 left-1/2 z-[4] -translate-x-1/2 flex gap-2">
|
|
{Array.from({ length: count }, (_, i) => (
|
|
<button
|
|
key={slides[i]?.id ?? heroSlides[i]?.title ?? i}
|
|
type="button"
|
|
aria-label={`اسلاید ${i + 1}`}
|
|
onClick={() => setIndex(i)}
|
|
className={`h-2.5 rounded-full transition-all ${i === index ? "w-7 bg-brand" : "w-2.5 bg-white/80"}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
{sideImages.length > 0
|
|
? sideImages.map((image) => {
|
|
const title = image.titleFa?.trim();
|
|
const english = image.titleEn?.trim();
|
|
const subtitle = image.subtext?.trim();
|
|
const hasCopy = Boolean(title || english || subtitle);
|
|
const frame = (
|
|
<div
|
|
className="relative overflow-hidden rounded-2xl"
|
|
style={slotAspectStyle(sideAspectRatio, "13:5")}
|
|
>
|
|
<SlotImage image={{ ...image, linkUrl: null }} fill alt={slotAlt(image)} />
|
|
{hasCopy ? (
|
|
<>
|
|
<div
|
|
className="pointer-events-none absolute inset-y-0 right-0 w-[62%] md:w-[52%]"
|
|
style={overlayStyle}
|
|
/>
|
|
<div className="pointer-events-none absolute right-4 md:right-6 top-1/2 -translate-y-1/2 max-w-[180px] md:max-w-[220px] text-white text-right">
|
|
{title ? (
|
|
<p className="text-base md:text-xl font-extrabold leading-none">{title}</p>
|
|
) : null}
|
|
{english ? (
|
|
<p
|
|
dir="ltr"
|
|
className="mt-0.5 font-carter text-[9px] md:text-[10px] tracking-[0.4em] text-brand uppercase"
|
|
>
|
|
{english}
|
|
</p>
|
|
) : null}
|
|
{subtitle ? (
|
|
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{subtitle}</p>
|
|
) : null}
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
);
|
|
return image.linkUrl ? (
|
|
<a key={image.id} href={image.linkUrl} className="block">
|
|
{frame}
|
|
</a>
|
|
) : (
|
|
<div key={image.id}>{frame}</div>
|
|
);
|
|
})
|
|
: heroSides.map((side) => (
|
|
<Link key={side.title} href={side.href} className="relative block overflow-hidden rounded-2xl">
|
|
<div className="relative aspect-[21/8] sm:aspect-[2.6/1]">
|
|
<Image
|
|
src={side.image}
|
|
alt={side.title}
|
|
fill
|
|
sizes="600px"
|
|
className="object-cover object-[28%_center]"
|
|
/>
|
|
</div>
|
|
<div
|
|
className="pointer-events-none absolute inset-y-0 right-0 w-[62%] md:w-[52%]"
|
|
style={overlayStyle}
|
|
/>
|
|
<div className="absolute right-4 md:right-6 top-1/2 -translate-y-1/2 max-w-[180px] md:max-w-[220px] text-white text-right">
|
|
<p className="text-base md:text-xl font-extrabold leading-none">{side.title}</p>
|
|
<p
|
|
dir="ltr"
|
|
className="mt-0.5 font-carter text-[9px] md:text-[10px] tracking-[0.4em] text-brand uppercase"
|
|
>
|
|
{side.english}
|
|
</p>
|
|
<p className="mt-0.5 text-sm md:text-base leading-tight text-white/90">{side.subtitle}</p>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|