first commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
README.md
|
||||||
@@ -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=""
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Media assets (deploy aşamasında sunucuya ayrıca kopyalanır)
|
||||||
|
public/frames/
|
||||||
|
public/hero-scroll.mp4
|
||||||
+32
@@ -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"]
|
||||||
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-64 bg-vesta-dark flex flex-col min-h-screen fixed left-0 top-0 bottom-0">
|
||||||
|
<div className="p-6 border-b border-white/10">
|
||||||
|
<Image
|
||||||
|
src="https://cdn.prod.website-files.com/6693a42300f08d15d3514511/6694e59f468a02af6267826d_H%20FULL%20LOGO%20-%20WHITE.png"
|
||||||
|
alt="Vesta Muğla"
|
||||||
|
width={130}
|
||||||
|
height={32}
|
||||||
|
className="h-7 w-auto object-contain"
|
||||||
|
/>
|
||||||
|
<p className="text-white/30 text-xs mt-1">Admin Paneli</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 p-4 space-y-1">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-white/60 hover:text-white hover:bg-white/10 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-white/10">
|
||||||
|
<Link
|
||||||
|
href="/tr"
|
||||||
|
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-white/40 hover:text-white/60 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
Siteye Dön
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main */}
|
||||||
|
<main className="ml-64 flex-1 p-8">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-vesta-dark flex items-center justify-center px-4">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<Image
|
||||||
|
src="https://cdn.prod.website-files.com/6693a42300f08d15d3514511/6694e59f468a02af6267826d_H%20FULL%20LOGO%20-%20WHITE.png"
|
||||||
|
alt="Vesta Muğla"
|
||||||
|
width={160}
|
||||||
|
height={40}
|
||||||
|
className="h-8 w-auto object-contain mx-auto mb-2"
|
||||||
|
/>
|
||||||
|
<p className="text-white/40 text-sm">Admin Paneli</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="bg-white/5 border border-white/10 rounded-2xl p-8 space-y-4">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-400 text-sm">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-vesta-earth text-white py-3 rounded-xl text-sm font-medium hover:bg-vesta-earth/80 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'Giriş yapılıyor...' : 'Giriş Yap'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900">Mesajlar</h1>
|
||||||
|
<p className="text-gray-400 text-sm mt-1">
|
||||||
|
{messages.filter((m) => !m.read).length} okunmamış mesaj
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={`bg-white rounded-2xl p-6 shadow-sm border-l-4 ${
|
||||||
|
!msg.read ? 'border-vesta-earth' : 'border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-gray-900">{msg.fullName}</h3>
|
||||||
|
<div className="flex items-center gap-4 mt-1">
|
||||||
|
<a
|
||||||
|
href={`mailto:${msg.email}`}
|
||||||
|
className="flex items-center gap-1 text-gray-400 hover:text-gray-600 text-sm"
|
||||||
|
>
|
||||||
|
<Mail className="w-3.5 h-3.5" />
|
||||||
|
{msg.email}
|
||||||
|
</a>
|
||||||
|
{msg.phone && (
|
||||||
|
<a
|
||||||
|
href={`tel:${msg.phone}`}
|
||||||
|
className="flex items-center gap-1 text-gray-400 hover:text-gray-600 text-sm"
|
||||||
|
>
|
||||||
|
<Phone className="w-3.5 h-3.5" />
|
||||||
|
{msg.phone}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-gray-300 text-xs">
|
||||||
|
<Clock className="w-3.5 h-3.5" />
|
||||||
|
{new Date(msg.createdAt).toLocaleDateString('tr-TR')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 text-sm leading-relaxed">{msg.message}</p>
|
||||||
|
{!msg.read && (
|
||||||
|
<span className="inline-block mt-3 text-xs bg-vesta-earth/10 text-vesta-earth px-2 py-0.5 rounded-full">
|
||||||
|
Yeni
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div className="text-center py-16 text-gray-300">
|
||||||
|
<Mail className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||||
|
<p>Henüz mesaj yok</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900 mb-2">Dashboard</h1>
|
||||||
|
<p className="text-gray-400 text-sm mb-8">Vesta Muğla yönetim paneline hoş geldiniz.</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{cards.map((card) => {
|
||||||
|
const Icon = card.icon
|
||||||
|
return (
|
||||||
|
<div key={card.label} className="bg-white rounded-2xl p-6 shadow-sm">
|
||||||
|
<div className={`w-10 h-10 rounded-xl flex items-center justify-center mb-4 ${card.color}`}>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-semibold text-gray-900">{card.value}</p>
|
||||||
|
<p className="text-gray-400 text-sm mt-1">{card.label}</p>
|
||||||
|
{card.sub && <p className="text-xs text-amber-500 mt-1">{card.sub}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900">Daireler</h1>
|
||||||
|
<p className="text-gray-400 text-sm mt-1">{units.length} daire tipi</p>
|
||||||
|
</div>
|
||||||
|
<button className="bg-vesta-dark text-white px-5 py-2.5 rounded-xl text-sm hover:bg-vesta-forest transition-colors">
|
||||||
|
+ Yeni Daire
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{units.map((unit) => (
|
||||||
|
<div key={unit.id} className="bg-white rounded-2xl p-5 shadow-sm flex items-center gap-6">
|
||||||
|
{unit.imageUrl && (
|
||||||
|
<div className="w-20 h-20 rounded-xl overflow-hidden shrink-0">
|
||||||
|
<Image
|
||||||
|
src={unit.imageUrl}
|
||||||
|
alt={unit.typeTr}
|
||||||
|
width={80}
|
||||||
|
height={80}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<h3 className="font-medium text-gray-900">{unit.typeTr}</h3>
|
||||||
|
{unit.available ? (
|
||||||
|
<span className="flex items-center gap-1 text-green-600 text-xs">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5" /> Müsait
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1 text-gray-400 text-xs">
|
||||||
|
<XCircle className="w-3.5 h-3.5" /> Satıldı
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-gray-400 text-sm">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Maximize2 className="w-3.5 h-3.5" /> {unit.size} m²
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<BedDouble className="w-3.5 h-3.5" /> {unit.rooms} oda
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="text-gray-400 hover:text-gray-600 text-sm px-3 py-1.5 border border-gray-200 rounded-lg transition-colors">
|
||||||
|
Düzenle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<NextIntlClientProvider messages={messages}>
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main>
|
||||||
|
<Navigation locale={locale} />
|
||||||
|
<CloudRevealSection />
|
||||||
|
<AboutSection />
|
||||||
|
<AmenitiesSection />
|
||||||
|
<LocationSection />
|
||||||
|
<UnitsSection />
|
||||||
|
<GallerySection />
|
||||||
|
<ContactSection />
|
||||||
|
<Footer locale={locale} />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { handlers } from '@/lib/auth'
|
||||||
|
export const { GET, POST } = handlers
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import './globals.css'
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html suppressHydrationWarning>
|
||||||
|
<body suppressHydrationWarning>{children}</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
|
export default function RootPage() {
|
||||||
|
redirect('/tr')
|
||||||
|
}
|
||||||
@@ -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,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 (
|
||||||
|
<header
|
||||||
|
className={cn(
|
||||||
|
'fixed top-0 left-0 right-0 z-50 transition-all duration-500',
|
||||||
|
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="https://cdn.prod.website-files.com/6693a42300f08d15d3514511/6694e59f468a02af6267826d_H%20FULL%20LOGO%20-%20WHITE.png"
|
||||||
|
alt="Vesta Muğla"
|
||||||
|
width={160}
|
||||||
|
height={40}
|
||||||
|
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}`}
|
||||||
|
className="text-white/60 hover:text-white text-xs tracking-widest uppercase transition-colors"
|
||||||
|
>
|
||||||
|
{otherLocale}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Telefon */}
|
||||||
|
<a
|
||||||
|
href="tel:+904443145"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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)}
|
||||||
|
>
|
||||||
|
{menuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile menu */}
|
||||||
|
{menuOpen && (
|
||||||
|
<div 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="tel:+904443145" 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,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<HTMLDivElement>(null)
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
const textRef = useRef<HTMLDivElement>(null)
|
||||||
|
const framesRef = useRef<HTMLImageElement[]>([])
|
||||||
|
const currentFrameRef = useRef(0)
|
||||||
|
const rafRef = useRef<number | null>(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 (
|
||||||
|
<div ref={sectionRef} style={{ height: '500vh' }}>
|
||||||
|
<div className="sticky top-0 h-screen w-full overflow-hidden bg-black">
|
||||||
|
|
||||||
|
{/* Canvas — frame'ler buraya çizilir */}
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="absolute inset-0 w-full h-full"
|
||||||
|
style={{ objectFit: 'cover' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Loading ekranı */}
|
||||||
|
{!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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tagline */}
|
||||||
|
<div
|
||||||
|
ref={textRef}
|
||||||
|
className="absolute bottom-16 left-0 right-0 z-10 text-center px-6"
|
||||||
|
style={{ willChange: 'opacity, transform', transition: 'none' }}
|
||||||
|
>
|
||||||
|
<p className="text-white/60 text-xs tracking-[0.4em] uppercase mb-3">
|
||||||
|
Vesta Muğla
|
||||||
|
</p>
|
||||||
|
<h1 className="text-white text-4xl md:text-6xl font-serif font-light leading-tight drop-shadow-2xl">
|
||||||
|
{t('tagline')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-white/50 mt-4 text-base md:text-lg font-light">
|
||||||
|
{t('subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scroll indicator */}
|
||||||
|
{loaded && (
|
||||||
|
<div className="absolute bottom-8 left-1/2 -translate-x-1/2 z-10 flex flex-col items-center gap-2">
|
||||||
|
<span className="text-white/30 text-xs tracking-widest uppercase">
|
||||||
|
{t('scroll')}
|
||||||
|
</span>
|
||||||
|
<div className="w-px h-10 bg-white/20 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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,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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { getRequestConfig } from 'next-intl/server'
|
||||||
|
import { routing } from './routing'
|
||||||
|
|
||||||
|
export default getRequestConfig(async ({ requestLocale }) => {
|
||||||
|
let locale = await requestLocale
|
||||||
|
if (!locale || !routing.locales.includes(locale as 'tr' | 'en')) {
|
||||||
|
locale = routing.defaultLocale
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
locale,
|
||||||
|
messages: (await import(`../messages/${locale}.json`)).default,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineRouting } from 'next-intl/routing'
|
||||||
|
|
||||||
|
export const routing = defineRouting({
|
||||||
|
locales: ['tr', 'en'],
|
||||||
|
defaultLocale: 'tr',
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { auth } from '@/lib/auth'
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
export async function requireAdmin() {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session || (session.user as { role?: string }).role !== 'ADMIN') {
|
||||||
|
throw new Error('Unauthorized')
|
||||||
|
}
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unauthorized() {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
import NextAuth from 'next-auth'
|
||||||
|
import { PrismaAdapter } from '@auth/prisma-adapter'
|
||||||
|
import Credentials from 'next-auth/providers/credentials'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { prisma } from '@/lib/db'
|
||||||
|
|
||||||
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
|
adapter: PrismaAdapter(prisma),
|
||||||
|
providers: [
|
||||||
|
Credentials({
|
||||||
|
credentials: {
|
||||||
|
email: { label: 'Email', type: 'email' },
|
||||||
|
password: { label: 'Password', type: 'password' },
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
if (!credentials?.email || !credentials?.password) return null
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: credentials.email as string },
|
||||||
|
})
|
||||||
|
if (!user?.password) return null
|
||||||
|
const valid = await bcrypt.compare(credentials.password as string, user.password)
|
||||||
|
if (!valid) return null
|
||||||
|
return { id: user.id, email: user.email, name: user.name, role: user.role }
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
jwt({ token, user }) {
|
||||||
|
if (user) token.role = (user as { role: string }).role
|
||||||
|
return token
|
||||||
|
},
|
||||||
|
session({ session, token }) {
|
||||||
|
if (token && session.user) {
|
||||||
|
session.user.role = token.role as string
|
||||||
|
}
|
||||||
|
return session
|
||||||
|
},
|
||||||
|
},
|
||||||
|
pages: {
|
||||||
|
signIn: '/tr/admin/login',
|
||||||
|
},
|
||||||
|
session: { strategy: 'jwt' },
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { v2 as cloudinary } from 'cloudinary'
|
||||||
|
|
||||||
|
cloudinary.config({
|
||||||
|
cloud_name: process.env.CLOUDINARY_CLOUD_NAME!,
|
||||||
|
api_key: process.env.CLOUDINARY_API_KEY!,
|
||||||
|
api_secret: process.env.CLOUDINARY_API_SECRET!,
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function uploadImage(
|
||||||
|
file: string,
|
||||||
|
folder: string = 'vesta-mugla'
|
||||||
|
): Promise<{ url: string; publicId: string }> {
|
||||||
|
const result = await cloudinary.uploader.upload(file, {
|
||||||
|
folder,
|
||||||
|
transformation: [{ quality: 'auto', fetch_format: 'auto' }],
|
||||||
|
})
|
||||||
|
return { url: result.secure_url, publicId: result.public_id }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteImage(publicId: string): Promise<void> {
|
||||||
|
await cloudinary.uploader.destroy(publicId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { cloudinary }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalForPrisma.prisma ??
|
||||||
|
new PrismaClient({ log: process.env.NODE_ENV === 'development' ? ['query'] : [] })
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
// ─── KULLANICI ─────────────────────────────────────────────────────────────────
|
||||||
|
export const MOCK_USERS = [
|
||||||
|
{
|
||||||
|
id: 'mock-user-1',
|
||||||
|
name: 'Admin',
|
||||||
|
email: 'admin@vestamugla.com',
|
||||||
|
role: 'ADMIN' as const,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
updatedAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ─── DAİRE TİPLERİ ─────────────────────────────────────────────────────────────
|
||||||
|
export const MOCK_UNITS = [
|
||||||
|
{
|
||||||
|
id: 'mock-unit-1',
|
||||||
|
slug: '2-1-tip-a',
|
||||||
|
typeTr: '2+1 Tip A',
|
||||||
|
typeEn: '2+1 Type A',
|
||||||
|
size: 89.5,
|
||||||
|
rooms: 3, // salon + 2 yatak odası
|
||||||
|
bathrooms: 1,
|
||||||
|
floor: '1-4. Kat',
|
||||||
|
descTr: 'Geniş salon ve iki yatak odasından oluşan bu daire tipi, orman manzarası ile birleşince eşsiz bir yaşam alanına dönüşüyor. Modern mutfak ve ferah banyo ile her detay özenle tasarlandı.',
|
||||||
|
descEn: 'This apartment type, consisting of a spacious living room and two bedrooms, turns into a unique living space when combined with a forest view. Every detail has been carefully designed with a modern kitchen and a spacious bathroom.',
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1560448204-e02f11c3d0e2?w=800&q=80',
|
||||||
|
planUrl: null,
|
||||||
|
featured: true,
|
||||||
|
available: true,
|
||||||
|
order: 1,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
updatedAt: new Date('2024-01-01'),
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mock-unit-2',
|
||||||
|
slug: '2-1-tip-b',
|
||||||
|
typeTr: '2+1 Tip B',
|
||||||
|
typeEn: '2+1 Type B',
|
||||||
|
size: 84.8,
|
||||||
|
rooms: 3,
|
||||||
|
bathrooms: 1,
|
||||||
|
floor: '1-4. Kat',
|
||||||
|
descTr: 'Kompakt ama son derece fonksiyonel tasarımıyla 2+1 Tip B, doğayla iç içe yaşamın keyfini çıkarmak isteyenler için ideal. Balkonundan Muğla\'nın yeşil siluetini seyredebilirsiniz.',
|
||||||
|
descEn: 'Compact yet extremely functional, the 2+1 Type B is ideal for those who want to enjoy living in harmony with nature. You can watch Muğla\'s green silhouette from your balcony.',
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?w=800&q=80',
|
||||||
|
planUrl: null,
|
||||||
|
featured: false,
|
||||||
|
available: true,
|
||||||
|
order: 2,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
updatedAt: new Date('2024-01-01'),
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mock-unit-3',
|
||||||
|
slug: '1-1-tip-a',
|
||||||
|
typeTr: '1+1 Tip A',
|
||||||
|
typeEn: '1+1 Type A',
|
||||||
|
size: 47.1,
|
||||||
|
rooms: 2,
|
||||||
|
bathrooms: 1,
|
||||||
|
floor: '1-4. Kat',
|
||||||
|
descTr: 'Yatırım değeri yüksek ve bakımı kolay 1+1 Tip A daireler, hem bireyler hem de yatırımcılar için mükemmel bir seçenek. Akıllı tasarımı ile her metrekare verimli kullanılıyor.',
|
||||||
|
descEn: 'With high investment value and easy maintenance, 1+1 Type A apartments are a perfect choice for both individuals and investors. With its smart design, every square meter is used efficiently.',
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1493809842364-78817add7ffb?w=800&q=80',
|
||||||
|
planUrl: null,
|
||||||
|
featured: false,
|
||||||
|
available: true,
|
||||||
|
order: 3,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
updatedAt: new Date('2024-01-01'),
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mock-unit-4',
|
||||||
|
slug: '1-1-tip-b',
|
||||||
|
typeTr: '1+1 Tip B',
|
||||||
|
typeEn: '1+1 Type B',
|
||||||
|
size: 60.8,
|
||||||
|
rooms: 2,
|
||||||
|
bathrooms: 1,
|
||||||
|
floor: '1-4. Kat',
|
||||||
|
descTr: 'Geniş 1+1 konseptiyle tasarlanan Tip B daireler, tek başına ya da çift olarak yaşayanlar için maksimum konfor sunuyor. Ferah yaşam alanı ve büyük pencereler ile doğal ışık her zaman içeride.',
|
||||||
|
descEn: 'Designed with a spacious 1+1 concept, Type B apartments offer maximum comfort for those living alone or as a couple. With a spacious living area and large windows, natural light is always inside.',
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1484154218962-a197022b5858?w=800&q=80',
|
||||||
|
planUrl: null,
|
||||||
|
featured: false,
|
||||||
|
available: true,
|
||||||
|
order: 4,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
updatedAt: new Date('2024-01-01'),
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ─── GALERİ ────────────────────────────────────────────────────────────────────
|
||||||
|
export const MOCK_GALLERY = [
|
||||||
|
{
|
||||||
|
id: 'mock-g-1',
|
||||||
|
titleTr: 'Proje Genel Görünüm',
|
||||||
|
titleEn: 'Project Overview',
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1580587771525-78b9dba3b914?w=800&q=80',
|
||||||
|
category: 'exterior',
|
||||||
|
order: 1,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mock-g-2',
|
||||||
|
titleTr: 'Orman İçi Yürüyüş Yolları',
|
||||||
|
titleEn: 'Forest Walking Paths',
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1448375240586-882707db888b?w=800&q=80',
|
||||||
|
category: 'amenities',
|
||||||
|
order: 2,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mock-g-3',
|
||||||
|
titleTr: 'Çocuk Oyun Alanı',
|
||||||
|
titleEn: 'Children\'s Playground',
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1575783970733-1aaedde1db74?w=800&q=80',
|
||||||
|
category: 'amenities',
|
||||||
|
order: 3,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mock-g-4',
|
||||||
|
titleTr: 'Spor Tesisleri',
|
||||||
|
titleEn: 'Sports Facilities',
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1534438327276-14e5300c3a48?w=800&q=80',
|
||||||
|
category: 'amenities',
|
||||||
|
order: 4,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mock-g-5',
|
||||||
|
titleTr: 'Muğla Doğası',
|
||||||
|
titleEn: 'Muğla Nature',
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&q=80',
|
||||||
|
category: 'location',
|
||||||
|
order: 5,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mock-g-6',
|
||||||
|
titleTr: 'Yaşam Alanları',
|
||||||
|
titleEn: 'Living Areas',
|
||||||
|
imageUrl: 'https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=800&q=80',
|
||||||
|
category: 'interior',
|
||||||
|
order: 6,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ─── MESAJLAR ──────────────────────────────────────────────────────────────────
|
||||||
|
export const MOCK_MESSAGES = [
|
||||||
|
{
|
||||||
|
id: 'mock-msg-1',
|
||||||
|
fullName: 'Ahmet Yıldız',
|
||||||
|
email: 'ahmet@example.com',
|
||||||
|
phone: '+90 532 111 2233',
|
||||||
|
message: '2+1 Tip A daire hakkında bilgi almak istiyorum. Fiyatları ve ödeme koşullarını öğrenebilir miyim?',
|
||||||
|
read: false,
|
||||||
|
createdAt: new Date('2025-06-01'),
|
||||||
|
updatedAt: new Date('2025-06-01'),
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mock-msg-2',
|
||||||
|
fullName: 'Selin Kaya',
|
||||||
|
email: 'selin@example.com',
|
||||||
|
phone: '+90 541 987 6543',
|
||||||
|
message: 'Projenizle ilgileniyorum. Yerinde ziyaret için randevu alabilir miyim?',
|
||||||
|
read: true,
|
||||||
|
createdAt: new Date('2025-05-28'),
|
||||||
|
updatedAt: new Date('2025-05-28'),
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function slugify(text: string) {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ğ/g, 'g')
|
||||||
|
.replace(/ü/g, 'u')
|
||||||
|
.replace(/ş/g, 's')
|
||||||
|
.replace(/ı/g, 'i')
|
||||||
|
.replace(/ö/g, 'o')
|
||||||
|
.replace(/ç/g, 'c')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/[^\w-]+/g, '')
|
||||||
|
.replace(/--+/g, '-')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const ContactSchema = z.object({
|
||||||
|
fullName: z.string().min(2, 'Ad soyad en az 2 karakter olmalı'),
|
||||||
|
email: z.string().email('Geçerli bir email adresi girin'),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
message: z.string().min(10, 'Mesaj en az 10 karakter olmalı'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ContactInput = z.infer<typeof ContactSchema>
|
||||||
|
|
||||||
|
export const UnitSchema = z.object({
|
||||||
|
slug: z.string().min(1),
|
||||||
|
typeTr: z.string().min(1),
|
||||||
|
typeEn: z.string().min(1),
|
||||||
|
size: z.number().positive(),
|
||||||
|
rooms: z.number().int().positive(),
|
||||||
|
bathrooms: z.number().int().positive().default(1),
|
||||||
|
floor: z.string().optional(),
|
||||||
|
descTr: z.string().optional(),
|
||||||
|
descEn: z.string().optional(),
|
||||||
|
imageUrl: z.string().optional(),
|
||||||
|
planUrl: z.string().optional(),
|
||||||
|
featured: z.boolean().default(false),
|
||||||
|
available: z.boolean().default(true),
|
||||||
|
order: z.number().int().default(0),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type UnitInput = z.infer<typeof UnitSchema>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"home": "Home",
|
||||||
|
"about": "Project",
|
||||||
|
"location": "Location",
|
||||||
|
"units": "Apartments",
|
||||||
|
"gallery": "Gallery",
|
||||||
|
"contact": "Contact",
|
||||||
|
"virtualTour": "360° Virtual Tour",
|
||||||
|
"phone": "444 3 145"
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"tagline": "Nature at Home, Peace in Nature",
|
||||||
|
"subtitle": "Luxury living in the heart of Muğla's forests",
|
||||||
|
"cta": "Explore the Project",
|
||||||
|
"scroll": "Scroll Down"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"label": "About the Project",
|
||||||
|
"title": "A residence nestled in Muğla's natural beauty",
|
||||||
|
"desc": "Modern designs, social areas, and a peaceful life in harmony with nature. At the heart of the forest, close to all city conveniences.",
|
||||||
|
"stat1Label": "Total Units",
|
||||||
|
"stat1Value": "120",
|
||||||
|
"stat2Label": "Project Area",
|
||||||
|
"stat2Value": "15,000 m²",
|
||||||
|
"stat3Label": "Green Space",
|
||||||
|
"stat3Value": "60%"
|
||||||
|
},
|
||||||
|
"amenities": {
|
||||||
|
"label": "Living Areas",
|
||||||
|
"title": "Every need considered",
|
||||||
|
"livingAreas": "Living Areas",
|
||||||
|
"livingDesc": "Fitness and spa centers, café spaces — social venues where you can experience the tranquility of nature.",
|
||||||
|
"playground": "Children's Playgrounds",
|
||||||
|
"playgroundDesc": "Safe, nature-integrated play areas. Secure environments where your children can release their energy with natural materials.",
|
||||||
|
"sports": "Sports Facilities",
|
||||||
|
"sportsDesc": "Walking and running tracks, tennis courts, fitness center. An active life in the heart of nature.",
|
||||||
|
"forest": "In the Forest",
|
||||||
|
"forestDesc": "Waking up to birdsong, starting the day with fresh air. You can feel every shade of green in your home."
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"label": "Location",
|
||||||
|
"title": "Close to everything, in the heart of nature",
|
||||||
|
"beach": "Sea & Beach",
|
||||||
|
"beachItems": "Akyaka Beach 35km · Gökova 35km · Marmaris 56km",
|
||||||
|
"education": "Education",
|
||||||
|
"educationItems": "Muğla Sıtkı Koçman Univ. 15km",
|
||||||
|
"city": "City Center",
|
||||||
|
"cityItems": "Muğla Center 10km",
|
||||||
|
"health": "Healthcare",
|
||||||
|
"healthItems": "Menteşe State Hospital 12km · Yücelen 15km",
|
||||||
|
"airport": "Airport",
|
||||||
|
"airportItems": "Milas-Bodrum 65km · Dalaman 100km",
|
||||||
|
"tourism": "Tourism",
|
||||||
|
"tourismItems": "Didim, Yalıkavak, Turgutreis, Gümüşlük"
|
||||||
|
},
|
||||||
|
"units": {
|
||||||
|
"label": "Apartments",
|
||||||
|
"title": "Choose your ideal living space",
|
||||||
|
"sqm": "m²",
|
||||||
|
"rooms": "rooms",
|
||||||
|
"bath": "bath",
|
||||||
|
"details": "Details",
|
||||||
|
"available": "Available",
|
||||||
|
"notAvailable": "Sold"
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"label": "Contact",
|
||||||
|
"title": "Your dream home is just one step away",
|
||||||
|
"subtitle": "Contact us for information about the project or to schedule a visit.",
|
||||||
|
"phone": "444 3 145",
|
||||||
|
"email": "vestamugla@gmail.com",
|
||||||
|
"instagram": "@vestamugla",
|
||||||
|
"form": {
|
||||||
|
"name": "Full Name",
|
||||||
|
"email": "Email",
|
||||||
|
"phone": "Phone (optional)",
|
||||||
|
"message": "Your Message",
|
||||||
|
"send": "Send",
|
||||||
|
"sending": "Sending...",
|
||||||
|
"success": "Your message has been sent. We will get back to you as soon as possible.",
|
||||||
|
"error": "An error occurred. Please try again."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"rights": "© 2025 Vesta Muğla · Emtisi Construction. All rights reserved.",
|
||||||
|
"designed": "All rights reserved."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"home": "Ana Sayfa",
|
||||||
|
"about": "Proje",
|
||||||
|
"location": "Lokasyon",
|
||||||
|
"units": "Daireler",
|
||||||
|
"gallery": "Galeri",
|
||||||
|
"contact": "İletişim",
|
||||||
|
"virtualTour": "360° Sanal Tur",
|
||||||
|
"phone": "444 3 145"
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"tagline": "Evinizde Doğa, Doğada Huzur",
|
||||||
|
"subtitle": "Muğla'nın kalbi, ormanın içinde lüks yaşam",
|
||||||
|
"cta": "Projeyi Keşfet",
|
||||||
|
"scroll": "Aşağı Kaydır"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"label": "Proje Hakkında",
|
||||||
|
"title": "Muğla'nın doğal güzellikleri içinde yer alan bu rezidans projesi",
|
||||||
|
"desc": "Modern tasarımlar, sosyal alanlar ve doğayla iç içe huzurlu bir yaşam sunuyor. Ormanın kalbinde, şehrin tüm imkânlarına yakın.",
|
||||||
|
"stat1Label": "Toplam Daire",
|
||||||
|
"stat1Value": "120",
|
||||||
|
"stat2Label": "Proje Alanı",
|
||||||
|
"stat2Value": "15.000 m²",
|
||||||
|
"stat3Label": "Yeşil Alan",
|
||||||
|
"stat3Value": "%60"
|
||||||
|
},
|
||||||
|
"amenities": {
|
||||||
|
"label": "Yaşam Alanları",
|
||||||
|
"title": "Her ihtiyacınız düşünüldü",
|
||||||
|
"livingAreas": "Yaşam Alanları",
|
||||||
|
"livingDesc": "Fitness ve spa merkezleri, kafe alanları, doğanın huzurunu yaşayabileceğiniz sosyal mekânlar.",
|
||||||
|
"playground": "Çocuk Oyun Alanları",
|
||||||
|
"playgroundDesc": "Güvenli, doğayla iç içe oyun alanları. Çocuklarınızın enerjilerini doğal materyallerle atabileceği güvenli ortamlar.",
|
||||||
|
"sports": "Spor Alanları",
|
||||||
|
"sportsDesc": "Yürüyüş ve koşu parkurları, tenis kortları, fitness merkezi. Doğanın içinde aktif bir yaşam.",
|
||||||
|
"forest": "Ormanın İçinde",
|
||||||
|
"forestDesc": "Kuş sesleriyle uyanmak, temiz hava ile güne başlamak. Yeşilin her tonunu evinizde hissedebilirsiniz."
|
||||||
|
},
|
||||||
|
"location": {
|
||||||
|
"label": "Lokasyon",
|
||||||
|
"title": "Her şeye yakın, doğanın içinde",
|
||||||
|
"beach": "Deniz ve Sahil",
|
||||||
|
"beachItems": "Akyaka Plajı 35km · Gökova 35km · Marmaris 56km",
|
||||||
|
"education": "Eğitim",
|
||||||
|
"educationItems": "Muğla Sıtkı Koçman Üni. 15km",
|
||||||
|
"city": "Şehir Merkezi",
|
||||||
|
"cityItems": "Muğla Merkez 10km",
|
||||||
|
"health": "Sağlık",
|
||||||
|
"healthItems": "Menteşe Dev. Hastanesi 12km · Yücelen 15km",
|
||||||
|
"airport": "Havalimanı",
|
||||||
|
"airportItems": "Milas-Bodrum 65km · Dalaman 100km",
|
||||||
|
"tourism": "Turizm",
|
||||||
|
"tourismItems": "Didim, Yalıkavak, Turgutreis, Gümüşlük"
|
||||||
|
},
|
||||||
|
"units": {
|
||||||
|
"label": "Daireler",
|
||||||
|
"title": "Size en uygun yaşam alanını seçin",
|
||||||
|
"sqm": "m²",
|
||||||
|
"rooms": "oda",
|
||||||
|
"bath": "banyo",
|
||||||
|
"details": "Detaylar",
|
||||||
|
"available": "Müsait",
|
||||||
|
"notAvailable": "Satıldı"
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"label": "İletişim",
|
||||||
|
"title": "Hayalinizdeki yaşam bir adım uzağınızda",
|
||||||
|
"subtitle": "Proje hakkında bilgi almak veya ziyaret randevusu için bize ulaşın.",
|
||||||
|
"phone": "444 3 145",
|
||||||
|
"email": "vestamugla@gmail.com",
|
||||||
|
"instagram": "@vestamugla",
|
||||||
|
"form": {
|
||||||
|
"name": "Ad Soyad",
|
||||||
|
"email": "E-Posta",
|
||||||
|
"phone": "Telefon (opsiyonel)",
|
||||||
|
"message": "Mesajınız",
|
||||||
|
"send": "Gönder",
|
||||||
|
"sending": "Gönderiliyor...",
|
||||||
|
"success": "Mesajınız iletildi. En kısa sürede size ulaşacağız.",
|
||||||
|
"error": "Bir hata oluştu. Lütfen tekrar deneyin."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"rights": "© 2025 Vesta Muğla · Emtisi İnşaat. Tüm hakları saklıdır.",
|
||||||
|
"designed": "Tüm hakları saklıdır."
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { NextConfig } from 'next'
|
||||||
|
import createNextIntlPlugin from 'next-intl/plugin'
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin('./i18n/request.ts')
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{ protocol: 'https', hostname: 'res.cloudinary.com' },
|
||||||
|
{ protocol: 'https', hostname: 'images.unsplash.com' },
|
||||||
|
{ protocol: 'https', hostname: 'cdn.prod.website-files.com' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withNextIntl(nextConfig)
|
||||||
Generated
+8053
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"name": "vesta-mugla",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:studio": "prisma studio"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "15.1.3",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"next-intl": "^3.26.3",
|
||||||
|
"next-auth": "^5.0.0-beta.25",
|
||||||
|
"@auth/prisma-adapter": "^2.7.2",
|
||||||
|
"@prisma/client": "^6.1.0",
|
||||||
|
"cloudinary": "^2.5.1",
|
||||||
|
"gsap": "^3.12.5",
|
||||||
|
"framer-motion": "^11.15.0",
|
||||||
|
"zod": "^3.24.1",
|
||||||
|
"react-hook-form": "^7.54.2",
|
||||||
|
"@hookform/resolvers": "^3.10.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"lucide-react": "^0.469.0",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||||
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
|
"@radix-ui/react-select": "^2.1.4",
|
||||||
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
|
"@radix-ui/react-toast": "^1.2.4",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
|
"@radix-ui/react-separator": "^1.1.1",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.5.1",
|
||||||
|
"prisma": "^6.1.0",
|
||||||
|
"eslint": "^9.0.0",
|
||||||
|
"eslint-config-next": "15.1.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
export default config
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
ADMIN
|
||||||
|
USER
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String?
|
||||||
|
email String @unique
|
||||||
|
emailVerified DateTime?
|
||||||
|
image String?
|
||||||
|
password String?
|
||||||
|
role Role @default(USER)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
accounts Account[]
|
||||||
|
sessions Session[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Account {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
type String
|
||||||
|
provider String
|
||||||
|
providerAccountId String
|
||||||
|
refresh_token String? @db.Text
|
||||||
|
access_token String? @db.Text
|
||||||
|
expires_at Int?
|
||||||
|
token_type String?
|
||||||
|
scope String?
|
||||||
|
id_token String? @db.Text
|
||||||
|
session_state String?
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([provider, providerAccountId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sessionToken String @unique
|
||||||
|
userId String
|
||||||
|
expires DateTime
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model VerificationToken {
|
||||||
|
identifier String
|
||||||
|
token String @unique
|
||||||
|
expires DateTime
|
||||||
|
|
||||||
|
@@unique([identifier, token])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daire tipleri
|
||||||
|
model Unit {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
typeTr String // "2+1 Tip A"
|
||||||
|
typeEn String // "2+1 Type A"
|
||||||
|
size Float // m²
|
||||||
|
rooms Int // oda sayısı
|
||||||
|
bathrooms Int @default(1)
|
||||||
|
floor String? // "1-3. Kat"
|
||||||
|
descTr String? @db.Text
|
||||||
|
descEn String? @db.Text
|
||||||
|
imageUrl String?
|
||||||
|
planUrl String? // kat planı görseli
|
||||||
|
featured Boolean @default(false)
|
||||||
|
available Boolean @default(true)
|
||||||
|
order Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
}
|
||||||
|
|
||||||
|
// Galeri
|
||||||
|
model Gallery {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
titleTr String
|
||||||
|
titleEn String
|
||||||
|
imageUrl String
|
||||||
|
category String @default("general") // "exterior" | "interior" | "amenities" | "location"
|
||||||
|
order Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
}
|
||||||
|
|
||||||
|
// İletişim mesajları
|
||||||
|
model ContactMessage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
fullName String
|
||||||
|
email String
|
||||||
|
phone String?
|
||||||
|
message String @db.Text
|
||||||
|
read Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
deletedAt DateTime?
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import createMiddleware from 'next-intl/middleware'
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { routing } from '@/i18n/routing'
|
||||||
|
|
||||||
|
const intlMiddleware = createMiddleware(routing)
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl
|
||||||
|
|
||||||
|
// Admin route'ları — token kontrolü
|
||||||
|
if (pathname.includes('/admin') && !pathname.includes('/admin/login')) {
|
||||||
|
const token =
|
||||||
|
request.cookies.get('next-auth.session-token')?.value ||
|
||||||
|
request.cookies.get('__Secure-next-auth.session-token')?.value
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
const locale = pathname.split('/')[1] || 'tr'
|
||||||
|
return NextResponse.redirect(new URL(`/${locale}/admin/login`, request.url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return intlMiddleware(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)', '/'],
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { Config } from 'tailwindcss'
|
||||||
|
import animate from 'tailwindcss-animate'
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
darkMode: ['class'],
|
||||||
|
content: [
|
||||||
|
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// Vesta Muğla brand colors
|
||||||
|
vesta: {
|
||||||
|
cream: '#F5F0E8',
|
||||||
|
sand: '#D4C5A9',
|
||||||
|
earth: '#8B7355',
|
||||||
|
forest: '#2D4A2D',
|
||||||
|
dark: '#1A1A1A',
|
||||||
|
mist: '#E8EDF0',
|
||||||
|
},
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))',
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
|
foreground: 'hsl(var(--popover-foreground))',
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))',
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))',
|
||||||
|
},
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['var(--font-sans)', 'system-ui', 'sans-serif'],
|
||||||
|
serif: ['var(--font-serif)', 'Georgia', 'serif'],
|
||||||
|
display: ['var(--font-display)', 'system-ui', 'sans-serif'],
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)',
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||||
|
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||||
|
'fade-up': 'fade-up 0.8s ease-out forwards',
|
||||||
|
'fade-in': 'fade-in 1s ease-out forwards',
|
||||||
|
'cloud-left': 'cloud-left 1s ease-in-out forwards',
|
||||||
|
'cloud-right': 'cloud-right 1s ease-in-out forwards',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
'accordion-down': {
|
||||||
|
from: { height: '0' },
|
||||||
|
to: { height: 'var(--radix-accordion-content-height)' },
|
||||||
|
},
|
||||||
|
'accordion-up': {
|
||||||
|
from: { height: 'var(--radix-accordion-content-height)' },
|
||||||
|
to: { height: '0' },
|
||||||
|
},
|
||||||
|
'fade-up': {
|
||||||
|
from: { opacity: '0', transform: 'translateY(30px)' },
|
||||||
|
to: { opacity: '1', transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
'fade-in': {
|
||||||
|
from: { opacity: '0' },
|
||||||
|
to: { opacity: '1' },
|
||||||
|
},
|
||||||
|
'cloud-left': {
|
||||||
|
from: { transform: 'translateX(0)', opacity: '1' },
|
||||||
|
to: { transform: 'translateX(-120%)', opacity: '0' },
|
||||||
|
},
|
||||||
|
'cloud-right': {
|
||||||
|
from: { transform: 'translateX(0)', opacity: '1' },
|
||||||
|
to: { transform: 'translateX(120%)', opacity: '0' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [animate],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"target": "ES2017"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user