Files
vesta/components/sections/CloudRevealSection.tsx
T

390 lines
15 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 { useEffect, useRef, useState } from 'react'
import { useTranslations } from 'next-intl'
import {
motion,
useScroll,
useTransform,
useReducedMotion,
} from 'framer-motion'
import UnitRevealOverlay from './UnitRevealOverlay'
const TOTAL_FRAMES = 299
const OPENINARY_BASE = process.env.NEXT_PUBLIC_OPENINARY_URL ?? 'https://media.ayris.tech'
function frameSrc(i: number) {
const path = `vesta/frames/ezgif-frame-${String(i + 1).padStart(3, '0')}.jpg`
return `${OPENINARY_BASE}/t/w_1920,f_webp,q_65/${path}`
}
export default function CloudRevealSection() {
const t = useTranslations('hero')
const prefersReducedMotion = useReducedMotion()
const sectionRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const framesRef = useRef<(HTMLImageElement | null)[]>([])
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'],
})
// Progress bar
const progressScaleX = useTransform(scrollYProgress, [0, 1], [0, 1])
// Logo: 08% görünür, sonra kaybolur
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])
// Phase 1: tagline (sol alt)
const phase1Opacity = useTransform(scrollYProgress, [0, 0.05, 0.12, 0.18], [0, 1, 1, 0])
const phase1Y = useTransform(scrollYProgress, [0, 0.05], [20, 0])
// Phase 2: orta metin
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])
// Phase 3: stat rakamları
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])
// Phase 4: CTA
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)
}, [])
// RAF loop: DOM scroll → frame index → canvas (Framer Motion bağımsız)
useEffect(() => {
const canvas = canvasRef.current
const section = sectionRef.current
if (!canvas || !section) return
const ctx = canvas.getContext('2d')
if (!ctx) return
let rafId: number
const loop = () => {
const rect = section.getBoundingClientRect()
const totalHeight = section.offsetHeight - window.innerHeight
const scrolled = -rect.top
const progress = Math.max(0, Math.min(1, scrolled / totalHeight))
const targetIdx = Math.min(
Math.floor(progress * (TOTAL_FRAMES - 1)),
TOTAL_FRAMES - 1
)
for (let i = targetIdx; i >= 0; i--) {
const img = framesRef.current[i]
if (img && canvas.width > 0) {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
break
}
}
rafId = requestAnimationFrame(loop)
}
rafId = requestAnimationFrame(loop)
return () => cancelAnimationFrame(rafId)
}, [])
// Frame preload
useEffect(() => {
if (prefersReducedMotion) return
framesRef.current = new Array(TOTAL_FRAMES).fill(null)
let loadedCount = 0
let cancelled = false
for (let i = 0; i < TOTAL_FRAMES; i++) {
const img = new Image()
img.src = frameSrc(i)
img.onload = () => {
if (cancelled) return
framesRef.current[i] = img
loadedCount++
setLoadProgress(Math.round((loadedCount / TOTAL_FRAMES) * 100))
// İlk frame yüklenince ekranı
if (i === 0 && !loaded) {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
if (canvas && ctx) {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
}
setLoaded(true)
}
}
img.onerror = () => {
if (cancelled) return
loadedCount++
if (loadedCount === TOTAL_FRAMES) setLoadProgress(100)
}
}
return () => { cancelled = true }
}, [prefersReducedMotion])
// 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 */}
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
{/* Alt gradient */}
<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 — merkez, ilk frame'de görünür, scroll ile kaybolur */}
<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 — Sol alt: Ana tagline */}
<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 — Merkez: Konum & kimlik */}
<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 — Sağ: Stat rakamları */}
<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 — Merkez alt: CTA */}
<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>
{/* HOTSPOTS — son frame'de görünür */}
<UnitHotspots
scrollYProgress={scrollYProgress}
onSelect={(unit) => setActiveUnit(unit)}
/>
{/* Progress bar */}
<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>
{/* Unit reveal overlay */}
<UnitRevealOverlay
unitId={activeUnit?.id ?? null}
unitLabel={activeUnit?.label ?? ''}
unitSub={activeUnit?.sub ?? ''}
onClose={() => setActiveUnit(null)}
/>
</>
)
}
// ─── Hotspot bileşeni ────────────────────────────────────────────────────────
const HOTSPOTS: {
id: string
label: string
sub: string
x: number
y: number
dir: 'left' | 'right'
}[] = [
{ id: 'blok-a', label: '2+1 Tip A', sub: 'Blok A · 83.35 m²', x: 52, y: 28, dir: 'left' },
{ id: 'blok-b', label: '2+1 Tip B', sub: 'Blok B · 84 m²', x: 93, y: 30, dir: 'left' },
{ id: 'blok-c', label: '1+1', sub: 'Blok C · 60 m²', x: 11, y: 42, dir: 'right' },
]
function ZigzagLine({ dir, delay }: { dir: 'left' | 'right'; delay: number }) {
const w = 72
const 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>
)
}