Files
marmarislocal/app/[locale]/[category]/[slug]/page.tsx
T
AyrisAIandClaude Sonnet 5 1b8cfeda95 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>
2026-08-24 00:01:06 +03:00

495 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { mockDb } from '@/lib/mockDb'
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, 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, buildAlternates } from '@/lib/seo'
interface DetailPageProps {
params: Promise<{ locale: string; category: string; slug: string }>
}
export const dynamic = 'force-dynamic'
export async function generateMetadata({ params }: DetailPageProps): Promise<Metadata> {
const { locale, slug } = await params
const listing = await mockDb.getListingBySlug(slug)
if (!listing) return {}
const name = locale === 'ru' ? listing.nameRu : locale === 'en' ? listing.nameEn : listing.nameTr
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}${pathname}`,
siteName: 'Marmaris Local',
type: 'website',
...(image ? { images: [{ url: image }] } : {}),
},
twitter: {
card: 'summary_large_image',
title,
description,
...(image ? { images: [image] } : {}),
},
}
}
export default async function ListingDetailPage({ params }: DetailPageProps) {
const { locale, category, slug } = await params
setRequestLocale(locale)
const t = await getTranslations('detail')
const listing = await mockDb.getListingBySlug(slug)
if (!listing) {
notFound()
}
// Fetch similar listings in same category (limit to 3, excluding current)
const allCategoryListings = await mockDb.getListings({
categoryId: listing.categoryId
})
const relatedListings = allCategoryListings
.filter(l => l.id !== listing.id)
.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
const name =
locale === 'ru'
? listing.nameRu
: locale === 'en'
? listing.nameEn
: listing.nameTr
const description =
locale === 'ru'
? listing.descriptionRu
: locale === 'en'
? listing.descriptionEn
: listing.descriptionTr
const priceSymbols = '₺'.repeat(listing.priceRange)
const categorySlug = listing.category?.slug || 'isletme'
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, '')
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}`
}
// 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',
apart: 'LodgingBusiness',
isletme: 'LocalBusiness',
}
const jsonLd = {
'@context': 'https://schema.org',
'@type': schemaTypeByCategory[categorySlug] || 'LocalBusiness',
name,
description,
image: listing.images?.map(img => img.url) || undefined,
url: `https://marmarislocal.com/${locale}/${categorySlug}/${listing.slug}`,
address: {
'@type': 'PostalAddress',
streetAddress: listing.address,
addressLocality: 'Marmaris',
addressCountry: 'TR',
},
...(listing.latitude && listing.longitude
? { geo: { '@type': 'GeoCoordinates', latitude: listing.latitude, longitude: listing.longitude } }
: {}),
...(listing.phone ? { telephone: listing.phone } : {}),
...(listing.website ? { sameAs: [listing.website] } : {}),
priceRange: priceSymbols || undefined,
...(listing.openingHours && (listing.openingHours as any).all
? { openingHours: (listing.openingHours as any).all }
: {}),
}
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 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) }}
/>
<DetailTracker listingId={listing.id} />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 flex-1 space-y-10">
{/* Breadcrumb */}
<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={`/${categorySlug}`} className="hover:text-turquoise transition-colors">
{categoryName}
</Link>
<span>/</span>
<span className="text-pine font-bold">{name}</span>
</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 with Interactive Lightbox */}
<div className="flex-1">
<ListingGallery images={listing.images} title={name} />
</div>
{/* Right: Info Panel */}
<div className="flex-1 flex flex-col justify-between space-y-6">
<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">
{categoryName}
</span>
{listing.isLocalApproved && (
<span className="inline-flex items-center gap-1 text-[10px] font-mono text-turquoise font-bold uppercase tracking-wider bg-turquoise/5 border border-turquoise/10 px-2.5 py-1 rounded-full">
{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">
{name}
</h1>
{/* Stars and Price level */}
<div className="flex items-center gap-6 font-mono text-sm border-b border-dashed border-pine/8 pb-4">
{listing.rating && (
<div className="flex items-center gap-1.5 text-gold font-bold">
<Star className="w-4 h-4 fill-gold stroke-gold" />
<span>{t('rating')}: {listing.rating.toFixed(1)}</span>
</div>
)}
<div className="text-pine font-semibold">
<span>{t('price')}: {priceSymbols}</span>
</div>
</div>
</div>
{/* Description */}
<div className="text-ink/80 text-sm sm:text-base leading-relaxed font-medium">
{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 & Actions Grid */}
<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 min-h-[44px]"
>
<Phone className="w-4 h-4" />
{t('call')}
</a>
)}
{listing.whatsapp && (
<a
id="listing-contact-whatsapp"
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 min-h-[44px]"
>
<MessageSquare className="w-4 h-4" />
{t('whatsapp')}
</a>
)}
{/* Menu Link */}
{listing.menuUrl && (
<a
id="listing-contact-menu"
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 min-h-[44px]"
>
<Globe className="w-4 h-4 text-turquoise" />
{t('viewMenu')}
</a>
)}
{/* Save Button */}
<SaveButton
listingId={listing.id}
saveLabel={t('save')}
savedLabel={t('saved')}
/>
</div>
{/* 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.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>
)}
{listing.instagram && (
<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 text-turquoise" />
{t('instagram')}
</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>
{/* Details footer (Hours & Location map) */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 pt-8 border-t border-dashed border-pine/12">
{/* Address */}
<div className="space-y-2">
<div className="flex items-center gap-2 font-heading font-bold text-xs text-pine uppercase tracking-wider">
<MapPin className="w-4 h-4 text-turquoise" />
<span>{t('address')}</span>
</div>
<p className="text-xs text-ink/80 font-medium leading-relaxed">
{listing.address}
</p>
</div>
{/* Hours */}
<div className="space-y-2">
<div className="flex items-center gap-2 font-heading font-bold text-xs text-pine uppercase tracking-wider">
<Clock className="w-4 h-4 text-turquoise" />
<span>{t('hours')}</span>
</div>
<div className="text-xs text-ink/80 font-medium font-mono">
{listing.openingHours ? (
<p>{(listing.openingHours as any).all || t('noHours')}</p>
) : (
<p>{t('noHours')}</p>
)}
</div>
</div>
{/* Map Frame with Directions Trigger */}
{((listing.latitude && listing.longitude) || hasGooglePlaceId) && (
<div className="space-y-2">
<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
width="100%"
height="100%"
frameBorder="0"
scrolling="no"
marginHeight={0}
marginWidth={0}
src={
hasGooglePlaceId
? `https://maps.google.com/maps?q=place_id:${placeId}&z=16&output=embed`
: `https://maps.google.com/maps?q=${listing.latitude},${listing.longitude}+(${encodeURIComponent(name)})&t=&z=16&ie=UTF8&output=embed`
}
className="w-full h-full shadow-sm"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
/>
</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-stone/80 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"
>
<Image
src={post.imageUrl}
alt={post.caption || 'Instagram post'}
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">
{post.caption}
</p>
</div>
</a>
))}
</div>
</div>
)}
{/* 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/75 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 || 'businesses'
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 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-stone/80 font-mono mt-0.5">{newest.neighborhood?.nameTr}</p>
</div>
</Link>
)
})}
</div>
</div>
)}
{/* Related Section */}
{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">
{t('related')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{relatedListings.map((listingItem) => (
<ListingCard key={listingItem.id} listing={listingItem} />
))}
</div>
</div>
)}
</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>
)
}