Initial release of Moniki paid services site and CMS (v1.0.0).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alexey Novikov
2026-09-16 13:10:25 +03:00
co-authored by Cursor
commit 8abd8d06dd
263 changed files with 24973 additions and 0 deletions
@@ -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>
);
}