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
+98 -97
View File
@@ -4,11 +4,14 @@ import ListingCard from '@/components/ListingCard'
import { notFound } from 'next/navigation'
import Image from 'next/image'
import { Link } from '@/i18n/routing'
import { Phone, Globe, MapPin, Clock, Star, MessageSquare, Share2 } from 'lucide-react'
import { Phone, Globe, MapPin, Clock, Star, MessageSquare, Share2, ExternalLink, Navigation } from 'lucide-react'
import SaveButton from './SaveButton'
import DetailTracker from './DetailTracker'
import ListingGallery from './ListingGallery'
import StickyActionBar from './StickyActionBar'
import OpenStatusBadge from './OpenStatusBadge'
import type { Metadata } from 'next'
import { SITE_URL } from '@/lib/seo'
import { SITE_URL, buildAlternates } from '@/lib/seo'
interface DetailPageProps {
params: Promise<{ locale: string; category: string; slug: string }>
@@ -25,14 +28,17 @@ export async function generateMetadata({ params }: DetailPageProps): Promise<Met
const description = locale === 'ru' ? listing.descriptionRu : locale === 'en' ? listing.descriptionEn : listing.descriptionTr
const title = `${name} — Marmaris Local`
const image = listing.images?.[0]?.url
const categorySlug = listing.category?.slug || 'isletme'
const pathname = `/${locale}/${categorySlug}/${listing.slug}`
return {
title,
description,
alternates: buildAlternates(pathname),
openGraph: {
title,
description,
url: `${SITE_URL}/${locale}/${listing.category?.slug || 'isletme'}/${listing.slug}`,
url: `${SITE_URL}${pathname}`,
siteName: 'Marmaris Local',
type: 'website',
...(image ? { images: [{ url: image }] } : {}),
@@ -41,6 +47,7 @@ export async function generateMetadata({ params }: DetailPageProps): Promise<Met
card: 'summary_large_image',
title,
description,
...(image ? { images: [image] } : {}),
},
}
}
@@ -91,9 +98,10 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
const priceSymbols = '₺'.repeat(listing.priceRange)
const categorySlug = listing.category?.slug || 'isletme'
const rawId = listing.id; // örn: "gm-ChIJY_2Q8tbJvxQRs7xwaSne0is"
const hasGooglePlaceId = rawId.startsWith('gm-');
const placeId = hasGooglePlaceId ? rawId.replace('gm-', '') : null;
const rawId = listing.id
const hasGooglePlaceId = rawId.startsWith('gm-')
const placeId = hasGooglePlaceId ? rawId.replace('gm-', '') : null
// Format WhatsApp Link
const getWhatsAppLink = (number: string) => {
const cleanNum = number.replace(/\D/g, '')
@@ -106,6 +114,11 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
return `https://wa.me/?text=${text}`
}
// Direct Directions URL
const directionsUrl = listing.latitude && listing.longitude
? `https://www.google.com/maps/dir/?api=1&destination=${listing.latitude},${listing.longitude}`
: `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(listing.address + ', Marmaris')}`
// schema.org LocalBusiness structured data
const schemaTypeByCategory: Record<string, string> = {
restoran: 'Restaurant',
@@ -137,8 +150,24 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
: {}),
}
const categoryName = locale === 'ru' ? listing.category?.nameRu : locale === 'en' ? listing.category?.nameEn : listing.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: categoryName || categorySlug, item: `${SITE_URL}/${locale}/${categorySlug}` },
{ '@type': 'ListItem', position: 3, name, item: `${SITE_URL}/${locale}/${categorySlug}/${listing.slug}` },
],
}
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 pb-20 lg:pb-12">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
@@ -148,47 +177,23 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 flex-1 space-y-10">
{/* Breadcrumb */}
<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>
<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>
<span className="hover:text-turquoise transition-colors">
{locale === 'ru' ? listing.category?.nameRu : locale === 'en' ? listing.category?.nameEn : listing.category?.nameTr}
</span>
<Link href={`/${categorySlug}`} className="hover:text-turquoise transition-colors">
{categoryName}
</Link>
<span>/</span>
<span className="text-pine font-bold">{name}</span>
</div>
</nav>
{/* Hero Details Block */}
<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">
{/* Left: Gallery Panel */}
<div className="flex-1 space-y-4">
<div className="aspect-[16/10] w-full relative rounded-2xl overflow-hidden bg-stone-deep shadow-sm">
<Image
src={listing.images && listing.images.length > 0 ? listing.images[0].url : 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&auto=format&fit=crop&q=80'}
alt={name}
fill
priority
className="object-cover"
/>
</div>
{/* Thumbnails if multiple images exist */}
{listing.images && listing.images.length > 1 && (
<div className="grid grid-cols-4 gap-4">
{listing.images.slice(1, 5).map((img, idx) => (
<div key={img.id} className="aspect-square relative rounded-xl overflow-hidden bg-stone-deep border border-pine/5 shadow-sm">
<Image
src={img.url}
alt={`${name} thumbnail ${idx + 1}`}
fill
className="object-cover hover:scale-105 transition duration-300"
/>
</div>
))}
</div>
)}
{/* Left: Gallery Panel with Interactive Lightbox */}
<div className="flex-1">
<ListingGallery images={listing.images} title={name} />
</div>
{/* Right: Info Panel */}
@@ -196,7 +201,7 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
<div className="space-y-4">
<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">
{locale === 'ru' ? listing.category?.nameRu : locale === 'en' ? listing.category?.nameEn : listing.category?.nameTr}
{categoryName}
</span>
{listing.isLocalApproved && (
@@ -204,6 +209,8 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
{t('approved')}
</span>
)}
<OpenStatusBadge openingHours={listing.openingHours} locale={locale} />
</div>
<h1 className="font-heading font-extrabold text-2xl sm:text-4xl text-pine leading-tight lowercase">
@@ -229,49 +236,16 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
{description ? description : <span className="italic text-ink/50">{t('no_description', { defaultValue: 'Bu işletme için henüz bir açıklama eklenmemiştir.' })}</span>}
</div>
{/* Contact Info (Only if at least one exists) */}
{(listing.phone || listing.website || listing.instagram) && (
<div className="space-y-4 pt-6 border-t border-pine/10">
<h3 className="font-heading font-bold text-xs text-pine uppercase tracking-wider">{t('contact')}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{listing.phone && (
<a id="listing-contact-phone" href={`tel:${listing.phone}`} className="flex items-center gap-3 p-3 rounded-xl border border-pine/10 hover:border-turquoise/30 hover:bg-turquoise/5 transition-colors group">
<div className="w-8 h-8 rounded-full bg-pine/5 flex items-center justify-center group-hover:bg-turquoise/10 transition-colors">
<Phone className="w-4 h-4 text-pine group-hover:text-turquoise transition-colors" />
</div>
<div className="flex flex-col">
<span className="text-[10px] font-bold text-pine/50 uppercase tracking-wider">{t('phone')}</span>
<span className="text-sm font-medium text-ink group-hover:text-pine transition-colors">{listing.phone}</span>
</div>
</a>
)}
{listing.website && (
<a id="listing-contact-website" href={listing.website.startsWith('http') ? listing.website : `https://${listing.website}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-3 p-3 rounded-xl border border-pine/10 hover:border-turquoise/30 hover:bg-turquoise/5 transition-colors group">
<div className="w-8 h-8 rounded-full bg-pine/5 flex items-center justify-center group-hover:bg-turquoise/10 transition-colors">
<Globe className="w-4 h-4 text-pine group-hover:text-turquoise transition-colors" />
</div>
<div className="flex flex-col">
<span className="text-[10px] font-bold text-pine/50 uppercase tracking-wider">{t('website')}</span>
<span className="text-sm font-medium text-ink group-hover:text-pine transition-colors truncate max-w-[150px]">
{listing.website.replace(/^https?:\/\//, '').replace(/\/$/, '')}
</span>
</div>
</a>
)}
</div>
</div>
)}
{/* Contact & Actions Grid */}
<div className="space-y-4 pt-2">
<h3 className="font-heading font-bold text-xs uppercase tracking-wider text-shutter">{t('contact')}</h3>
<div className="space-y-4 pt-4 border-t border-pine/10">
<h3 className="font-heading font-bold text-xs uppercase tracking-wider text-pine/80">{t('contact')}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{listing.phone && (
<a
id="listing-contact-phone"
href={`tel:${listing.phone}`}
className="flex items-center justify-center gap-2 bg-pine hover:bg-pine/90 text-stone font-bold text-xs py-3.5 px-4 rounded-xl transition"
className="flex items-center justify-center gap-2 bg-pine hover:bg-pine/90 text-stone font-bold text-xs py-3.5 px-4 rounded-xl transition min-h-[44px]"
>
<Phone className="w-4 h-4" />
{t('call')}
@@ -284,7 +258,7 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
href={getWhatsAppLink(listing.whatsapp)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 bg-turquoise hover:bg-turquoise/90 text-paper font-bold text-xs py-3.5 px-4 rounded-xl transition"
className="flex items-center justify-center gap-2 bg-turquoise hover:bg-turquoise/90 text-paper font-bold text-xs py-3.5 px-4 rounded-xl transition min-h-[44px]"
>
<MessageSquare className="w-4 h-4" />
{t('whatsapp')}
@@ -298,14 +272,14 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
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"
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 min-h-[44px]"
>
<Globe className="w-4 h-4 text-turquoise" />
{t('viewMenu')}
</a>
)}
{/* Save Button (Favorites client action) */}
{/* Save Button */}
<SaveButton
listingId={listing.id}
saveLabel={t('save')}
@@ -313,10 +287,10 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
/>
</div>
{/* External links */}
<div className="flex flex-wrap gap-5 pt-2 text-xs font-semibold text-shutter">
{/* External links & Share */}
<div className="flex flex-wrap gap-5 pt-2 text-xs font-semibold text-pine/80">
{listing.website && (
<a href={listing.website} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1.5 hover:text-turquoise transition">
<a href={listing.website.startsWith('http') ? listing.website : `https://${listing.website}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1.5 hover:text-turquoise transition">
<Globe className="w-4 h-4 text-turquoise" />
{t('website')}
</a>
@@ -348,7 +322,7 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
<MapPin className="w-4 h-4 text-turquoise" />
<span>{t('address')}</span>
</div>
<p className="text-xs text-ink/75 font-medium leading-relaxed">
<p className="text-xs text-ink/80 font-medium leading-relaxed">
{listing.address}
</p>
</div>
@@ -359,7 +333,7 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
<Clock className="w-4 h-4 text-turquoise" />
<span>{t('hours')}</span>
</div>
<div className="text-xs text-ink/75 font-medium">
<div className="text-xs text-ink/80 font-medium font-mono">
{listing.openingHours ? (
<p>{(listing.openingHours as any).all || t('noHours')}</p>
) : (
@@ -368,12 +342,23 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
</div>
</div>
{/* Map Frame */}
{/* Map Frame with Directions Trigger */}
{((listing.latitude && listing.longitude) || hasGooglePlaceId) && (
<div className="space-y-2">
<div className="flex items-center gap-2 font-heading font-bold text-xs text-pine uppercase tracking-wider">
<Globe className="w-4 h-4 text-turquoise" />
<span>{t('location')}</span>
<div className="flex items-center justify-between font-heading font-bold text-xs text-pine uppercase tracking-wider">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-turquoise" />
<span>{t('location')}</span>
</div>
<a
href={directionsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] font-mono text-turquoise hover:underline flex items-center gap-1 font-bold lowercase"
>
<Navigation className="w-3 h-3" />
<span>Haritalarda </span>
</a>
</div>
<div className="rounded-xl overflow-hidden border border-pine/8 aspect-[16/10] sm:aspect-auto sm:h-36 relative group">
<iframe
@@ -407,7 +392,7 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
</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>
<p className="text-[10px] font-mono text-stone/80 uppercase tracking-wider mt-0.5">{t('instagramFeed')}</p>
</div>
</div>
@@ -420,10 +405,12 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
rel="noopener noreferrer"
className="group relative aspect-square rounded-2xl overflow-hidden bg-stone border border-pine/5 shadow-sm block"
>
<img
<Image
src={post.imageUrl}
alt={post.caption || 'Instagram post'}
className="w-full h-full object-cover group-hover:scale-103 transition duration-500"
fill
sizes="(max-width: 768px) 100vw, 33vw"
className="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">
@@ -436,14 +423,14 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
</div>
)}
{/* Newly Added Widget (Phase 2) */}
{/* Newly Added Widget */}
{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">
<p className="text-ink/75 text-xs font-medium mt-0.5">
{t('newlyAddedSubtitle')}
</p>
</div>
@@ -459,12 +446,12 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
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 className="w-16 h-16 rounded-xl overflow-hidden bg-stone shrink-0 border border-pine/8 relative">
<Image src={newestImg} alt={newestName} fill sizes="64px" className="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>
<p className="text-[10px] text-stone/80 font-mono mt-0.5">{newest.neighborhood?.nameTr}</p>
</div>
</Link>
)
@@ -488,6 +475,20 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
)}
</main>
{/* Pinned Mobile Action Bar */}
<StickyActionBar
phone={listing.phone}
whatsapp={listing.whatsapp}
latitude={listing.latitude}
longitude={listing.longitude}
address={listing.address}
labels={{
call: t('call'),
whatsapp: t('whatsapp'),
directions: t('address'),
}}
/>
</div>
)
}