chore: clean up scratch files and apply remaining updates
This commit is contained in:
@@ -7,6 +7,8 @@ import { Link } from '@/i18n/routing'
|
|||||||
import { Phone, Globe, MapPin, Clock, Star, MessageSquare, Share2 } from 'lucide-react'
|
import { Phone, Globe, MapPin, Clock, Star, MessageSquare, Share2 } from 'lucide-react'
|
||||||
import SaveButton from './SaveButton'
|
import SaveButton from './SaveButton'
|
||||||
import DetailTracker from './DetailTracker'
|
import DetailTracker from './DetailTracker'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { SITE_URL } from '@/lib/seo'
|
||||||
|
|
||||||
interface DetailPageProps {
|
interface DetailPageProps {
|
||||||
params: Promise<{ locale: string; category: string; slug: string }>
|
params: Promise<{ locale: string; category: string; slug: string }>
|
||||||
@@ -14,14 +16,32 @@ interface DetailPageProps {
|
|||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
export async function generateMetadata({ params }: DetailPageProps) {
|
export async function generateMetadata({ params }: DetailPageProps): Promise<Metadata> {
|
||||||
const { slug } = await params
|
const { locale, slug } = await params
|
||||||
const listing = await mockDb.getListingBySlug(slug)
|
const listing = await mockDb.getListingBySlug(slug)
|
||||||
if (!listing) return {}
|
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 {
|
return {
|
||||||
title: `${listing.nameTr} — Marmaris Local`,
|
title,
|
||||||
description: listing.descriptionTr
|
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}`
|
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 (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
|
<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} />
|
<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">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 flex-1 space-y-10">
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { Link } from '@/i18n/routing'
|
|||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { MapPin, SlidersHorizontal, Check } from 'lucide-react'
|
import { MapPin, SlidersHorizontal, Check } from 'lucide-react'
|
||||||
import LiveFilterForm from '@/components/LiveFilterForm'
|
import LiveFilterForm from '@/components/LiveFilterForm'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { basicMetadata } from '@/lib/seo'
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ locale: string, category: string }>
|
params: Promise<{ locale: string, category: string }>
|
||||||
@@ -18,6 +20,31 @@ interface PageProps {
|
|||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
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) {
|
export default async function DynamicCategoryPage({ params, searchParams }: PageProps) {
|
||||||
const { locale, category: categorySlug } = await params
|
const { locale, category: categorySlug } = await params
|
||||||
setRequestLocale(locale)
|
setRequestLocale(locale)
|
||||||
|
|||||||
@@ -1,11 +1,33 @@
|
|||||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||||
import { Link } from '@/i18n/routing'
|
import { Link } from '@/i18n/routing'
|
||||||
import { CheckCircle, ShieldAlert, Award, Star } from 'lucide-react'
|
import { CheckCircle, ShieldAlert, Award, Star } from 'lucide-react'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { basicMetadata } from '@/lib/seo'
|
||||||
|
|
||||||
interface AboutPageProps {
|
interface AboutPageProps {
|
||||||
params: Promise<{ locale: string }>
|
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) {
|
export default async function AboutPage({ params }: AboutPageProps) {
|
||||||
const { locale } = await params
|
const { locale } = await params
|
||||||
setRequestLocale(locale)
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import { mockDb } from '@/lib/mockDb'
|
import { mockDb } from '@/lib/mockDb'
|
||||||
import BlogForm from './BlogForm'
|
import BlogPostForm from './BlogPostForm'
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ locale: string; id: string }>
|
params: Promise<{ locale: string; id: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminBlogEditPage({ params }: Props) {
|
export default async function AdminBlogPostEditPage({ params }: Props) {
|
||||||
const { id } = await params
|
const { locale, id } = await params
|
||||||
|
|
||||||
let post = null
|
let post = null
|
||||||
|
|
||||||
if (id !== 'new') {
|
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 (
|
return (
|
||||||
<div className="space-y-6 max-w-5xl">
|
<div className="space-y-6 max-w-4xl">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">
|
<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>
|
</h2>
|
||||||
<p className="text-ink/65 text-xs font-medium mt-1">
|
<p className="text-ink/65 text-xs font-medium mt-1">
|
||||||
{id === 'new'
|
{id === 'new'
|
||||||
? 'Yeni bir rehber veya gastronomi tanıtım yazısı oluşturun.'
|
? 'Yeni bir blog yazısı oluşturun.'
|
||||||
: 'Mevcut blog yazısı içeriğini güncelleyin.'}
|
: 'Mevcut blog yazısı bilgilerini güncelleyin.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-paper border border-pine/8 rounded-3xl shadow-sm p-6 sm:p-8">
|
<div className="bg-paper border border-pine/8 rounded-3xl shadow-sm p-6 sm:p-8">
|
||||||
<BlogForm
|
<BlogPostForm post={post} />
|
||||||
post={post}
|
|
||||||
listingOptions={listingOptions}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { createOrUpdateCollectionAction } from '@/app/actions'
|
import { createOrUpdateCollectionAction } from '@/app/actions'
|
||||||
import { ArrowLeft, Save } from 'lucide-react'
|
|
||||||
import { Link } from '@/i18n/routing'
|
import { Link } from '@/i18n/routing'
|
||||||
|
import { ArrowLeft, Save } from 'lucide-react'
|
||||||
|
|
||||||
interface Option {
|
interface Option {
|
||||||
value: string
|
value: string
|
||||||
@@ -13,23 +13,24 @@ interface Option {
|
|||||||
|
|
||||||
interface FormProps {
|
interface FormProps {
|
||||||
collection: any | null
|
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 router = useRouter()
|
||||||
const [activeTab, setActiveTab] = useState<'tr' | 'en' | 'ru'>('tr')
|
const [activeTab, setActiveTab] = useState<'tr' | 'en' | 'ru'>('tr')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [success, setSuccess] = useState(false)
|
const [success, setSuccess] = useState(false)
|
||||||
|
|
||||||
|
// Track selected listing IDs
|
||||||
|
const [selectedListingIds, setSelectedListingIds] = useState<string[]>(
|
||||||
|
collection?.listingIds || []
|
||||||
|
)
|
||||||
|
|
||||||
// Selected Listings
|
const toggleListing = (id: string) => {
|
||||||
const [selectedListings, setSelectedListings] = useState<string[]>(collection?.listingIds || [])
|
setSelectedListingIds(prev =>
|
||||||
const [listingSearch, setListingSearch] = useState('')
|
prev.includes(id) ? prev.filter(p => p !== id) : [...prev, id]
|
||||||
|
|
||||||
const handleListingToggle = (id: string) => {
|
|
||||||
setSelectedListings(prev =>
|
|
||||||
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +44,11 @@ export default function CollectionForm({ collection, listingOptions }: FormProps
|
|||||||
if (collection) {
|
if (collection) {
|
||||||
formData.append('id', collection.id)
|
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 {
|
try {
|
||||||
const res = await createOrUpdateCollectionAction(formData)
|
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 (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{error && (
|
{error && (
|
||||||
@@ -77,11 +78,10 @@ export default function CollectionForm({ collection, listingOptions }: FormProps
|
|||||||
|
|
||||||
{success && (
|
{success && (
|
||||||
<div className="bg-turquoise/10 border border-turquoise/25 text-turquoise p-4 rounded-xl text-xs font-bold">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Language tabs */}
|
|
||||||
<div className="border-b border-pine/8">
|
<div className="border-b border-pine/8">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{(['tr', 'en', 'ru'] as const).map((lang) => {
|
{(['tr', 'en', 'ru'] as const).map((lang) => {
|
||||||
@@ -105,88 +105,40 @@ export default function CollectionForm({ collection, listingOptions }: FormProps
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Localized inputs */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{activeTab === 'tr' && (
|
{activeTab === 'tr' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Seçki Başlığı (TR) *</label>
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Başlık (TR) *</label>
|
||||||
<input
|
<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ı" />
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Açıklama (TR) *</label>
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Açıklama (TR) *</label>
|
||||||
<textarea
|
<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..." />
|
||||||
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..."
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'en' && (
|
{activeTab === 'en' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Seçki Başlığı (EN) *</label>
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Başlık (EN) *</label>
|
||||||
<input
|
<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" />
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Açıklama (EN) *</label>
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Açıklama (EN) *</label>
|
||||||
<textarea
|
<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..." />
|
||||||
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..."
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'ru' && (
|
{activeTab === 'ru' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Seçki Başlığı (RU) *</label>
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Başlık (RU) *</label>
|
||||||
<input
|
<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. Лучшие места для завтрака" />
|
||||||
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: Семейные рестораны"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Açıklama (RU) *</label>
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Açıklama (RU) *</label>
|
||||||
<textarea
|
<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..." />
|
||||||
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="Описание подборки..."
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -194,97 +146,42 @@ export default function CollectionForm({ collection, listingOptions }: FormProps
|
|||||||
|
|
||||||
<hr className="border-dashed border-pine/8" />
|
<hr className="border-dashed border-pine/8" />
|
||||||
|
|
||||||
{/* Global fields */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
{/* Slug */}
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">URL Slug *</label>
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">URL Slug *</label>
|
||||||
<input
|
<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" />
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cover Image Upload */}
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Kapak Görseli</label>
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Kapak Görseli URL</label>
|
||||||
<input
|
<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://..." />
|
||||||
type="file"
|
</div>
|
||||||
name="coverImageFile"
|
</div>
|
||||||
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"
|
<div className="space-y-1.5">
|
||||||
/>
|
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Koleksiyon Mekanları (Seçiniz)</label>
|
||||||
{collection?.coverImage && (
|
<div className="bg-stone border border-pine/10 rounded-xl p-4 max-h-60 overflow-y-auto space-y-2">
|
||||||
<div className="mt-2 text-xs flex items-center gap-2">
|
{allListings.map(listing => (
|
||||||
<span className="text-shutter/65">Mevcut Görsel:</span>
|
<label key={listing.value} className="flex items-center gap-2 cursor-pointer hover:bg-stone-deep/20 p-2 rounded-lg transition">
|
||||||
<a href={collection.coverImage} target="_blank" rel="noopener noreferrer" className="text-turquoise hover:underline font-mono truncate max-w-xs">{collection.coverImage}</a>
|
<input
|
||||||
<input type="hidden" name="coverImageUrl" value={collection.coverImage} />
|
type="checkbox"
|
||||||
</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Listing multi-select panel */}
|
<div className="flex items-center justify-between pt-4">
|
||||||
<div className="space-y-2 border-t border-dashed border-pine/8 pt-4">
|
<Link href="/admin/collections" className="text-xs font-bold text-shutter hover:text-pine transition inline-flex items-center gap-1">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Seçkide Yer Alan Mekanlar * ({selectedListings.length} seçildi)</label>
|
İptal ve Geri Dön
|
||||||
<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
|
|
||||||
</Link>
|
</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">
|
||||||
<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" />
|
<Save className="w-4 h-4" />
|
||||||
{loading ? 'Kaydediliyor...' : 'Seçkiyi Kaydet'}
|
{loading ? 'Kaydediliyor...' : 'Kaydet'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminCollectionEditPage({ params }: Props) {
|
export default async function AdminCollectionEditPage({ params }: Props) {
|
||||||
const { id } = await params
|
const { locale, id } = await params
|
||||||
|
|
||||||
let collection = null
|
let collection = null
|
||||||
|
|
||||||
if (id !== 'new') {
|
if (id !== 'new') {
|
||||||
@@ -17,30 +18,29 @@ export default async function AdminCollectionEditPage({ params }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch listings for option list
|
const allListings = await mockDb.getListings()
|
||||||
const listings = await mockDb.getListings()
|
const listingOptions = allListings.map(l => ({
|
||||||
const listingOptions = listings.map(l => ({
|
|
||||||
value: l.id,
|
value: l.id,
|
||||||
label: `${l.nameTr} (${l.neighborhood?.nameTr})`
|
label: l.nameTr
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-5xl">
|
<div className="space-y-6 max-w-4xl">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">
|
<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>
|
</h2>
|
||||||
<p className="text-ink/65 text-xs font-medium mt-1">
|
<p className="text-ink/65 text-xs font-medium mt-1">
|
||||||
{id === 'new'
|
{id === 'new'
|
||||||
? 'Yeni bir tematik mekan derlemesi oluşturun.'
|
? 'Yeni bir mekan kürasyonu oluşturun.'
|
||||||
: 'Mevcut kürasyon seki bilgilerini güncelleyin.'}
|
: 'Mevcut kürasyon bilgilerini güncelleyin.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-paper border border-pine/8 rounded-3xl shadow-sm p-6 sm:p-8">
|
<div className="bg-paper border border-pine/8 rounded-3xl shadow-sm p-6 sm:p-8">
|
||||||
<CollectionForm
|
<CollectionForm
|
||||||
collection={collection}
|
collection={collection}
|
||||||
listingOptions={listingOptions}
|
allListings={listingOptions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { mockDb } from '@/lib/mockDb'
|
import { mockDb } from '@/lib/mockDb'
|
||||||
import { deleteCollectionAction } from '@/app/actions'
|
import { deleteCollectionAction } from '@/app/actions'
|
||||||
import { Link } from '@/i18n/routing'
|
import { Link } from '@/i18n/routing'
|
||||||
|
import DeleteButton from '@/components/DeleteButton'
|
||||||
import { Edit, Trash, Plus, Layers } from 'lucide-react'
|
import { Edit, Trash, Plus, Layers } from 'lucide-react'
|
||||||
|
|
||||||
export default async function AdminCollectionsPage() {
|
export default async function AdminCollectionsPage() {
|
||||||
@@ -79,17 +80,11 @@ export default async function AdminCollectionsPage() {
|
|||||||
<Edit className="w-4 h-4" />
|
<Edit className="w-4 h-4" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<form action={async () => {
|
<form action={async () => {
|
||||||
'use server'
|
'use server'
|
||||||
await deleteCollectionAction(col.id)
|
await deleteCollectionAction(col.id)
|
||||||
}}>
|
}}>
|
||||||
<button
|
<DeleteButton />
|
||||||
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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { mockDb } from '@/lib/mockDb'
|
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'
|
import { Check, MailOpen, Mail } from 'lucide-react'
|
||||||
|
|
||||||
export default async function AdminMessagesPage() {
|
export default async function AdminMessagesPage() {
|
||||||
@@ -54,7 +55,7 @@ export default async function AdminMessagesPage() {
|
|||||||
<td className="px-6 py-4 max-w-md">
|
<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>
|
<p className="text-xs break-words leading-relaxed whitespace-pre-line font-medium">{msg.message}</p>
|
||||||
</td>
|
</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 ? (
|
{!msg.isRead ? (
|
||||||
<form action={async () => {
|
<form action={async () => {
|
||||||
'use server'
|
'use server'
|
||||||
@@ -74,6 +75,14 @@ export default async function AdminMessagesPage() {
|
|||||||
Okundu
|
Okundu
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<form action={async () => {
|
||||||
|
'use server'
|
||||||
|
await deleteMessageAction(msg.id)
|
||||||
|
}} className="ml-2">
|
||||||
|
<DeleteButton />
|
||||||
|
</form>
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { mockDb } from '@/lib/mockDb'
|
import { mockDb } from '@/lib/mockDb'
|
||||||
import { approveSubmissionAction, rejectSubmissionAction } from '@/app/actions'
|
import { approveSubmissionAction, rejectSubmissionAction, deleteSubmissionAction } from '@/app/actions'
|
||||||
import { Check, X } from 'lucide-react'
|
import DeleteButton from '@/components/DeleteButton'
|
||||||
|
import { Check, X, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
export default async function AdminSubmissionsPage() {
|
export default async function AdminSubmissionsPage() {
|
||||||
const submissions = await mockDb.getSubmissions()
|
const submissions = await mockDb.getSubmissions()
|
||||||
@@ -104,6 +105,14 @@ export default async function AdminSubmissionsPage() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<form action={async () => {
|
||||||
|
'use server'
|
||||||
|
await deleteSubmissionAction(sub.id)
|
||||||
|
}}>
|
||||||
|
<DeleteButton />
|
||||||
|
</form>
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,19 +3,36 @@ import { Link } from '@/i18n/routing'
|
|||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { Calendar, Tag, ArrowLeft } from 'lucide-react'
|
import { Calendar, Tag, ArrowLeft } from 'lucide-react'
|
||||||
import ListingCard from '@/components/ListingCard'
|
import ListingCard from '@/components/ListingCard'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ locale: string; slug: string }>
|
params: Promise<{ locale: string; slug: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: Props) {
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
const { slug } = await params
|
const { locale, slug } = await params
|
||||||
const post = await mockDb.getBlogPostBySlug(slug)
|
const post = await mockDb.getBlogPostBySlug(slug)
|
||||||
if (!post) return {}
|
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 {
|
return {
|
||||||
title: `${post.titleTr} — Marmaris Local`,
|
title: fullTitle,
|
||||||
description: post.contentTr.substring(0, 150)
|
description,
|
||||||
|
openGraph: {
|
||||||
|
title: fullTitle,
|
||||||
|
description,
|
||||||
|
type: 'article',
|
||||||
|
...(post.coverImage ? { images: [{ url: post.coverImage }] } : {}),
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: fullTitle,
|
||||||
|
description,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,27 @@ import { mockDb } from '@/lib/mockDb'
|
|||||||
import { Link } from '@/i18n/routing'
|
import { Link } from '@/i18n/routing'
|
||||||
import { getTranslations } from 'next-intl/server'
|
import { getTranslations } from 'next-intl/server'
|
||||||
import { Calendar, Tag } from 'lucide-react'
|
import { Calendar, Tag } from 'lucide-react'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { basicMetadata } from '@/lib/seo'
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||||
return {
|
const { locale } = await params
|
||||||
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.'
|
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 }> }) {
|
export default async function BlogIndexPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
|||||||
@@ -3,19 +3,30 @@ import { Link } from '@/i18n/routing'
|
|||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { ArrowLeft, Layers } from 'lucide-react'
|
import { ArrowLeft, Layers } from 'lucide-react'
|
||||||
import ListingCard from '@/components/ListingCard'
|
import ListingCard from '@/components/ListingCard'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ locale: string; slug: string }>
|
params: Promise<{ locale: string; slug: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: Props) {
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
const { slug } = await params
|
const { locale, slug } = await params
|
||||||
const col = await mockDb.getCollectionBySlug(slug)
|
const col = await mockDb.getCollectionBySlug(slug)
|
||||||
if (!col) return {}
|
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 {
|
return {
|
||||||
title: `${col.titleTr} Seçkisi — Marmaris Local`,
|
title: fullTitle,
|
||||||
description: col.descriptionTr
|
description,
|
||||||
|
openGraph: {
|
||||||
|
title: fullTitle,
|
||||||
|
description,
|
||||||
|
type: 'website',
|
||||||
|
...(col.coverImage ? { images: [{ url: col.coverImage }] } : {}),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,27 @@ import { mockDb } from '@/lib/mockDb'
|
|||||||
import { Link } from '@/i18n/routing'
|
import { Link } from '@/i18n/routing'
|
||||||
import { getTranslations } from 'next-intl/server'
|
import { getTranslations } from 'next-intl/server'
|
||||||
import { Layers } from 'lucide-react'
|
import { Layers } from 'lucide-react'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { basicMetadata } from '@/lib/seo'
|
||||||
|
|
||||||
export async function generateMetadata() {
|
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
|
||||||
return {
|
const { locale } = await params
|
||||||
title: 'Marmaris Local Seçkileri — Editör Kürasyonları',
|
|
||||||
description: 'Marmaris\'teki en iyi restoranlar, apartlar ve hizmetlerin özel tematik derlemeleri.'
|
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 }> }) {
|
export default async function CollectionsIndexPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
|||||||
@@ -1,10 +1,32 @@
|
|||||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||||
import ContactForm from './ContactForm'
|
import ContactForm from './ContactForm'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { basicMetadata } from '@/lib/seo'
|
||||||
|
|
||||||
interface ContactPageProps {
|
interface ContactPageProps {
|
||||||
params: Promise<{ locale: string }>
|
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) {
|
export default async function ContactPage({ params }: ContactPageProps) {
|
||||||
const { locale } = await params
|
const { locale } = await params
|
||||||
setRequestLocale(locale)
|
setRequestLocale(locale)
|
||||||
|
|||||||
@@ -3,11 +3,33 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'
|
|||||||
import { Link } from '@/i18n/routing'
|
import { Link } from '@/i18n/routing'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { Calendar, MapPin, Zap } from 'lucide-react'
|
import { Calendar, MapPin, Zap } from 'lucide-react'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { basicMetadata } from '@/lib/seo'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ locale: string }>
|
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) {
|
export default async function EventsPage({ params }: Props) {
|
||||||
const { locale } = await params
|
const { locale } = await params
|
||||||
setRequestLocale(locale)
|
setRequestLocale(locale)
|
||||||
|
|||||||
+46
-6
@@ -8,6 +8,7 @@ import { routing } from '@/i18n/routing';
|
|||||||
import { headers } from 'next/headers';
|
import { headers } from 'next/headers';
|
||||||
import Navbar from '@/components/Navbar';
|
import Navbar from '@/components/Navbar';
|
||||||
import Footer from '@/components/Footer';
|
import Footer from '@/components/Footer';
|
||||||
|
import { SITE_URL, buildAlternates, ogLocale } from '@/lib/seo';
|
||||||
import "../globals.css";
|
import "../globals.css";
|
||||||
|
|
||||||
const unbounded = Unbounded({
|
const unbounded = Unbounded({
|
||||||
@@ -28,16 +29,55 @@ const ibmPlexMono = IBM_Plex_Mono({
|
|||||||
weight: ["400", "500"],
|
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() {
|
export function generateStaticParams() {
|
||||||
return routing.locales.map((locale) => ({locale}));
|
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({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
params
|
params
|
||||||
|
|||||||
@@ -3,11 +3,37 @@ import { mockDb } from '@/lib/mockDb'
|
|||||||
import ListingCard from '@/components/ListingCard'
|
import ListingCard from '@/components/ListingCard'
|
||||||
import { notFound } from 'next/navigation'
|
import { notFound } from 'next/navigation'
|
||||||
import { MapPin } from 'lucide-react'
|
import { MapPin } from 'lucide-react'
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
import { basicMetadata } from '@/lib/seo'
|
||||||
|
|
||||||
interface NeighborhoodPageProps {
|
interface NeighborhoodPageProps {
|
||||||
params: Promise<{ locale: string; slug: string }>
|
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) {
|
export default async function NeighborhoodPage({ params }: NeighborhoodPageProps) {
|
||||||
const { locale, slug } = await params
|
const { locale, slug } = await params
|
||||||
setRequestLocale(locale)
|
setRequestLocale(locale)
|
||||||
|
|||||||
+28
-13
@@ -3,6 +3,29 @@ import { mockDb } from '@/lib/mockDb'
|
|||||||
import ListingCard from '@/components/ListingCard'
|
import ListingCard from '@/components/ListingCard'
|
||||||
import { Link } from '@/i18n/routing'
|
import { Link } from '@/i18n/routing'
|
||||||
import { Search, MapPin, CheckCircle, ArrowRight } from 'lucide-react'
|
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 }> }) {
|
export default async function HomePage({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
const { locale } = await params
|
const { locale } = await params
|
||||||
@@ -55,7 +78,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
|
|||||||
</div>
|
</div>
|
||||||
</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' && (
|
{locale === 'tr' && (
|
||||||
<>En iyi yerel adresler,<br /><span className="text-turquoise">turistin göremediği yerde.</span></>
|
<>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' && (
|
{locale === 'ru' && (
|
||||||
<>Лучшие места,<br /><span className="text-turquoise">которые знают местные.</span></>
|
<>Лучшие места,<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">
|
<p className="text-sm sm:text-base text-stone/70 max-w-xl mx-auto font-medium">
|
||||||
{t('subtitle')}
|
{t('subtitle')}
|
||||||
@@ -73,19 +96,11 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
|
|||||||
|
|
||||||
{/* Search Form */}
|
{/* Search Form */}
|
||||||
<form
|
<form
|
||||||
action={`/${locale}/restoran`}
|
action={`/${locale}/${categories[0]?.slug || 'restoran'}`}
|
||||||
method="GET"
|
method="GET"
|
||||||
className="max-w-xl mx-auto bg-paper p-2 rounded-2xl flex items-center shadow-lg border border-white/10"
|
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">
|
<LiveSearchInput placeholder={t('searchPlaceholder')} />
|
||||||
<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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="bg-turquoise hover:bg-turquoise/90 text-paper font-medium text-xs px-5 py-3 rounded-xl transition"
|
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>
|
</div>
|
||||||
|
|
||||||
<Link
|
<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"
|
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>
|
<span>{locale === 'tr' ? 'tüm onaylı mekanlar' : locale === 'en' ? 'all approved places' : 'все проверенные места'}</span>
|
||||||
|
|||||||
+83
-41
@@ -1,70 +1,112 @@
|
|||||||
import { MetadataRoute } from 'next'
|
import { MetadataRoute } from 'next'
|
||||||
import { mockDb } from '@/lib/mockDb'
|
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> {
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
const baseUrl = 'https://marmarislocal.com'
|
const entries: MetadataRoute.Sitemap = []
|
||||||
const locales = ['tr', 'en', 'ru']
|
|
||||||
|
|
||||||
// Static routes
|
// 1. Home
|
||||||
const staticRoutes = [
|
entries.push(...localizedEntries('', { changeFrequency: 'weekly', priority: 1.0 }))
|
||||||
'',
|
|
||||||
'/aparts',
|
|
||||||
'/restaurants',
|
|
||||||
'/businesses',
|
|
||||||
'/add-business',
|
|
||||||
'/contact',
|
|
||||||
'/about'
|
|
||||||
]
|
|
||||||
|
|
||||||
const sitemapEntries: MetadataRoute.Sitemap = []
|
// 2. Static routes
|
||||||
|
const staticRoutes = ['/about', '/contact', '/add-business', '/collections', '/blog', '/events']
|
||||||
// 1. Generate static routes for each locale
|
|
||||||
for (const route of staticRoutes) {
|
for (const route of staticRoutes) {
|
||||||
for (const locale of locales) {
|
entries.push(...localizedEntries(route, { changeFrequency: 'weekly', priority: 0.7 }))
|
||||||
sitemapEntries.push({
|
|
||||||
url: `${baseUrl}/${locale}${route}`,
|
|
||||||
lastModified: new Date(),
|
|
||||||
changeFrequency: 'weekly',
|
|
||||||
priority: route === '' ? 1.0 : 0.8,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
try {
|
||||||
const listings = await mockDb.getListings()
|
const listings = await mockDb.getListings()
|
||||||
for (const listing of listings) {
|
for (const listing of listings) {
|
||||||
// Find the category slug dynamically
|
const catSlug = listing.category?.slug || 'isletme'
|
||||||
const catSlug = listing.category?.slug || 'isletmeler'
|
entries.push(
|
||||||
for (const locale of locales) {
|
...localizedEntries(`/${catSlug}/${listing.slug}`, {
|
||||||
sitemapEntries.push({
|
lastModified: listing.updatedAt,
|
||||||
url: `${baseUrl}/${locale}/${catSlug}/${listing.slug}`,
|
|
||||||
lastModified: listing.updatedAt || new Date(),
|
|
||||||
changeFrequency: 'weekly',
|
changeFrequency: 'weekly',
|
||||||
priority: 0.6,
|
priority: 0.6,
|
||||||
})
|
})
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Sitemap listings fetch error:', e)
|
console.error('Sitemap listings fetch error:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Dynamic neighborhood routes
|
// 5. Neighborhood pages
|
||||||
try {
|
try {
|
||||||
const neighborhoods = await mockDb.getNeighborhoods()
|
const neighborhoods = await mockDb.getNeighborhoods()
|
||||||
for (const neighborhood of neighborhoods) {
|
for (const neighborhood of neighborhoods) {
|
||||||
for (const locale of locales) {
|
entries.push(
|
||||||
sitemapEntries.push({
|
...localizedEntries(`/neighborhood/${neighborhood.slug}`, { changeFrequency: 'monthly', priority: 0.4 })
|
||||||
url: `${baseUrl}/${locale}/neighborhood/${neighborhood.slug}`,
|
)
|
||||||
lastModified: new Date(),
|
|
||||||
changeFrequency: 'monthly',
|
|
||||||
priority: 0.4,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Sitemap neighborhoods fetch error:', 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
import { searchListingsAction } from '@/app/actions'
|
||||||
|
import { useLocale } from 'next-intl'
|
||||||
|
|
||||||
|
interface Suggestion {
|
||||||
|
id: string
|
||||||
|
nameTr: string
|
||||||
|
nameEn: string
|
||||||
|
nameRu: string
|
||||||
|
slug: string
|
||||||
|
categorySlug: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LiveSearchInput({
|
||||||
|
placeholder = "İsim veya adres...",
|
||||||
|
defaultValue = "",
|
||||||
|
className = ""
|
||||||
|
}: {
|
||||||
|
placeholder?: string
|
||||||
|
defaultValue?: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState(defaultValue)
|
||||||
|
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
|
const router = useRouter()
|
||||||
|
const locale = useLocale()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (query.length < 2) {
|
||||||
|
setSuggestions([])
|
||||||
|
setIsOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
const results = await searchListingsAction(query)
|
||||||
|
setSuggestions(results)
|
||||||
|
setIsOpen(true)
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
const getLocalizedName = (s: Suggestion) => {
|
||||||
|
return locale === 'ru' ? s.nameRu : locale === 'en' ? s.nameEn : s.nameTr
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-full" ref={wrapperRef}>
|
||||||
|
<div className="flex items-center flex-1 w-full">
|
||||||
|
<Search className="w-5 h-5 text-shutter shrink-0 absolute left-3" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="search"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onFocus={() => {
|
||||||
|
if (suggestions.length > 0) setIsOpen(true)
|
||||||
|
}}
|
||||||
|
className={className ? className : "w-full bg-transparent border-0 focus:ring-0 text-sm py-3 pl-10 pr-3 text-ink placeholder:text-ink/40 outline-none h-full rounded-xl"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && suggestions.length > 0 && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-2 bg-paper border border-pine/10 rounded-xl shadow-xl overflow-hidden z-50">
|
||||||
|
<ul className="py-1">
|
||||||
|
{suggestions.map((s) => (
|
||||||
|
<li key={s.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setQuery(getLocalizedName(s))
|
||||||
|
setIsOpen(false)
|
||||||
|
router.push(`/${locale}/${s.categorySlug}/${s.slug}`)
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2.5 hover:bg-stone/50 transition-colors flex items-center justify-between group"
|
||||||
|
>
|
||||||
|
<span className="font-heading font-bold text-pine lowercase text-sm group-hover:text-turquoise transition-colors">
|
||||||
|
{getLocalizedName(s)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-mono text-shutter uppercase tracking-wider">
|
||||||
|
{s.categorySlug}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -80,9 +80,9 @@ export default function Navbar({ categories = [] }: { categories?: Category[] })
|
|||||||
<span className="font-heading font-extrabold text-pine text-sm tracking-tighter">ML</span>
|
<span className="font-heading font-extrabold text-pine text-sm tracking-tighter">ML</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:block">
|
<div className="hidden sm:block">
|
||||||
<h1 className="font-heading font-extrabold text-lg text-stone tracking-tight leading-none lowercase">
|
<p className="font-heading font-extrabold text-lg text-stone tracking-tight leading-none lowercase">
|
||||||
marmaris <span className="text-turquoise">local</span>
|
marmaris <span className="text-turquoise">local</span>
|
||||||
</h1>
|
</p>
|
||||||
<p className="text-[9px] font-mono text-shutter tracking-wider mt-0.5 uppercase">
|
<p className="text-[9px] font-mono text-shutter tracking-wider mt-0.5 uppercase">
|
||||||
local knowledge
|
local knowledge
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
export const SITE_URL = 'https://marmarislocal.com'
|
||||||
|
export const LOCALES = ['tr', 'en', 'ru'] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds self-referencing canonical + reciprocal hreflang alternates for a given
|
||||||
|
* request pathname (as set by proxy.ts on `x-pathname`, e.g. "/en/restoran/foo").
|
||||||
|
* x-default falls back to the site's default locale (tr).
|
||||||
|
*/
|
||||||
|
export function buildAlternates(pathname: string): Metadata['alternates'] {
|
||||||
|
const segments = pathname.split('/').filter(Boolean)
|
||||||
|
const currentLocale = LOCALES.includes(segments[0] as any) ? segments[0] : 'tr'
|
||||||
|
const rest = segments.slice(LOCALES.includes(segments[0] as any) ? 1 : 0).join('/')
|
||||||
|
const suffix = rest ? `/${rest}` : ''
|
||||||
|
|
||||||
|
const languages: Record<string, string> = {}
|
||||||
|
for (const locale of LOCALES) {
|
||||||
|
languages[locale] = `${SITE_URL}/${locale}${suffix}`
|
||||||
|
}
|
||||||
|
languages['x-default'] = `${SITE_URL}/tr${suffix}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
canonical: `${SITE_URL}/${currentLocale}${suffix}`,
|
||||||
|
languages,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ogLocale(locale: string): string {
|
||||||
|
return locale === 'en' ? 'en_US' : locale === 'ru' ? 'ru_RU' : 'tr_TR'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata for pages that only need a localized title/description, but still
|
||||||
|
* want their own Open Graph/Twitter tags instead of inheriting the layout's
|
||||||
|
* generic sitewide fallback (which would otherwise show on every such page).
|
||||||
|
*/
|
||||||
|
export function basicMetadata(title: string, description: string): Metadata {
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
openGraph: { title, description, type: 'website' },
|
||||||
|
twitter: { card: 'summary_large_image', title, description },
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -66,7 +66,7 @@
|
|||||||
"noResults": "No results match your criteria.",
|
"noResults": "No results match your criteria.",
|
||||||
"allNeighborhoods": "All Neighborhoods",
|
"allNeighborhoods": "All Neighborhoods",
|
||||||
"allPrices": "All Prices",
|
"allPrices": "All Prices",
|
||||||
"rating": "Rating",
|
"rating": "Google Reviews",
|
||||||
"address": "Address",
|
"address": "Address",
|
||||||
"phone": "Phone",
|
"phone": "Phone",
|
||||||
"price": "Price",
|
"price": "Price",
|
||||||
|
|||||||
+1
-1
@@ -66,7 +66,7 @@
|
|||||||
"noResults": "Ничего не найдено по вашему запросу.",
|
"noResults": "Ничего не найдено по вашему запросу.",
|
||||||
"allNeighborhoods": "Все районы",
|
"allNeighborhoods": "Все районы",
|
||||||
"allPrices": "Любые цены",
|
"allPrices": "Любые цены",
|
||||||
"rating": "Рейтинг",
|
"rating": "Оценка Google",
|
||||||
"address": "Адрес",
|
"address": "Адрес",
|
||||||
"phone": "Телефон",
|
"phone": "Телефон",
|
||||||
"price": "Цена",
|
"price": "Цена",
|
||||||
|
|||||||
+1
-1
@@ -66,7 +66,7 @@
|
|||||||
"noResults": "Kriterlerinize uygun sonuç bulunamadı.",
|
"noResults": "Kriterlerinize uygun sonuç bulunamadı.",
|
||||||
"allNeighborhoods": "Tüm Mahalleler",
|
"allNeighborhoods": "Tüm Mahalleler",
|
||||||
"allPrices": "Tüm Fiyatlar",
|
"allPrices": "Tüm Fiyatlar",
|
||||||
"rating": "Puan",
|
"rating": "Google Puanı",
|
||||||
"address": "Adres",
|
"address": "Adres",
|
||||||
"phone": "Telefon",
|
"phone": "Telefon",
|
||||||
"price": "Fiyat",
|
"price": "Fiyat",
|
||||||
|
|||||||
Reference in New Issue
Block a user