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
+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;
}
}