feat: Implement Phase 2 features including blog, collections, saved listings, manifest, and api cron sync

This commit is contained in:
AyrisAI
2026-07-12 20:49:41 +03:00
parent 862544b5a1
commit f696d359b0
29 changed files with 3066 additions and 194 deletions
@@ -0,0 +1,58 @@
'use client'
import { useState, useEffect } from 'react'
import { Heart } from 'lucide-react'
interface Props {
listingId: string
saveLabel: string
savedLabel: string
}
export default function SaveButton({ listingId, saveLabel, savedLabel }: Props) {
const [isSaved, setIsSaved] = useState(false)
useEffect(() => {
try {
const stored = localStorage.getItem('savedListingIds')
if (stored) {
const ids = JSON.parse(stored) as string[]
setIsSaved(ids.includes(listingId))
}
} catch (e) {
console.error('Error reading localStorage:', e)
}
}, [listingId])
const toggleSave = () => {
try {
const stored = localStorage.getItem('savedListingIds')
let ids: string[] = stored ? JSON.parse(stored) : []
if (ids.includes(listingId)) {
ids = ids.filter(id => id !== listingId)
setIsSaved(false)
} else {
ids.push(listingId)
setIsSaved(true)
}
localStorage.setItem('savedListingIds', JSON.stringify(ids))
window.dispatchEvent(new Event('favorites-updated'))
} catch (err) {
console.error('Error writing localStorage:', err)
}
}
return (
<button
onClick={toggleSave}
className={`flex items-center justify-center gap-2 border font-bold text-xs py-3.5 px-4 rounded-xl transition ${
isSaved
? 'bg-bougainvillea/5 border-bougainvillea/20 text-bougainvillea'
: 'bg-paper border-pine/15 hover:bg-stone text-pine'
}`}
>
<Heart className={`w-4 h-4 ${isSaved ? 'fill-bougainvillea text-bougainvillea' : 'text-pine/70'}`} />
{isSaved ? savedLabel : saveLabel}
</button>
)
}
+146 -23
View File
@@ -5,12 +5,25 @@ import Footer from '@/components/Footer'
import ListingCard from '@/components/ListingCard'
import { notFound } from 'next/navigation'
import Image from 'next/image'
import { Phone, Globe, MapPin, Clock, Star, MessageSquare } from 'lucide-react'
import { Link } from '@/i18n/routing'
import { Phone, Globe, MapPin, Clock, Star, MessageSquare, Share2 } from 'lucide-react'
import SaveButton from './SaveButton'
interface DetailPageProps {
params: Promise<{ locale: string; category: string; slug: string }>
}
export async function generateMetadata({ params }: DetailPageProps) {
const { slug } = await params
const listing = await mockDb.getListingBySlug(slug)
if (!listing) return {}
return {
title: `${listing.nameTr} — Marmaris Local`,
description: listing.descriptionTr
}
}
export default async function ListingDetailPage({ params }: DetailPageProps) {
const { locale, category, slug } = await params
setRequestLocale(locale)
@@ -30,6 +43,16 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
.filter(l => l.id !== listing.id)
.slice(0, 3)
// Fetch newest 3 listings (excluding current)
const allListings = await mockDb.getListings()
const latestListings = allListings
.filter(l => l.id !== listing.id)
.slice(0, 3)
// Fetch Instagram Feed Cache
const instagramFeed = await mockDb.getInstagramFeedCacheByListingId(listing.id)
const instagramPosts = instagramFeed?.posts ? (instagramFeed.posts as any[]) : []
// Localized values
const name =
locale === 'ru'
@@ -46,22 +69,28 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
: listing.descriptionTr
const priceSymbols = '₺'.repeat(listing.priceRange)
const categorySlug = listing.category?.slug || 'isletme'
// Format WhatsApp Link
const getWhatsAppLink = (number: string) => {
// Clean spaces, parenthesis, plus sign
const cleanNum = number.replace(/\D/g, '')
return `https://wa.me/${cleanNum}`
}
// WhatsApp Share deep link text
const getShareLink = () => {
const text = encodeURIComponent(`Marmaris Local'da harika bir yer keşfettim: ${name}\nDetayları incele: https://marmarislocal.com/${locale}/${categorySlug}/${listing.slug}`)
return `https://wa.me/?text=${text}`
}
return (
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
<Navbar />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 flex-1">
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 flex-1 space-y-10">
{/* Breadcrumb / Category Link */}
<div className="mb-6 text-xs font-mono uppercase tracking-wider text-shutter flex items-center gap-2">
{/* Breadcrumb */}
<div className="text-xs font-mono uppercase tracking-wider text-shutter flex items-center gap-2">
<span className="hover:text-turquoise transition-colors">marmaris local</span>
<span>/</span>
<span className="hover:text-turquoise transition-colors">
@@ -72,8 +101,7 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
</div>
{/* Hero Details Block */}
<div className="bg-paper rounded-3xl border border-pine/8 p-6 sm:p-10 shadow-sm mb-10 space-y-8">
<div className="bg-paper rounded-3xl border border-pine/8 p-6 sm:p-10 shadow-sm space-y-8">
<div className="flex flex-col lg:flex-row gap-10">
{/* Left: Gallery Panel */}
@@ -107,8 +135,6 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
{/* Right: Info Panel */}
<div className="flex-1 flex flex-col justify-between space-y-6">
{/* Badge & Title */}
<div className="space-y-4">
<div className="flex flex-wrap gap-2.5 items-center">
<span className="text-[10px] font-mono text-bougainvillea font-bold uppercase tracking-wider bg-bougainvillea/5 border border-bougainvillea/10 px-2.5 py-1 rounded-full">
@@ -141,12 +167,12 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
</div>
{/* Description */}
<div className="text-ink/80 text-sm leading-relaxed font-medium">
<div className="text-ink/80 text-sm sm:text-base leading-relaxed font-medium">
{description}
</div>
{/* Contact Actions */}
<div className="space-y-4">
{/* Contact & Actions Grid */}
<div className="space-y-4 pt-2">
<h3 className="font-heading font-bold text-xs uppercase tracking-wider text-shutter">{t('contact')}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
@@ -171,28 +197,52 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
{t('whatsapp')}
</a>
)}
{/* Menu Link */}
{listing.menuUrl && (
<a
href={listing.menuUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 bg-paper text-pine font-bold text-xs py-3.5 px-4 rounded-xl border border-pine/15 hover:bg-stone/50 transition sm:col-span-2"
>
<Globe className="w-4 h-4 text-turquoise" />
{t('viewMenu')}
</a>
)}
{/* Save Button (Favorites client action) */}
<SaveButton
listingId={listing.id}
saveLabel={t('save')}
savedLabel={t('saved')}
/>
</div>
{/* External links */}
<div className="flex gap-4 pt-2 text-xs font-semibold text-shutter">
<div className="flex flex-wrap gap-5 pt-2 text-xs font-semibold text-shutter">
{listing.website && (
<a href={listing.website} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 hover:text-turquoise transition">
<Globe className="w-4 h-4" />
<a href={listing.website} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1.5 hover:text-turquoise transition">
<Globe className="w-4 h-4 text-turquoise" />
{t('website')}
</a>
)}
{listing.instagram && (
<a href={listing.instagram} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 hover:text-turquoise transition">
<Globe className="w-4 h-4" />
<a href={`https://instagram.com/${listing.instagram}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1.5 hover:text-turquoise transition">
<Globe className="w-4 h-4 text-turquoise" />
{t('instagram')}
</a>
)}
{/* WhatsApp Share button */}
<a href={getShareLink()} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1.5 hover:text-turquoise transition">
<Share2 className="w-4 h-4 text-turquoise" />
{t('shareWhatsapp')}
</a>
</div>
</div>
</div>
</div>
{/* Details footer (Hours & Location map) */}
@@ -245,15 +295,88 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
</div>
</div>
)}
</div>
</div>
{/* Instagram Feed Cache (Phase 2) */}
{instagramFeed && instagramPosts.length > 0 && (
<div className="bg-paper rounded-3xl border border-pine/8 p-6 sm:p-10 shadow-sm space-y-6">
<div className="flex items-center gap-3 border-b border-dashed border-pine/8 pb-4">
<div className="w-10 h-10 rounded-full border border-pine/10 flex items-center justify-center relative bg-stone shrink-0">
<span className="font-heading font-bold text-pine text-xs tracking-tighter">IG</span>
</div>
<div>
<h3 className="font-heading font-bold text-sm text-pine lowercase">@{instagramFeed.handle}</h3>
<p className="text-[10px] font-mono text-shutter uppercase tracking-wider mt-0.5">{t('instagramFeed')}</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{instagramPosts.slice(0, 3).map((post: any, idx: number) => (
<a
key={idx}
href={post.permalink || '#'}
target="_blank"
rel="noopener noreferrer"
className="group relative aspect-square rounded-2xl overflow-hidden bg-stone border border-pine/5 shadow-sm block"
>
<img
src={post.imageUrl}
alt={post.caption || 'Instagram post'}
className="w-full h-full object-cover group-hover:scale-103 transition duration-500"
/>
<div className="absolute inset-0 bg-pine/70 opacity-0 group-hover:opacity-100 transition-opacity duration-300 p-4 flex flex-col justify-end">
<p className="text-[11px] text-stone font-medium line-clamp-3 leading-relaxed">
{post.caption}
</p>
</div>
</a>
))}
</div>
</div>
)}
{/* Newly Added Widget (Phase 2) */}
{latestListings.length > 0 && (
<div className="bg-paper rounded-3xl border border-pine/8 p-6 sm:p-10 shadow-sm space-y-6">
<div className="border-b border-dashed border-pine/8 pb-4">
<h3 className="text-lg font-heading font-extrabold text-pine lowercase">
{t('newlyAdded')}
</h3>
<p className="text-ink/65 text-xs font-medium mt-0.5">
{t('newlyAddedSubtitle')}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{latestListings.map((newest) => {
const catSlug = newest.category?.slug || 'isletmeler'
const newestName = locale === 'en' ? newest.nameEn : locale === 'ru' ? newest.nameRu : newest.nameTr
const newestImg = newest.images && newest.images.length > 0 ? newest.images[0].url : 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&auto=format&fit=crop&q=80'
return (
<Link
key={newest.id}
href={`/${catSlug}/${newest.slug}`}
className="flex gap-4 items-center group bg-stone/20 p-3.5 rounded-2xl border border-pine/5 hover:border-turquoise/25 transition duration-150"
>
<div className="w-16 h-16 rounded-xl overflow-hidden bg-stone shrink-0 border border-pine/8">
<img src={newestImg} alt={newestName} className="w-full h-full object-cover group-hover:scale-105 transition duration-300" />
</div>
<div>
<h4 className="font-heading font-bold text-xs text-pine lowercase line-clamp-1 group-hover:text-turquoise transition-colors">{newestName}</h4>
<p className="text-[10px] text-shutter font-mono mt-0.5">{newest.neighborhood?.nameTr}</p>
</div>
</Link>
)
})}
</div>
</div>
)}
{/* Related Section */}
{relatedListings.length > 0 && (
<div className="space-y-6 pt-10">
<h3 className="text-xl font-heading font-extrabold text-pine lowercase">
<div className="space-y-6 pt-6">
<h3 className="text-xl font-heading font-extrabold text-pine lowercase border-b border-dashed border-pine/8 pb-3">
{t('related')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
+399
View File
@@ -0,0 +1,399 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { createOrUpdateBlogPostAction } from '@/app/actions'
import { ArrowLeft, Save, Eye, Edit3 } from 'lucide-react'
import { Link } from '@/i18n/routing'
interface Option {
value: string
label: string
}
interface FormProps {
post: any | null
listingOptions: Option[]
}
// Simple local markdown preview renderer helper
function previewMarkdown(md: string): string {
if (!md) return ''
let html = md
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>')
html = html.replace(/^### (.*?)$/gm, '<h4 class="text-xs font-bold font-mono text-shutter uppercase tracking-wider mt-4 mb-1">$1</h4>')
html = html.replace(/^## (.*?)$/gm, '<h3 class="text-sm font-heading font-extrabold text-pine mt-5 mb-2 lowercase">$1</h3>')
html = html.replace(/^# (.*?)$/gm, '<h2 class="text-base font-heading font-extrabold text-pine mt-6 mb-3 lowercase">$1</h2>')
html = html.replace(/^\* (.*?)$/gm, '<li class="ml-4 list-disc text-xs text-ink/80">$1</li>')
html = html.replace(/^- (.*?)$/gm, '<li class="ml-4 list-disc text-xs text-ink/80">$1</li>')
html = html.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" class="text-turquoise hover:underline" target="_blank">$1</a>')
const paragraphs = html.split(/\n\n+/)
return paragraphs.map(p => {
const t = p.trim()
if (!t) return ''
if (t.startsWith('<h') || t.startsWith('<li') || t.startsWith('<ul')) return t
return `<p class="text-xs sm:text-sm text-ink/75 leading-relaxed mb-3">${t.replace(/\n/g, '<br/>')}</p>`
}).join('\n')
}
export default function BlogForm({ post, listingOptions }: FormProps) {
const router = useRouter()
const [activeTab, setActiveTab] = useState<'tr' | 'en' | 'ru'>('tr')
const [editorMode, setEditorMode] = useState<'edit' | 'preview'>('edit')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState(false)
// Markdown content states for live preview
const [contentTr, setContentTr] = useState(post?.contentTr || '')
const [contentEn, setContentEn] = useState(post?.contentEn || '')
const [contentRu, setContentRu] = useState(post?.contentRu || '')
// Selected Listings
const [selectedListings, setSelectedListings] = useState<string[]>(post?.relatedListingIds || [])
const [listingSearch, setListingSearch] = useState('')
const handleListingToggle = (id: string) => {
setSelectedListings(prev =>
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
)
}
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)
}
formData.append('relatedListingIds', selectedListings.join(','))
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)
}
}
const filteredListings = listingOptions.filter(opt =>
opt.label.toLowerCase().includes(listingSearch.toLowerCase())
)
const activeContent = activeTab === 'tr' ? contentTr : activeTab === 'en' ? contentEn : contentRu
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>
)}
{/* Language tabs */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between border-b border-pine/8 gap-4 pb-1">
<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>
{/* Edit / Preview controls */}
<div className="flex gap-1 bg-stone/50 p-1 rounded-xl border border-pine/5 shrink-0 self-start">
<button
type="button"
onClick={() => setEditorMode('edit')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-mono font-bold uppercase transition ${
editorMode === 'edit' ? 'bg-pine text-stone' : 'text-pine/70 hover:text-pine'
}`}
>
<Edit3 className="w-3.5 h-3.5" />
düzenle
</button>
<button
type="button"
onClick={() => setEditorMode('preview')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-mono font-bold uppercase transition ${
editorMode === 'preview' ? 'bg-pine text-stone' : 'text-pine/70 hover:text-pine'
}`}
>
<Eye className="w-3.5 h-3.5" />
önizleme
</button>
</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">Yazı Başlığı (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'te Nerede Yenir? 2026 Rehberi"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">İçerik (TR - Markdown) *</label>
{editorMode === 'edit' ? (
<textarea
name="contentTr"
required
rows={10}
value={contentTr}
onChange={(e) => setContentTr(e.target.value)}
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm font-medium focus:ring-1 focus:ring-turquoise outline-none text-ink font-mono"
placeholder="Başlıklar için ##, listeler için - kullanın..."
/>
) : (
<div
className="w-full bg-stone/30 border border-dashed border-pine/10 rounded-xl px-6 py-4 min-h-[220px]"
dangerouslySetInnerHTML={{ __html: previewMarkdown(contentTr) }}
/>
)}
</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">Yazı Başlığı (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="Örn: Where to Eat in Marmaris? 2026 Guide"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">İçerik (EN - Markdown) *</label>
{editorMode === 'edit' ? (
<textarea
name="contentEn"
required
rows={10}
value={contentEn}
onChange={(e) => setContentEn(e.target.value)}
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm font-medium focus:ring-1 focus:ring-turquoise outline-none text-ink font-mono"
placeholder="English text in markdown..."
/>
) : (
<div
className="w-full bg-stone/30 border border-dashed border-pine/10 rounded-xl px-6 py-4 min-h-[220px]"
dangerouslySetInnerHTML={{ __html: previewMarkdown(contentEn) }}
/>
)}
</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">Yazı Başlığı (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="Örn: Где поесть в Мармарисе? Путеводитель 2026"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">İçerik (RU - Markdown) *</label>
{editorMode === 'edit' ? (
<textarea
name="contentRu"
required
rows={10}
value={contentRu}
onChange={(e) => setContentRu(e.target.value)}
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm font-medium focus:ring-1 focus:ring-turquoise outline-none text-ink font-mono"
placeholder="Russian text in markdown..."
/>
) : (
<div
className="w-full bg-stone/30 border border-dashed border-pine/10 rounded-xl px-6 py-4 min-h-[220px]"
dangerouslySetInnerHTML={{ __html: previewMarkdown(contentRu) }}
/>
)}
</div>
</div>
)}
</div>
<hr className="border-dashed border-pine/8" />
{/* Global settings */}
<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={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="marmariste-nerede-yenir-2026"
/>
</div>
{/* Tags */}
<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 text-xs"
placeholder="Gezilecek Yerler, Restoranlar, Rehber"
/>
</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"
/>
{post?.coverImage && (
<div className="mt-2 text-xs flex items-center gap-2">
<span className="text-shutter/65">Mevcut Kapak:</span>
<a href={post.coverImage} target="_blank" rel="noopener noreferrer" className="text-turquoise hover:underline font-mono truncate max-w-xs">{post.coverImage}</a>
<input type="hidden" name="coverImageUrl" value={post.coverImage} />
</div>
)}
</div>
{/* Publish Status */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">Yayın Durumu</label>
<select
name="isPublished"
defaultValue={post?.publishedAt ? 'true' : 'false'}
className="w-full bg-stone border border-pine/10 rounded-xl px-4 py-3 text-sm focus:ring-1 focus:ring-turquoise outline-none text-ink"
>
<option value="false">Taslak (Yayınlama)</option>
<option value="true">Yayında (Aktif)</option>
</select>
</div>
</div>
{/* Related Listings checkbox selection box */}
<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">Yazıyla İlişkili Mekanlar (Internal Links)</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' : '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/blog"
className="inline-flex items-center gap-1.5 px-5 py-3 border border-pine/20 rounded-xl text-xs font-bold text-pine hover:bg-stone/30 transition duration-150"
>
<ArrowLeft className="w-4 h-4" />
İptal
</Link>
<button
type="submit"
disabled={loading}
className="inline-flex items-center gap-1.5 px-5 py-3 bg-turquoise hover:bg-turquoise/90 disabled:opacity-75 text-paper rounded-xl text-xs font-bold shadow-sm transition duration-150"
>
<Save className="w-4 h-4" />
{loading ? 'Kaydediliyor...' : 'Yazıyı Kaydet'}
</button>
</div>
</form>
)
}
+48
View File
@@ -0,0 +1,48 @@
import { mockDb } from '@/lib/mockDb'
import BlogForm from './BlogForm'
import { notFound } from 'next/navigation'
interface Props {
params: Promise<{ locale: string; id: string }>
}
export default async function AdminBlogEditPage({ params }: Props) {
const { id } = await params
let post = null
if (id !== 'new') {
post = await mockDb.getBlogPostById(id)
if (!post) {
notFound()
}
}
// 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>
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">
{id === 'new' ? 'yeni blog yazısı' : '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.'}
</p>
</div>
<div className="bg-paper border border-pine/8 rounded-3xl shadow-sm p-6 sm:p-8">
<BlogForm
post={post}
listingOptions={listingOptions}
/>
</div>
</div>
)
}
+124
View File
@@ -0,0 +1,124 @@
import { mockDb } from '@/lib/mockDb'
import { deleteBlogPostAction } from '@/app/actions'
import { Link } from '@/i18n/routing'
import { Edit, Trash, Plus, FileText, CheckCircle, XCircle } from 'lucide-react'
export default async function AdminBlogPage() {
const posts = await mockDb.getBlogPosts()
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">blog yazıları</h2>
<p className="text-ink/65 text-xs font-medium mt-1">
Marmaris Local rehberi için hazırlanan gezi, gastronomi ve tanıtım yazıları.
</p>
</div>
<Link
href="/admin/blog/new"
className="inline-flex items-center gap-1.5 bg-turquoise hover:bg-turquoise/90 text-paper text-xs font-bold py-3 px-5 rounded-xl shadow-sm transition active:scale-95 duration-150"
>
<Plus className="w-4 h-4" />
Yeni Yazı Ekle
</Link>
</div>
<div className="bg-paper border border-pine/8 rounded-2xl shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-pine/8 text-sm">
<thead className="bg-stone-deep/40 text-shutter font-mono text-[10px] uppercase tracking-wider">
<tr>
<th className="px-6 py-4 text-left">Görsel</th>
<th className="px-6 py-4 text-left">Yazı Başlığı (TR)</th>
<th className="px-6 py-4 text-left">URL Slug</th>
<th className="px-6 py-4 text-left">Etiketler</th>
<th className="px-6 py-4 text-left">Yayın Durumu</th>
<th className="px-6 py-4 text-right">İşlemler</th>
</tr>
</thead>
<tbody className="divide-y divide-dashed divide-pine/8 text-ink/80">
{posts.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-xs text-shutter font-medium">
Henüz blog yazısı eklenmemiş.
</td>
</tr>
) : (
posts.map((post) => {
return (
<tr key={post.id} className="hover:bg-stone/20 transition duration-150">
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-12 w-16 relative rounded-lg overflow-hidden bg-stone border border-pine/8">
{post.coverImage ? (
<img src={post.coverImage} alt={post.titleTr} className="object-cover w-full h-full" />
) : (
<div className="w-full h-full flex items-center justify-center text-shutter bg-stone/50">
<FileText className="w-5 h-5" />
</div>
)}
</div>
</td>
<td className="px-6 py-4 font-medium">
<div className="font-heading font-bold text-pine lowercase text-sm">{post.titleTr}</div>
</td>
<td className="px-6 py-4 font-mono text-xs text-turquoise">
/blog/{post.slug}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex flex-wrap gap-1">
{post.tags.map(tag => (
<span key={tag} className="bg-paper text-shutter border border-pine/10 px-2 py-0.5 rounded text-[10px] font-mono">
{tag}
</span>
))}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{post.publishedAt ? (
<span className="inline-flex items-center gap-1 rounded-full bg-paper px-2.5 py-0.5 text-[10px] font-mono font-bold text-turquoise border border-turquoise/20 uppercase tracking-wider">
<CheckCircle className="w-3.5 h-3.5" />
Yayında
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-paper px-2.5 py-0.5 text-[10px] font-mono font-medium text-shutter/65 border border-pine/8 uppercase tracking-wider">
<XCircle className="w-3.5 h-3.5" />
Taslak
</span>
)}
</td>
<td className="px-6 py-4 text-right whitespace-nowrap">
<div className="flex justify-end gap-2">
<Link
href={`/admin/blog/${post.id}`}
className="p-1.5 bg-paper text-shutter hover:text-turquoise hover:bg-turquoise/5 rounded-lg border border-pine/10 hover:border-turquoise/25 transition shadow-sm"
title="Düzenle"
>
<Edit className="w-4 h-4" />
</Link>
<form action={async () => {
'use server'
await deleteBlogPostAction(post.id)
}}>
<button
type="submit"
className="p-1.5 bg-paper text-shutter hover:text-bougainvillea hover:bg-bougainvillea/5 rounded-lg border border-pine/10 hover:border-bougainvillea/25 transition shadow-sm"
title="Sil"
>
<Trash className="w-4 h-4" />
</button>
</form>
</div>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
@@ -0,0 +1,292 @@
'use client'
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'
interface Option {
value: string
label: string
}
interface FormProps {
collection: any | null
listingOptions: Option[]
}
export default function CollectionForm({ collection, listingOptions }: 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)
// 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 handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setLoading(true)
setError('')
setSuccess(false)
const formData = new FormData(e.currentTarget)
if (collection) {
formData.append('id', collection.id)
}
formData.append('listingIds', selectedListings.join(','))
try {
const res = await createOrUpdateCollectionAction(formData)
if (res.error) {
setError(res.error)
} else {
setSuccess(true)
setTimeout(() => {
router.push('/admin/collections')
router.refresh()
}, 1500)
}
} catch (err) {
setError('İşlem sırasında bir hata oluştu.')
} finally {
setLoading(false)
}
}
const filteredListings = listingOptions.filter(opt =>
opt.label.toLowerCase().includes(listingSearch.toLowerCase())
)
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">
Seçki başarıyla kaydedildi! Yönlendiriliyorsunuz...
</div>
)}
{/* Language tabs */}
<div className="border-b border-pine/8">
<div className="flex gap-2">
{(['tr', 'en', 'ru'] as const).map((lang) => {
const label = lang === 'tr' ? '🇹🇷 Türkçe' : lang === 'en' ? '🇬🇧 English' : '🇷🇺 Русский'
const isTabActive = activeTab === lang
return (
<button
key={lang}
type="button"
onClick={() => setActiveTab(lang)}
className={`py-3 px-4 font-heading font-bold text-xs border-b-2 transition lowercase ${
isTabActive
? 'border-turquoise text-turquoise'
: 'border-transparent text-shutter hover:text-pine'
}`}
>
{label}
</button>
)
})}
</div>
</div>
{/* 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"
/>
</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..."
/>
</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"
/>
</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..."
/>
</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: Семейные рестораны"
/>
</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="Описание подборки..."
/>
</div>
</div>
)}
</div>
<hr className="border-dashed border-pine/8" />
{/* Global fields */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Slug */}
<div className="space-y-1.5">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">URL Slug *</label>
<input
type="text"
name="slug"
required
defaultValue={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>
{/* 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>
)}
</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
</Link>
<button
type="submit"
disabled={loading}
className="inline-flex items-center gap-1.5 px-5 py-3 bg-turquoise hover:bg-turquoise/90 disabled:opacity-75 text-paper rounded-xl text-xs font-bold shadow-sm transition duration-150"
>
<Save className="w-4 h-4" />
{loading ? 'Kaydediliyor...' : 'Seçkiyi Kaydet'}
</button>
</div>
</form>
)
}
@@ -0,0 +1,48 @@
import { mockDb } from '@/lib/mockDb'
import CollectionForm from './CollectionForm'
import { notFound } from 'next/navigation'
interface Props {
params: Promise<{ locale: string; id: string }>
}
export default async function AdminCollectionEditPage({ params }: Props) {
const { id } = await params
let collection = null
if (id !== 'new') {
collection = await mockDb.getCollectionById(id)
if (!collection) {
notFound()
}
}
// Fetch listings for option list
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>
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">
{id === 'new' ? 'yeni seçki (koleksiyon)' : '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.'}
</p>
</div>
<div className="bg-paper border border-pine/8 rounded-3xl shadow-sm p-6 sm:p-8">
<CollectionForm
collection={collection}
listingOptions={listingOptions}
/>
</div>
</div>
)
}
+106
View File
@@ -0,0 +1,106 @@
import { mockDb } from '@/lib/mockDb'
import { deleteCollectionAction } from '@/app/actions'
import { Link } from '@/i18n/routing'
import { Edit, Trash, Plus, Layers } from 'lucide-react'
export default async function AdminCollectionsPage() {
const collections = await mockDb.getCollections()
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-3xl font-heading font-extrabold text-pine lowercase">kürasyon seçkileri</h2>
<p className="text-ink/65 text-xs font-medium mt-1">
Rehberdeki işletmelerin tematik olarak gruplandırıldığı koleksiyonlar.
</p>
</div>
<Link
href="/admin/collections/new"
className="inline-flex items-center gap-1.5 bg-turquoise hover:bg-turquoise/90 text-paper text-xs font-bold py-3 px-5 rounded-xl shadow-sm transition active:scale-95 duration-150"
>
<Plus className="w-4 h-4" />
Yeni Seçki Ekle
</Link>
</div>
<div className="bg-paper border border-pine/8 rounded-2xl shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-pine/8 text-sm">
<thead className="bg-stone-deep/40 text-shutter font-mono text-[10px] uppercase tracking-wider">
<tr>
<th className="px-6 py-4 text-left">Görsel</th>
<th className="px-6 py-4 text-left">Koleksiyon Başlığı (TR)</th>
<th className="px-6 py-4 text-left">URL Slug</th>
<th className="px-6 py-4 text-left">Mekan Sayısı</th>
<th className="px-6 py-4 text-right">İşlemler</th>
</tr>
</thead>
<tbody className="divide-y divide-dashed divide-pine/8 text-ink/80">
{collections.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center text-xs text-shutter font-medium">
Henüz seçki eklenmemiş.
</td>
</tr>
) : (
collections.map((col) => {
const listingCount = col.listings?.length || 0
return (
<tr key={col.id} className="hover:bg-stone/20 transition duration-150">
<td className="px-6 py-4 whitespace-nowrap">
<div className="h-12 w-16 relative rounded-lg overflow-hidden bg-stone border border-pine/8">
{col.coverImage ? (
<img src={col.coverImage} alt={col.titleTr} className="object-cover w-full h-full" />
) : (
<div className="w-full h-full flex items-center justify-center text-shutter bg-stone/50">
<Layers className="w-5 h-5" />
</div>
)}
</div>
</td>
<td className="px-6 py-4 font-medium">
<div className="font-heading font-bold text-pine lowercase text-sm">{col.titleTr}</div>
<div className="text-xs text-ink/65 mt-0.5 line-clamp-1 max-w-xs">{col.descriptionTr}</div>
</td>
<td className="px-6 py-4 font-mono text-xs text-turquoise">
/secki/{col.slug}
</td>
<td className="px-6 py-4 font-mono text-xs text-pine font-semibold">
{listingCount} Mekan
</td>
<td className="px-6 py-4 text-right whitespace-nowrap">
<div className="flex justify-end gap-2">
<Link
href={`/admin/collections/${col.id}`}
className="p-1.5 bg-paper text-shutter hover:text-turquoise hover:bg-turquoise/5 rounded-lg border border-pine/10 hover:border-turquoise/25 transition shadow-sm"
title="Düzenle"
>
<Edit className="w-4 h-4" />
</Link>
<form action={async () => {
'use server'
await 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>
</form>
</div>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
+2
View File
@@ -12,6 +12,8 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const navigation = [
{ name: 'Dashboard', href: '/admin', icon: LayoutDashboard },
{ name: 'Mekanlar', href: '/admin/listings', icon: ClipboardList },
{ name: 'Yazılar (Blog)', href: '/admin/blog', icon: FileText },
{ name: 'Seçkiler (Kürasyon)', href: '/admin/collections', icon: ClipboardList },
{ name: 'Başvurular', href: '/admin/submissions', icon: FileText },
{ name: 'Mesajlar', href: '/admin/messages', icon: Inbox },
{ name: 'Kategoriler', href: '/admin/categories', icon: LayoutDashboard },
+192
View File
@@ -0,0 +1,192 @@
import { mockDb } from '@/lib/mockDb'
import { Link } from '@/i18n/routing'
import { notFound } from 'next/navigation'
import { Calendar, Tag, ArrowLeft } from 'lucide-react'
import ListingCard from '@/components/ListingCard'
interface Props {
params: Promise<{ locale: string; slug: string }>
}
export async function generateMetadata({ params }: Props) {
const { slug } = await params
const post = await mockDb.getBlogPostBySlug(slug)
if (!post) return {}
return {
title: `${post.titleTr} — Marmaris Local`,
description: post.contentTr.substring(0, 150)
}
}
// Lightweight safe Markdown to HTML parsing function
function renderMarkdownToHtml(md: string): string {
if (!md) return ''
let html = md
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Bold
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/__(.*?)__/g, '<strong>$1</strong>')
// Italic
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>')
html = html.replace(/_(.*?)_/g, '<em>$1</em>')
// Headings
html = html.replace(/^### (.*?)$/gm, '<h4 class="text-base font-heading font-bold text-pine mt-6 mb-2 lowercase">$1</h4>')
html = html.replace(/^## (.*?)$/gm, '<h3 class="text-lg font-heading font-extrabold text-pine mt-8 mb-3 lowercase">$1</h3>')
html = html.replace(/^# (.*?)$/gm, '<h2 class="text-xl font-heading font-extrabold text-pine mt-10 mb-4 lowercase">$1</h2>')
// Bullet Lists
html = html.replace(/^\* (.*?)$/gm, '<li class="ml-4 list-disc text-sm text-ink/80 leading-relaxed">$1</li>')
html = html.replace(/^- (.*?)$/gm, '<li class="ml-4 list-disc text-sm text-ink/80 leading-relaxed">$1</li>')
// Links
html = html.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" class="text-turquoise hover:underline" target="_blank" rel="noopener">$1</a>')
// Paragraphs
const blocks = html.split(/\n\n+/)
html = blocks.map(block => {
const trimmed = block.trim()
if (!trimmed) return ''
if (trimmed.startsWith('<h') || trimmed.startsWith('<li') || trimmed.startsWith('<ul') || trimmed.startsWith('<ol')) {
return trimmed
}
return `<p class="leading-relaxed mb-4 text-sm sm:text-base text-ink/80 font-medium">${trimmed.replace(/\n/g, '<br/>')}</p>`
}).join('\n')
return html
}
export default async function BlogPostDetailPage({ params }: Props) {
const { locale, slug } = await params
const post = await mockDb.getBlogPostBySlug(slug)
if (!post || post.deletedAt) {
notFound()
}
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 htmlContent = renderMarkdownToHtml(content)
// Fetch associated listings
const relatedListings: any[] = []
if (post.relatedListingIds && post.relatedListingIds.length > 0) {
for (const listingId of post.relatedListingIds) {
const listing = await mockDb.getListingById(listingId)
if (listing) {
relatedListings.push(listing)
}
}
}
// schema.org Structured Data
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
'headline': title,
'image': post.coverImage || 'https://images.unsplash.com/photo-1504674900247-0877df9cc836?w=1200&auto=format&fit=crop&q=80',
'datePublished': post.publishedAt || post.createdAt,
'dateModified': post.updatedAt,
'author': {
'@type': 'Organization',
'name': 'Marmaris Local',
'url': 'https://marmarislocal.com'
},
'publisher': {
'@type': 'Organization',
'name': 'Marmaris Local',
'logo': {
'@type': 'ImageObject',
'url': 'https://marmarislocal.com/logo.png'
}
},
'description': content.substring(0, 150)
}
return (
<>
{/* Schema.org Article Structured Data */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<div className="bg-stone min-h-screen py-10 px-4 sm:px-6 lg:px-8 font-sans">
<div className="max-w-3xl mx-auto space-y-8">
{/* Back Link */}
<Link
href="/blog"
className="inline-flex items-center gap-1.5 px-4 py-2 border border-pine/10 rounded-xl text-xs font-mono font-bold text-pine hover:bg-paper transition"
>
<ArrowLeft className="w-3.5 h-3.5" />
GERİ DÖN
</Link>
{/* Article Box */}
<article className="bg-paper border border-pine/8 rounded-3xl overflow-hidden shadow-sm">
{post.coverImage && (
<div className="h-[350px] relative overflow-hidden bg-stone border-b border-pine/5">
<img
src={post.coverImage}
alt={title}
className="w-full h-full object-cover"
/>
</div>
)}
<div className="p-6 sm:p-10 space-y-6">
{/* Meta */}
<div className="flex flex-wrap items-center gap-4 text-xs text-shutter font-mono border-b border-dashed border-pine/8 pb-4">
<span className="flex items-center gap-1.5">
<Calendar className="w-4 h-4" />
{post.publishedAt ? new Date(post.publishedAt).toLocaleDateString(locale === 'tr' ? 'tr-TR' : locale === 'ru' ? 'ru-RU' : 'en-US') : ''}
</span>
{post.tags.map((tag) => (
<span key={tag} className="flex items-center gap-1 bg-stone/50 px-2 py-0.5 rounded-md border border-pine/5">
<Tag className="w-3.5 h-3.5" />
{tag}
</span>
))}
</div>
{/* Title */}
<h1 className="text-2xl sm:text-3xl font-heading font-extrabold text-pine lowercase leading-tight">
{title}
</h1>
{/* Markdown Content */}
<div
className="markdown-content space-y-4"
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
</div>
</article>
{/* Related Listings Section (Internal Linking) */}
{relatedListings.length > 0 && (
<div className="space-y-6 pt-6">
<h3 className="text-xl font-heading font-extrabold text-pine lowercase border-b border-dashed border-pine/8 pb-3">
yazıda geçen mekanlar
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{relatedListings.map((listing) => (
<ListingCard key={listing.id} listing={listing} />
))}
</div>
</div>
)}
</div>
</div>
</>
)
}
+110
View File
@@ -0,0 +1,110 @@
import { mockDb } from '@/lib/mockDb'
import { Link } from '@/i18n/routing'
import { getTranslations } from 'next-intl/server'
import { Calendar, Tag } from 'lucide-react'
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 default async function BlogIndexPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params
const t = await getTranslations('nav')
const posts = await mockDb.getBlogPosts(true)
return (
<div className="bg-stone min-h-screen py-10 px-4 sm:px-6 lg:px-8 font-sans">
<div className="max-w-5xl mx-auto space-y-10">
{/* Title */}
<div className="text-center space-y-3">
<span className="text-[10px] font-mono tracking-widest text-shutter uppercase bg-paper px-3 py-1.5 rounded-full border border-pine/5">
küratörlü rehberler
</span>
<h1 className="text-4xl font-heading font-extrabold text-pine lowercase">
marmaris local <span className="text-turquoise">blog</span>
</h1>
<p className="max-w-xl mx-auto text-ink/70 text-xs sm:text-sm font-medium">
Turistlerin gözünden kaçan yerel detaylar, en iyi lezzet durakları ve gizli gezi noktaları.
</p>
</div>
{/* Blog Posts Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{posts.length === 0 ? (
<div className="col-span-2 bg-paper border border-pine/8 p-12 text-center text-shutter text-sm rounded-3xl">
Henüz yayınlanmış bir yazı bulunmuyor.
</div>
) : (
posts.map((post) => {
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 snippet = content.substring(0, 140) + '...'
return (
<article
key={post.id}
className="bg-paper border border-pine/8 rounded-3xl overflow-hidden shadow-sm flex flex-col justify-between hover:border-turquoise/35 transition-all duration-300 hover:shadow-md"
>
<div>
{/* Cover image */}
{post.coverImage && (
<div className="h-56 relative overflow-hidden bg-stone border-b border-pine/5">
<img
src={post.coverImage}
alt={title}
className="w-full h-full object-cover hover:scale-105 transition duration-500"
/>
</div>
)}
{/* Post content preview */}
<div className="p-6 sm:p-8 space-y-4">
{/* Meta info */}
<div className="flex items-center gap-4 text-[10px] text-shutter font-mono">
<span className="flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5" />
{post.publishedAt ? new Date(post.publishedAt).toLocaleDateString(locale === 'tr' ? 'tr-TR' : locale === 'ru' ? 'ru-RU' : 'en-US') : ''}
</span>
{post.tags.length > 0 && (
<span className="flex items-center gap-1.5">
<Tag className="w-3.5 h-3.5" />
{post.tags[0]}
</span>
)}
</div>
<h2 className="font-heading font-extrabold text-lg text-pine hover:text-turquoise transition duration-150 lowercase leading-snug">
<Link href={`/blog/${post.slug}`}>
{title}
</Link>
</h2>
<p className="text-ink/75 text-xs sm:text-sm leading-relaxed">
{snippet}
</p>
</div>
</div>
<div className="px-6 sm:px-8 pb-6 sm:pb-8 pt-2">
<Link
href={`/blog/${post.slug}`}
className="inline-flex items-center text-xs font-mono font-bold text-turquoise hover:underline uppercase tracking-wider"
>
okumaya devam et
</Link>
</div>
</article>
)
})
)}
</div>
</div>
</div>
)
}
+118
View File
@@ -0,0 +1,118 @@
'use client'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import ListingCard from '@/components/ListingCard'
import { Heart, Share2, ClipboardCheck } from 'lucide-react'
interface Props {
allListings: any[]
locale: string
}
export default function SavedClient({ allListings, locale }: Props) {
const searchParams = useSearchParams()
const [savedIds, setSavedIds] = useState<string[]>([])
const [copied, setCopied] = useState(false)
const [isClient, setIsClient] = useState(false)
useEffect(() => {
setIsClient(true)
const paramIds = searchParams.get('ids')
if (paramIds) {
// Load shared favorites
const ids = paramIds.split(',').filter(Boolean)
setSavedIds(ids)
// Save shared list to local storage as well
localStorage.setItem('savedListingIds', JSON.stringify(ids))
} else {
// Load personal favorites from localStorage
try {
const stored = localStorage.getItem('savedListingIds')
if (stored) {
setSavedIds(JSON.parse(stored))
}
} catch (e) {
console.error('Error reading localStorage:', e)
}
}
}, [searchParams])
const handleShare = () => {
if (savedIds.length === 0) return
const shareUrl = `${window.location.origin}${window.location.pathname}?ids=${savedIds.join(',')}`
navigator.clipboard.writeText(shareUrl).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
// Filter listings matching saved IDs
const filteredListings = allListings.filter(l => savedIds.includes(l.id))
if (!isClient) {
return (
<div className="py-20 text-center font-mono text-xs text-shutter animate-pulse">
liste yükleniyor...
</div>
)
}
return (
<div className="space-y-8">
{/* Title / Action bar */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-dashed border-pine/8 pb-6">
<div>
<span className="text-[10px] font-mono tracking-widest text-shutter uppercase bg-paper px-3 py-1.5 rounded-full border border-pine/5">
kişisel rehberiniz
</span>
<h1 className="text-3xl font-heading font-extrabold text-pine lowercase mt-2.5">
kaydedilen <span className="text-turquoise">mekanlar</span>
</h1>
</div>
{savedIds.length > 0 && (
<button
onClick={handleShare}
className="inline-flex items-center gap-2 bg-turquoise hover:bg-turquoise/90 text-paper text-xs font-bold py-3 px-5 rounded-xl shadow-sm transition active:scale-95 duration-150 shrink-0 self-start"
>
{copied ? (
<>
<ClipboardCheck className="w-4 h-4 text-paper" />
Paylaşım Linki Kopyalandı!
</>
) : (
<>
<Share2 className="w-4 h-4 text-paper" />
Listeyi Paylaş (URL Kopyala)
</>
)}
</button>
)}
</div>
{/* Grid List */}
{filteredListings.length === 0 ? (
<div className="bg-paper border border-pine/8 rounded-3xl p-12 text-center space-y-4 max-w-xl mx-auto">
<div className="w-12 h-12 rounded-full bg-stone/50 border border-pine/5 flex items-center justify-center mx-auto text-shutter">
<Heart className="w-5 h-5 fill-transparent" />
</div>
<div>
<h3 className="font-heading font-bold text-pine lowercase text-sm">listeniz henüz boş</h3>
<p className="text-ink/65 text-xs mt-1.5 max-w-sm mx-auto leading-relaxed">
Mekan kartlarındaki kalp simgesine tıklayarak beğendiğiniz yerleri buraya ekleyebilir ve daha sonra hızlıca erişebilirsiniz.
</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredListings.map((listing) => (
<ListingCard key={listing.id} listing={listing} />
))}
</div>
)}
</div>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { Suspense } from 'react'
import { mockDb } from '@/lib/mockDb'
import SavedClient from './SavedClient'
export async function generateMetadata() {
return {
title: 'Kaydedilen Yerler — Marmaris Local',
description: 'Marmaris rehberinde kaydettiğiniz ve beğendiğiniz tüm yerel adresler.'
}
}
export default async function SavedPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params
const allListings = await mockDb.getListings()
return (
<div className="bg-stone min-h-screen py-10 px-4 sm:px-6 lg:px-8 font-sans">
<div className="max-w-5xl mx-auto">
<Suspense fallback={<div className="text-xs font-mono py-12 text-center text-shutter">yükleniyor...</div>}>
<SavedClient allListings={allListings} locale={locale} />
</Suspense>
</div>
</div>
)
}
+1
View File
@@ -27,6 +27,7 @@ const ibmPlexMono = IBM_Plex_Mono({
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() {
+91
View File
@@ -0,0 +1,91 @@
import { mockDb } from '@/lib/mockDb'
import { Link } from '@/i18n/routing'
import { notFound } from 'next/navigation'
import { ArrowLeft, Layers } from 'lucide-react'
import ListingCard from '@/components/ListingCard'
interface Props {
params: Promise<{ locale: string; slug: string }>
}
export async function generateMetadata({ params }: Props) {
const { slug } = await params
const col = await mockDb.getCollectionBySlug(slug)
if (!col) return {}
return {
title: `${col.titleTr} Seçkisi — Marmaris Local`,
description: col.descriptionTr
}
}
export default async function CollectionDetailPage({ params }: Props) {
const { locale, slug } = await params
const col = await mockDb.getCollectionBySlug(slug)
if (!col) {
notFound()
}
const title = locale === 'en' ? col.titleEn : locale === 'ru' ? col.titleRu : col.titleTr
const desc = locale === 'en' ? col.descriptionEn : locale === 'ru' ? col.descriptionRu : col.descriptionTr
const listings = col.listings || []
return (
<div className="bg-stone min-h-screen py-10 px-4 sm:px-6 lg:px-8 font-sans">
<div className="max-w-5xl mx-auto space-y-8">
{/* Back Link */}
<Link
href="/seckiler"
className="inline-flex items-center gap-1.5 px-4 py-2 border border-pine/10 rounded-xl text-xs font-mono font-bold text-pine hover:bg-paper transition"
>
<ArrowLeft className="w-3.5 h-3.5" />
GERİ DÖN
</Link>
{/* Collection details banner */}
<div className="bg-paper border border-pine/8 rounded-3xl overflow-hidden shadow-sm flex flex-col md:flex-row gap-6 md:gap-8 p-6 sm:p-8">
{col.coverImage && (
<div className="w-full md:w-80 h-56 relative rounded-2xl overflow-hidden bg-stone shrink-0 border border-pine/5">
<img src={col.coverImage} alt={title} className="w-full h-full object-cover" />
</div>
)}
<div className="flex flex-col justify-center space-y-3">
<span className="text-[9px] font-mono tracking-widest text-turquoise uppercase font-bold flex items-center gap-1.5">
<Layers className="w-3.5 h-3.5" />
Editör Seçkisi
</span>
<h1 className="text-2xl sm:text-3xl font-heading font-extrabold text-pine lowercase leading-tight">
{title}
</h1>
<p className="text-ink/75 text-xs sm:text-sm leading-relaxed max-w-2xl font-medium">
{desc}
</p>
</div>
</div>
{/* Listings Grid */}
<div className="space-y-6">
<h2 className="text-lg font-heading font-extrabold text-pine lowercase border-b border-dashed border-pine/8 pb-3">
seçkide yer alan yerler ({listings.length})
</h2>
{listings.length === 0 ? (
<div className="bg-paper border border-pine/8 p-12 text-center text-shutter text-sm rounded-3xl">
Bu seçkiye henüz mekan eklenmemiş.
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{listings.map((listing) => (
<ListingCard key={listing.id} listing={listing} />
))}
</div>
)}
</div>
</div>
</div>
)
}
+98
View File
@@ -0,0 +1,98 @@
import { mockDb } from '@/lib/mockDb'
import { Link } from '@/i18n/routing'
import { getTranslations } from 'next-intl/server'
import { Layers } from 'lucide-react'
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 default async function CollectionsIndexPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params
const t = await getTranslations('nav')
const collections = await mockDb.getCollections()
return (
<div className="bg-stone min-h-screen py-10 px-4 sm:px-6 lg:px-8 font-sans">
<div className="max-w-5xl mx-auto space-y-10">
{/* Title */}
<div className="text-center space-y-3">
<span className="text-[10px] font-mono tracking-widest text-shutter uppercase bg-paper px-3 py-1.5 rounded-full border border-pine/5">
özel tematik listeler
</span>
<h1 className="text-4xl font-heading font-extrabold text-pine lowercase">
yerel onaylı <span className="text-turquoise">seçkiler</span>
</h1>
<p className="max-w-xl mx-auto text-ink/70 text-xs sm:text-sm font-medium">
Editörlerimizin deneyimlerine göre özenle hazırladığı, güncel kategorize edilmiş mekan listeleri.
</p>
</div>
{/* Collections Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{collections.length === 0 ? (
<div className="col-span-2 bg-paper border border-pine/8 p-12 text-center text-shutter text-sm rounded-3xl">
Henüz eklenmiş bir seçki bulunmuyor.
</div>
) : (
collections.map((col) => {
const title = locale === 'en' ? col.titleEn : locale === 'ru' ? col.titleRu : col.titleTr
const desc = locale === 'en' ? col.descriptionEn : locale === 'ru' ? col.descriptionRu : col.descriptionTr
const listingCount = col.listings?.length || 0
return (
<div
key={col.id}
className="bg-paper border border-pine/8 rounded-3xl overflow-hidden shadow-sm flex flex-col justify-between hover:border-turquoise/35 transition-all duration-300 hover:shadow-md group"
>
<div>
{/* Cover image */}
{col.coverImage && (
<div className="h-56 relative overflow-hidden bg-stone border-b border-pine/5">
<img
src={col.coverImage}
alt={title}
className="w-full h-full object-cover group-hover:scale-103 transition duration-500"
/>
{/* Count tag */}
<span className="absolute top-4 right-4 bg-pine text-stone font-mono text-[9px] font-bold px-2.5 py-1 rounded-full uppercase tracking-wider">
{listingCount} Mekan
</span>
</div>
)}
<div className="p-6 sm:p-8 space-y-3">
<h2 className="font-heading font-extrabold text-lg text-pine group-hover:text-turquoise transition duration-150 lowercase leading-snug">
<Link href={`/secki/${col.slug}`}>
{title}
</Link>
</h2>
<p className="text-ink/75 text-xs sm:text-sm leading-relaxed line-clamp-3">
{desc}
</p>
</div>
</div>
<div className="px-6 sm:px-8 pb-6 sm:pb-8 pt-2">
<Link
href={`/secki/${col.slug}`}
className="inline-flex items-center gap-1.5 text-xs font-mono font-bold text-turquoise hover:underline uppercase tracking-wider"
>
listeyi incele
</Link>
</div>
</div>
)
})
)}
</div>
</div>
</div>
)
}
+133
View File
@@ -93,6 +93,7 @@ export async function approveSubmissionAction(id: string) {
priceRange: 2,
rating: 5.0,
isLocalApproved: false,
isFeatured: false,
images: submission.imageUrl ? [submission.imageUrl] : []
})
@@ -149,6 +150,7 @@ export async function createOrUpdateListingAction(formData: FormData) {
const priceRange = parseInt(formData.get('priceRange') as string)
const rating = formData.get('rating') ? parseFloat(formData.get('rating') as string) : null
const isLocalApproved = formData.get('isLocalApproved') === 'true'
const isFeatured = formData.get('isFeatured') === 'true'
const latitude = formData.get('latitude') ? parseFloat(formData.get('latitude') as string) : null
const longitude = formData.get('longitude') ? parseFloat(formData.get('longitude') as string) : null
@@ -209,6 +211,7 @@ export async function createOrUpdateListingAction(formData: FormData) {
priceRange,
rating,
isLocalApproved,
isFeatured,
latitude,
longitude,
openingHours,
@@ -230,3 +233,133 @@ export async function createOrUpdateListingAction(formData: FormData) {
return { success: false, error: err.message || 'Mekan kaydedilemedi.' }
}
}
// Blog Actions
export async function deleteBlogPostAction(id: string) {
await mockDb.deleteBlogPost(id)
revalidatePath('/admin/blog')
revalidatePath('/blog')
return { success: true }
}
export async function createOrUpdateBlogPostAction(formData: FormData) {
const id = formData.get('id') as string | null
const slug = formData.get('slug') as string
const titleTr = formData.get('titleTr') as string
const titleEn = formData.get('titleEn') as string
const titleRu = formData.get('titleRu') as string
const contentTr = formData.get('contentTr') as string
const contentEn = formData.get('contentEn') as string
const contentRu = formData.get('contentRu') as string
const tagsStr = formData.get('tags') as string || ''
const tags = tagsStr.split(',').map(t => t.trim()).filter(Boolean)
const relatedListingIdsStr = formData.get('relatedListingIds') as string || ''
const relatedListingIds = relatedListingIdsStr.split(',').map(i => i.trim()).filter(Boolean)
const isPublished = formData.get('isPublished') === 'true'
const publishedAt = isPublished ? new Date() : null
// Handle Cover Image
const coverImageFile = formData.get('coverImageFile') as File | null
let coverImage = formData.get('coverImageUrl') as string | null || null
if (coverImageFile && coverImageFile.size > 0) {
try {
coverImage = await uploadToOpeninary(coverImageFile, `blog/${slug}`)
} catch (e: any) {
console.error('Blog cover upload error:', e)
return { success: false, error: `Kapak görseli yüklenemedi: ${e.message}` }
}
}
const data = {
slug,
titleTr,
titleEn,
titleRu,
contentTr,
contentEn,
contentRu,
coverImage,
tags,
relatedListingIds,
publishedAt
}
try {
if (id && id !== 'new') {
await mockDb.updateBlogPost(id, data)
} else {
await mockDb.createBlogPost(data)
}
revalidatePath('/admin/blog')
revalidatePath('/blog')
revalidatePath(`/blog/${slug}`)
return { success: true }
} catch (err: any) {
return { success: false, error: err.message || 'Yazı kaydedilemedi.' }
}
}
// Collections Actions
export async function deleteCollectionAction(id: string) {
await mockDb.deleteCollection(id)
revalidatePath('/admin/collections')
revalidatePath('/seckiler')
return { success: true }
}
export async function createOrUpdateCollectionAction(formData: FormData) {
const id = formData.get('id') as string | null
const slug = formData.get('slug') as string
const titleTr = formData.get('titleTr') as string
const titleEn = formData.get('titleEn') as string
const titleRu = formData.get('titleRu') as string
const descriptionTr = formData.get('descriptionTr') as string
const descriptionEn = formData.get('descriptionEn') as string
const descriptionRu = formData.get('descriptionRu') as string
const listingIdsStr = formData.get('listingIds') as string || ''
const listingIds = listingIdsStr.split(',').map(i => i.trim()).filter(Boolean)
// Handle Cover Image
const coverImageFile = formData.get('coverImageFile') as File | null
let coverImage = formData.get('coverImageUrl') as string | null || null
if (coverImageFile && coverImageFile.size > 0) {
try {
coverImage = await uploadToOpeninary(coverImageFile, `collections/${slug}`)
} catch (e: any) {
console.error('Collection cover upload error:', e)
return { success: false, error: `Kapak görseli yüklenemedi: ${e.message}` }
}
}
const data = {
slug,
titleTr,
titleEn,
titleRu,
descriptionTr,
descriptionEn,
descriptionRu,
coverImage,
listingIds
}
try {
if (id && id !== 'new') {
await mockDb.updateCollection(id, data)
} else {
await mockDb.createCollection(data)
}
revalidatePath('/admin/collections')
revalidatePath('/seckiler')
revalidatePath(`/secki/${slug}`)
return { success: true }
} catch (err: any) {
return { success: false, error: err.message || 'Koleksiyon kaydedilemedi.' }
}
}
+65
View File
@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from 'next/server'
import { mockDb } from '@/lib/mockDb'
export async function POST(req: NextRequest) {
const authHeader = req.headers.get('Authorization')
const secret = process.env.CRON_SECRET || 'secret-token-key-123'
if (authHeader !== `Bearer ${secret}`) {
return new NextResponse('Unauthorized', { status: 401 })
}
// Get listings that have instagram handle filled
const listings = await mockDb.getListings()
const activeListings = listings.filter(l => l.instagram)
const syncResults = []
for (const listing of activeListings) {
const handle = listing.instagram!
let posts = []
if (process.env.USE_MOCK === 'true') {
// In mock/demo mode, return simulated posts with high quality Unsplash placeholders
posts = [
{ imageUrl: 'https://images.unsplash.com/photo-1544025162-d76694265947?w=500&auto=format&fit=crop&q=80', caption: `Mezelerimiz taze taze hazırlandı! 🐟 @${handle}`, permalink: '#', postedAt: new Date().toISOString() },
{ imageUrl: 'https://images.unsplash.com/photo-1519708227418-c8fd9a32b7a2?w=500&auto=format&fit=crop&q=80', caption: `Bu akşam iskelede gün batımı bir başka güzel... 🌅 @${handle}`, permalink: '#', postedAt: new Date().toISOString() },
{ imageUrl: 'https://images.unsplash.com/photo-1476224203421-9ac39bcb3327?w=500&auto=format&fit=crop&q=80', caption: `Marmaris'in lezzet keyfini kaçırmayın! 🍽️ @${handle}`, permalink: '#', postedAt: new Date().toISOString() }
]
} else {
const apiKey = process.env.RAPIDAPI_KEY
const host = process.env.RAPIDAPI_INSTAGRAM_HOST || 'instagram-scraper-api2.p.rapidapi.com'
if (apiKey) {
try {
const res = await fetch(`https://${host}/v1/user/posts?username=${handle}`, {
headers: {
'x-rapidapi-key': apiKey,
'x-rapidapi-host': host
}
})
if (res.ok) {
const resultData = await res.json()
const items = resultData?.data?.items || []
posts = items.slice(0, 3).map((item: any) => ({
imageUrl: item.image_versions2?.candidates?.[0]?.url || item.thumbnail_url,
caption: item.caption?.text || '',
permalink: `https://instagram.com/p/${item.code}`,
postedAt: new Date(item.taken_at * 1000).toISOString()
}))
}
} catch (e) {
console.error(`Error scraping Instagram for @${handle}:`, e)
}
}
}
if (posts.length > 0) {
await mockDb.updateInstagramFeedCache(listing.id, handle, posts)
syncResults.push({ id: listing.id, handle, postsCount: posts.length })
}
}
return NextResponse.json({ success: true, synced: syncResults })
}
+58
View File
@@ -0,0 +1,58 @@
import { mockDb } from '@/lib/mockDb'
export async function GET() {
const baseUrl = 'https://marmarislocal.com'
// Fetch data
const posts = await mockDb.getBlogPosts(true)
const listings = await mockDb.getListings({ isLocalApproved: true })
// Construct XML
let xml = `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Marmaris Local</title>
<link>${baseUrl}</link>
<description>Marmaris'in yerel rehberi, gurme mekanları ve gezilecek gizli yerleri.</description>
<language>tr</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
<atom:link href="${baseUrl}/feed.xml" rel="self" type="application/rss+xml" />
`
// Add blog posts
for (const post of posts.slice(0, 10)) {
const pubDate = post.publishedAt ? new Date(post.publishedAt).toUTCString() : new Date(post.createdAt).toUTCString()
xml += ` <item>
<title><![CDATA[${post.titleTr}]]></title>
<link>${baseUrl}/tr/blog/${post.slug}</link>
<guid>${baseUrl}/tr/blog/${post.slug}</guid>
<pubDate>${pubDate}</pubDate>
<description><![CDATA[${post.contentTr.substring(0, 200)}...]]></description>
</item>
`
}
// Add newly added approved listings
for (const listing of listings.slice(0, 10)) {
const pubDate = new Date(listing.createdAt).toUTCString()
const catSlug = listing.category?.slug || 'isletmeler'
xml += ` <item>
<title><![CDATA[Yeni Onaylandı: ${listing.nameTr} (${listing.neighborhood?.nameTr})]]></title>
<link>${baseUrl}/tr/${catSlug}/${listing.slug}</link>
<guid>${baseUrl}/tr/${catSlug}/${listing.slug}</guid>
<pubDate>${pubDate}</pubDate>
<description><![CDATA[${listing.descriptionTr}]]></description>
</item>
`
}
xml += `</channel>
</rss>`
return new Response(xml, {
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'Cache-Control': 'public, s-maxage=86400, stale-while-revalidate=43200'
}
})
}
+10
View File
@@ -47,6 +47,16 @@ export default function Footer() {
{nav('businesses')}
</Link>
</li>
<li>
<Link href="/seckiler" className="hover:text-turquoise transition-colors">
{nav('collections')}
</Link>
</li>
<li>
<Link href="/blog" className="hover:text-turquoise transition-colors">
{nav('blog')}
</Link>
</li>
</ul>
</div>
+46 -8
View File
@@ -1,7 +1,10 @@
'use client'
import { useState, useEffect } from 'react'
import Image from 'next/image'
import { Link } from '@/i18n/routing'
import { useLocale } from 'next-intl'
import { Star } from 'lucide-react'
import { Star, Heart } from 'lucide-react'
export interface Gallery {
id: string
@@ -44,6 +47,39 @@ export interface Listing {
export default function ListingCard({ listing }: { listing: Listing }) {
const locale = useLocale()
const [isSaved, setIsSaved] = useState(false)
useEffect(() => {
try {
const stored = localStorage.getItem('savedListingIds')
if (stored) {
const ids = JSON.parse(stored) as string[]
setIsSaved(ids.includes(listing.id))
}
} catch (e) {
console.error('Error reading localStorage:', e)
}
}, [listing.id])
const toggleSave = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
try {
const stored = localStorage.getItem('savedListingIds')
let ids: string[] = stored ? JSON.parse(stored) : []
if (ids.includes(listing.id)) {
ids = ids.filter(id => id !== listing.id)
setIsSaved(false)
} else {
ids.push(listing.id)
setIsSaved(true)
}
localStorage.setItem('savedListingIds', JSON.stringify(ids))
window.dispatchEvent(new Event('favorites-updated'))
} catch (err) {
console.error('Error writing localStorage:', err)
}
}
const name =
locale === 'ru'
@@ -52,13 +88,6 @@ export default function ListingCard({ listing }: { listing: Listing }) {
? listing.nameEn
: listing.nameTr
const description =
locale === 'ru'
? listing.descriptionRu
: locale === 'en'
? listing.descriptionEn
: listing.descriptionTr
const categoryName = listing.category
? locale === 'ru'
? listing.category.nameRu
@@ -88,6 +117,15 @@ export default function ListingCard({ listing }: { listing: Listing }) {
href={`/${categorySlug}/${listing.slug}`}
className="group bg-paper rounded-2xl border border-pine/8 overflow-hidden flex flex-col relative shadow-sm hover:shadow-md hover:border-turquoise/35 transition-all duration-300 transform hover:-translate-y-0.5"
>
{/* Heart Save Button */}
<button
onClick={toggleSave}
className="absolute top-4 left-4 z-20 w-9 h-9 rounded-full bg-paper/95 border border-pine/8 flex items-center justify-center shadow-sm hover:bg-stone transition duration-150 text-pine"
aria-label="Kaydet"
>
<Heart className={`w-4.5 h-4.5 transition duration-150 ${isSaved ? 'fill-bougainvillea text-bougainvillea' : 'text-pine/70 hover:text-pine'}`} />
</button>
{/* Local Approved Seal */}
{listing.isLocalApproved && (
<div className="absolute top-4 right-4 z-10 w-[52px] h-[52px] rounded-full border-[1.5px] border-turquoise bg-paper flex items-center justify-center -rotate-12 shadow-sm shrink-0">
+59 -2
View File
@@ -1,9 +1,9 @@
'use client'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { Link, usePathname, useRouter } from '@/i18n/routing'
import { useTranslations, useLocale } from 'next-intl'
import { Menu, X, Globe, PlusCircle } from 'lucide-react'
import { Menu, X, Globe, PlusCircle, Heart } from 'lucide-react'
export default function Navbar() {
const t = useTranslations('nav')
@@ -12,6 +12,33 @@ export default function Navbar() {
const router = useRouter()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [langMenuOpen, setLangMenuOpen] = useState(false)
const [favoritesCount, setFavoritesCount] = useState(0)
useEffect(() => {
const updateCount = () => {
try {
const stored = localStorage.getItem('savedListingIds')
if (stored) {
const ids = JSON.parse(stored) as string[]
setFavoritesCount(ids.length)
} else {
setFavoritesCount(0)
}
} catch (e) {
console.error('Error reading localStorage:', e)
}
}
updateCount()
window.addEventListener('favorites-updated', updateCount)
window.addEventListener('storage', updateCount)
return () => {
window.removeEventListener('favorites-updated', updateCount)
window.removeEventListener('storage', updateCount)
}
}, [])
const languages = [
{ code: 'tr', label: 'Türkçe' },
@@ -24,6 +51,8 @@ export default function Navbar() {
{ name: t('restaurants'), href: '/restoranlar' },
{ name: t('aparts'), href: '/apartlar' },
{ name: t('businesses'), href: '/isletmeler' },
{ name: t('collections'), href: '/seckiler' },
{ name: t('blog'), href: '/blog' },
{ name: t('about'), href: '/hakkinda' },
{ name: t('contact'), href: '/iletisim' }
]
@@ -74,6 +103,20 @@ export default function Navbar() {
{/* Action Items */}
<div className="hidden lg:flex items-center gap-4">
{/* Saved items trigger */}
<Link
href="/kaydedilenler"
className="relative w-8 h-8 rounded-full border border-stone/20 hover:border-turquoise transition text-stone hover:text-turquoise flex items-center justify-center"
title={t('saved')}
>
<Heart className="w-4 h-4" />
{favoritesCount > 0 && (
<span className="absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-bougainvillea text-stone text-[8px] font-mono font-bold flex items-center justify-center shadow-sm shrink-0">
{favoritesCount}
</span>
)}
</Link>
{/* Language Selector */}
<div className="relative">
<button
@@ -117,6 +160,20 @@ export default function Navbar() {
{/* Mobile menu button */}
<div className="flex items-center gap-3 lg:hidden">
{/* Mobile Saved trigger */}
<Link
href="/kaydedilenler"
className="relative w-8 h-8 rounded-full border border-stone/20 text-stone flex items-center justify-center"
title={t('saved')}
>
<Heart className="w-4 h-4" />
{favoritesCount > 0 && (
<span className="absolute -top-1.5 -right-1.5 w-3.5 h-3.5 rounded-full bg-bougainvillea text-stone text-[8px] font-mono font-bold flex items-center justify-center shadow-sm shrink-0">
{favoritesCount}
</span>
)}
</Link>
{/* Lang menu for mobile */}
<div className="relative">
<button
+195
View File
@@ -0,0 +1,195 @@
# Faz 2 Planı — Marmaris Local
**Önceki:** `prd.md` (MVP — tamamlandı)
**Bu doküman:** MVP sonrası geliştirme sırası
Sıralama mantığı: önce trafik getiren şey (içerik/SEO), sonra geleni tutan şey (etkileşim), sonra parayı getiren şey (gelir modeli — ama listeleme sayısı bir eşiğe ulaşmadan anlamsız), en altta her an eklenebilecek düşük efor altyapı işleri.
---
## Faz 2.1 — İçerik / SEO Motoru (öncelik: yüksek, ilk başlanacak)
Directory sitesi tek başına trafik çekmez — kürasyonlu içerik olmadan sadece Google'ın zaten indekslediği işletmelerin bir kopyası olursun. Bu faz olmadan diğerlerinin anlamı sınırlı.
### Blog
```prisma
model BlogPost {
id String @id @default(cuid())
slug String @unique
titleTr String
titleEn String
titleRu String
contentTr String // MDX/rich text
contentEn String
contentRu String
coverImage String? // Openinary
tags String[]
relatedListingIds String[] // Listing'lere internal link için
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
}
```
- Route: `/blog`, `/blog/[slug]`
- Admin: `/admin/blog` — rich text editör (Tiptap öneri, shadcn ile uyumlu)
- Her yazıda ilgili `Listing`'lere otomatik link kartı (internal linking = SEO)
- `schema.org Article` structured data
**İlk içerik önerisi (mock/gerçek karışık başlanabilir):** "Marmaris'te Nerede Yenir 2026", "Gün Batımı İçin 5 Mekan", "İçmeler mi Yat Limanı mı — Nerede Kalınır"
### Kürasyon Listeleri (Koleksiyonlar)
```prisma
model Collection {
id String @id @default(cuid())
slug String @unique
titleTr String
titleEn String
titleRu String
descriptionTr String
descriptionEn String
descriptionRu String
coverImage String?
listings Listing[] @relation("CollectionListings")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
- Route: `/seckiler`, `/secki/[slug]`
- Admin: `/admin/collections` — listeleme seç (multi-select), sürükle-bırak sıralama
- Örnek koleksiyonlar: "Aile Dostu 5 Restoran", "Bütçe Dostu Mekanlar", "Deniz Kenarında Kahvaltı"
- Bu, "Yerel Onaylı" mührünün doğal uzantısı — tekil rozetten küratörlü gruplamaya geçiş
### RSS
- `/feed.xml` — blog yazıları + yeni eklenen "Yerel Onaylı" listelemeler
- Öncelik gerekçesi: kendi başına trafik kanalı değil ama Google News / ileride newsletter otomasyonu (n8n) için altyapı — blog ile aynı sprintte, 1 saatlik ek iş
---
## Faz 2.2 — Etkileşim (öncelik: orta, 2.1 ile paralel gidebilir)
Auth gerektirmeyen, dönüşümü ucuza artıran işler.
### Favorilere Ekle
- **Backend yok** — client-side localStorage, `savedListingIds: string[]`
- Route: `/kaydedilenler` — localStorage'daki ID'leri okuyup listeler
- Paylaşılabilir link: `/kaydedilenler?ids=abc,def,ghi` — arkadaşla link paylaşımı, auth şart değil
### WhatsApp Paylaş
- Her listeleme detay sayfasında `wa.me/?text=...` deep link buton
- Şema değişikliği yok, ~30 dakikalık iş
### Instagram Feed (her listeleme sayfasında)
Sadece anasayfa değil — Instagram hesabı olan **her** listeleme (kategori fark etmez) kendi detay sayfasında son gönderilerini gösterir + profile giden link.
Veri kaynağı: RapidAPI üzerinden bir Instagram scraper endpoint'i (ör. "Instagram Scraper API2" / "Instagram Data" — RapidAPI marketplace'te birden fazla sağlayıcı var, fiyat/limit karşılaştırıp seçilecek). Instagram'ın resmi Graph API'si business-verified hesap + Facebook app review gerektirdiği için, işletme sahiplerinden bu izni almak pratik değil — RapidAPI scraper daha az sürtünmeli.
**Canlı istekte RapidAPI çağırma — YAPMA.** Her sayfa görüntülemesinde çağırırsan hem yavaş hem RapidAPI faturası trafiğe bağlı, öngörülemez olur. Bunun yerine 24 saatte bir çalışan bir arka plan job'ı ile önbelleğe al:
```prisma
model InstagramFeedCache {
id String @id @default(cuid())
listingId String @unique
handle String
posts Json // [{ imageUrl, caption, permalink, postedAt }]
fetchedAt DateTime @default(now())
listing Listing @relation(fields: [listingId], references: [id])
}
```
- `Listing.instagram` alanı zaten PRD'de vardı (handle/URL) — bunu kaynak olarak kullan
- Senkron mekanizması: n8n'de günlük (24h) tetiklenen bir workflow — `instagram` dolu olan tüm `Listing`'leri gez, her biri için RapidAPI'yi çağır, sonucu `POST /api/cron/instagram-sync` (secret header ile korunan internal endpoint) üzerinden `InstagramFeedCache`'e yaz. n8n zaten elinde (auto2.ayris.tech), bu tam onun işi.
- Frontend sadece DB'den okur, hiçbir zaman canlı RapidAPI çağrısı yapmaz — sayfa yükü hızlı kalır
- `fetchedAt` 24 saatten eskiyse UI'da sessizce eski veriyi göstermeye devam et (job bir sonraki döngüde tazeleyecek), kullanıcıya hata gösterme
### Menüyü Gör Butonu
Restoran/bar/club tarzı kategorilerde, işletmenin kendi menü sayfası/PDF'i varsa, listeleme detay sayfasında "Menüyü Gör" butonu → yeni sekmede işletmenin kendi sitesine/menü linkine gönderir.
```prisma
// Listing modeline eklenecek alan
menuUrl String?
```
- Kategoriye göre kod içinde zorlama yok — buton sadece `menuUrl` doluysa görünür, admin doğal olarak bunu sadece ilgili kategorilerde dolduracak
- Admin formunda (`/admin/listings/[id]`) yeni alan: "Menü Linki (opsiyonel)"
### "Yeni Eklenenler" Widget
- Şema değişikliği yok — `Listing.findMany({ orderBy: createdAt desc, take: 6 })`
- Anasayfa + kategori sayfalarında taze içerik sinyali
---
## Faz 2.3 — Gelir Modeli: Öne Çıkan Listeleme (öncelik: koşullu — belirli bir listeleme sayısına ulaşınca anlamlı)
**Ön koşul:** En az ~30-40 aktif "Yerel Onaylı" listeleme olmadan bu faza girmeye gerek yok — az listelemeyle "öne çıkan" kavramının hiçbir ayırt edici gücü olmaz.
```prisma
// Listing modeline eklenecek alanlar
isFeatured Boolean @default(false)
featuredUntil DateTime? // otomatik süre dolumu için
```
- Kategori/anasayfa sıralamasında `isFeatured` önce gelir
- Admin toggle + tarih seçici (`/admin/listings/[id]`)
- Gelir mekanizması: Ayris Tech'in mevcut 40+ müşteri ilişkisiyle doğal satış kanalı — ayrı bir ödeme altyapısı MVP'de gerekmez, manuel faturalama yeterli
---
## Faz 2.4 — Altyapı (öncelik: düşük efor, istenilen an araya sokulabilir)
### PWA
- `manifest.json` + service worker (`next-pwa` veya manuel)
- "Ana ekrana ekle" — turist telefonda kullanıyor, pratik fark yaratır
### Analytics
- Kendi VPS'teki self-hosted panel (Plausible-tarzı, zaten var) — yeni SaaS ödemesi gerekmiyor
- Takip edilecek event'ler: favori ekleme, WhatsApp paylaşım tıklaması, QR kaynak trafiği, kategori bazlı arama
---
## Önerilen Sıra
1. **Blog + Koleksiyonlar** (2.1) — SEO motoru olmadan geri kalanının önemi düşük
2. **Menü butonu + Instagram feed + Favoriler + WhatsApp paylaş + Yeni Eklenenler** (2.2) — düşük-orta efor, hızlı kazanım, 2.1 ile aynı sprint'te bile yapılabilir. Instagram feed'in n8n cron tarafı ayrı bir iş parçası olarak planlanmalı (senkron job önce kurulmalı, sonra frontend)
3. **PWA + Analytics** (2.4) — ne zaman uygun olursa, bağımsız işler
4. **Öne Çıkan Listeleme** (2.3) — listeleme sayısı eşiğe ulaşınca devreye al
## Env Değişkenleri (Faz 2 eklentileri)
```env
RAPIDAPI_KEY=""
RAPIDAPI_INSTAGRAM_HOST="" # seçilen sağlayıcıya göre (ör. instagram-scraper-api2.p.rapidapi.com)
CRON_SECRET="" # /api/cron/instagram-sync endpoint'ini korumak için
```
## Kararlar (önceki açık sorulardan)
- **Blog içeriği:** DeepSeek ile üretilip otomasyonla (n8n) yayınlanacak — akış detayı ayrıca kurulacak
- **Koleksiyon kapak görselleri:** stok veya yerinde çekim fark etmiyor, ikisi de kullanılabilir
- **Öne çıkan listeleme fiyatlandırması:** aylık sabit ücret (süre bazlı değil)
- **RapidAPI sağlayıcısı:** free-tier bir model kullanılacak
**Free-tier RapidAPI konusunda dikkat edilecek nokta:** RapidAPI'deki Instagram scraper'ların çoğunda (ör. "Instagram Scraper API2", "Instagram Looter2") free/basic plan genelde aylık ~50-100 istek civarında sınırlı. 24 saatte bir senkron ile bile, listeleme sayısı arttıkça (30-40 listeleme × günlük = ~900-1200 istek/ay) free tier hızla yetersiz kalır. Pratik yaklaşım:
- Az listelemeyle (MVP/Faz 2 başlangıcı) free tier rahatça yeter
- Listeleme sayısı büyüdükçe ya senkron sıklığını düşür (24h yerine 48-72h), ya sadece `isLocalApproved` olanları senkronla, ya da o noktada ücretli tiere geç
- İlk kurulumda 2-3 free-tier sağlayıcıyı denemek (aynı endpoint şeklini farklı sağlayıcıdan alacağın için `InstagramFeedCache` şeması sağlayıcıdan bağımsız kalacak şekilde tasarlandı) makul bir başlangıç
## Açık Sorular
- [ ] Free-tier adaylarından hangisi seçilecek — birkaçını test edip (aynı listeleme üzerinde) veri kalitesi/limit karşılaştırması yapılabilir
+478 -103
View File
@@ -51,6 +51,11 @@ export interface Listing {
createdAt: Date
updatedAt: Date
deletedAt?: Date | null
// Phase 2
menuUrl?: string | null
isFeatured: boolean
featuredUntil?: Date | null
}
export interface BusinessSubmission {
@@ -81,12 +86,62 @@ export interface ContactMessage {
updatedAt: Date
}
export interface BlogPost {
id: string
slug: string
titleTr: string
titleEn: string
titleRu: string
contentTr: string
contentEn: string
contentRu: string
coverImage?: string | null
tags: string[]
relatedListingIds: string[]
publishedAt?: Date | null
createdAt: Date
updatedAt: Date
deletedAt?: Date | null
}
export interface Collection {
id: string
slug: string
titleTr: string
titleEn: string
titleRu: string
descriptionTr: string
descriptionEn: string
descriptionRu: string
coverImage?: string | null
listings?: Listing[]
listingIds: string[]
createdAt: Date
updatedAt: Date
}
export interface InstagramFeedCache {
id: string
listingId: string
handle: string
posts: Array<{
imageUrl: string
caption?: string
permalink?: string
postedAt: string
}>
fetchedAt: Date
}
const globalForMockDb = globalThis as unknown as {
categories: Category[]
neighborhoods: Neighborhood[]
listings: Listing[]
submissions: BusinessSubmission[]
messages: ContactMessage[]
blogPosts: BlogPost[]
collections: Collection[]
instagramFeedCaches: InstagramFeedCache[]
initialized: boolean
}
@@ -108,7 +163,7 @@ if (!globalForMockDb.initialized) {
globalForMockDb.submissions = []
globalForMockDb.messages = []
// Pre-populate with realistic Marmaris data
// Seed Listings with Phase 2 fields
globalForMockDb.listings = [
{
id: 'list-1',
@@ -126,7 +181,7 @@ if (!globalForMockDb.initialized) {
phone: '+90 252 412 34 56',
whatsapp: '+90 532 123 45 67',
website: 'https://iskelemarmaris.com',
instagram: 'https://instagram.com/iskele_marmaris',
instagram: 'iskele_marmaris',
priceRange: 3,
rating: 4.8,
isLocalApproved: true,
@@ -137,8 +192,10 @@ if (!globalForMockDb.initialized) {
{ id: 'img-1-1', listingId: 'list-1', url: 'https://images.unsplash.com/photo-1519708227418-c8fd9a32b7a2?w=800&auto=format&fit=crop&q=80' },
{ id: 'img-1-2', listingId: 'list-1', url: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&auto=format&fit=crop&q=80' }
],
createdAt: new Date(),
updatedAt: new Date()
createdAt: new Date(Date.now() - 3600000 * 24 * 5),
updatedAt: new Date(),
menuUrl: 'https://iskelemarmaris.com/menu',
isFeatured: true
},
{
id: 'list-2',
@@ -156,7 +213,7 @@ if (!globalForMockDb.initialized) {
phone: '+90 252 412 78 90',
whatsapp: '+90 533 987 65 43',
website: 'https://mavibeyazmarmaris.com',
instagram: 'https://instagram.com/mavibeyaz_marmaris',
instagram: 'mavibeyaz_marmaris',
priceRange: 2,
rating: 4.5,
isLocalApproved: true,
@@ -166,8 +223,10 @@ if (!globalForMockDb.initialized) {
images: [
{ id: 'img-2-1', listingId: 'list-2', url: 'https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=800&auto=format&fit=crop&q=80' }
],
createdAt: new Date(),
updatedAt: new Date()
createdAt: new Date(Date.now() - 3600000 * 24 * 4),
updatedAt: new Date(),
menuUrl: 'https://mavibeyazmarmaris.com/digital-menu',
isFeatured: false
},
{
id: 'list-3',
@@ -185,7 +244,7 @@ if (!globalForMockDb.initialized) {
phone: '+90 252 413 11 22',
whatsapp: null,
website: null,
instagram: 'https://instagram.com/dostlarkebap_marmaris',
instagram: 'dostlarkebap_marmaris',
priceRange: 1,
rating: 4.7,
isLocalApproved: false,
@@ -195,8 +254,9 @@ if (!globalForMockDb.initialized) {
images: [
{ id: 'img-3-1', listingId: 'list-3', url: 'https://images.unsplash.com/photo-1544025162-d76694265947?w=800&auto=format&fit=crop&q=80' }
],
createdAt: new Date(),
updatedAt: new Date()
createdAt: new Date(Date.now() - 3600000 * 24 * 3),
updatedAt: new Date(),
isFeatured: false
},
{
id: 'list-4',
@@ -225,8 +285,9 @@ if (!globalForMockDb.initialized) {
{ id: 'img-4-1', listingId: 'list-4', url: 'https://images.unsplash.com/photo-1566073771259-6a8506099945?w=800&auto=format&fit=crop&q=80' },
{ id: 'img-4-2', listingId: 'list-4', url: 'https://images.unsplash.com/photo-1520250497591-112f2f40a3f4?w=800&auto=format&fit=crop&q=80' }
],
createdAt: new Date(),
updatedAt: new Date()
createdAt: new Date(Date.now() - 3600000 * 24 * 2),
updatedAt: new Date(),
isFeatured: true
},
{
id: 'list-5',
@@ -254,55 +315,57 @@ if (!globalForMockDb.initialized) {
images: [
{ id: 'img-5-1', listingId: 'list-5', url: 'https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=800&auto=format&fit=crop&q=80' }
],
createdAt: new Date(),
updatedAt: new Date()
createdAt: new Date(Date.now() - 3600000 * 24 * 1),
updatedAt: new Date(),
isFeatured: false
},
{
id: 'list-6',
slug: 'marina-dalis-merkezi',
slug: 'marina-diving-center',
categoryId: 'cat-3',
neighborhoodId: 'neigh-2',
neighborhoodId: 'neigh-1',
city: 'marmaris',
nameTr: 'Marina Dalış Merkezi',
nameEn: 'Marina Diving Center',
nameRu: 'Дайвинг-центр Марина',
descriptionTr: 'Marmaris\'in eşsiz koylarında profesyonel eğitmenler eşliğinde tüplü dalış deneyimi. Başlangıç seviyesinden PADI sertifikasyonuna kadar hizmet.',
descriptionEn: 'Scuba diving experience in unique bays of Marmaris accompanied by professional instructors. Service from discovery dive to PADI certification.',
descriptionRu: 'Опыт подводного плавания в уникальных бухтах Мармариса в сопровождении профессиональных инструкторов. Услуги от ознакомительного погружения до сертификации PADI.',
address: 'Liman Yolu No:32, İçmeler, Marmaris',
phone: '+90 252 455 22 33',
whatsapp: '+90 536 777 88 99',
descriptionTr: 'Marmaris\'in kristal netliğindeki sularında profesyonel eğitmenlerle dalış eğitimleri ve günk dalış turları. CMAS ve PADI sertifikalı eğitimler.',
descriptionEn: 'Diving training and daily diving tours in the crystal clear waters of Marmaris with professional instructors. CMAS and PADI certified courses.',
descriptionRu: 'Обучение дайвингу и ежедневные дайв-туры в кристально чистых водах Мармариса с профессиональными инструкторами. Курсы с сертификатом CMAS и PADI.',
address: 'Yat Limanı Belediye İskelesi, Marmaris',
phone: '+90 532 234 56 78',
whatsapp: '+90 532 234 56 78',
website: 'https://marinadivingmarmaris.com',
instagram: 'https://instagram.com/marinadiving_marmaris',
priceRange: 3,
instagram: null,
priceRange: 2,
rating: 4.9,
isLocalApproved: true,
latitude: 36.8020,
longitude: 28.2320,
openingHours: { all: '08:30 - 19:30' },
latitude: 36.8521,
longitude: 28.2748,
openingHours: { all: '09:00 - 19:00' },
images: [
{ id: 'img-6-1', listingId: 'list-6', url: 'https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=800&auto=format&fit=crop&q=80' }
],
createdAt: new Date(),
updatedAt: new Date()
createdAt: new Date(Date.now() - 3600000 * 12),
updatedAt: new Date(),
isFeatured: false
},
{
id: 'list-7',
slug: 'ege-ruzgari-tekne-kiralama',
slug: 'aegean-wind-yacht-charter',
categoryId: 'cat-3',
neighborhoodId: 'neigh-1',
city: 'marmaris',
nameTr: 'Ege Rüzgarı Tekne Kiralama',
nameEn: 'Aegean Wind Boat Rental',
nameRu: 'Аренда Лодок Эгейский Ветер',
descriptionTr: 'Kaptanlı veya kaptansız günlük ve haftalık özel tekne kiralama hizmeti. Marmaris koylarını kendi rotanızda özgürce keşfedin.',
descriptionEn: 'Daily and weekly private boat rental service with or without skipper. Discover Marmaris bays freely on your own route.',
nameTr: 'Ege Rüzgarı Yat Kiralama',
nameEn: 'Aegean Wind Yacht Charter',
nameRu: 'Аренда Яхт Эгейский Ветер',
descriptionTr: 'Kaptanlı veya kaptansız olarak günlük ve haftalık özel tekne kiralama. Marmaris koylarını kendi rotanızla özgürce keşfedin.',
descriptionEn: 'Daily and weekly private boat charter with or without skipper. Discover the bays of Marmaris freely with your own route.',
descriptionRu: 'Ежедневная и еженедельная аренда частных лодок со шкипером или без. Откройте для себя бухты Мармариса свободно по собственному маршруту.',
address: 'Yat Limanı G İskelesi, Marmaris',
phone: '+90 532 999 88 77',
whatsapp: '+90 532 999 88 77',
website: 'https://egeruzgariboat.com',
instagram: 'https://instagram.com/egeruzgariboat',
instagram: 'egeruzgariboat',
priceRange: 3,
rating: 4.8,
isLocalApproved: true,
@@ -312,8 +375,9 @@ if (!globalForMockDb.initialized) {
images: [
{ id: 'img-7-1', listingId: 'list-7', url: 'https://images.unsplash.com/photo-1567899378494-47b22a2ae96a?w=800&auto=format&fit=crop&q=80' }
],
createdAt: new Date(),
updatedAt: new Date()
createdAt: new Date(Date.now() - 3600000 * 6),
updatedAt: new Date(),
isFeatured: false
},
{
id: 'list-8',
@@ -341,8 +405,102 @@ if (!globalForMockDb.initialized) {
images: [
{ id: 'img-8-1', listingId: 'list-8', url: 'https://images.unsplash.com/photo-1549317661-bd32c8ce0db2?w=800&auto=format&fit=crop&q=80' }
],
createdAt: new Date(Date.now() - 3600000 * 2),
updatedAt: new Date(),
isFeatured: false
}
]
// Seed Blog Posts
globalForMockDb.blogPosts = [
{
id: 'post-1',
slug: 'marmariste-nerede-yenir-2026',
titleTr: 'Marmaris\'te Nerede Yenir? 2026 Lezzet Durakları',
titleEn: 'Where to Eat in Marmaris? 2026 Culinary Hotspots',
titleRu: 'Где поесть в Мармарисе? Лучшие места 2026 года',
contentTr: 'Marmaris, Ege ve Akdeniz mutfağının en taze deniz ürünlerini ve mezelerini bulabileceğiniz harika bir sahil kenti. İşte 2026 yılında ziyaret etmeniz gereken en lezzetli mekanlar...\n\n### 1. İskele Balık Ocakbaşı\nYat Limanında yer alan bu harika mekan, taze balıkları ve mezeleriyle ünlüdür.\n\n### 2. Mavi Beyaz Restoran\nSöğüt köyündeki eşsiz manzarası ve gurme Ege yemekleri ile unutulmaz bir akşam sunuyor.',
contentEn: 'Marmaris is a wonderful coastal town where you can find the freshest seafood and appetizers of Aegean and Mediterranean cuisine. Here are the most delicious places you should visit in 2026...\n\n### 1. Iskele Fish & Grill\nLocated in Yat Limani, this wonderful venue is famous for its fresh fish and appetizers.\n\n### 2. Mavi Beyaz Restaurant\nOffers an unforgettable evening with its unique view and gourmet Aegean dishes in Sogut village.',
contentRu: 'Мармарис — прекрасный прибрежный город, где вы найдете самые свежие морепродукты и закуски эгейской и средиземноморской кухни. Вот самые вкусные места, которые стоит посетить в 2026 году...\n\n### 1. Искеле Балык Оджакбаши\nЭто замечательное заведение, расположенное в Ят Лимани, славится свежей рыбой и закусками.\n\n### 2. Ресторан Мави Беяз\nПредлагает незабываемый вечер с уникальным видом и изысканными блюдами эгейской кухни в деревне Сегют.',
coverImage: 'https://images.unsplash.com/photo-1504674900247-0877df9cc836?w=1200&auto=format&fit=crop&q=80',
tags: ['Restoran', 'Yemek', 'Rehber'],
relatedListingIds: ['list-1', 'list-2'],
publishedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date()
},
{
id: 'post-2',
slug: 'gun-batimi-icin-5-mekan',
titleTr: 'Marmaris\'te Gün Batımını İzleyebileceğiniz En İyi 5 Yer',
titleEn: 'Top 5 Places to Watch the Sunset in Marmaris',
titleRu: '5 лучших мест для наблюдения за закатом в Мармарисе',
contentTr: 'Marmaris\'in en büyüleyici anlarından biri hiç şüphesiz gün batımıdır. Gökyüzünün kızıla büründüğü bu saatlerde unutulmaz kareler yakalayabileceğiniz ve keyifle içeceğinizi yudumlayabileceğiniz en güzel yerleri derledik.\n\n* **Yat Limanı:** Şehir merkezinde en popüler seyir yeri.\n* **İçmeler Sahili:** Adaların arkasından batan güneşi izlemek şahanedir.\n* **Turunç Tepesi:** Kuşbakışı körfez manzarası sunar.',
contentEn: 'One of the most fascinating moments of Marmaris is undoubtedly the sunset. We have compiled the most beautiful places where you can capture unforgettable frames and enjoy your drink while the sky turns red.\n\n* **Marina:** The most popular viewing point in the city center.\n* **Icmeler Beach:** It is wonderful to watch the sun setting behind the islands.\n* **Turunc Hill:** Offers a panoramic view of the bay.',
contentRu: 'Один из самых захватывающих моментов в Мармарисе — это, без сомнения, закат. Мы собрали самые красивые места, где вы сможете сделать незабываемые снимки и насладиться напитком, пока небо окрашивается в красный цвет.\n\n* **Марина:** Самая популярная точка обзора в центре города.\n* **Пляж Ичмелер:** Прекрасно наблюдать за закатом солнца за островами.\n* **Холм Турунч:** Панорамный вид на залив.',
coverImage: 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=1200&auto=format&fit=crop&q=80',
tags: ['Manzara', 'Gezi', 'Gün Batımı'],
relatedListingIds: ['list-2', 'list-7'],
publishedAt: new Date(),
createdAt: new Date(),
updatedAt: new Date()
}
]
// Seed Collections
globalForMockDb.collections = [
{
id: 'col-1',
slug: 'aile-dostu-restoranlar',
titleTr: 'Aile Dostu Restoranlar',
titleEn: 'Family Friendly Restaurants',
titleRu: 'Семейные рестораны',
descriptionTr: 'Çocuklarınızla birlikte rahatça yemek yiyebileceğiniz, oyun alanları ve özel çocuk menüleri bulunan en iyi Marmaris mekanları.',
descriptionEn: 'The best Marmaris restaurants with playgrounds and special kids\' menus where you can comfortably dine with your children.',
descriptionRu: 'Лучшие рестораны Мармариса с детскими площадками и специальным детским меню, где вы сможете комфортно пообедать с детьми.',
coverImage: 'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=1200&auto=format&fit=crop&q=80',
listingIds: ['list-1', 'list-3'],
createdAt: new Date(),
updatedAt: new Date()
},
{
id: 'col-2',
slug: 'butce-dostu-mekanlar',
titleTr: 'Bütçe Dostu Mekanlar',
titleEn: 'Budget-Friendly Venues',
titleRu: 'Бюджетные заведения',
descriptionTr: 'Marmaris tatilinizde cebinizi yormayacak, hem kaliteli hizmet sunan hem de uygun fiyatlı lokasyonlar.',
descriptionEn: 'Budget-friendly locations in Marmaris that offer high-quality service and reasonable prices for your holiday.',
descriptionRu: 'Доступные заведения в Мармарисе, которые предлагают высококачественный сервис и умеренные цены во время вашего отдыха.',
coverImage: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=1200&auto=format&fit=crop&q=80',
listingIds: ['list-3', 'list-5'],
createdAt: new Date(),
updatedAt: new Date()
}
]
// Seed Instagram Caches
globalForMockDb.instagramFeedCaches = [
{
id: 'insta-1',
listingId: 'list-1',
handle: 'iskele_marmaris',
posts: [
{ imageUrl: 'https://images.unsplash.com/photo-1544025162-d76694265947?w=500&auto=format&fit=crop&q=80', caption: 'Mezelerimiz taze taze hazırlandı! 🐟', permalink: '#', postedAt: '2026-07-10T12:00:00Z' },
{ imageUrl: 'https://images.unsplash.com/photo-1519708227418-c8fd9a32b7a2?w=500&auto=format&fit=crop&q=80', caption: 'Bu akşam iskelede gün batımı bir başka güzel... 🌅', permalink: '#', postedAt: '2026-07-09T18:30:00Z' },
{ imageUrl: 'https://images.unsplash.com/photo-1476224203421-9ac39bcb3327?w=500&auto=format&fit=crop&q=80', caption: 'Balık keyfini kaçırmayın! 🍽️', permalink: '#', postedAt: '2026-07-08T14:15:00Z' }
],
fetchedAt: new Date()
},
{
id: 'insta-2',
listingId: 'list-2',
handle: 'mavibeyaz_marmaris',
posts: [
{ imageUrl: 'https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=500&auto=format&fit=crop&q=80', caption: 'Kordon keyfi... 🍷', permalink: '#', postedAt: '2026-07-11T16:00:00Z' },
{ imageUrl: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=500&auto=format&fit=crop&q=80', caption: 'Taze deniz ürünleri ve meze çeşitleri Mavi Beyaz\'da!', permalink: '#', postedAt: '2026-07-08T15:30:00Z' }
],
fetchedAt: new Date()
}
]
@@ -358,7 +516,7 @@ export const mockDb = {
if (this.isMock()) {
return globalForMockDb.categories
}
return db.category.findMany({ orderBy: { nameTr: 'asc' } })
return db.category.findMany({ orderBy: { id: 'asc' } })
},
async getCategoryBySlug(slug: string) {
@@ -368,25 +526,11 @@ export const mockDb = {
return db.category.findUnique({ where: { slug } })
},
async createCategory(data: { slug: string; nameTr: string; nameEn: string; nameRu: string }) {
async getCategoryById(id: string) {
if (this.isMock()) {
const newCat = { id: `cat-${Date.now()}`, ...data }
globalForMockDb.categories.push(newCat)
return newCat
return globalForMockDb.categories.find(c => c.id === id) || null
}
return db.category.create({ data })
},
async updateCategory(id: string, data: { slug: string; nameTr: string; nameEn: string; nameRu: string }) {
if (this.isMock()) {
const idx = globalForMockDb.categories.findIndex(c => c.id === id)
if (idx !== -1) {
globalForMockDb.categories[idx] = { ...globalForMockDb.categories[idx], ...data }
return globalForMockDb.categories[idx]
}
return null
}
return db.category.update({ where: { id }, data })
return db.category.findUnique({ where: { id } })
},
// Neighborhoods
@@ -394,7 +538,7 @@ export const mockDb = {
if (this.isMock()) {
return globalForMockDb.neighborhoods
}
return db.neighborhood.findMany({ orderBy: { nameTr: 'asc' } })
return db.neighborhood.findMany({ orderBy: { id: 'asc' } })
},
async getNeighborhoodBySlug(slug: string) {
@@ -404,52 +548,49 @@ export const mockDb = {
return db.neighborhood.findUnique({ where: { slug } })
},
async createNeighborhood(data: { slug: string; nameTr: string; nameEn: string; nameRu: string }) {
async getNeighborhoodById(id: string) {
if (this.isMock()) {
const newNeigh = { id: `neigh-${Date.now()}`, ...data }
globalForMockDb.neighborhoods.push(newNeigh)
return newNeigh
return globalForMockDb.neighborhoods.find(n => n.id === id) || null
}
return db.neighborhood.create({ data })
return db.neighborhood.findUnique({ where: { id } })
},
async updateNeighborhood(id: string, data: { slug: string; nameTr: string; nameEn: string; nameRu: string }) {
if (this.isMock()) {
const idx = globalForMockDb.neighborhoods.findIndex(n => n.id === id)
if (idx !== -1) {
globalForMockDb.neighborhoods[idx] = { ...globalForMockDb.neighborhoods[idx], ...data }
return globalForMockDb.neighborhoods[idx]
}
return null
}
return db.neighborhood.update({ where: { id }, data })
},
// Listings
// Listings CRUD
async getListings(filters?: {
categoryId?: string
neighborhoodId?: string
priceRange?: number
isLocalApproved?: boolean
search?: string
isFeatured?: boolean
}) {
if (this.isMock()) {
let result = globalForMockDb.listings.filter(l => !l.deletedAt)
let result = [...globalForMockDb.listings].filter(l => !l.deletedAt)
if (filters) {
if (filters.categoryId) result = result.filter(l => l.categoryId === filters.categoryId)
if (filters.neighborhoodId) result = result.filter(l => l.neighborhoodId === filters.neighborhoodId)
if (filters.priceRange) result = result.filter(l => l.priceRange === filters.priceRange)
if (filters.isLocalApproved !== undefined) result = result.filter(l => l.isLocalApproved === filters.isLocalApproved)
if (filters.isFeatured !== undefined) result = result.filter(l => l.isFeatured === filters.isFeatured)
if (filters.search) {
const s = filters.search.toLowerCase()
result = result.filter(l =>
l.nameTr.toLowerCase().includes(s) ||
l.nameEn.toLowerCase().includes(s) ||
l.nameRu.toLowerCase().includes(s) ||
l.address.toLowerCase().includes(s)
result = result.filter(
l =>
l.nameTr.toLowerCase().includes(s) ||
l.nameEn.toLowerCase().includes(s) ||
l.nameRu.toLowerCase().includes(s) ||
l.address.toLowerCase().includes(s)
)
}
}
// Sort: Featured items first, then newer listings first
result.sort((a, b) => {
if (a.isFeatured && !b.isFeatured) return -1
if (!a.isFeatured && b.isFeatured) return 1
return b.createdAt.getTime() - a.createdAt.getTime()
})
return result.map(l => ({
...l,
category: globalForMockDb.categories.find(c => c.id === l.categoryId),
@@ -463,6 +604,7 @@ export const mockDb = {
if (filters.neighborhoodId) where.neighborhoodId = filters.neighborhoodId
if (filters.priceRange) where.priceRange = filters.priceRange
if (filters.isLocalApproved !== undefined) where.isLocalApproved = filters.isLocalApproved
if (filters.isFeatured !== undefined) where.isFeatured = filters.isFeatured
if (filters.search) {
where.OR = [
{ nameTr: { contains: filters.search, mode: 'insensitive' } },
@@ -476,7 +618,10 @@ export const mockDb = {
return db.listing.findMany({
where,
include: { category: true, neighborhood: true, images: true },
orderBy: { createdAt: 'desc' }
orderBy: [
{ isFeatured: 'desc' },
{ createdAt: 'desc' }
]
})
},
@@ -522,18 +667,19 @@ export const mockDb = {
...rest,
images: gallery,
createdAt: new Date(),
updatedAt: new Date()
updatedAt: new Date(),
isFeatured: data.isFeatured || false
}
globalForMockDb.listings.unshift(newListing)
globalForMockDb.listings.push(newListing)
return newListing
}
return db.listing.create({
data: {
...rest,
images: images ? { create: images.map(url => ({ url })) } : undefined
},
include: { images: true }
images: {
create: (images || []).map(url => ({ url }))
}
}
})
},
@@ -542,33 +688,35 @@ export const mockDb = {
if (this.isMock()) {
const idx = globalForMockDb.listings.findIndex(l => l.id === id)
if (idx !== -1) {
const oldListing = globalForMockDb.listings[idx]
const updatedGallery = images
? images.map((url, i) => ({ id: `img-${id}-${i}`, listingId: id, url }))
: oldListing.images
const updated = {
...oldListing,
const gallery = images ? images.map((url, i) => ({ id: `img-${id}-${i}`, listingId: id, url })) : globalForMockDb.listings[idx].images
globalForMockDb.listings[idx] = {
...globalForMockDb.listings[idx],
...rest,
images: updatedGallery,
images: gallery,
updatedAt: new Date()
} as Listing
globalForMockDb.listings[idx] = updated
return updated
return globalForMockDb.listings[idx]
}
return null
}
// Update listing images transaction
if (images) {
await db.gallery.deleteMany({ where: { listingId: id } })
return db.listing.update({
where: { id },
data: {
...rest,
images: {
create: images.map(url => ({ url }))
}
}
})
}
return db.listing.update({
where: { id },
data: {
...rest,
images: images ? { create: images.map(url => ({ url })) } : undefined
},
include: { images: true }
data: rest
})
},
@@ -671,5 +819,232 @@ export const mockDb = {
where: { id },
data: { isRead: true }
})
},
// BlogPost CRUD (Phase 2)
async getBlogPosts(onlyPublished: boolean = false) {
if (this.isMock()) {
let posts = globalForMockDb.blogPosts.filter(p => !p.deletedAt)
if (onlyPublished) {
posts = posts.filter(p => p.publishedAt !== null)
}
return posts.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
}
const where: any = { deletedAt: null }
if (onlyPublished) {
where.publishedAt = { not: null }
}
return db.blogPost.findMany({
where,
orderBy: { createdAt: 'desc' }
})
},
async getBlogPostBySlug(slug: string) {
if (this.isMock()) {
return globalForMockDb.blogPosts.find(p => p.slug === slug && !p.deletedAt) || null
}
return db.blogPost.findUnique({ where: { slug } })
},
async getBlogPostById(id: string) {
if (this.isMock()) {
return globalForMockDb.blogPosts.find(p => p.id === id && !p.deletedAt) || null
}
return db.blogPost.findUnique({ where: { id } })
},
async createBlogPost(data: Omit<BlogPost, 'id' | 'createdAt' | 'updatedAt'>) {
if (this.isMock()) {
const newPost: BlogPost = {
id: `post-${Date.now()}`,
...data,
createdAt: new Date(),
updatedAt: new Date()
}
globalForMockDb.blogPosts.push(newPost)
return newPost
}
return db.blogPost.create({ data })
},
async updateBlogPost(id: string, data: Partial<Omit<BlogPost, 'id' | 'createdAt' | 'updatedAt'>>) {
if (this.isMock()) {
const idx = globalForMockDb.blogPosts.findIndex(p => p.id === id)
if (idx !== -1) {
globalForMockDb.blogPosts[idx] = {
...globalForMockDb.blogPosts[idx],
...data,
updatedAt: new Date()
}
return globalForMockDb.blogPosts[idx]
}
return null
}
return db.blogPost.update({ where: { id }, data })
},
async deleteBlogPost(id: string) {
if (this.isMock()) {
const idx = globalForMockDb.blogPosts.findIndex(p => p.id === id)
if (idx !== -1) {
globalForMockDb.blogPosts[idx].deletedAt = new Date()
return true
}
return false
}
await db.blogPost.update({ where: { id }, data: { deletedAt: new Date() } })
return true
},
// Collection CRUD (Phase 2)
async getCollections() {
if (this.isMock()) {
const colls = [...globalForMockDb.collections]
return colls.map(c => ({
...c,
listings: globalForMockDb.listings.filter(l => c.listingIds.includes(l.id) && !l.deletedAt)
}))
}
return db.collection.findMany({
include: { listings: { include: { images: true, category: true, neighborhood: true } } },
orderBy: { createdAt: 'desc' }
})
},
async getCollectionBySlug(slug: string) {
if (this.isMock()) {
const c = globalForMockDb.collections.find(col => col.slug === slug)
if (!c) return null
return {
...c,
listings: globalForMockDb.listings.filter(l => c.listingIds.includes(l.id) && !l.deletedAt).map(l => ({
...l,
category: globalForMockDb.categories.find(cat => cat.id === l.categoryId),
neighborhood: globalForMockDb.neighborhoods.find(n => n.id === l.neighborhoodId)
}))
}
}
return db.collection.findUnique({
where: { slug },
include: { listings: { include: { images: true, category: true, neighborhood: true } } }
})
},
async getCollectionById(id: string) {
if (this.isMock()) {
const c = globalForMockDb.collections.find(col => col.id === id)
if (!c) return null
return {
...c,
listings: globalForMockDb.listings.filter(l => c.listingIds.includes(l.id) && !l.deletedAt)
}
}
return db.collection.findUnique({
where: { id },
include: { listings: true }
})
},
async createCollection(data: Omit<Collection, 'id' | 'createdAt' | 'updatedAt' | 'listings'>) {
if (this.isMock()) {
const newCol: Collection = {
id: `col-${Date.now()}`,
...data,
createdAt: new Date(),
updatedAt: new Date()
}
globalForMockDb.collections.push(newCol)
return newCol
}
const { listingIds, ...rest } = data
return db.collection.create({
data: {
...rest,
listings: {
connect: listingIds.map(id => ({ id }))
}
}
})
},
async updateCollection(id: string, data: Partial<Omit<Collection, 'id' | 'createdAt' | 'updatedAt' | 'listings'>>) {
if (this.isMock()) {
const idx = globalForMockDb.collections.findIndex(c => c.id === id)
if (idx !== -1) {
globalForMockDb.collections[idx] = {
...globalForMockDb.collections[idx],
...data,
updatedAt: new Date()
} as Collection
return globalForMockDb.collections[idx]
}
return null
}
const { listingIds, ...rest } = data
if (listingIds) {
// For actual DB, first disconnect all and connect new
const current = await db.collection.findUnique({ where: { id }, include: { listings: true } })
const disconnectIds = current?.listings.map(l => ({ id: l.id })) || []
return db.collection.update({
where: { id },
data: {
...rest,
listings: {
disconnect: disconnectIds,
connect: listingIds.map(lid => ({ id: lid }))
}
}
})
}
return db.collection.update({ where: { id }, data: rest as any })
},
async deleteCollection(id: string) {
if (this.isMock()) {
const idx = globalForMockDb.collections.findIndex(c => c.id === id)
if (idx !== -1) {
globalForMockDb.collections.splice(idx, 1)
return true
}
return false
}
await db.collection.delete({ where: { id } })
return true
},
// Instagram Feed Cache (Phase 2)
async getInstagramFeedCacheByListingId(listingId: string) {
if (this.isMock()) {
return globalForMockDb.instagramFeedCaches.find(cache => cache.listingId === listingId) || null
}
return db.instagramFeedCache.findUnique({ where: { listingId } })
},
async updateInstagramFeedCache(listingId: string, handle: string, posts: any[]) {
if (this.isMock()) {
const idx = globalForMockDb.instagramFeedCaches.findIndex(cache => cache.listingId === listingId)
if (idx !== -1) {
globalForMockDb.instagramFeedCaches[idx].handle = handle
globalForMockDb.instagramFeedCaches[idx].posts = posts
globalForMockDb.instagramFeedCaches[idx].fetchedAt = new Date()
return globalForMockDb.instagramFeedCaches[idx]
} else {
const newCache = {
id: `insta-${Date.now()}`,
listingId,
handle,
posts,
fetchedAt: new Date()
}
globalForMockDb.instagramFeedCaches.push(newCache)
return newCache
}
}
return db.instagramFeedCache.upsert({
where: { listingId },
update: { handle, posts, fetchedAt: new Date() },
create: { listingId, handle, posts, fetchedAt: new Date() }
})
}
}
+31 -21
View File
@@ -4,27 +4,30 @@
"restaurants": "Restaurants",
"aparts": "Aparts",
"businesses": "Businesses",
"about": "About Us",
"about": "About",
"contact": "Contact",
"addBusiness": "Add Business",
"admin": "Admin Panel",
"login": "Login"
"admin": "Admin",
"login": "Login",
"blog": "Blog",
"collections": "Collections",
"saved": "Favorites"
},
"hero": {
"title": "The Best Local Places in Marmaris",
"subtitle": "Local knowledge tourists can't see. Handpicked restaurants, apart hotels and hidden gems approved by locals.",
"searchPlaceholder": "Search place, category or neighborhood...",
"title": "Marmaris' Finest Local Spots",
"subtitle": "Local insider knowledge hidden from ordinary tourists. Trusted restaurants, apart hotels, and secret spots verified by residents.",
"searchPlaceholder": "Search venue, category or neighborhood...",
"cta": "Start Exploring",
"approvedBadge": "Local Approved",
"approvedExplain": "The Local Approved Seal shows businesses that have been personally experienced and verified by Marmaris Local editors."
"approvedExplain": "The Local Approved Seal shows establishments personally experienced and verified by Marmaris Local editors."
},
"home": {
"categories": "Categories",
"categoriesSubtitle": "Everything you need to live Marmaris like a local",
"featured": "Featured Local Approved Places",
"featuredSubtitle": "Establishments selected by our editors with quality and taste guarantees",
"categoriesSubtitle": "Everything you need to experience Marmaris like a local",
"featured": "Featured Local Approved Spots",
"featuredSubtitle": "Establishments handpicked by our editors with quality and taste guarantees",
"neighborhoods": "Neighborhoods",
"neighborhoodsSubtitle": "Explore Marmaris by region",
"neighborhoodsSubtitle": "Explore Marmaris by areas",
"explore": "Explore"
},
"categories": {
@@ -33,8 +36,8 @@
"isletme": "General Businesses",
"filterNeighborhood": "Neighborhood Filter",
"filterPrice": "Price Range",
"filterApproved": "Local Approved Only",
"noResults": "No results found matching your criteria.",
"filterApproved": "Approved Only",
"noResults": "No results match your criteria.",
"allNeighborhoods": "All Neighborhoods",
"allPrices": "All Prices",
"rating": "Rating",
@@ -45,18 +48,25 @@
},
"detail": {
"approved": "Local Approved Sealed Business",
"rating": "Editor Rating",
"rating": "Editor Score",
"price": "Price Level",
"address": "Address",
"hours": "Opening Hours",
"contact": "Contact Info",
"call": "Call Now",
"whatsapp": "WhatsApp Message",
"website": "Web Site",
"whatsapp": "Send WhatsApp",
"website": "Website",
"instagram": "Instagram",
"location": "Location / Map",
"related": "Similar Places",
"noHours": "Opening hours not specified."
"noHours": "Opening hours not specified.",
"viewMenu": "View Menu",
"shareWhatsapp": "Share on WhatsApp",
"save": "Save",
"saved": "Saved",
"instagramFeed": "Recent Instagram Posts",
"newlyAdded": "Newly Added",
"newlyAddedSubtitle": "Latest additions to our local guide"
},
"forms": {
"name": "Full Name",
@@ -72,12 +82,12 @@
"description": "Short Description",
"image": "Image File or URL (Optional)",
"submit": "Submit",
"sending": "Submitting...",
"success": "Your submission has been received successfully! It will be published after editor review.",
"contactSuccess": "Your message has been sent successfully. We will get back to you soon."
"sending": "Sending...",
"success": "Your submission has been successfully received! It will be published after editorial review.",
"contactSuccess": "Your message was successfully sent. We will get back to you shortly."
},
"footer": {
"rights": "© 2026 Marmaris Local. All rights reserved.",
"about": "Marmaris Local is a curated directory bringing together the best restaurants, accommodation, and services in Marmaris."
"about": "Marmaris Local is a curated directory bringing together the best restaurants, accommodations, and local services in Marmaris."
}
}
+42 -32
View File
@@ -3,40 +3,43 @@
"home": "Главная",
"restaurants": "Рестораны",
"aparts": "Апартаменты",
"businesses": "Заведения",
"businesses": "Услуги",
"about": "О нас",
"contact": "Контакты",
"addBusiness": "Добавить бизнес",
"admin": "Панель администратора",
"login": "Войти"
"admin": "Админ-панель",
"login": "Войти",
"blog": "Блог",
"collections": "Подборки",
"saved": "Избранное"
},
"hero": {
"title": "Лучшие места Мармариса от местных жителей",
"subtitle": "Информация, которую не увидит обычный турист. Рестораны, апарт-отели и скрытые жемчужины, проверенные местными жителями.",
"searchPlaceholder": "Поиск места, категории или района...",
"title": "Лучшие места Мармариса",
"subtitle": "Инсайдерская информация, скрытая от обычных туристов. Проверенные рестораны, апарт-отели и секретные места, одобренные местными жителями.",
"searchPlaceholder": "Поиск заведения, категории или района...",
"cta": "Начать исследование",
"approvedBadge": "Проверено местными",
"approvedExplain": "Печать «Проверено местными» отмечает заведения, которые были лично опробованы и подтверждены редакторами Marmaris Local."
"approvedBadge": "Одобрено местными",
"approvedExplain": "Знак «Одобрено местными» отмечает заведения, лично проверенные редакторами Marmaris Local."
},
"home": {
"categories": "Категории",
"categoriesSubtitle": "Все, что нужно, чтобы жить в Мармарисе как местный житель",
"featured": "Рекомендуемые места с печатью качества",
"featuredSubtitle": "Заведения, выбранные нашими редакторами с гарантией качества и вкуса",
"categoriesSubtitle": "Все, что нужно, чтобы прочувствовать Мармарис как местный житель",
"featured": "Рекомендуемые заведения",
"featuredSubtitle": "Места, выбранные нашими редакторами, с гарантией качества и вкуса",
"neighborhoods": "Районы",
"neighborhoodsSubtitle": "Исследуйте Мармарис по регионам",
"neighborhoodsSubtitle": "Исследуйте Мармарис по районам",
"explore": "Исследовать"
},
"categories": {
"restoran": "Рестораны",
"apart": "Апартаменты",
"isletme": "Бизнес и Услуги",
"isletme": "Услуги и Сервисы",
"filterNeighborhood": "Фильтр по районам",
"filterPrice": "Ценовой диапазон",
"filterApproved": "Только проверенные местными",
"noResults": "Результатов по вашему запросу не найдено.",
"filterPrice": "Диапазон цен",
"filterApproved": "Только одобренные",
"noResults": "Ничего не найдено по вашему запросу.",
"allNeighborhoods": "Все районы",
"allPrices": "Все цены",
"allPrices": "Любые цены",
"rating": "Рейтинг",
"address": "Адрес",
"phone": "Телефон",
@@ -44,40 +47,47 @@
"viewDetails": "Подробнее"
},
"detail": {
"approved": "Заведение с печатью «Проверено местными»",
"rating": "Рейтинг редакции",
"approved": "Заведение со знаком «Одобрено местными»",
"rating": "Оценка редактора",
"price": "Уровень цен",
"address": "Точный адрес",
"address": "Адрес",
"hours": "Часы работы",
"contact": "Контактная информация",
"contact": "Контакты",
"call": "Позвонить",
"whatsapp": "Написать в WhatsApp",
"website": "Веб-сайт",
"website": "Сайт",
"instagram": "Instagram",
"location": "Местоположение / Карта",
"location": "Расположение / Карта",
"related": "Похожие места",
"noHours": "Часы работы не указаны."
"noHours": "Часы работы не указаны.",
"viewMenu": "Посмотреть меню",
"shareWhatsapp": "Поделиться в WhatsApp",
"save": "Сохранить",
"saved": "Сохранено",
"instagramFeed": "Последние публикации в Instagram",
"newlyAdded": "Недавно добавленные",
"newlyAddedSubtitle": "Последние поступления в наш местный путеводитель"
},
"forms": {
"name": "Имя и фамилия",
"email": "Электронная почта",
"name": "Имя и Фамилия",
"email": "Эл. почта",
"message": "Ваше сообщение",
"subject": "Тема",
"businessName": "Название компании",
"businessName": "Название заведения",
"category": "Категория",
"neighborhood": "Район",
"address": "Адрес компании",
"address": "Адрес заведения",
"phone": "Номер телефона",
"whatsapp": "Номер WhatsApp (необязательно)",
"description": "Краткое описание",
"image": "Файл изображения или URL-адрес (необязательно)",
"image": "Изображение или ссылка (необязательно)",
"submit": "Отправить",
"sending": "Отправка...",
"success": "Ваша заявка успешно принята! Она будет опубликована после проверки редактором.",
"contactSuccess": "Ваше сообщение успешно отправлено. Мы свяжемся с вами в ближайшее время."
"success": "Ваша заявка успешно отправлена! Она будет опубликована после модерации.",
"contactSuccess": "Ваше сообщение успешно отправлено. Мы ответим вам в ближайшее время."
},
"footer": {
"rights": "© 2026 Marmaris Local. Все права защищены.",
"about": "Marmaris Local — это курируемый гид, объединяющий лучшие рестораны, жилье и местные услуги в Мармарисе."
"about": "Marmaris Local — это путеводитель, в котором собраны лучшие рестораны, отели и услуги в Мармарисе."
}
}
+12 -2
View File
@@ -8,7 +8,10 @@
"contact": "İletişim",
"addBusiness": "İşletme Ekle",
"admin": "Yönetim",
"login": "Giriş Yap"
"login": "Giriş Yap",
"blog": "Blog",
"collections": "Seçkiler",
"saved": "Kaydedilenler"
},
"hero": {
"title": "Marmaris'in En İyi Yerel Adresleri",
@@ -56,7 +59,14 @@
"instagram": "Instagram",
"location": "Konum / Harita",
"related": "Benzer Mekanlar",
"noHours": "Çalışma saatleri belirtilmemiş."
"noHours": "Çalışma saatleri belirtilmemiş.",
"viewMenu": "Menüyü Gör",
"shareWhatsapp": "WhatsApp ile Paylaş",
"save": "Kaydet",
"saved": "Kaydedildi",
"instagramFeed": "Instagram'dan Son Paylaşımlar",
"newlyAdded": "Yeni Eklenenler",
"newlyAddedSubtitle": "Rehberimize son eklenen popüler adresler"
},
"forms": {
"name": "Ad Soyad",
+57 -3
View File
@@ -53,9 +53,10 @@ model Session {
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
id_dummy String @id @default(cuid()) // Dummy ID to avoid next-auth adapter issue
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
@@ -118,6 +119,13 @@ model Listing {
category Category @relation(fields: [categoryId], references: [id])
neighborhood Neighborhood @relation(fields: [neighborhoodId], references: [id])
// Phase 2 Fields
menuUrl String?
isFeatured Boolean @default(false)
featuredUntil DateTime?
collections Collection[] @relation("CollectionListings")
instagramFeed InstagramFeedCache?
}
model Gallery {
@@ -156,3 +164,49 @@ model ContactMessage {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model BlogPost {
id String @id @default(cuid())
slug String @unique
titleTr String
titleEn String
titleRu String
contentTr String @db.Text
contentEn String @db.Text
contentRu String @db.Text
coverImage String?
tags String[]
relatedListingIds String[]
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
}
model Collection {
id String @id @default(cuid())
slug String @unique
titleTr String
titleEn String
titleRu String
descriptionTr String @db.Text
descriptionEn String @db.Text
descriptionRu String @db.Text
coverImage String?
listings Listing[] @relation("CollectionListings")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model InstagramFeedCache {
id String @id @default(cuid())
listingId String @unique
handle String
posts Json // [{ imageUrl, caption, permalink, postedAt }]
fetchedAt DateTime @default(now())
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "Marmaris Local Rehberi",
"short_name": "Marmaris Local",
"description": "Marmaris'in yerel rehberi, gurme mekanları ve gizli gezi noktaları.",
"start_url": "/tr",
"display": "standalone",
"background_color": "#FBFAF6",
"theme_color": "#123238",
"orientation": "portrait",
"icons": [
{
"src": "/globe.svg",
"sizes": "192x192",
"type": "image/svg+xml"
},
{
"src": "/globe.svg",
"sizes": "512x512",
"type": "image/svg+xml"
}
]
}