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

This commit is contained in:
AyrisAI
2026-07-12 20:49:41 +03:00
parent 862544b5a1
commit f696d359b0
29 changed files with 3066 additions and 194 deletions
+192
View File
@@ -0,0 +1,192 @@
import { mockDb } from '@/lib/mockDb'
import { Link } from '@/i18n/routing'
import { notFound } from 'next/navigation'
import { Calendar, Tag, ArrowLeft } from 'lucide-react'
import ListingCard from '@/components/ListingCard'
interface Props {
params: Promise<{ locale: string; slug: string }>
}
export async function generateMetadata({ params }: Props) {
const { slug } = await params
const post = await mockDb.getBlogPostBySlug(slug)
if (!post) return {}
return {
title: `${post.titleTr} — Marmaris Local`,
description: post.contentTr.substring(0, 150)
}
}
// Lightweight safe Markdown to HTML parsing function
function renderMarkdownToHtml(md: string): string {
if (!md) return ''
let html = md
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Bold
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/__(.*?)__/g, '<strong>$1</strong>')
// Italic
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>')
html = html.replace(/_(.*?)_/g, '<em>$1</em>')
// Headings
html = html.replace(/^### (.*?)$/gm, '<h4 class="text-base font-heading font-bold text-pine mt-6 mb-2 lowercase">$1</h4>')
html = html.replace(/^## (.*?)$/gm, '<h3 class="text-lg font-heading font-extrabold text-pine mt-8 mb-3 lowercase">$1</h3>')
html = html.replace(/^# (.*?)$/gm, '<h2 class="text-xl font-heading font-extrabold text-pine mt-10 mb-4 lowercase">$1</h2>')
// Bullet Lists
html = html.replace(/^\* (.*?)$/gm, '<li class="ml-4 list-disc text-sm text-ink/80 leading-relaxed">$1</li>')
html = html.replace(/^- (.*?)$/gm, '<li class="ml-4 list-disc text-sm text-ink/80 leading-relaxed">$1</li>')
// Links
html = html.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" class="text-turquoise hover:underline" target="_blank" rel="noopener">$1</a>')
// Paragraphs
const blocks = html.split(/\n\n+/)
html = blocks.map(block => {
const trimmed = block.trim()
if (!trimmed) return ''
if (trimmed.startsWith('<h') || trimmed.startsWith('<li') || trimmed.startsWith('<ul') || trimmed.startsWith('<ol')) {
return trimmed
}
return `<p class="leading-relaxed mb-4 text-sm sm:text-base text-ink/80 font-medium">${trimmed.replace(/\n/g, '<br/>')}</p>`
}).join('\n')
return html
}
export default async function BlogPostDetailPage({ params }: Props) {
const { locale, slug } = await params
const post = await mockDb.getBlogPostBySlug(slug)
if (!post || post.deletedAt) {
notFound()
}
const title = locale === 'en' ? post.titleEn : locale === 'ru' ? post.titleRu : post.titleTr
const content = locale === 'en' ? post.contentEn : locale === 'ru' ? post.contentRu : post.contentTr
const htmlContent = renderMarkdownToHtml(content)
// Fetch associated listings
const relatedListings: any[] = []
if (post.relatedListingIds && post.relatedListingIds.length > 0) {
for (const listingId of post.relatedListingIds) {
const listing = await mockDb.getListingById(listingId)
if (listing) {
relatedListings.push(listing)
}
}
}
// schema.org Structured Data
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
'headline': title,
'image': post.coverImage || 'https://images.unsplash.com/photo-1504674900247-0877df9cc836?w=1200&auto=format&fit=crop&q=80',
'datePublished': post.publishedAt || post.createdAt,
'dateModified': post.updatedAt,
'author': {
'@type': 'Organization',
'name': 'Marmaris Local',
'url': 'https://marmarislocal.com'
},
'publisher': {
'@type': 'Organization',
'name': 'Marmaris Local',
'logo': {
'@type': 'ImageObject',
'url': 'https://marmarislocal.com/logo.png'
}
},
'description': content.substring(0, 150)
}
return (
<>
{/* Schema.org Article Structured Data */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<div className="bg-stone min-h-screen py-10 px-4 sm:px-6 lg:px-8 font-sans">
<div className="max-w-3xl mx-auto space-y-8">
{/* Back Link */}
<Link
href="/blog"
className="inline-flex items-center gap-1.5 px-4 py-2 border border-pine/10 rounded-xl text-xs font-mono font-bold text-pine hover:bg-paper transition"
>
<ArrowLeft className="w-3.5 h-3.5" />
GERİ DÖN
</Link>
{/* Article Box */}
<article className="bg-paper border border-pine/8 rounded-3xl overflow-hidden shadow-sm">
{post.coverImage && (
<div className="h-[350px] relative overflow-hidden bg-stone border-b border-pine/5">
<img
src={post.coverImage}
alt={title}
className="w-full h-full object-cover"
/>
</div>
)}
<div className="p-6 sm:p-10 space-y-6">
{/* Meta */}
<div className="flex flex-wrap items-center gap-4 text-xs text-shutter font-mono border-b border-dashed border-pine/8 pb-4">
<span className="flex items-center gap-1.5">
<Calendar className="w-4 h-4" />
{post.publishedAt ? new Date(post.publishedAt).toLocaleDateString(locale === 'tr' ? 'tr-TR' : locale === 'ru' ? 'ru-RU' : 'en-US') : ''}
</span>
{post.tags.map((tag) => (
<span key={tag} className="flex items-center gap-1 bg-stone/50 px-2 py-0.5 rounded-md border border-pine/5">
<Tag className="w-3.5 h-3.5" />
{tag}
</span>
))}
</div>
{/* Title */}
<h1 className="text-2xl sm:text-3xl font-heading font-extrabold text-pine lowercase leading-tight">
{title}
</h1>
{/* Markdown Content */}
<div
className="markdown-content space-y-4"
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
</div>
</article>
{/* Related Listings Section (Internal Linking) */}
{relatedListings.length > 0 && (
<div className="space-y-6 pt-6">
<h3 className="text-xl font-heading font-extrabold text-pine lowercase border-b border-dashed border-pine/8 pb-3">
yazıda geçen mekanlar
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{relatedListings.map((listing) => (
<ListingCard key={listing.id} listing={listing} />
))}
</div>
</div>
)}
</div>
</div>
</>
)
}
+110
View File
@@ -0,0 +1,110 @@
import { mockDb } from '@/lib/mockDb'
import { Link } from '@/i18n/routing'
import { getTranslations } from 'next-intl/server'
import { Calendar, Tag } from 'lucide-react'
export async function generateMetadata() {
return {
title: 'Marmaris Local Blog — Yerel Lezzet ve Gezi Rehberi',
description: 'Marmaris\'i bir yerel gibi keşfetmeniz için rehberler, nerede ne yenir tavsiyeleri ve gizli yerler.'
}
}
export default async function BlogIndexPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params
const t = await getTranslations('nav')
const posts = await mockDb.getBlogPosts(true)
return (
<div className="bg-stone min-h-screen py-10 px-4 sm:px-6 lg:px-8 font-sans">
<div className="max-w-5xl mx-auto space-y-10">
{/* Title */}
<div className="text-center space-y-3">
<span className="text-[10px] font-mono tracking-widest text-shutter uppercase bg-paper px-3 py-1.5 rounded-full border border-pine/5">
küratörlü rehberler
</span>
<h1 className="text-4xl font-heading font-extrabold text-pine lowercase">
marmaris local <span className="text-turquoise">blog</span>
</h1>
<p className="max-w-xl mx-auto text-ink/70 text-xs sm:text-sm font-medium">
Turistlerin gözünden kaçan yerel detaylar, en iyi lezzet durakları ve gizli gezi noktaları.
</p>
</div>
{/* Blog Posts Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{posts.length === 0 ? (
<div className="col-span-2 bg-paper border border-pine/8 p-12 text-center text-shutter text-sm rounded-3xl">
Henüz yayınlanmış bir yazı bulunmuyor.
</div>
) : (
posts.map((post) => {
const title = locale === 'en' ? post.titleEn : locale === 'ru' ? post.titleRu : post.titleTr
const content = locale === 'en' ? post.contentEn : locale === 'ru' ? post.contentRu : post.contentTr
const snippet = content.substring(0, 140) + '...'
return (
<article
key={post.id}
className="bg-paper border border-pine/8 rounded-3xl overflow-hidden shadow-sm flex flex-col justify-between hover:border-turquoise/35 transition-all duration-300 hover:shadow-md"
>
<div>
{/* Cover image */}
{post.coverImage && (
<div className="h-56 relative overflow-hidden bg-stone border-b border-pine/5">
<img
src={post.coverImage}
alt={title}
className="w-full h-full object-cover hover:scale-105 transition duration-500"
/>
</div>
)}
{/* Post content preview */}
<div className="p-6 sm:p-8 space-y-4">
{/* Meta info */}
<div className="flex items-center gap-4 text-[10px] text-shutter font-mono">
<span className="flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5" />
{post.publishedAt ? new Date(post.publishedAt).toLocaleDateString(locale === 'tr' ? 'tr-TR' : locale === 'ru' ? 'ru-RU' : 'en-US') : ''}
</span>
{post.tags.length > 0 && (
<span className="flex items-center gap-1.5">
<Tag className="w-3.5 h-3.5" />
{post.tags[0]}
</span>
)}
</div>
<h2 className="font-heading font-extrabold text-lg text-pine hover:text-turquoise transition duration-150 lowercase leading-snug">
<Link href={`/blog/${post.slug}`}>
{title}
</Link>
</h2>
<p className="text-ink/75 text-xs sm:text-sm leading-relaxed">
{snippet}
</p>
</div>
</div>
<div className="px-6 sm:px-8 pb-6 sm:pb-8 pt-2">
<Link
href={`/blog/${post.slug}`}
className="inline-flex items-center text-xs font-mono font-bold text-turquoise hover:underline uppercase tracking-wider"
>
okumaya devam et
</Link>
</div>
</article>
)
})
)}
</div>
</div>
</div>
)
}