feat: harden admin security, add AI trip planner, map view, and SEO/notification improvements
Security: - requireAdmin() session check added to every admin-only server action (previously relied only on middleware path matching, which Next.js Server Actions don't reliably respect) - Real Prisma + bcrypt admin auth, replacing hardcoded credentials; split into an Edge-safe auth.config.ts (used by proxy.ts) and the full Prisma-backed auth.ts (route handler, server actions, server components) - Removed hardcoded fallback secret on the Instagram sync cron endpoint - Honeypot field + per-IP rate limiting on contact/business-submission forms and the analytics events endpoint Features: - AI trip planner (/plan-olustur, /plan/[id]) backed by DeepSeek, grounded to only recommend isLocalApproved listings, with a deterministic link-injection fallback for anything the model doesn't format as markdown - Interactive Leaflet/OpenStreetMap view on category listing pages - Telegram notifications for new contact messages and business submissions SEO: - Brand-consistent favicon/apple-icon/PWA icons and default Open Graph/ Twitter share images, generated via next/og (replacing default Next.js placeholders) - BreadcrumbList structured data on category and listing detail pages - Fixed two remaining raw <img> tags to use next/image Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
390bd699a6
commit
1b8cfeda95
@@ -0,0 +1,232 @@
|
||||
import { setRequestLocale } from 'next-intl/server'
|
||||
import { mockDb } from '@/lib/mockDb'
|
||||
import ListingCard from '@/components/ListingCard'
|
||||
import ListingsMapLoader from '@/components/ListingsMapLoader'
|
||||
import { Link } from '@/i18n/routing'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { MapPin, Compass, ArrowRight } from 'lucide-react'
|
||||
import type { Metadata } from 'next'
|
||||
import { basicMetadata, SITE_URL } from '@/lib/seo'
|
||||
|
||||
interface ProgrammaticPageProps {
|
||||
params: Promise<{ locale: string; slug: string; categorySlug: string }>
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: ProgrammaticPageProps): Promise<Metadata> {
|
||||
const { locale, slug: neighborhoodSlug, categorySlug } = await params
|
||||
const neighborhood = await mockDb.getNeighborhoodBySlug(neighborhoodSlug)
|
||||
const categories = await mockDb.getCategories()
|
||||
const category = categories.find((c) => c.slug === categorySlug)
|
||||
|
||||
if (!neighborhood || !category) return {}
|
||||
|
||||
const neighborhoodName = locale === 'ru' ? neighborhood.nameRu : locale === 'en' ? neighborhood.nameEn : neighborhood.nameTr
|
||||
const categoryName = locale === 'ru' ? category.nameRu : locale === 'en' ? category.nameEn : category.nameTr
|
||||
|
||||
const listings = await mockDb.getListings({
|
||||
neighborhoodId: neighborhood.id,
|
||||
categoryId: category.id,
|
||||
})
|
||||
|
||||
const title =
|
||||
locale === 'en'
|
||||
? `Best ${categoryName} in ${neighborhoodName}, Marmaris — Local Guide`
|
||||
: locale === 'ru'
|
||||
? `${categoryName} в районе ${neighborhoodName}, Мармарис — Местный гид`
|
||||
: `Marmaris ${neighborhoodName} ${categoryName} Rehberi — Yerel Onaylı`
|
||||
|
||||
const description =
|
||||
locale === 'en'
|
||||
? `Browse curated ${categoryName.toLowerCase()} in ${neighborhoodName}, Marmaris. Verified local spots with contact info, map locations and prices.`
|
||||
: locale === 'ru'
|
||||
? `Лучшие заведения категории «${categoryName}» в районе ${neighborhoodName}, Мармарис. Проверено местными жителями.`
|
||||
: `${neighborhoodName}, Marmaris'teki en iyi ${categoryName.toLowerCase()} listesi — yerel onaylı mekanlar, harita ve iletişim bilgileri.`
|
||||
|
||||
const pathSuffix = `/neighborhood/${neighborhoodSlug}/${categorySlug}`
|
||||
const base = basicMetadata(title, description, locale, pathSuffix)
|
||||
|
||||
// Quality Control: Prevent thin content indexing if fewer than 2 listings exist
|
||||
const isIndexable = listings.length >= 2
|
||||
|
||||
return {
|
||||
...base,
|
||||
robots: {
|
||||
index: isIndexable,
|
||||
follow: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ProgrammaticCategoryNeighborhoodPage({ params }: ProgrammaticPageProps) {
|
||||
const { locale, slug: neighborhoodSlug, categorySlug } = await params
|
||||
setRequestLocale(locale)
|
||||
|
||||
const neighborhood = await mockDb.getNeighborhoodBySlug(neighborhoodSlug)
|
||||
const categories = await mockDb.getCategories()
|
||||
const category = categories.find((c) => c.slug === categorySlug)
|
||||
|
||||
if (!neighborhood || !category) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const listings = await mockDb.getListings({
|
||||
neighborhoodId: neighborhood.id,
|
||||
categoryId: category.id,
|
||||
})
|
||||
|
||||
const allNeighborhoods = await mockDb.getNeighborhoods()
|
||||
|
||||
const neighborhoodName = locale === 'ru' ? neighborhood.nameRu : locale === 'en' ? neighborhood.nameEn : neighborhood.nameTr
|
||||
const categoryName = locale === 'ru' ? category.nameRu : locale === 'en' ? category.nameEn : category.nameTr
|
||||
const homeLabel = locale === 'en' ? 'Home' : locale === 'ru' ? 'Главная' : 'Ana Sayfa'
|
||||
|
||||
const breadcrumbLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: homeLabel, item: `${SITE_URL}/${locale}` },
|
||||
{ '@type': 'ListItem', position: 2, name: neighborhoodName, item: `${SITE_URL}/${locale}/neighborhood/${neighborhoodSlug}` },
|
||||
{ '@type': 'ListItem', position: 3, name: `${neighborhoodName} ${categoryName}`, item: `${SITE_URL}/${locale}/neighborhood/${neighborhoodSlug}/${categorySlug}` },
|
||||
],
|
||||
}
|
||||
|
||||
const itemListLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: `${neighborhoodName} ${categoryName}`,
|
||||
numberOfItems: listings.length,
|
||||
itemListElement: listings.map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: `${SITE_URL}/${locale}/${categorySlug}/${item.slug}`,
|
||||
name: locale === 'ru' ? item.nameRu : locale === 'en' ? item.nameEn : item.nameTr,
|
||||
})),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbLd) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(itemListLd) }}
|
||||
/>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 flex-1 space-y-10">
|
||||
|
||||
{/* Breadcrumbs */}
|
||||
<nav aria-label="Breadcrumb" className="text-xs font-mono uppercase tracking-wider text-stone/80 flex items-center gap-2 flex-wrap">
|
||||
<Link href="/" className="hover:text-turquoise transition-colors">marmaris local</Link>
|
||||
<span>/</span>
|
||||
<Link href={`/neighborhood/${neighborhoodSlug}`} className="hover:text-turquoise transition-colors">
|
||||
{neighborhoodName}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-pine font-bold">{categoryName}</span>
|
||||
</nav>
|
||||
|
||||
{/* Header Hero */}
|
||||
<div className="bg-paper p-8 rounded-3xl border border-pine/8 shadow-sm flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="space-y-2">
|
||||
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-turquoise/10 border border-turquoise/20 text-turquoise text-[10px] font-mono font-bold uppercase tracking-wider">
|
||||
<MapPin className="w-3.5 h-3.5" />
|
||||
<span>{neighborhoodName} • {categoryName}</span>
|
||||
</div>
|
||||
<h1 className="text-3xl sm:text-4xl font-heading font-extrabold text-pine lowercase">
|
||||
{neighborhoodName} {categoryName}
|
||||
</h1>
|
||||
<p className="text-xs text-stone/80 font-mono uppercase tracking-wider">
|
||||
{listings.length} {locale === 'tr' ? 'onaylı mekan listeleniyor' : locale === 'en' ? 'verified places listed' : 'подтвержденных мест'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<Link
|
||||
href={`/${categorySlug}`}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-mono font-semibold text-pine hover:text-turquoise border border-pine/10 hover:border-turquoise/30 px-4 py-2.5 rounded-full transition bg-stone/20"
|
||||
>
|
||||
<span>{locale === 'tr' ? 'Tüm Marmaris' : locale === 'en' ? 'All Marmaris' : 'Весь Мармарис'} {categoryName}</span>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Listings Grid */}
|
||||
{listings.length === 0 ? (
|
||||
<div className="bg-paper/60 rounded-3xl border border-dashed border-pine/12 p-16 text-center text-stone/80 space-y-3">
|
||||
<p className="text-sm font-medium">
|
||||
{locale === 'tr'
|
||||
? `${neighborhoodName} mahallesinde henüz ${categoryName.toLowerCase()} kategorisinde mekan bulunmamaktadır.`
|
||||
: locale === 'en'
|
||||
? `No places found under ${categoryName} in ${neighborhoodName} yet.`
|
||||
: `В районе ${neighborhoodName} пока нет заведений в категории «${categoryName}».`}
|
||||
</p>
|
||||
<Link
|
||||
href={`/neighborhood/${neighborhoodSlug}`}
|
||||
className="inline-block text-xs font-mono font-bold text-turquoise hover:underline"
|
||||
>
|
||||
{locale === 'tr' ? 'Tüm ' + neighborhoodName + ' mekanlarını gör' : locale === 'en' ? 'View all places in ' + neighborhoodName : 'Посмотреть все места в ' + neighborhoodName} →
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{listings.map((listing) => (
|
||||
<ListingCard key={listing.id} listing={listing} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other Categories in same Neighborhood */}
|
||||
<div className="bg-paper p-6 sm:p-8 rounded-3xl border border-pine/8 shadow-sm space-y-4">
|
||||
<h3 className="font-heading font-extrabold text-lg text-pine lowercase flex items-center gap-2">
|
||||
<Compass className="w-5 h-5 text-turquoise" />
|
||||
<span>{neighborhoodName} {locale === 'tr' ? 'bölgesindeki diğer kategoriler' : locale === 'en' ? 'other categories in' : 'другие категории в'} {neighborhoodName}</span>
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
{categories
|
||||
.filter((c) => c.slug !== categorySlug)
|
||||
.map((cat) => {
|
||||
const name = locale === 'ru' ? cat.nameRu : locale === 'en' ? cat.nameEn : cat.nameTr
|
||||
return (
|
||||
<Link
|
||||
key={cat.id}
|
||||
href={`/neighborhood/${neighborhoodSlug}/${cat.slug}`}
|
||||
className="text-xs font-mono font-medium px-4 py-2 rounded-full border border-pine/10 hover:border-turquoise hover:text-turquoise bg-stone/20 transition"
|
||||
>
|
||||
{neighborhoodName} {name}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Same Category in other Neighborhoods */}
|
||||
<div className="bg-paper p-6 sm:p-8 rounded-3xl border border-pine/8 shadow-sm space-y-4">
|
||||
<h3 className="font-heading font-extrabold text-lg text-pine lowercase flex items-center gap-2">
|
||||
<MapPin className="w-5 h-5 text-turquoise" />
|
||||
<span>{categoryName} — {locale === 'tr' ? 'Diğer Mahalleler' : locale === 'en' ? 'Other Neighborhoods' : 'Другие районы'}</span>
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
{allNeighborhoods
|
||||
.filter((n) => n.slug !== neighborhoodSlug)
|
||||
.map((neigh) => {
|
||||
const name = locale === 'ru' ? neigh.nameRu : locale === 'en' ? neigh.nameEn : neigh.nameTr
|
||||
return (
|
||||
<Link
|
||||
key={neigh.id}
|
||||
href={`/neighborhood/${neigh.slug}/${categorySlug}`}
|
||||
className="text-xs font-mono font-medium px-4 py-2 rounded-full border border-pine/10 hover:border-turquoise hover:text-turquoise bg-stone/20 transition"
|
||||
>
|
||||
{name} {categoryName}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user