From 1f29eead28a8e3502e23c0c89d84b0c0759b9ad9 Mon Sep 17 00:00:00 2001 From: Mustafa Date: Thu, 30 Jul 2026 12:15:17 +0300 Subject: [PATCH] first commit --- .dockerignore | 8 + .env.example | 17 + .gitignore | 3 + Dockerfile | 32 + app/[locale]/admin/layout.tsx | 61 + app/[locale]/admin/login/page.tsx | 81 + app/[locale]/admin/messages/page.tsx | 82 + app/[locale]/admin/page.tsx | 63 + app/[locale]/admin/units/page.tsx | 72 + app/[locale]/layout.tsx | 42 + app/[locale]/page.tsx | 31 + app/api/admin/messages/route.ts | 21 + app/api/admin/units/route.ts | 31 + app/api/auth/[...nextauth]/route.ts | 2 + app/api/contact/route.ts | 34 + app/api/upload/route.ts | 27 + app/globals.css | 82 + app/layout.tsx | 9 + app/page.tsx | 5 + components/Footer.tsx | 60 + components/Navigation.tsx | 130 + components/sections/AboutSection.tsx | 96 + components/sections/AmenitiesSection.tsx | 104 + components/sections/CloudRevealSection.tsx | 171 + components/sections/ContactSection.tsx | 189 + components/sections/GallerySection.tsx | 53 + components/sections/LocationSection.tsx | 91 + components/sections/UnitsSection.tsx | 120 + i18n/request.ts | 13 + i18n/routing.ts | 6 + lib/auth-helpers.ts | 14 + lib/auth.ts | 43 + lib/cloudinary.ts | 24 + lib/db.ts | 9 + lib/mock.ts | 191 + lib/utils.ts | 21 + lib/validations.ts | 29 + messages/en.json | 89 + messages/tr.json | 89 + next-env.d.ts | 5 + next.config.ts | 17 + package-lock.json | 8053 ++++++++++++++++++++ package.json | 56 + postcss.config.mjs | 7 + prisma/schema.prisma | 110 + proxy.ts | 27 + tailwind.config.ts | 106 + tsconfig.json | 40 + 48 files changed, 10666 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/[locale]/admin/layout.tsx create mode 100644 app/[locale]/admin/login/page.tsx create mode 100644 app/[locale]/admin/messages/page.tsx create mode 100644 app/[locale]/admin/page.tsx create mode 100644 app/[locale]/admin/units/page.tsx create mode 100644 app/[locale]/layout.tsx create mode 100644 app/[locale]/page.tsx create mode 100644 app/api/admin/messages/route.ts create mode 100644 app/api/admin/units/route.ts create mode 100644 app/api/auth/[...nextauth]/route.ts create mode 100644 app/api/contact/route.ts create mode 100644 app/api/upload/route.ts create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 components/Footer.tsx create mode 100644 components/Navigation.tsx create mode 100644 components/sections/AboutSection.tsx create mode 100644 components/sections/AmenitiesSection.tsx create mode 100644 components/sections/CloudRevealSection.tsx create mode 100644 components/sections/ContactSection.tsx create mode 100644 components/sections/GallerySection.tsx create mode 100644 components/sections/LocationSection.tsx create mode 100644 components/sections/UnitsSection.tsx create mode 100644 i18n/request.ts create mode 100644 i18n/routing.ts create mode 100644 lib/auth-helpers.ts create mode 100644 lib/auth.ts create mode 100644 lib/cloudinary.ts create mode 100644 lib/db.ts create mode 100644 lib/mock.ts create mode 100644 lib/utils.ts create mode 100644 lib/validations.ts create mode 100644 messages/en.json create mode 100644 messages/tr.json create mode 100644 next-env.d.ts create mode 100644 next.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 prisma/schema.prisma create mode 100644 proxy.ts create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fd1f5b0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.gitignore +node_modules +.next +.env +.env.* +!.env.example +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2340120 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# Database +DATABASE_URL="postgresql://user:password@localhost:5432/vesta_mugla" + +# NextAuth +NEXTAUTH_SECRET="generate-with-openssl-rand-base64-32" +NEXTAUTH_URL="http://localhost:3000" + +# Cloudinary +CLOUDINARY_CLOUD_NAME="" +CLOUDINARY_API_KEY="" +CLOUDINARY_API_SECRET="" + +# Mock data (dev: true, production: false) +USE_MOCK="true" + +# Email (opsiyonel - iletişim formu için) +RESEND_API_KEY="" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..be0850b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Media assets (deploy aşamasında sunucuya ayrıca kopyalanır) +public/frames/ +public/hero-scroll.mp4 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..afbd09a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM node:20-alpine AS base + +FROM base AS deps +RUN apk add --no-cache libc6-compat +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci --legacy-peer-deps + +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npx prisma generate +RUN npm run build + +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs +COPY --from=builder /app/public ./public +RUN mkdir .next +RUN chown nextjs:nodejs .next +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" +CMD ["node", "server.js"] diff --git a/app/[locale]/admin/layout.tsx b/app/[locale]/admin/layout.tsx new file mode 100644 index 0000000..ea77b00 --- /dev/null +++ b/app/[locale]/admin/layout.tsx @@ -0,0 +1,61 @@ +import Link from 'next/link' +import { LayoutDashboard, Home, MessageSquare, Images, LogOut } from 'lucide-react' +import Image from 'next/image' + +const navItems = [ + { href: '/tr/admin', label: 'Dashboard', icon: LayoutDashboard }, + { href: '/tr/admin/units', label: 'Daireler', icon: Home }, + { href: '/tr/admin/messages', label: 'Mesajlar', icon: MessageSquare }, + { href: '/tr/admin/gallery', label: 'Galeri', icon: Images }, +] + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + return ( +
+ {/* Sidebar */} + + + {/* Main */} +
+ {children} +
+
+ ) +} diff --git a/app/[locale]/admin/login/page.tsx b/app/[locale]/admin/login/page.tsx new file mode 100644 index 0000000..55c43c5 --- /dev/null +++ b/app/[locale]/admin/login/page.tsx @@ -0,0 +1,81 @@ +'use client' + +import { useState } from 'react' +import { signIn } from 'next-auth/react' +import { useRouter } from 'next/navigation' +import Image from 'next/image' + +export default function AdminLoginPage() { + const router = useRouter() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setLoading(true) + setError('') + + const result = await signIn('credentials', { + email, + password, + redirect: false, + }) + + if (result?.error) { + setError('Email veya şifre hatalı.') + setLoading(false) + } else { + router.push('/tr/admin') + } + } + + return ( +
+
+
+ Vesta Muğla +

Admin Paneli

+
+ +
+
+ setEmail(e.target.value)} + placeholder="Email" + required + className="w-full bg-white/10 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-white/30 focus:outline-none focus:ring-2 focus:ring-vesta-earth/50" + /> +
+
+ setPassword(e.target.value)} + placeholder="Şifre" + required + className="w-full bg-white/10 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-white/30 focus:outline-none focus:ring-2 focus:ring-vesta-earth/50" + /> +
+ {error &&

{error}

} + +
+
+
+ ) +} diff --git a/app/[locale]/admin/messages/page.tsx b/app/[locale]/admin/messages/page.tsx new file mode 100644 index 0000000..45b668a --- /dev/null +++ b/app/[locale]/admin/messages/page.tsx @@ -0,0 +1,82 @@ +import { prisma } from '@/lib/db' +import { MOCK_MESSAGES } from '@/lib/mock' +import { Mail, Phone, Clock } from 'lucide-react' + +const USE_MOCK = process.env.USE_MOCK === 'true' + +async function getMessages() { + if (USE_MOCK) return MOCK_MESSAGES + return prisma.contactMessage.findMany({ + where: { deletedAt: null }, + orderBy: { createdAt: 'desc' }, + }) +} + +export default async function MessagesPage() { + const messages = await getMessages() + + return ( +
+
+
+

Mesajlar

+

+ {messages.filter((m) => !m.read).length} okunmamış mesaj +

+
+
+ +
+ {messages.map((msg) => ( +
+
+
+

{msg.fullName}

+
+ + + {msg.email} + + {msg.phone && ( + + + {msg.phone} + + )} +
+
+
+ + {new Date(msg.createdAt).toLocaleDateString('tr-TR')} +
+
+

{msg.message}

+ {!msg.read && ( + + Yeni + + )} +
+ ))} + + {messages.length === 0 && ( +
+ +

Henüz mesaj yok

+
+ )} +
+
+ ) +} diff --git a/app/[locale]/admin/page.tsx b/app/[locale]/admin/page.tsx new file mode 100644 index 0000000..02a9b9a --- /dev/null +++ b/app/[locale]/admin/page.tsx @@ -0,0 +1,63 @@ +import { Home, MessageSquare, Images, Users } from 'lucide-react' +import { prisma } from '@/lib/db' +import { MOCK_UNITS, MOCK_MESSAGES, MOCK_GALLERY } from '@/lib/mock' + +const USE_MOCK = process.env.USE_MOCK === 'true' + +async function getStats() { + if (USE_MOCK) { + return { + units: MOCK_UNITS.length, + messages: MOCK_MESSAGES.length, + unread: MOCK_MESSAGES.filter((m) => !m.read).length, + gallery: MOCK_GALLERY.length, + } + } + const [units, messages, gallery] = await Promise.all([ + prisma.unit.count({ where: { deletedAt: null } }), + prisma.contactMessage.count({ where: { deletedAt: null } }), + prisma.gallery.count({ where: { deletedAt: null } }), + ]) + const unread = await prisma.contactMessage.count({ where: { deletedAt: null, read: false } }) + return { units, messages, unread, gallery } +} + +export default async function AdminDashboard() { + const stats = await getStats() + + const cards = [ + { label: 'Daire Tipi', value: stats.units, icon: Home, color: 'bg-blue-50 text-blue-600' }, + { + label: 'Mesaj', + value: stats.messages, + sub: `${stats.unread} okunmamış`, + icon: MessageSquare, + color: 'bg-amber-50 text-amber-600', + }, + { label: 'Galeri', value: stats.gallery, icon: Images, color: 'bg-green-50 text-green-600' }, + { label: 'Kullanıcı', value: 1, icon: Users, color: 'bg-purple-50 text-purple-600' }, + ] + + return ( +
+

Dashboard

+

Vesta Muğla yönetim paneline hoş geldiniz.

+ +
+ {cards.map((card) => { + const Icon = card.icon + return ( +
+
+ +
+

{card.value}

+

{card.label}

+ {card.sub &&

{card.sub}

} +
+ ) + })} +
+
+ ) +} diff --git a/app/[locale]/admin/units/page.tsx b/app/[locale]/admin/units/page.tsx new file mode 100644 index 0000000..56e8cdb --- /dev/null +++ b/app/[locale]/admin/units/page.tsx @@ -0,0 +1,72 @@ +import { prisma } from '@/lib/db' +import { MOCK_UNITS } from '@/lib/mock' +import { BedDouble, Maximize2, CheckCircle2, XCircle } from 'lucide-react' +import Image from 'next/image' + +const USE_MOCK = process.env.USE_MOCK === 'true' + +async function getUnits() { + if (USE_MOCK) return MOCK_UNITS + return prisma.unit.findMany({ where: { deletedAt: null }, orderBy: { order: 'asc' } }) +} + +export default async function UnitsAdminPage() { + const units = await getUnits() + + return ( +
+
+
+

Daireler

+

{units.length} daire tipi

+
+ +
+ +
+ {units.map((unit) => ( +
+ {unit.imageUrl && ( +
+ {unit.typeTr} +
+ )} +
+
+

{unit.typeTr}

+ {unit.available ? ( + + Müsait + + ) : ( + + Satıldı + + )} +
+
+ + {unit.size} m² + + + {unit.rooms} oda + +
+
+ +
+ ))} +
+
+ ) +} diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx new file mode 100644 index 0000000..9d31982 --- /dev/null +++ b/app/[locale]/layout.tsx @@ -0,0 +1,42 @@ +import type { Metadata } from 'next' +import { NextIntlClientProvider } from 'next-intl' +import { getMessages } from 'next-intl/server' +import { notFound } from 'next/navigation' +import { routing } from '@/i18n/routing' + +export const metadata: Metadata = { + title: 'Vesta Muğla | Evinizde Doğa, Doğada Huzur', + description: + "Muğla'nın doğal güzellikleri içinde yer alan lüks rezidans projesi. Modern tasarımlar, sosyal alanlar ve doğayla iç içe huzurlu bir yaşam.", + openGraph: { + title: 'Vesta Muğla', + description: "Muğla'nın orman içinde lüks konut projesi", + images: ['https://images.unsplash.com/photo-1580587771525-78b9dba3b914?w=1200&q=80'], + }, +} + +export function generateStaticParams() { + return routing.locales.map((locale) => ({ locale })) +} + +export default async function LocaleLayout({ + children, + params, +}: { + children: React.ReactNode + params: Promise<{ locale: string }> +}) { + const { locale } = await params + + if (!routing.locales.includes(locale as 'tr' | 'en')) { + notFound() + } + + const messages = await getMessages() + + return ( + + {children} + + ) +} diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx new file mode 100644 index 0000000..01fa6f4 --- /dev/null +++ b/app/[locale]/page.tsx @@ -0,0 +1,31 @@ +import Navigation from '@/components/Navigation' +import CloudRevealSection from '@/components/sections/CloudRevealSection' +import AboutSection from '@/components/sections/AboutSection' +import AmenitiesSection from '@/components/sections/AmenitiesSection' +import LocationSection from '@/components/sections/LocationSection' +import UnitsSection from '@/components/sections/UnitsSection' +import GallerySection from '@/components/sections/GallerySection' +import ContactSection from '@/components/sections/ContactSection' +import Footer from '@/components/Footer' + +export default async function HomePage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + + return ( +
+ + + + + + + + +
+
+ ) +} diff --git a/app/api/admin/messages/route.ts b/app/api/admin/messages/route.ts new file mode 100644 index 0000000..7209307 --- /dev/null +++ b/app/api/admin/messages/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/db' +import { requireAdmin } from '@/lib/auth-helpers' +import { MOCK_MESSAGES } from '@/lib/mock' + +const USE_MOCK = process.env.USE_MOCK === 'true' + +export async function GET() { + try { + await requireAdmin() + const data = USE_MOCK + ? MOCK_MESSAGES + : await prisma.contactMessage.findMany({ + where: { deletedAt: null }, + orderBy: { createdAt: 'desc' }, + }) + return NextResponse.json({ data }) + } catch { + return NextResponse.json({ error: 'Server error' }, { status: 500 }) + } +} diff --git a/app/api/admin/units/route.ts b/app/api/admin/units/route.ts new file mode 100644 index 0000000..f006b24 --- /dev/null +++ b/app/api/admin/units/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/db' +import { requireAdmin } from '@/lib/auth-helpers' +import { MOCK_UNITS } from '@/lib/mock' +import { UnitSchema } from '@/lib/validations' + +const USE_MOCK = process.env.USE_MOCK === 'true' + +export async function GET() { + try { + const data = USE_MOCK + ? MOCK_UNITS + : await prisma.unit.findMany({ where: { deletedAt: null }, orderBy: { order: 'asc' } }) + return NextResponse.json({ data }) + } catch { + return NextResponse.json({ error: 'Server error' }, { status: 500 }) + } +} + +export async function POST(req: NextRequest) { + try { + await requireAdmin() + const body = await req.json() + const parsed = UnitSchema.safeParse(body) + if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }) + const record = await prisma.unit.create({ data: parsed.data }) + return NextResponse.json({ data: record }, { status: 201 }) + } catch { + return NextResponse.json({ error: 'Server error' }, { status: 500 }) + } +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..1c4d0de --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,2 @@ +import { handlers } from '@/lib/auth' +export const { GET, POST } = handlers diff --git a/app/api/contact/route.ts b/app/api/contact/route.ts new file mode 100644 index 0000000..e3bd2f9 --- /dev/null +++ b/app/api/contact/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/db' +import { ContactSchema } from '@/lib/validations' + +export async function POST(req: NextRequest) { + try { + const body = await req.json() + const parsed = ContactSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }) + } + + const message = await prisma.contactMessage.create({ + data: parsed.data, + }) + + return NextResponse.json({ data: message }, { status: 201 }) + } catch (error) { + console.error('Contact form error:', error) + return NextResponse.json({ error: 'Server error' }, { status: 500 }) + } +} + +export async function GET() { + try { + const messages = await prisma.contactMessage.findMany({ + where: { deletedAt: null }, + orderBy: { createdAt: 'desc' }, + }) + return NextResponse.json({ data: messages }) + } catch { + return NextResponse.json({ error: 'Server error' }, { status: 500 }) + } +} diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts new file mode 100644 index 0000000..7d105c4 --- /dev/null +++ b/app/api/upload/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server' +import { uploadImage, deleteImage } from '@/lib/cloudinary' +import { requireAdmin } from '@/lib/auth-helpers' + +export async function POST(req: NextRequest) { + try { + await requireAdmin() + const { file, folder = 'vesta-mugla' } = await req.json() + if (!file) return NextResponse.json({ error: 'File required' }, { status: 400 }) + const result = await uploadImage(file, folder) + return NextResponse.json({ data: result }) + } catch (error) { + console.error('Upload error:', error) + return NextResponse.json({ error: 'Upload failed' }, { status: 500 }) + } +} + +export async function DELETE(req: NextRequest) { + try { + await requireAdmin() + const { publicId } = await req.json() + await deleteImage(publicId) + return NextResponse.json({ success: true }) + } catch { + return NextResponse.json({ error: 'Delete failed' }, { status: 500 }) + } +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..29b3b49 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,82 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;1,300;1,400&family=Inter:wght@300;400;500;600&display=swap'); + +:root { + --font-sans: 'Inter', system-ui, sans-serif; + --font-serif: 'Cormorant Garamond', Georgia, serif; + --font-display: 'Inter', system-ui, sans-serif; + --radius: 0.75rem; + + /* shadcn/ui CSS variables */ + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + scroll-behavior: smooth; +} + +body { + font-family: var(--font-sans); + background-color: #1a1a1a; + color: #1a1a1a; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Serif font utility */ +.font-serif { + font-family: var(--font-serif) !important; +} + +/* Smooth scrollbar */ +::-webkit-scrollbar { + width: 6px; +} +::-webkit-scrollbar-track { + background: #1a1a1a; +} +::-webkit-scrollbar-thumb { + background: #3a3a3a; + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: #555; +} + +/* GSAP kullanıldığı için overflow-x gizle */ +body { + overflow-x: hidden; +} + +/* Subtle text selection */ +::selection { + background: rgba(139, 115, 85, 0.3); + color: inherit; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..1f28d54 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,9 @@ +import './globals.css' + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..9a36af0 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation' + +export default function RootPage() { + redirect('/tr') +} diff --git a/components/Footer.tsx b/components/Footer.tsx new file mode 100644 index 0000000..5846189 --- /dev/null +++ b/components/Footer.tsx @@ -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 ( + + ) +} diff --git a/components/Navigation.tsx b/components/Navigation.tsx new file mode 100644 index 0000000..5aaa647 --- /dev/null +++ b/components/Navigation.tsx @@ -0,0 +1,130 @@ +'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' + +interface NavigationProps { + locale: string +} + +export default function Navigation({ locale }: NavigationProps) { + const t = useTranslations('nav') + const [menuOpen, setMenuOpen] = useState(false) + const [scrolled, setScrolled] = useState(false) + + useEffect(() => { + const handleScroll = () => setScrolled(window.scrollY > 80) + window.addEventListener('scroll', handleScroll) + 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 ( +
+
+ {/* Logo */} + + Vesta Muğla + + + {/* Desktop nav */} + + + {/* Right side */} +
+ {/* Dil seçici */} + + {otherLocale} + + + {/* Telefon */} + + + {t('phone')} + +
+ + {/* Mobile hamburger */} + +
+ + {/* Mobile menu */} + {menuOpen && ( +
+
+ {links.map((link) => ( + 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)} + + ))} +
+ + + {t('phone')} + + setMenuOpen(false)} + > + {otherLocale} + +
+
+
+ )} +
+ ) +} diff --git a/components/sections/AboutSection.tsx b/components/sections/AboutSection.tsx new file mode 100644 index 0000000..4572d73 --- /dev/null +++ b/components/sections/AboutSection.tsx @@ -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(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 ( +
+
+
+ {/* Sol: Görsel */} + + Vesta Muğla - Orman +
+ {/* Label */} +
+ + Emtisi İnşaat + +
+ + + {/* Sağ: İçerik */} +
+ + {t('label')} + + + + {t('title')} + + + + {t('desc')} + + + {/* İstatistikler */} +
+ {stats.map((stat, i) => ( + +

+ {stat.value} +

+

{stat.label}

+
+ ))} +
+
+
+
+
+ ) +} diff --git a/components/sections/AmenitiesSection.tsx b/components/sections/AmenitiesSection.tsx new file mode 100644 index 0000000..c40845a --- /dev/null +++ b/components/sections/AmenitiesSection.tsx @@ -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(null) + const isInView = useInView(ref, { once: true, margin: '-80px' }) + + return ( +
+
+ {/* Başlık */} +
+ + {t('label')} + + + {t('title')} + +
+ + {/* Grid */} +
+ {amenities.map((item, i) => { + const Icon = item.icon + return ( + + {/* Arka plan görseli */} +
+ {t(item.key)} +
+
+ + {/* İçerik */} +
+
+ +

{t(item.key)}

+
+

+ {t(item.descKey)} +

+
+ + ) + })} +
+
+
+ ) +} diff --git a/components/sections/CloudRevealSection.tsx b/components/sections/CloudRevealSection.tsx new file mode 100644 index 0000000..1d8c4e0 --- /dev/null +++ b/components/sections/CloudRevealSection.tsx @@ -0,0 +1,171 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { useTranslations } from 'next-intl' + +const TOTAL_FRAMES = 298 +const FRAME_PATH = (n: number) => + `/frames/ezgif-frame-${String(n).padStart(3, '0')}.jpg` + +export default function CloudRevealSection() { + const t = useTranslations('hero') + const sectionRef = useRef(null) + const canvasRef = useRef(null) + const textRef = useRef(null) + const framesRef = useRef([]) + const currentFrameRef = useRef(0) + const rafRef = useRef(null) + const [loaded, setLoaded] = useState(false) + const [loadProgress, setLoadProgress] = useState(0) + + // Frame'leri sıralı preload et + useEffect(() => { + const images: HTMLImageElement[] = new Array(TOTAL_FRAMES) + let loadedCount = 0 + + for (let i = 0; i < TOTAL_FRAMES; i++) { + const img = new Image() + img.src = FRAME_PATH(i + 1) + img.onload = () => { + loadedCount++ + setLoadProgress(Math.round((loadedCount / TOTAL_FRAMES) * 100)) + if (loadedCount === TOTAL_FRAMES) { + framesRef.current = images + setLoaded(true) + // İlk frame'i hemen çiz + drawFrame(0, images) + } + } + images[i] = img + } + }, []) + + 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) + } + + // Scroll handler + useEffect(() => { + if (!loaded) return + + const section = sectionRef.current + const text = textRef.current + if (!section || !text) return + + const handleScroll = () => { + const rect = section.getBoundingClientRect() + const sectionHeight = section.offsetHeight + const windowH = window.innerHeight + + const scrolled = -rect.top + const total = sectionHeight - windowH + const progress = Math.min(Math.max(scrolled / total, 0), 1) + + // Hangi frame'i göstereceğimizi hesapla + const frameIndex = Math.min( + Math.floor(progress * (TOTAL_FRAMES - 1)), + TOTAL_FRAMES - 1 + ) + + // Sadece frame değiştiyse çiz (RAF ile) + if (frameIndex !== currentFrameRef.current) { + currentFrameRef.current = frameIndex + if (rafRef.current) cancelAnimationFrame(rafRef.current) + rafRef.current = requestAnimationFrame(() => drawFrame(frameIndex)) + } + + // Tagline: ilk %15'te görünür, sonra solar + if (progress < 0.15) { + const opacity = 1 - progress / 0.15 + text.style.opacity = String(opacity) + text.style.transform = `translateY(${progress * 40}px)` + } else { + text.style.opacity = '0' + } + } + + window.addEventListener('scroll', handleScroll, { passive: true }) + return () => { + window.removeEventListener('scroll', handleScroll) + if (rafRef.current) cancelAnimationFrame(rafRef.current) + } + }, [loaded]) + + // Canvas boyutunu ekrana sığdır + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + const resize = () => { + canvas.width = window.innerWidth + canvas.height = window.innerHeight + // Resize sonrası mevcut frame'i yeniden çiz + drawFrame(currentFrameRef.current) + } + resize() + window.addEventListener('resize', resize) + return () => window.removeEventListener('resize', resize) + }, [loaded]) + + return ( +
+
+ + {/* Canvas — frame'ler buraya çizilir */} + + + {/* Loading ekranı */} + {!loaded && ( +
+
+
+
+

+ {loadProgress}% +

+
+ )} + + {/* Tagline */} +
+

+ Vesta Muğla +

+

+ {t('tagline')} +

+

+ {t('subtitle')} +

+
+ + {/* Scroll indicator */} + {loaded && ( +
+ + {t('scroll')} + +
+
+ )} +
+
+ ) +} diff --git a/components/sections/ContactSection.tsx b/components/sections/ContactSection.tsx new file mode 100644 index 0000000..54c50d9 --- /dev/null +++ b/components/sections/ContactSection.tsx @@ -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(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({ + 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 ( +
+
+
+ {/* Sol: Bilgi */} +
+ + {t('label')} + + + {t('title')} + + + {t('subtitle')} + + + + +
+ +
+ {t('phone')} +
+ +
+ +
+ {t('email')} +
+ +
+ +
+ {t('instagram')} +
+ + {/* Sanal Tur */} + + 360° Sanal Tur + +
+
+ + {/* Sağ: Form */} + + {status === 'success' ? ( +
+
+ +
+

{t('form.success')}

+
+ ) : ( +
+
+ + {errors.fullName && ( +

{errors.fullName.message}

+ )} +
+
+ + {errors.email && ( +

{errors.email.message}

+ )} +
+
+ +
+
+