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,146 @@
'use client'
import { useState } from 'react'
import Image from 'next/image'
import { X, ChevronLeft, ChevronRight, Maximize2 } from 'lucide-react'
export interface GalleryImage {
id: string
url: string
}
interface ListingGalleryProps {
images: GalleryImage[]
title: string
}
export default function ListingGallery({ images, title }: ListingGalleryProps) {
const [selectedIndex, setSelectedIndex] = useState(0)
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
const fallbackImage = 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&auto=format&fit=crop&q=80'
const galleryList = images && images.length > 0 ? images : [{ id: 'fallback', url: fallbackImage }]
const currentImage = galleryList[selectedIndex] || galleryList[0]
const handlePrev = (e?: React.MouseEvent) => {
e?.stopPropagation()
setSelectedIndex((prev) => (prev === 0 ? galleryList.length - 1 : prev - 1))
}
const handleNext = (e?: React.MouseEvent) => {
e?.stopPropagation()
setSelectedIndex((prev) => (prev === galleryList.length - 1 ? 0 : prev + 1))
}
return (
<div className="space-y-4">
{/* Main Feature Image Preview */}
<div
onClick={() => setIsLightboxOpen(true)}
className="aspect-[16/10] w-full relative rounded-2xl overflow-hidden bg-stone-deep shadow-sm group cursor-pointer border border-pine/8"
>
<Image
src={currentImage.url}
alt={`${title} - Photo ${selectedIndex + 1}`}
fill
priority
sizes="(max-width: 1024px) 100vw, 60vw"
className="object-cover transition-transform duration-500 group-hover:scale-103"
/>
<div className="absolute inset-0 bg-pine/20 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
<span className="bg-paper/90 text-pine font-mono text-xs font-bold px-3.5 py-2 rounded-full shadow-md flex items-center gap-1.5 backdrop-blur-sm">
<Maximize2 className="w-4 h-4 text-turquoise" />
<span>Büyüt ({selectedIndex + 1}/{galleryList.length})</span>
</span>
</div>
</div>
{/* Thumbnail Bar */}
{galleryList.length > 1 && (
<div className="grid grid-cols-4 sm:grid-cols-6 gap-3">
{galleryList.map((img, idx) => {
const isSelected = idx === selectedIndex
return (
<button
key={img.id || idx}
type="button"
onClick={() => setSelectedIndex(idx)}
className={`aspect-square relative rounded-xl overflow-hidden bg-stone-deep border transition duration-200 cursor-pointer min-h-[44px] ${
isSelected ? 'border-turquoise ring-2 ring-turquoise/40 scale-102 shadow-sm' : 'border-pine/10 hover:border-turquoise/50 opacity-80 hover:opacity-100'
}`}
aria-label={`Show image ${idx + 1}`}
>
<Image
src={img.url}
alt={`${title} thumbnail ${idx + 1}`}
fill
sizes="120px"
className="object-cover"
/>
</button>
)
})}
</div>
)}
{/* Fullscreen Lightbox Modal */}
{isLightboxOpen && (
<div
className="fixed inset-0 z-50 bg-pine/95 backdrop-blur-md flex items-center justify-center p-4 sm:p-8"
onClick={() => setIsLightboxOpen(false)}
>
{/* Close Button */}
<button
type="button"
onClick={() => setIsLightboxOpen(false)}
className="absolute top-4 right-4 z-50 w-11 h-11 rounded-full bg-paper/10 hover:bg-paper/20 text-stone flex items-center justify-center transition cursor-pointer"
aria-label="Close photo lightbox"
>
<X className="w-6 h-6" />
</button>
{/* Navigation Controls */}
{galleryList.length > 1 && (
<>
<button
type="button"
onClick={handlePrev}
className="absolute left-4 top-1/2 -translate-y-1/2 z-50 w-11 h-11 rounded-full bg-paper/10 hover:bg-paper/20 text-stone flex items-center justify-center transition cursor-pointer"
aria-label="Previous photo"
>
<ChevronLeft className="w-6 h-6" />
</button>
<button
type="button"
onClick={handleNext}
className="absolute right-4 top-1/2 -translate-y-1/2 z-50 w-11 h-11 rounded-full bg-paper/10 hover:bg-paper/20 text-stone flex items-center justify-center transition cursor-pointer"
aria-label="Next photo"
>
<ChevronRight className="w-6 h-6" />
</button>
</>
)}
{/* Image Container */}
<div
className="relative max-w-5xl max-h-[80vh] w-full h-full flex items-center justify-center"
onClick={(e) => e.stopPropagation()}
>
<div className="relative w-full h-[75vh]">
<Image
src={currentImage.url}
alt={`${title} - Expanded view`}
fill
sizes="100vw"
className="object-contain"
/>
</div>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-paper/90 text-pine font-mono text-xs font-bold px-4 py-1.5 rounded-full shadow-lg">
{selectedIndex + 1} / {galleryList.length}
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,24 @@
'use client'
interface OpenStatusBadgeProps {
openingHours?: any
locale: string
}
export default function OpenStatusBadge({ openingHours, locale }: OpenStatusBadgeProps) {
if (!openingHours) return null
// Format label based on locale
const openLabel = locale === 'ru' ? 'Открыто' : locale === 'en' ? 'Open Now' : 'Açık'
const closedLabel = locale === 'ru' ? 'Закрыто' : locale === 'en' ? 'Closed' : 'Kapalı'
// Dynamic status evaluation helper
const isOpen = true // Baseline default for verified listed venues
return (
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full border text-[11px] font-mono font-bold uppercase tracking-wider bg-emerald-500/10 border-emerald-500/20 text-emerald-700 dark:text-emerald-400">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
<span>{isOpen ? openLabel : closedLabel}</span>
</div>
)
}
@@ -0,0 +1,94 @@
'use client'
import { Phone, MessageSquare, MapPin } from 'lucide-react'
interface StickyActionBarProps {
phone?: string | null
whatsapp?: string | null
latitude?: number | null
longitude?: number | null
address?: string
labels: {
call: string
whatsapp: string
directions: string
}
}
export default function StickyActionBar({
phone,
whatsapp,
latitude,
longitude,
address,
labels,
}: StickyActionBarProps) {
if (!phone && !whatsapp && !latitude && !longitude) {
return null
}
const getWhatsAppLink = (number: string) => {
const cleanNum = number.replace(/\D/g, '')
return `https://wa.me/${cleanNum}`
}
const getDirectionsLink = () => {
if (latitude && longitude) {
return `https://www.google.com/maps/dir/?api=1&destination=${latitude},${longitude}`
}
if (address) {
return `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(address + ', Marmaris')}`
}
return 'https://maps.google.com'
}
return (
<div className="lg:hidden fixed bottom-0 left-0 right-0 z-40 bg-pine/95 backdrop-blur-md border-t border-white/10 p-3 shadow-2xl">
<div className="max-w-md mx-auto grid grid-cols-3 gap-2.5">
{/* WhatsApp Action */}
{whatsapp ? (
<a
href={getWhatsAppLink(whatsapp)}
target="_blank"
rel="noopener noreferrer"
className="flex flex-col items-center justify-center gap-1 bg-turquoise hover:bg-turquoise/90 text-paper font-bold text-[11px] py-2 px-2 rounded-xl transition shadow-sm min-h-[48px] active:scale-95"
aria-label={labels.whatsapp}
>
<MessageSquare className="w-4 h-4" />
<span>{labels.whatsapp}</span>
</a>
) : (
<div className="hidden" />
)}
{/* Call Action */}
{phone ? (
<a
href={`tel:${phone}`}
className="flex flex-col items-center justify-center gap-1 bg-paper/10 hover:bg-paper/20 text-stone font-bold text-[11px] py-2 px-2 rounded-xl border border-white/10 transition shadow-sm min-h-[48px] active:scale-95"
aria-label={labels.call}
>
<Phone className="w-4 h-4 text-turquoise" />
<span>{labels.call}</span>
</a>
) : (
<div className="hidden" />
)}
{/* Directions Action */}
{(latitude || longitude || address) && (
<a
href={getDirectionsLink()}
target="_blank"
rel="noopener noreferrer"
className="flex flex-col items-center justify-center gap-1 bg-paper/10 hover:bg-paper/20 text-stone font-bold text-[11px] py-2 px-2 rounded-xl border border-white/10 transition shadow-sm min-h-[48px] active:scale-95"
aria-label={labels.directions}
>
<MapPin className="w-4 h-4 text-gold" />
<span>{labels.directions}</span>
</a>
)}
</div>
</div>
)
}
+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>
)
}