feat: Implement Phase 2 features including blog, collections, saved listings, manifest, and api cron sync
This commit is contained in:
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
@@ -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.' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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ünlük 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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user