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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { mockDb } from '@/lib/mockDb'
|
||||
import ListingCard from '@/components/ListingCard'
|
||||
import ListingsMapLoader from '@/components/ListingsMapLoader'
|
||||
import { Link } from '@/i18n/routing'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { MapPin, SlidersHorizontal, Check } from 'lucide-react'
|
||||
@@ -42,7 +43,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
? `Лучшие места категории «${name}» в Мармарисе — с фильтрами по районам и ценам, проверено местными.`
|
||||
: `Marmaris'teki en iyi ${name.toLowerCase()} listesi — mahalle ve fiyata göre filtrele, yerel onaylılardan seç.`
|
||||
|
||||
return basicMetadata(title, description)
|
||||
return basicMetadata(title, description, locale, `/${categorySlug}`)
|
||||
}
|
||||
|
||||
export default async function DynamicCategoryPage({ params, searchParams }: PageProps) {
|
||||
@@ -53,6 +54,7 @@ export default async function DynamicCategoryPage({ params, searchParams }: Page
|
||||
|
||||
const t = await getTranslations('categories')
|
||||
const navT = await getTranslations('nav')
|
||||
const heroT = await getTranslations('hero')
|
||||
|
||||
// Find Category Restoran
|
||||
const categories = await mockDb.getCategories()
|
||||
@@ -81,8 +83,39 @@ export default async function DynamicCategoryPage({ params, searchParams }: Page
|
||||
return locale === 'ru' ? obj.nameRu : locale === 'en' ? obj.nameEn : obj.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: `https://marmarislocal.com/${locale}` },
|
||||
{ '@type': 'ListItem', position: 2, name: getLocalizedName(currentCategory), item: `https://marmarislocal.com/${locale}/${categorySlug}` },
|
||||
],
|
||||
}
|
||||
|
||||
const itemListLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: getLocalizedName(currentCategory),
|
||||
numberOfItems: listings.length,
|
||||
itemListElement: listings.map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: `https://marmarislocal.com/${locale}/${categorySlug}/${item.slug}`,
|
||||
name: locale === 'ru' ? item.nameRu : locale === 'en' ? item.nameEn : item.nameTr,
|
||||
})),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbLd) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(itemListLd) }}
|
||||
/>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 flex-1">
|
||||
|
||||
@@ -166,6 +199,40 @@ export default async function DynamicCategoryPage({ params, searchParams }: Page
|
||||
</LiveFilterForm>
|
||||
</div>
|
||||
|
||||
{/* Map View */}
|
||||
{(() => {
|
||||
const mapListings = listings
|
||||
.filter((l) => l.latitude != null && l.longitude != null)
|
||||
.map((l) => ({
|
||||
id: l.id,
|
||||
slug: l.slug,
|
||||
name: getLocalizedName({ nameTr: l.nameTr, nameEn: l.nameEn, nameRu: l.nameRu }),
|
||||
categorySlug: l.category?.slug || categorySlug,
|
||||
categoryName: getLocalizedName(currentCategory),
|
||||
latitude: l.latitude as number,
|
||||
longitude: l.longitude as number,
|
||||
isLocalApproved: l.isLocalApproved,
|
||||
}))
|
||||
|
||||
if (mapListings.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mb-10 space-y-3">
|
||||
<div className="flex items-center gap-2 font-heading font-bold text-sm text-pine lowercase">
|
||||
<MapPin className="w-4 h-4 text-turquoise" />
|
||||
<span>{t('map')}</span>
|
||||
</div>
|
||||
<div className="h-80 rounded-2xl overflow-hidden border border-pine/8 shadow-sm">
|
||||
<ListingsMapLoader
|
||||
listings={mapListings}
|
||||
approvedLabel={heroT('approvedBadge')}
|
||||
viewLabel={t('viewDetails')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Results */}
|
||||
{listings.length === 0 ? (
|
||||
<div className="bg-paper/50 rounded-2xl border border-dashed border-pine/12 p-12 text-center text-shutter">
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function generateMetadata({ params }: AboutPageProps): Promise<Meta
|
||||
? 'Узнайте, что означает знак одобрения Marmaris Local и как мы составляем гид.'
|
||||
: "Marmaris Local yerel onay mührünün ne anlama geldiğini ve rehberi nasıl kürasyonladığımızı öğrenin."
|
||||
|
||||
return basicMetadata(title, description)
|
||||
return basicMetadata(title, description, locale, '/about')
|
||||
}
|
||||
|
||||
export default async function AboutPage({ params }: AboutPageProps) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { submitBusinessAction } from '@/app/actions'
|
||||
import HoneypotField from '@/components/HoneypotField'
|
||||
|
||||
interface Option {
|
||||
value: string
|
||||
@@ -73,6 +74,8 @@ export default function BusinessForm({ translations, categories, neighborhoods }
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HoneypotField />
|
||||
|
||||
{/* Business name */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.businessName} *</label>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { basicMetadata } from '@/lib/seo'
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { mockDb } from '@/lib/mockDb'
|
||||
import BusinessForm from './BusinessForm'
|
||||
@@ -6,6 +8,13 @@ interface AddBusinessPageProps {
|
||||
params: Promise<{ locale: string }>
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: AddBusinessPageProps): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const title = locale === 'en' ? 'Add Your Business — Marmaris Local' : locale === 'ru' ? 'Добавить заведение — Marmaris Local' : 'İşletme Ekle — Marmaris Local'
|
||||
const description = locale === 'en' ? 'Apply to get your Marmaris business curated and featured with the Yerel Onaylı seal.' : locale === 'ru' ? 'Подайте заявку на включение вашего заведения в гид Marmaris Local.' : 'İşletmenizi Marmaris Local rehberine ekleyin ve Yerel Onaylı mührünü alın.'
|
||||
return basicMetadata(title, description, locale, '/add-business')
|
||||
}
|
||||
|
||||
export default async function AddBusinessPage({ params }: AddBusinessPageProps) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
|
||||
@@ -73,13 +73,13 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
href={item.href}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={`
|
||||
flex items-center px-4 py-3 text-xs font-semibold rounded-xl transition-all duration-150
|
||||
flex items-center px-4 py-3 text-xs font-semibold rounded-xl transition-all duration-150 min-h-[44px]
|
||||
${isActive
|
||||
? 'bg-white/5 border-l-4 border-turquoise text-stone pl-3'
|
||||
: 'text-stone/75 hover:bg-white/5 hover:text-stone'}
|
||||
? 'bg-turquoise/15 text-turquoise font-bold'
|
||||
: 'text-stone/80 hover:bg-white/10 hover:text-stone'}
|
||||
`}
|
||||
>
|
||||
<item.icon className={`mr-3 flex-shrink-0 h-4.5 w-4.5 ${isActive ? 'text-turquoise' : 'text-stone/50'}`} />
|
||||
<item.icon className={`mr-3 flex-shrink-0 h-4.5 w-4.5 ${isActive ? 'text-turquoise' : 'text-stone/60'}`} />
|
||||
{item.name}
|
||||
</Link>
|
||||
)
|
||||
@@ -90,10 +90,11 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
{/* Logout Action */}
|
||||
<div className="p-4 border-t border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signOut({ callbackUrl: '/' })}
|
||||
className="flex w-full items-center px-4 py-3 text-xs font-semibold text-red-400 hover:text-red-300 rounded-xl hover:bg-red-950/20 transition-colors"
|
||||
className="flex w-full items-center px-4 py-3 text-xs font-semibold text-bougainvillea hover:bg-bougainvillea/10 rounded-xl transition-colors min-h-[44px] cursor-pointer"
|
||||
>
|
||||
<LogOut className="mr-3 h-4.5 w-4.5 text-red-400/70" />
|
||||
<LogOut className="mr-3 h-4.5 w-4.5 text-bougainvillea" />
|
||||
Çıkış Yap
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -23,12 +23,14 @@ export default async function AdminListingEditPage({ params }: Props) {
|
||||
|
||||
const categoryOptions = categories.map(c => ({
|
||||
value: c.id,
|
||||
label: c.nameTr
|
||||
label: c.nameTr,
|
||||
slug: c.slug
|
||||
}))
|
||||
|
||||
const neighborhoodOptions = neighborhoods.map(n => ({
|
||||
value: n.id,
|
||||
label: n.nameTr
|
||||
label: n.nameTr,
|
||||
slug: n.slug
|
||||
}))
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Link } from '@/i18n/routing'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { Calendar, Tag, ArrowLeft } from 'lucide-react'
|
||||
import ListingCard from '@/components/ListingCard'
|
||||
import { renderMarkdownToHtml } from '@/lib/markdown'
|
||||
import Image from 'next/image'
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
interface Props {
|
||||
@@ -36,48 +38,6 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
}
|
||||
}
|
||||
|
||||
// Lightweight safe Markdown to HTML parsing function
|
||||
function renderMarkdownToHtml(md: string): string {
|
||||
if (!md) return ''
|
||||
let html = md
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
// Bold
|
||||
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
html = html.replace(/__(.*?)__/g, '<strong>$1</strong>')
|
||||
|
||||
// Italic
|
||||
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||
html = html.replace(/_(.*?)_/g, '<em>$1</em>')
|
||||
|
||||
// Headings
|
||||
html = html.replace(/^### (.*?)$/gm, '<h4 class="text-base font-heading font-bold text-pine mt-6 mb-2 lowercase">$1</h4>')
|
||||
html = html.replace(/^## (.*?)$/gm, '<h3 class="text-lg font-heading font-extrabold text-pine mt-8 mb-3 lowercase">$1</h3>')
|
||||
html = html.replace(/^# (.*?)$/gm, '<h2 class="text-xl font-heading font-extrabold text-pine mt-10 mb-4 lowercase">$1</h2>')
|
||||
|
||||
// Bullet Lists
|
||||
html = html.replace(/^\* (.*?)$/gm, '<li class="ml-4 list-disc text-sm text-ink/80 leading-relaxed">$1</li>')
|
||||
html = html.replace(/^- (.*?)$/gm, '<li class="ml-4 list-disc text-sm text-ink/80 leading-relaxed">$1</li>')
|
||||
|
||||
// Links
|
||||
html = html.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" class="text-turquoise hover:underline" target="_blank" rel="noopener">$1</a>')
|
||||
|
||||
// Paragraphs
|
||||
const blocks = html.split(/\n\n+/)
|
||||
html = blocks.map(block => {
|
||||
const trimmed = block.trim()
|
||||
if (!trimmed) return ''
|
||||
if (trimmed.startsWith('<h') || trimmed.startsWith('<li') || trimmed.startsWith('<ul') || trimmed.startsWith('<ol')) {
|
||||
return trimmed
|
||||
}
|
||||
return `<p class="leading-relaxed mb-4 text-sm sm:text-base text-ink/80 font-medium">${trimmed.replace(/\n/g, '<br/>')}</p>`
|
||||
}).join('\n')
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
export default async function BlogPostDetailPage({ params }: Props) {
|
||||
const { locale, slug } = await params
|
||||
const post = await mockDb.getBlogPostBySlug(slug)
|
||||
@@ -149,10 +109,13 @@ export default async function BlogPostDetailPage({ params }: Props) {
|
||||
<article className="bg-paper border border-pine/8 rounded-3xl overflow-hidden shadow-sm">
|
||||
{post.coverImage && (
|
||||
<div className="h-[350px] relative overflow-hidden bg-stone border-b border-pine/5">
|
||||
<img
|
||||
src={post.coverImage}
|
||||
alt={title}
|
||||
className="w-full h-full object-cover"
|
||||
<Image
|
||||
src={post.coverImage}
|
||||
alt={title}
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, 768px"
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
||||
? 'Гиды, чтобы открыть Мармарис как местный житель — где поесть, скрытые места и советы путешественникам.'
|
||||
: "Marmaris'i bir yerel gibi keşfetmeniz için rehberler, nerede ne yenir tavsiyeleri ve gizli yerler."
|
||||
|
||||
return basicMetadata(title, description)
|
||||
return basicMetadata(title, description, locale)
|
||||
}
|
||||
|
||||
export default async function BlogIndexPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
||||
? 'Тематические подборки лучших ресторанов, апарт-отелей и услуг Мармариса, проверенные местными.'
|
||||
: "Marmaris'teki en iyi restoranlar, apartlar ve hizmetlerin özel tematik derlemeleri."
|
||||
|
||||
return basicMetadata(title, description)
|
||||
return basicMetadata(title, description, locale)
|
||||
}
|
||||
|
||||
export default async function CollectionsIndexPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { submitContactMessageAction } from '@/app/actions'
|
||||
import HoneypotField from '@/components/HoneypotField'
|
||||
|
||||
interface Translations {
|
||||
name: string
|
||||
@@ -60,6 +61,8 @@ export default function ContactForm({ translations }: FormProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HoneypotField />
|
||||
|
||||
{/* Name */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.name} *</label>
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function generateMetadata({ params }: ContactPageProps): Promise<Me
|
||||
? 'Свяжитесь с командой Marmaris Local.'
|
||||
: 'Marmaris Local ekibiyle iletişime geçin.'
|
||||
|
||||
return basicMetadata(title, description)
|
||||
return basicMetadata(title, description, locale, '/contact')
|
||||
}
|
||||
|
||||
export default async function ContactPage({ params }: ContactPageProps) {
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
? 'Предстоящие местные события, живая музыка и вечеринки у бассейна в Мармарисе.'
|
||||
: 'Marmaris\'teki yaklaşan yerel etkinlikler, canlı müzik geceleri ve havuz partileri.'
|
||||
|
||||
return basicMetadata(title, description)
|
||||
return basicMetadata(title, description, locale)
|
||||
}
|
||||
|
||||
export default async function EventsPage({ params }: Props) {
|
||||
|
||||
@@ -101,12 +101,41 @@ export default async function RootLayout({
|
||||
|
||||
const categories = await mockDb.getCategories();
|
||||
|
||||
const websiteLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'Marmaris Local',
|
||||
url: SITE_URL,
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: `${SITE_URL}/${locale}/restoran?search={search_term_string}`,
|
||||
'query-input': 'required name=search_term_string',
|
||||
},
|
||||
};
|
||||
|
||||
const organizationLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'Marmaris Local',
|
||||
url: SITE_URL,
|
||||
logo: `${SITE_URL}/${locale}/opengraph-image`,
|
||||
description: 'Marmaris curated local guide and verified places directory.',
|
||||
};
|
||||
|
||||
return (
|
||||
<html
|
||||
lang={locale}
|
||||
className={`${unbounded.variable} ${golosText.variable} ${ibmPlexMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col font-sans" suppressHydrationWarning>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteLd) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationLd) }}
|
||||
/>
|
||||
{/* VPS Panel Analytics */}
|
||||
<Script
|
||||
src="https://panel.ayris.tech/api/analytics/script"
|
||||
|
||||
+23
-18
@@ -32,24 +32,33 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 px-4">
|
||||
<div className="w-full max-w-md bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-100 dark:border-gray-800 overflow-hidden">
|
||||
<div className="p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Admin Girişi</h1>
|
||||
<p className="text-sm text-gray-500 mt-2">Yönetim paneline erişmek için giriş yapın</p>
|
||||
<div className="min-h-[100dvh] flex items-center justify-center bg-pine px-4 py-12">
|
||||
<div className="w-full max-w-md bg-paper rounded-3xl shadow-xl border border-white/10 overflow-hidden">
|
||||
<div className="p-8 sm:p-10 space-y-6">
|
||||
|
||||
{/* Logo & Brand Header */}
|
||||
<div className="text-center space-y-3">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full border-2 border-stone bg-paper shadow-sm">
|
||||
<span className="font-heading font-extrabold text-pine text-sm tracking-tighter">ML</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-heading font-extrabold text-pine tracking-tight lowercase">
|
||||
marmaris <span className="text-turquoise">local</span>
|
||||
</h1>
|
||||
<p className="text-xs text-stone-deep font-mono uppercase tracking-wider">
|
||||
backoffice admin login
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 p-3 rounded-md text-sm mb-6 border border-red-100">
|
||||
<div className="bg-bougainvillea/10 text-bougainvillea p-3.5 rounded-xl text-xs font-mono border border-bougainvillea/20 text-center font-semibold">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" htmlFor="email">
|
||||
E-posta
|
||||
<label className="block text-xs font-mono font-bold text-pine/80 uppercase tracking-wider mb-1.5" htmlFor="email">
|
||||
E-posta Adresi
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
@@ -57,12 +66,12 @@ export default function LoginPage() {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-900 text-gray-900 dark:text-white transition-colors"
|
||||
className="w-full px-4 py-3 border border-pine/15 rounded-xl focus:ring-2 focus:ring-turquoise focus:border-turquoise bg-stone/20 text-pine text-sm transition-colors outline-none font-medium min-h-[44px]"
|
||||
placeholder="admin@ayris.tech"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" htmlFor="password">
|
||||
<label className="block text-xs font-mono font-bold text-pine/80 uppercase tracking-wider mb-1.5" htmlFor="password">
|
||||
Şifre
|
||||
</label>
|
||||
<input
|
||||
@@ -71,22 +80,18 @@ export default function LoginPage() {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-900 text-gray-900 dark:text-white transition-colors"
|
||||
className="w-full px-4 py-3 border border-pine/15 rounded-xl focus:ring-2 focus:ring-turquoise focus:border-turquoise bg-stone/20 text-pine text-sm transition-colors outline-none font-medium min-h-[44px]"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2.5 px-4 rounded-md transition-colors disabled:opacity-70 disabled:cursor-not-allowed"
|
||||
className="w-full bg-turquoise hover:bg-turquoise/90 text-paper font-bold py-3.5 px-4 rounded-xl transition-all duration-150 active:scale-95 shadow-sm disabled:opacity-70 disabled:cursor-not-allowed cursor-pointer min-h-[44px] text-xs font-mono uppercase tracking-wider mt-2"
|
||||
>
|
||||
{loading ? 'Giriş yapılıyor...' : 'Giriş Yap'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-xs text-gray-400">
|
||||
Demo credentials: admin@ayris.tech / admin
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { setRequestLocale } from 'next-intl/server'
|
||||
import { mockDb } from '@/lib/mockDb'
|
||||
import ListingCard from '@/components/ListingCard'
|
||||
import ListingsMapLoader from '@/components/ListingsMapLoader'
|
||||
import { Link } from '@/i18n/routing'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { MapPin, Compass, ArrowRight } from 'lucide-react'
|
||||
import type { Metadata } from 'next'
|
||||
import { basicMetadata, SITE_URL } from '@/lib/seo'
|
||||
|
||||
interface ProgrammaticPageProps {
|
||||
params: Promise<{ locale: string; slug: string; categorySlug: string }>
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: ProgrammaticPageProps): Promise<Metadata> {
|
||||
const { locale, slug: neighborhoodSlug, categorySlug } = await params
|
||||
const neighborhood = await mockDb.getNeighborhoodBySlug(neighborhoodSlug)
|
||||
const categories = await mockDb.getCategories()
|
||||
const category = categories.find((c) => c.slug === categorySlug)
|
||||
|
||||
if (!neighborhood || !category) return {}
|
||||
|
||||
const neighborhoodName = locale === 'ru' ? neighborhood.nameRu : locale === 'en' ? neighborhood.nameEn : neighborhood.nameTr
|
||||
const categoryName = locale === 'ru' ? category.nameRu : locale === 'en' ? category.nameEn : category.nameTr
|
||||
|
||||
const listings = await mockDb.getListings({
|
||||
neighborhoodId: neighborhood.id,
|
||||
categoryId: category.id,
|
||||
})
|
||||
|
||||
const title =
|
||||
locale === 'en'
|
||||
? `Best ${categoryName} in ${neighborhoodName}, Marmaris — Local Guide`
|
||||
: locale === 'ru'
|
||||
? `${categoryName} в районе ${neighborhoodName}, Мармарис — Местный гид`
|
||||
: `Marmaris ${neighborhoodName} ${categoryName} Rehberi — Yerel Onaylı`
|
||||
|
||||
const description =
|
||||
locale === 'en'
|
||||
? `Browse curated ${categoryName.toLowerCase()} in ${neighborhoodName}, Marmaris. Verified local spots with contact info, map locations and prices.`
|
||||
: locale === 'ru'
|
||||
? `Лучшие заведения категории «${categoryName}» в районе ${neighborhoodName}, Мармарис. Проверено местными жителями.`
|
||||
: `${neighborhoodName}, Marmaris'teki en iyi ${categoryName.toLowerCase()} listesi — yerel onaylı mekanlar, harita ve iletişim bilgileri.`
|
||||
|
||||
const pathSuffix = `/neighborhood/${neighborhoodSlug}/${categorySlug}`
|
||||
const base = basicMetadata(title, description, locale, pathSuffix)
|
||||
|
||||
// Quality Control: Prevent thin content indexing if fewer than 2 listings exist
|
||||
const isIndexable = listings.length >= 2
|
||||
|
||||
return {
|
||||
...base,
|
||||
robots: {
|
||||
index: isIndexable,
|
||||
follow: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ProgrammaticCategoryNeighborhoodPage({ params }: ProgrammaticPageProps) {
|
||||
const { locale, slug: neighborhoodSlug, categorySlug } = await params
|
||||
setRequestLocale(locale)
|
||||
|
||||
const neighborhood = await mockDb.getNeighborhoodBySlug(neighborhoodSlug)
|
||||
const categories = await mockDb.getCategories()
|
||||
const category = categories.find((c) => c.slug === categorySlug)
|
||||
|
||||
if (!neighborhood || !category) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const listings = await mockDb.getListings({
|
||||
neighborhoodId: neighborhood.id,
|
||||
categoryId: category.id,
|
||||
})
|
||||
|
||||
const allNeighborhoods = await mockDb.getNeighborhoods()
|
||||
|
||||
const neighborhoodName = locale === 'ru' ? neighborhood.nameRu : locale === 'en' ? neighborhood.nameEn : neighborhood.nameTr
|
||||
const categoryName = locale === 'ru' ? category.nameRu : locale === 'en' ? category.nameEn : category.nameTr
|
||||
const homeLabel = locale === 'en' ? 'Home' : locale === 'ru' ? 'Главная' : 'Ana Sayfa'
|
||||
|
||||
const breadcrumbLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: homeLabel, item: `${SITE_URL}/${locale}` },
|
||||
{ '@type': 'ListItem', position: 2, name: neighborhoodName, item: `${SITE_URL}/${locale}/neighborhood/${neighborhoodSlug}` },
|
||||
{ '@type': 'ListItem', position: 3, name: `${neighborhoodName} ${categoryName}`, item: `${SITE_URL}/${locale}/neighborhood/${neighborhoodSlug}/${categorySlug}` },
|
||||
],
|
||||
}
|
||||
|
||||
const itemListLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: `${neighborhoodName} ${categoryName}`,
|
||||
numberOfItems: listings.length,
|
||||
itemListElement: listings.map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: `${SITE_URL}/${locale}/${categorySlug}/${item.slug}`,
|
||||
name: locale === 'ru' ? item.nameRu : locale === 'en' ? item.nameEn : item.nameTr,
|
||||
})),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbLd) }}
|
||||
/>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(itemListLd) }}
|
||||
/>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 flex-1 space-y-10">
|
||||
|
||||
{/* Breadcrumbs */}
|
||||
<nav aria-label="Breadcrumb" className="text-xs font-mono uppercase tracking-wider text-stone/80 flex items-center gap-2 flex-wrap">
|
||||
<Link href="/" className="hover:text-turquoise transition-colors">marmaris local</Link>
|
||||
<span>/</span>
|
||||
<Link href={`/neighborhood/${neighborhoodSlug}`} className="hover:text-turquoise transition-colors">
|
||||
{neighborhoodName}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-pine font-bold">{categoryName}</span>
|
||||
</nav>
|
||||
|
||||
{/* Header Hero */}
|
||||
<div className="bg-paper p-8 rounded-3xl border border-pine/8 shadow-sm flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="space-y-2">
|
||||
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-turquoise/10 border border-turquoise/20 text-turquoise text-[10px] font-mono font-bold uppercase tracking-wider">
|
||||
<MapPin className="w-3.5 h-3.5" />
|
||||
<span>{neighborhoodName} • {categoryName}</span>
|
||||
</div>
|
||||
<h1 className="text-3xl sm:text-4xl font-heading font-extrabold text-pine lowercase">
|
||||
{neighborhoodName} {categoryName}
|
||||
</h1>
|
||||
<p className="text-xs text-stone/80 font-mono uppercase tracking-wider">
|
||||
{listings.length} {locale === 'tr' ? 'onaylı mekan listeleniyor' : locale === 'en' ? 'verified places listed' : 'подтвержденных мест'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<Link
|
||||
href={`/${categorySlug}`}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-mono font-semibold text-pine hover:text-turquoise border border-pine/10 hover:border-turquoise/30 px-4 py-2.5 rounded-full transition bg-stone/20"
|
||||
>
|
||||
<span>{locale === 'tr' ? 'Tüm Marmaris' : locale === 'en' ? 'All Marmaris' : 'Весь Мармарис'} {categoryName}</span>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Listings Grid */}
|
||||
{listings.length === 0 ? (
|
||||
<div className="bg-paper/60 rounded-3xl border border-dashed border-pine/12 p-16 text-center text-stone/80 space-y-3">
|
||||
<p className="text-sm font-medium">
|
||||
{locale === 'tr'
|
||||
? `${neighborhoodName} mahallesinde henüz ${categoryName.toLowerCase()} kategorisinde mekan bulunmamaktadır.`
|
||||
: locale === 'en'
|
||||
? `No places found under ${categoryName} in ${neighborhoodName} yet.`
|
||||
: `В районе ${neighborhoodName} пока нет заведений в категории «${categoryName}».`}
|
||||
</p>
|
||||
<Link
|
||||
href={`/neighborhood/${neighborhoodSlug}`}
|
||||
className="inline-block text-xs font-mono font-bold text-turquoise hover:underline"
|
||||
>
|
||||
{locale === 'tr' ? 'Tüm ' + neighborhoodName + ' mekanlarını gör' : locale === 'en' ? 'View all places in ' + neighborhoodName : 'Посмотреть все места в ' + neighborhoodName} →
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{listings.map((listing) => (
|
||||
<ListingCard key={listing.id} listing={listing} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other Categories in same Neighborhood */}
|
||||
<div className="bg-paper p-6 sm:p-8 rounded-3xl border border-pine/8 shadow-sm space-y-4">
|
||||
<h3 className="font-heading font-extrabold text-lg text-pine lowercase flex items-center gap-2">
|
||||
<Compass className="w-5 h-5 text-turquoise" />
|
||||
<span>{neighborhoodName} {locale === 'tr' ? 'bölgesindeki diğer kategoriler' : locale === 'en' ? 'other categories in' : 'другие категории в'} {neighborhoodName}</span>
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
{categories
|
||||
.filter((c) => c.slug !== categorySlug)
|
||||
.map((cat) => {
|
||||
const name = locale === 'ru' ? cat.nameRu : locale === 'en' ? cat.nameEn : cat.nameTr
|
||||
return (
|
||||
<Link
|
||||
key={cat.id}
|
||||
href={`/neighborhood/${neighborhoodSlug}/${cat.slug}`}
|
||||
className="text-xs font-mono font-medium px-4 py-2 rounded-full border border-pine/10 hover:border-turquoise hover:text-turquoise bg-stone/20 transition"
|
||||
>
|
||||
{neighborhoodName} {name}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Same Category in other Neighborhoods */}
|
||||
<div className="bg-paper p-6 sm:p-8 rounded-3xl border border-pine/8 shadow-sm space-y-4">
|
||||
<h3 className="font-heading font-extrabold text-lg text-pine lowercase flex items-center gap-2">
|
||||
<MapPin className="w-5 h-5 text-turquoise" />
|
||||
<span>{categoryName} — {locale === 'tr' ? 'Diğer Mahalleler' : locale === 'en' ? 'Other Neighborhoods' : 'Другие районы'}</span>
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
{allNeighborhoods
|
||||
.filter((n) => n.slug !== neighborhoodSlug)
|
||||
.map((neigh) => {
|
||||
const name = locale === 'ru' ? neigh.nameRu : locale === 'en' ? neigh.nameEn : neigh.nameTr
|
||||
return (
|
||||
<Link
|
||||
key={neigh.id}
|
||||
href={`/neighborhood/${neigh.slug}/${categorySlug}`}
|
||||
className="text-xs font-mono font-medium px-4 py-2 rounded-full border border-pine/10 hover:border-turquoise hover:text-turquoise bg-stone/20 transition"
|
||||
>
|
||||
{name} {categoryName}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export async function generateMetadata({ params }: NeighborhoodPageProps): Promi
|
||||
? `Лучшие рестораны, апарт-отели и заведения в районе ${name}, Мармарис.`
|
||||
: `${name}, Marmaris'teki en iyi restoranlar, apart oteller ve yerel işletmeler.`
|
||||
|
||||
return basicMetadata(title, description)
|
||||
return basicMetadata(title, description, locale, `/neighborhood/${slug}`)
|
||||
}
|
||||
|
||||
export default async function NeighborhoodPage({ params }: NeighborhoodPageProps) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ImageResponse } from 'next/og'
|
||||
import { OgImageContent } from '@/lib/ogImage'
|
||||
|
||||
export const size = { width: 1200, height: 630 }
|
||||
export const contentType = 'image/png'
|
||||
|
||||
const TAGLINES: Record<string, string> = {
|
||||
tr: 'turistin göremediği yerel bilgi.',
|
||||
en: 'the local knowledge tourists never see.',
|
||||
ru: 'инсайдерская информация, скрытая от туристов.',
|
||||
}
|
||||
|
||||
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params
|
||||
return new ImageResponse(<OgImageContent tagline={TAGLINES[locale] || TAGLINES.tr} />, size)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import Image from 'next/image'
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { mockDb } from '@/lib/mockDb'
|
||||
import ListingCard from '@/components/ListingCard'
|
||||
@@ -24,7 +25,7 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
|
||||
? 'Лучшие рестораны, апарт-отели и заведения Мармариса — подборка, проверенная местными жителями.'
|
||||
: "Marmaris'in turistin göremediği en iyi restoranları, apart otelleri ve yerel işletmeleri — yerel onaylı, güvenilir rehber."
|
||||
|
||||
return basicMetadata(title, description)
|
||||
return basicMetadata(title, description, locale)
|
||||
}
|
||||
|
||||
export default async function HomePage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
@@ -143,10 +144,12 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
|
||||
className="group relative h-64 rounded-2xl overflow-hidden shadow-sm hover:shadow-md transition duration-300 border border-pine/5 flex items-end p-6"
|
||||
>
|
||||
<div className="absolute inset-0">
|
||||
<img
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={catName}
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
className="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-pine/90 via-pine/30 to-transparent" />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from '@/i18n/routing'
|
||||
import { generateItineraryAction } from '@/app/actions'
|
||||
import { Utensils, Waves, Mountain, Loader2 } from 'lucide-react'
|
||||
|
||||
interface Option {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface Translations {
|
||||
days: string
|
||||
style: string
|
||||
neighborhoods: string
|
||||
generate: string
|
||||
generating: string
|
||||
gastronomy: string
|
||||
relaxation: string
|
||||
adventure: string
|
||||
}
|
||||
|
||||
interface FormProps {
|
||||
locale: string
|
||||
translations: Translations
|
||||
neighborhoods: Option[]
|
||||
}
|
||||
|
||||
const STYLES = [
|
||||
{ value: 'gastronomy', icon: Utensils },
|
||||
{ value: 'relaxation', icon: Waves },
|
||||
{ value: 'adventure', icon: Mountain },
|
||||
] as const
|
||||
|
||||
export default function PlannerForm({ locale, translations, neighborhoods }: FormProps) {
|
||||
const router = useRouter()
|
||||
const [days, setDays] = useState(3)
|
||||
const [style, setStyle] = useState<string>('gastronomy')
|
||||
const [selectedNeighborhoods, setSelectedNeighborhoods] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const styleLabels: Record<string, string> = {
|
||||
gastronomy: translations.gastronomy,
|
||||
relaxation: translations.relaxation,
|
||||
adventure: translations.adventure,
|
||||
}
|
||||
|
||||
const toggleNeighborhood = (slug: string) => {
|
||||
setSelectedNeighborhoods(prev =>
|
||||
prev.includes(slug) ? prev.filter(s => s !== slug) : [...prev, slug]
|
||||
)
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const res = await generateItineraryAction(days, style, selectedNeighborhoods, locale)
|
||||
if (res.success && res.id) {
|
||||
router.push(`/plan/${res.id}`)
|
||||
} else {
|
||||
setError(res.error || 'Bir hata oluştu. Lütfen tekrar deneyin.')
|
||||
setLoading(false)
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Bir hata oluştu. Lütfen tekrar deneyin.')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{error && (
|
||||
<div className="bg-bougainvillea/10 border border-bougainvillea/20 text-bougainvillea p-4 rounded-xl text-xs font-semibold">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Days */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.days}</label>
|
||||
<div className="flex gap-2">
|
||||
{[1, 2, 3, 4, 5].map(n => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
onClick={() => setDays(n)}
|
||||
className={`w-12 h-12 rounded-xl border text-sm font-bold font-mono transition ${
|
||||
days === n
|
||||
? 'bg-turquoise border-turquoise text-paper'
|
||||
: 'bg-stone border-pine/10 text-ink hover:border-turquoise/40'
|
||||
}`}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Style */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.style}</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{STYLES.map(({ value, icon: Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setStyle(value)}
|
||||
className={`flex flex-col items-center gap-2 rounded-xl border p-4 text-center transition ${
|
||||
style === value
|
||||
? 'bg-turquoise/10 border-turquoise text-pine'
|
||||
: 'bg-stone border-pine/10 text-ink hover:border-turquoise/40'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span className="text-xs font-semibold leading-tight">{styleLabels[value]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Neighborhoods */}
|
||||
{neighborhoods.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.neighborhoods}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{neighborhoods.map(n => (
|
||||
<button
|
||||
key={n.value}
|
||||
type="button"
|
||||
onClick={() => toggleNeighborhood(n.value)}
|
||||
className={`px-3 py-2 rounded-lg border text-xs font-semibold transition ${
|
||||
selectedNeighborhoods.includes(n.value)
|
||||
? 'bg-pine border-pine text-stone'
|
||||
: 'bg-stone border-pine/10 text-ink hover:border-turquoise/40'
|
||||
}`}
|
||||
>
|
||||
{n.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex items-center justify-center gap-2 bg-turquoise hover:bg-turquoise/90 disabled:opacity-75 disabled:cursor-not-allowed text-paper font-bold text-sm py-4.5 px-4 rounded-xl transition duration-300 shadow-sm"
|
||||
>
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{loading ? translations.generating : translations.generate}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { mockDb } from '@/lib/mockDb'
|
||||
import PlannerForm from './PlannerForm'
|
||||
import type { Metadata } from 'next'
|
||||
import { basicMetadata } from '@/lib/seo'
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ locale: string }>
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations({ locale, namespace: 'planner' })
|
||||
return basicMetadata(`${t('title')} — Marmaris Local`, t('subtitle'), locale)
|
||||
}
|
||||
|
||||
export default async function PlanOlusturPage({ params }: Props) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
|
||||
const t = await getTranslations('planner')
|
||||
const neighborhoods = await mockDb.getNeighborhoods()
|
||||
|
||||
const getLocalizedName = (obj: any) => {
|
||||
if (!obj) return ''
|
||||
return locale === 'ru' ? obj.nameRu : locale === 'en' ? obj.nameEn : obj.nameTr
|
||||
}
|
||||
|
||||
const neighborhoodOptions = neighborhoods.map(n => ({
|
||||
value: n.slug,
|
||||
label: getLocalizedName(n)
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
|
||||
<main className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-12 flex-1 w-full">
|
||||
<div className="bg-paper p-8 rounded-3xl border border-pine/8 shadow-sm">
|
||||
<div className="mb-8 border-b border-dashed border-pine/8 pb-6">
|
||||
<h1 className="text-3xl font-heading font-extrabold text-pine lowercase">
|
||||
{t('title')}
|
||||
</h1>
|
||||
<p className="text-sm text-shutter mt-2">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PlannerForm
|
||||
locale={locale}
|
||||
translations={{
|
||||
days: t('days'),
|
||||
style: t('style'),
|
||||
neighborhoods: t('neighborhoods'),
|
||||
generate: t('generate'),
|
||||
generating: t('generating'),
|
||||
gastronomy: t('styles.gastronomy'),
|
||||
relaxation: t('styles.relaxation'),
|
||||
adventure: t('styles.adventure'),
|
||||
}}
|
||||
neighborhoods={neighborhoodOptions}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { mockDb } from '@/lib/mockDb'
|
||||
import { Link } from '@/i18n/routing'
|
||||
import { notFound } from 'next/navigation'
|
||||
import { renderMarkdownToHtml } from '@/lib/markdown'
|
||||
import ListingCard from '@/components/ListingCard'
|
||||
import { ArrowLeft, Sparkles } from 'lucide-react'
|
||||
import type { Metadata } from 'next'
|
||||
import { basicMetadata } from '@/lib/seo'
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ locale: string; id: string }>
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { locale, id } = await params
|
||||
const itinerary = await mockDb.getItineraryById(id)
|
||||
if (!itinerary) return {}
|
||||
|
||||
const t = await getTranslations({ locale, namespace: 'planner' })
|
||||
const p = itinerary.params as { days?: number }
|
||||
const title = `${p.days ?? ''}${locale === 'tr' ? ' günlük' : locale === 'ru' ? '-дневный' : '-day'} ${t('title')} — Marmaris Local`
|
||||
const description = itinerary.content.replace(/[#*_>[\]()]/g, '').slice(0, 150)
|
||||
|
||||
return basicMetadata(title, description, locale)
|
||||
}
|
||||
|
||||
export default async function PlanResultPage({ params }: Props) {
|
||||
const { locale, id } = await params
|
||||
setRequestLocale(locale)
|
||||
|
||||
const itinerary = await mockDb.getItineraryById(id)
|
||||
if (!itinerary) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const t = await getTranslations('planner')
|
||||
const htmlContent = renderMarkdownToHtml(itinerary.content)
|
||||
|
||||
const relatedListings: any[] = []
|
||||
for (const listingId of itinerary.listingIds.slice(0, 6)) {
|
||||
const listing = await mockDb.getListingById(listingId)
|
||||
if (listing) relatedListings.push(listing)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-stone min-h-screen py-10 px-4 sm:px-6 lg:px-8 font-sans">
|
||||
<div className="max-w-3xl mx-auto space-y-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Link
|
||||
href="/plan-olustur"
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 border border-pine/10 rounded-xl text-xs font-mono font-bold text-pine hover:bg-paper transition"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
{t('generate')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<article className="bg-paper border border-pine/8 rounded-3xl overflow-hidden shadow-sm p-6 sm:p-10 space-y-6">
|
||||
<div className="flex items-center gap-2 text-xs font-mono uppercase tracking-wider text-turquoise">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
{t('title')}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="markdown-content space-y-4"
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
</article>
|
||||
|
||||
{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('placesInPlan')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
{relatedListings.map((listing) => (
|
||||
<ListingCard key={listing.id} listing={listing} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ImageResponse } from 'next/og'
|
||||
import { OgImageContent } from '@/lib/ogImage'
|
||||
|
||||
export const size = { width: 1200, height: 630 }
|
||||
export const contentType = 'image/png'
|
||||
|
||||
const TAGLINES: Record<string, string> = {
|
||||
tr: 'turistin göremediği yerel bilgi.',
|
||||
en: 'the local knowledge tourists never see.',
|
||||
ru: 'инсайдерская информация, скрытая от туристов.',
|
||||
}
|
||||
|
||||
export default async function Image({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params
|
||||
return new ImageResponse(<OgImageContent tagline={TAGLINES[locale] || TAGLINES.tr} />, size)
|
||||
}
|
||||
Reference in New Issue
Block a user