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,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>
|
||||
)
|
||||
}
|
||||
@@ -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 Aç</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user