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 ListingCard from '@/components/ListingCard'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import Image from 'next/image' 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 { interface DetailPageProps {
params: Promise<{ locale: string; category: string; slug: string }> 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) { export default async function ListingDetailPage({ params }: DetailPageProps) {
const { locale, category, slug } = await params const { locale, category, slug } = await params
setRequestLocale(locale) setRequestLocale(locale)
@@ -30,6 +43,16 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
.filter(l => l.id !== listing.id) .filter(l => l.id !== listing.id)
.slice(0, 3) .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 // Localized values
const name = const name =
locale === 'ru' locale === 'ru'
@@ -46,22 +69,28 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
: listing.descriptionTr : listing.descriptionTr
const priceSymbols = '₺'.repeat(listing.priceRange) const priceSymbols = '₺'.repeat(listing.priceRange)
const categorySlug = listing.category?.slug || 'isletme'
// Format WhatsApp Link // Format WhatsApp Link
const getWhatsAppLink = (number: string) => { const getWhatsAppLink = (number: string) => {
// Clean spaces, parenthesis, plus sign
const cleanNum = number.replace(/\D/g, '') const cleanNum = number.replace(/\D/g, '')
return `https://wa.me/${cleanNum}` 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 ( return (
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink"> <div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
<Navbar /> <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 */} {/* Breadcrumb */}
<div className="mb-6 text-xs font-mono uppercase tracking-wider text-shutter flex items-center gap-2"> <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 className="hover:text-turquoise transition-colors">marmaris local</span>
<span>/</span> <span>/</span>
<span className="hover:text-turquoise transition-colors"> <span className="hover:text-turquoise transition-colors">
@@ -72,8 +101,7 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
</div> </div>
{/* Hero Details Block */} {/* 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"> <div className="flex flex-col lg:flex-row gap-10">
{/* Left: Gallery Panel */} {/* Left: Gallery Panel */}
@@ -107,8 +135,6 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
{/* Right: Info Panel */} {/* Right: Info Panel */}
<div className="flex-1 flex flex-col justify-between space-y-6"> <div className="flex-1 flex flex-col justify-between space-y-6">
{/* Badge & Title */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap gap-2.5 items-center"> <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"> <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> </div>
{/* Description */} {/* 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} {description}
</div> </div>
{/* Contact Actions */} {/* Contact & Actions Grid */}
<div className="space-y-4"> <div className="space-y-4 pt-2">
<h3 className="font-heading font-bold text-xs uppercase tracking-wider text-shutter">{t('contact')}</h3> <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"> <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')} {t('whatsapp')}
</a> </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> </div>
{/* External links */} {/* 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 && ( {listing.website && (
<a href={listing.website} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 hover:text-turquoise transition"> <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" /> <Globe className="w-4 h-4 text-turquoise" />
{t('website')} {t('website')}
</a> </a>
)} )}
{listing.instagram && ( {listing.instagram && (
<a href={listing.instagram} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 hover:text-turquoise transition"> <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" /> <Globe className="w-4 h-4 text-turquoise" />
{t('instagram')} {t('instagram')}
</a> </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>
</div> </div>
</div> </div>
{/* Details footer (Hours & Location map) */} {/* Details footer (Hours & Location map) */}
@@ -245,15 +295,88 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
</div> </div>
</div> </div>
)} )}
</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 */} {/* Related Section */}
{relatedListings.length > 0 && ( {relatedListings.length > 0 && (
<div className="space-y-6 pt-10"> <div className="space-y-6 pt-6">
<h3 className="text-xl font-heading font-extrabold text-pine lowercase"> <h3 className="text-xl font-heading font-extrabold text-pine lowercase border-b border-dashed border-pine/8 pb-3">
{t('related')} {t('related')}
</h3> </h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8"> <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 = [ const navigation = [
{ name: 'Dashboard', href: '/admin', icon: LayoutDashboard }, { name: 'Dashboard', href: '/admin', icon: LayoutDashboard },
{ name: 'Mekanlar', href: '/admin/listings', icon: ClipboardList }, { 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: 'Başvurular', href: '/admin/submissions', icon: FileText },
{ name: 'Mesajlar', href: '/admin/messages', icon: Inbox }, { name: 'Mesajlar', href: '/admin/messages', icon: Inbox },
{ name: 'Kategoriler', href: '/admin/categories', icon: LayoutDashboard }, { 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 = { export const metadata: Metadata = {
title: "Marmaris Local — Yerel Rehber", title: "Marmaris Local — Yerel Rehber",
description: "Marmaris'in en iyi yerel mekanları, restoranları ve saklı apart otelleri.", description: "Marmaris'in en iyi yerel mekanları, restoranları ve saklı apart otelleri.",
manifest: "/manifest.json",
}; };
export function generateStaticParams() { 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, priceRange: 2,
rating: 5.0, rating: 5.0,
isLocalApproved: false, isLocalApproved: false,
isFeatured: false,
images: submission.imageUrl ? [submission.imageUrl] : [] images: submission.imageUrl ? [submission.imageUrl] : []
}) })
@@ -149,6 +150,7 @@ export async function createOrUpdateListingAction(formData: FormData) {
const priceRange = parseInt(formData.get('priceRange') as string) const priceRange = parseInt(formData.get('priceRange') as string)
const rating = formData.get('rating') ? parseFloat(formData.get('rating') as string) : null const rating = formData.get('rating') ? parseFloat(formData.get('rating') as string) : null
const isLocalApproved = formData.get('isLocalApproved') === 'true' 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 latitude = formData.get('latitude') ? parseFloat(formData.get('latitude') as string) : null
const longitude = formData.get('longitude') ? parseFloat(formData.get('longitude') 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, priceRange,
rating, rating,
isLocalApproved, isLocalApproved,
isFeatured,
latitude, latitude,
longitude, longitude,
openingHours, openingHours,
@@ -230,3 +233,133 @@ export async function createOrUpdateListingAction(formData: FormData) {
return { success: false, error: err.message || 'Mekan kaydedilemedi.' } 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')} {nav('businesses')}
</Link> </Link>
</li> </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> </ul>
</div> </div>
+46 -8
View File
@@ -1,7 +1,10 @@
'use client'
import { useState, useEffect } from 'react'
import Image from 'next/image' import Image from 'next/image'
import { Link } from '@/i18n/routing' import { Link } from '@/i18n/routing'
import { useLocale } from 'next-intl' import { useLocale } from 'next-intl'
import { Star } from 'lucide-react' import { Star, Heart } from 'lucide-react'
export interface Gallery { export interface Gallery {
id: string id: string
@@ -44,6 +47,39 @@ export interface Listing {
export default function ListingCard({ listing }: { listing: Listing }) { export default function ListingCard({ listing }: { listing: Listing }) {
const locale = useLocale() 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 = const name =
locale === 'ru' locale === 'ru'
@@ -52,13 +88,6 @@ export default function ListingCard({ listing }: { listing: Listing }) {
? listing.nameEn ? listing.nameEn
: listing.nameTr : listing.nameTr
const description =
locale === 'ru'
? listing.descriptionRu
: locale === 'en'
? listing.descriptionEn
: listing.descriptionTr
const categoryName = listing.category const categoryName = listing.category
? locale === 'ru' ? locale === 'ru'
? listing.category.nameRu ? listing.category.nameRu
@@ -88,6 +117,15 @@ export default function ListingCard({ listing }: { listing: Listing }) {
href={`/${categorySlug}/${listing.slug}`} 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" 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 */} {/* Local Approved Seal */}
{listing.isLocalApproved && ( {listing.isLocalApproved && (
<div className="absolute top-4 right-4 z-10 w-[52px] h-[52px] rounded-full border-[1.5px] border-turquoise bg-paper flex items-center justify-center -rotate-12 shadow-sm shrink-0"> <div className="absolute 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' 'use client'
import { useState } from 'react' import { useState, useEffect } from 'react'
import { Link, usePathname, useRouter } from '@/i18n/routing' import { Link, usePathname, useRouter } from '@/i18n/routing'
import { useTranslations, useLocale } from 'next-intl' 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() { export default function Navbar() {
const t = useTranslations('nav') const t = useTranslations('nav')
@@ -12,6 +12,33 @@ export default function Navbar() {
const router = useRouter() const router = useRouter()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [langMenuOpen, setLangMenuOpen] = 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 = [ const languages = [
{ code: 'tr', label: 'Türkçe' }, { code: 'tr', label: 'Türkçe' },
@@ -24,6 +51,8 @@ export default function Navbar() {
{ name: t('restaurants'), href: '/restoranlar' }, { name: t('restaurants'), href: '/restoranlar' },
{ name: t('aparts'), href: '/apartlar' }, { name: t('aparts'), href: '/apartlar' },
{ name: t('businesses'), href: '/isletmeler' }, { name: t('businesses'), href: '/isletmeler' },
{ name: t('collections'), href: '/seckiler' },
{ name: t('blog'), href: '/blog' },
{ name: t('about'), href: '/hakkinda' }, { name: t('about'), href: '/hakkinda' },
{ name: t('contact'), href: '/iletisim' } { name: t('contact'), href: '/iletisim' }
] ]
@@ -74,6 +103,20 @@ export default function Navbar() {
{/* Action Items */} {/* Action Items */}
<div className="hidden lg:flex items-center gap-4"> <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 */} {/* Language Selector */}
<div className="relative"> <div className="relative">
<button <button
@@ -117,6 +160,20 @@ export default function Navbar() {
{/* Mobile menu button */} {/* Mobile menu button */}
<div className="flex items-center gap-3 lg:hidden"> <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 */} {/* Lang menu for mobile */}
<div className="relative"> <div className="relative">
<button <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 createdAt: Date
updatedAt: Date updatedAt: Date
deletedAt?: Date | null deletedAt?: Date | null
// Phase 2
menuUrl?: string | null
isFeatured: boolean
featuredUntil?: Date | null
} }
export interface BusinessSubmission { export interface BusinessSubmission {
@@ -81,12 +86,62 @@ export interface ContactMessage {
updatedAt: Date 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 { const globalForMockDb = globalThis as unknown as {
categories: Category[] categories: Category[]
neighborhoods: Neighborhood[] neighborhoods: Neighborhood[]
listings: Listing[] listings: Listing[]
submissions: BusinessSubmission[] submissions: BusinessSubmission[]
messages: ContactMessage[] messages: ContactMessage[]
blogPosts: BlogPost[]
collections: Collection[]
instagramFeedCaches: InstagramFeedCache[]
initialized: boolean initialized: boolean
} }
@@ -108,7 +163,7 @@ if (!globalForMockDb.initialized) {
globalForMockDb.submissions = [] globalForMockDb.submissions = []
globalForMockDb.messages = [] globalForMockDb.messages = []
// Pre-populate with realistic Marmaris data // Seed Listings with Phase 2 fields
globalForMockDb.listings = [ globalForMockDb.listings = [
{ {
id: 'list-1', id: 'list-1',
@@ -126,7 +181,7 @@ if (!globalForMockDb.initialized) {
phone: '+90 252 412 34 56', phone: '+90 252 412 34 56',
whatsapp: '+90 532 123 45 67', whatsapp: '+90 532 123 45 67',
website: 'https://iskelemarmaris.com', website: 'https://iskelemarmaris.com',
instagram: 'https://instagram.com/iskele_marmaris', instagram: 'iskele_marmaris',
priceRange: 3, priceRange: 3,
rating: 4.8, rating: 4.8,
isLocalApproved: true, 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-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' } { 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(), createdAt: new Date(Date.now() - 3600000 * 24 * 5),
updatedAt: new Date() updatedAt: new Date(),
menuUrl: 'https://iskelemarmaris.com/menu',
isFeatured: true
}, },
{ {
id: 'list-2', id: 'list-2',
@@ -156,7 +213,7 @@ if (!globalForMockDb.initialized) {
phone: '+90 252 412 78 90', phone: '+90 252 412 78 90',
whatsapp: '+90 533 987 65 43', whatsapp: '+90 533 987 65 43',
website: 'https://mavibeyazmarmaris.com', website: 'https://mavibeyazmarmaris.com',
instagram: 'https://instagram.com/mavibeyaz_marmaris', instagram: 'mavibeyaz_marmaris',
priceRange: 2, priceRange: 2,
rating: 4.5, rating: 4.5,
isLocalApproved: true, isLocalApproved: true,
@@ -166,8 +223,10 @@ if (!globalForMockDb.initialized) {
images: [ images: [
{ id: 'img-2-1', listingId: 'list-2', url: 'https://images.unsplash.com/photo-1414235077428-338989a2e8c0?w=800&auto=format&fit=crop&q=80' } { 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(), createdAt: new Date(Date.now() - 3600000 * 24 * 4),
updatedAt: new Date() updatedAt: new Date(),
menuUrl: 'https://mavibeyazmarmaris.com/digital-menu',
isFeatured: false
}, },
{ {
id: 'list-3', id: 'list-3',
@@ -185,7 +244,7 @@ if (!globalForMockDb.initialized) {
phone: '+90 252 413 11 22', phone: '+90 252 413 11 22',
whatsapp: null, whatsapp: null,
website: null, website: null,
instagram: 'https://instagram.com/dostlarkebap_marmaris', instagram: 'dostlarkebap_marmaris',
priceRange: 1, priceRange: 1,
rating: 4.7, rating: 4.7,
isLocalApproved: false, isLocalApproved: false,
@@ -195,8 +254,9 @@ if (!globalForMockDb.initialized) {
images: [ images: [
{ id: 'img-3-1', listingId: 'list-3', url: 'https://images.unsplash.com/photo-1544025162-d76694265947?w=800&auto=format&fit=crop&q=80' } { 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(), createdAt: new Date(Date.now() - 3600000 * 24 * 3),
updatedAt: new Date() updatedAt: new Date(),
isFeatured: false
}, },
{ {
id: 'list-4', 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-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' } { 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(), createdAt: new Date(Date.now() - 3600000 * 24 * 2),
updatedAt: new Date() updatedAt: new Date(),
isFeatured: true
}, },
{ {
id: 'list-5', id: 'list-5',
@@ -254,55 +315,57 @@ if (!globalForMockDb.initialized) {
images: [ images: [
{ id: 'img-5-1', listingId: 'list-5', url: 'https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=800&auto=format&fit=crop&q=80' } { 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(), createdAt: new Date(Date.now() - 3600000 * 24 * 1),
updatedAt: new Date() updatedAt: new Date(),
isFeatured: false
}, },
{ {
id: 'list-6', id: 'list-6',
slug: 'marina-dalis-merkezi', slug: 'marina-diving-center',
categoryId: 'cat-3', categoryId: 'cat-3',
neighborhoodId: 'neigh-2', neighborhoodId: 'neigh-1',
city: 'marmaris', city: 'marmaris',
nameTr: 'Marina Dalış Merkezi', nameTr: 'Marina Dalış Merkezi',
nameEn: 'Marina Diving Center', nameEn: 'Marina Diving Center',
nameRu: 'Дайвинг-центр Марина', 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.', 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: 'Scuba diving experience in unique bays of Marmaris accompanied by professional instructors. Service from discovery dive to PADI certification.', descriptionEn: 'Diving training and daily diving tours in the crystal clear waters of Marmaris with professional instructors. CMAS and PADI certified courses.',
descriptionRu: 'Опыт подводного плавания в уникальных бухтах Мармариса в сопровождении профессиональных инструкторов. Услуги от ознакомительного погружения до сертификации PADI.', descriptionRu: 'Обучение дайвингу и ежедневные дайв-туры в кристально чистых водах Мармариса с профессиональными инструкторами. Курсы с сертификатом CMAS и PADI.',
address: 'Liman Yolu No:32, İçmeler, Marmaris', address: 'Yat Limanı Belediye İskelesi, Marmaris',
phone: '+90 252 455 22 33', phone: '+90 532 234 56 78',
whatsapp: '+90 536 777 88 99', whatsapp: '+90 532 234 56 78',
website: 'https://marinadivingmarmaris.com', website: 'https://marinadivingmarmaris.com',
instagram: 'https://instagram.com/marinadiving_marmaris', instagram: null,
priceRange: 3, priceRange: 2,
rating: 4.9, rating: 4.9,
isLocalApproved: true, isLocalApproved: true,
latitude: 36.8020, latitude: 36.8521,
longitude: 28.2320, longitude: 28.2748,
openingHours: { all: '08:30 - 19:30' }, openingHours: { all: '09:00 - 19:00' },
images: [ images: [
{ id: 'img-6-1', listingId: 'list-6', url: 'https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=800&auto=format&fit=crop&q=80' } { 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(), createdAt: new Date(Date.now() - 3600000 * 12),
updatedAt: new Date() updatedAt: new Date(),
isFeatured: false
}, },
{ {
id: 'list-7', id: 'list-7',
slug: 'ege-ruzgari-tekne-kiralama', slug: 'aegean-wind-yacht-charter',
categoryId: 'cat-3', categoryId: 'cat-3',
neighborhoodId: 'neigh-1', neighborhoodId: 'neigh-1',
city: 'marmaris', city: 'marmaris',
nameTr: 'Ege Rüzgarı Tekne Kiralama', nameTr: 'Ege Rüzgarı Yat Kiralama',
nameEn: 'Aegean Wind Boat Rental', nameEn: 'Aegean Wind Yacht Charter',
nameRu: 'Аренда Лодок Эгейский Ветер', 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.', 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 rental service with or without skipper. Discover Marmaris bays freely on your own route.', descriptionEn: 'Daily and weekly private boat charter with or without skipper. Discover the bays of Marmaris freely with your own route.',
descriptionRu: 'Ежедневная и еженедельная аренда частных лодок со шкипером или без. Откройте для себя бухты Мармариса свободно по собственному маршруту.', descriptionRu: 'Ежедневная и еженедельная аренда частных лодок со шкипером или без. Откройте для себя бухты Мармариса свободно по собственному маршруту.',
address: 'Yat Limanı G İskelesi, Marmaris', address: 'Yat Limanı G İskelesi, Marmaris',
phone: '+90 532 999 88 77', phone: '+90 532 999 88 77',
whatsapp: '+90 532 999 88 77', whatsapp: '+90 532 999 88 77',
website: 'https://egeruzgariboat.com', website: 'https://egeruzgariboat.com',
instagram: 'https://instagram.com/egeruzgariboat', instagram: 'egeruzgariboat',
priceRange: 3, priceRange: 3,
rating: 4.8, rating: 4.8,
isLocalApproved: true, isLocalApproved: true,
@@ -312,8 +375,9 @@ if (!globalForMockDb.initialized) {
images: [ images: [
{ id: 'img-7-1', listingId: 'list-7', url: 'https://images.unsplash.com/photo-1567899378494-47b22a2ae96a?w=800&auto=format&fit=crop&q=80' } { 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(), createdAt: new Date(Date.now() - 3600000 * 6),
updatedAt: new Date() updatedAt: new Date(),
isFeatured: false
}, },
{ {
id: 'list-8', id: 'list-8',
@@ -341,8 +405,102 @@ if (!globalForMockDb.initialized) {
images: [ images: [
{ id: 'img-8-1', listingId: 'list-8', url: 'https://images.unsplash.com/photo-1549317661-bd32c8ce0db2?w=800&auto=format&fit=crop&q=80' } { 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(), createdAt: new Date(),
updatedAt: 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()) { if (this.isMock()) {
return globalForMockDb.categories return globalForMockDb.categories
} }
return db.category.findMany({ orderBy: { nameTr: 'asc' } }) return db.category.findMany({ orderBy: { id: 'asc' } })
}, },
async getCategoryBySlug(slug: string) { async getCategoryBySlug(slug: string) {
@@ -368,25 +526,11 @@ export const mockDb = {
return db.category.findUnique({ where: { slug } }) return db.category.findUnique({ where: { slug } })
}, },
async createCategory(data: { slug: string; nameTr: string; nameEn: string; nameRu: string }) { async getCategoryById(id: string) {
if (this.isMock()) { if (this.isMock()) {
const newCat = { id: `cat-${Date.now()}`, ...data } return globalForMockDb.categories.find(c => c.id === id) || null
globalForMockDb.categories.push(newCat)
return newCat
} }
return db.category.create({ data }) return db.category.findUnique({ where: { id } })
},
async updateCategory(id: string, data: { slug: string; nameTr: string; nameEn: string; nameRu: string }) {
if (this.isMock()) {
const idx = globalForMockDb.categories.findIndex(c => c.id === id)
if (idx !== -1) {
globalForMockDb.categories[idx] = { ...globalForMockDb.categories[idx], ...data }
return globalForMockDb.categories[idx]
}
return null
}
return db.category.update({ where: { id }, data })
}, },
// Neighborhoods // Neighborhoods
@@ -394,7 +538,7 @@ export const mockDb = {
if (this.isMock()) { if (this.isMock()) {
return globalForMockDb.neighborhoods return globalForMockDb.neighborhoods
} }
return db.neighborhood.findMany({ orderBy: { nameTr: 'asc' } }) return db.neighborhood.findMany({ orderBy: { id: 'asc' } })
}, },
async getNeighborhoodBySlug(slug: string) { async getNeighborhoodBySlug(slug: string) {
@@ -404,52 +548,49 @@ export const mockDb = {
return db.neighborhood.findUnique({ where: { slug } }) return db.neighborhood.findUnique({ where: { slug } })
}, },
async createNeighborhood(data: { slug: string; nameTr: string; nameEn: string; nameRu: string }) { async getNeighborhoodById(id: string) {
if (this.isMock()) { if (this.isMock()) {
const newNeigh = { id: `neigh-${Date.now()}`, ...data } return globalForMockDb.neighborhoods.find(n => n.id === id) || null
globalForMockDb.neighborhoods.push(newNeigh)
return newNeigh
} }
return db.neighborhood.create({ data }) return db.neighborhood.findUnique({ where: { id } })
}, },
async updateNeighborhood(id: string, data: { slug: string; nameTr: string; nameEn: string; nameRu: string }) { // Listings CRUD
if (this.isMock()) {
const idx = globalForMockDb.neighborhoods.findIndex(n => n.id === id)
if (idx !== -1) {
globalForMockDb.neighborhoods[idx] = { ...globalForMockDb.neighborhoods[idx], ...data }
return globalForMockDb.neighborhoods[idx]
}
return null
}
return db.neighborhood.update({ where: { id }, data })
},
// Listings
async getListings(filters?: { async getListings(filters?: {
categoryId?: string categoryId?: string
neighborhoodId?: string neighborhoodId?: string
priceRange?: number priceRange?: number
isLocalApproved?: boolean isLocalApproved?: boolean
search?: string search?: string
isFeatured?: boolean
}) { }) {
if (this.isMock()) { if (this.isMock()) {
let result = globalForMockDb.listings.filter(l => !l.deletedAt) let result = [...globalForMockDb.listings].filter(l => !l.deletedAt)
if (filters) { if (filters) {
if (filters.categoryId) result = result.filter(l => l.categoryId === filters.categoryId) if (filters.categoryId) result = result.filter(l => l.categoryId === filters.categoryId)
if (filters.neighborhoodId) result = result.filter(l => l.neighborhoodId === filters.neighborhoodId) if (filters.neighborhoodId) result = result.filter(l => l.neighborhoodId === filters.neighborhoodId)
if (filters.priceRange) result = result.filter(l => l.priceRange === filters.priceRange) 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.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) { if (filters.search) {
const s = filters.search.toLowerCase() const s = filters.search.toLowerCase()
result = result.filter(l => result = result.filter(
l.nameTr.toLowerCase().includes(s) || l =>
l.nameEn.toLowerCase().includes(s) || l.nameTr.toLowerCase().includes(s) ||
l.nameRu.toLowerCase().includes(s) || l.nameEn.toLowerCase().includes(s) ||
l.address.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 => ({ return result.map(l => ({
...l, ...l,
category: globalForMockDb.categories.find(c => c.id === l.categoryId), 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.neighborhoodId) where.neighborhoodId = filters.neighborhoodId
if (filters.priceRange) where.priceRange = filters.priceRange if (filters.priceRange) where.priceRange = filters.priceRange
if (filters.isLocalApproved !== undefined) where.isLocalApproved = filters.isLocalApproved if (filters.isLocalApproved !== undefined) where.isLocalApproved = filters.isLocalApproved
if (filters.isFeatured !== undefined) where.isFeatured = filters.isFeatured
if (filters.search) { if (filters.search) {
where.OR = [ where.OR = [
{ nameTr: { contains: filters.search, mode: 'insensitive' } }, { nameTr: { contains: filters.search, mode: 'insensitive' } },
@@ -476,7 +618,10 @@ export const mockDb = {
return db.listing.findMany({ return db.listing.findMany({
where, where,
include: { category: true, neighborhood: true, images: true }, include: { category: true, neighborhood: true, images: true },
orderBy: { createdAt: 'desc' } orderBy: [
{ isFeatured: 'desc' },
{ createdAt: 'desc' }
]
}) })
}, },
@@ -522,18 +667,19 @@ export const mockDb = {
...rest, ...rest,
images: gallery, images: gallery,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date() updatedAt: new Date(),
isFeatured: data.isFeatured || false
} }
globalForMockDb.listings.unshift(newListing) globalForMockDb.listings.push(newListing)
return newListing return newListing
} }
return db.listing.create({ return db.listing.create({
data: { data: {
...rest, ...rest,
images: images ? { create: images.map(url => ({ url })) } : undefined images: {
}, create: (images || []).map(url => ({ url }))
include: { images: true } }
}
}) })
}, },
@@ -542,33 +688,35 @@ export const mockDb = {
if (this.isMock()) { if (this.isMock()) {
const idx = globalForMockDb.listings.findIndex(l => l.id === id) const idx = globalForMockDb.listings.findIndex(l => l.id === id)
if (idx !== -1) { if (idx !== -1) {
const oldListing = globalForMockDb.listings[idx] const gallery = images ? images.map((url, i) => ({ id: `img-${id}-${i}`, listingId: id, url })) : globalForMockDb.listings[idx].images
const updatedGallery = images globalForMockDb.listings[idx] = {
? images.map((url, i) => ({ id: `img-${id}-${i}`, listingId: id, url })) ...globalForMockDb.listings[idx],
: oldListing.images
const updated = {
...oldListing,
...rest, ...rest,
images: updatedGallery, images: gallery,
updatedAt: new Date() updatedAt: new Date()
} as Listing } as Listing
globalForMockDb.listings[idx] = updated return globalForMockDb.listings[idx]
return updated
} }
return null return null
} }
// Update listing images transaction
if (images) { if (images) {
await db.gallery.deleteMany({ where: { listingId: id } }) 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({ return db.listing.update({
where: { id }, where: { id },
data: { data: rest
...rest,
images: images ? { create: images.map(url => ({ url })) } : undefined
},
include: { images: true }
}) })
}, },
@@ -671,5 +819,232 @@ export const mockDb = {
where: { id }, where: { id },
data: { isRead: true } 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", "restaurants": "Restaurants",
"aparts": "Aparts", "aparts": "Aparts",
"businesses": "Businesses", "businesses": "Businesses",
"about": "About Us", "about": "About",
"contact": "Contact", "contact": "Contact",
"addBusiness": "Add Business", "addBusiness": "Add Business",
"admin": "Admin Panel", "admin": "Admin",
"login": "Login" "login": "Login",
"blog": "Blog",
"collections": "Collections",
"saved": "Favorites"
}, },
"hero": { "hero": {
"title": "The Best Local Places in Marmaris", "title": "Marmaris' Finest Local Spots",
"subtitle": "Local knowledge tourists can't see. Handpicked restaurants, apart hotels and hidden gems approved by locals.", "subtitle": "Local insider knowledge hidden from ordinary tourists. Trusted restaurants, apart hotels, and secret spots verified by residents.",
"searchPlaceholder": "Search place, category or neighborhood...", "searchPlaceholder": "Search venue, category or neighborhood...",
"cta": "Start Exploring", "cta": "Start Exploring",
"approvedBadge": "Local Approved", "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": { "home": {
"categories": "Categories", "categories": "Categories",
"categoriesSubtitle": "Everything you need to live Marmaris like a local", "categoriesSubtitle": "Everything you need to experience Marmaris like a local",
"featured": "Featured Local Approved Places", "featured": "Featured Local Approved Spots",
"featuredSubtitle": "Establishments selected by our editors with quality and taste guarantees", "featuredSubtitle": "Establishments handpicked by our editors with quality and taste guarantees",
"neighborhoods": "Neighborhoods", "neighborhoods": "Neighborhoods",
"neighborhoodsSubtitle": "Explore Marmaris by region", "neighborhoodsSubtitle": "Explore Marmaris by areas",
"explore": "Explore" "explore": "Explore"
}, },
"categories": { "categories": {
@@ -33,8 +36,8 @@
"isletme": "General Businesses", "isletme": "General Businesses",
"filterNeighborhood": "Neighborhood Filter", "filterNeighborhood": "Neighborhood Filter",
"filterPrice": "Price Range", "filterPrice": "Price Range",
"filterApproved": "Local Approved Only", "filterApproved": "Approved Only",
"noResults": "No results found matching your criteria.", "noResults": "No results match your criteria.",
"allNeighborhoods": "All Neighborhoods", "allNeighborhoods": "All Neighborhoods",
"allPrices": "All Prices", "allPrices": "All Prices",
"rating": "Rating", "rating": "Rating",
@@ -45,18 +48,25 @@
}, },
"detail": { "detail": {
"approved": "Local Approved Sealed Business", "approved": "Local Approved Sealed Business",
"rating": "Editor Rating", "rating": "Editor Score",
"price": "Price Level", "price": "Price Level",
"address": "Address", "address": "Address",
"hours": "Opening Hours", "hours": "Opening Hours",
"contact": "Contact Info", "contact": "Contact Info",
"call": "Call Now", "call": "Call Now",
"whatsapp": "WhatsApp Message", "whatsapp": "Send WhatsApp",
"website": "Web Site", "website": "Website",
"instagram": "Instagram", "instagram": "Instagram",
"location": "Location / Map", "location": "Location / Map",
"related": "Similar Places", "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": { "forms": {
"name": "Full Name", "name": "Full Name",
@@ -72,12 +82,12 @@
"description": "Short Description", "description": "Short Description",
"image": "Image File or URL (Optional)", "image": "Image File or URL (Optional)",
"submit": "Submit", "submit": "Submit",
"sending": "Submitting...", "sending": "Sending...",
"success": "Your submission has been received successfully! It will be published after editor review.", "success": "Your submission has been successfully received! It will be published after editorial review.",
"contactSuccess": "Your message has been sent successfully. We will get back to you soon." "contactSuccess": "Your message was successfully sent. We will get back to you shortly."
}, },
"footer": { "footer": {
"rights": "© 2026 Marmaris Local. All rights reserved.", "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": "Главная", "home": "Главная",
"restaurants": "Рестораны", "restaurants": "Рестораны",
"aparts": "Апартаменты", "aparts": "Апартаменты",
"businesses": "Заведения", "businesses": "Услуги",
"about": "О нас", "about": "О нас",
"contact": "Контакты", "contact": "Контакты",
"addBusiness": "Добавить бизнес", "addBusiness": "Добавить бизнес",
"admin": "Панель администратора", "admin": "Админ-панель",
"login": "Войти" "login": "Войти",
"blog": "Блог",
"collections": "Подборки",
"saved": "Избранное"
}, },
"hero": { "hero": {
"title": "Лучшие места Мармариса от местных жителей", "title": "Лучшие места Мармариса",
"subtitle": "Информация, которую не увидит обычный турист. Рестораны, апарт-отели и скрытые жемчужины, проверенные местными жителями.", "subtitle": "Инсайдерская информация, скрытая от обычных туристов. Проверенные рестораны, апарт-отели и секретные места, одобренные местными жителями.",
"searchPlaceholder": "Поиск места, категории или района...", "searchPlaceholder": "Поиск заведения, категории или района...",
"cta": "Начать исследование", "cta": "Начать исследование",
"approvedBadge": "Проверено местными", "approvedBadge": "Одобрено местными",
"approvedExplain": "Печать «Проверено местными» отмечает заведения, которые были лично опробованы и подтверждены редакторами Marmaris Local." "approvedExplain": "Знак «Одобрено местными» отмечает заведения, лично проверенные редакторами Marmaris Local."
}, },
"home": { "home": {
"categories": "Категории", "categories": "Категории",
"categoriesSubtitle": "Все, что нужно, чтобы жить в Мармарисе как местный житель", "categoriesSubtitle": "Все, что нужно, чтобы прочувствовать Мармарис как местный житель",
"featured": "Рекомендуемые места с печатью качества", "featured": "Рекомендуемые заведения",
"featuredSubtitle": "Заведения, выбранные нашими редакторами с гарантией качества и вкуса", "featuredSubtitle": "Места, выбранные нашими редакторами, с гарантией качества и вкуса",
"neighborhoods": "Районы", "neighborhoods": "Районы",
"neighborhoodsSubtitle": "Исследуйте Мармарис по регионам", "neighborhoodsSubtitle": "Исследуйте Мармарис по районам",
"explore": "Исследовать" "explore": "Исследовать"
}, },
"categories": { "categories": {
"restoran": "Рестораны", "restoran": "Рестораны",
"apart": "Апартаменты", "apart": "Апартаменты",
"isletme": "Бизнес и Услуги", "isletme": "Услуги и Сервисы",
"filterNeighborhood": "Фильтр по районам", "filterNeighborhood": "Фильтр по районам",
"filterPrice": "Ценовой диапазон", "filterPrice": "Диапазон цен",
"filterApproved": "Только проверенные местными", "filterApproved": "Только одобренные",
"noResults": "Результатов по вашему запросу не найдено.", "noResults": "Ничего не найдено по вашему запросу.",
"allNeighborhoods": "Все районы", "allNeighborhoods": "Все районы",
"allPrices": "Все цены", "allPrices": "Любые цены",
"rating": "Рейтинг", "rating": "Рейтинг",
"address": "Адрес", "address": "Адрес",
"phone": "Телефон", "phone": "Телефон",
@@ -44,40 +47,47 @@
"viewDetails": "Подробнее" "viewDetails": "Подробнее"
}, },
"detail": { "detail": {
"approved": "Заведение с печатью «Проверено местными»", "approved": "Заведение со знаком «Одобрено местными»",
"rating": "Рейтинг редакции", "rating": "Оценка редактора",
"price": "Уровень цен", "price": "Уровень цен",
"address": "Точный адрес", "address": "Адрес",
"hours": "Часы работы", "hours": "Часы работы",
"contact": "Контактная информация", "contact": "Контакты",
"call": "Позвонить", "call": "Позвонить",
"whatsapp": "Написать в WhatsApp", "whatsapp": "Написать в WhatsApp",
"website": "Веб-сайт", "website": "Сайт",
"instagram": "Instagram", "instagram": "Instagram",
"location": "Местоположение / Карта", "location": "Расположение / Карта",
"related": "Похожие места", "related": "Похожие места",
"noHours": "Часы работы не указаны." "noHours": "Часы работы не указаны.",
"viewMenu": "Посмотреть меню",
"shareWhatsapp": "Поделиться в WhatsApp",
"save": "Сохранить",
"saved": "Сохранено",
"instagramFeed": "Последние публикации в Instagram",
"newlyAdded": "Недавно добавленные",
"newlyAddedSubtitle": "Последние поступления в наш местный путеводитель"
}, },
"forms": { "forms": {
"name": "Имя и фамилия", "name": "Имя и Фамилия",
"email": "Электронная почта", "email": "Эл. почта",
"message": "Ваше сообщение", "message": "Ваше сообщение",
"subject": "Тема", "subject": "Тема",
"businessName": "Название компании", "businessName": "Название заведения",
"category": "Категория", "category": "Категория",
"neighborhood": "Район", "neighborhood": "Район",
"address": "Адрес компании", "address": "Адрес заведения",
"phone": "Номер телефона", "phone": "Номер телефона",
"whatsapp": "Номер WhatsApp (необязательно)", "whatsapp": "Номер WhatsApp (необязательно)",
"description": "Краткое описание", "description": "Краткое описание",
"image": "Файл изображения или URL-адрес (необязательно)", "image": "Изображение или ссылка (необязательно)",
"submit": "Отправить", "submit": "Отправить",
"sending": "Отправка...", "sending": "Отправка...",
"success": "Ваша заявка успешно принята! Она будет опубликована после проверки редактором.", "success": "Ваша заявка успешно отправлена! Она будет опубликована после модерации.",
"contactSuccess": "Ваше сообщение успешно отправлено. Мы свяжемся с вами в ближайшее время." "contactSuccess": "Ваше сообщение успешно отправлено. Мы ответим вам в ближайшее время."
}, },
"footer": { "footer": {
"rights": "© 2026 Marmaris Local. Все права защищены.", "rights": "© 2026 Marmaris Local. Все права защищены.",
"about": "Marmaris Local — это курируемый гид, объединяющий лучшие рестораны, жилье и местные услуги в Мармарисе." "about": "Marmaris Local — это путеводитель, в котором собраны лучшие рестораны, отели и услуги в Мармарисе."
} }
} }
+12 -2
View File
@@ -8,7 +8,10 @@
"contact": "İletişim", "contact": "İletişim",
"addBusiness": "İşletme Ekle", "addBusiness": "İşletme Ekle",
"admin": "Yönetim", "admin": "Yönetim",
"login": "Giriş Yap" "login": "Giriş Yap",
"blog": "Blog",
"collections": "Seçkiler",
"saved": "Kaydedilenler"
}, },
"hero": { "hero": {
"title": "Marmaris'in En İyi Yerel Adresleri", "title": "Marmaris'in En İyi Yerel Adresleri",
@@ -56,7 +59,14 @@
"instagram": "Instagram", "instagram": "Instagram",
"location": "Konum / Harita", "location": "Konum / Harita",
"related": "Benzer Mekanlar", "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": { "forms": {
"name": "Ad Soyad", "name": "Ad Soyad",
+57 -3
View File
@@ -53,9 +53,10 @@ model Session {
} }
model VerificationToken { model VerificationToken {
identifier String id_dummy String @id @default(cuid()) // Dummy ID to avoid next-auth adapter issue
token String @unique identifier String
expires DateTime token String @unique
expires DateTime
@@unique([identifier, token]) @@unique([identifier, token])
} }
@@ -118,6 +119,13 @@ model Listing {
category Category @relation(fields: [categoryId], references: [id]) category Category @relation(fields: [categoryId], references: [id])
neighborhood Neighborhood @relation(fields: [neighborhoodId], 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 { model Gallery {
@@ -156,3 +164,49 @@ model ContactMessage {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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"
}
]
}