first commit
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { Instagram, Mail, Phone } from 'lucide-react'
|
||||
|
||||
interface FooterProps {
|
||||
locale: string
|
||||
}
|
||||
|
||||
export default function Footer({ locale }: FooterProps) {
|
||||
const t = useTranslations('footer')
|
||||
|
||||
return (
|
||||
<footer className="bg-vesta-dark border-t border-white/8 py-12">
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-8">
|
||||
{/* Logo */}
|
||||
<Link href={`/${locale}`}>
|
||||
<Image
|
||||
src="https://cdn.prod.website-files.com/6693a42300f08d15d3514511/6694e59f468a02af6267826d_H%20FULL%20LOGO%20-%20WHITE.png"
|
||||
alt="Vesta Muğla"
|
||||
width={140}
|
||||
height={35}
|
||||
className="h-7 w-auto object-contain opacity-80"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* Linkler */}
|
||||
<div className="flex items-center gap-6">
|
||||
<a
|
||||
href="tel:+904443145"
|
||||
className="text-white/40 hover:text-white/80 transition-colors"
|
||||
>
|
||||
<Phone className="w-4 h-4" />
|
||||
</a>
|
||||
<a
|
||||
href="mailto:vestamugla@gmail.com"
|
||||
className="text-white/40 hover:text-white/80 transition-colors"
|
||||
>
|
||||
<Mail className="w-4 h-4" />
|
||||
</a>
|
||||
<a
|
||||
href="https://www.instagram.com/vestamugla/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-white/40 hover:text-white/80 transition-colors"
|
||||
>
|
||||
<Instagram className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Copyright */}
|
||||
<p className="text-white/30 text-xs text-center md:text-right">
|
||||
{t('rights')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { Menu, X, Phone } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SITE } from '@/lib/constants'
|
||||
|
||||
interface NavigationProps {
|
||||
locale: string
|
||||
}
|
||||
|
||||
export default function Navigation({ locale }: NavigationProps) {
|
||||
const t = useTranslations('nav')
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
// Hero section 500vh — navbar scroll bittikten sonra görünür
|
||||
const heroEnd = window.innerHeight * 4.5
|
||||
setVisible(window.scrollY > heroEnd)
|
||||
setScrolled(window.scrollY > heroEnd + 80)
|
||||
}
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
|
||||
const otherLocale = locale === 'tr' ? 'en' : 'tr'
|
||||
|
||||
const links = [
|
||||
{ key: 'about', href: '#proje' },
|
||||
{ key: 'location', href: '#lokasyon' },
|
||||
{ key: 'units', href: '#daireler' },
|
||||
{ key: 'gallery', href: '#galeri' },
|
||||
{ key: 'contact', href: '#iletisim' },
|
||||
]
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'fixed top-0 left-0 right-0 z-50 transition-all duration-500',
|
||||
visible ? 'translate-y-0 opacity-100' : '-translate-y-full opacity-0',
|
||||
scrolled
|
||||
? 'bg-vesta-dark/95 backdrop-blur-md py-3 shadow-lg'
|
||||
: 'bg-transparent py-5'
|
||||
)}
|
||||
>
|
||||
<div className="container mx-auto px-6 flex items-center justify-between">
|
||||
{/* Logo */}
|
||||
<Link href={`/${locale}`} className="flex items-center">
|
||||
<Image
|
||||
src={SITE.logoUrl}
|
||||
alt={SITE.name}
|
||||
width={160}
|
||||
height={40}
|
||||
priority
|
||||
className="h-8 w-auto object-contain"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<nav className="hidden md:flex items-center gap-8">
|
||||
{links.map((link) => (
|
||||
<a
|
||||
key={link.key}
|
||||
href={link.href}
|
||||
className="text-white/80 hover:text-white text-sm tracking-wide transition-colors duration-200"
|
||||
>
|
||||
{t(link.key as keyof ReturnType<typeof t>)}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="hidden md:flex items-center gap-4">
|
||||
{/* Dil seçici */}
|
||||
<Link
|
||||
href={`/${otherLocale}`}
|
||||
aria-label={`Switch language to ${otherLocale === 'tr' ? 'Turkish' : 'English'}`}
|
||||
className="text-white/60 hover:text-white text-xs tracking-widest uppercase transition-colors"
|
||||
>
|
||||
{otherLocale}
|
||||
</Link>
|
||||
|
||||
{/* Telefon */}
|
||||
<a
|
||||
href={SITE.phoneHref}
|
||||
className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-4 py-2 rounded-full text-sm tracking-wide transition-all duration-200 active:scale-[0.98]"
|
||||
>
|
||||
<Phone className="w-3.5 h-3.5" />
|
||||
{t('phone')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Mobile hamburger */}
|
||||
<button
|
||||
className="md:hidden text-white p-2"
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
aria-label={menuOpen ? 'Close menu' : 'Open menu'}
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="mobile-menu"
|
||||
>
|
||||
{menuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
{menuOpen && (
|
||||
<div id="mobile-menu" className="md:hidden bg-vesta-dark/98 backdrop-blur-md border-t border-white/10">
|
||||
<div className="container mx-auto px-6 py-6 flex flex-col gap-4">
|
||||
{links.map((link) => (
|
||||
<a
|
||||
key={link.key}
|
||||
href={link.href}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="text-white/80 hover:text-white text-base py-2 border-b border-white/10 transition-colors"
|
||||
>
|
||||
{t(link.key as keyof ReturnType<typeof t>)}
|
||||
</a>
|
||||
))}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<a href={SITE.phoneHref} className="text-white flex items-center gap-2">
|
||||
<Phone className="w-4 h-4" />
|
||||
{t('phone')}
|
||||
</a>
|
||||
<Link
|
||||
href={`/${otherLocale}`}
|
||||
className="text-white/50 uppercase text-sm"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
>
|
||||
{otherLocale}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { motion, useInView } from 'framer-motion'
|
||||
import Image from 'next/image'
|
||||
|
||||
export default function AboutSection() {
|
||||
const t = useTranslations('about')
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const isInView = useInView(ref, { once: true, margin: '-100px' })
|
||||
|
||||
const stats = [
|
||||
{ label: t('stat1Label'), value: t('stat1Value') },
|
||||
{ label: t('stat2Label'), value: t('stat2Value') },
|
||||
{ label: t('stat3Label'), value: t('stat3Value') },
|
||||
]
|
||||
|
||||
return (
|
||||
<section id="proje" ref={ref} className="bg-vesta-dark py-24 md:py-32">
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="grid md:grid-cols-2 gap-16 items-center">
|
||||
{/* Sol: Görsel */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -40 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.9, ease: 'easeOut' }}
|
||||
className="relative aspect-[4/5] rounded-2xl overflow-hidden"
|
||||
>
|
||||
<Image
|
||||
src="https://images.unsplash.com/photo-1448375240586-882707db888b?w=800&q=80"
|
||||
alt="Vesta Muğla - Orman"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-vesta-dark/60 to-transparent" />
|
||||
{/* Label */}
|
||||
<div className="absolute bottom-6 left-6">
|
||||
<span className="text-white/60 text-xs tracking-[0.3em] uppercase">
|
||||
Emtisi İnşaat
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Sağ: İçerik */}
|
||||
<div>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
className="text-vesta-earth text-xs tracking-[0.3em] uppercase mb-4"
|
||||
>
|
||||
{t('label')}
|
||||
</motion.p>
|
||||
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.7, delay: 0.2 }}
|
||||
className="text-white text-3xl md:text-4xl font-serif font-light leading-snug mb-6"
|
||||
>
|
||||
{t('title')}
|
||||
</motion.h2>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.7, delay: 0.3 }}
|
||||
className="text-white/50 text-base leading-relaxed mb-12"
|
||||
>
|
||||
{t('desc')}
|
||||
</motion.p>
|
||||
|
||||
{/* İstatistikler */}
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{stats.map((stat, i) => (
|
||||
<motion.div
|
||||
key={stat.label}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.4 + i * 0.1 }}
|
||||
className="border-t border-white/10 pt-4"
|
||||
>
|
||||
<p className="text-white text-2xl md:text-3xl font-serif font-light">
|
||||
{stat.value}
|
||||
</p>
|
||||
<p className="text-white/40 text-xs mt-1 tracking-wide">{stat.label}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { motion, useInView } from 'framer-motion'
|
||||
import Image from 'next/image'
|
||||
import { TreePine, Users, Dumbbell, Wind } from 'lucide-react'
|
||||
|
||||
const amenities = [
|
||||
{
|
||||
key: 'livingAreas' as const,
|
||||
descKey: 'livingDesc' as const,
|
||||
icon: Users,
|
||||
image: 'https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=800&q=80',
|
||||
},
|
||||
{
|
||||
key: 'playground' as const,
|
||||
descKey: 'playgroundDesc' as const,
|
||||
icon: Wind,
|
||||
image: 'https://images.unsplash.com/photo-1575783970733-1aaedde1db74?w=800&q=80',
|
||||
},
|
||||
{
|
||||
key: 'sports' as const,
|
||||
descKey: 'sportsDesc' as const,
|
||||
icon: Dumbbell,
|
||||
image: 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=800&q=80',
|
||||
},
|
||||
{
|
||||
key: 'forest' as const,
|
||||
descKey: 'forestDesc' as const,
|
||||
icon: TreePine,
|
||||
image: 'https://images.unsplash.com/photo-1448375240586-882707db888b?w=800&q=80',
|
||||
},
|
||||
]
|
||||
|
||||
export default function AmenitiesSection() {
|
||||
const t = useTranslations('amenities')
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const isInView = useInView(ref, { once: true, margin: '-80px' })
|
||||
|
||||
return (
|
||||
<section className="bg-vesta-cream py-24 md:py-32">
|
||||
<div ref={ref} className="container mx-auto px-6">
|
||||
{/* Başlık */}
|
||||
<div className="text-center mb-16">
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-vesta-earth text-xs tracking-[0.3em] uppercase mb-3"
|
||||
>
|
||||
{t('label')}
|
||||
</motion.p>
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
className="text-vesta-dark text-3xl md:text-5xl font-serif font-light"
|
||||
>
|
||||
{t('title')}
|
||||
</motion.h2>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{amenities.map((item, i) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<motion.div
|
||||
key={item.key}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.15 * i }}
|
||||
className="relative rounded-2xl overflow-hidden group cursor-default"
|
||||
>
|
||||
{/* Arka plan görseli */}
|
||||
<div className="relative aspect-[16/9]">
|
||||
<Image
|
||||
src={item.image}
|
||||
alt={t(item.key)}
|
||||
fill
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* İçerik */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Icon className="w-4 h-4 text-white/70" />
|
||||
<h3 className="text-white font-medium text-lg">{t(item.key)}</h3>
|
||||
</div>
|
||||
<p className="text-white/60 text-sm leading-relaxed max-w-md">
|
||||
{t(item.descKey)}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { motion, useInView } from 'framer-motion'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Phone, Mail, Instagram, Send } from 'lucide-react'
|
||||
import { ContactSchema, type ContactInput } from '@/lib/validations'
|
||||
|
||||
export default function ContactSection() {
|
||||
const t = useTranslations('contact')
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const isInView = useInView(ref, { once: true, margin: '-80px' })
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
|
||||
|
||||
const { register, handleSubmit, reset, formState: { errors } } = useForm<ContactInput>({
|
||||
resolver: zodResolver(ContactSchema),
|
||||
})
|
||||
|
||||
const onSubmit = async (data: ContactInput) => {
|
||||
setStatus('loading')
|
||||
try {
|
||||
const res = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!res.ok) throw new Error()
|
||||
setStatus('success')
|
||||
reset()
|
||||
} catch {
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="iletisim" className="bg-vesta-cream py-24 md:py-32">
|
||||
<div ref={ref} className="container mx-auto px-6">
|
||||
<div className="grid md:grid-cols-2 gap-16 items-start">
|
||||
{/* Sol: Bilgi */}
|
||||
<div>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-vesta-earth text-xs tracking-[0.3em] uppercase mb-4"
|
||||
>
|
||||
{t('label')}
|
||||
</motion.p>
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
className="text-vesta-dark text-3xl md:text-4xl font-serif font-light leading-snug mb-4"
|
||||
>
|
||||
{t('title')}
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="text-vesta-dark/50 text-base mb-10"
|
||||
>
|
||||
{t('subtitle')}
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
<a
|
||||
href={`tel:+904443145`}
|
||||
className="flex items-center gap-4 text-vesta-dark hover:text-vesta-earth transition-colors group"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-vesta-dark/10 flex items-center justify-center group-hover:bg-vesta-earth/20 transition-colors">
|
||||
<Phone className="w-4 h-4" />
|
||||
</div>
|
||||
<span className="text-lg">{t('phone')}</span>
|
||||
</a>
|
||||
<a
|
||||
href={`mailto:${t('email')}`}
|
||||
className="flex items-center gap-4 text-vesta-dark hover:text-vesta-earth transition-colors group"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-vesta-dark/10 flex items-center justify-center group-hover:bg-vesta-earth/20 transition-colors">
|
||||
<Mail className="w-4 h-4" />
|
||||
</div>
|
||||
<span className="text-lg">{t('email')}</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.instagram.com/vestamugla/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 text-vesta-dark hover:text-vesta-earth transition-colors group"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-vesta-dark/10 flex items-center justify-center group-hover:bg-vesta-earth/20 transition-colors">
|
||||
<Instagram className="w-4 h-4" />
|
||||
</div>
|
||||
<span className="text-lg">{t('instagram')}</span>
|
||||
</a>
|
||||
|
||||
{/* Sanal Tur */}
|
||||
<a
|
||||
href="https://emtisiinsaat.com/vr.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 mt-4 border border-vesta-dark/20 text-vesta-dark px-5 py-2.5 rounded-full text-sm hover:bg-vesta-dark hover:text-white transition-colors"
|
||||
>
|
||||
360° Sanal Tur
|
||||
</a>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Sağ: Form */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.7, delay: 0.2 }}
|
||||
className="bg-white rounded-2xl p-8 shadow-sm"
|
||||
>
|
||||
{status === 'success' ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 rounded-full bg-vesta-forest/20 flex items-center justify-center mx-auto mb-4">
|
||||
<Send className="w-5 h-5 text-vesta-forest" />
|
||||
</div>
|
||||
<p className="text-vesta-dark font-medium">{t('form.success')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
<div>
|
||||
<input
|
||||
{...register('fullName')}
|
||||
placeholder={t('form.name')}
|
||||
className="w-full border border-gray-200 rounded-xl px-4 py-3 text-sm text-vesta-dark placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-vesta-earth/30 focus:border-vesta-earth"
|
||||
/>
|
||||
{errors.fullName && (
|
||||
<p className="text-red-500 text-xs mt-1">{errors.fullName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
{...register('email')}
|
||||
type="email"
|
||||
placeholder={t('form.email')}
|
||||
className="w-full border border-gray-200 rounded-xl px-4 py-3 text-sm text-vesta-dark placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-vesta-earth/30 focus:border-vesta-earth"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-red-500 text-xs mt-1">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
{...register('phone')}
|
||||
type="tel"
|
||||
placeholder={t('form.phone')}
|
||||
className="w-full border border-gray-200 rounded-xl px-4 py-3 text-sm text-vesta-dark placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-vesta-earth/30 focus:border-vesta-earth"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<textarea
|
||||
{...register('message')}
|
||||
placeholder={t('form.message')}
|
||||
rows={4}
|
||||
className="w-full border border-gray-200 rounded-xl px-4 py-3 text-sm text-vesta-dark placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-vesta-earth/30 focus:border-vesta-earth resize-none"
|
||||
/>
|
||||
{errors.message && (
|
||||
<p className="text-red-500 text-xs mt-1">{errors.message.message}</p>
|
||||
)}
|
||||
</div>
|
||||
{status === 'error' && (
|
||||
<p className="text-red-500 text-xs">{t('form.error')}</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'loading'}
|
||||
className="w-full bg-vesta-dark text-white py-3.5 rounded-xl text-sm tracking-wide hover:bg-vesta-forest transition-colors disabled:opacity-50"
|
||||
>
|
||||
{status === 'loading' ? t('form.sending') : t('form.send')}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { motion, useInView } from 'framer-motion'
|
||||
import Image from 'next/image'
|
||||
import { MOCK_GALLERY } from '@/lib/mock'
|
||||
|
||||
export default function GallerySection() {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const isInView = useInView(ref, { once: true, margin: '-80px' })
|
||||
|
||||
return (
|
||||
<section id="galeri" className="bg-vesta-dark py-24 md:py-32">
|
||||
<div ref={ref} className="container mx-auto px-6">
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-vesta-earth text-xs tracking-[0.3em] uppercase mb-12 text-center"
|
||||
>
|
||||
Galeri
|
||||
</motion.p>
|
||||
|
||||
{/* Masonry-like grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{MOCK_GALLERY.map((item, i) => (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
initial={{ opacity: 0, scale: 0.97 }}
|
||||
animate={isInView ? { opacity: 1, scale: 1 } : {}}
|
||||
transition={{ duration: 0.5, delay: 0.07 * i }}
|
||||
className={`relative overflow-hidden rounded-xl group ${
|
||||
i === 0 ? 'col-span-2 md:col-span-2 row-span-2 aspect-[4/3]' : 'aspect-square'
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={item.imageUrl}
|
||||
alt={item.titleTr}
|
||||
fill
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-108"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors duration-300" />
|
||||
<div className="absolute bottom-3 left-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<p className="text-white text-sm font-medium">{item.titleTr}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { motion, useInView } from 'framer-motion'
|
||||
import { Waves, GraduationCap, Building2, Heart, Plane, Landmark } from 'lucide-react'
|
||||
|
||||
const locationItems = [
|
||||
{ key: 'beach' as const, itemsKey: 'beachItems' as const, icon: Waves },
|
||||
{ key: 'education' as const, itemsKey: 'educationItems' as const, icon: GraduationCap },
|
||||
{ key: 'city' as const, itemsKey: 'cityItems' as const, icon: Building2 },
|
||||
{ key: 'health' as const, itemsKey: 'healthItems' as const, icon: Heart },
|
||||
{ key: 'airport' as const, itemsKey: 'airportItems' as const, icon: Plane },
|
||||
{ key: 'tourism' as const, itemsKey: 'tourismItems' as const, icon: Landmark },
|
||||
]
|
||||
|
||||
export default function LocationSection() {
|
||||
const t = useTranslations('location')
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const isInView = useInView(ref, { once: true, margin: '-80px' })
|
||||
|
||||
return (
|
||||
<section id="lokasyon" className="bg-vesta-dark py-24 md:py-32">
|
||||
<div ref={ref} className="container mx-auto px-6">
|
||||
<div className="grid md:grid-cols-2 gap-16 items-start">
|
||||
{/* Sol: Başlık + Grid */}
|
||||
<div>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-vesta-earth text-xs tracking-[0.3em] uppercase mb-4"
|
||||
>
|
||||
{t('label')}
|
||||
</motion.p>
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
className="text-white text-3xl md:text-4xl font-serif font-light leading-snug mb-12"
|
||||
>
|
||||
{t('title')}
|
||||
</motion.h2>
|
||||
|
||||
<div className="grid grid-cols-1 gap-5">
|
||||
{locationItems.map((item, i) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<motion.div
|
||||
key={item.key}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: 0.15 + i * 0.08 }}
|
||||
className="flex items-start gap-4 border-b border-white/8 pb-5"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full bg-vesta-forest/30 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<Icon className="w-4 h-4 text-vesta-earth" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white/80 text-sm font-medium mb-1">{t(item.key)}</p>
|
||||
<p className="text-white/40 text-xs leading-relaxed">{t(item.itemsKey)}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sağ: Harita embed */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 40 }}
|
||||
animate={isInView ? { opacity: 1, x: 0 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="rounded-2xl overflow-hidden aspect-square md:aspect-auto md:h-[600px] sticky top-24"
|
||||
>
|
||||
<iframe
|
||||
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d50536.60905254124!2d28.3200!3d37.2153!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x14be5ce7c5a5ccdb%3A0x1e3eb12f3c0f3c0a!2sMu%C4%9Fla%2C%20T%C3%BCrkiye!5e1!3m2!1str!2str!4v1700000000000"
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ border: 0 }}
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
className="grayscale"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
const UNIT_FRAMES = 120
|
||||
const CDN = 'https://media.ayris.tech/t'
|
||||
|
||||
// Video kullanan birimler
|
||||
const VIDEO_UNITS: Record<string, string> = {
|
||||
'blok-a': `${CDN}/vesta/unit-videos/blok-a.mp4`,
|
||||
}
|
||||
|
||||
interface UnitRevealOverlayProps {
|
||||
unitId: string | null
|
||||
unitLabel: string
|
||||
unitSub: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function UnitRevealOverlay({
|
||||
unitId,
|
||||
unitLabel,
|
||||
unitSub,
|
||||
onClose,
|
||||
}: UnitRevealOverlayProps) {
|
||||
const isVideo = unitId ? !!VIDEO_UNITS[unitId] : false
|
||||
|
||||
// ── Video mode ──────────────────────────────────────────────────
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const [videoFinished, setVideoFinished] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!unitId || !isVideo) return
|
||||
setVideoFinished(false)
|
||||
const v = videoRef.current
|
||||
if (!v) return
|
||||
v.currentTime = 0
|
||||
v.play().catch(() => {})
|
||||
}, [unitId, isVideo])
|
||||
|
||||
// ── Frame mode ───────────────────────────────────────────────────
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const framesRef = useRef<HTMLImageElement[]>([])
|
||||
const rafRef = useRef<number | null>(null)
|
||||
const [loadProgress, setLoadProgress] = useState(0)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [finished, setFinished] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!unitId || isVideo) return
|
||||
|
||||
setLoaded(false)
|
||||
setFinished(false)
|
||||
setLoadProgress(0)
|
||||
framesRef.current = []
|
||||
|
||||
const images: HTMLImageElement[] = new Array(UNIT_FRAMES)
|
||||
let loadedCount = 0
|
||||
let cancelled = false
|
||||
|
||||
for (let i = 0; i < UNIT_FRAMES; i++) {
|
||||
const img = new Image()
|
||||
img.src = `${CDN}/vesta/unit-frames/${unitId}/ezgif-frame-${String(i + 1).padStart(3, '0')}.jpg`
|
||||
img.onload = () => {
|
||||
if (cancelled) return
|
||||
loadedCount++
|
||||
setLoadProgress(Math.round((loadedCount / UNIT_FRAMES) * 100))
|
||||
if (loadedCount === UNIT_FRAMES) {
|
||||
framesRef.current = images
|
||||
setLoaded(true)
|
||||
}
|
||||
}
|
||||
images[i] = img
|
||||
}
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [unitId, isVideo])
|
||||
|
||||
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)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded || isVideo) return
|
||||
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas?.getContext('2d')
|
||||
if (!canvas || !ctx) return
|
||||
|
||||
let frame = 0
|
||||
const fps = 24
|
||||
const interval = 1000 / fps
|
||||
let last = 0
|
||||
|
||||
const animate = (ts: number) => {
|
||||
if (ts - last >= interval) {
|
||||
const img = framesRef.current[frame]
|
||||
if (img?.complete && img.naturalWidth > 0) {
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
last = ts
|
||||
if (frame < UNIT_FRAMES - 1) {
|
||||
frame++
|
||||
} else {
|
||||
setFinished(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(animate)
|
||||
return () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current)
|
||||
}
|
||||
}, [loaded, isVideo])
|
||||
|
||||
// ESC kapat
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
const showInfo = isVideo ? videoFinished : finished
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{unitId && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="fixed inset-0 z-50 bg-black"
|
||||
>
|
||||
{/* Video modu */}
|
||||
{isVideo && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={VIDEO_UNITS[unitId!]}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
playsInline
|
||||
muted
|
||||
onEnded={() => setVideoFinished(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Frame modu */}
|
||||
{!isVideo && (
|
||||
<>
|
||||
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
|
||||
{!loaded && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black z-10">
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Kapat */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-6 right-6 z-20 flex items-center gap-2 bg-black/40 backdrop-blur-sm border border-white/20 text-white/70 hover:text-white hover:bg-black/60 transition-all rounded-full px-4 py-2 text-sm"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Geri
|
||||
</button>
|
||||
|
||||
{/* Bitiş bilgisi */}
|
||||
<AnimatePresence>
|
||||
{showInfo && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="absolute bottom-16 left-10 z-20"
|
||||
>
|
||||
<p className="text-white/50 text-[10px] tracking-[0.4em] uppercase mb-2">Vesta Muğla</p>
|
||||
<h2 className="text-white text-3xl md:text-4xl font-serif font-light">{unitLabel}</h2>
|
||||
<p className="text-white/50 mt-1 text-sm">{unitSub}</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="mt-5 flex items-center gap-2 text-white/60 hover:text-white text-xs tracking-widest uppercase transition-colors"
|
||||
>
|
||||
<span>← Projeye Dön</span>
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { motion, useInView } from 'framer-motion'
|
||||
import Image from 'next/image'
|
||||
import { BedDouble, Bath, Maximize2 } from 'lucide-react'
|
||||
import { MOCK_UNITS } from '@/lib/mock'
|
||||
|
||||
export default function UnitsSection() {
|
||||
const t = useTranslations('units')
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const isInView = useInView(ref, { once: true, margin: '-80px' })
|
||||
|
||||
return (
|
||||
<section id="daireler" className="bg-vesta-cream py-24 md:py-32">
|
||||
<div ref={ref} className="container mx-auto px-6">
|
||||
{/* Başlık */}
|
||||
<div className="text-center mb-16">
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="text-vesta-earth text-xs tracking-[0.3em] uppercase mb-3"
|
||||
>
|
||||
{t('label')}
|
||||
</motion.p>
|
||||
<motion.h2
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
className="text-vesta-dark text-3xl md:text-5xl font-serif font-light"
|
||||
>
|
||||
{t('title')}
|
||||
</motion.h2>
|
||||
</div>
|
||||
|
||||
{/* Daire listesi */}
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{MOCK_UNITS.map((unit, i) => (
|
||||
<motion.div
|
||||
key={unit.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.1 * i }}
|
||||
className="group bg-white rounded-2xl overflow-hidden shadow-sm hover:shadow-xl transition-shadow duration-400"
|
||||
>
|
||||
{/* Görsel */}
|
||||
<div className="relative aspect-video overflow-hidden">
|
||||
<Image
|
||||
src={unit.imageUrl || 'https://images.unsplash.com/photo-1560448204-e02f11c3d0e2?w=800'}
|
||||
alt={unit.typeTr}
|
||||
fill
|
||||
className="object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
/>
|
||||
{/* Müsaitlik badge */}
|
||||
<div className={`absolute top-4 right-4 px-3 py-1 rounded-full text-xs font-medium ${
|
||||
unit.available
|
||||
? 'bg-vesta-forest text-white'
|
||||
: 'bg-gray-400 text-white'
|
||||
}`}>
|
||||
{unit.available ? t('available') : t('notAvailable')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* İçerik */}
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="text-vesta-dark text-xl font-serif font-medium">
|
||||
{unit.typeTr}
|
||||
</h3>
|
||||
<span className="text-vesta-earth text-2xl font-serif font-light">
|
||||
{unit.size} <span className="text-sm">{t('sqm')}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-vesta-dark/60 text-sm leading-relaxed mb-5 line-clamp-2">
|
||||
{unit.descTr}
|
||||
</p>
|
||||
|
||||
{/* Özellikler */}
|
||||
<div className="flex items-center gap-5 pt-4 border-t border-gray-100">
|
||||
<div className="flex items-center gap-1.5 text-vesta-dark/50 text-sm">
|
||||
<BedDouble className="w-4 h-4" />
|
||||
<span>{unit.rooms} {t('rooms')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-vesta-dark/50 text-sm">
|
||||
<Bath className="w-4 h-4" />
|
||||
<span>{unit.bathrooms} {t('bath')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-vesta-dark/50 text-sm">
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
<span>{unit.size} {t('sqm')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Katalog CTA */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6, delay: 0.5 }}
|
||||
className="text-center mt-12"
|
||||
>
|
||||
<a
|
||||
href="https://indd.adobe.com/view/6b17e969-ae44-481c-861f-684806e8c3b2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 bg-vesta-dark text-white px-8 py-4 rounded-full text-sm tracking-wide hover:bg-vesta-forest transition-colors duration-300"
|
||||
>
|
||||
Proje Kataloğunu İncele
|
||||
</a>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user