Files
Moniki_uslugi/apps/web/src/components/home/HeroCarousel.tsx
T

275 lines
10 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 { Button } from "@/components/ui/Button";
import { Link } from "@/i18n/routing";
import type { HeroBanner } from "@/lib/demo-data";
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useRef, useState } from "react";
function isInternalHref(href: string) {
return href.startsWith("/") && !href.startsWith("//");
}
function BannerLink({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
if (!href) return null;
if (isInternalHref(href)) {
return <Link href={href}>{children}</Link>;
}
return (
<a href={href} target={href.startsWith("http") ? "_blank" : undefined} rel="noreferrer">
{children}
</a>
);
}
export function HeroCarousel({ banners }: { banners: HeroBanner[] }) {
const t = useTranslations("hero");
const meta = useTranslations("meta");
const slides = banners.length ? banners : [];
const [index, setIndex] = useState(0);
const [paused, setPaused] = useState(false);
const [playingId, setPlayingId] = useState<string | null>(null);
const videoRefs = useRef<Record<string, HTMLVideoElement | null>>({});
const current = slides[index] ?? slides[0];
const count = slides.length;
const autoplayBlocked = paused || Boolean(playingId);
const stopVideos = useCallback(() => {
for (const video of Object.values(videoRefs.current)) {
if (!video) continue;
video.pause();
video.currentTime = 0;
}
setPlayingId(null);
}, []);
const go = useCallback(
(next: number) => {
if (!count) return;
stopVideos();
setIndex((next + count) % count);
},
[count, stopVideos],
);
useEffect(() => {
if (count < 2 || autoplayBlocked) return;
const reduce =
document.documentElement.dataset.motion === "reduce" ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduce) return;
const duration = Math.max(4000, current?.durationMs || 7000);
const timer = window.setTimeout(() => go(index + 1), duration);
return () => window.clearTimeout(timer);
}, [count, autoplayBlocked, current?.durationMs, go, index]);
const toggleVideo = useCallback(
(slide: HeroBanner) => {
if (!slide.video) return;
const video = videoRefs.current[slide.id];
if (!video) return;
if (playingId === slide.id && !video.paused) {
video.pause();
setPlayingId(null);
return;
}
void video.play().then(() => setPlayingId(slide.id)).catch(() => setPlayingId(null));
},
[playingId],
);
if (!current) return null;
return (
<section
className="hero-gradient relative overflow-hidden pb-8 lg:pb-16"
aria-roledescription="carousel"
aria-label={t("carousel")}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocusCapture={() => setPaused(true)}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setPaused(false);
}
}}
>
{slides.map((slide, i) => (
// eslint-disable-next-line @next/next/no-img-element
<img
key={`bg-${slide.id}`}
src={(slide.background || "/hero/building-blur.jpg").replace(
/building-blur\.jpg$/,
"building-blur.jpg?v=30",
)}
alt=""
aria-hidden
data-decorative
className={cn(
"pointer-events-none absolute inset-0 h-full w-full max-w-none scale-105 object-cover object-center transition-opacity duration-700",
i === index ? "opacity-100" : "opacity-0",
)}
/>
))}
<div data-decorative className="hero-scrim pointer-events-none absolute inset-0" aria-hidden />
<div className="relative mx-auto grid max-w-6xl items-center gap-6 px-4 py-12 sm:px-6 lg:min-h-[calc(100vh-var(--header-h))] lg:grid-cols-2 lg:gap-14 lg:py-20">
<div className="hero-copy max-w-xl rounded-[28px] p-6 sm:p-8">
<p className="text-4xl font-extrabold tracking-tight text-[var(--brand-700)] sm:text-5xl lg:text-6xl">
{current.eyebrow || t("brand")}
</p>
<p className="mt-2 text-sm font-medium text-[var(--text-muted)]">{meta("fullName")}</p>
<h1
key={`title-${current.id}`}
className="mt-6 text-2xl font-semibold leading-snug text-[var(--text)] sm:text-3xl"
>
{current.title}
</h1>
{current.subtitle ? (
<p
key={`subtitle-${current.id}`}
className="mt-4 text-base leading-relaxed text-[var(--text-muted)] sm:text-lg"
>
{current.subtitle}
</p>
) : null}
<div className="mt-8 flex flex-wrap gap-3">
{current.ctaPrimaryLabel ? (
<BannerLink href={current.ctaPrimaryHref}>
<Button size="lg">{current.ctaPrimaryLabel}</Button>
</BannerLink>
) : null}
{current.ctaSecondaryLabel ? (
<BannerLink href={current.ctaSecondaryHref}>
<Button size="lg" variant="outline">
{current.ctaSecondaryLabel}
</Button>
</BannerLink>
) : null}
</div>
{(current.phone || current.address) && (
<p className="mt-6 text-sm text-[var(--text-muted)]">
{current.phone ? (
<a href={`tel:${current.phone.replace(/[^\d+]/g, "")}`} className="font-semibold text-[var(--brand-700)]">
{current.phone}
</a>
) : null}
{current.phone && current.address ? (
<span className="mx-2 text-[var(--border)]">·</span>
) : null}
{current.address}
</p>
)}
</div>
<div className="relative">
<div className="relative hidden aspect-[4/5] overflow-hidden rounded-[28px] shadow-[0_24px_80px_-32px_rgba(15,118,110,0.45)] sm:aspect-[5/6] lg:block">
{slides.map((slide, i) => {
const active = i === index;
const playing = playingId === slide.id && active;
return (
<div
key={slide.id}
className={cn(
"absolute inset-0 transition-opacity duration-700",
active ? "opacity-100" : "pointer-events-none opacity-0",
)}
>
{slide.video ? (
<video
ref={(node) => {
videoRefs.current[slide.id] = node;
}}
src={slide.video}
poster={slide.image}
playsInline
preload="metadata"
className="absolute inset-0 h-full w-full max-w-none object-cover object-center"
onEnded={() => setPlayingId(null)}
/>
) : null}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={slide.image}
alt={active ? slide.imageAlt || current.title : ""}
className={cn(
"absolute inset-0 h-full w-full max-w-none object-cover object-center transition-opacity duration-300",
playing ? "opacity-0" : "opacity-100",
)}
/>
{slide.video ? (
<button
type="button"
className="absolute inset-0 z-10 flex items-center justify-center"
aria-label={playing ? t("pauseVideo") : t("playVideo")}
onClick={() => toggleVideo(slide)}
>
{playing ? null : (
<span className="flex h-16 w-16 items-center justify-center rounded-full bg-[var(--brand-700)]/90 text-3xl text-white shadow-lg ring-4 ring-white/40">
▶
</span>
)}
</button>
) : null}
</div>
);
})}
<div
data-decorative
className={cn(
"pointer-events-none absolute inset-0 bg-gradient-to-t from-[var(--brand-900)]/35 via-transparent to-transparent transition-opacity duration-300",
playingId === current.id ? "opacity-0" : "opacity-100",
)}
/>
</div>
{count > 1 ? (
<div className="mt-5 flex items-center justify-center gap-3 lg:absolute lg:-bottom-2 lg:left-1/2 lg:mt-0 lg:-translate-x-1/2 lg:translate-y-full">
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface)] text-[var(--text)] shadow-sm hover:border-[var(--brand-400)] hover:text-[var(--brand-700)]"
aria-label={t("prev")}
onClick={() => go(index - 1)}
>
‹
</button>
<div className="flex items-center gap-2" role="tablist" aria-label={t("carousel")}>
{slides.map((slide, i) => (
<button
key={slide.id}
type="button"
role="tab"
aria-selected={i === index}
aria-label={t("slide", { current: i + 1, total: count })}
className={cn(
"h-2.5 rounded-full transition-all",
i === index ? "w-7 bg-[var(--brand-600)]" : "w-2.5 bg-[var(--neutral-300)] hover:bg-[var(--brand-300)]",
)}
onClick={() => go(i)}
/>
))}
</div>
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface)] text-[var(--text)] shadow-sm hover:border-[var(--brand-400)] hover:text-[var(--brand-700)]"
aria-label={t("next")}
onClick={() => go(index + 1)}
>
›
</button>
</div>
) : null}
</div>
</div>
</section>
);
}