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:
AyrisAI
2026-08-24 00:01:06 +03:00
co-authored by Claude Sonnet 5
parent 390bd699a6
commit 1b8cfeda95
62 changed files with 2216 additions and 411 deletions
@@ -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>
)
}
+1 -1
View File
@@ -31,7 +31,7 @@ export async function generateMetadata({ params }: NeighborhoodPageProps): Promi
? `Лучшие рестораны, апарт-отели и заведения в районе ${name}, Мармарис.`
: `${name}, Marmaris'teki en iyi restoranlar, apart oteller ve yerel işletmeler.`
return basicMetadata(title, description)
return basicMetadata(title, description, locale, `/neighborhood/${slug}`)
}
export default async function NeighborhoodPage({ params }: NeighborhoodPageProps) {