first commit

This commit is contained in:
AyrisAI
2026-07-12 20:18:55 +03:00
commit cbc59222c2
65 changed files with 16871 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
.next
node_modules
.env
.env.*.local
.git
.vscode
docs
README.md
AGENTS.md
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+42
View File
@@ -0,0 +1,42 @@
# AGENTS.md
## Stack
- Framework: Next.js 16, App Router, TypeScript strict
- Styling: Tailwind CSS v4
- UI: shadcn/ui (new-york style, OKLCH)
- Animation: Framer Motion
- Icons: Lucide React
- i18n: next-intl
- ORM: Prisma + PostgreSQL
- Auth: NextAuth.js v5
- Media: Cloudinary
- Deploy: Coolify (Docker, standalone output)
## Sabit Tercihler
- Mock data: USE_MOCK=true (demo aşaması)
- proxy.ts kullan — middleware.ts deprecated (Next.js 15.3+)
- İletişim formu sadece /iletisim sayfasında — ana sayfada olmaz
- Footer'da "Created by ayris.tech" linki zorunlu
- Dockerfile'da dummy DATABASE_URL (prisma generate için)
## Altyapı
- Gitea: https://git.ayris.tech (kullanıcı: ayrisdev)
- Coolify: https://client2.ayris.tech
- Cloudflare zone: ayris.tech
- Server IP: 188.245.175.169
## docs/ Klasörü
- docs/prd.md → ana içerik kaynağı
- docs/*.html → varsa mevcut site içeriği
- docs/*.md → ek belgeler
## Aktif Skill'ler
- nextjs-seo → sitemap, metadata, robots.txt
- next-best-practices → kod kalitesi
- nextjs-app-router-patterns → Server Actions, Suspense
- demo-site → komple site üretimi
- design-demo → görsel kalite
- coolify-deploy → deploy pipeline
## Proje Özel Notlar
<!-- Buraya proje bazlı notlar ekle -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+34
View File
@@ -0,0 +1,34 @@
FROM node:22-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
# Prisma generate için dummy URL — build sırasında gerçek DB gerekmez
ARG DATABASE_URL=postgresql://dummy:dummy@localhost:5432/dummy
ENV DATABASE_URL=$DATABASE_URL
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 && 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"]
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+272
View File
@@ -0,0 +1,272 @@
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { mockDb } from '@/lib/mockDb'
import Navbar from '@/components/Navbar'
import Footer from '@/components/Footer'
import ListingCard from '@/components/ListingCard'
import { notFound } from 'next/navigation'
import Image from 'next/image'
import { Phone, Globe, MapPin, Clock, Star, MessageSquare } from 'lucide-react'
interface DetailPageProps {
params: Promise<{ locale: string; category: string; slug: string }>
}
export default async function ListingDetailPage({ params }: DetailPageProps) {
const { locale, category, slug } = await params
setRequestLocale(locale)
const t = await getTranslations('detail')
const listing = await mockDb.getListingBySlug(slug)
if (!listing) {
notFound()
}
// Fetch similar listings in same category (limit to 3, excluding current)
const allCategoryListings = await mockDb.getListings({
categoryId: listing.categoryId
})
const relatedListings = allCategoryListings
.filter(l => l.id !== listing.id)
.slice(0, 3)
// Localized values
const name =
locale === 'ru'
? listing.nameRu
: locale === 'en'
? listing.nameEn
: listing.nameTr
const description =
locale === 'ru'
? listing.descriptionRu
: locale === 'en'
? listing.descriptionEn
: listing.descriptionTr
const priceSymbols = '₺'.repeat(listing.priceRange)
// Format WhatsApp Link
const getWhatsAppLink = (number: string) => {
// Clean spaces, parenthesis, plus sign
const cleanNum = number.replace(/\D/g, '')
return `https://wa.me/${cleanNum}`
}
return (
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
<Navbar />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 flex-1">
{/* Breadcrumb / Category Link */}
<div className="mb-6 text-xs font-mono uppercase tracking-wider text-shutter flex items-center gap-2">
<span className="hover:text-turquoise transition-colors">marmaris local</span>
<span>/</span>
<span className="hover:text-turquoise transition-colors">
{locale === 'ru' ? listing.category?.nameRu : locale === 'en' ? listing.category?.nameEn : listing.category?.nameTr}
</span>
<span>/</span>
<span className="text-pine font-bold">{name}</span>
</div>
{/* Hero Details Block */}
<div className="bg-paper rounded-3xl border border-pine/8 p-6 sm:p-10 shadow-sm mb-10 space-y-8">
<div className="flex flex-col lg:flex-row gap-10">
{/* Left: Gallery Panel */}
<div className="flex-1 space-y-4">
<div className="aspect-[16/10] w-full relative rounded-2xl overflow-hidden bg-stone-deep shadow-sm">
<Image
src={listing.images && listing.images.length > 0 ? listing.images[0].url : 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&auto=format&fit=crop&q=80'}
alt={name}
fill
priority
className="object-cover"
/>
</div>
{/* Thumbnails if multiple images exist */}
{listing.images && listing.images.length > 1 && (
<div className="grid grid-cols-4 gap-4">
{listing.images.slice(1, 5).map((img, idx) => (
<div key={img.id} className="aspect-square relative rounded-xl overflow-hidden bg-stone-deep border border-pine/5 shadow-sm">
<Image
src={img.url}
alt={`${name} thumbnail ${idx + 1}`}
fill
className="object-cover hover:scale-105 transition duration-300"
/>
</div>
))}
</div>
)}
</div>
{/* Right: Info Panel */}
<div className="flex-1 flex flex-col justify-between space-y-6">
{/* Badge & Title */}
<div className="space-y-4">
<div className="flex flex-wrap gap-2.5 items-center">
<span className="text-[10px] font-mono text-bougainvillea font-bold uppercase tracking-wider bg-bougainvillea/5 border border-bougainvillea/10 px-2.5 py-1 rounded-full">
{locale === 'ru' ? listing.category?.nameRu : locale === 'en' ? listing.category?.nameEn : listing.category?.nameTr}
</span>
{listing.isLocalApproved && (
<span className="inline-flex items-center gap-1 text-[10px] font-mono text-turquoise font-bold uppercase tracking-wider bg-turquoise/5 border border-turquoise/10 px-2.5 py-1 rounded-full">
{t('approved')}
</span>
)}
</div>
<h1 className="font-heading font-extrabold text-2xl sm:text-4xl text-pine leading-tight lowercase">
{name}
</h1>
{/* Stars and Price level */}
<div className="flex items-center gap-6 font-mono text-sm border-b border-dashed border-pine/8 pb-4">
{listing.rating && (
<div className="flex items-center gap-1.5 text-gold font-bold">
<Star className="w-4 h-4 fill-gold stroke-gold" />
<span>{t('rating')}: {listing.rating.toFixed(1)}</span>
</div>
)}
<div className="text-pine font-semibold">
<span>{t('price')}: {priceSymbols}</span>
</div>
</div>
</div>
{/* Description */}
<div className="text-ink/80 text-sm leading-relaxed font-medium">
{description}
</div>
{/* Contact Actions */}
<div className="space-y-4">
<h3 className="font-heading font-bold text-xs uppercase tracking-wider text-shutter">{t('contact')}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{listing.phone && (
<a
href={`tel:${listing.phone}`}
className="flex items-center justify-center gap-2 bg-pine hover:bg-pine/90 text-stone font-bold text-xs py-3.5 px-4 rounded-xl transition"
>
<Phone className="w-4 h-4" />
{t('call')}
</a>
)}
{listing.whatsapp && (
<a
href={getWhatsAppLink(listing.whatsapp)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 bg-turquoise hover:bg-turquoise/90 text-paper font-bold text-xs py-3.5 px-4 rounded-xl transition"
>
<MessageSquare className="w-4 h-4" />
{t('whatsapp')}
</a>
)}
</div>
{/* External links */}
<div className="flex gap-4 pt-2 text-xs font-semibold text-shutter">
{listing.website && (
<a href={listing.website} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 hover:text-turquoise transition">
<Globe className="w-4 h-4" />
{t('website')}
</a>
)}
{listing.instagram && (
<a href={listing.instagram} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 hover:text-turquoise transition">
<Globe className="w-4 h-4" />
{t('instagram')}
</a>
)}
</div>
</div>
</div>
</div>
{/* Details footer (Hours & Location map) */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 pt-8 border-t border-dashed border-pine/12">
{/* Address */}
<div className="space-y-2">
<div className="flex items-center gap-2 font-heading font-bold text-xs text-pine uppercase tracking-wider">
<MapPin className="w-4 h-4 text-turquoise" />
<span>{t('address')}</span>
</div>
<p className="text-xs text-ink/75 font-medium leading-relaxed">
{listing.address}
</p>
</div>
{/* Hours */}
<div className="space-y-2">
<div className="flex items-center gap-2 font-heading font-bold text-xs text-pine uppercase tracking-wider">
<Clock className="w-4 h-4 text-turquoise" />
<span>{t('hours')}</span>
</div>
<div className="text-xs text-ink/75 font-medium">
{listing.openingHours ? (
<p>{(listing.openingHours as any).all || t('noHours')}</p>
) : (
<p>{t('noHours')}</p>
)}
</div>
</div>
{/* Map Frame */}
{listing.latitude && listing.longitude && (
<div className="space-y-2">
<div className="flex items-center gap-2 font-heading font-bold text-xs text-pine uppercase tracking-wider">
<Globe className="w-4 h-4 text-turquoise" />
<span>{t('location')}</span>
</div>
<div className="rounded-xl overflow-hidden border border-pine/8 aspect-[16/10] sm:aspect-auto sm:h-36">
<iframe
width="100%"
height="100%"
frameBorder="0"
scrolling="no"
marginHeight={0}
marginWidth={0}
src={`https://maps.google.com/maps?q=${listing.latitude},${listing.longitude}&t=&z=15&ie=UTF8&iwloc=&output=embed`}
className="w-full h-full shadow-sm"
/>
</div>
</div>
)}
</div>
</div>
{/* Related Section */}
{relatedListings.length > 0 && (
<div className="space-y-6 pt-10">
<h3 className="text-xl font-heading font-extrabold text-pine lowercase">
{t('related')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{relatedListings.map((listingItem) => (
<ListingCard key={listingItem.id} listing={listingItem} />
))}
</div>
</div>
)}
</main>
<Footer />
</div>
)
}
+43
View File
@@ -0,0 +1,43 @@
import { mockDb } from '@/lib/mockDb'
export default async function AdminCategoriesPage() {
const categories = await mockDb.getCategories()
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">kategoriler</h2>
<p className="text-ink/65 text-xs font-medium mt-1">
Sistemde listelenen işletmelerin sınıflandırıldığı ana kategoriler.
</p>
</div>
</div>
<div className="bg-paper border border-pine/8 rounded-2xl shadow-sm overflow-hidden max-w-4xl">
<table className="min-w-full divide-y divide-pine/8 text-sm">
<thead className="bg-stone-deep/40 text-shutter font-mono text-[10px] uppercase tracking-wider">
<tr>
<th className="px-6 py-4 text-left">ID</th>
<th className="px-6 py-4 text-left">Slug</th>
<th className="px-6 py-4 text-left">Adı (TR)</th>
<th className="px-6 py-4 text-left">Name (EN)</th>
<th className="px-6 py-4 text-left">Имя (RU)</th>
</tr>
</thead>
<tbody className="divide-y divide-dashed divide-pine/8 text-ink/85 font-medium">
{categories.map((cat) => (
<tr key={cat.id} className="hover:bg-stone/20 transition duration-150">
<td className="px-6 py-4 font-mono text-xs text-shutter">{cat.id}</td>
<td className="px-6 py-4 font-mono text-xs font-bold text-turquoise">{cat.slug}</td>
<td className="px-6 py-4 font-heading font-bold text-pine lowercase text-sm">{cat.nameTr}</td>
<td className="px-6 py-4 text-xs">{cat.nameEn}</td>
<td className="px-6 py-4 text-xs">{cat.nameRu}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
+123
View File
@@ -0,0 +1,123 @@
'use client'
import { signOut } from 'next-auth/react'
import { Link, usePathname } from '@/i18n/routing'
import { LayoutDashboard, FileText, Inbox, ClipboardList, Map, LogOut, Menu, X } from 'lucide-react'
import { useState } from 'react'
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const [sidebarOpen, setSidebarOpen] = useState(false)
const navigation = [
{ name: 'Dashboard', href: '/admin', icon: LayoutDashboard },
{ name: 'Mekanlar', href: '/admin/listings', icon: ClipboardList },
{ name: 'Başvurular', href: '/admin/submissions', icon: FileText },
{ name: 'Mesajlar', href: '/admin/messages', icon: Inbox },
{ name: 'Kategoriler', href: '/admin/categories', icon: LayoutDashboard },
{ name: 'Mahalleler', href: '/admin/neighborhoods', icon: Map },
]
return (
<div className="min-h-screen bg-stone text-ink flex font-sans">
{/* Mobile sidebar backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-pine/80 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<div className={`
fixed inset-y-0 left-0 z-50 w-64 bg-pine border-r border-white/10
transform transition-transform duration-200 ease-in-out lg:translate-x-0 lg:static lg:inset-0
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
`}>
<div className="h-full flex flex-col justify-between">
<div>
{/* Sidebar Brand Header */}
<div className="h-20 flex items-center px-6 border-b border-white/10 gap-3">
<Link href="/" className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-full border border-stone/30 flex items-center justify-center relative bg-paper shrink-0">
<div className="absolute inset-[2px] rounded-full border border-dashed border-turquoise/50" />
<span className="font-heading font-extrabold text-pine text-xs tracking-tighter">ML</span>
</div>
<div>
<h1 className="font-heading font-extrabold text-sm text-stone tracking-tight leading-none lowercase">
marmaris <span className="text-turquoise">local</span>
</h1>
<span className="text-[8px] font-mono text-shutter tracking-wider uppercase">backoffice</span>
</div>
</Link>
<button
className="ml-auto lg:hidden text-stone/70 hover:text-stone"
onClick={() => setSidebarOpen(false)}
>
<X className="h-5 w-5" />
</button>
</div>
{/* Navigation links */}
<nav className="px-3 py-6 space-y-1.5 overflow-y-auto">
{navigation.map((item) => {
const isActive = pathname === item.href || (item.href !== '/admin' && pathname.startsWith(item.href))
return (
<Link
key={item.name}
href={item.href}
onClick={() => setSidebarOpen(false)}
className={`
flex items-center px-4 py-3 text-xs font-semibold rounded-xl transition-all duration-150
${isActive
? 'bg-white/5 border-l-4 border-turquoise text-stone pl-3'
: 'text-stone/75 hover:bg-white/5 hover:text-stone'}
`}
>
<item.icon className={`mr-3 flex-shrink-0 h-4.5 w-4.5 ${isActive ? 'text-turquoise' : 'text-stone/50'}`} />
{item.name}
</Link>
)
})}
</nav>
</div>
{/* Logout Action */}
<div className="p-4 border-t border-white/10">
<button
onClick={() => signOut({ callbackUrl: '/' })}
className="flex w-full items-center px-4 py-3 text-xs font-semibold text-red-400 hover:text-red-300 rounded-xl hover:bg-red-950/20 transition-colors"
>
<LogOut className="mr-3 h-4.5 w-4.5 text-red-400/70" />
Çıkış Yap
</button>
</div>
</div>
</div>
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
{/* Mobile Header Bar */}
<header className="h-16 flex items-center lg:hidden bg-pine border-b border-white/10 px-4 shrink-0 text-stone">
<button
onClick={() => setSidebarOpen(true)}
className="text-stone hover:text-turquoise focus:outline-none"
>
<Menu className="h-6 w-6" />
</button>
<div className="ml-4 flex items-center gap-2">
<div className="w-8 h-8 rounded-full border border-stone/20 flex items-center justify-center relative bg-paper shrink-0">
<span className="font-heading font-extrabold text-pine text-[10px] tracking-tighter">ML</span>
</div>
<span className="text-sm font-heading font-bold lowercase">marmaris local <span className="text-turquoise">backoffice</span></span>
</div>
</header>
{/* Page Area */}
<main className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
{children}
</main>
</div>
</div>
)
}
@@ -0,0 +1,444 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { createOrUpdateListingAction } from '@/app/actions'
import { ArrowLeft, Save } from 'lucide-react'
import { Link } from '@/i18n/routing'
interface Option {
value: string
label: string
}
interface FormProps {
listing: any | null
categories: Option[]
neighborhoods: Option[]
}
export default function ListingForm({ listing, categories, neighborhoods }: FormProps) {
const router = useRouter()
const [activeTab, setActiveTab] = useState<'tr' | 'en' | 'ru'>('tr')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setLoading(true)
setError('')
setSuccess(false)
const formData = new FormData(e.currentTarget)
if (listing) {
formData.append('id', listing.id)
}
try {
const res = await createOrUpdateListingAction(formData)
if (res.error) {
setError(res.error)
} else {
setSuccess(true)
setTimeout(() => {
router.push('/admin/listings')
router.refresh()
}, 1500)
}
} catch (err) {
setError('İşlem sırasında bir hata oluştu.')
} finally {
setLoading(false)
}
}
const initialImages = listing?.images || []
const img1 = initialImages[0]?.url || ''
const img2 = initialImages[1]?.url || ''
return (
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-bougainvillea/10 border border-bougainvillea/25 text-bougainvillea p-4 rounded-xl text-xs font-semibold">
{error}
</div>
)}
{success && (
<div className="bg-turquoise/10 border border-turquoise/25 text-turquoise p-4 rounded-xl text-xs font-bold">
Mekan başarıyla kaydedildi! Yönlendiriliyorsunuz...
</div>
)}
{/* Language tabs */}
<div className="border-b border-pine/8">
<div className="flex gap-2">
{(['tr', 'en', 'ru'] as const).map((lang) => {
const label = lang === 'tr' ? '🇹🇷 Türkçe' : lang === 'en' ? '🇬🇧 English' : '🇷🇺 Русский'
const isTabActive = activeTab === lang
return (
<button
key={lang}
type="button"
onClick={() => setActiveTab(lang)}
className={`py-3 px-4 font-heading font-bold text-xs border-b-2 transition lowercase ${
isTabActive
? 'border-turquoise text-turquoise'
: 'border-transparent text-shutter hover:text-pine'
}`}
>
{label}
</button>
)
})}
</div>
</div>
{/* Multilingual input fields */}
<div className="space-y-4">
{/* TR Tab */}
{activeTab === 'tr' && (
<div className="space-y-4">
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Mekan Adı (TR) *</label>
<input
type="text"
name="nameTr"
required
defaultValue={listing?.nameTr || ''}
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink"
placeholder="Örn: İskele Balık Ocakbaşı"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Açıklama (TR) *</label>
<textarea
name="descriptionTr"
required
rows={4}
defaultValue={listing?.descriptionTr || ''}
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink"
placeholder="Türkçe tanıtım metni..."
/>
</div>
</div>
)}
{/* EN Tab */}
{activeTab === 'en' && (
<div className="space-y-4">
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Mekan Adı (EN) *</label>
<input
type="text"
name="nameEn"
required
defaultValue={listing?.nameEn || ''}
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink"
placeholder="Örn: Iskele Fish & Grill"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Açıklama (EN) *</label>
<textarea
name="descriptionEn"
required
rows={4}
defaultValue={listing?.descriptionEn || ''}
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink"
placeholder="English description..."
/>
</div>
</div>
)}
{/* RU Tab */}
{activeTab === 'ru' && (
<div className="space-y-4">
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Mekan Adı (RU) *</label>
<input
type="text"
name="nameRu"
required
defaultValue={listing?.nameRu || ''}
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink"
placeholder="Örn: Рыбный Гриль Искеле"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Açıklama (RU) *</label>
<textarea
name="descriptionRu"
required
rows={4}
defaultValue={listing?.descriptionRu || ''}
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink"
placeholder="Russian description..."
/>
</div>
</div>
)}
</div>
<hr className="border-dashed border-pine/8" />
{/* Global fields */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Slug */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">URL Slug *</label>
<input
type="text"
name="slug"
required
defaultValue={listing?.slug || ''}
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink font-mono"
placeholder="iskele-balik-ocakbasi"
/>
</div>
{/* Categories */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Kategori *</label>
<select
name="categoryId"
required
defaultValue={listing?.categoryId || ''}
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink appearance-none"
>
<option value="">Kategori seçin...</option>
{categories.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</div>
{/* Neighborhoods */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Mahalle/Bölge *</label>
<select
name="neighborhoodId"
required
defaultValue={listing?.neighborhoodId || ''}
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink appearance-none"
>
<option value="">Mahalle seçin...</option>
{neighborhoods.map((n) => (
<option key={n.value} value={n.value}>
{n.label}
</option>
))}
</select>
</div>
{/* Address */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Açık Adres *</label>
<input
type="text"
name="address"
required
defaultValue={listing?.address || ''}
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink"
placeholder="Yat Limanı No:12, Marmaris"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
{/* Phone */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Telefon</label>
<input
type="text"
name="phone"
defaultValue={listing?.phone || ''}
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink font-mono"
placeholder="+90 252..."
/>
</div>
{/* Whatsapp */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">WhatsApp</label>
<input
type="text"
name="whatsapp"
defaultValue={listing?.whatsapp || ''}
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink font-mono"
placeholder="+90 532..."
/>
</div>
{/* Website */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Web Sitesi</label>
<input
type="text"
name="website"
defaultValue={listing?.website || ''}
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink font-mono"
placeholder="https://..."
/>
</div>
{/* Instagram */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Instagram</label>
<input
type="text"
name="instagram"
defaultValue={listing?.instagram || ''}
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink font-mono"
placeholder="https://instagram.com/..."
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
{/* Price range */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Fiyat Aralığı (1-3) *</label>
<select
name="priceRange"
required
defaultValue={listing?.priceRange || 2}
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink"
>
<option value="1"> (Ekonomik)</option>
<option value="2"> (Orta)</option>
<option value="3"> (Lüks)</option>
</select>
</div>
{/* Rating */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Puan (0.0 - 5.0)</label>
<input
type="number"
name="rating"
step="0.1"
min="0"
max="5"
defaultValue={listing?.rating || ''}
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink font-mono"
placeholder="4.8"
/>
</div>
{/* Latitude */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Enlem (Lat)</label>
<input
type="number"
name="latitude"
step="0.0001"
defaultValue={listing?.latitude || ''}
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink font-mono"
placeholder="36.8524"
/>
</div>
{/* Longitude */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Boylam (Lng)</label>
<input
type="number"
name="longitude"
step="0.0001"
defaultValue={listing?.longitude || ''}
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink font-mono"
placeholder="28.2741"
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Opening hours */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Çalışma Saatleri</label>
<input
type="text"
name="openingHours"
defaultValue={listing?.openingHours?.all || ''}
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink"
placeholder="Örn: 12:00 - 00:00"
/>
</div>
{/* Approved toggle */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Yerel Onaylı Mührü</label>
<select
name="isLocalApproved"
defaultValue={listing?.isLocalApproved ? 'true' : 'false'}
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink"
>
<option value="false">Normal Mekan</option>
<option value="true">Yerel Onaylı (Mühürlü)</option>
</select>
</div>
</div>
{/* Image Upload fields */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 border-t border-dashed border-pine/8 pt-6">
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Görsel 1 (Dosya Yükle)</label>
<input
type="file"
name="imageFile1"
accept="image/*"
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink file:mr-3 file:py-1 file:px-2.5 file:rounded-lg file:border-0 file:text-xs file:font-semibold file:bg-pine file:text-stone hover:file:opacity-90 cursor-pointer"
/>
{img1 && (
<div className="mt-2 text-xs flex items-center gap-2">
<span className="text-shutter/65">Mevcut Görsel:</span>
<a href={img1} target="_blank" rel="noopener noreferrer" className="text-turquoise hover:underline font-mono truncate max-w-xs">{img1}</a>
<input type="hidden" name="imageUrl1" value={img1} />
</div>
)}
</div>
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Görsel 2 (Dosya Yükle)</label>
<input
type="file"
name="imageFile2"
accept="image/*"
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink file:mr-3 file:py-1 file:px-2.5 file:rounded-lg file:border-0 file:text-xs file:font-semibold file:bg-pine file:text-stone hover:file:opacity-90 cursor-pointer"
/>
{img2 && (
<div className="mt-2 text-xs flex items-center gap-2">
<span className="text-shutter/65">Mevcut Görsel:</span>
<a href={img2} target="_blank" rel="noopener noreferrer" className="text-turquoise hover:underline font-mono truncate max-w-xs">{img2}</a>
<input type="hidden" name="imageUrl2" value={img2} />
</div>
)}
</div>
</div>
<div className="flex gap-4 pt-4 border-t border-dashed border-pine/8">
<Link
href="/admin/listings"
className="inline-flex items-center gap-1.5 px-5 py-3 border border-pine/20 rounded-xl text-xs font-bold text-pine hover:bg-stone/30 transition duration-150"
>
<ArrowLeft className="w-4 h-4" />
İptal
</Link>
<button
type="submit"
disabled={loading}
className="inline-flex items-center gap-1.5 px-5 py-3 bg-turquoise hover:bg-turquoise/90 disabled:opacity-75 text-paper rounded-xl text-xs font-bold shadow-sm transition duration-150"
>
<Save className="w-4 h-4" />
{loading ? 'Kaydediliyor...' : 'Kaydet'}
</button>
</div>
</form>
)
}
+56
View File
@@ -0,0 +1,56 @@
import { mockDb } from '@/lib/mockDb'
import ListingForm from './ListingForm'
import { notFound } from 'next/navigation'
interface Props {
params: Promise<{ locale: string; id: string }>
}
export default async function AdminListingEditPage({ params }: Props) {
const { locale, id } = await params
const categories = await mockDb.getCategories()
const neighborhoods = await mockDb.getNeighborhoods()
let listing = null
if (id !== 'new') {
listing = await mockDb.getListingById(id)
if (!listing) {
notFound()
}
}
const categoryOptions = categories.map(c => ({
value: c.id,
label: c.nameTr
}))
const neighborhoodOptions = neighborhoods.map(n => ({
value: n.id,
label: n.nameTr
}))
return (
<div className="space-y-6 max-w-4xl">
<div>
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">
{id === 'new' ? 'yeni mekan ekle' : 'mekanı düzenle'}
</h2>
<p className="text-ink/65 text-xs font-medium mt-1">
{id === 'new'
? 'Rehbere eklenecek yeni işletme bilgilerini girin.'
: 'Mevcut mekan bilgilerini güncelleyin.'}
</p>
</div>
<div className="bg-paper border border-pine/8 rounded-3xl shadow-sm p-6 sm:p-8">
<ListingForm
listing={listing}
categories={categoryOptions}
neighborhoods={neighborhoodOptions}
/>
</div>
</div>
)
}
+115
View File
@@ -0,0 +1,115 @@
import { mockDb } from '@/lib/mockDb'
import { deleteListingAction } from '@/app/actions'
import { Link } from '@/i18n/routing'
import { Edit, Trash, Plus, CheckCircle } from 'lucide-react'
export default async function AdminListingsPage() {
const listings = await mockDb.getListings()
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">mekanlar</h2>
<p className="text-ink/65 text-xs font-medium mt-1">
Marmaris Local rehberinde kayıtlı olan restoran, apart otel ve yerel hizmetlerin listesi.
</p>
</div>
<Link
href="/admin/listings/new"
className="inline-flex items-center gap-1.5 bg-turquoise hover:bg-turquoise/90 text-paper text-xs font-bold py-3 px-5 rounded-xl shadow-sm transition active:scale-95 duration-150"
>
<Plus className="w-4 h-4" />
Yeni Mekan Ekle
</Link>
</div>
<div className="bg-paper border border-pine/8 rounded-2xl shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-pine/8 text-sm">
<thead className="bg-stone-deep/40 text-shutter font-mono text-[10px] uppercase tracking-wider">
<tr>
<th className="px-6 py-4 text-left">Görsel</th>
<th className="px-6 py-4 text-left">Mekan</th>
<th className="px-6 py-4 text-left">Kategori & Konum</th>
<th className="px-6 py-4 text-left">Fiyat & Puan</th>
<th className="px-6 py-4 text-left">Mühür</th>
<th className="px-6 py-4 text-right">İşlemler</th>
</tr>
</thead>
<tbody className="divide-y divide-dashed divide-pine/8 text-ink/80">
{listings.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-xs text-shutter font-medium">
Henüz kayıtlı mekan bulunmamaktadır.
</td>
</tr>
) : (
listings.map((l) => {
const mainImage = l.images && l.images.length > 0 ? l.images[0].url : 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&auto=format&fit=crop&q=80'
return (
<tr key={l.id} className="hover:bg-stone/20 transition duration-150">
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-12 w-16 relative rounded-lg overflow-hidden bg-stone border border-pine/8">
<img src={mainImage} alt={l.nameTr} className="object-cover w-full h-full" />
</div>
</td>
<td className="px-6 py-4">
<div className="font-heading font-bold text-pine lowercase text-sm">{l.nameTr}</div>
<div className="text-xs text-shutter font-mono mt-1">/{l.slug}</div>
</td>
<td className="px-6 py-4 text-xs font-semibold">
<div className="text-pine">Kategori: {l.category?.nameTr}</div>
<div className="text-shutter mt-0.5">Bölge: {l.neighborhood?.nameTr}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap font-mono text-xs">
<div className="text-pine font-semibold">Fiyat: {'₺'.repeat(l.priceRange)}</div>
{l.rating && <div className="text-gold font-bold mt-0.5"> {l.rating.toFixed(1)}</div>}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{l.isLocalApproved ? (
<span className="inline-flex items-center gap-1 rounded-full bg-paper px-2.5 py-0.5 text-[10px] font-mono font-bold text-turquoise border border-turquoise/20 uppercase tracking-wider">
<CheckCircle className="w-3.5 h-3.5 fill-turquoise/5" />
Yerel Onaylı
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-paper px-2.5 py-0.5 text-[10px] font-mono font-medium text-shutter/60 border border-pine/8 uppercase tracking-wider">
Normal
</span>
)}
</td>
<td className="px-6 py-4 text-right whitespace-nowrap">
<div className="flex justify-end gap-2">
<Link
href={`/admin/listings/${l.id}`}
className="p-1.5 bg-paper text-shutter hover:text-turquoise hover:bg-turquoise/5 rounded-lg border border-pine/10 hover:border-turquoise/25 transition shadow-sm"
title="Düzenle"
>
<Edit className="w-4 h-4" />
</Link>
<form action={async () => {
'use server'
await deleteListingAction(l.id)
}}>
<button
type="submit"
className="p-1.5 bg-paper text-shutter hover:text-bougainvillea hover:bg-bougainvillea/5 rounded-lg border border-pine/10 hover:border-bougainvillea/25 transition shadow-sm"
title="Sil"
>
<Trash className="w-4 h-4" />
</button>
</form>
</div>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
+88
View File
@@ -0,0 +1,88 @@
import { mockDb } from '@/lib/mockDb'
import { markMessageReadAction } from '@/app/actions'
import { Check, MailOpen, Mail } from 'lucide-react'
export default async function AdminMessagesPage() {
const messages = await mockDb.getMessages()
return (
<div className="space-y-6">
<div>
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">iletisim mesajları</h2>
<p className="text-ink/65 text-xs font-medium mt-1">
Kullanıcılar tarafından `/iletisim` formu üzerinden gönderilen genel mesajlar.
</p>
</div>
<div className="bg-paper border border-pine/8 rounded-2xl shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-pine/8 text-sm">
<thead className="bg-stone-deep/40 text-shutter font-mono text-[10px] uppercase tracking-wider">
<tr>
<th className="px-6 py-4 text-left">Tarih</th>
<th className="px-6 py-4 text-left">Gönderen</th>
<th className="px-6 py-4 text-left">Konu</th>
<th className="px-6 py-4 text-left">Mesaj</th>
<th className="px-6 py-4 text-right">İşlemler</th>
</tr>
</thead>
<tbody className="divide-y divide-dashed divide-pine/8 text-ink/80">
{messages.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center text-xs text-shutter font-medium">
Gelen iletişim mesajı bulunmamaktadır.
</td>
</tr>
) : (
messages.map((msg) => {
return (
<tr key={msg.id} className={`hover:bg-stone/20 transition duration-150 ${!msg.isRead ? 'bg-turquoise/5' : ''}`}>
<td className="px-6 py-4 whitespace-nowrap text-xs text-shutter font-mono">
{new Date(msg.createdAt).toLocaleString('tr-TR')}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-pine font-heading font-bold text-xs lowercase">{msg.name}</div>
<a href={`mailto:${msg.email}`} className="text-xs text-turquoise hover:underline">
{msg.email}
</a>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center rounded-md bg-paper px-2.5 py-0.5 text-[10px] font-mono font-bold text-shutter border border-pine/10 uppercase tracking-wider">
{msg.subject}
</span>
</td>
<td className="px-6 py-4 max-w-md">
<p className="text-xs break-words leading-relaxed whitespace-pre-line font-medium">{msg.message}</p>
</td>
<td className="px-6 py-4 text-right whitespace-nowrap">
{!msg.isRead ? (
<form action={async () => {
'use server'
await markMessageReadAction(msg.id)
}}>
<button
type="submit"
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-paper hover:bg-turquoise/5 text-turquoise rounded-lg border border-turquoise/20 text-xs font-bold transition shadow-sm"
>
<MailOpen className="w-3.5 h-3.5" />
Okundu İşaretle
</button>
</form>
) : (
<span className="text-shutter/60 inline-flex items-center gap-1 text-xs font-semibold">
<Check className="w-4 h-4 text-turquoise" />
Okundu
</span>
)}
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
+43
View File
@@ -0,0 +1,43 @@
import { mockDb } from '@/lib/mockDb'
export default async function AdminNeighborhoodsPage() {
const neighborhoods = await mockDb.getNeighborhoods()
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">mahalleler</h2>
<p className="text-ink/65 text-xs font-medium mt-1">
Marmaris Local rehberindeki mekanların filtrelendiği mahalle/bölgeler.
</p>
</div>
</div>
<div className="bg-paper border border-pine/8 rounded-2xl shadow-sm overflow-hidden max-w-4xl">
<table className="min-w-full divide-y divide-pine/8 text-sm">
<thead className="bg-stone-deep/40 text-shutter font-mono text-[10px] uppercase tracking-wider">
<tr>
<th className="px-6 py-4 text-left">ID</th>
<th className="px-6 py-4 text-left">Slug</th>
<th className="px-6 py-4 text-left">Adı (TR)</th>
<th className="px-6 py-4 text-left">Name (EN)</th>
<th className="px-6 py-4 text-left">Имя (RU)</th>
</tr>
</thead>
<tbody className="divide-y divide-dashed divide-pine/8 text-ink/85 font-medium">
{neighborhoods.map((neigh) => (
<tr key={neigh.id} className="hover:bg-stone/20 transition duration-150">
<td className="px-6 py-4 font-mono text-xs text-shutter">{neigh.id}</td>
<td className="px-6 py-4 font-mono text-xs font-bold text-turquoise">{neigh.slug}</td>
<td className="px-6 py-4 font-heading font-bold text-pine lowercase text-sm">{neigh.nameTr}</td>
<td className="px-6 py-4 text-xs">{neigh.nameEn}</td>
<td className="px-6 py-4 text-xs">{neigh.nameRu}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
+126
View File
@@ -0,0 +1,126 @@
import { auth } from '@/lib/auth'
import { mockDb } from '@/lib/mockDb'
import { Link } from '@/i18n/routing'
import { FileText, Inbox, ClipboardList, Map, Clock } from 'lucide-react'
export default async function AdminDashboardPage() {
const session = await auth()
// Load stats
const listings = await mockDb.getListings()
const submissions = await mockDb.getSubmissions()
const messages = await mockDb.getMessages()
const categories = await mockDb.getCategories()
const neighborhoods = await mockDb.getNeighborhoods()
const pendingSubmissions = submissions.filter(s => s.status === 'PENDING')
const unreadMessages = messages.filter(m => !m.isRead)
const stats = [
{ name: 'Toplam Mekan', stat: listings.length.toString(), icon: ClipboardList, color: 'text-pine bg-stone-deep border-pine/8' },
{ name: 'Bekleyen Başvuru', stat: pendingSubmissions.length.toString(), icon: FileText, color: 'text-gold bg-paper border-gold/15' },
{ name: 'Okunmamış Mesaj', stat: unreadMessages.length.toString(), icon: Inbox, color: 'text-turquoise bg-stone-deep border-turquoise/15' },
{ name: 'Kategori / Bölge', stat: `${categories.length} / ${neighborhoods.length}`, icon: Map, color: 'text-shutter bg-paper border-shutter/15' },
]
return (
<div className="space-y-8">
<div>
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">dashboard</h2>
<p className="text-ink/65 text-xs font-medium mt-1">
Hoş geldiniz, {session?.user?.name || session?.user?.email}. Marmaris Local için genel rehber durumu.
</p>
</div>
{/* Stats grid */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((item) => (
<div
key={item.name}
className="bg-paper rounded-2xl border border-pine/8 p-5 shadow-sm flex items-center gap-4 hover:border-turquoise/30 transition-all duration-300"
>
<div className={`p-3.5 rounded-xl border ${item.color}`}>
<item.icon className="h-5 w-5" />
</div>
<div>
<dt className="truncate text-[10px] font-mono uppercase tracking-wider text-shutter">{item.name}</dt>
<dd className="mt-1 text-2xl font-heading font-extrabold text-pine">
{item.stat}
</dd>
</div>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Recent Submissions */}
<div className="bg-paper border border-pine/8 rounded-2xl shadow-sm p-6 sm:p-8 space-y-6">
<div className="flex items-center justify-between border-b border-dashed border-pine/8 pb-4">
<h3 className="text-lg font-heading font-bold text-pine lowercase flex items-center gap-2">
<Clock className="w-5 h-5 text-gold" />
onay bekleyen başvurular
</h3>
<Link href="/admin/submissions" className="text-xs font-mono font-bold text-turquoise hover:underline uppercase tracking-wide">
tümünü gör
</Link>
</div>
<div className="divide-y divide-dashed divide-pine/8">
{pendingSubmissions.length === 0 ? (
<div className="py-8 text-center text-xs text-shutter font-medium">
Onay bekleyen yeni işletme başvurusu bulunmuyor.
</div>
) : (
pendingSubmissions.slice(0, 5).map((sub) => (
<div key={sub.id} className="py-4 flex items-center justify-between first:pt-0 last:pb-0">
<div className="space-y-1">
<h4 className="font-heading font-bold text-sm text-pine lowercase">{sub.businessName}</h4>
<p className="text-xs text-ink/65 font-medium">{sub.contactName} ({sub.contactEmail})</p>
</div>
<span className="inline-flex items-center rounded-full bg-paper px-3 py-1 text-[10px] font-mono font-bold text-gold border border-gold/20 uppercase">
Bekliyor
</span>
</div>
))
)}
</div>
</div>
{/* Recent Messages */}
<div className="bg-paper border border-pine/8 rounded-2xl shadow-sm p-6 sm:p-8 space-y-6">
<div className="flex items-center justify-between border-b border-dashed border-pine/8 pb-4">
<h3 className="text-lg font-heading font-bold text-pine lowercase flex items-center gap-2">
<Inbox className="w-5 h-5 text-turquoise" />
okunmamış mesajlar
</h3>
<Link href="/admin/messages" className="text-xs font-mono font-bold text-turquoise hover:underline uppercase tracking-wide">
tümünü gör
</Link>
</div>
<div className="divide-y divide-dashed divide-pine/8">
{unreadMessages.length === 0 ? (
<div className="py-8 text-center text-xs text-shutter font-medium">
Okunmamış yeni mesaj bulunmuyor.
</div>
) : (
unreadMessages.slice(0, 5).map((msg) => (
<div key={msg.id} className="py-4 flex items-center justify-between first:pt-0 last:pb-0">
<div className="space-y-1 pr-4 flex-1">
<h4 className="font-heading font-bold text-sm text-pine lowercase">{msg.name}</h4>
<p className="text-xs text-ink/70 font-medium line-clamp-1">{msg.message}</p>
</div>
<span className="text-[10px] text-shutter font-mono shrink-0">
{new Date(msg.createdAt).toLocaleDateString('tr-TR')}
</span>
</div>
))
)}
</div>
</div>
</div>
</div>
)
}
+118
View File
@@ -0,0 +1,118 @@
import { mockDb } from '@/lib/mockDb'
import { approveSubmissionAction, rejectSubmissionAction } from '@/app/actions'
import { Check, X } from 'lucide-react'
export default async function AdminSubmissionsPage() {
const submissions = await mockDb.getSubmissions()
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">işletme başvuruları</h2>
<p className="text-ink/65 text-xs font-medium mt-1">
Kullanıcılar tarafından `/isletme-ekle` formu üzerinden gönderilen ve onay bekleyen başvurular.
</p>
</div>
</div>
<div className="bg-paper border border-pine/8 rounded-2xl shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-pine/8 text-sm">
<thead className="bg-stone-deep/40 text-shutter font-mono text-[10px] uppercase tracking-wider">
<tr>
<th className="px-6 py-4 text-left">İşletme</th>
<th className="px-6 py-4 text-left">Konum & Kategori</th>
<th className="px-6 py-4 text-left">Gönderen</th>
<th className="px-6 py-4 text-left">Görsel</th>
<th className="px-6 py-4 text-left">Durum</th>
<th className="px-6 py-4 text-right">İşlemler</th>
</tr>
</thead>
<tbody className="divide-y divide-dashed divide-pine/8 text-ink/80">
{submissions.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-xs text-shutter font-medium">
Kayıtlı işletme başvurusu bulunmamaktadır.
</td>
</tr>
) : (
submissions.map((sub) => {
return (
<tr key={sub.id} className="hover:bg-stone/20 transition duration-150">
<td className="px-6 py-4">
<div className="font-heading font-bold text-pine lowercase text-sm">{sub.businessName}</div>
<div className="text-xs text-ink/70 mt-1 max-w-xs line-clamp-2 leading-relaxed">{sub.description}</div>
<div className="text-[11px] text-shutter mt-1.5">{sub.address}</div>
</td>
<td className="px-6 py-4 font-mono text-xs">
<div className="text-pine font-semibold">Kategori: {sub.categoryId}</div>
<div className="text-shutter mt-0.5">Bölge: {sub.neighborhoodId}</div>
</td>
<td className="px-6 py-4">
<div className="text-xs font-semibold text-pine">{sub.contactName}</div>
<a href={`mailto:${sub.contactEmail}`} className="text-xs text-turquoise hover:underline">
{sub.contactEmail}
</a>
{sub.phone && <div className="text-[11px] text-ink/65 font-mono mt-1">{sub.phone}</div>}
</td>
<td className="px-6 py-4">
{sub.imageUrl ? (
<div className="w-16 h-12 relative rounded-lg border border-pine/8 overflow-hidden">
<img src={sub.imageUrl} alt={sub.businessName} className="w-full h-full object-cover" />
</div>
) : (
<span className="text-[10px] font-mono text-shutter">Görsel Yok</span>
)}
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px] font-mono font-bold border uppercase tracking-wider ${
sub.status === 'PENDING' ? 'bg-paper text-gold border-gold/20' :
sub.status === 'APPROVED' ? 'bg-paper text-turquoise border-turquoise/20' :
'bg-paper text-bougainvillea border-bougainvillea/20'
}`}>
{sub.status === 'PENDING' ? 'Bekliyor' : sub.status === 'APPROVED' ? 'Onaylandı' : 'Reddedildi'}
</span>
</td>
<td className="px-6 py-4 text-right">
{sub.status === 'PENDING' && (
<div className="flex justify-end gap-2">
<form action={async () => {
'use server'
await approveSubmissionAction(sub.id)
}}>
<button
type="submit"
className="p-1.5 bg-paper text-turquoise hover:bg-turquoise/5 rounded-lg border border-turquoise/20 transition shadow-sm"
title="Onayla ve Mekanlara Ekle"
>
<Check className="w-4 h-4" />
</button>
</form>
<form action={async () => {
'use server'
await rejectSubmissionAction(sub.id)
}}>
<button
type="submit"
className="p-1.5 bg-paper text-bougainvillea hover:bg-bougainvillea/5 rounded-lg border border-bougainvillea/20 transition shadow-sm"
title="Reddet"
>
<X className="w-4 h-4" />
</button>
</form>
</div>
)}
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
+161
View File
@@ -0,0 +1,161 @@
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { mockDb } from '@/lib/mockDb'
import Navbar from '@/components/Navbar'
import Footer from '@/components/Footer'
import ListingCard from '@/components/ListingCard'
import { SlidersHorizontal } from 'lucide-react'
interface PageProps {
params: Promise<{ locale: string }>
searchParams: Promise<{
search?: string
neighborhood?: string
price?: string
approved?: string
}>
}
export default async function ApartsPage({ params, searchParams }: PageProps) {
const { locale } = await params
setRequestLocale(locale)
const { search, neighborhood, price, approved } = await searchParams
const t = await getTranslations('categories')
// Find Category Apart
const categories = await mockDb.getCategories()
const currentCategory = categories.find(c => c.slug === 'apart')
const categoryId = currentCategory?.id
// Get active neighborhoods for filter
const neighborhoods = await mockDb.getNeighborhoods()
// Selected filters
const selectedNeighborhoodId = neighborhood || undefined
const selectedPriceRange = price ? parseInt(price) : undefined
const isApprovedOnly = approved === 'true'
const listings = await mockDb.getListings({
categoryId,
neighborhoodId: selectedNeighborhoodId,
priceRange: selectedPriceRange,
isLocalApproved: isApprovedOnly ? true : undefined,
search: search
})
const getLocalizedName = (obj: any) => {
if (!obj) return ''
return locale === 'ru' ? obj.nameRu : locale === 'en' ? obj.nameEn : obj.nameTr
}
return (
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
<Navbar />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 flex-1">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-heading font-extrabold text-pine lowercase">
{t('apart')}
</h1>
<p className="text-xs text-shutter font-mono uppercase tracking-wider mt-1">
marmaris local {listings.length} {locale === 'tr' ? 'sonuç' : locale === 'en' ? 'results' : 'результатов'}
</p>
</div>
{/* Filters Panel */}
<div className="bg-paper p-5 rounded-2xl border border-pine/8 shadow-sm mb-10">
<div className="flex items-center gap-2 mb-4 font-heading font-bold text-sm text-pine lowercase border-b border-dashed border-pine/8 pb-3">
<SlidersHorizontal className="w-4 h-4 text-turquoise" />
<span>filtreler</span>
</div>
<form method="GET" className="grid grid-cols-1 sm:grid-cols-4 gap-4 items-end">
{/* Search Input */}
<div className="space-y-1.5">
<label className="block text-[11px] font-mono uppercase tracking-wider text-shutter">Arama</label>
<input
type="text"
name="search"
defaultValue={search || ''}
placeholder="İsim veya adres..."
className="w-full bg-stone border border-pine/10 rounded-xl px-3 py-2 text-xs focus:ring-1 focus:ring-turquoise outline-none"
/>
</div>
{/* Neighborhood select */}
<div className="space-y-1.5">
<label className="block text-[11px] font-mono uppercase tracking-wider text-shutter">{t('filterNeighborhood')}</label>
<select
name="neighborhood"
defaultValue={neighborhood || ''}
className="w-full bg-stone border border-pine/10 rounded-xl px-3 py-2 text-xs focus:ring-1 focus:ring-turquoise outline-none appearance-none"
>
<option value="">{t('allNeighborhoods')}</option>
{neighborhoods.map((n) => (
<option key={n.id} value={n.id}>
{getLocalizedName(n)}
</option>
))}
</select>
</div>
{/* Price range select */}
<div className="space-y-1.5">
<label className="block text-[11px] font-mono uppercase tracking-wider text-shutter">{t('filterPrice')}</label>
<select
name="price"
defaultValue={price || ''}
className="w-full bg-stone border border-pine/10 rounded-xl px-3 py-2 text-xs focus:ring-1 focus:ring-turquoise outline-none"
>
<option value="">{t('allPrices')}</option>
<option value="1"> (Ekonomik)</option>
<option value="2"> (Orta)</option>
<option value="3"> (Lüks)</option>
</select>
</div>
{/* Submit / Checkbox area */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer select-none text-xs font-semibold py-2.5">
<input
type="checkbox"
name="approved"
value="true"
defaultChecked={isApprovedOnly}
className="rounded border-pine/10 text-turquoise focus:ring-turquoise w-4 h-4"
/>
<span className="text-pine">{t('filterApproved')}</span>
</label>
<button
type="submit"
className="flex-1 bg-turquoise hover:bg-turquoise/90 text-paper text-xs font-bold py-2.5 px-4 rounded-xl transition text-center"
>
Filtrele
</button>
</div>
</form>
</div>
{/* Results */}
{listings.length === 0 ? (
<div className="bg-paper/50 rounded-2xl border border-dashed border-pine/12 p-12 text-center text-shutter">
<p className="text-sm font-medium">{t('noResults')}</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{listings.map((listing) => (
<ListingCard key={listing.id} listing={listing} />
))}
</div>
)}
</main>
<Footer />
</div>
)
}
+126
View File
@@ -0,0 +1,126 @@
import { getTranslations, setRequestLocale } from 'next-intl/server'
import Navbar from '@/components/Navbar'
import Footer from '@/components/Footer'
import { Link } from '@/i18n/routing'
import { CheckCircle, ShieldAlert, Award, Star } from 'lucide-react'
interface AboutPageProps {
params: Promise<{ locale: string }>
}
export default async function AboutPage({ params }: AboutPageProps) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations('hero')
const navT = await getTranslations('nav')
return (
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
<Navbar />
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-16 flex-1 space-y-12">
{/* Title */}
<div className="text-center space-y-4">
<div className="flex justify-center">
<div className="w-14 h-14 rounded-full border-2 border-turquoise flex items-center justify-center relative bg-paper -rotate-6 shadow-sm">
<div className="absolute inset-[3px] rounded-full border border-dashed border-turquoise/60" />
<span className="font-heading font-extrabold text-pine text-xs tracking-tighter">ML</span>
</div>
</div>
<h1 className="text-4xl font-heading font-extrabold text-pine lowercase leading-tight">
{locale === 'tr' ? 'hakkımızda' : locale === 'en' ? 'about us' : 'о нас'}
</h1>
<p className="text-xs font-mono uppercase tracking-wider text-shutter">
marmaris local yerel bilgi rehberi
</p>
</div>
{/* Content */}
<div className="bg-paper p-8 sm:p-12 rounded-3xl border border-pine/8 shadow-sm space-y-8 leading-relaxed font-medium text-sm sm:text-base text-ink/80">
<div className="space-y-4">
<h2 className="text-xl sm:text-2xl font-heading font-bold text-pine lowercase">
{locale === 'tr' ? 'turistin göremediği yerel bilgi' : locale === 'en' ? 'local knowledge hidden from tourists' : 'местные знания, скрытые от туристов'}
</h2>
<p>
{locale === 'tr' && 'Marmaris Local, popüler tatil beldemiz Marmaris\'teki restoranları, apart otelleri ve tekne kiralama ya da dalış merkezleri gibi çeşitli yerel işletmeleri tek bir küratörlü rehberde toplayan bağımsız bir dizin sitesidir. Temel amacımız, yerli ve yabancı turistleri Marmaris\'in gerçek yerel halkının gittiği, kalitesinden ve samimiyetinden emin olduğu işletmelerle buluşturmaktır.'}
{locale === 'en' && 'Marmaris Local is an independent directory site that gathers restaurants, apart hotels, and various local businesses such as boat rentals or diving centers in our popular holiday destination Marmaris into a single curated guide. Our main goal is to connect domestic and foreign tourists with establishments that the real local people of Marmaris visit, confident in their quality and friendliness.'}
{locale === 'ru' && 'Marmaris Local — это независимый каталог, объединяющий рестораны, апарт-отели и различные местные предприятия, такие как аренда лодок или дайвинг-центры в нашем популярном месте отдыха Мармарис, в единый курируемый гид. Наша главная цель — познакомить местных и иностранных туристов с заведениями, которые посещают настоящие местные жители Мармариса, будучи уверенными в их качестве и дружелюбии.'}
</p>
</div>
<hr className="border-dashed border-pine/10" />
{/* How local approved works */}
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full border-2 border-turquoise bg-stone/30 flex items-center justify-center shrink-0 text-turquoise">
<Award className="w-5 h-5" />
</div>
<h2 className="text-xl sm:text-2xl font-heading font-bold text-pine lowercase leading-tight">
{locale === 'tr' ? 'yerel onaylı mührü nedir?' : locale === 'en' ? 'what is the local approved seal?' : 'что такое печать качества?'}
</h2>
</div>
<p className="text-xs sm:text-sm text-ink/70">
{t('approvedExplain')}
</p>
{/* Steps / Criteria */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 pt-4 text-xs font-semibold text-pine font-mono">
<div className="bg-stone/35 p-5 rounded-2xl border border-pine/5 space-y-2">
<div className="text-turquoise text-lg font-bold">01</div>
<div className="uppercase tracking-wider">
{locale === 'tr' ? 'editör ziyareti' : locale === 'en' ? 'editor visit' : 'визит редактора'}
</div>
<p className="text-[11px] font-sans font-medium text-ink/65 normal-case leading-relaxed">
{locale === 'tr' ? 'Her mekan editörlerimiz tarafından gizlice ziyaret edilir.' : locale === 'en' ? 'Every place is visited secretly by our editors.' : 'Каждое место тайно посещается нашими редакторами.'}
</p>
</div>
<div className="bg-stone/35 p-5 rounded-2xl border border-pine/5 space-y-2">
<div className="text-turquoise text-lg font-bold">02</div>
<div className="uppercase tracking-wider">
{locale === 'tr' ? 'fiyat/performans' : locale === 'en' ? 'value for money' : 'цена / качество'}
</div>
<p className="text-[11px] font-sans font-medium text-ink/65 normal-case leading-relaxed">
{locale === 'tr' ? 'Hizmet kalitesi ile fiyat dengesi yerel standartlara göre değerlendirilir.' : locale === 'en' ? 'The balance between service quality and price is evaluated according to local standards.' : 'Баланс качества услуг и цены оценивается по местным стандартам.'}
</p>
</div>
<div className="bg-stone/35 p-5 rounded-2xl border border-pine/5 space-y-2">
<div className="text-turquoise text-lg font-bold">03</div>
<div className="uppercase tracking-wider">
{locale === 'tr' ? 'yerel onay' : locale === 'en' ? 'local approval' : 'местное одобрение'}
</div>
<p className="text-[11px] font-sans font-medium text-ink/65 normal-case leading-relaxed">
{locale === 'tr' ? 'Marmaris sakinlerinin tavsiye ve memnuniyet oranları kontrol edilir.' : locale === 'en' ? 'Recommendations and satisfaction rates of Marmaris residents are checked.' : 'Проверяются рекомендации и уровень удовлетворенности жителей Мармариса.'}
</p>
</div>
</div>
</div>
<hr className="border-dashed border-pine/10" />
{/* Action Link */}
<div className="text-center pt-4 space-y-4">
<h3 className="font-heading font-bold text-pine lowercase text-lg">
{locale === 'tr' ? 'kendi işletmenizi önermek ister misiniz?' : locale === 'en' ? 'would you like to suggest your own business?' : 'хотите предложить свой бизнес?'}
</h3>
<Link
href="/isletme-ekle"
className="inline-flex items-center gap-2 bg-turquoise hover:bg-turquoise/90 text-paper font-bold text-xs py-3.5 px-6 rounded-xl transition shadow-sm"
>
{navT('addBusiness')}
</Link>
</div>
</div>
</main>
<Footer />
</div>
)
}
+124
View File
@@ -0,0 +1,124 @@
'use client'
import { useState } from 'react'
import { submitContactMessageAction } from '@/app/actions'
interface Translations {
name: string
email: string
subject: string
message: string
submit: string
sending: string
success: string
}
interface FormProps {
translations: Translations
}
export default function ContactForm({ translations }: FormProps) {
const [success, setSuccess] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setLoading(true)
setError('')
const formData = new FormData(e.currentTarget)
try {
const res = await submitContactMessageAction(formData)
if (res.error) {
setError(res.error)
} else {
setSuccess(true)
e.currentTarget.reset()
}
} catch (err) {
setError('Bir hata oluştu. Lütfen tekrar deneyin.')
} finally {
setLoading(false)
}
}
if (success) {
return (
<div className="bg-turquoise/10 border border-turquoise/20 text-turquoise p-6 rounded-2xl text-center space-y-3">
<h3 className="font-heading font-bold text-lg lowercase">teşekkürler!</h3>
<p className="text-sm font-medium">{translations.success}</p>
</div>
)
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-bougainvillea/10 border border-bougainvillea/20 text-bougainvillea p-4 rounded-xl text-xs font-semibold">
{error}
</div>
)}
{/* Name */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.name} *</label>
<input
type="text"
name="name"
required
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink placeholder:text-ink/30"
placeholder="Adınız Soyadınız"
/>
</div>
{/* Email */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.email} *</label>
<input
type="email"
name="email"
required
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink placeholder:text-ink/30"
placeholder="eposta@adresiniz.com"
/>
</div>
{/* Subject */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.subject} *</label>
<select
name="subject"
required
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink appearance-none"
>
<option value="">Konu seçin...</option>
<option value="Genel">Genel Sorular</option>
<option value="İşbirliği">İşbirliği</option>
<option value="Hata Bildirimi">Hata Bildirimi</option>
<option value="Diğer">Diğer</option>
</select>
</div>
{/* Message */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.message} *</label>
<textarea
name="message"
required
rows={5}
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink placeholder:text-ink/30"
placeholder="Mesajınızı buraya yazın..."
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-turquoise hover:bg-turquoise/90 disabled:opacity-75 disabled:cursor-not-allowed text-paper font-bold text-sm py-4.5 px-4 rounded-xl transition duration-300 shadow-sm"
>
{loading ? translations.sending : translations.submit}
</button>
</form>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { getTranslations, setRequestLocale } from 'next-intl/server'
import Navbar from '@/components/Navbar'
import Footer from '@/components/Footer'
import ContactForm from './ContactForm'
interface ContactPageProps {
params: Promise<{ locale: string }>
}
export default async function ContactPage({ params }: ContactPageProps) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations('forms')
const navT = await getTranslations('nav')
return (
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
<Navbar />
<main className="max-w-xl mx-auto px-4 sm:px-6 lg:px-8 py-12 flex-1 w-full">
<div className="bg-paper p-8 rounded-3xl border border-pine/8 shadow-sm">
<div className="mb-8 border-b border-dashed border-pine/8 pb-6">
<h1 className="text-3xl font-heading font-extrabold text-pine lowercase">
{navT('contact')}
</h1>
<p className="text-xs text-shutter font-mono uppercase tracking-wider mt-1.5">
marmaris local bize ulaşın
</p>
</div>
<ContactForm
translations={{
name: t('name'),
email: t('email'),
subject: t('subject'),
message: t('message'),
submit: t('submit'),
sending: t('sending'),
success: t('contactSuccess')
}}
/>
</div>
</main>
<Footer />
</div>
)
}
+217
View File
@@ -0,0 +1,217 @@
'use client'
import { useState } from 'react'
import { submitBusinessAction } from '@/app/actions'
interface Option {
value: string
label: string
}
interface Translations {
businessName: string
category: string
neighborhood: string
address: string
phone: string
whatsapp: string
description: string
image: string
submit: string
sending: string
success: string
contactName: string
contactEmail: string
}
interface FormProps {
translations: Translations
categories: Option[]
neighborhoods: Option[]
}
export default function BusinessForm({ translations, categories, neighborhoods }: FormProps) {
const [success, setSuccess] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setLoading(true)
setError('')
const formData = new FormData(e.currentTarget)
try {
const res = await submitBusinessAction(formData)
if (res.error) {
setError(res.error)
} else {
setSuccess(true)
e.currentTarget.reset()
}
} catch (err) {
setError('Bir hata oluştu. Lütfen tekrar deneyin.')
} finally {
setLoading(false)
}
}
if (success) {
return (
<div className="bg-turquoise/10 border border-turquoise/20 text-turquoise p-6 rounded-2xl text-center space-y-3">
<h3 className="font-heading font-bold text-lg lowercase">teşekkürler!</h3>
<p className="text-sm font-medium">{translations.success}</p>
</div>
)
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-bougainvillea/10 border border-bougainvillea/20 text-bougainvillea p-4 rounded-xl text-xs font-semibold">
{error}
</div>
)}
{/* Business name */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.businessName} *</label>
<input
type="text"
name="businessName"
required
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink placeholder:text-ink/30"
placeholder="Örn: İskele Balık Ocakbaşı"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Category */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.category} *</label>
<select
name="categoryId"
required
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink appearance-none"
>
<option value="">Kategori seçin...</option>
{categories.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</div>
{/* Neighborhood */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.neighborhood} *</label>
<select
name="neighborhoodId"
required
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink appearance-none"
>
<option value="">Mahalle seçin...</option>
{neighborhoods.map((n) => (
<option key={n.value} value={n.value}>
{n.label}
</option>
))}
</select>
</div>
</div>
{/* Address */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.address} *</label>
<input
type="text"
name="address"
required
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink placeholder:text-ink/30"
placeholder="Örn: Yat Limanı No:12, Marmaris"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Phone */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.phone}</label>
<input
type="text"
name="phone"
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink placeholder:text-ink/30"
placeholder="Örn: +90 252 412 34 56"
/>
</div>
{/* Whatsapp */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.whatsapp}</label>
<input
type="text"
name="whatsapp"
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink placeholder:text-ink/30"
placeholder="Örn: +90 532 123 45 67"
/>
</div>
</div>
{/* Description */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.description} *</label>
<textarea
name="description"
required
rows={4}
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink placeholder:text-ink/30"
placeholder="İşletmenizi tanıtan kısa bir açıklama yazın..."
/>
</div>
{/* Image File */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.image}</label>
<input
type="file"
name="imageFile"
accept="image/*"
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink file:mr-4 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-semibold file:bg-pine file:text-stone hover:file:opacity-90 cursor-pointer"
/>
</div>
{/* Contact Name & Email */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 border-t border-dashed border-pine/10 pt-6">
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.contactName} *</label>
<input
type="text"
name="contactName"
required
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink placeholder:text-ink/30"
placeholder="Adınız Soyadınız"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.contactEmail} *</label>
<input
type="email"
name="contactEmail"
required
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink placeholder:text-ink/30"
placeholder="eposta@adresiniz.com"
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-turquoise hover:bg-turquoise/90 disabled:opacity-75 disabled:cursor-not-allowed text-paper font-bold text-sm py-4.5 px-4 rounded-xl transition duration-300 shadow-sm"
>
{loading ? translations.sending : translations.submit}
</button>
</form>
)
}
+79
View File
@@ -0,0 +1,79 @@
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { mockDb } from '@/lib/mockDb'
import Navbar from '@/components/Navbar'
import Footer from '@/components/Footer'
import BusinessForm from './BusinessForm'
interface AddBusinessPageProps {
params: Promise<{ locale: string }>
}
export default async function AddBusinessPage({ params }: AddBusinessPageProps) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations('forms')
const navT = await getTranslations('nav')
// Fetch categories and neighborhoods to populate form select options
const categories = await mockDb.getCategories()
const neighborhoods = await mockDb.getNeighborhoods()
const getLocalizedName = (obj: any) => {
if (!obj) return ''
return locale === 'ru' ? obj.nameRu : locale === 'en' ? obj.nameEn : obj.nameTr
}
const categoryOptions = categories.map(c => ({
value: c.id,
label: getLocalizedName(c)
}))
const neighborhoodOptions = neighborhoods.map(n => ({
value: n.id,
label: getLocalizedName(n)
}))
return (
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
<Navbar />
<main className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-12 flex-1 w-full">
<div className="bg-paper p-8 rounded-3xl border border-pine/8 shadow-sm">
<div className="mb-8 border-b border-dashed border-pine/8 pb-6">
<h1 className="text-3xl font-heading font-extrabold text-pine lowercase">
{navT('addBusiness')}
</h1>
<p className="text-xs text-shutter font-mono uppercase tracking-wider mt-1.5">
marmaris local yeni başvuru
</p>
</div>
<BusinessForm
translations={{
businessName: t('businessName'),
category: t('category'),
neighborhood: t('neighborhood'),
address: t('address'),
phone: t('phone'),
whatsapp: t('whatsapp'),
description: t('description'),
image: t('image'),
submit: t('submit'),
sending: t('sending'),
success: t('success'),
contactName: 'Yetkili Adı Soyadı',
contactEmail: 'İletişim E-postası'
}}
categories={categoryOptions}
neighborhoods={neighborhoodOptions}
/>
</div>
</main>
<Footer />
</div>
)
}
+161
View File
@@ -0,0 +1,161 @@
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { mockDb } from '@/lib/mockDb'
import Navbar from '@/components/Navbar'
import Footer from '@/components/Footer'
import ListingCard from '@/components/ListingCard'
import { SlidersHorizontal } from 'lucide-react'
interface PageProps {
params: Promise<{ locale: string }>
searchParams: Promise<{
search?: string
neighborhood?: string
price?: string
approved?: string
}>
}
export default async function BusinessesPage({ params, searchParams }: PageProps) {
const { locale } = await params
setRequestLocale(locale)
const { search, neighborhood, price, approved } = await searchParams
const t = await getTranslations('categories')
// Find Category Isletme
const categories = await mockDb.getCategories()
const currentCategory = categories.find(c => c.slug === 'isletme')
const categoryId = currentCategory?.id
// Get active neighborhoods for filter
const neighborhoods = await mockDb.getNeighborhoods()
// Selected filters
const selectedNeighborhoodId = neighborhood || undefined
const selectedPriceRange = price ? parseInt(price) : undefined
const isApprovedOnly = approved === 'true'
const listings = await mockDb.getListings({
categoryId,
neighborhoodId: selectedNeighborhoodId,
priceRange: selectedPriceRange,
isLocalApproved: isApprovedOnly ? true : undefined,
search: search
})
const getLocalizedName = (obj: any) => {
if (!obj) return ''
return locale === 'ru' ? obj.nameRu : locale === 'en' ? obj.nameEn : obj.nameTr
}
return (
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
<Navbar />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 flex-1">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-heading font-extrabold text-pine lowercase">
{t('isletme')}
</h1>
<p className="text-xs text-shutter font-mono uppercase tracking-wider mt-1">
marmaris local {listings.length} {locale === 'tr' ? 'sonuç' : locale === 'en' ? 'results' : 'результатов'}
</p>
</div>
{/* Filters Panel */}
<div className="bg-paper p-5 rounded-2xl border border-pine/8 shadow-sm mb-10">
<div className="flex items-center gap-2 mb-4 font-heading font-bold text-sm text-pine lowercase border-b border-dashed border-pine/8 pb-3">
<SlidersHorizontal className="w-4 h-4 text-turquoise" />
<span>filtreler</span>
</div>
<form method="GET" className="grid grid-cols-1 sm:grid-cols-4 gap-4 items-end">
{/* Search Input */}
<div className="space-y-1.5">
<label className="block text-[11px] font-mono uppercase tracking-wider text-shutter">Arama</label>
<input
type="text"
name="search"
defaultValue={search || ''}
placeholder="İsim veya adres..."
className="w-full bg-stone border border-pine/10 rounded-xl px-3 py-2 text-xs focus:ring-1 focus:ring-turquoise outline-none"
/>
</div>
{/* Neighborhood select */}
<div className="space-y-1.5">
<label className="block text-[11px] font-mono uppercase tracking-wider text-shutter">{t('filterNeighborhood')}</label>
<select
name="neighborhood"
defaultValue={neighborhood || ''}
className="w-full bg-stone border border-pine/10 rounded-xl px-3 py-2 text-xs focus:ring-1 focus:ring-turquoise outline-none appearance-none"
>
<option value="">{t('allNeighborhoods')}</option>
{neighborhoods.map((n) => (
<option key={n.id} value={n.id}>
{getLocalizedName(n)}
</option>
))}
</select>
</div>
{/* Price range select */}
<div className="space-y-1.5">
<label className="block text-[11px] font-mono uppercase tracking-wider text-shutter">{t('filterPrice')}</label>
<select
name="price"
defaultValue={price || ''}
className="w-full bg-stone border border-pine/10 rounded-xl px-3 py-2 text-xs focus:ring-1 focus:ring-turquoise outline-none"
>
<option value="">{t('allPrices')}</option>
<option value="1"> (Ekonomik)</option>
<option value="2"> (Orta)</option>
<option value="3"> (Lüks)</option>
</select>
</div>
{/* Submit / Checkbox area */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer select-none text-xs font-semibold py-2.5">
<input
type="checkbox"
name="approved"
value="true"
defaultChecked={isApprovedOnly}
className="rounded border-pine/10 text-turquoise focus:ring-turquoise w-4 h-4"
/>
<span className="text-pine">{t('filterApproved')}</span>
</label>
<button
type="submit"
className="flex-1 bg-turquoise hover:bg-turquoise/90 text-paper text-xs font-bold py-2.5 px-4 rounded-xl transition text-center"
>
Filtrele
</button>
</div>
</form>
</div>
{/* Results */}
{listings.length === 0 ? (
<div className="bg-paper/50 rounded-2xl border border-dashed border-pine/12 p-12 text-center text-shutter">
<p className="text-sm font-medium">{t('noResults')}</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{listings.map((listing) => (
<ListingCard key={listing.id} listing={listing} />
))}
</div>
)}
</main>
<Footer />
</div>
)
}
+64
View File
@@ -0,0 +1,64 @@
import type { Metadata } from "next";
import { Unbounded, Golos_Text, IBM_Plex_Mono } from "next/font/google";
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, setRequestLocale } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { routing } from '@/i18n/routing';
import "../globals.css";
const unbounded = Unbounded({
variable: "--font-unbounded",
subsets: ["latin", "cyrillic"],
weight: ["400", "600", "800"],
});
const golosText = Golos_Text({
variable: "--font-golos",
subsets: ["latin", "cyrillic"],
weight: ["400", "500", "600"],
});
const ibmPlexMono = IBM_Plex_Mono({
variable: "--font-mono",
subsets: ["latin", "cyrillic"],
weight: ["400", "500"],
});
export const metadata: Metadata = {
title: "Marmaris Local — Yerel Rehber",
description: "Marmaris'in en iyi yerel mekanları, restoranları ve saklı apart otelleri.",
};
export function generateStaticParams() {
return routing.locales.map((locale) => ({locale}));
}
export default async function RootLayout({
children,
params
}: Readonly<{
children: React.ReactNode;
params: Promise<{ locale: string }>;
}>) {
const { locale } = await params;
if (!routing.locales.includes(locale as any)) {
notFound();
}
setRequestLocale(locale);
const messages = await getMessages();
return (
<html
lang={locale}
className={`${unbounded.variable} ${golosText.variable} ${ibmPlexMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col font-sans" suppressHydrationWarning>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
+94
View File
@@ -0,0 +1,94 @@
'use client'
import { useState } from 'react'
import { signIn } from 'next-auth/react'
import { useRouter } from 'next/navigation'
export default function LoginPage() {
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', {
redirect: false,
email,
password,
})
if (result?.error) {
setError('Geçersiz e-posta veya şifre')
setLoading(false)
} else {
router.push('/admin')
router.refresh()
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4">
<div className="w-full max-w-md bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-100 dark:border-gray-800 overflow-hidden">
<div className="p-8">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Admin Girişi</h1>
<p className="text-sm text-gray-500 mt-2">Yönetim paneline erişmek için giriş yapın</p>
</div>
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded-md text-sm mb-6 border border-red-100">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" htmlFor="email">
E-posta
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-900 text-gray-900 dark:text-white transition-colors"
placeholder="admin@ayris.tech"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" htmlFor="password">
Şifre
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-900 text-gray-900 dark:text-white transition-colors"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2.5 px-4 rounded-md transition-colors disabled:opacity-70 disabled:cursor-not-allowed"
>
{loading ? 'Giriş yapılıyor...' : 'Giriş Yap'}
</button>
</form>
<div className="mt-6 text-center text-xs text-gray-400">
Demo credentials: admin@ayris.tech / admin
</div>
</div>
</div>
</div>
)
}
+74
View File
@@ -0,0 +1,74 @@
import { setRequestLocale } from 'next-intl/server'
import { mockDb } from '@/lib/mockDb'
import Navbar from '@/components/Navbar'
import Footer from '@/components/Footer'
import ListingCard from '@/components/ListingCard'
import { notFound } from 'next/navigation'
import { MapPin } from 'lucide-react'
interface NeighborhoodPageProps {
params: Promise<{ locale: string; slug: string }>
}
export default async function NeighborhoodPage({ params }: NeighborhoodPageProps) {
const { locale, slug } = await params
setRequestLocale(locale)
const neighborhood = await mockDb.getNeighborhoodBySlug(slug)
if (!neighborhood) {
notFound()
}
const listings = await mockDb.getListings({
neighborhoodId: neighborhood.id
})
const name =
locale === 'ru'
? neighborhood.nameRu
: locale === 'en'
? neighborhood.nameEn
: neighborhood.nameTr
return (
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
<Navbar />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 flex-1">
{/* Header */}
<div className="flex items-center gap-3 mb-10 pb-6 border-b border-pine/8">
<div className="p-3 bg-paper rounded-xl border border-pine/8 text-turquoise shadow-sm">
<MapPin className="w-6 h-6" />
</div>
<div>
<h1 className="text-3xl font-heading font-extrabold text-pine lowercase">
{name}
</h1>
<p className="text-xs text-shutter font-mono uppercase tracking-wider mt-1">
{locale === 'tr' ? 'mahalle rehberi' : locale === 'en' ? 'neighborhood directory' : 'гид по району'} {listings.length} {locale === 'tr' ? 'mekan' : locale === 'en' ? 'places' : 'заведений'}
</p>
</div>
</div>
{/* Results */}
{listings.length === 0 ? (
<div className="bg-paper/50 rounded-2xl border border-dashed border-pine/12 p-16 text-center text-shutter">
<p className="text-sm font-medium">
{locale === 'tr' ? 'Bu mahallede henüz kayıtlı mekan bulunmamaktadır.' : locale === 'en' ? 'No registered places in this neighborhood yet.' : 'В этом районе пока нет зарегистрированных мест.'}
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{listings.map((listing) => (
<ListingCard key={listing.id} listing={listing} />
))}
</div>
)}
</main>
<Footer />
</div>
)
}
+248
View File
@@ -0,0 +1,248 @@
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { mockDb } from '@/lib/mockDb'
import Navbar from '@/components/Navbar'
import Footer from '@/components/Footer'
import ListingCard from '@/components/ListingCard'
import { Link } from '@/i18n/routing'
import { Search, MapPin, CheckCircle, ArrowRight } from 'lucide-react'
export default async function HomePage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations('hero')
const homeT = await getTranslations('home')
const navT = await getTranslations('nav')
// Get active listings and filter for featured (Local Approved)
const allListings = await mockDb.getListings()
const featuredListings = allListings.filter(l => l.isLocalApproved).slice(0, 3)
const categories = await mockDb.getCategories()
const neighborhoods = await mockDb.getNeighborhoods()
// Get localized names
const getLocalizedName = (obj: any) => {
if (!obj) return ''
return locale === 'ru' ? obj.nameRu : locale === 'en' ? obj.nameEn : obj.nameTr
}
// Map category slug to lucide icons or nice visual cues
const categoryImages: Record<string, string> = {
restoran: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&auto=format&fit=crop&q=80',
apart: 'https://images.unsplash.com/photo-1566073771259-6a8506099945?w=800&auto=format&fit=crop&q=80',
isletme: 'https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=800&auto=format&fit=crop&q=80'
}
const categoryPaths: Record<string, string> = {
restoran: '/restoranlar',
apart: '/apartlar',
isletme: '/isletmeler'
}
return (
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
<Navbar />
{/* Hero Section */}
<section className="bg-pine text-stone pt-20 pb-24 relative overflow-hidden">
{/* Decorative background shapes */}
<div className="absolute inset-0 opacity-5 pointer-events-none">
<div className="absolute -top-40 -right-40 w-96 h-96 rounded-full bg-turquoise blur-3xl" />
<div className="absolute -bottom-45 -left-40 w-96 h-96 rounded-full bg-bougainvillea blur-3xl" />
</div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center relative z-10 space-y-8">
{/* Logo stamp effect */}
<div className="flex justify-center">
<div className="w-16 h-16 rounded-full border-2 border-stone/30 flex items-center justify-center relative bg-white/5 backdrop-blur-sm -rotate-6">
<div className="absolute inset-[3.5px] rounded-full border border-dashed border-turquoise/50" />
<span className="font-heading font-extrabold text-stone text-lg tracking-tighter">ML</span>
</div>
</div>
<h2 className="font-heading font-extrabold text-3xl sm:text-5xl lg:text-6xl text-stone leading-tight tracking-tight max-w-3xl mx-auto lowercase">
{locale === 'tr' && (
<>En iyi yerel adresler,<br /><span className="text-turquoise">turistin göremediği yerde.</span></>
)}
{locale === 'en' && (
<>The best local spots,<br /><span className="text-turquoise">hidden from plain sight.</span></>
)}
{locale === 'ru' && (
<>Лучшие места,<br /><span className="text-turquoise">которые знают местные.</span></>
)}
</h2>
<p className="text-sm sm:text-base text-stone/70 max-w-xl mx-auto font-medium">
{t('subtitle')}
</p>
{/* Search Form */}
<form
action={`/${locale}/restoranlar`}
method="GET"
className="max-w-xl mx-auto bg-paper p-2 rounded-2xl flex items-center shadow-lg border border-white/10"
>
<div className="flex items-center flex-1 px-3">
<Search className="w-5 h-5 text-shutter shrink-0" />
<input
type="text"
name="search"
placeholder={t('searchPlaceholder')}
className="w-full bg-transparent border-0 focus:ring-0 text-sm py-2 px-3 text-ink placeholder:text-ink/40 outline-none"
/>
</div>
<button
type="submit"
className="bg-turquoise hover:bg-turquoise/90 text-paper font-medium text-xs px-5 py-3 rounded-xl transition"
>
{t('cta')}
</button>
</form>
{/* Local Approved Banner */}
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/5 border border-white/10 text-xs">
<span className="inline-block w-2.5 h-2.5 rounded-full bg-turquoise animate-pulse" />
<span className="font-semibold text-turquoise">{t('approvedBadge')}</span>
<span className="text-stone/60">mührü ile güvenli rehber</span>
</div>
</div>
</section>
{/* Category Grid Section */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
<div className="text-center mb-12">
<h3 className="text-2xl sm:text-3xl font-heading font-extrabold text-pine lowercase">
{homeT('categories')}
</h3>
<p className="text-sm text-ink/65 mt-2 font-medium">
{homeT('categoriesSubtitle')}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{categories.map((cat) => {
const imageUrl = categoryImages[cat.slug] || categoryImages.isletme
const path = categoryPaths[cat.slug] || '/isletmeler'
const catName = getLocalizedName(cat)
return (
<Link
key={cat.id}
href={path}
className="group relative h-64 rounded-2xl overflow-hidden shadow-sm hover:shadow-md transition duration-300 border border-pine/5 flex items-end p-6"
>
<div className="absolute inset-0">
<img
src={imageUrl}
alt={catName}
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-pine/90 via-pine/30 to-transparent" />
</div>
<div className="relative z-10 space-y-1">
<h4 className="font-heading font-extrabold text-xl text-stone group-hover:text-turquoise transition-colors lowercase">
{catName}
</h4>
<div className="flex items-center gap-1 text-[11px] font-mono text-turquoise">
<span>{homeT('explore')}</span>
<ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform" />
</div>
</div>
</Link>
)
})}
</div>
</section>
{/* Local Approved Featured Section */}
<section className="bg-stone-deep py-20 border-t border-b border-pine/5">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex flex-col sm:flex-row sm:items-end justify-between mb-12 gap-4">
<div>
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full border border-turquoise/20 bg-turquoise/5 text-[10px] font-mono text-turquoise font-semibold uppercase tracking-wider mb-3">
{t('approvedBadge')}
</div>
<h3 className="text-2xl sm:text-3xl font-heading font-extrabold text-pine lowercase">
{homeT('featured')}
</h3>
<p className="text-sm text-ink/65 mt-2 font-medium">
{homeT('featuredSubtitle')}
</p>
</div>
<Link
href="/restoranlar?approved=true"
className="flex items-center gap-1 text-xs font-bold text-pine hover:text-turquoise transition-colors border-b border-pine/20 hover:border-turquoise pb-1 w-fit"
>
<span>{locale === 'tr' ? 'tüm onaylı mekanlar' : locale === 'en' ? 'all approved places' : 'все проверенные места'}</span>
<ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{featuredListings.map((listing) => (
<ListingCard key={listing.id} listing={listing} />
))}
</div>
</div>
</section>
{/* Neighborhoods Section */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20">
<div className="text-center mb-12">
<h3 className="text-2xl sm:text-3xl font-heading font-extrabold text-pine lowercase">
{homeT('neighborhoods')}
</h3>
<p className="text-sm text-ink/65 mt-2 font-medium">
{homeT('neighborhoodsSubtitle')}
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{neighborhoods.map((neigh) => {
const name = getLocalizedName(neigh)
return (
<Link
key={neigh.id}
href={`/mahalle/${neigh.slug}`}
className="bg-paper p-5 rounded-xl border border-pine/8 text-center hover:border-turquoise/40 hover:bg-paper/90 transition shadow-sm group flex flex-col items-center gap-2"
>
<MapPin className="w-5 h-5 text-shutter group-hover:text-turquoise transition-colors" />
<span className="font-heading font-bold text-xs text-pine lowercase group-hover:text-turquoise transition-colors">
{name}
</span>
</Link>
)
})}
</div>
</section>
{/* Trust Badge Explainer */}
<section className="bg-pine text-stone py-16 border-t border-white/5">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center space-y-6">
<div className="flex justify-center">
<div className="w-14 h-14 rounded-full border-2 border-turquoise flex items-center justify-center relative bg-paper -rotate-6">
<div className="absolute inset-[2px] rounded-full border border-dashed border-turquoise/60" />
<span className="font-mono text-[8px] text-center font-bold text-turquoise tracking-tight leading-none uppercase">
YEREL<br />ONAYLI
</span>
</div>
</div>
<h3 className="text-xl sm:text-2xl font-heading font-extrabold lowercase">
yerel onay mührü nedir?
</h3>
<p className="text-xs sm:text-sm text-stone/70 max-w-xl mx-auto font-medium leading-relaxed">
{t('approvedExplain')}
</p>
</div>
</section>
<Footer />
</div>
)
}
+163
View File
@@ -0,0 +1,163 @@
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { mockDb } from '@/lib/mockDb'
import Navbar from '@/components/Navbar'
import Footer from '@/components/Footer'
import ListingCard from '@/components/ListingCard'
import { Link } from '@/i18n/routing'
import { MapPin, SlidersHorizontal, Check } from 'lucide-react'
interface PageProps {
params: Promise<{ locale: string }>
searchParams: Promise<{
search?: string
neighborhood?: string
price?: string
approved?: string
}>
}
export default async function RestaurantsPage({ params, searchParams }: PageProps) {
const { locale } = await params
setRequestLocale(locale)
const { search, neighborhood, price, approved } = await searchParams
const t = await getTranslations('categories')
const navT = await getTranslations('nav')
// Find Category Restoran
const categories = await mockDb.getCategories()
const currentCategory = categories.find(c => c.slug === 'restoran')
const categoryId = currentCategory?.id
// Get active neighborhoods for filter
const neighborhoods = await mockDb.getNeighborhoods()
// Selected filters
const selectedNeighborhoodId = neighborhood || undefined
const selectedPriceRange = price ? parseInt(price) : undefined
const isApprovedOnly = approved === 'true'
const listings = await mockDb.getListings({
categoryId,
neighborhoodId: selectedNeighborhoodId,
priceRange: selectedPriceRange,
isLocalApproved: isApprovedOnly ? true : undefined,
search: search
})
const getLocalizedName = (obj: any) => {
if (!obj) return ''
return locale === 'ru' ? obj.nameRu : locale === 'en' ? obj.nameEn : obj.nameTr
}
return (
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
<Navbar />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 flex-1">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-heading font-extrabold text-pine lowercase">
{t('restoran')}
</h1>
<p className="text-xs text-shutter font-mono uppercase tracking-wider mt-1">
marmaris local {listings.length} {locale === 'tr' ? 'sonuç' : locale === 'en' ? 'results' : 'результатов'}
</p>
</div>
{/* Filters Panel */}
<div className="bg-paper p-5 rounded-2xl border border-pine/8 shadow-sm mb-10">
<div className="flex items-center gap-2 mb-4 font-heading font-bold text-sm text-pine lowercase border-b border-dashed border-pine/8 pb-3">
<SlidersHorizontal className="w-4 h-4 text-turquoise" />
<span>filtreler</span>
</div>
<form method="GET" className="grid grid-cols-1 sm:grid-cols-4 gap-4 items-end">
{/* Search Input */}
<div className="space-y-1.5">
<label className="block text-[11px] font-mono uppercase tracking-wider text-shutter">Arama</label>
<input
type="text"
name="search"
defaultValue={search || ''}
placeholder="İsim veya adres..."
className="w-full bg-stone border border-pine/10 rounded-xl px-3 py-2 text-xs focus:ring-1 focus:ring-turquoise outline-none"
/>
</div>
{/* Neighborhood select */}
<div className="space-y-1.5">
<label className="block text-[11px] font-mono uppercase tracking-wider text-shutter">{t('filterNeighborhood')}</label>
<select
name="neighborhood"
defaultValue={neighborhood || ''}
className="w-full bg-stone border border-pine/10 rounded-xl px-3 py-2 text-xs focus:ring-1 focus:ring-turquoise outline-none appearance-none"
>
<option value="">{t('allNeighborhoods')}</option>
{neighborhoods.map((n) => (
<option key={n.id} value={n.id}>
{getLocalizedName(n)}
</option>
))}
</select>
</div>
{/* Price range select */}
<div className="space-y-1.5">
<label className="block text-[11px] font-mono uppercase tracking-wider text-shutter">{t('filterPrice')}</label>
<select
name="price"
defaultValue={price || ''}
className="w-full bg-stone border border-pine/10 rounded-xl px-3 py-2 text-xs focus:ring-1 focus:ring-turquoise outline-none"
>
<option value="">{t('allPrices')}</option>
<option value="1"> (Ekonomik)</option>
<option value="2"> (Orta)</option>
<option value="3"> (Lüks)</option>
</select>
</div>
{/* Submit / Checkbox area */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer select-none text-xs font-semibold py-2.5">
<input
type="checkbox"
name="approved"
value="true"
defaultChecked={isApprovedOnly}
className="rounded border-pine/10 text-turquoise focus:ring-turquoise w-4 h-4"
/>
<span className="text-pine">{t('filterApproved')}</span>
</label>
<button
type="submit"
className="flex-1 bg-turquoise hover:bg-turquoise/90 text-paper text-xs font-bold py-2.5 px-4 rounded-xl transition text-center"
>
Filtrele
</button>
</div>
</form>
</div>
{/* Results */}
{listings.length === 0 ? (
<div className="bg-paper/50 rounded-2xl border border-dashed border-pine/12 p-12 text-center text-shutter">
<p className="text-sm font-medium">{t('noResults')}</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{listings.map((listing) => (
<ListingCard key={listing.id} listing={listing} />
))}
</div>
)}
</main>
<Footer />
</div>
)
}
+232
View File
@@ -0,0 +1,232 @@
'use server'
import { mockDb } from '@/lib/mockDb'
import { uploadToOpeninary } from '@/lib/openinary'
import { revalidatePath } from 'next/cache'
export async function submitBusinessAction(formData: FormData) {
const businessName = formData.get('businessName') as string
const categoryId = formData.get('categoryId') as string
const neighborhoodId = formData.get('neighborhoodId') as string
const address = formData.get('address') as string
const phone = formData.get('phone') as string
const whatsapp = formData.get('whatsapp') as string
const description = formData.get('description') as string
const contactName = formData.get('contactName') as string
const contactEmail = formData.get('contactEmail') as string
const imageFile = formData.get('imageFile') as File | null
if (!businessName || !categoryId || !neighborhoodId || !address || !description || !contactName || !contactEmail) {
return { error: 'Lütfen tüm zorunlu alanları doldurun.' }
}
let imageUrl = null
if (imageFile && imageFile.size > 0) {
try {
imageUrl = await uploadToOpeninary(imageFile, 'submissions')
} catch (e: any) {
console.error('Openinary upload error:', e)
return { error: `Görsel yüklenemedi: ${e.message}` }
}
}
await mockDb.createSubmission({
businessName,
categoryId,
neighborhoodId,
address,
phone,
whatsapp,
description,
contactName,
contactEmail,
imageUrl
})
revalidatePath('/admin/submissions')
return { success: true }
}
export async function submitContactMessageAction(formData: FormData) {
const name = formData.get('name') as string
const email = formData.get('email') as string
const subject = formData.get('subject') as string
const message = formData.get('message') as string
if (!name || !email || !subject || !message) {
return { error: 'Lütfen tüm alanları doldurun.' }
}
await mockDb.createMessage({
name,
email,
subject,
message
})
revalidatePath('/admin/messages')
return { success: true }
}
export async function approveSubmissionAction(id: string) {
const submission = await mockDb.getSubmissionById(id)
if (!submission) return { error: 'Başvuru bulunamadı' }
const slug = submission.businessName.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
await mockDb.createListing({
slug,
categoryId: submission.categoryId,
neighborhoodId: submission.neighborhoodId,
city: 'marmaris',
nameTr: submission.businessName,
nameEn: submission.businessName,
nameRu: submission.businessName,
descriptionTr: submission.description,
descriptionEn: submission.description,
descriptionRu: submission.description,
address: submission.address,
phone: submission.phone,
whatsapp: submission.whatsapp,
priceRange: 2,
rating: 5.0,
isLocalApproved: false,
images: submission.imageUrl ? [submission.imageUrl] : []
})
await mockDb.updateSubmissionStatus(id, 'APPROVED')
revalidatePath('/admin/submissions')
revalidatePath('/restoranlar')
revalidatePath('/apartlar')
revalidatePath('/isletmeler')
return { success: true }
}
export async function rejectSubmissionAction(id: string) {
await mockDb.updateSubmissionStatus(id, 'REJECTED')
revalidatePath('/admin/submissions')
return { success: true }
}
export async function deleteListingAction(id: string) {
await mockDb.deleteListing(id)
revalidatePath('/admin/listings')
revalidatePath('/restoranlar')
revalidatePath('/apartlar')
revalidatePath('/isletmeler')
return { success: true }
}
export async function markMessageReadAction(id: string) {
await mockDb.markMessageAsRead(id)
revalidatePath('/admin/messages')
return { success: true }
}
export async function createOrUpdateListingAction(formData: FormData) {
const id = formData.get('id') as string | null
const slug = formData.get('slug') as string
const categoryId = formData.get('categoryId') as string
const neighborhoodId = formData.get('neighborhoodId') as string
const nameTr = formData.get('nameTr') as string
const nameEn = formData.get('nameEn') as string
const nameRu = formData.get('nameRu') as string
const descriptionTr = formData.get('descriptionTr') as string
const descriptionEn = formData.get('descriptionEn') as string
const descriptionRu = formData.get('descriptionRu') as string
const address = formData.get('address') as string
const phone = formData.get('phone') as string || null
const whatsapp = formData.get('whatsapp') as string || null
const website = formData.get('website') as string || null
const instagram = formData.get('instagram') as string || null
const priceRange = parseInt(formData.get('priceRange') as string)
const rating = formData.get('rating') ? parseFloat(formData.get('rating') as string) : null
const isLocalApproved = formData.get('isLocalApproved') === 'true'
const latitude = formData.get('latitude') ? parseFloat(formData.get('latitude') as string) : null
const longitude = formData.get('longitude') ? parseFloat(formData.get('longitude') as string) : null
const openingHoursStr = formData.get('openingHours') as string
const openingHours = openingHoursStr ? { all: openingHoursStr } : null
// Handle images
const images: string[] = []
const imageFile1 = formData.get('imageFile1') as File | null
const imageUrl1 = formData.get('imageUrl1') as string | null
const imageFile2 = formData.get('imageFile2') as File | null
const imageUrl2 = formData.get('imageUrl2') as string | null
// Process first image
if (imageFile1 && imageFile1.size > 0) {
try {
const url = await uploadToOpeninary(imageFile1, `listings/${slug}`)
images.push(url)
} catch (e: any) {
console.error('Image 1 upload error:', e)
return { success: false, error: `1. Görsel yüklenemedi: ${e.message}` }
}
} else if (imageUrl1) {
images.push(imageUrl1)
}
// Process second image
if (imageFile2 && imageFile2.size > 0) {
try {
const url = await uploadToOpeninary(imageFile2, `listings/${slug}`)
images.push(url)
} catch (e: any) {
console.error('Image 2 upload error:', e)
return { success: false, error: `2. Görsel yüklenemedi: ${e.message}` }
}
} else if (imageUrl2) {
images.push(imageUrl2)
}
const data = {
slug,
categoryId,
neighborhoodId,
city: 'marmaris',
nameTr,
nameEn,
nameRu,
descriptionTr,
descriptionEn,
descriptionRu,
address,
phone,
whatsapp,
website,
instagram,
priceRange,
rating,
isLocalApproved,
latitude,
longitude,
openingHours,
images
}
try {
if (id && id !== 'new') {
await mockDb.updateListing(id, data)
} else {
await mockDb.createListing(data)
}
revalidatePath('/admin/listings')
revalidatePath('/restoranlar')
revalidatePath('/apartlar')
revalidatePath('/isletmeler')
return { success: true }
} catch (err: any) {
return { success: false, error: err.message || 'Mekan kaydedilemedi.' }
}
}
+3
View File
@@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth"
export const { GET, POST } = handlers
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+153
View File
@@ -0,0 +1,153 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-golos);
--font-mono: var(--font-mono);
--font-heading: var(--font-unbounded);
--color-pine: var(--pine);
--color-turquoise: var(--turquoise);
--color-shutter: var(--shutter);
--color-gold: var(--gold);
--color-bougainvillea: var(--bougainvillea);
--color-stone: var(--stone);
--color-stone-deep: var(--stone-deep);
--color-paper: var(--paper);
--color-ink: var(--ink);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--stone: #EDEEE3;
--stone-deep: #E2E4D5;
--pine: #123238;
--turquoise: #2E9C9A;
--shutter: #4F7C93;
--gold: #E8A23D;
--bougainvillea: #E85D6E;
--ink: #21231F;
--paper: #FBFAF6;
/* shadcn mappings */
--background: var(--stone);
--foreground: var(--ink);
--card: var(--paper);
--card-foreground: var(--ink);
--popover: var(--paper);
--popover-foreground: var(--ink);
--primary: var(--pine);
--primary-foreground: var(--stone);
--secondary: var(--turquoise);
--secondary-foreground: var(--paper);
--muted: var(--stone-deep);
--muted-foreground: var(--shutter);
--accent: var(--turquoise);
--accent-foreground: var(--paper);
--destructive: oklch(0.577 0.245 27.325);
--border: rgba(18, 50, 56, 0.08);
--input: rgba(18, 50, 56, 0.08);
--ring: var(--turquoise);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.75rem;
--sidebar: var(--paper);
--sidebar-foreground: var(--ink);
--sidebar-primary: var(--pine);
--sidebar-primary-foreground: var(--stone);
--sidebar-accent: var(--stone);
--sidebar-accent-foreground: var(--pine);
--sidebar-border: rgba(18, 50, 56, 0.08);
--sidebar-ring: var(--turquoise);
}
.dark {
--background: #123238;
--foreground: #EDEEE3;
--card: #21231F;
--card-foreground: #EDEEE3;
--popover: #21231F;
--popover-foreground: #EDEEE3;
--primary: #2E9C9A;
--primary-foreground: #123238;
--secondary: #4F7C93;
--secondary-foreground: #EDEEE3;
--muted: #123238;
--muted-foreground: #4F7C93;
--accent: #2E9C9A;
--accent-foreground: #123238;
--destructive: oklch(0.704 0.191 22.216);
--border: rgba(237, 238, 227, 0.1);
--input: rgba(237, 238, 227, 0.1);
--ring: #2E9C9A;
--sidebar: #21231F;
--sidebar-foreground: #EDEEE3;
--sidebar-primary: #2E9C9A;
--sidebar-primary-foreground: #123238;
--sidebar-accent: #123238;
--sidebar-accent-foreground: #EDEEE3;
--sidebar-border: rgba(237, 238, 227, 0.1);
--sidebar-ring: #2E9C9A;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
h1, h2, h3, h4, h5, h6 {
@apply font-heading;
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+103
View File
@@ -0,0 +1,103 @@
import { Link } from '@/i18n/routing'
import { useTranslations } from 'next-intl'
export default function Footer() {
const t = useTranslations('footer')
const nav = useTranslations('nav')
return (
<footer className="bg-pine text-stone/70 border-t border-white/10 pt-16 pb-8 font-sans">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 md:grid-cols-4 gap-10 mb-12">
{/* Logo & Info */}
<div className="md:col-span-2 space-y-4">
<Link href="/" className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full border-2 border-stone flex items-center justify-center relative bg-paper shrink-0">
<div className="absolute inset-[2.5px] rounded-full border border-dashed border-turquoise" />
<span className="font-heading font-extrabold text-pine text-xs tracking-tighter">ML</span>
</div>
<h2 className="font-heading font-extrabold text-lg text-stone tracking-tight leading-none lowercase">
marmaris <span className="text-turquoise">local</span>
</h2>
</Link>
<p className="text-xs text-stone/50 max-w-sm font-medium leading-relaxed">
{t('about')}
</p>
</div>
{/* Quick Links */}
<div className="space-y-4">
<h3 className="font-heading font-semibold text-xs text-stone uppercase tracking-wider">
{nav('home')}
</h3>
<ul className="space-y-2 text-xs">
<li>
<Link href="/restoranlar" className="hover:text-turquoise transition-colors">
{nav('restaurants')}
</Link>
</li>
<li>
<Link href="/apartlar" className="hover:text-turquoise transition-colors">
{nav('aparts')}
</Link>
</li>
<li>
<Link href="/isletmeler" className="hover:text-turquoise transition-colors">
{nav('businesses')}
</Link>
</li>
</ul>
</div>
{/* Guidelines */}
<div className="space-y-4">
<h3 className="font-heading font-semibold text-xs text-stone uppercase tracking-wider">
Marmaris Local
</h3>
<ul className="space-y-2 text-xs">
<li>
<Link href="/hakkinda" className="hover:text-turquoise transition-colors">
{nav('about')}
</Link>
</li>
<li>
<Link href="/iletisim" className="hover:text-turquoise transition-colors">
{nav('contact')}
</Link>
</li>
<li>
<Link href="/isletme-ekle" className="hover:text-turquoise transition-colors">
{nav('addBusiness')}
</Link>
</li>
<li>
<Link href="/login" className="hover:text-turquoise transition-colors">
{nav('admin')}
</Link>
</li>
</ul>
</div>
</div>
{/* Bottom Copyright & Branding */}
<div className="border-t border-white/5 pt-8 flex flex-col sm:flex-row items-center justify-between text-xs text-stone/40 gap-4">
<p>{t('rights')}</p>
<div className="flex items-center gap-1.5 font-medium">
<span>Powered by</span>
<a
href="https://ayris.tech"
target="_blank"
rel="noopener noreferrer"
className="text-stone hover:text-turquoise transition-colors border-b border-stone/20 hover:border-turquoise pb-0.5"
>
ayris.tech
</a>
</div>
</div>
</div>
</footer>
)
}
+146
View File
@@ -0,0 +1,146 @@
import Image from 'next/image'
import { Link } from '@/i18n/routing'
import { useLocale } from 'next-intl'
import { Star } from 'lucide-react'
export interface Gallery {
id: string
url: string
}
export interface Category {
slug: string
nameTr: string
nameEn: string
nameRu: string
}
export interface Neighborhood {
slug: string
nameTr: string
nameEn: string
nameRu: string
}
export interface Listing {
id: string
slug: string
categoryId: string
neighborhoodId: string
nameTr: string
nameEn: string
nameRu: string
descriptionTr: string
descriptionEn: string
descriptionRu: string
address: string
priceRange: number
rating?: number | null
isLocalApproved: boolean
images: Gallery[]
category?: Category
neighborhood?: Neighborhood
}
export default function ListingCard({ listing }: { listing: Listing }) {
const locale = useLocale()
const name =
locale === 'ru'
? listing.nameRu
: locale === 'en'
? listing.nameEn
: listing.nameTr
const description =
locale === 'ru'
? listing.descriptionRu
: locale === 'en'
? listing.descriptionEn
: listing.descriptionTr
const categoryName = listing.category
? locale === 'ru'
? listing.category.nameRu
: locale === 'en'
? listing.category.nameEn
: listing.category.nameTr
: ''
const neighborhoodName = listing.neighborhood
? locale === 'ru'
? listing.neighborhood.nameRu
: locale === 'en'
? listing.neighborhood.nameEn
: listing.neighborhood.nameTr
: ''
const priceSymbols = '₺'.repeat(listing.priceRange)
const categorySlug = listing.category?.slug || 'isletme'
const mainImageUrl =
listing.images && listing.images.length > 0
? listing.images[0].url
: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&auto=format&fit=crop&q=80'
return (
<Link
href={`/${categorySlug}/${listing.slug}`}
className="group bg-paper rounded-2xl border border-pine/8 overflow-hidden flex flex-col relative shadow-sm hover:shadow-md hover:border-turquoise/35 transition-all duration-300 transform hover:-translate-y-0.5"
>
{/* Local Approved Seal */}
{listing.isLocalApproved && (
<div className="absolute top-4 right-4 z-10 w-[52px] h-[52px] rounded-full border-[1.5px] border-turquoise bg-paper flex items-center justify-center -rotate-12 shadow-sm shrink-0">
<div className="absolute inset-[2.5px] rounded-full border border-dashed border-turquoise/60" />
<span className="font-mono text-[7px] text-center font-bold text-turquoise tracking-tight leading-[1.1] uppercase">
YEREL<br />ONAYLI
</span>
</div>
)}
{/* Image Preview */}
<div className="aspect-[4/3] w-full relative bg-stone-deep overflow-hidden">
<Image
src={mainImageUrl}
alt={name}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-gradient-to-t from-pine/30 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
</div>
{/* Content Details */}
<div className="p-5 flex-1 flex flex-col justify-between">
<div>
{/* Category */}
<div className="text-[10px] font-mono text-bougainvillea font-bold uppercase tracking-wider mb-2">
{categoryName}
</div>
{/* Title */}
<h3 className="font-heading font-bold text-lg text-pine leading-tight mb-2 group-hover:text-turquoise transition-colors line-clamp-1">
{name}
</h3>
{/* Neighborhood */}
<div className="text-xs text-ink/60 font-medium mb-4">
{neighborhoodName}
</div>
</div>
{/* Footer info (price range, rating) */}
<div className="border-t border-dashed border-pine/12 pt-3.5 mt-auto flex items-center justify-between text-xs font-mono">
<span className="text-pine font-semibold">{priceSymbols}</span>
{listing.rating && (
<div className="flex items-center gap-1 text-gold font-bold">
<Star className="w-3.5 h-3.5 fill-gold stroke-gold" />
<span> {listing.rating.toFixed(1)}</span>
</div>
)}
</div>
</div>
</Link>
)
}
+192
View File
@@ -0,0 +1,192 @@
'use client'
import { useState } from 'react'
import { Link, usePathname, useRouter } from '@/i18n/routing'
import { useTranslations, useLocale } from 'next-intl'
import { Menu, X, Globe, PlusCircle } from 'lucide-react'
export default function Navbar() {
const t = useTranslations('nav')
const activeLocale = useLocale()
const pathname = usePathname()
const router = useRouter()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [langMenuOpen, setLangMenuOpen] = useState(false)
const languages = [
{ code: 'tr', label: 'Türkçe' },
{ code: 'en', label: 'English' },
{ code: 'ru', label: 'Русский' }
]
const navItems = [
{ name: t('home'), href: '/' },
{ name: t('restaurants'), href: '/restoranlar' },
{ name: t('aparts'), href: '/apartlar' },
{ name: t('businesses'), href: '/isletmeler' },
{ name: t('about'), href: '/hakkinda' },
{ name: t('contact'), href: '/iletisim' }
]
const handleLanguageChange = (localeCode: string) => {
setLangMenuOpen(false)
router.replace(pathname, { locale: localeCode })
}
return (
<header className="bg-pine text-stone border-b border-white/10 sticky top-0 z-40">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-20">
{/* Logo & Wordmark */}
<Link href="/" className="flex items-center gap-3 group">
<div className="w-11 h-11 rounded-full border-2 border-stone flex items-center justify-center relative bg-paper shrink-0 shadow-sm transition-transform group-hover:scale-105">
<div className="absolute inset-[3px] rounded-full border border-dashed border-turquoise" />
<span className="font-heading font-extrabold text-pine text-sm tracking-tighter">ML</span>
</div>
<div className="hidden sm:block">
<h1 className="font-heading font-extrabold text-lg text-stone tracking-tight leading-none lowercase">
marmaris <span className="text-turquoise">local</span>
</h1>
<p className="text-[9px] font-mono text-shutter tracking-wider mt-0.5 uppercase">
local knowledge
</p>
</div>
</Link>
{/* Desktop Navigation */}
<nav className="hidden lg:flex items-center gap-6">
{navItems.map((item) => {
const isActive = pathname === item.href
return (
<Link
key={item.name}
href={item.href}
className={`text-sm font-medium transition-colors hover:text-turquoise ${
isActive ? 'text-turquoise font-semibold' : 'text-stone/80'
}`}
>
{item.name}
</Link>
)
})}
</nav>
{/* Action Items */}
<div className="hidden lg:flex items-center gap-4">
{/* Language Selector */}
<div className="relative">
<button
onClick={() => setLangMenuOpen(!langMenuOpen)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-stone/20 hover:border-turquoise transition text-xs font-mono"
>
<Globe className="w-3.5 h-3.5 text-turquoise" />
{activeLocale.toUpperCase()}
</button>
{langMenuOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setLangMenuOpen(false)} />
<div className="absolute right-0 mt-2 w-36 rounded-xl bg-paper text-ink shadow-lg ring-1 ring-black/5 z-20 py-1.5 overflow-hidden">
{languages.map((lang) => (
<button
key={lang.code}
onClick={() => handleLanguageChange(lang.code)}
className={`w-full text-left px-4 py-2 text-xs hover:bg-stone transition flex items-center justify-between ${
activeLocale === lang.code ? 'font-bold text-turquoise bg-stone/40' : 'text-ink/80'
}`}
>
{lang.label}
<span className="font-mono text-[10px] text-shutter">{lang.code.toUpperCase()}</span>
</button>
))}
</div>
</>
)}
</div>
{/* Add Business Button */}
<Link
href="/isletme-ekle"
className="flex items-center gap-1.5 bg-turquoise hover:bg-turquoise/90 text-paper font-medium text-xs py-2 px-4 rounded-full transition-transform active:scale-95 shadow-sm"
>
<PlusCircle className="w-3.5 h-3.5" />
{t('addBusiness')}
</Link>
</div>
{/* Mobile menu button */}
<div className="flex items-center gap-3 lg:hidden">
{/* Lang menu for mobile */}
<div className="relative">
<button
onClick={() => setLangMenuOpen(!langMenuOpen)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-full border border-stone/20 text-xs font-mono"
>
<Globe className="w-3.5 h-3.5 text-turquoise" />
{activeLocale.toUpperCase()}
</button>
{langMenuOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setLangMenuOpen(false)} />
<div className="absolute right-0 mt-2 w-32 rounded-xl bg-paper text-ink shadow-lg ring-1 ring-black/5 z-20 py-1">
{languages.map((lang) => (
<button
key={lang.code}
onClick={() => handleLanguageChange(lang.code)}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-stone"
>
{lang.label}
</button>
))}
</div>
</>
)}
</div>
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="text-stone hover:text-turquoise focus:outline-none"
>
{mobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
</div>
</div>
</div>
{/* Mobile Menu */}
{mobileMenuOpen && (
<div className="lg:hidden border-t border-white/10 bg-pine py-4 px-4 space-y-3">
<nav className="flex flex-col gap-3">
{navItems.map((item) => {
const isActive = pathname === item.href
return (
<Link
key={item.name}
href={item.href}
onClick={() => setMobileMenuOpen(false)}
className={`text-sm font-medium py-2 px-3 rounded-lg transition-colors ${
isActive ? 'bg-white/10 text-turquoise' : 'text-stone/80 hover:bg-white/5'
}`}
>
{item.name}
</Link>
)
})}
</nav>
<div className="pt-4 border-t border-white/10">
<Link
href="/isletme-ekle"
onClick={() => setMobileMenuOpen(false)}
className="w-full flex items-center justify-center gap-2 bg-turquoise hover:bg-turquoise/90 text-paper font-medium py-3 rounded-lg text-sm transition"
>
<PlusCircle className="w-4 h-4" />
{t('addBusiness')}
</Link>
</div>
</div>
)}
</header>
)
}
+58
View File
@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+103
View File
@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+398
View File
@@ -0,0 +1,398 @@
<!DOCTYPE html>
<html lang="tr">
<head>
<meta charset="UTF-8">
<title>Marmaris Local — Marka Yönü</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;600;800&family=Golos+Text:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap"
rel="stylesheet">
<style>
:root {
--stone: #EDEEE3;
--stone-deep: #E2E4D5;
--pine: #123238;
--turquoise: #2E9C9A;
--shutter: #4F7C93;
--gold: #E8A23D;
--bougainvillea: #E85D6E;
--ink: #21231F;
--paper: #FBFAF6;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background: var(--stone);
color: var(--ink);
font-family: 'Golos Text', sans-serif;
padding: 64px 24px 100px;
}
.wrap {
max-width: 980px;
margin: 0 auto;
}
/* ---- Header / wordmark ---- */
.brandhead {
display: flex;
align-items: center;
gap: 28px;
margin-bottom: 80px;
}
.seal {
width: 92px;
height: 92px;
border-radius: 50%;
border: 2.5px solid var(--pine);
display: flex;
align-items: center;
justify-content: center;
position: relative;
flex-shrink: 0;
background: var(--paper);
}
.seal::before {
content: "";
position: absolute;
inset: 7px;
border-radius: 50%;
border: 1px dashed var(--turquoise);
}
.seal-mark {
font-family: 'Unbounded', sans-serif;
font-weight: 800;
font-size: 22px;
color: var(--pine);
letter-spacing: -0.5px;
}
.wordmark h1 {
font-family: 'Unbounded', sans-serif;
font-weight: 800;
font-size: 40px;
letter-spacing: -1px;
color: var(--pine);
text-transform: lowercase;
}
.wordmark p {
font-family: 'IBM Plex Mono', monospace;
font-size: 13px;
color: var(--shutter);
margin-top: 6px;
letter-spacing: 0.3px;
}
section {
margin-bottom: 72px;
}
.label {
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--shutter);
margin-bottom: 18px;
display: block;
}
/* ---- Palette ---- */
.palette {
display: flex;
gap: 14px;
flex-wrap: wrap;
}
.swatch {
width: 148px;
border-radius: 14px;
overflow: hidden;
box-shadow: 0 1px 2px rgba(18, 50, 56, 0.08);
}
.swatch .fill {
height: 84px;
}
.swatch .meta {
background: var(--paper);
padding: 10px 12px;
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
}
.swatch .meta .name {
font-family: 'Golos Text', sans-serif;
font-weight: 600;
font-size: 13px;
margin-bottom: 3px;
}
/* ---- Type specimen ---- */
.type-block {
background: var(--paper);
border-radius: 20px;
padding: 44px;
}
.type-block .display {
font-family: 'Unbounded', sans-serif;
font-weight: 700;
font-size: 42px;
line-height: 1.12;
letter-spacing: -1px;
color: var(--pine);
margin-bottom: 10px;
}
.type-block .display.ru {
font-weight: 600;
font-size: 36px;
color: var(--turquoise);
margin-bottom: 28px;
}
.type-block .body-sample {
font-family: 'Golos Text', sans-serif;
font-size: 16px;
line-height: 1.6;
color: var(--ink);
max-width: 560px;
opacity: 0.85;
}
.type-note {
display: flex;
gap: 32px;
margin-top: 28px;
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
color: var(--shutter);
flex-wrap: wrap;
}
/* ---- Cards ---- */
.cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.card {
background: var(--paper);
border-radius: 18px;
padding: 22px;
position: relative;
border: 1px solid rgba(18, 50, 56, 0.08);
}
.card .stamp {
position: absolute;
top: 16px;
right: 16px;
width: 46px;
height: 46px;
border-radius: 50%;
border: 1.5px solid var(--turquoise);
display: flex;
align-items: center;
justify-content: center;
transform: rotate(-8deg);
}
.card .stamp span {
font-family: 'IBM Plex Mono', monospace;
font-size: 7.5px;
text-align: center;
line-height: 1.2;
color: var(--turquoise);
font-weight: 500;
}
.card .cat {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--bougainvillea);
margin-bottom: 10px;
}
.card h3 {
font-family: 'Unbounded', sans-serif;
font-size: 19px;
font-weight: 600;
color: var(--pine);
max-width: 75%;
line-height: 1.25;
margin-bottom: 10px;
}
.card .loc {
font-size: 13px;
color: var(--ink);
opacity: 0.65;
margin-bottom: 16px;
}
.card .foot {
display: flex;
justify-content: space-between;
align-items: center;
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
border-top: 1px dashed rgba(18, 50, 56, 0.15);
padding-top: 12px;
}
.card .price {
color: var(--pine);
font-weight: 500;
}
.card .gold {
color: var(--gold);
}
.signature-note {
background: var(--pine);
color: var(--stone);
border-radius: 20px;
padding: 32px 36px;
font-size: 14.5px;
line-height: 1.65;
}
.signature-note b {
color: var(--gold);
font-family: 'Golos Text';
}
</style>
</head>
<body>
<div class="wrap">
<div class="brandhead">
<div class="seal">
<div class="seal-mark">ML</div>
</div>
<div class="wordmark">
<h1>marmaris local</h1>
<p>YEREL BİLGİ · МЕСТНЫЕ ЗНАНИЯ · LOCAL KNOWLEDGE</p>
</div>
</div>
<section>
<span class="label">01 — Renk Paleti</span>
<div class="palette">
<div class="swatch">
<div class="fill" style="background:#123238"></div>
<div class="meta">
<div class="name">Pine Night</div>#123238
</div>
</div>
<div class="swatch">
<div class="fill" style="background:#2E9C9A"></div>
<div class="meta">
<div class="name">Bay Turquoise</div>#2E9C9A
</div>
</div>
<div class="swatch">
<div class="fill" style="background:#4F7C93"></div>
<div class="meta">
<div class="name">Shutter Blue</div>#4F7C93
</div>
</div>
<div class="swatch">
<div class="fill" style="background:#E8A23D"></div>
<div class="meta">
<div class="name">Golden Hour</div>#E8A23D
</div>
</div>
<div class="swatch">
<div class="fill" style="background:#E85D6E"></div>
<div class="meta">
<div class="name">Bougainvillea</div>#E85D6E
</div>
</div>
<div class="swatch">
<div class="fill" style="background:#EDEEE3"></div>
<div class="meta">
<div class="name">Limestone</div>#EDEEE3
</div>
</div>
</div>
</section>
<section>
<span class="label">02 — Tipografi</span>
<div class="type-block">
<div class="display">En iyi yerel adresler,<br>turistin göremediği yerde.</div>
<div class="display ru">Лучшие места, которые знают местные.</div>
<div class="body-sample">Golos Text — gövde metni. Hem Latin hem Kiril alfabesinde eşit ağırlıkta
okunur, çeviri hissi vermez. Restoran açıklamaları, işletme saatleri ve kullanıcı yorumları bu yazı
tipiyle yazılır.</div>
<div class="type-note">
<span>DISPLAY — Unbounded 800/600</span>
<span>BODY — Golos Text 400/500</span>
<span>UTILITY — IBM Plex Mono (fiyat, saat, telefon)</span>
</div>
</div>
</section>
<section>
<span class="label">03 — Kart Sistemi (imza öğe: "Yerel Onaylı" mührü)</span>
<div class="cards">
<div class="card">
<div class="stamp"><span>YEREL<br>ONAYLI</span></div>
<div class="cat">Restoran</div>
<h3>İskele Balık Ocakbaşı</h3>
<div class="loc">Yat Limanı, Marmaris</div>
<div class="foot"><span class="price">₺₺₺</span><span class="gold">★ 4.8</span></div>
</div>
<div class="card">
<div class="stamp"><span>YEREL<br>ONAYLI</span></div>
<div class="cat">Apart</div>
<h3>Zeytin Bahçesi Apart</h3>
<div class="loc">Sedir Mah., Marmaris</div>
<div class="foot"><span class="price">₺₺</span><span class="gold">★ 4.6</span></div>
</div>
<div class="card">
<div class="stamp"><span>YEREL<br>ONAYLI</span></div>
<div class="cat">Hizmet</div>
<h3>Marina Dalış Merkezi</h3>
<div class="loc">İçmeler, Marmaris</div>
<div class="foot"><span class="price">₺₺₺</span><span class="gold">★ 4.9</span></div>
</div>
</div>
</section>
<section>
<span class="label">04 — Neden bu yön</span>
<div class="signature-note">
Palet klasik "krem + turuncu" AI şablonundan kaçınıp doğrudan Marmaris'in kendi görsel dünyasından
geliyor: çam yeşiline kaçan koyu <b>Pine Night</b> zemin, körfezin <b>Bay Turquoise</b>'u imza rengi,
badem çiçeği pembesi <b>Bougainvillea</b> nadir vurgu olarak. <b>Unbounded</b> hem Latin hem Kiril'de
doğal duran, karaktersiz Inter/Manrope klişesine düşmeyen bir başlık fontu — Rus kullanıcıya "sonradan
çevrilmiş" değil, kendisi için tasarlanmış hissi verir. İmza öğe: her mekanın yanındaki daire mühür —
markanın vaadini ("yereldan onaylı") harf yerine görsel bir jestle taşıyor.
</div>
</section>
</div>
</body>
</html>
+225
View File
@@ -0,0 +1,225 @@
# PRD — Marmaris Local
**Domain:** marmarislocal.com
**Tarih:** 2026-07-12
**Durum:** MVP tanımı — geliştirme başka ortamda yapılacak
---
## 1. Proje Özeti
Marmaris Local, Marmaris'teki restoran, apart otel ve yerel işletmeleri (dalış merkezi, tekne kiralama, tur operatörü, transfer vb.) tek bir küratörlü rehberde toplayan, uluslararası turiste (öncelikle Rus ve İngiliz, ayrıca Türk) hitap eden çok dilli bir dizin/rehber sitesi.
Konumlandırma: "Turistin göremediği yerel bilgi." Genel bir Yelp/TripAdvisor klonu değil — her listelemenin bir kürasyon/onay katmanı var ("Yerel Onaylı" mührü), bu da markanın ana güven vaadi.
---
## 2. Marka Kimliği (özet — tam mockup ekte: `marmaris-local-brand.html`)
- **Palet:** Pine Night `#123238` (zemin/koyu), Bay Turquoise `#2E9C9A` (imza rengi), Shutter Blue `#4F7C93`, Golden Hour `#E8A23D` (puan/rozet), Bougainvillea `#E85D6E` (nadir vurgu, kategori etiketleri), Limestone `#EDEEE3` (açık zemin)
- **Tipografi:** Unbounded (başlık, 800/600 — Latin+Kiril), Golos Text (gövde — Latin+Kiril), IBM Plex Mono (fiyat/saat/telefon gibi pratik veri)
- **İmza öğe:** Dairesel "Yerel Onaylı" mührü — her kürasyona giren listelemenin yanında görünür, logo motifiyle aynı dil
---
## 3. Sayfa Envanteri / Site Haritası
```
/ → Anasayfa (öne çıkan listelemeler, kategori girişleri, arama)
/restoranlar → Restoran listesi (filtre: mahalle, mutfak, fiyat)
/apartlar → Apart/konaklama listesi
/isletmeler → Genel işletme listesi (dalış, tekne, tur, transfer, kiralama)
/[kategori]/[slug] → Tekil listeleme detay sayfası
/mahalle/[slug] → Mahalle bazlı liste (Yat Limanı, İçmeler, Armutalan vb.)
/isletme-ekle → İşletme sahibi başvuru formu
/hakkinda → Marka hikayesi, "Yerel Onaylı" nasıl çalışır
/iletisim → İletişim formu
/admin/* → Yönetim paneli (bkz. §8)
```
Tekil listeleme sayfası içeriği: galeri, açıklama (TR/EN/RU), adres + harita, çalışma saatleri, fiyat aralığı, telefon/WhatsApp, "Yerel Onaylı" rozeti (varsa), kategori etiketleri, benzer listelemeler.
---
## 4. Dil Seçenekleri
`next-intl` locale listesi: **tr, en, ru**
- Varsayılan: `tr` (yerel SEO ve Türk kullanıcı için)
- Öncelik sırası: TR → EN → RU (RU içerik girişi başta makine çevirisi + manuel düzeltme olabilir, MVP'de tüm içerik 3 dilde eksiksiz olmalı — Rus turist kitlesi ana hedef olduğu için RU içerik EN kadar özenli olmalı, "sonradan eklenmiş" hissi vermemeli)
- Fiyat/saat gibi sayısal veriler dil bağımsız (IBM Plex Mono ile gösterilir, çeviri gerektirmez)
---
## 5. İçerik Modelleri (Prisma)
| Model | Açıklama |
|---|---|
| `Listing` | Ana varlık — restoran/apart/işletme hepsi bu modelde, `category` alanıyla ayrılır |
| `Category` | Restoran, Apart, Dalış, Tekne Kiralama, Tur Operatörü, Transfer, Araç Kiralama vb. |
| `Neighborhood` | Yat Limanı, İçmeler, Armutalan, Siteler, Turunç vb. |
| `BusinessSubmission` | `/isletme-ekle` formundan gelen, admin onayı bekleyen başvurular |
| `ContactMessage` | `/iletisim` formundan gelen genel mesajlar |
| `Gallery` | Listeleme başına çoklu görsel (Cloudinary) |
### Listing alanları
```prisma
model Listing {
id String @id @default(cuid())
slug String @unique
categoryId String
neighborhoodId String
nameTr String
nameEn String
nameRu String
descriptionTr String
descriptionEn String
descriptionRu String
address String
phone String?
whatsapp String?
website String?
instagram String?
priceRange Int // 1-3 (₺ / ₺₺ / ₺₺₺)
rating Float? // 0-5, admin girişli (MVP'de kullanıcı yorumu yok)
isLocalApproved Boolean @default(false) // "Yerel Onaylı" mührü
latitude Float?
longitude Float?
openingHours Json? // gün bazlı saat aralıkları
images Gallery[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
category Category @relation(fields: [categoryId], references: [id])
neighborhood Neighborhood @relation(fields: [neighborhoodId], references: [id])
}
```
**Not:** MVP'de kullanıcı yorumu/puanlama yok — `rating` admin tarafından manuel giriliyor (küratörlü rehber mantığına uygun). Kullanıcı yorumları P2'de değerlendirilebilir (bkz. §11).
---
## 6. "İşletme Ekle" Başvuru Formu Alanları
`/isletme-ekle` — işletme sahiplerinin kendi mekanlarını öneri olarak gönderdiği form:
- İşletme adı
- Kategori (dropdown → `Category`)
- Mahalle (dropdown → `Neighborhood`)
- Adres
- Telefon / WhatsApp
- Kısa açıklama (tek dilde yeterli, admin çevirip yayınlıyor)
- İletişim eden kişinin adı + e-postası
- Görsel yükleme (opsiyonel, Cloudinary)
`BusinessSubmission` tablosuna düşer, admin panelden onaylanınca `Listing`'e dönüştürülür (manuel "Onayla ve Listeye Ekle" aksiyonu — otomatik değil, kürasyon burada gerçekleşiyor).
---
## 7. İletişim Formu Alanları (`/iletisim`)
- Ad Soyad
- E-posta
- Konu (dropdown: Genel, İşbirliği, Hata Bildirimi, Diğer)
- Mesaj
`ContactMessage` tablosu, admin panelden okunur/işaretlenir.
---
## 8. Admin / Yönetim İhtiyaçları
```
/admin → Dashboard (toplam listeleme, kategori bazlı sayı, bekleyen başvuru sayısı)
/admin/listings → Liste + CRUD (kategori, mahalle filtreli)
/admin/listings/[id] → Düzenleme formu (3 dilde içerik alanları, görsel yönetimi, "Yerel Onaylı" toggle)
/admin/submissions → Bekleyen işletme başvuruları → Onayla (Listing'e dönüştür) / Reddet
/admin/messages → İletişim mesajları
/admin/categories → Kategori yönetimi
/admin/neighborhoods → Mahalle yönetimi
/admin/users → Kullanıcı yönetimi (sabit)
```
Auth: NextAuth (credentials, default) — tek admin rolü yeterli, MVP'de çoklu yetki seviyesi gerekmiyor.
---
## 9. Arama & Filtreleme
- Anasayfa arama kutusu: isim + kategori + mahalle üzerinden basit metin araması
- Kategori sayfalarında filtre: mahalle, fiyat aralığı, sadece "Yerel Onaylı" olanlar
- Harita görünümü: MVP'de opsiyonel (P1) — Leaflet + OpenStreetMap (Google Maps API maliyeti yerine)
---
## 10. Sosyal Medya / 3rd Party — Env Değişkenleri
```env
CLOUDINARY_CLOUD_NAME=""
CLOUDINARY_API_KEY=""
CLOUDINARY_API_SECRET=""
NEXT_PUBLIC_INSTAGRAM_URL=""
NEXT_PUBLIC_WHATSAPP_NUMBER="" # site geneli iletişim WhatsApp
RESEND_API_KEY="" # iletişim formu bildirimleri için
```
Harita için P1'de `NEXT_PUBLIC_MAPBOX_TOKEN` veya Leaflet (ücretsiz, tercih edilir) değerlendirilecek.
---
## 11. SEO & Uluslararası Notlar
- `hreflang` etiketleri tr/en/ru için eksiksiz olmalı — Google'ın doğru dil versiyonunu doğru kullanıcıya göstermesi kritik
- RU içerik gerçek çeviri kalitesinde olmalı, otomatik çeviri kokan metin marka güvenini zedeler (bkz. marka notunda "sonradan çevrilmiş hissi vermemeli")
- Her `Listing` için yapılandırılmış veri (schema.org `LocalBusiness`/`Restaurant`) eklenmeli — Google'da zengin sonuç (rating, adres, fiyat) için
- Sayfa başlıkları ve meta açıklamalar 3 dilde ayrı üretilmeli, tek dilden otomatik türetilmemeli
---
## 12. Teknik Notlar
- Çok-şehirli genişleme ihtimaline karşı: `Listing` modelinde şimdiden bir `city` alanı (MVP'de sabit `"marmaris"`) bulunmalı — ileride Fethiye/Bodrum/Ören/Datça eklenirse migration basit olsun
- Görseller Cloudinary, klasörleme: `marmarislocal/{category}/{listingSlug}/`
- Mock data: MVP gösterimi için en az 3 restoran, 2 apart, 3 işletme — gerçekçi TR/EN/RU içerikle (Unsplash görselli, mevcut demo-site kuralına uygun)
- Standart: soft delete (`deletedAt`), `USE_MOCK` env flag, `requireAdmin()` tüm admin route'larında — mevcut altyapı kurallarıyla birebir uyumlu
---
## 13. MVP Kapsamı
**P0 (MVP'siz olmaz):**
- Listing/Category/Neighborhood modelleri + 3 dilli içerik
- Kategori ve mahalle bazlı listeleme sayfaları + tekil detay sayfası
- "Yerel Onaylı" rozet sistemi
- İşletme ekle formu + admin onay akışı
- İletişim formu
- Admin panel (CRUD + başvuru onayı)
- TR/EN/RU dil desteği
**P1 (hızlı takip):**
- Harita görünümü (Leaflet)
- Gelişmiş filtreleme (fiyat aralığı, açık/kapalı durumu)
- Instagram feed entegrasyonu (anasayfa)
**P2 (gelecek, mimariyi şimdiden zorlamasın):**
- Kullanıcı yorumu/puanlama sistemi
- Çok-şehir desteği (Fethiye Local, Bodrum Local vb. — aynı codebase, `city` filtresiyle)
- İşletme sahibi kendi paneline giriş yapıp kendi listesini güncelleyebilsin (şu an sadece admin düzenliyor)
---
## 14. Açık Sorular
- [ ] Harita için Leaflet/OSM mi yoksa Google Maps mi tercih edilecek? (maliyet vs. tanıdıklık — Rus/İngiliz turist Google Maps'e daha alışkın olabilir)
- [ ] RU içerik çevirisi kim yapacak — profesyonel çeviri mi, DeepSeek/Gemini ile üretim + manuel kontrol mü?
- [ ] "Yerel Onaylı" kriterleri net bir liste halinde tanımlanacak mı (editoryal tutarlılık için), yoksa şimdilik sübjektif admin kararı mı?
- [ ] İlk yayın için hedef listeleme sayısı ve hangi kategoriden başlanacağı (öneri: restoranla başlamak, en yüksek arama hacmi orada)
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+15
View File
@@ -0,0 +1,15 @@
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 any)) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default
};
});
+10
View File
@@ -0,0 +1,10 @@
import {defineRouting} from 'next-intl/routing';
import {createNavigation} from 'next-intl/navigation';
export const routing = defineRouting({
locales: ['en', 'tr', 'ru'],
defaultLocale: 'tr'
});
export const {Link, redirect, usePathname, useRouter, getPathname} =
createNavigation(routing);
+47
View File
@@ -0,0 +1,47 @@
import NextAuth from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
// Boilerplate mock logic
// TODO: In production, lookup user in Prisma and verify password using bcrypt
// const user = await db.user.findUnique({ where: { email: credentials.email } })
if (credentials?.email === "admin@ayris.tech" && credentials?.password === "admin") {
return {
id: "1",
name: "Admin User",
email: "admin@ayris.tech",
role: "ADMIN"
}
}
return null
}
})
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = (user as any).role
}
return token
},
async session({ session, token }) {
if (session.user && token.role) {
(session.user as any).role = token.role
}
return session
}
},
pages: {
signIn: '/login'
}
})
+20
View File
@@ -0,0 +1,20 @@
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) {
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) {
await cloudinary.uploader.destroy(publicId)
}
export { cloudinary }
+9
View File
@@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const db = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db
+675
View File
@@ -0,0 +1,675 @@
import { db } from './db'
export interface Category {
id: string
slug: string
nameTr: string
nameEn: string
nameRu: string
}
export interface Neighborhood {
id: string
slug: string
nameTr: string
nameEn: string
nameRu: string
}
export interface Gallery {
id: string
listingId: string
url: string
}
export interface Listing {
id: string
slug: string
categoryId: string
neighborhoodId: string
city: string
nameTr: string
nameEn: string
nameRu: string
descriptionTr: string
descriptionEn: string
descriptionRu: string
address: string
phone?: string | null
whatsapp?: string | null
website?: string | null
instagram?: string | null
priceRange: number
rating?: number | null
isLocalApproved: boolean
latitude?: number | null
longitude?: number | null
openingHours?: any | null
images: Gallery[]
category?: Category
neighborhood?: Neighborhood
createdAt: Date
updatedAt: Date
deletedAt?: Date | null
}
export interface BusinessSubmission {
id: string
businessName: string
categoryId: string
neighborhoodId: string
address: string
phone?: string | null
whatsapp?: string | null
description: string
contactName: string
contactEmail: string
imageUrl?: string | null
status: string
createdAt: Date
updatedAt: Date
}
export interface ContactMessage {
id: string
name: string
email: string
subject: string
message: string
isRead: boolean
createdAt: Date
updatedAt: Date
}
const globalForMockDb = globalThis as unknown as {
categories: Category[]
neighborhoods: Neighborhood[]
listings: Listing[]
submissions: BusinessSubmission[]
messages: ContactMessage[]
initialized: boolean
}
if (!globalForMockDb.initialized) {
globalForMockDb.categories = [
{ id: 'cat-1', slug: 'restoran', nameTr: 'Restoran', nameEn: 'Restaurant', nameRu: 'Ресторан' },
{ id: 'cat-2', slug: 'apart', nameTr: 'Apart', nameEn: 'Apart Hotel', nameRu: 'Апарт-отель' },
{ id: 'cat-3', slug: 'isletme', nameTr: 'İşletme & Hizmet', nameEn: 'Business & Service', nameRu: 'Бизнес и Услуги' }
]
globalForMockDb.neighborhoods = [
{ id: 'neigh-1', slug: 'yat-limani', nameTr: 'Yat Limanı', nameEn: 'Marina', nameRu: 'Марина' },
{ id: 'neigh-2', slug: 'icmeler', nameTr: 'İçmeler', nameEn: 'Icmeler', nameRu: 'Ичмелер' },
{ id: 'neigh-3', slug: 'armutalan', nameTr: 'Armutalan', nameEn: 'Armutalan', nameRu: 'Армуталан' },
{ id: 'neigh-4', slug: 'siteler', nameTr: 'Siteler', nameEn: 'Siteler', nameRu: 'Сителер' },
{ id: 'neigh-5', slug: 'turunc', nameTr: 'Turunç', nameEn: 'Turunc', nameRu: 'Турунч' }
]
globalForMockDb.submissions = []
globalForMockDb.messages = []
// Pre-populate with realistic Marmaris data
globalForMockDb.listings = [
{
id: 'list-1',
slug: 'iskele-balik-ocakbasi',
categoryId: 'cat-1',
neighborhoodId: 'neigh-1',
city: 'marmaris',
nameTr: 'İskele Balık Ocakbaşı',
nameEn: 'Iskele Fish & Grill',
nameRu: 'Рыбный Гриль Искеле',
descriptionTr: 'Yat Limanı\'nda taze Ege balıkları ve geleneksel meze çeşitleriyle yerel lezzet durağınız. Mükemmel körfez manzarası eşliğinde akşam yemeği.',
descriptionEn: 'Your local taste stop at the Marina with fresh Aegean fish and traditional appetizers. Dinner accompanied by excellent bay views.',
descriptionRu: 'Ваша местная гастрономическая остановка в Марине со свежей эгейской рыбой и традиционными закусками. Ужин в сопровождении великолепного вида на залив.',
address: 'Yat Limanı No:12, Marmaris',
phone: '+90 252 412 34 56',
whatsapp: '+90 532 123 45 67',
website: 'https://iskelemarmaris.com',
instagram: 'https://instagram.com/iskele_marmaris',
priceRange: 3,
rating: 4.8,
isLocalApproved: true,
latitude: 36.8524,
longitude: 28.2741,
openingHours: { all: '12:00 - 00:00' },
images: [
{ id: 'img-1-1', listingId: 'list-1', url: 'https://images.unsplash.com/photo-1519708227418-c8fd9a32b7a2?w=800&auto=format&fit=crop&q=80' },
{ id: 'img-1-2', listingId: 'list-1', url: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&auto=format&fit=crop&q=80' }
],
createdAt: new Date(),
updatedAt: new Date()
},
{
id: 'list-2',
slug: 'mavi-beyaz-restoran',
categoryId: 'cat-1',
neighborhoodId: 'neigh-1',
city: 'marmaris',
nameTr: 'Mavi Beyaz Restoran',
nameEn: 'Blue White Restaurant',
nameRu: 'Ресторан Сине-Белый',
descriptionTr: 'Ege kıyılarının esintisini taşıyan, deniz ürünleri ağırlıklı menüsü ve gün batımı eşliğindeki eşsiz mezeleriyle ünlüdür.',
descriptionEn: 'Famous for its seafood-oriented menu carrying the breeze of the Aegean coasts and unique appetizers accompanied by sunset.',
descriptionRu: 'Славится своим меню из морепродуктов, несущим бриз Эгейского побережья, и уникальными закусками на фоне заката.',
address: 'Kordon Caddesi No:45, Yat Limanı, Marmaris',
phone: '+90 252 412 78 90',
whatsapp: '+90 533 987 65 43',
website: 'https://mavibeyazmarmaris.com',
instagram: 'https://instagram.com/mavibeyaz_marmaris',
priceRange: 2,
rating: 4.5,
isLocalApproved: true,
latitude: 36.8530,
longitude: 28.2725,
openingHours: { all: '11:00 - 23:30' },
images: [
{ id: 'img-2-1', listingId: 'list-2', url: 'https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=800&auto=format&fit=crop&q=80' }
],
createdAt: new Date(),
updatedAt: new Date()
},
{
id: 'list-3',
slug: 'dostlar-kebap',
categoryId: 'cat-1',
neighborhoodId: 'neigh-3',
city: 'marmaris',
nameTr: 'Dostlar Kebap',
nameEn: 'Dostlar Kebab House',
nameRu: 'Кебаб Хаус Достлар',
descriptionTr: 'Marmaris Armutalan\'da yıllardır değişmeyen lezzetiyle gerçek ocakbaşı ve kebap deneyimi sunan samimi bir mahalle restoranı.',
descriptionEn: 'A cozy neighborhood restaurant offering real grill and kebab experience with its unchanged taste for years in Armutalan, Marmaris.',
descriptionRu: 'Уютный районный ресторан, предлагающий настоящий гриль и кебаб с неизменным вкусом на протяжении многих лет в Армуталане, Мармарис.',
address: 'Vatan Caddesi No:18, Armutalan, Marmaris',
phone: '+90 252 413 11 22',
whatsapp: null,
website: null,
instagram: 'https://instagram.com/dostlarkebap_marmaris',
priceRange: 1,
rating: 4.7,
isLocalApproved: false,
latitude: 36.8488,
longitude: 28.2450,
openingHours: { all: '11:00 - 22:00' },
images: [
{ id: 'img-3-1', listingId: 'list-3', url: 'https://images.unsplash.com/photo-1544025162-d76694265947?w=800&auto=format&fit=crop&q=80' }
],
createdAt: new Date(),
updatedAt: new Date()
},
{
id: 'list-4',
slug: 'zeytin-bahcesi-apart',
categoryId: 'cat-2',
neighborhoodId: 'neigh-3',
city: 'marmaris',
nameTr: 'Zeytin Bahçesi Apart',
nameEn: 'Olive Garden Apart',
nameRu: 'Апарт Оливковый Сад',
descriptionTr: 'Zeytin ağaçları arasında, sakin ve huzurlu bir ortamda ailece tatil yapmak isteyenler için tasarlanmış geniş mutfaklı apart daireler.',
descriptionEn: 'Spacious apart apartments with kitchens designed for families who want to have a holiday in a quiet and peaceful environment among olive trees.',
descriptionRu: 'Просторные апартаменты с кухнями, предназначенные для семей, желающих отдохнуть в тихой и спокойной обстановке среди оливковых деревьев.',
address: 'Zeytinlik Sokak No:5, Armutalan, Marmaris',
phone: '+90 252 413 55 66',
whatsapp: '+90 535 555 66 77',
website: 'https://zeytinbahcesiapart.com',
instagram: null,
priceRange: 2,
rating: 4.6,
isLocalApproved: true,
latitude: 36.8510,
longitude: 28.2415,
openingHours: null,
images: [
{ id: 'img-4-1', listingId: 'list-4', url: 'https://images.unsplash.com/photo-1566073771259-6a8506099945?w=800&auto=format&fit=crop&q=80' },
{ id: 'img-4-2', listingId: 'list-4', url: 'https://images.unsplash.com/photo-1520250497591-112f2f40a3f4?w=800&auto=format&fit=crop&q=80' }
],
createdAt: new Date(),
updatedAt: new Date()
},
{
id: 'list-5',
slug: 'deniz-apart',
categoryId: 'cat-2',
neighborhoodId: 'neigh-4',
city: 'marmaris',
nameTr: 'Deniz Apart',
nameEn: 'Sea Apart Hotel',
nameRu: 'Апарт-отель Дениз',
descriptionTr: 'Denize sadece 100 metre mesafede, bütçe dostu fiyatları ve güler yüzlü yerel işletme sahibiyle Marmaris\'te sıcak konaklama.',
descriptionEn: 'Cozy accommodation in Marmaris, just 100 meters from the sea, with budget-friendly prices and a friendly local owner.',
descriptionRu: 'Уютное жилье в Мармарисе, всего в 100 метрах от моря, с доступными ценами и дружелюбным местным владельцем.',
address: 'Sahil Yolu Caddesi No:88, Siteler, Marmaris',
phone: '+90 252 417 88 99',
whatsapp: null,
website: null,
instagram: null,
priceRange: 1,
rating: 4.2,
isLocalApproved: false,
latitude: 36.8375,
longitude: 28.2580,
openingHours: null,
images: [
{ id: 'img-5-1', listingId: 'list-5', url: 'https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=800&auto=format&fit=crop&q=80' }
],
createdAt: new Date(),
updatedAt: new Date()
},
{
id: 'list-6',
slug: 'marina-dalis-merkezi',
categoryId: 'cat-3',
neighborhoodId: 'neigh-2',
city: 'marmaris',
nameTr: 'Marina Dalış Merkezi',
nameEn: 'Marina Diving Center',
nameRu: 'Дайвинг-центр Марина',
descriptionTr: 'Marmaris\'in eşsiz koylarında profesyonel eğitmenler eşliğinde tüplü dalış deneyimi. Başlangıç seviyesinden PADI sertifikasyonuna kadar hizmet.',
descriptionEn: 'Scuba diving experience in unique bays of Marmaris accompanied by professional instructors. Service from discovery dive to PADI certification.',
descriptionRu: 'Опыт подводного плавания в уникальных бухтах Мармариса в сопровождении профессиональных инструкторов. Услуги от ознакомительного погружения до сертификации PADI.',
address: 'Liman Yolu No:32, İçmeler, Marmaris',
phone: '+90 252 455 22 33',
whatsapp: '+90 536 777 88 99',
website: 'https://marinadivingmarmaris.com',
instagram: 'https://instagram.com/marinadiving_marmaris',
priceRange: 3,
rating: 4.9,
isLocalApproved: true,
latitude: 36.8020,
longitude: 28.2320,
openingHours: { all: '08:30 - 19:30' },
images: [
{ id: 'img-6-1', listingId: 'list-6', url: 'https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=800&auto=format&fit=crop&q=80' }
],
createdAt: new Date(),
updatedAt: new Date()
},
{
id: 'list-7',
slug: 'ege-ruzgari-tekne-kiralama',
categoryId: 'cat-3',
neighborhoodId: 'neigh-1',
city: 'marmaris',
nameTr: 'Ege Rüzgarı Tekne Kiralama',
nameEn: 'Aegean Wind Boat Rental',
nameRu: 'Аренда Лодок Эгейский Ветер',
descriptionTr: 'Kaptanlı veya kaptansız günlük ve haftalık özel tekne kiralama hizmeti. Marmaris koylarını kendi rotanızda özgürce keşfedin.',
descriptionEn: 'Daily and weekly private boat rental service with or without skipper. Discover Marmaris bays freely on your own route.',
descriptionRu: 'Ежедневная и еженедельная аренда частных лодок со шкипером или без. Откройте для себя бухты Мармариса свободно по собственному маршруту.',
address: 'Yat Limanı G İskelesi, Marmaris',
phone: '+90 532 999 88 77',
whatsapp: '+90 532 999 88 77',
website: 'https://egeruzgariboat.com',
instagram: 'https://instagram.com/egeruzgariboat',
priceRange: 3,
rating: 4.8,
isLocalApproved: true,
latitude: 36.8528,
longitude: 28.2755,
openingHours: { all: '08:00 - 21:00' },
images: [
{ id: 'img-7-1', listingId: 'list-7', url: 'https://images.unsplash.com/photo-1567899378494-47b22a2ae96a?w=800&auto=format&fit=crop&q=80' }
],
createdAt: new Date(),
updatedAt: new Date()
},
{
id: 'list-8',
slug: 'marmaris-transfer-tours',
categoryId: 'cat-3',
neighborhoodId: 'neigh-3',
city: 'marmaris',
nameTr: 'Marmaris VIP Transfer & Tur',
nameEn: 'Marmaris VIP Transfer & Tours',
nameRu: 'Marmaris VIP Трансфер и Туры',
descriptionTr: 'Dalaman Havalimanı transferleri ve Marmaris çevresindeki tarihi/doğal alanlara özel konforlu turlar. Güvenilir ve lüks taşımacılık.',
descriptionEn: 'Dalaman Airport transfers and comfortable private tours to historical/natural areas around Marmaris. Reliable and luxurious transportation.',
descriptionRu: 'Трансфер из аэропорта Даламан и комфортабельные частные туры по историческим и природным местам вокруг Мармариса. Надежный и роскошный транспорт.',
address: 'Atatürk Caddesi No:102, Armutalan, Marmaris',
phone: '+90 252 413 77 88',
whatsapp: '+90 541 333 44 55',
website: 'https://marmarisviptransfer.com',
instagram: null,
priceRange: 2,
rating: 4.6,
isLocalApproved: false,
latitude: 36.8495,
longitude: 28.2430,
openingHours: { all: '24 Hours Open' },
images: [
{ id: 'img-8-1', listingId: 'list-8', url: 'https://images.unsplash.com/photo-1549317661-bd32c8ce0db2?w=800&auto=format&fit=crop&q=80' }
],
createdAt: new Date(),
updatedAt: new Date()
}
]
globalForMockDb.initialized = true
}
export const mockDb = {
// Config helpers
isMock: () => process.env.USE_MOCK === 'true',
// Categories
async getCategories() {
if (this.isMock()) {
return globalForMockDb.categories
}
return db.category.findMany({ orderBy: { nameTr: 'asc' } })
},
async getCategoryBySlug(slug: string) {
if (this.isMock()) {
return globalForMockDb.categories.find(c => c.slug === slug) || null
}
return db.category.findUnique({ where: { slug } })
},
async createCategory(data: { slug: string; nameTr: string; nameEn: string; nameRu: string }) {
if (this.isMock()) {
const newCat = { id: `cat-${Date.now()}`, ...data }
globalForMockDb.categories.push(newCat)
return newCat
}
return db.category.create({ data })
},
async updateCategory(id: string, data: { slug: string; nameTr: string; nameEn: string; nameRu: string }) {
if (this.isMock()) {
const idx = globalForMockDb.categories.findIndex(c => c.id === id)
if (idx !== -1) {
globalForMockDb.categories[idx] = { ...globalForMockDb.categories[idx], ...data }
return globalForMockDb.categories[idx]
}
return null
}
return db.category.update({ where: { id }, data })
},
// Neighborhoods
async getNeighborhoods() {
if (this.isMock()) {
return globalForMockDb.neighborhoods
}
return db.neighborhood.findMany({ orderBy: { nameTr: 'asc' } })
},
async getNeighborhoodBySlug(slug: string) {
if (this.isMock()) {
return globalForMockDb.neighborhoods.find(n => n.slug === slug) || null
}
return db.neighborhood.findUnique({ where: { slug } })
},
async createNeighborhood(data: { slug: string; nameTr: string; nameEn: string; nameRu: string }) {
if (this.isMock()) {
const newNeigh = { id: `neigh-${Date.now()}`, ...data }
globalForMockDb.neighborhoods.push(newNeigh)
return newNeigh
}
return db.neighborhood.create({ data })
},
async updateNeighborhood(id: string, data: { slug: string; nameTr: string; nameEn: string; nameRu: string }) {
if (this.isMock()) {
const idx = globalForMockDb.neighborhoods.findIndex(n => n.id === id)
if (idx !== -1) {
globalForMockDb.neighborhoods[idx] = { ...globalForMockDb.neighborhoods[idx], ...data }
return globalForMockDb.neighborhoods[idx]
}
return null
}
return db.neighborhood.update({ where: { id }, data })
},
// Listings
async getListings(filters?: {
categoryId?: string
neighborhoodId?: string
priceRange?: number
isLocalApproved?: boolean
search?: string
}) {
if (this.isMock()) {
let result = globalForMockDb.listings.filter(l => !l.deletedAt)
if (filters) {
if (filters.categoryId) result = result.filter(l => l.categoryId === filters.categoryId)
if (filters.neighborhoodId) result = result.filter(l => l.neighborhoodId === filters.neighborhoodId)
if (filters.priceRange) result = result.filter(l => l.priceRange === filters.priceRange)
if (filters.isLocalApproved !== undefined) result = result.filter(l => l.isLocalApproved === filters.isLocalApproved)
if (filters.search) {
const s = filters.search.toLowerCase()
result = result.filter(l =>
l.nameTr.toLowerCase().includes(s) ||
l.nameEn.toLowerCase().includes(s) ||
l.nameRu.toLowerCase().includes(s) ||
l.address.toLowerCase().includes(s)
)
}
}
return result.map(l => ({
...l,
category: globalForMockDb.categories.find(c => c.id === l.categoryId),
neighborhood: globalForMockDb.neighborhoods.find(n => n.id === l.neighborhoodId)
}))
}
const where: any = { deletedAt: null }
if (filters) {
if (filters.categoryId) where.categoryId = filters.categoryId
if (filters.neighborhoodId) where.neighborhoodId = filters.neighborhoodId
if (filters.priceRange) where.priceRange = filters.priceRange
if (filters.isLocalApproved !== undefined) where.isLocalApproved = filters.isLocalApproved
if (filters.search) {
where.OR = [
{ nameTr: { contains: filters.search, mode: 'insensitive' } },
{ nameEn: { contains: filters.search, mode: 'insensitive' } },
{ nameRu: { contains: filters.search, mode: 'insensitive' } },
{ address: { contains: filters.search, mode: 'insensitive' } },
]
}
}
return db.listing.findMany({
where,
include: { category: true, neighborhood: true, images: true },
orderBy: { createdAt: 'desc' }
})
},
async getListingBySlug(slug: string) {
if (this.isMock()) {
const listing = globalForMockDb.listings.find(l => l.slug === slug && !l.deletedAt)
if (!listing) return null
return {
...listing,
category: globalForMockDb.categories.find(c => c.id === listing.categoryId),
neighborhood: globalForMockDb.neighborhoods.find(n => n.id === listing.neighborhoodId)
}
}
return db.listing.findUnique({
where: { slug },
include: { category: true, neighborhood: true, images: true }
})
},
async getListingById(id: string) {
if (this.isMock()) {
const listing = globalForMockDb.listings.find(l => l.id === id && !l.deletedAt)
if (!listing) return null
return {
...listing,
category: globalForMockDb.categories.find(c => c.id === listing.categoryId),
neighborhood: globalForMockDb.neighborhoods.find(n => n.id === listing.neighborhoodId)
}
}
return db.listing.findUnique({
where: { id },
include: { category: true, neighborhood: true, images: true }
})
},
async createListing(data: Omit<Listing, 'id' | 'images' | 'createdAt' | 'updatedAt'> & { images?: string[] }) {
const { images, category, neighborhood, ...rest } = data
if (this.isMock()) {
const newId = `list-${Date.now()}`
const gallery = (images || []).map((url, i) => ({ id: `img-${newId}-${i}`, listingId: newId, url }))
const newListing: Listing = {
id: newId,
...rest,
images: gallery,
createdAt: new Date(),
updatedAt: new Date()
}
globalForMockDb.listings.unshift(newListing)
return newListing
}
return db.listing.create({
data: {
...rest,
images: images ? { create: images.map(url => ({ url })) } : undefined
},
include: { images: true }
})
},
async updateListing(id: string, data: Partial<Omit<Listing, 'id' | 'images' | 'createdAt' | 'updatedAt'>> & { images?: string[] }) {
const { images, category, neighborhood, ...rest } = data
if (this.isMock()) {
const idx = globalForMockDb.listings.findIndex(l => l.id === id)
if (idx !== -1) {
const oldListing = globalForMockDb.listings[idx]
const updatedGallery = images
? images.map((url, i) => ({ id: `img-${id}-${i}`, listingId: id, url }))
: oldListing.images
const updated = {
...oldListing,
...rest,
images: updatedGallery,
updatedAt: new Date()
} as Listing
globalForMockDb.listings[idx] = updated
return updated
}
return null
}
if (images) {
await db.gallery.deleteMany({ where: { listingId: id } })
}
return db.listing.update({
where: { id },
data: {
...rest,
images: images ? { create: images.map(url => ({ url })) } : undefined
},
include: { images: true }
})
},
async deleteListing(id: string) {
if (this.isMock()) {
const idx = globalForMockDb.listings.findIndex(l => l.id === id)
if (idx !== -1) {
globalForMockDb.listings[idx].deletedAt = new Date()
return true
}
return false
}
await db.listing.update({
where: { id },
data: { deletedAt: new Date() }
})
return true
},
// Business Submissions
async getSubmissions() {
if (this.isMock()) {
return globalForMockDb.submissions.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
}
return db.businessSubmission.findMany({ orderBy: { createdAt: 'desc' } })
},
async createSubmission(data: Omit<BusinessSubmission, 'id' | 'status' | 'createdAt' | 'updatedAt'>) {
if (this.isMock()) {
const newSub: BusinessSubmission = {
id: `sub-${Date.now()}`,
...data,
status: 'PENDING',
createdAt: new Date(),
updatedAt: new Date()
}
globalForMockDb.submissions.push(newSub)
return newSub
}
return db.businessSubmission.create({ data: { ...data, status: 'PENDING' } })
},
async updateSubmissionStatus(id: string, status: 'APPROVED' | 'REJECTED') {
if (this.isMock()) {
const idx = globalForMockDb.submissions.findIndex(s => s.id === id)
if (idx !== -1) {
globalForMockDb.submissions[idx].status = status
globalForMockDb.submissions[idx].updatedAt = new Date()
return globalForMockDb.submissions[idx]
}
return null
}
return db.businessSubmission.update({
where: { id },
data: { status }
})
},
async getSubmissionById(id: string) {
if (this.isMock()) {
return globalForMockDb.submissions.find(s => s.id === id) || null
}
return db.businessSubmission.findUnique({ where: { id } })
},
// Contact Messages
async getMessages() {
if (this.isMock()) {
return globalForMockDb.messages.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
}
return db.contactMessage.findMany({ orderBy: { createdAt: 'desc' } })
},
async createMessage(data: Omit<ContactMessage, 'id' | 'isRead' | 'createdAt' | 'updatedAt'>) {
if (this.isMock()) {
const newMsg: ContactMessage = {
id: `msg-${Date.now()}`,
...data,
isRead: false,
createdAt: new Date(),
updatedAt: new Date()
}
globalForMockDb.messages.push(newMsg)
return newMsg
}
return db.contactMessage.create({ data: { ...data, isRead: false } })
},
async markMessageAsRead(id: string) {
if (this.isMock()) {
const idx = globalForMockDb.messages.findIndex(m => m.id === id)
if (idx !== -1) {
globalForMockDb.messages[idx].isRead = true
globalForMockDb.messages[idx].updatedAt = new Date()
return globalForMockDb.messages[idx]
}
return null
}
return db.contactMessage.update({
where: { id },
data: { isRead: true }
})
}
}
+36
View File
@@ -0,0 +1,36 @@
export async function uploadToOpeninary(file: File, folder: string = "marmarislocal"): Promise<string> {
const url = `${process.env.OPENINARY_API_URL}/upload`;
const key = process.env.OPENINARY_API_KEY;
if (!url || !key) {
throw new Error("Openinary configuration (OPENINARY_API_URL or OPENINARY_API_KEY) is missing in environment variables.");
}
const formData = new FormData();
formData.append("files", file);
formData.append("folder", folder);
const response = await fetch(url, {
method: "POST",
headers: {
"x-api-key": key,
},
body: formData,
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`Openinary upload failed: ${response.statusText} - ${errText}`);
}
const data = await response.json();
// Openinary standard response is usually an array: [{ url: "...", name: "..." }]
if (Array.isArray(data) && data.length > 0) {
return data[0].url || data[0].secure_url;
} else if (data && typeof data === "object") {
return data.url || data.secure_url || (data.files && data.files[0]?.url);
}
throw new Error("Invalid response format from Openinary server.");
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+83
View File
@@ -0,0 +1,83 @@
{
"nav": {
"home": "Home",
"restaurants": "Restaurants",
"aparts": "Aparts",
"businesses": "Businesses",
"about": "About Us",
"contact": "Contact",
"addBusiness": "Add Business",
"admin": "Admin Panel",
"login": "Login"
},
"hero": {
"title": "The Best Local Places in Marmaris",
"subtitle": "Local knowledge tourists can't see. Handpicked restaurants, apart hotels and hidden gems approved by locals.",
"searchPlaceholder": "Search place, category or neighborhood...",
"cta": "Start Exploring",
"approvedBadge": "Local Approved",
"approvedExplain": "The Local Approved Seal shows businesses that have been personally experienced and verified by Marmaris Local editors."
},
"home": {
"categories": "Categories",
"categoriesSubtitle": "Everything you need to live Marmaris like a local",
"featured": "Featured Local Approved Places",
"featuredSubtitle": "Establishments selected by our editors with quality and taste guarantees",
"neighborhoods": "Neighborhoods",
"neighborhoodsSubtitle": "Explore Marmaris by region",
"explore": "Explore"
},
"categories": {
"restoran": "Restaurants",
"apart": "Aparts",
"isletme": "General Businesses",
"filterNeighborhood": "Neighborhood Filter",
"filterPrice": "Price Range",
"filterApproved": "Local Approved Only",
"noResults": "No results found matching your criteria.",
"allNeighborhoods": "All Neighborhoods",
"allPrices": "All Prices",
"rating": "Rating",
"address": "Address",
"phone": "Phone",
"price": "Price",
"viewDetails": "View Details"
},
"detail": {
"approved": "Local Approved Sealed Business",
"rating": "Editor Rating",
"price": "Price Level",
"address": "Address",
"hours": "Opening Hours",
"contact": "Contact Info",
"call": "Call Now",
"whatsapp": "WhatsApp Message",
"website": "Web Site",
"instagram": "Instagram",
"location": "Location / Map",
"related": "Similar Places",
"noHours": "Opening hours not specified."
},
"forms": {
"name": "Full Name",
"email": "Email Address",
"message": "Your Message",
"subject": "Subject",
"businessName": "Business Name",
"category": "Category",
"neighborhood": "Neighborhood",
"address": "Business Address",
"phone": "Phone Number",
"whatsapp": "WhatsApp Number (Optional)",
"description": "Short Description",
"image": "Image File or URL (Optional)",
"submit": "Submit",
"sending": "Submitting...",
"success": "Your submission has been received successfully! It will be published after editor review.",
"contactSuccess": "Your message has been sent successfully. We will get back to you soon."
},
"footer": {
"rights": "© 2026 Marmaris Local. All rights reserved.",
"about": "Marmaris Local is a curated directory bringing together the best restaurants, accommodation, and services in Marmaris."
}
}
+83
View File
@@ -0,0 +1,83 @@
{
"nav": {
"home": "Главная",
"restaurants": "Рестораны",
"aparts": "Апартаменты",
"businesses": "Заведения",
"about": "О нас",
"contact": "Контакты",
"addBusiness": "Добавить бизнес",
"admin": "Панель администратора",
"login": "Войти"
},
"hero": {
"title": "Лучшие места Мармариса от местных жителей",
"subtitle": "Информация, которую не увидит обычный турист. Рестораны, апарт-отели и скрытые жемчужины, проверенные местными жителями.",
"searchPlaceholder": "Поиск места, категории или района...",
"cta": "Начать исследование",
"approvedBadge": "Проверено местными",
"approvedExplain": "Печать «Проверено местными» отмечает заведения, которые были лично опробованы и подтверждены редакторами Marmaris Local."
},
"home": {
"categories": "Категории",
"categoriesSubtitle": "Все, что нужно, чтобы жить в Мармарисе как местный житель",
"featured": "Рекомендуемые места с печатью качества",
"featuredSubtitle": "Заведения, выбранные нашими редакторами с гарантией качества и вкуса",
"neighborhoods": "Районы",
"neighborhoodsSubtitle": "Исследуйте Мармарис по регионам",
"explore": "Исследовать"
},
"categories": {
"restoran": "Рестораны",
"apart": "Апартаменты",
"isletme": "Бизнес и Услуги",
"filterNeighborhood": "Фильтр по районам",
"filterPrice": "Ценовой диапазон",
"filterApproved": "Только проверенные местными",
"noResults": "Результатов по вашему запросу не найдено.",
"allNeighborhoods": "Все районы",
"allPrices": "Все цены",
"rating": "Рейтинг",
"address": "Адрес",
"phone": "Телефон",
"price": "Цена",
"viewDetails": "Подробнее"
},
"detail": {
"approved": "Заведение с печатью «Проверено местными»",
"rating": "Рейтинг редакции",
"price": "Уровень цен",
"address": "Точный адрес",
"hours": "Часы работы",
"contact": "Контактная информация",
"call": "Позвонить",
"whatsapp": "Написать в WhatsApp",
"website": "Веб-сайт",
"instagram": "Instagram",
"location": "Местоположение / Карта",
"related": "Похожие места",
"noHours": "Часы работы не указаны."
},
"forms": {
"name": "Имя и фамилия",
"email": "Электронная почта",
"message": "Ваше сообщение",
"subject": "Тема",
"businessName": "Название компании",
"category": "Категория",
"neighborhood": "Район",
"address": "Адрес компании",
"phone": "Номер телефона",
"whatsapp": "Номер WhatsApp (необязательно)",
"description": "Краткое описание",
"image": "Файл изображения или URL-адрес (необязательно)",
"submit": "Отправить",
"sending": "Отправка...",
"success": "Ваша заявка успешно принята! Она будет опубликована после проверки редактором.",
"contactSuccess": "Ваше сообщение успешно отправлено. Мы свяжемся с вами в ближайшее время."
},
"footer": {
"rights": "© 2026 Marmaris Local. Все права защищены.",
"about": "Marmaris Local — это курируемый гид, объединяющий лучшие рестораны, жилье и местные услуги в Мармарисе."
}
}
+83
View File
@@ -0,0 +1,83 @@
{
"nav": {
"home": "Ana Sayfa",
"restaurants": "Restoranlar",
"aparts": "Apartlar",
"businesses": "İşletmeler",
"about": "Hakkımızda",
"contact": "İletişim",
"addBusiness": "İşletme Ekle",
"admin": "Yönetim",
"login": "Giriş Yap"
},
"hero": {
"title": "Marmaris'in En İyi Yerel Adresleri",
"subtitle": "Turistin göremediği yerel bilgi. Yerel sakinlerin onayladığı restoranlar, apart oteller ve gizli yerler.",
"searchPlaceholder": "Mekan, kategori veya mahalle ara...",
"cta": "Keşfetmeye Başla",
"approvedBadge": "Yerel Onaylı",
"approvedExplain": "Yerel Onaylı Mührü, Marmaris Local editörleri tarafından bizzat deneyimlenip onaylanmış mekanları gösterir."
},
"home": {
"categories": "Kategoriler",
"categoriesSubtitle": "Marmaris'i bir yerel gibi yaşamak için ihtiyacınız olan her şey",
"featured": "Öne Çıkan Yerel Onaylı Mekanlar",
"featuredSubtitle": "Editörlerimizin seçtiği, kalite ve lezzet garantili işletmeler",
"neighborhoods": "Mahalleler",
"neighborhoodsSubtitle": "Bölgelere göre Marmaris'i keşfedin",
"explore": "Keşfet"
},
"categories": {
"restoran": "Restoranlar",
"apart": "Apartlar",
"isletme": "Genel İşletmeler",
"filterNeighborhood": "Mahalle Filtresi",
"filterPrice": "Fiyat Aralığı",
"filterApproved": "Sadece Yerel Onaylılar",
"noResults": "Kriterlerinize uygun sonuç bulunamadı.",
"allNeighborhoods": "Tüm Mahalleler",
"allPrices": "Tüm Fiyatlar",
"rating": "Puan",
"address": "Adres",
"phone": "Telefon",
"price": "Fiyat",
"viewDetails": "Detayları Gör"
},
"detail": {
"approved": "Yerel Onaylı Mühürlü İşletme",
"rating": "Editör Puanı",
"price": "Fiyat Seviyesi",
"address": "Açık Adres",
"hours": "Çalışma Saatleri",
"contact": "İletişim Bilgileri",
"call": "Hemen Ara",
"whatsapp": "WhatsApp Mesajı",
"website": "Web Sitesi",
"instagram": "Instagram",
"location": "Konum / Harita",
"related": "Benzer Mekanlar",
"noHours": "Çalışma saatleri belirtilmemiş."
},
"forms": {
"name": "Ad Soyad",
"email": "E-posta Adresi",
"message": "Mesajınız",
"subject": "Konu",
"businessName": "İşletme Adı",
"category": "Kategori",
"neighborhood": "Mahalle",
"address": "İşletme Adresi",
"phone": "Telefon Numarası",
"whatsapp": "WhatsApp Numarası (Opsiyonel)",
"description": "Kısa Açıklama",
"image": "Görsel Dosyası veya URL (Opsiyonel)",
"submit": "Gönder",
"sending": "Gönderiliyor...",
"success": "Başvurunuz başarıyla alınmıştır! Editör incelemesinden sonra yayınlanacaktır.",
"contactSuccess": "Mesajınız başarıyla iletildi. En kısa sürede dönüş yapacağız."
},
"footer": {
"rights": "© 2026 Marmaris Local. Tüm hakları saklıdır.",
"about": "Marmaris Local, Marmaris'teki en iyi restoran, konaklama ve yerel hizmetleri bir araya getiren küratörlü bir rehberdir."
}
}
+16
View File
@@ -0,0 +1,16 @@
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' },
],
},
}
export default withNextIntl(nextConfig)
+10695
View File
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
{
"name": "marmarislocal",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@prisma/client": "^6.3.0",
"class-variance-authority": "^0.7.1",
"cloudinary": "^2.10.0",
"clsx": "^2.1.1",
"developer-icons": "^7.0.1",
"framer-motion": "^12.40.0",
"lucide-react": "^1.18.0",
"next": "16.2.9",
"next-auth": "^5.0.0-beta.31",
"next-intl": "^4.13.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"shadcn": "^4.11.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.9",
"prisma": "^6.3.0",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+158
View File
@@ -0,0 +1,158 @@
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
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])
}
model Category {
id String @id @default(cuid())
slug String @unique
nameTr String
nameEn String
nameRu String
listings Listing[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Neighborhood {
id String @id @default(cuid())
slug String @unique
nameTr String
nameEn String
nameRu String
listings Listing[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Listing {
id String @id @default(cuid())
slug String @unique
categoryId String
neighborhoodId String
city String @default("marmaris")
nameTr String
nameEn String
nameRu String
descriptionTr String
descriptionEn String
descriptionRu String
address String
phone String?
whatsapp String?
website String?
instagram String?
priceRange Int // 1-3
rating Float? // 0-5
isLocalApproved Boolean @default(false)
latitude Float?
longitude Float?
openingHours Json?
images Gallery[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
category Category @relation(fields: [categoryId], references: [id])
neighborhood Neighborhood @relation(fields: [neighborhoodId], references: [id])
}
model Gallery {
id String @id @default(cuid())
listingId String
url String
createdAt DateTime @default(now())
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
}
model BusinessSubmission {
id String @id @default(cuid())
businessName String
categoryId String
neighborhoodId String
address String
phone String?
whatsapp String?
description String
contactName String
contactEmail String
imageUrl String?
status String @default("PENDING") // PENDING, APPROVED, REJECTED
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ContactMessage {
id String @id @default(cuid())
name String
email String
subject String // Genel, İşbirliği, Hata Bildirimi, Diğer
message String
isRead Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server'
import createMiddleware from 'next-intl/middleware'
import { auth } from '@/lib/auth'
import { routing } from '@/i18n/routing'
const intlMiddleware = createMiddleware(routing)
export async function proxy(request: NextRequest) {
if (request.nextUrl.pathname.includes('/admin')) {
const session = await auth()
if (!session || (session.user as any)?.role !== 'ADMIN') {
return NextResponse.redirect(new URL('/login', request.url))
}
}
return intlMiddleware(request)
}
export const config = {
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)']
}
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}