Initial release of Moniki paid services site and CMS (v1.0.0).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
"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 min-h-[calc(100vh-var(--header-h))] max-w-6xl items-center gap-10 px-4 py-16 sm:px-6 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 aspect-[4/5] overflow-hidden rounded-[28px] shadow-[0_24px_80px_-32px_rgba(15,118,110,0.45)] sm:aspect-[5/6]">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user