Files
vesta/components/sections/CloudRevealSection.tsx
T

384 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,
useMotionValueEvent,
useReducedMotion,
} from 'framer-motion'
import UnitRevealOverlay from './UnitRevealOverlay'
const TOTAL_FRAMES = 299
const CDN = 'https://media.ayris.tech/t'
const FRAME_PATH = (n: number) =>
`${CDN}/vesta/frames/ezgif-frame-${String(n).padStart(3, '0')}.jpg`
// Scroll aralığına göre opacity döndüren helper
function usePhaseOpacity(
scrollYProgress: ReturnType<typeof useScroll>['scrollYProgress'],
inStart: number,
inEnd: number,
outStart: number,
outEnd: number
) {
return useTransform(
scrollYProgress,
[inStart, inEnd, outStart, outEnd],
[0, 1, 1, 0]
)
}
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[]>([])
const currentFrameRef = useRef(0)
const [loaded, setLoaded] = useState(false)
const [loadProgress, setLoadProgress] = useState(0)
const hasSnapped = useRef(false)
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])
// --- Text phase opacities ---
// 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: 015% → 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: 2045% → orta metin (merkez)
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: 5072% → stat kartları (sağ)
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: 78100% → CTA (merkez alt)
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])
// Frame preload
useEffect(() => {
if (prefersReducedMotion) return
const images: HTMLImageElement[] = new Array(TOTAL_FRAMES)
let loadedCount = 0
let cancelled = false
for (let i = 0; i < TOTAL_FRAMES; i++) {
const img = new Image()
img.src = FRAME_PATH(i + 1)
img.onload = () => {
if (cancelled) return
loadedCount++
setLoadProgress(Math.round((loadedCount / TOTAL_FRAMES) * 100))
if (loadedCount === TOTAL_FRAMES) {
framesRef.current = images
setLoaded(true)
drawFrame(0, images)
}
}
images[i] = img
}
return () => { cancelled = true }
}, [prefersReducedMotion])
function drawFrame(index: number, images?: HTMLImageElement[]) {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const imgs = images || framesRef.current
const img = imgs[index]
if (!img || !img.complete || img.naturalWidth === 0) return
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
}
useMotionValueEvent(scrollYProgress, 'change', (progress) => {
if (!loaded) return
const frameIndex = Math.min(Math.floor(progress * (TOTAL_FRAMES - 1)), TOTAL_FRAMES - 1)
if (frameIndex !== currentFrameRef.current) {
currentFrameRef.current = frameIndex
drawFrame(frameIndex)
}
})
// Scroll pozisyonunu hero sonunda kilitle
useEffect(() => {
const lockScroll = () => {
const section = sectionRef.current
if (!section) return
// Hero'nun scroll edebileceği maksimum pozisyon
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)
}, [])
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const resize = () => {
canvas.width = window.innerWidth
canvas.height = window.innerHeight
drawFrame(currentFrameRef.current)
}
resize()
window.addEventListener('resize', resize)
return () => window.removeEventListener('resize', resize)
}, [loaded])
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' },
]
// Zigzag SVG çizgisi — dot'tan karta uzanır
function ZigzagLine({ dir, delay }: { dir: 'left' | 'right'; delay: number }) {
// sağa giden: sol→sağ, sola giden: sağ→sol
const w = 72
const h = 24
// zigzag path: 3 segment, orta kısmı yukarı/aşağı kayar
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'}`}>
{/* Pulsing dot */}
<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>
{/* Zigzag çizgi */}
<ZigzagLine dir={spot.dir} delay={i * 0.25 + 0.1} />
{/* Ok ucu */}
<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>
{/* Kart */}
<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>
)
}