fa26092ea4
- Remove scrollbar-gutter: stable, add overflow-x: hidden on html - Cap section-glow pseudo-element width to viewport on mobile - Scale down hero logo, text, spacing, and button for small screens (iPhone SE)
199 lines
8.1 KiB
TypeScript
199 lines
8.1 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useCallback, useState } from "react";
|
|
import { Button } from "@/components/ui/Button";
|
|
import { FloatingHearts } from "@/components/ui/FloatingHearts";
|
|
import { HeroLogo } from "@/components/ui/HeroLogo";
|
|
import type { SiteContent } from "@/types/content";
|
|
import { useBooking } from "@/contexts/BookingContext";
|
|
|
|
const DEFAULT_VIDEOS = ["/video/ira.mp4", "/video/nadezda.mp4", "/video/nastya-2.mp4"];
|
|
|
|
interface HeroProps {
|
|
data: SiteContent["hero"];
|
|
}
|
|
|
|
export function Hero({ data: hero }: HeroProps) {
|
|
const { openBooking } = useBooking();
|
|
const sectionRef = useRef<HTMLElement>(null);
|
|
const scrolledRef = useRef(false);
|
|
const overlayRef = useRef<HTMLDivElement>(null);
|
|
const readyCount = useRef(0);
|
|
const [mounted, setMounted] = useState(false);
|
|
const hasVideos = hero.videos?.some(Boolean);
|
|
const videos = hasVideos ? hero.videos! : DEFAULT_VIDEOS;
|
|
const centerVideo = videos[1] || videos[0];
|
|
const totalVideos = videos.slice(0, 3).filter(Boolean).length + 1; // desktop (filled) + mobile (1)
|
|
|
|
const prefersReducedMotion = useRef(false);
|
|
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
prefersReducedMotion.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
}, []);
|
|
|
|
const handleVideoReady = useCallback(() => {
|
|
readyCount.current += 1;
|
|
if (readyCount.current >= totalVideos && overlayRef.current) {
|
|
overlayRef.current.style.opacity = "0";
|
|
}
|
|
}, [totalVideos]);
|
|
|
|
const scrollToNext = useCallback(() => {
|
|
const el = sectionRef.current;
|
|
if (!el) return;
|
|
let next = el.nextElementSibling;
|
|
while (next && next.tagName !== "SECTION") {
|
|
next = next.nextElementSibling;
|
|
}
|
|
next?.scrollIntoView({ behavior: "smooth" });
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const el = sectionRef.current;
|
|
if (!el) return;
|
|
|
|
function handleWheel(e: WheelEvent) {
|
|
if (prefersReducedMotion.current) return;
|
|
if (e.deltaY <= 0 || scrolledRef.current) return;
|
|
if (window.scrollY > 10) return;
|
|
scrolledRef.current = true;
|
|
scrollToNext();
|
|
setTimeout(() => { scrolledRef.current = false; }, 1000);
|
|
}
|
|
|
|
function handleTouchStart(e: TouchEvent) {
|
|
(el as HTMLElement).dataset.touchY = String(e.touches[0].clientY);
|
|
}
|
|
|
|
function handleTouchEnd(e: TouchEvent) {
|
|
if (prefersReducedMotion.current) return;
|
|
const startY = Number((el as HTMLElement).dataset.touchY);
|
|
const endY = e.changedTouches[0].clientY;
|
|
if (startY - endY > 50 && !scrolledRef.current && window.scrollY < 10) {
|
|
scrolledRef.current = true;
|
|
scrollToNext();
|
|
setTimeout(() => { scrolledRef.current = false; }, 1000);
|
|
}
|
|
}
|
|
|
|
el.addEventListener("wheel", handleWheel, { passive: true });
|
|
el.addEventListener("touchstart", handleTouchStart, { passive: true });
|
|
el.addEventListener("touchend", handleTouchEnd, { passive: true });
|
|
return () => {
|
|
el.removeEventListener("wheel", handleWheel);
|
|
el.removeEventListener("touchstart", handleTouchStart);
|
|
el.removeEventListener("touchend", handleTouchEnd);
|
|
};
|
|
}, [scrollToNext]);
|
|
|
|
return (
|
|
<section id="hero" ref={sectionRef} aria-label="Главный баннер" className="relative flex min-h-svh items-center justify-center overflow-hidden bg-neutral-950">
|
|
{/* Videos render only after hydration to avoid SSR mismatch */}
|
|
{mounted && (
|
|
<>
|
|
{/* Mobile: single centered video */}
|
|
<div className="absolute inset-0 md:hidden">
|
|
<video
|
|
autoPlay muted loop playsInline preload="auto"
|
|
onCanPlayThrough={handleVideoReady}
|
|
className="absolute inset-0 h-full w-full object-cover object-center"
|
|
>
|
|
<source src={centerVideo} type="video/mp4" />
|
|
</video>
|
|
<div className="absolute inset-0 bg-black/40" />
|
|
</div>
|
|
|
|
{/* Desktop: diagonal split with all videos */}
|
|
<div className="absolute inset-0 hidden md:block">
|
|
{videos.slice(0, 3).map((src, i) => {
|
|
if (!src) return null;
|
|
const positions = [
|
|
{ left: "0%", width: "38%" },
|
|
{ left: "31%", width: "38%" },
|
|
{ left: "62%", width: "38%" },
|
|
];
|
|
const clips = [
|
|
"polygon(0 0, 100% 0, 86% 100%, 0 100%)",
|
|
"polygon(14% 0, 100% 0, 86% 100%, 0 100%)",
|
|
"polygon(14% 0, 100% 0, 100% 100%, 0 100%)",
|
|
];
|
|
return (
|
|
<div
|
|
key={`video-${i}`}
|
|
className="absolute top-0 bottom-0 overflow-hidden"
|
|
style={{
|
|
left: positions[i].left,
|
|
width: positions[i].width,
|
|
clipPath: clips[i],
|
|
}}
|
|
>
|
|
<video
|
|
autoPlay muted loop playsInline preload="auto"
|
|
onCanPlayThrough={handleVideoReady}
|
|
className="absolute inset-0 h-full w-full object-cover object-center"
|
|
>
|
|
<source src={src} type="video/mp4" />
|
|
</video>
|
|
<div className="absolute inset-0 bg-black/40" />
|
|
</div>
|
|
);
|
|
})}
|
|
{/* Gold diagonal lines between panels */}
|
|
<svg className="absolute inset-0 h-full w-full z-10 pointer-events-none" preserveAspectRatio="none" viewBox="0 0 100 100">
|
|
<line x1="38" y1="0" x2="33" y2="100" stroke="rgba(201,169,110,0.25)" strokeWidth="0.15" />
|
|
<line x1="69" y1="0" x2="64" y2="100" stroke="rgba(201,169,110,0.25)" strokeWidth="0.15" />
|
|
</svg>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Loading overlay — covers videos but not content */}
|
|
<div
|
|
ref={overlayRef}
|
|
className="absolute inset-0 z-[5] bg-neutral-950 pointer-events-none transition-opacity duration-1000"
|
|
/>
|
|
|
|
{/* Vignette — dark edges to guide eye to center */}
|
|
<div className="absolute inset-0 z-[4] pointer-events-none" style={{
|
|
background: "radial-gradient(ellipse 70% 60% at 50% 50%, transparent 0%, rgba(0,0,0,0.6) 100%)",
|
|
}} />
|
|
|
|
{/* Floating hearts */}
|
|
<FloatingHearts />
|
|
|
|
{/* Content */}
|
|
<div className="section-container relative z-10 text-center" style={{ textShadow: "0 1px 0 rgba(201,169,110,0.3), 0 2px 0 rgba(201,169,110,0.2), 0 4px 8px rgba(0,0,0,0.4), 0 8px 20px rgba(0,0,0,0.3)" }}>
|
|
<div className="hero-logo relative mx-auto mb-6 sm:mb-12 flex items-center justify-center" style={{ width: 160, height: 132 }}>
|
|
<div className="absolute -inset-10 rounded-full blur-[80px]" style={{ background: "radial-gradient(circle, rgba(201,169,110,0.25), transparent 70%)" }} />
|
|
<div className="hero-logo-heartbeat relative">
|
|
<HeroLogo
|
|
size={160}
|
|
className="drop-shadow-[0_0_10px_rgba(201,169,110,0.35)] drop-shadow-[0_0_40px_rgba(201,169,110,0.15)] sm:scale-[1.375]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<h1 className="hero-title font-display text-4xl font-bold tracking-tight sm:text-6xl lg:text-8xl">
|
|
<span className="gradient-text">{hero.headline}</span>
|
|
</h1>
|
|
|
|
<p className="hero-subtitle mx-auto mt-5 max-w-xl text-lg text-gold/80 sm:mt-8 sm:text-2xl">
|
|
{hero.subheadline}
|
|
</p>
|
|
|
|
<div className="hero-cta mt-8 sm:mt-14">
|
|
<button
|
|
onClick={openBooking}
|
|
className="group relative rounded-full border border-gold/60 bg-gold/15 px-8 py-4 text-base font-semibold text-gold backdrop-blur-md transition-all duration-300 hover:bg-gold/25 hover:border-gold hover:shadow-[0_0_40px_rgba(201,169,110,0.35)] cursor-pointer sm:px-10 sm:py-5 sm:text-lg"
|
|
>
|
|
<span className="relative z-10">{hero.ctaText}</span>
|
|
{/* Pulse glow on hover */}
|
|
<span className="absolute inset-0 rounded-full bg-gold/10 opacity-0 group-hover:opacity-100 group-hover:animate-pulse transition-opacity" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|