chore: clean up scratch files and apply remaining updates

This commit is contained in:
AyrisAI
2026-07-13 14:17:57 +03:00
parent 9866413511
commit 53cbee0841
28 changed files with 986 additions and 284 deletions
+59 -4
View File
@@ -7,6 +7,8 @@ import { Link } from '@/i18n/routing'
import { Phone, Globe, MapPin, Clock, Star, MessageSquare, Share2 } from 'lucide-react'
import SaveButton from './SaveButton'
import DetailTracker from './DetailTracker'
import type { Metadata } from 'next'
import { SITE_URL } from '@/lib/seo'
interface DetailPageProps {
params: Promise<{ locale: string; category: string; slug: string }>
@@ -14,14 +16,32 @@ interface DetailPageProps {
export const dynamic = 'force-dynamic'
export async function generateMetadata({ params }: DetailPageProps) {
const { slug } = await params
export async function generateMetadata({ params }: DetailPageProps): Promise<Metadata> {
const { locale, slug } = await params
const listing = await mockDb.getListingBySlug(slug)
if (!listing) return {}
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 title = `${name} — Marmaris Local`
const image = listing.images?.[0]?.url
return {
title: `${listing.nameTr} — Marmaris Local`,
description: listing.descriptionTr
title,
description,
openGraph: {
title,
description,
url: `${SITE_URL}/${locale}/${listing.category?.slug || 'isletme'}/${listing.slug}`,
siteName: 'Marmaris Local',
type: 'website',
...(image ? { images: [{ url: image }] } : {}),
},
twitter: {
card: 'summary_large_image',
title,
description,
},
}
}
@@ -86,8 +106,43 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
return `https://wa.me/?text=${text}`
}
// schema.org LocalBusiness structured data
const schemaTypeByCategory: Record<string, string> = {
restoran: 'Restaurant',
apart: 'LodgingBusiness',
isletme: 'LocalBusiness',
}
const jsonLd = {
'@context': 'https://schema.org',
'@type': schemaTypeByCategory[categorySlug] || 'LocalBusiness',
name,
description,
image: listing.images?.map(img => img.url) || undefined,
url: `https://marmarislocal.com/${locale}/${categorySlug}/${listing.slug}`,
address: {
'@type': 'PostalAddress',
streetAddress: listing.address,
addressLocality: 'Marmaris',
addressCountry: 'TR',
},
...(listing.latitude && listing.longitude
? { geo: { '@type': 'GeoCoordinates', latitude: listing.latitude, longitude: listing.longitude } }
: {}),
...(listing.phone ? { telephone: listing.phone } : {}),
...(listing.website ? { sameAs: [listing.website] } : {}),
priceRange: priceSymbols || undefined,
...(listing.openingHours && (listing.openingHours as any).all
? { openingHours: (listing.openingHours as any).all }
: {}),
}
return (
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<DetailTracker listingId={listing.id} />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 flex-1 space-y-10">
+27
View File
@@ -5,6 +5,8 @@ import { Link } from '@/i18n/routing'
import { notFound } from 'next/navigation'
import { MapPin, SlidersHorizontal, Check } from 'lucide-react'
import LiveFilterForm from '@/components/LiveFilterForm'
import type { Metadata } from 'next'
import { basicMetadata } from '@/lib/seo'
interface PageProps {
params: Promise<{ locale: string, category: string }>
@@ -18,6 +20,31 @@ interface PageProps {
export const dynamic = 'force-dynamic'
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale, category: categorySlug } = await params
const categories = await mockDb.getCategories()
const category = categories.find(c => c.slug === categorySlug)
if (!category) return {}
const name = locale === 'ru' ? category.nameRu : locale === 'en' ? category.nameEn : category.nameTr
const title =
locale === 'en'
? `${name} in Marmaris — Marmaris Local`
: locale === 'ru'
? `${name} в Мармарисе — Marmaris Local`
: `Marmaris ${name} Rehberi — Marmaris Local`
const description =
locale === 'en'
? `Browse the best ${name.toLowerCase()} in Marmaris, filtered by neighborhood and price — curated and locally approved.`
: locale === 'ru'
? `Лучшие места категории «${name}» в Мармарисе — с фильтрами по районам и ценам, проверено местными.`
: `Marmaris'teki en iyi ${name.toLowerCase()} listesi — mahalle ve fiyata göre filtrele, yerel onaylılardan seç.`
return basicMetadata(title, description)
}
export default async function DynamicCategoryPage({ params, searchParams }: PageProps) {
const { locale, category: categorySlug } = await params
setRequestLocale(locale)
+22
View File
@@ -1,11 +1,33 @@
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { Link } from '@/i18n/routing'
import { CheckCircle, ShieldAlert, Award, Star } from 'lucide-react'
import type { Metadata } from 'next'
import { basicMetadata } from '@/lib/seo'
interface AboutPageProps {
params: Promise<{ locale: string }>
}
export async function generateMetadata({ params }: AboutPageProps): Promise<Metadata> {
const { locale } = await params
const title =
locale === 'en'
? 'About Us — Marmaris Local'
: locale === 'ru'
? 'О нас — Marmaris Local'
: 'Hakkımızda — Marmaris Local'
const description =
locale === 'en'
? 'Learn what the Marmaris Local approval seal means and how we curate the guide.'
: locale === 'ru'
? 'Узнайте, что означает знак одобрения Marmaris Local и как мы составляем гид.'
: "Marmaris Local yerel onay mührünün ne anlama geldiğini ve rehberi nasıl kürasyonladığımızı öğrenin."
return basicMetadata(title, description)
}
export default async function AboutPage({ params }: AboutPageProps) {
const { locale } = await params
setRequestLocale(locale)
@@ -0,0 +1,171 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { createOrUpdateBlogPostAction } from '@/app/actions'
import { Link } from '@/i18n/routing'
import { ArrowLeft, Save } from 'lucide-react'
interface FormProps {
post: any | null
}
export default function BlogPostForm({ post }: 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 (post) {
formData.append('id', post.id)
}
try {
const res = await createOrUpdateBlogPostAction(formData)
if (res.error) {
setError(res.error)
} else {
setSuccess(true)
setTimeout(() => {
router.push('/admin/blog')
router.refresh()
}, 1500)
}
} catch (err) {
setError('İşlem sırasında bir hata oluştu.')
} finally {
setLoading(false)
}
}
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">
Yazı başarıyla kaydedildi! Yönlendiriliyorsunuz...
</div>
)}
<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>
<div className="space-y-4">
{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">Başlık (TR) *</label>
<input type="text" name="titleTr" required defaultValue={post?.titleTr || ''} 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: Marmaris'in En İyi 5 Plajı" />
</div>
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">İçerik (TR) *</label>
<textarea name="contentTr" required rows={10} defaultValue={post?.contentTr || ''} 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="Yazı içeriği..." />
</div>
</div>
)}
{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">Başlık (EN) *</label>
<input type="text" name="titleEn" required defaultValue={post?.titleEn || ''} 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="e.g. Top 5 Beaches in Marmaris" />
</div>
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">İçerik (EN) *</label>
<textarea name="contentEn" required rows={10} defaultValue={post?.contentEn || ''} 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="Post content..." />
</div>
</div>
)}
{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">Başlık (RU) *</label>
<input type="text" name="titleRu" required defaultValue={post?.titleRu || ''} 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="e.g. Топ 5 пляжей Мармариса" />
</div>
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">İçerik (RU) *</label>
<textarea name="contentRu" required rows={10} defaultValue={post?.contentRu || ''} 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="Контент..." />
</div>
</div>
)}
</div>
<hr className="border-dashed border-pine/8" />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<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={post?.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="en-iyi-5-plaj" />
</div>
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Kapak Görseli URL</label>
<input type="url" name="coverImageUrl" defaultValue={post?.coverImage || ''} 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="https://..." />
</div>
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Yazar</label>
<input type="text" name="author" defaultValue={post?.author || ''} 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: Ayris Dev" />
</div>
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Etiketler (Virgülle ayırın)</label>
<input type="text" name="tags" defaultValue={post?.tags?.join(', ') || ''} 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="plaj, yaz, tatil" />
</div>
</div>
<div className="space-y-4 pt-4 border-t border-pine/8">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
name="isPublished"
value="true"
defaultChecked={!!post?.publishedAt}
className="rounded border-pine/20 text-turquoise focus:ring-turquoise"
/>
<span className="text-sm font-medium text-ink">Yazıyı Yayınla (Görünür Yap)</span>
</label>
</div>
<div className="flex items-center justify-between pt-4">
<Link href="/admin/blog" className="text-xs font-bold text-shutter hover:text-pine transition inline-flex items-center gap-1">
<ArrowLeft className="w-3.5 h-3.5" />
İptal ve Geri Dön
</Link>
<button disabled={loading} type="submit" className="bg-turquoise hover:bg-turquoise/90 text-paper px-6 py-3 rounded-xl font-bold text-sm transition-colors shadow-sm inline-flex items-center gap-2 disabled:opacity-50">
<Save className="w-4 h-4" />
{loading ? 'Kaydediliyor...' : 'Kaydet'}
</button>
</div>
</form>
)
}
+9 -18
View File
@@ -1,13 +1,14 @@
import { mockDb } from '@/lib/mockDb'
import BlogForm from './BlogForm'
import BlogPostForm from './BlogPostForm'
import { notFound } from 'next/navigation'
interface Props {
params: Promise<{ locale: string; id: string }>
}
export default async function AdminBlogEditPage({ params }: Props) {
const { id } = await params
export default async function AdminBlogPostEditPage({ params }: Props) {
const { locale, id } = await params
let post = null
if (id !== 'new') {
@@ -17,31 +18,21 @@ export default async function AdminBlogEditPage({ params }: Props) {
}
}
// Fetch listings to link them to the blog post
const listings = await mockDb.getListings()
const listingOptions = listings.map(l => ({
value: l.id,
label: `${l.nameTr} (${l.neighborhood?.nameTr})`
}))
return (
<div className="space-y-6 max-w-5xl">
<div className="space-y-6 max-w-4xl">
<div>
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">
{id === 'new' ? 'yeni blog yazısı' : 'yazıyı düzenle'}
{id === 'new' ? 'yeni yazı ekle' : 'yazıyı düzenle'}
</h2>
<p className="text-ink/65 text-xs font-medium mt-1">
{id === 'new'
? 'Yeni bir rehber veya gastronomi tanıtım yazısı oluşturun.'
: 'Mevcut blog yazısı içeriğini güncelleyin.'}
? 'Yeni bir blog yazısı oluşturun.'
: 'Mevcut blog yazısı bilgilerini güncelleyin.'}
</p>
</div>
<div className="bg-paper border border-pine/8 rounded-3xl shadow-sm p-6 sm:p-8">
<BlogForm
post={post}
listingOptions={listingOptions}
/>
<BlogPostForm post={post} />
</div>
</div>
)
@@ -3,8 +3,8 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { createOrUpdateCollectionAction } from '@/app/actions'
import { ArrowLeft, Save } from 'lucide-react'
import { Link } from '@/i18n/routing'
import { ArrowLeft, Save } from 'lucide-react'
interface Option {
value: string
@@ -13,23 +13,24 @@ interface Option {
interface FormProps {
collection: any | null
listingOptions: Option[]
allListings: Option[]
}
export default function CollectionForm({ collection, listingOptions }: FormProps) {
export default function CollectionForm({ collection, allListings }: 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)
// Track selected listing IDs
const [selectedListingIds, setSelectedListingIds] = useState<string[]>(
collection?.listingIds || []
)
// Selected Listings
const [selectedListings, setSelectedListings] = useState<string[]>(collection?.listingIds || [])
const [listingSearch, setListingSearch] = useState('')
const handleListingToggle = (id: string) => {
setSelectedListings(prev =>
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
const toggleListing = (id: string) => {
setSelectedListingIds(prev =>
prev.includes(id) ? prev.filter(p => p !== id) : [...prev, id]
)
}
@@ -43,7 +44,11 @@ export default function CollectionForm({ collection, listingOptions }: FormProps
if (collection) {
formData.append('id', collection.id)
}
formData.append('listingIds', selectedListings.join(','))
// Append array of listing IDs manually since standard FormData doesn't handle arrays easily
selectedListingIds.forEach(id => {
formData.append('listingIds', id)
})
try {
const res = await createOrUpdateCollectionAction(formData)
@@ -63,10 +68,6 @@ export default function CollectionForm({ collection, listingOptions }: FormProps
}
}
const filteredListings = listingOptions.filter(opt =>
opt.label.toLowerCase().includes(listingSearch.toLowerCase())
)
return (
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
@@ -77,11 +78,10 @@ export default function CollectionForm({ collection, listingOptions }: FormProps
{success && (
<div className="bg-turquoise/10 border border-turquoise/25 text-turquoise p-4 rounded-xl text-xs font-bold">
Seçki başarıyla kaydedildi! Yönlendiriliyorsunuz...
Koleksiyon 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) => {
@@ -105,88 +105,40 @@ export default function CollectionForm({ collection, listingOptions }: FormProps
</div>
</div>
{/* Localized inputs */}
<div className="space-y-4">
{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">Seçki Başlığı (TR) *</label>
<input
type="text"
name="titleTr"
required
defaultValue={collection?.titleTr || ''}
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: Aile Dostu Restoranlar"
/>
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Başlık (TR) *</label>
<input type="text" name="titleTr" required defaultValue={collection?.titleTr || ''} 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: En İyi Kahvaltı Mekanları" />
</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={3}
defaultValue={collection?.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="Bu seçkinin odağını ve amacını açıklayın..."
/>
<textarea name="descriptionTr" required rows={4} defaultValue={collection?.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 açıklama..." />
</div>
</div>
)}
{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">Seçki Başlığı (EN) *</label>
<input
type="text"
name="titleEn"
required
defaultValue={collection?.titleEn || ''}
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: Family Friendly Restaurants"
/>
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Başlık (EN) *</label>
<input type="text" name="titleEn" required defaultValue={collection?.titleEn || ''} 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="e.g. Best Breakfast Spots" />
</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={3}
defaultValue={collection?.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="Describe this collection's focus..."
/>
<textarea name="descriptionEn" required rows={4} defaultValue={collection?.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>
)}
{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">Seçki Başlığı (RU) *</label>
<input
type="text"
name="titleRu"
required
defaultValue={collection?.titleRu || ''}
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: Семейные рестораны"
/>
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Başlık (RU) *</label>
<input type="text" name="titleRu" required defaultValue={collection?.titleRu || ''} 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="e.g. Лучшие места для завтрака" />
</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={3}
defaultValue={collection?.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="Описание подборки..."
/>
<textarea name="descriptionRu" required rows={4} defaultValue={collection?.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>
)}
@@ -194,97 +146,42 @@ export default function CollectionForm({ collection, listingOptions }: FormProps
<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={collection?.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="aile-dostu-restoranlar"
/>
<input type="text" name="slug" required defaultValue={collection?.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="en-iyi-kahvalti" />
</div>
{/* Cover Image Upload */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Kapak Görseli</label>
<input
type="file"
name="coverImageFile"
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"
/>
{collection?.coverImage && (
<div className="mt-2 text-xs flex items-center gap-2">
<span className="text-shutter/65">Mevcut Görsel:</span>
<a href={collection.coverImage} target="_blank" rel="noopener noreferrer" className="text-turquoise hover:underline font-mono truncate max-w-xs">{collection.coverImage}</a>
<input type="hidden" name="coverImageUrl" value={collection.coverImage} />
</div>
)}
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Kapak Görseli URL</label>
<input type="url" name="coverImageUrl" defaultValue={collection?.coverImage || ''} 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="https://..." />
</div>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Koleksiyon Mekanları (Seçiniz)</label>
<div className="bg-stone border border-pine/10 rounded-xl p-4 max-h-60 overflow-y-auto space-y-2">
{allListings.map(listing => (
<label key={listing.value} className="flex items-center gap-2 cursor-pointer hover:bg-stone-deep/20 p-2 rounded-lg transition">
<input
type="checkbox"
checked={selectedListingIds.includes(listing.value)}
onChange={() => toggleListing(listing.value)}
className="rounded border-pine/20 text-turquoise focus:ring-turquoise"
/>
<span className="text-sm font-medium text-ink">{listing.label}</span>
</label>
))}
</div>
</div>
{/* Listing multi-select panel */}
<div className="space-y-2 border-t border-dashed border-pine/8 pt-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Seçkide Yer Alan Mekanlar * ({selectedListings.length} seçildi)</label>
<input
type="text"
value={listingSearch}
onChange={(e) => setListingSearch(e.target.value)}
className="bg-stone border border-pine/10 rounded-xl px-3 py-1.5 text-xs outline-none focus:ring-1 focus:ring-turquoise w-full sm:w-64 placeholder:text-shutter/60"
placeholder="Mekan ara..."
/>
</div>
<div className="border border-pine/10 bg-stone/20 rounded-2xl max-h-52 overflow-y-auto p-4 grid grid-cols-1 sm:grid-cols-2 gap-2">
{filteredListings.length === 0 ? (
<span className="text-xs text-shutter/60 col-span-2 py-4 text-center font-mono">Aradığınız mekan bulunamadı.</span>
) : (
filteredListings.map((opt) => {
const isChecked = selectedListings.includes(opt.value)
return (
<label
key={opt.value}
className={`flex items-center gap-2.5 p-2 rounded-xl border text-xs cursor-pointer select-none transition ${
isChecked ? 'bg-turquoise/5 border-turquoise/35 text-pine font-bold' : 'border-transparent text-ink/75 hover:bg-stone/50'
}`}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => handleListingToggle(opt.value)}
className="accent-turquoise rounded w-3.5 h-3.5 shrink-0"
/>
<span className="truncate leading-none">{opt.label}</span>
</label>
)
})
)}
</div>
</div>
{/* Buttons */}
<div className="flex gap-4 pt-4 border-t border-dashed border-pine/8">
<Link
href="/admin/collections"
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
<div className="flex items-center justify-between pt-4">
<Link href="/admin/collections" className="text-xs font-bold text-shutter hover:text-pine transition inline-flex items-center gap-1">
<ArrowLeft className="w-3.5 h-3.5" />
İptal ve Geri Dön
</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"
>
<button disabled={loading} type="submit" className="bg-turquoise hover:bg-turquoise/90 text-paper px-6 py-3 rounded-xl font-bold text-sm transition-colors shadow-sm inline-flex items-center gap-2 disabled:opacity-50">
<Save className="w-4 h-4" />
{loading ? 'Kaydediliyor...' : 'Seçkiyi Kaydet'}
{loading ? 'Kaydediliyor...' : 'Kaydet'}
</button>
</div>
</form>
+10 -10
View File
@@ -7,7 +7,8 @@ interface Props {
}
export default async function AdminCollectionEditPage({ params }: Props) {
const { id } = await params
const { locale, id } = await params
let collection = null
if (id !== 'new') {
@@ -17,30 +18,29 @@ export default async function AdminCollectionEditPage({ params }: Props) {
}
}
// Fetch listings for option list
const listings = await mockDb.getListings()
const listingOptions = listings.map(l => ({
const allListings = await mockDb.getListings()
const listingOptions = allListings.map(l => ({
value: l.id,
label: `${l.nameTr} (${l.neighborhood?.nameTr})`
label: l.nameTr
}))
return (
<div className="space-y-6 max-w-5xl">
<div className="space-y-6 max-w-4xl">
<div>
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">
{id === 'new' ? 'yeni seçki (koleksiyon)' : 'seçkiyi düzenle'}
{id === 'new' ? 'yeni seçki ekle' : 'seçkiyi düzenle'}
</h2>
<p className="text-ink/65 text-xs font-medium mt-1">
{id === 'new'
? 'Yeni bir tematik mekan derlemesi oluşturun.'
: 'Mevcut kürasyon seki bilgilerini güncelleyin.'}
? 'Yeni bir mekan kürasyonu oluşturun.'
: 'Mevcut kürasyon bilgilerini güncelleyin.'}
</p>
</div>
<div className="bg-paper border border-pine/8 rounded-3xl shadow-sm p-6 sm:p-8">
<CollectionForm
collection={collection}
listingOptions={listingOptions}
allListings={listingOptions}
/>
</div>
</div>
+3 -8
View File
@@ -1,6 +1,7 @@
import { mockDb } from '@/lib/mockDb'
import { deleteCollectionAction } from '@/app/actions'
import { Link } from '@/i18n/routing'
import DeleteButton from '@/components/DeleteButton'
import { Edit, Trash, Plus, Layers } from 'lucide-react'
export default async function AdminCollectionsPage() {
@@ -79,17 +80,11 @@ export default async function AdminCollectionsPage() {
<Edit className="w-4 h-4" />
</Link>
<form action={async () => {
<form action={async () => {
'use server'
await deleteCollectionAction(col.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>
<DeleteButton />
</form>
</div>
</td>
+11 -2
View File
@@ -1,5 +1,6 @@
import { mockDb } from '@/lib/mockDb'
import { markMessageReadAction } from '@/app/actions'
import { markMessageReadAction, deleteMessageAction } from '@/app/actions'
import DeleteButton from '@/components/DeleteButton'
import { Check, MailOpen, Mail } from 'lucide-react'
export default async function AdminMessagesPage() {
@@ -54,7 +55,7 @@ export default async function AdminMessagesPage() {
<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">
<td className="px-6 py-4 text-right whitespace-nowrap flex items-center justify-end">
{!msg.isRead ? (
<form action={async () => {
'use server'
@@ -74,6 +75,14 @@ export default async function AdminMessagesPage() {
Okundu
</span>
)}
<form action={async () => {
'use server'
await deleteMessageAction(msg.id)
}} className="ml-2">
<DeleteButton />
</form>
</td>
</tr>
)
+11 -2
View File
@@ -1,6 +1,7 @@
import { mockDb } from '@/lib/mockDb'
import { approveSubmissionAction, rejectSubmissionAction } from '@/app/actions'
import { Check, X } from 'lucide-react'
import { approveSubmissionAction, rejectSubmissionAction, deleteSubmissionAction } from '@/app/actions'
import DeleteButton from '@/components/DeleteButton'
import { Check, X, Trash2 } from 'lucide-react'
export default async function AdminSubmissionsPage() {
const submissions = await mockDb.getSubmissions()
@@ -104,6 +105,14 @@ export default async function AdminSubmissionsPage() {
</form>
</div>
)}
<form action={async () => {
'use server'
await deleteSubmissionAction(sub.id)
}}>
<DeleteButton />
</form>
</td>
</tr>
)
@@ -0,0 +1,110 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Loader2 } from 'lucide-react'
import { saveWidgetPartnerAction } from '@/app/actions'
export default function WidgetPartnerForm({ initialData }: { initialData?: any }) {
const router = useRouter()
const [isPending, setIsPending] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsPending(true)
setError(null)
const formData = new FormData(e.currentTarget)
if (initialData?.id) formData.append('id', initialData.id)
try {
const res = await saveWidgetPartnerAction(formData)
if (res?.error) {
setError(res.error)
} else {
router.push('/tr/admin/widget-partners')
router.refresh()
}
} catch (err) {
setError('Bir hata oluştu')
} finally {
setIsPending(false)
}
}
return (
<form onSubmit={handleSubmit} className="bg-paper p-8 rounded-3xl border border-pine/10 shadow-sm space-y-6">
{error && (
<div className="bg-red-50 text-red-500 p-4 rounded-xl text-sm">
{error}
</div>
)}
<div>
<label className="block text-xs font-mono uppercase tracking-wider text-shutter mb-2">Partner Adı *</label>
<input
type="text"
name="name"
defaultValue={initialData?.name}
required
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-turquoise/20 outline-none transition"
/>
</div>
<div>
<label className="block text-xs font-mono uppercase tracking-wider text-shutter mb-2">Logo URL *</label>
<input
type="text"
name="logoUrl"
defaultValue={initialData?.logoUrl}
required
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-turquoise/20 outline-none transition"
/>
</div>
<div>
<label className="block text-xs font-mono uppercase tracking-wider text-shutter mb-2">Web Sitesi *</label>
<input
type="text"
name="websiteUrl"
defaultValue={initialData?.websiteUrl}
required
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-turquoise/20 outline-none transition"
/>
</div>
<div>
<label className="block text-xs font-mono uppercase tracking-wider text-shutter mb-2">Widget Tipi *</label>
<select
name="widgetType"
defaultValue={initialData?.widgetType || 'listing'}
required
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-2 focus:ring-turquoise/20 outline-none transition"
>
<option value="listing">Listing</option>
<option value="collection">Collection</option>
<option value="custom">Custom</option>
</select>
</div>
<div className="pt-6 border-t border-pine/10 flex justify-end gap-3">
<button
type="button"
onClick={() => router.back()}
className="px-6 py-3 rounded-xl border border-pine/10 text-pine font-bold text-sm hover:bg-stone transition"
>
İptal
</button>
<button
type="submit"
disabled={isPending}
className="px-6 py-3 rounded-xl bg-turquoise text-white font-bold text-sm hover:bg-turquoise/90 transition flex items-center gap-2"
>
{isPending && <Loader2 className="w-4 h-4 animate-spin" />}
Kaydet
</button>
</div>
</form>
)
}
@@ -0,0 +1,36 @@
import { getTranslations } from 'next-intl/server'
import { mockDb } from '@/lib/mockDb'
import { notFound } from 'next/navigation'
import WidgetPartnerForm from './WidgetPartnerForm'
export const dynamic = 'force-dynamic'
interface Props {
params: Promise<{ id: string; locale: string }>
}
export default async function EditWidgetPartnerPage({ params }: Props) {
const { id } = await params
const isNew = id === 'new'
let partner = null
if (!isNew) {
partner = (await mockDb.getWidgetPartners()).find(p => p.id === id)
if (!partner) notFound()
}
return (
<div className="max-w-3xl mx-auto py-10 space-y-6">
<div className="flex flex-col gap-1 mb-8">
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">
{isNew ? 'yeni partner ekle' : 'partneri düzenle'}
</h2>
<p className="text-ink/65 text-xs font-medium">
Widget partneri bilgilerini buradan {isNew ? 'ekleyebilirsiniz' : 'düzenleyebilirsiniz'}.
</p>
</div>
<WidgetPartnerForm initialData={partner} />
</div>
)
}
+22 -5
View File
@@ -3,19 +3,36 @@ import { Link } from '@/i18n/routing'
import { notFound } from 'next/navigation'
import { Calendar, Tag, ArrowLeft } from 'lucide-react'
import ListingCard from '@/components/ListingCard'
import type { Metadata } from 'next'
interface Props {
params: Promise<{ locale: string; slug: string }>
}
export async function generateMetadata({ params }: Props) {
const { slug } = await params
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale, slug } = await params
const post = await mockDb.getBlogPostBySlug(slug)
if (!post) return {}
const title = locale === 'en' ? post.titleEn : locale === 'ru' ? post.titleRu : post.titleTr
const content = locale === 'en' ? post.contentEn : locale === 'ru' ? post.contentRu : post.contentTr
const description = content.substring(0, 150)
const fullTitle = `${title} — Marmaris Local`
return {
title: `${post.titleTr} — Marmaris Local`,
description: post.contentTr.substring(0, 150)
title: fullTitle,
description,
openGraph: {
title: fullTitle,
description,
type: 'article',
...(post.coverImage ? { images: [{ url: post.coverImage }] } : {}),
},
twitter: {
card: 'summary_large_image',
title: fullTitle,
description,
},
}
}
+20 -5
View File
@@ -2,12 +2,27 @@ import { mockDb } from '@/lib/mockDb'
import { Link } from '@/i18n/routing'
import { getTranslations } from 'next-intl/server'
import { Calendar, Tag } from 'lucide-react'
import type { Metadata } from 'next'
import { basicMetadata } from '@/lib/seo'
export async function generateMetadata() {
return {
title: 'Marmaris Local Blog — Yerel Lezzet ve Gezi Rehberi',
description: 'Marmaris\'i bir yerel gibi keşfetmeniz için rehberler, nerede ne yenir tavsiyeleri ve gizli yerler.'
}
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params
const title =
locale === 'en'
? 'Marmaris Local Blog — Local Food & Travel Guides'
: locale === 'ru'
? 'Блог Marmaris Local — Гид по еде и путешествиям'
: 'Marmaris Local Blog — Yerel Lezzet ve Gezi Rehberi'
const description =
locale === 'en'
? 'Guides to explore Marmaris like a local — where to eat, hidden spots and travel tips.'
: locale === 'ru'
? 'Гиды, чтобы открыть Мармарис как местный житель — где поесть, скрытые места и советы путешественникам.'
: "Marmaris'i bir yerel gibi keşfetmeniz için rehberler, nerede ne yenir tavsiyeleri ve gizli yerler."
return basicMetadata(title, description)
}
export default async function BlogIndexPage({ params }: { params: Promise<{ locale: string }> }) {
+16 -5
View File
@@ -3,19 +3,30 @@ import { Link } from '@/i18n/routing'
import { notFound } from 'next/navigation'
import { ArrowLeft, Layers } from 'lucide-react'
import ListingCard from '@/components/ListingCard'
import type { Metadata } from 'next'
interface Props {
params: Promise<{ locale: string; slug: string }>
}
export async function generateMetadata({ params }: Props) {
const { slug } = await params
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale, slug } = await params
const col = await mockDb.getCollectionBySlug(slug)
if (!col) return {}
const title = locale === 'en' ? col.titleEn : locale === 'ru' ? col.titleRu : col.titleTr
const description = locale === 'en' ? col.descriptionEn : locale === 'ru' ? col.descriptionRu : col.descriptionTr
const fullTitle = `${title} — Marmaris Local`
return {
title: `${col.titleTr} Seçkisi — Marmaris Local`,
description: col.descriptionTr
title: fullTitle,
description,
openGraph: {
title: fullTitle,
description,
type: 'website',
...(col.coverImage ? { images: [{ url: col.coverImage }] } : {}),
},
}
}
+20 -5
View File
@@ -2,12 +2,27 @@ import { mockDb } from '@/lib/mockDb'
import { Link } from '@/i18n/routing'
import { getTranslations } from 'next-intl/server'
import { Layers } from 'lucide-react'
import type { Metadata } from 'next'
import { basicMetadata } from '@/lib/seo'
export async function generateMetadata() {
return {
title: 'Marmaris Local Seçkileri — Editör Kürasyonları',
description: 'Marmaris\'teki en iyi restoranlar, apartlar ve hizmetlerin özel tematik derlemeleri.'
}
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params
const title =
locale === 'en'
? 'Curated Collections — Marmaris Local'
: locale === 'ru'
? 'Подборки редакции — Marmaris Local'
: 'Marmaris Local Seçkileri — Editör Kürasyonları'
const description =
locale === 'en'
? 'Themed, locally-approved collections of the best restaurants, apart hotels and services in Marmaris.'
: locale === 'ru'
? 'Тематические подборки лучших ресторанов, апарт-отелей и услуг Мармариса, проверенные местными.'
: "Marmaris'teki en iyi restoranlar, apartlar ve hizmetlerin özel tematik derlemeleri."
return basicMetadata(title, description)
}
export default async function CollectionsIndexPage({ params }: { params: Promise<{ locale: string }> }) {
+22
View File
@@ -1,10 +1,32 @@
import { getTranslations, setRequestLocale } from 'next-intl/server'
import ContactForm from './ContactForm'
import type { Metadata } from 'next'
import { basicMetadata } from '@/lib/seo'
interface ContactPageProps {
params: Promise<{ locale: string }>
}
export async function generateMetadata({ params }: ContactPageProps): Promise<Metadata> {
const { locale } = await params
const title =
locale === 'en'
? 'Contact — Marmaris Local'
: locale === 'ru'
? 'Контакты — Marmaris Local'
: 'İletişim — Marmaris Local'
const description =
locale === 'en'
? 'Get in touch with the Marmaris Local team.'
: locale === 'ru'
? 'Свяжитесь с командой Marmaris Local.'
: 'Marmaris Local ekibiyle iletişime geçin.'
return basicMetadata(title, description)
}
export default async function ContactPage({ params }: ContactPageProps) {
const { locale } = await params
setRequestLocale(locale)
+22
View File
@@ -3,11 +3,33 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'
import { Link } from '@/i18n/routing'
import Image from 'next/image'
import { Calendar, MapPin, Zap } from 'lucide-react'
import type { Metadata } from 'next'
import { basicMetadata } from '@/lib/seo'
interface Props {
params: Promise<{ locale: string }>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params
const title =
locale === 'en'
? 'Events in Marmaris — Marmaris Local'
: locale === 'ru'
? 'События в Мармарисе — Marmaris Local'
: 'Marmaris Etkinlikleri — Marmaris Local'
const description =
locale === 'en'
? 'Upcoming local events, live music nights and pool parties in Marmaris.'
: locale === 'ru'
? 'Предстоящие местные события, живая музыка и вечеринки у бассейна в Мармарисе.'
: 'Marmaris\'teki yaklaşan yerel etkinlikler, canlı müzik geceleri ve havuz partileri.'
return basicMetadata(title, description)
}
export default async function EventsPage({ params }: Props) {
const { locale } = await params
setRequestLocale(locale)
+46 -6
View File
@@ -8,6 +8,7 @@ import { routing } from '@/i18n/routing';
import { headers } from 'next/headers';
import Navbar from '@/components/Navbar';
import Footer from '@/components/Footer';
import { SITE_URL, buildAlternates, ogLocale } from '@/lib/seo';
import "../globals.css";
const unbounded = Unbounded({
@@ -28,16 +29,55 @@ const ibmPlexMono = IBM_Plex_Mono({
weight: ["400", "500"],
});
export const metadata: Metadata = {
title: "Marmaris Local — Yerel Rehber",
description: "Marmaris'in en iyi yerel mekanları, restoranları ve saklı apart otelleri.",
manifest: "/manifest.json",
};
export function generateStaticParams() {
return routing.locales.map((locale) => ({locale}));
}
export async function generateMetadata({
params
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale } = await params
const headersList = await headers();
const pathname = headersList.get('x-pathname') || `/${locale}`;
const title =
locale === 'en'
? 'Marmaris Local — Local Guide, Not the Tourist Trail'
: locale === 'ru'
? 'Marmaris Local — Местный гид по Мармарису'
: 'Marmaris Local — Yerel Rehber';
const description =
locale === 'en'
? "Marmaris' best local spots — restaurants, apart hotels and businesses, curated and locally approved."
: locale === 'ru'
? 'Лучшие места Мармариса — рестораны, апарт-отели и заведения, проверенные местными жителями.'
: "Marmaris'in en iyi yerel mekanları, restoranları ve saklı apart otelleri.";
return {
title,
description,
metadataBase: new URL(SITE_URL),
manifest: '/manifest.json',
alternates: buildAlternates(pathname),
openGraph: {
title,
description,
url: `${SITE_URL}${pathname}`,
siteName: 'Marmaris Local',
locale: ogLocale(locale),
type: 'website',
},
twitter: {
card: 'summary_large_image',
title,
description,
},
};
}
export default async function RootLayout({
children,
params
+26
View File
@@ -3,11 +3,37 @@ import { mockDb } from '@/lib/mockDb'
import ListingCard from '@/components/ListingCard'
import { notFound } from 'next/navigation'
import { MapPin } from 'lucide-react'
import type { Metadata } from 'next'
import { basicMetadata } from '@/lib/seo'
interface NeighborhoodPageProps {
params: Promise<{ locale: string; slug: string }>
}
export async function generateMetadata({ params }: NeighborhoodPageProps): Promise<Metadata> {
const { locale, slug } = await params
const neighborhood = await mockDb.getNeighborhoodBySlug(slug)
if (!neighborhood) return {}
const name = locale === 'ru' ? neighborhood.nameRu : locale === 'en' ? neighborhood.nameEn : neighborhood.nameTr
const title =
locale === 'en'
? `${name}, Marmaris — Local Guide — Marmaris Local`
: locale === 'ru'
? `${name}, Мармарис — Местный гид — Marmaris Local`
: `${name}, Marmaris — Mahalle Rehberi — Marmaris Local`
const description =
locale === 'en'
? `Discover the best restaurants, apart hotels and local businesses in ${name}, Marmaris.`
: locale === 'ru'
? `Лучшие рестораны, апарт-отели и заведения в районе ${name}, Мармарис.`
: `${name}, Marmaris'teki en iyi restoranlar, apart oteller ve yerel işletmeler.`
return basicMetadata(title, description)
}
export default async function NeighborhoodPage({ params }: NeighborhoodPageProps) {
const { locale, slug } = await params
setRequestLocale(locale)
+28 -13
View File
@@ -3,6 +3,29 @@ import { mockDb } from '@/lib/mockDb'
import ListingCard from '@/components/ListingCard'
import { Link } from '@/i18n/routing'
import { Search, MapPin, CheckCircle, ArrowRight } from 'lucide-react'
import LiveSearchInput from '@/components/LiveSearchInput'
import type { Metadata } from 'next'
import { basicMetadata } from '@/lib/seo'
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params
const title =
locale === 'en'
? 'Marmaris Local — Best Local Spots, Hidden From Plain Sight'
: locale === 'ru'
? 'Marmaris Local — Лучшие места, которые знают местные'
: 'Marmaris Local — Turistin Göremediği Yerel Adresler'
const description =
locale === 'en'
? "Discover Marmaris' best restaurants, apart hotels and local businesses — curated and locally approved, not tourist traps."
: locale === 'ru'
? 'Лучшие рестораны, апарт-отели и заведения Мармариса — подборка, проверенная местными жителями.'
: "Marmaris'in turistin göremediği en iyi restoranları, apart otelleri ve yerel işletmeleri — yerel onaylı, güvenilir rehber."
return basicMetadata(title, description)
}
export default async function HomePage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params
@@ -55,7 +78,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
</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">
<h1 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></>
)}
@@ -65,7 +88,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
{locale === 'ru' && (
<>Лучшие места,<br /><span className="text-turquoise">которые знают местные.</span></>
)}
</h2>
</h1>
<p className="text-sm sm:text-base text-stone/70 max-w-xl mx-auto font-medium">
{t('subtitle')}
@@ -73,19 +96,11 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
{/* Search Form */}
<form
action={`/${locale}/restoran`}
action={`/${locale}/${categories[0]?.slug || 'restoran'}`}
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>
<LiveSearchInput placeholder={t('searchPlaceholder')} />
<button
type="submit"
className="bg-turquoise hover:bg-turquoise/90 text-paper font-medium text-xs px-5 py-3 rounded-xl transition"
@@ -168,7 +183,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
</div>
<Link
href="/restaurants?approved=true"
href={`/${categories[0]?.slug || 'restoran'}?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>
+83 -41
View File
@@ -1,70 +1,112 @@
import { MetadataRoute } from 'next'
import { mockDb } from '@/lib/mockDb'
import { SITE_URL, LOCALES } from '@/lib/seo'
function localizedEntries(
pathSuffix: string,
opts: {
lastModified?: Date
changeFrequency?: NonNullable<MetadataRoute.Sitemap[number]['changeFrequency']>
priority?: number
}
): MetadataRoute.Sitemap {
const languages: Record<string, string> = {}
for (const locale of LOCALES) {
languages[locale] = `${SITE_URL}/${locale}${pathSuffix}`
}
languages['x-default'] = `${SITE_URL}/tr${pathSuffix}`
return LOCALES.map((locale) => ({
url: `${SITE_URL}/${locale}${pathSuffix}`,
lastModified: opts.lastModified || new Date(),
changeFrequency: opts.changeFrequency,
priority: opts.priority,
alternates: { languages },
}))
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = 'https://marmarislocal.com'
const locales = ['tr', 'en', 'ru']
const entries: MetadataRoute.Sitemap = []
// Static routes
const staticRoutes = [
'',
'/aparts',
'/restaurants',
'/businesses',
'/add-business',
'/contact',
'/about'
]
// 1. Home
entries.push(...localizedEntries('', { changeFrequency: 'weekly', priority: 1.0 }))
const sitemapEntries: MetadataRoute.Sitemap = []
// 1. Generate static routes for each locale
// 2. Static routes
const staticRoutes = ['/about', '/contact', '/add-business', '/collections', '/blog', '/events']
for (const route of staticRoutes) {
for (const locale of locales) {
sitemapEntries.push({
url: `${baseUrl}/${locale}${route}`,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: route === '' ? 1.0 : 0.8,
})
}
entries.push(...localizedEntries(route, { changeFrequency: 'weekly', priority: 0.7 }))
}
// 2. Dynamic listing routes
// 3. Category landing pages (slugs are DB-driven, not hardcoded)
try {
const categories = await mockDb.getCategories()
for (const category of categories) {
entries.push(...localizedEntries(`/${category.slug}`, { changeFrequency: 'daily', priority: 0.8 }))
}
} catch (e) {
console.error('Sitemap categories fetch error:', e)
}
// 4. Listing detail pages
try {
const listings = await mockDb.getListings()
for (const listing of listings) {
// Find the category slug dynamically
const catSlug = listing.category?.slug || 'isletmeler'
for (const locale of locales) {
sitemapEntries.push({
url: `${baseUrl}/${locale}/${catSlug}/${listing.slug}`,
lastModified: listing.updatedAt || new Date(),
const catSlug = listing.category?.slug || 'isletme'
entries.push(
...localizedEntries(`/${catSlug}/${listing.slug}`, {
lastModified: listing.updatedAt,
changeFrequency: 'weekly',
priority: 0.6,
})
}
)
}
} catch (e) {
console.error('Sitemap listings fetch error:', e)
}
// 3. Dynamic neighborhood routes
// 5. Neighborhood pages
try {
const neighborhoods = await mockDb.getNeighborhoods()
for (const neighborhood of neighborhoods) {
for (const locale of locales) {
sitemapEntries.push({
url: `${baseUrl}/${locale}/neighborhood/${neighborhood.slug}`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.4,
})
}
entries.push(
...localizedEntries(`/neighborhood/${neighborhood.slug}`, { changeFrequency: 'monthly', priority: 0.4 })
)
}
} catch (e) {
console.error('Sitemap neighborhoods fetch error:', e)
}
return sitemapEntries
// 6. Collections
try {
const collections = await mockDb.getCollections()
for (const collection of collections) {
entries.push(
...localizedEntries(`/collection/${collection.slug}`, {
lastModified: collection.updatedAt,
changeFrequency: 'monthly',
priority: 0.5,
})
)
}
} catch (e) {
console.error('Sitemap collections fetch error:', e)
}
// 7. Blog posts
try {
const posts = await mockDb.getBlogPosts(true)
for (const post of posts) {
entries.push(
...localizedEntries(`/blog/${post.slug}`, {
lastModified: post.updatedAt,
changeFrequency: 'monthly',
priority: 0.5,
})
)
}
} catch (e) {
console.error('Sitemap blog posts fetch error:', e)
}
return entries
}