Files
2026-08-05 19:40:55 +03:00

301 lines
14 KiB
TypeScript
Raw Permalink 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 { useEffect, useRef, useState } from 'react'
import { useTranslations } from 'next-intl'
import {
motion,
useScroll,
useTransform,
useReducedMotion,
} from 'framer-motion'
import UnitRevealOverlay from './UnitRevealOverlay'
const HERO_VIDEO = '/hero/hero.mp4'
export default function CloudRevealSection() {
const t = useTranslations('hero')
const prefersReducedMotion = useReducedMotion()
const sectionRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const videoRef = useRef<HTMLVideoElement | null>(null)
const curTimeRef = useRef(0) // lerp'in anlık değeri
const targetTimeRef = useRef(0) // scroll'un hedef değeri
const [loaded, setLoaded] = useState(false)
const [loadProgress, setLoadProgress] = useState(0)
const [activeUnit, setActiveUnit] = useState<{ id: string; label: string; sub: string } | null>(null)
const { scrollYProgress } = useScroll({
target: sectionRef,
offset: ['start start', 'end end'],
})
const progressScaleX = useTransform(scrollYProgress, [0, 1], [0, 1])
const logoOpacity = useTransform(scrollYProgress, [0, 0.02, 0.08, 0.14], [0, 1, 1, 0])
const logoScale = useTransform(scrollYProgress, [0, 0.02], [0.92, 1])
const phase1Opacity = useTransform(scrollYProgress, [0, 0.05, 0.12, 0.18], [0, 1, 1, 0])
const phase1Y = useTransform(scrollYProgress, [0, 0.05], [20, 0])
const phase2Opacity = useTransform(scrollYProgress, [0.20, 0.28, 0.40, 0.48], [0, 1, 1, 0])
const phase2Y = useTransform(scrollYProgress, [0.20, 0.28], [30, 0])
const phase3Opacity = useTransform(scrollYProgress, [0.50, 0.57, 0.68, 0.75], [0, 1, 1, 0])
const phase3Y = useTransform(scrollYProgress, [0.50, 0.57], [30, 0])
const phase4Opacity = useTransform(scrollYProgress, [0.78, 0.85, 0.95, 1], [0, 1, 1, 0])
const phase4Y = useTransform(scrollYProgress, [0.78, 0.85], [30, 0])
// Canvas boyutu
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const resize = () => { canvas.width = window.innerWidth; canvas.height = window.innerHeight }
resize()
window.addEventListener('resize', resize)
return () => window.removeEventListener('resize', resize)
}, [])
// Blob yükle → video → RAF loop
useEffect(() => {
if (prefersReducedMotion) return
let cancelled = false
let rafId: number
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
if (!canvas || !ctx) return
// Blob fetch — progress ile
fetch(HERO_VIDEO)
.then(res => {
if (!res.ok) throw new Error('fetch failed')
const total = Number(res.headers.get('content-length') || 0)
const reader = res.body!.getReader()
const chunks: BlobPart[] = []
let received = 0
const pump = (): Promise<Blob> =>
reader.read().then(({ done, value }) => {
if (done) return new Blob(chunks, { type: 'video/mp4' })
chunks.push(value!)
received += value!.length
if (total > 0) setLoadProgress(Math.round((received / total) * 100))
return pump()
})
return pump()
})
.then(blob => {
if (cancelled) return
const url = URL.createObjectURL(blob)
const v = document.createElement('video')
v.muted = true
v.playsInline = true
v.preload = 'auto'
v.src = url
v.addEventListener('loadedmetadata', () => {
if (cancelled) return
videoRef.current = v
setLoaded(true)
// RAF loop: lerp curTime → targetTime, canvas'a çiz
const loop = () => {
if (v.readyState >= 2) {
const eps = 0.008
curTimeRef.current += (targetTimeRef.current - curTimeRef.current) * 0.18
const t = Math.max(0, Math.min(curTimeRef.current, v.duration * 0.999))
if (!v.seeking && Math.abs(v.currentTime - t) > eps) {
try { v.currentTime = t } catch (_) {}
}
ctx.drawImage(v, 0, 0, canvas.width, canvas.height)
}
rafId = requestAnimationFrame(loop)
}
rafId = requestAnimationFrame(loop)
})
})
.catch(() => {}) // sessizce yoksay
return () => {
cancelled = true
cancelAnimationFrame(rafId)
if (videoRef.current?.src?.startsWith('blob:')) URL.revokeObjectURL(videoRef.current.src)
videoRef.current = null
}
}, [prefersReducedMotion])
// Scroll → targetTime
useEffect(() => {
return scrollYProgress.on('change', (progress) => {
const v = videoRef.current
if (!v || !v.duration) return
targetTimeRef.current = progress * v.duration
})
}, [scrollYProgress])
// Scroll kilidi
useEffect(() => {
const lockScroll = () => {
const section = sectionRef.current
if (!section) return
const maxY = section.offsetTop + section.offsetHeight - window.innerHeight
if (window.scrollY > maxY) window.scrollTo({ top: maxY, behavior: 'instant' })
}
window.addEventListener('scroll', lockScroll, { passive: true })
return () => window.removeEventListener('scroll', lockScroll)
}, [])
if (prefersReducedMotion) {
return (
<div className="relative min-h-[100dvh] w-full overflow-hidden bg-black flex items-end">
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent" />
<div className="relative z-10 px-10 pb-24">
<p className="text-white/50 text-xs tracking-[0.4em] uppercase mb-4">Vesta Muğla</p>
<h1 className="text-white text-5xl md:text-7xl font-serif font-light leading-tight">{t('tagline')}</h1>
<p className="text-white/50 mt-4 text-base font-light max-w-md">{t('subtitle')}</p>
</div>
</div>
)
}
return (
<>
<div ref={sectionRef} style={{ height: '500vh' }}>
<div className="sticky top-0 min-h-[100dvh] w-full overflow-hidden bg-black">
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
{/* Loading */}
{!loaded && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black z-20">
<div className="w-48 h-px bg-white/10 mb-3">
<div className="h-full bg-white/60 transition-all duration-150" style={{ width: `${loadProgress}%` }} />
</div>
<p className="text-white/30 text-xs tracking-widest uppercase">{loadProgress}%</p>
</div>
)}
{/* LOGO */}
<motion.div
style={{ opacity: logoOpacity, scale: logoScale }}
className="absolute inset-0 flex flex-col items-center justify-center z-10 pointer-events-none"
>
<p className="text-white text-5xl md:text-7xl font-serif font-light tracking-[0.25em] drop-shadow-2xl">VESTA</p>
<div className="mt-3 w-16 h-px bg-white/40" />
<p className="text-white/60 text-xs tracking-[0.5em] uppercase mt-3">Muğla</p>
</motion.div>
{/* PHASE 1 */}
<motion.div style={{ opacity: phase1Opacity, y: phase1Y }} className="absolute bottom-20 left-10 z-10 max-w-sm">
<p className="text-white/50 text-[10px] tracking-[0.4em] uppercase mb-3">Vesta Muğla</p>
<h1 className="text-white text-4xl md:text-5xl font-serif font-light leading-tight drop-shadow-2xl">{t('tagline')}</h1>
<p className="text-white/50 mt-3 text-sm font-light">{t('subtitle')}</p>
</motion.div>
{/* PHASE 2 */}
<motion.div style={{ opacity: phase2Opacity, y: phase2Y }} className="absolute inset-0 flex items-center justify-center z-10 pointer-events-none">
<div className="text-center px-6">
<p className="text-white/40 text-[10px] tracking-[0.5em] uppercase mb-5">Muğla · Türkiye</p>
<p className="text-white text-3xl md:text-5xl font-serif font-light leading-snug drop-shadow-2xl">
Bulutların altında,<br /><span className="italic">ormanın içinde</span>
</p>
<div className="mt-6 w-12 h-px bg-white/30 mx-auto" />
</div>
</motion.div>
{/* PHASE 3 */}
<motion.div style={{ opacity: phase3Opacity, y: phase3Y }} className="absolute right-10 top-1/2 -translate-y-1/2 z-10 flex flex-col gap-8 text-right">
{[{ value: '120', label: 'Konut' }, { value: '15.000', label: 'm² Alan' }, { value: '%60', label: 'Yeşil Alan' }].map(stat => (
<div key={stat.label}>
<p className="text-white text-4xl md:text-5xl font-serif font-light drop-shadow-xl">{stat.value}</p>
<p className="text-white/40 text-xs tracking-widest uppercase mt-1">{stat.label}</p>
</div>
))}
</motion.div>
{/* PHASE 4 */}
<motion.div style={{ opacity: phase4Opacity, y: phase4Y }} className="absolute bottom-24 left-0 right-0 z-10 flex flex-col items-center gap-4">
<p className="text-white/40 text-[10px] tracking-[0.5em] uppercase">Projeyi Keşfet</p>
<p className="text-white text-2xl md:text-3xl font-serif font-light text-center drop-shadow-xl">
Hayalinizdeki yaşam<br />bir adım uzağınızda
</p>
<div className="mt-2 w-px h-10 bg-white/30 animate-pulse" />
</motion.div>
<UnitHotspots scrollYProgress={scrollYProgress} onSelect={(unit) => setActiveUnit(unit)} />
<div className="absolute bottom-8 left-10 right-10 z-10 h-[2px] bg-white/10">
<motion.div className="h-full bg-white/60 origin-left" style={{ scaleX: progressScaleX }} />
</div>
</div>
</div>
<UnitRevealOverlay
unitId={activeUnit?.id ?? null}
unitLabel={activeUnit?.label ?? ''}
unitSub={activeUnit?.sub ?? ''}
onClose={() => setActiveUnit(null)}
/>
</>
)
}
// ─── Hotspot bileşeni ────────────────────────────────────────────────────────
const HOTSPOTS = [
{ id: 'blok-a', label: '2+1 Tip A', sub: 'Blok A · 83.35 m²', x: 52, y: 28, dir: 'left' as const },
{ id: 'blok-b', label: '2+1 Tip B', sub: 'Blok B · 84 m²', x: 93, y: 30, dir: 'left' as const },
{ id: 'blok-c', label: '1+1', sub: 'Blok C · 60 m²', x: 11, y: 42, dir: 'right' as const },
]
function ZigzagLine({ dir, delay }: { dir: 'left' | 'right'; delay: number }) {
const w = 72, h = 24
const path = dir === 'right'
? `M0,${h/2} L${w*0.3},${h/2} L${w*0.45},4 L${w*0.6},${h-4} L${w*0.75},${h/2} L${w},${h/2}`
: `M${w},${h/2} L${w*0.7},${h/2} L${w*0.55},4 L${w*0.4},${h-4} L${w*0.25},${h/2} L0,${h/2}`
return (
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} fill="none" className="shrink-0">
<motion.path d={path} stroke="rgba(255,255,255,0.65)" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"
initial={{ pathLength: 0, opacity: 0 }} animate={{ pathLength: 1, opacity: 1 }}
transition={{ delay, duration: 0.7, ease: 'easeInOut' }} />
</svg>
)
}
function UnitHotspots({ scrollYProgress, onSelect }: {
scrollYProgress: ReturnType<typeof useScroll>['scrollYProgress']
onSelect: (unit: { id: string; label: string; sub: string }) => void
}) {
const containerOpacity = useTransform(scrollYProgress, [0.92, 0.98], [0, 1])
return (
<motion.div style={{ opacity: containerOpacity }} className="absolute inset-0 z-20 pointer-events-none">
{HOTSPOTS.map((spot, i) => (
<div key={spot.id} className="absolute" style={{ left: `${spot.x}%`, top: `${spot.y}%` }}>
<div className={`flex items-center gap-0 ${spot.dir === 'left' ? 'flex-row-reverse' : 'flex-row'}`}>
<span className="relative flex h-3 w-3 shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-70" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-white" />
</span>
<ZigzagLine dir={spot.dir} delay={i * 0.25 + 0.1} />
<motion.div initial={{ opacity: 0, x: spot.dir === 'right' ? -4 : 4 }} animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.25 + 0.7, duration: 0.2 }} className="shrink-0">
{spot.dir === 'right'
? <svg width="6" height="10" viewBox="0 0 6 10" fill="none"><path d="M1 1l4 4-4 4" stroke="white" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/></svg>
: <svg width="6" height="10" viewBox="0 0 6 10" fill="none"><path d="M5 1L1 5l4 4" stroke="white" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/></svg>
}
</motion.div>
<motion.button initial={{ opacity: 0, x: spot.dir === 'right' ? -8 : 8 }} animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.25 + 0.75, duration: 0.3, ease: 'easeOut' }}
className="pointer-events-auto bg-black/60 backdrop-blur-md border border-white/20 rounded-lg px-3 py-2 text-left hover:bg-black/80 hover:border-white/40 transition-all duration-200 active:scale-95 cursor-pointer"
onClick={() => onSelect({ id: spot.id, label: spot.label, sub: spot.sub })}>
<p className="text-white text-xs font-medium tracking-wide whitespace-nowrap">{spot.label}</p>
<p className="text-white/50 text-[10px] mt-0.5 whitespace-nowrap">{spot.sub}</p>
<div className="mt-1.5 flex items-center gap-1 text-white/40 text-[10px] tracking-widest uppercase">
<span>İncele</span>
<svg width="8" height="8" viewBox="0 0 8 8" fill="none"><path d="M1 4h6M4 1l3 3-3 3" stroke="currentColor" strokeWidth="1" strokeLinecap="round"/></svg>
</div>
</motion.button>
</div>
</div>
))}
</motion.div>
)
}