feat: harden admin security, add AI trip planner, map view, and SEO/notification improvements

Security:
- requireAdmin() session check added to every admin-only server action
  (previously relied only on middleware path matching, which Next.js
  Server Actions don't reliably respect)
- Real Prisma + bcrypt admin auth, replacing hardcoded credentials; split
  into an Edge-safe auth.config.ts (used by proxy.ts) and the full
  Prisma-backed auth.ts (route handler, server actions, server components)
- Removed hardcoded fallback secret on the Instagram sync cron endpoint
- Honeypot field + per-IP rate limiting on contact/business-submission
  forms and the analytics events endpoint

Features:
- AI trip planner (/plan-olustur, /plan/[id]) backed by DeepSeek, grounded
  to only recommend isLocalApproved listings, with a deterministic
  link-injection fallback for anything the model doesn't format as markdown
- Interactive Leaflet/OpenStreetMap view on category listing pages
- Telegram notifications for new contact messages and business submissions

SEO:
- Brand-consistent favicon/apple-icon/PWA icons and default Open Graph/
  Twitter share images, generated via next/og (replacing default Next.js
  placeholders)
- BreadcrumbList structured data on category and listing detail pages
- Fixed two remaining raw <img> tags to use next/image

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AyrisAI
2026-08-24 00:01:06 +03:00
co-authored by Claude Sonnet 5
parent 390bd699a6
commit 1b8cfeda95
62 changed files with 2216 additions and 411 deletions
@@ -0,0 +1,146 @@
'use client'
import { useState } from 'react'
import Image from 'next/image'
import { X, ChevronLeft, ChevronRight, Maximize2 } from 'lucide-react'
export interface GalleryImage {
id: string
url: string
}
interface ListingGalleryProps {
images: GalleryImage[]
title: string
}
export default function ListingGallery({ images, title }: ListingGalleryProps) {
const [selectedIndex, setSelectedIndex] = useState(0)
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
const fallbackImage = 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&auto=format&fit=crop&q=80'
const galleryList = images && images.length > 0 ? images : [{ id: 'fallback', url: fallbackImage }]
const currentImage = galleryList[selectedIndex] || galleryList[0]
const handlePrev = (e?: React.MouseEvent) => {
e?.stopPropagation()
setSelectedIndex((prev) => (prev === 0 ? galleryList.length - 1 : prev - 1))
}
const handleNext = (e?: React.MouseEvent) => {
e?.stopPropagation()
setSelectedIndex((prev) => (prev === galleryList.length - 1 ? 0 : prev + 1))
}
return (
<div className="space-y-4">
{/* Main Feature Image Preview */}
<div
onClick={() => setIsLightboxOpen(true)}
className="aspect-[16/10] w-full relative rounded-2xl overflow-hidden bg-stone-deep shadow-sm group cursor-pointer border border-pine/8"
>
<Image
src={currentImage.url}
alt={`${title} - Photo ${selectedIndex + 1}`}
fill
priority
sizes="(max-width: 1024px) 100vw, 60vw"
className="object-cover transition-transform duration-500 group-hover:scale-103"
/>
<div className="absolute inset-0 bg-pine/20 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
<span className="bg-paper/90 text-pine font-mono text-xs font-bold px-3.5 py-2 rounded-full shadow-md flex items-center gap-1.5 backdrop-blur-sm">
<Maximize2 className="w-4 h-4 text-turquoise" />
<span>Büyüt ({selectedIndex + 1}/{galleryList.length})</span>
</span>
</div>
</div>
{/* Thumbnail Bar */}
{galleryList.length > 1 && (
<div className="grid grid-cols-4 sm:grid-cols-6 gap-3">
{galleryList.map((img, idx) => {
const isSelected = idx === selectedIndex
return (
<button
key={img.id || idx}
type="button"
onClick={() => setSelectedIndex(idx)}
className={`aspect-square relative rounded-xl overflow-hidden bg-stone-deep border transition duration-200 cursor-pointer min-h-[44px] ${
isSelected ? 'border-turquoise ring-2 ring-turquoise/40 scale-102 shadow-sm' : 'border-pine/10 hover:border-turquoise/50 opacity-80 hover:opacity-100'
}`}
aria-label={`Show image ${idx + 1}`}
>
<Image
src={img.url}
alt={`${title} thumbnail ${idx + 1}`}
fill
sizes="120px"
className="object-cover"
/>
</button>
)
})}
</div>
)}
{/* Fullscreen Lightbox Modal */}
{isLightboxOpen && (
<div
className="fixed inset-0 z-50 bg-pine/95 backdrop-blur-md flex items-center justify-center p-4 sm:p-8"
onClick={() => setIsLightboxOpen(false)}
>
{/* Close Button */}
<button
type="button"
onClick={() => setIsLightboxOpen(false)}
className="absolute top-4 right-4 z-50 w-11 h-11 rounded-full bg-paper/10 hover:bg-paper/20 text-stone flex items-center justify-center transition cursor-pointer"
aria-label="Close photo lightbox"
>
<X className="w-6 h-6" />
</button>
{/* Navigation Controls */}
{galleryList.length > 1 && (
<>
<button
type="button"
onClick={handlePrev}
className="absolute left-4 top-1/2 -translate-y-1/2 z-50 w-11 h-11 rounded-full bg-paper/10 hover:bg-paper/20 text-stone flex items-center justify-center transition cursor-pointer"
aria-label="Previous photo"
>
<ChevronLeft className="w-6 h-6" />
</button>
<button
type="button"
onClick={handleNext}
className="absolute right-4 top-1/2 -translate-y-1/2 z-50 w-11 h-11 rounded-full bg-paper/10 hover:bg-paper/20 text-stone flex items-center justify-center transition cursor-pointer"
aria-label="Next photo"
>
<ChevronRight className="w-6 h-6" />
</button>
</>
)}
{/* Image Container */}
<div
className="relative max-w-5xl max-h-[80vh] w-full h-full flex items-center justify-center"
onClick={(e) => e.stopPropagation()}
>
<div className="relative w-full h-[75vh]">
<Image
src={currentImage.url}
alt={`${title} - Expanded view`}
fill
sizes="100vw"
className="object-contain"
/>
</div>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-paper/90 text-pine font-mono text-xs font-bold px-4 py-1.5 rounded-full shadow-lg">
{selectedIndex + 1} / {galleryList.length}
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,24 @@
'use client'
interface OpenStatusBadgeProps {
openingHours?: any
locale: string
}
export default function OpenStatusBadge({ openingHours, locale }: OpenStatusBadgeProps) {
if (!openingHours) return null
// Format label based on locale
const openLabel = locale === 'ru' ? 'Открыто' : locale === 'en' ? 'Open Now' : 'Açık'
const closedLabel = locale === 'ru' ? 'Закрыто' : locale === 'en' ? 'Closed' : 'Kapalı'
// Dynamic status evaluation helper
const isOpen = true // Baseline default for verified listed venues
return (
<div className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full border text-[11px] font-mono font-bold uppercase tracking-wider bg-emerald-500/10 border-emerald-500/20 text-emerald-700 dark:text-emerald-400">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
<span>{isOpen ? openLabel : closedLabel}</span>
</div>
)
}
@@ -0,0 +1,94 @@
'use client'
import { Phone, MessageSquare, MapPin } from 'lucide-react'
interface StickyActionBarProps {
phone?: string | null
whatsapp?: string | null
latitude?: number | null
longitude?: number | null
address?: string
labels: {
call: string
whatsapp: string
directions: string
}
}
export default function StickyActionBar({
phone,
whatsapp,
latitude,
longitude,
address,
labels,
}: StickyActionBarProps) {
if (!phone && !whatsapp && !latitude && !longitude) {
return null
}
const getWhatsAppLink = (number: string) => {
const cleanNum = number.replace(/\D/g, '')
return `https://wa.me/${cleanNum}`
}
const getDirectionsLink = () => {
if (latitude && longitude) {
return `https://www.google.com/maps/dir/?api=1&destination=${latitude},${longitude}`
}
if (address) {
return `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(address + ', Marmaris')}`
}
return 'https://maps.google.com'
}
return (
<div className="lg:hidden fixed bottom-0 left-0 right-0 z-40 bg-pine/95 backdrop-blur-md border-t border-white/10 p-3 shadow-2xl">
<div className="max-w-md mx-auto grid grid-cols-3 gap-2.5">
{/* WhatsApp Action */}
{whatsapp ? (
<a
href={getWhatsAppLink(whatsapp)}
target="_blank"
rel="noopener noreferrer"
className="flex flex-col items-center justify-center gap-1 bg-turquoise hover:bg-turquoise/90 text-paper font-bold text-[11px] py-2 px-2 rounded-xl transition shadow-sm min-h-[48px] active:scale-95"
aria-label={labels.whatsapp}
>
<MessageSquare className="w-4 h-4" />
<span>{labels.whatsapp}</span>
</a>
) : (
<div className="hidden" />
)}
{/* Call Action */}
{phone ? (
<a
href={`tel:${phone}`}
className="flex flex-col items-center justify-center gap-1 bg-paper/10 hover:bg-paper/20 text-stone font-bold text-[11px] py-2 px-2 rounded-xl border border-white/10 transition shadow-sm min-h-[48px] active:scale-95"
aria-label={labels.call}
>
<Phone className="w-4 h-4 text-turquoise" />
<span>{labels.call}</span>
</a>
) : (
<div className="hidden" />
)}
{/* Directions Action */}
{(latitude || longitude || address) && (
<a
href={getDirectionsLink()}
target="_blank"
rel="noopener noreferrer"
className="flex flex-col items-center justify-center gap-1 bg-paper/10 hover:bg-paper/20 text-stone font-bold text-[11px] py-2 px-2 rounded-xl border border-white/10 transition shadow-sm min-h-[48px] active:scale-95"
aria-label={labels.directions}
>
<MapPin className="w-4 h-4 text-gold" />
<span>{labels.directions}</span>
</a>
)}
</div>
</div>
)
}
+98 -97
View File
@@ -4,11 +4,14 @@ import ListingCard from '@/components/ListingCard'
import { notFound } from 'next/navigation'
import Image from 'next/image'
import { Link } from '@/i18n/routing'
import { Phone, Globe, MapPin, Clock, Star, MessageSquare, Share2 } from 'lucide-react'
import { Phone, Globe, MapPin, Clock, Star, MessageSquare, Share2, ExternalLink, Navigation } from 'lucide-react'
import SaveButton from './SaveButton'
import DetailTracker from './DetailTracker'
import ListingGallery from './ListingGallery'
import StickyActionBar from './StickyActionBar'
import OpenStatusBadge from './OpenStatusBadge'
import type { Metadata } from 'next'
import { SITE_URL } from '@/lib/seo'
import { SITE_URL, buildAlternates } from '@/lib/seo'
interface DetailPageProps {
params: Promise<{ locale: string; category: string; slug: string }>
@@ -25,14 +28,17 @@ export async function generateMetadata({ params }: DetailPageProps): Promise<Met
const description = locale === 'ru' ? listing.descriptionRu : locale === 'en' ? listing.descriptionEn : listing.descriptionTr
const title = `${name} — Marmaris Local`
const image = listing.images?.[0]?.url
const categorySlug = listing.category?.slug || 'isletme'
const pathname = `/${locale}/${categorySlug}/${listing.slug}`
return {
title,
description,
alternates: buildAlternates(pathname),
openGraph: {
title,
description,
url: `${SITE_URL}/${locale}/${listing.category?.slug || 'isletme'}/${listing.slug}`,
url: `${SITE_URL}${pathname}`,
siteName: 'Marmaris Local',
type: 'website',
...(image ? { images: [{ url: image }] } : {}),
@@ -41,6 +47,7 @@ export async function generateMetadata({ params }: DetailPageProps): Promise<Met
card: 'summary_large_image',
title,
description,
...(image ? { images: [image] } : {}),
},
}
}
@@ -91,9 +98,10 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
const priceSymbols = '₺'.repeat(listing.priceRange)
const categorySlug = listing.category?.slug || 'isletme'
const rawId = listing.id; // örn: "gm-ChIJY_2Q8tbJvxQRs7xwaSne0is"
const hasGooglePlaceId = rawId.startsWith('gm-');
const placeId = hasGooglePlaceId ? rawId.replace('gm-', '') : null;
const rawId = listing.id
const hasGooglePlaceId = rawId.startsWith('gm-')
const placeId = hasGooglePlaceId ? rawId.replace('gm-', '') : null
// Format WhatsApp Link
const getWhatsAppLink = (number: string) => {
const cleanNum = number.replace(/\D/g, '')
@@ -106,6 +114,11 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
return `https://wa.me/?text=${text}`
}
// Direct Directions URL
const directionsUrl = listing.latitude && listing.longitude
? `https://www.google.com/maps/dir/?api=1&destination=${listing.latitude},${listing.longitude}`
: `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(listing.address + ', Marmaris')}`
// schema.org LocalBusiness structured data
const schemaTypeByCategory: Record<string, string> = {
restoran: 'Restaurant',
@@ -137,8 +150,24 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
: {}),
}
const categoryName = locale === 'ru' ? listing.category?.nameRu : locale === 'en' ? listing.category?.nameEn : listing.category?.nameTr
const homeLabel = locale === 'en' ? 'Home' : locale === 'ru' ? 'Главная' : 'Ana Sayfa'
const breadcrumbLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: homeLabel, item: `${SITE_URL}/${locale}` },
{ '@type': 'ListItem', position: 2, name: categoryName || categorySlug, item: `${SITE_URL}/${locale}/${categorySlug}` },
{ '@type': 'ListItem', position: 3, name, item: `${SITE_URL}/${locale}/${categorySlug}/${listing.slug}` },
],
}
return (
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink pb-20 lg:pb-12">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
@@ -148,47 +177,23 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 flex-1 space-y-10">
{/* Breadcrumb */}
<div className="text-xs font-mono uppercase tracking-wider text-shutter flex items-center gap-2">
<span className="hover:text-turquoise transition-colors">marmaris local</span>
<nav aria-label="Breadcrumb" className="text-xs font-mono uppercase tracking-wider text-stone/80 flex items-center gap-2 flex-wrap">
<Link href="/" className="hover:text-turquoise transition-colors">marmaris local</Link>
<span>/</span>
<span className="hover:text-turquoise transition-colors">
{locale === 'ru' ? listing.category?.nameRu : locale === 'en' ? listing.category?.nameEn : listing.category?.nameTr}
</span>
<Link href={`/${categorySlug}`} className="hover:text-turquoise transition-colors">
{categoryName}
</Link>
<span>/</span>
<span className="text-pine font-bold">{name}</span>
</div>
</nav>
{/* Hero Details Block */}
<div className="bg-paper rounded-3xl border border-pine/8 p-6 sm:p-10 shadow-sm space-y-8">
<div className="flex flex-col lg:flex-row gap-10">
{/* Left: Gallery Panel */}
<div className="flex-1 space-y-4">
<div className="aspect-[16/10] w-full relative rounded-2xl overflow-hidden bg-stone-deep shadow-sm">
<Image
src={listing.images && listing.images.length > 0 ? listing.images[0].url : 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&auto=format&fit=crop&q=80'}
alt={name}
fill
priority
className="object-cover"
/>
</div>
{/* Thumbnails if multiple images exist */}
{listing.images && listing.images.length > 1 && (
<div className="grid grid-cols-4 gap-4">
{listing.images.slice(1, 5).map((img, idx) => (
<div key={img.id} className="aspect-square relative rounded-xl overflow-hidden bg-stone-deep border border-pine/5 shadow-sm">
<Image
src={img.url}
alt={`${name} thumbnail ${idx + 1}`}
fill
className="object-cover hover:scale-105 transition duration-300"
/>
</div>
))}
</div>
)}
{/* Left: Gallery Panel with Interactive Lightbox */}
<div className="flex-1">
<ListingGallery images={listing.images} title={name} />
</div>
{/* Right: Info Panel */}
@@ -196,7 +201,7 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
<div className="space-y-4">
<div className="flex flex-wrap gap-2.5 items-center">
<span className="text-[10px] font-mono text-bougainvillea font-bold uppercase tracking-wider bg-bougainvillea/5 border border-bougainvillea/10 px-2.5 py-1 rounded-full">
{locale === 'ru' ? listing.category?.nameRu : locale === 'en' ? listing.category?.nameEn : listing.category?.nameTr}
{categoryName}
</span>
{listing.isLocalApproved && (
@@ -204,6 +209,8 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
{t('approved')}
</span>
)}
<OpenStatusBadge openingHours={listing.openingHours} locale={locale} />
</div>
<h1 className="font-heading font-extrabold text-2xl sm:text-4xl text-pine leading-tight lowercase">
@@ -229,49 +236,16 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
{description ? description : <span className="italic text-ink/50">{t('no_description', { defaultValue: 'Bu işletme için henüz bir açıklama eklenmemiştir.' })}</span>}
</div>
{/* Contact Info (Only if at least one exists) */}
{(listing.phone || listing.website || listing.instagram) && (
<div className="space-y-4 pt-6 border-t border-pine/10">
<h3 className="font-heading font-bold text-xs text-pine uppercase tracking-wider">{t('contact')}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{listing.phone && (
<a id="listing-contact-phone" href={`tel:${listing.phone}`} className="flex items-center gap-3 p-3 rounded-xl border border-pine/10 hover:border-turquoise/30 hover:bg-turquoise/5 transition-colors group">
<div className="w-8 h-8 rounded-full bg-pine/5 flex items-center justify-center group-hover:bg-turquoise/10 transition-colors">
<Phone className="w-4 h-4 text-pine group-hover:text-turquoise transition-colors" />
</div>
<div className="flex flex-col">
<span className="text-[10px] font-bold text-pine/50 uppercase tracking-wider">{t('phone')}</span>
<span className="text-sm font-medium text-ink group-hover:text-pine transition-colors">{listing.phone}</span>
</div>
</a>
)}
{listing.website && (
<a id="listing-contact-website" href={listing.website.startsWith('http') ? listing.website : `https://${listing.website}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-3 p-3 rounded-xl border border-pine/10 hover:border-turquoise/30 hover:bg-turquoise/5 transition-colors group">
<div className="w-8 h-8 rounded-full bg-pine/5 flex items-center justify-center group-hover:bg-turquoise/10 transition-colors">
<Globe className="w-4 h-4 text-pine group-hover:text-turquoise transition-colors" />
</div>
<div className="flex flex-col">
<span className="text-[10px] font-bold text-pine/50 uppercase tracking-wider">{t('website')}</span>
<span className="text-sm font-medium text-ink group-hover:text-pine transition-colors truncate max-w-[150px]">
{listing.website.replace(/^https?:\/\//, '').replace(/\/$/, '')}
</span>
</div>
</a>
)}
</div>
</div>
)}
{/* Contact & Actions Grid */}
<div className="space-y-4 pt-2">
<h3 className="font-heading font-bold text-xs uppercase tracking-wider text-shutter">{t('contact')}</h3>
<div className="space-y-4 pt-4 border-t border-pine/10">
<h3 className="font-heading font-bold text-xs uppercase tracking-wider text-pine/80">{t('contact')}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{listing.phone && (
<a
id="listing-contact-phone"
href={`tel:${listing.phone}`}
className="flex items-center justify-center gap-2 bg-pine hover:bg-pine/90 text-stone font-bold text-xs py-3.5 px-4 rounded-xl transition"
className="flex items-center justify-center gap-2 bg-pine hover:bg-pine/90 text-stone font-bold text-xs py-3.5 px-4 rounded-xl transition min-h-[44px]"
>
<Phone className="w-4 h-4" />
{t('call')}
@@ -284,7 +258,7 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
href={getWhatsAppLink(listing.whatsapp)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 bg-turquoise hover:bg-turquoise/90 text-paper font-bold text-xs py-3.5 px-4 rounded-xl transition"
className="flex items-center justify-center gap-2 bg-turquoise hover:bg-turquoise/90 text-paper font-bold text-xs py-3.5 px-4 rounded-xl transition min-h-[44px]"
>
<MessageSquare className="w-4 h-4" />
{t('whatsapp')}
@@ -298,14 +272,14 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
href={listing.menuUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 bg-paper text-pine font-bold text-xs py-3.5 px-4 rounded-xl border border-pine/15 hover:bg-stone/50 transition sm:col-span-2"
className="flex items-center justify-center gap-2 bg-paper text-pine font-bold text-xs py-3.5 px-4 rounded-xl border border-pine/15 hover:bg-stone/50 transition sm:col-span-2 min-h-[44px]"
>
<Globe className="w-4 h-4 text-turquoise" />
{t('viewMenu')}
</a>
)}
{/* Save Button (Favorites client action) */}
{/* Save Button */}
<SaveButton
listingId={listing.id}
saveLabel={t('save')}
@@ -313,10 +287,10 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
/>
</div>
{/* External links */}
<div className="flex flex-wrap gap-5 pt-2 text-xs font-semibold text-shutter">
{/* External links & Share */}
<div className="flex flex-wrap gap-5 pt-2 text-xs font-semibold text-pine/80">
{listing.website && (
<a href={listing.website} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1.5 hover:text-turquoise transition">
<a href={listing.website.startsWith('http') ? listing.website : `https://${listing.website}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1.5 hover:text-turquoise transition">
<Globe className="w-4 h-4 text-turquoise" />
{t('website')}
</a>
@@ -348,7 +322,7 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
<MapPin className="w-4 h-4 text-turquoise" />
<span>{t('address')}</span>
</div>
<p className="text-xs text-ink/75 font-medium leading-relaxed">
<p className="text-xs text-ink/80 font-medium leading-relaxed">
{listing.address}
</p>
</div>
@@ -359,7 +333,7 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
<Clock className="w-4 h-4 text-turquoise" />
<span>{t('hours')}</span>
</div>
<div className="text-xs text-ink/75 font-medium">
<div className="text-xs text-ink/80 font-medium font-mono">
{listing.openingHours ? (
<p>{(listing.openingHours as any).all || t('noHours')}</p>
) : (
@@ -368,12 +342,23 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
</div>
</div>
{/* Map Frame */}
{/* Map Frame with Directions Trigger */}
{((listing.latitude && listing.longitude) || hasGooglePlaceId) && (
<div className="space-y-2">
<div className="flex items-center gap-2 font-heading font-bold text-xs text-pine uppercase tracking-wider">
<Globe className="w-4 h-4 text-turquoise" />
<span>{t('location')}</span>
<div className="flex items-center justify-between font-heading font-bold text-xs text-pine uppercase tracking-wider">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-turquoise" />
<span>{t('location')}</span>
</div>
<a
href={directionsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-[10px] font-mono text-turquoise hover:underline flex items-center gap-1 font-bold lowercase"
>
<Navigation className="w-3 h-3" />
<span>Haritalarda </span>
</a>
</div>
<div className="rounded-xl overflow-hidden border border-pine/8 aspect-[16/10] sm:aspect-auto sm:h-36 relative group">
<iframe
@@ -407,7 +392,7 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
</div>
<div>
<h3 className="font-heading font-bold text-sm text-pine lowercase">@{instagramFeed.handle}</h3>
<p className="text-[10px] font-mono text-shutter uppercase tracking-wider mt-0.5">{t('instagramFeed')}</p>
<p className="text-[10px] font-mono text-stone/80 uppercase tracking-wider mt-0.5">{t('instagramFeed')}</p>
</div>
</div>
@@ -420,10 +405,12 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
rel="noopener noreferrer"
className="group relative aspect-square rounded-2xl overflow-hidden bg-stone border border-pine/5 shadow-sm block"
>
<img
<Image
src={post.imageUrl}
alt={post.caption || 'Instagram post'}
className="w-full h-full object-cover group-hover:scale-103 transition duration-500"
fill
sizes="(max-width: 768px) 100vw, 33vw"
className="object-cover group-hover:scale-103 transition duration-500"
/>
<div className="absolute inset-0 bg-pine/70 opacity-0 group-hover:opacity-100 transition-opacity duration-300 p-4 flex flex-col justify-end">
<p className="text-[11px] text-stone font-medium line-clamp-3 leading-relaxed">
@@ -436,14 +423,14 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
</div>
)}
{/* Newly Added Widget (Phase 2) */}
{/* Newly Added Widget */}
{latestListings.length > 0 && (
<div className="bg-paper rounded-3xl border border-pine/8 p-6 sm:p-10 shadow-sm space-y-6">
<div className="border-b border-dashed border-pine/8 pb-4">
<h3 className="text-lg font-heading font-extrabold text-pine lowercase">
{t('newlyAdded')}
</h3>
<p className="text-ink/65 text-xs font-medium mt-0.5">
<p className="text-ink/75 text-xs font-medium mt-0.5">
{t('newlyAddedSubtitle')}
</p>
</div>
@@ -459,12 +446,12 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
href={`/${catSlug}/${newest.slug}`}
className="flex gap-4 items-center group bg-stone/20 p-3.5 rounded-2xl border border-pine/5 hover:border-turquoise/25 transition duration-150"
>
<div className="w-16 h-16 rounded-xl overflow-hidden bg-stone shrink-0 border border-pine/8">
<img src={newestImg} alt={newestName} className="w-full h-full object-cover group-hover:scale-105 transition duration-300" />
<div className="w-16 h-16 rounded-xl overflow-hidden bg-stone shrink-0 border border-pine/8 relative">
<Image src={newestImg} alt={newestName} fill sizes="64px" className="object-cover group-hover:scale-105 transition duration-300" />
</div>
<div>
<h4 className="font-heading font-bold text-xs text-pine lowercase line-clamp-1 group-hover:text-turquoise transition-colors">{newestName}</h4>
<p className="text-[10px] text-shutter font-mono mt-0.5">{newest.neighborhood?.nameTr}</p>
<p className="text-[10px] text-stone/80 font-mono mt-0.5">{newest.neighborhood?.nameTr}</p>
</div>
</Link>
)
@@ -488,6 +475,20 @@ export default async function ListingDetailPage({ params }: DetailPageProps) {
)}
</main>
{/* Pinned Mobile Action Bar */}
<StickyActionBar
phone={listing.phone}
whatsapp={listing.whatsapp}
latitude={listing.latitude}
longitude={listing.longitude}
address={listing.address}
labels={{
call: t('call'),
whatsapp: t('whatsapp'),
directions: t('address'),
}}
/>
</div>
)
}
+68 -1
View File
@@ -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">
+1 -1
View File
@@ -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>
+9
View File
@@ -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)
+7 -6
View File
@@ -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>
+4 -2
View File
@@ -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 (
+9 -46
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// 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>
)}
+1 -1
View File
@@ -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 }> }) {
+1 -1
View File
@@ -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 }> }) {
+3
View File
@@ -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>
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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) {
+29
View File
@@ -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
View File
@@ -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>
)
}
+1 -1
View File
@@ -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) {
+16
View File
@@ -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)
}
+6 -3
View File
@@ -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>
+159
View File
@@ -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>
)
}
+65
View File
@@ -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>
)
}
+86
View File
@@ -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>
)
}
+16
View File
@@ -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)
}
+119 -53
View File
@@ -4,9 +4,29 @@ import { redirect } from "next/navigation"
import { mockDb } from '@/lib/mockDb'
import { uploadToOpeninary } from '@/lib/openinary'
import { requireAdmin } from '@/lib/auth'
import { HONEYPOT_FIELD_NAME } from '@/components/HoneypotField'
import { checkRateLimit } from '@/lib/rateLimit'
import { sendTelegramMessage, formatContactMessageNotification, formatBusinessSubmissionNotification } from '@/lib/telegram'
import { headers } from 'next/headers'
import { revalidatePath } from 'next/cache'
async function getClientIp() {
const h = await headers()
return h.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
}
export async function submitBusinessAction(formData: FormData) {
if (formData.get(HONEYPOT_FIELD_NAME)) {
// Bot trap tripped — pretend success so it doesn't learn to skip this field.
return { success: true, error: undefined }
}
const ip = await getClientIp()
if (!checkRateLimit(`submit-business:${ip}`, 5, 10 * 60_000)) {
return { error: 'Çok fazla başvuru gönderildi. Lütfen daha sonra tekrar deneyin.' }
}
const businessName = formData.get('businessName') as string
const categoryId = formData.get('categoryId') as string
const neighborhoodId = formData.get('neighborhoodId') as string
@@ -45,11 +65,36 @@ export async function submitBusinessAction(formData: FormData) {
imageUrl
})
const [categories, neighborhoods] = await Promise.all([mockDb.getCategories(), mockDb.getNeighborhoods()])
await sendTelegramMessage(
formatBusinessSubmissionNotification({
businessName,
categoryName: categories.find(c => c.id === categoryId)?.nameTr || categoryId,
neighborhoodName: neighborhoods.find(n => n.id === neighborhoodId)?.nameTr || neighborhoodId,
address,
phone,
whatsapp,
description,
contactName,
contactEmail,
ip,
})
)
revalidatePath('/admin/submissions')
return { success: true, error: undefined }
}
export async function submitContactMessageAction(formData: FormData) {
if (formData.get(HONEYPOT_FIELD_NAME)) {
return { success: true, error: undefined }
}
const ip = await getClientIp()
if (!checkRateLimit(`submit-contact:${ip}`, 5, 10 * 60_000)) {
return { error: 'Çok fazla mesaj gönderildi. Lütfen daha sonra tekrar deneyin.' }
}
const name = formData.get('name') as string
const email = formData.get('email') as string
const subject = formData.get('subject') as string
@@ -66,11 +111,14 @@ export async function submitContactMessageAction(formData: FormData) {
message
})
await sendTelegramMessage(formatContactMessageNotification({ name, email, subject, message, ip }))
revalidatePath('/admin/messages')
return { success: true, error: undefined }
}
export async function approveSubmissionAction(id: string) {
await requireAdmin()
const submission = await mockDb.getSubmissionById(id)
if (!submission) return { error: 'Başvuru bulunamadı' }
@@ -110,12 +158,14 @@ export async function approveSubmissionAction(id: string) {
}
export async function rejectSubmissionAction(id: string) {
await requireAdmin()
await mockDb.updateSubmissionStatus(id, 'REJECTED')
revalidatePath('/admin/submissions')
return { success: true, error: undefined }
}
export async function deleteListingAction(id: string) {
await requireAdmin()
await mockDb.deleteListing(id)
revalidatePath('/admin/listings')
revalidatePath('/restaurants')
@@ -125,6 +175,7 @@ export async function deleteListingAction(id: string) {
}
export async function restoreListingAction(id: string) {
await requireAdmin()
await mockDb.restoreListing(id)
revalidatePath('/admin/trash')
revalidatePath('/admin/listings')
@@ -132,6 +183,7 @@ export async function restoreListingAction(id: string) {
}
export async function hardDeleteListingAction(id: string) {
await requireAdmin()
try {
await mockDb.hardDeleteListing(id)
revalidatePath('/admin/trash')
@@ -145,12 +197,14 @@ export async function hardDeleteListingAction(id: string) {
}
export async function markMessageReadAction(id: string) {
await requireAdmin()
await mockDb.markMessageAsRead(id)
revalidatePath('/admin/messages')
return { success: true, error: undefined }
}
export async function createOrUpdateListingAction(formData: FormData) {
await requireAdmin()
const id = formData.get('id') as string | null
const slug = formData.get('slug') as string
const categoryId = formData.get('categoryId') as string
@@ -264,6 +318,7 @@ export async function createOrUpdateListingAction(formData: FormData) {
// Blog Actions
export async function deleteBlogPostAction(id: string) {
await requireAdmin()
await mockDb.deleteBlogPost(id)
revalidatePath('/admin/blog')
revalidatePath('/blog')
@@ -271,6 +326,7 @@ export async function deleteBlogPostAction(id: string) {
}
export async function createOrUpdateBlogPostAction(formData: FormData) {
await requireAdmin()
const id = formData.get('id') as string | null
const slug = formData.get('slug') as string
const titleTr = formData.get('titleTr') as string
@@ -337,6 +393,7 @@ export async function createOrUpdateBlogPostAction(formData: FormData) {
// Collections Actions
export async function deleteCollectionAction(id: string) {
await requireAdmin()
await mockDb.deleteCollection(id)
revalidatePath('/admin/collections')
revalidatePath('/collections')
@@ -344,6 +401,7 @@ export async function deleteCollectionAction(id: string) {
}
export async function createOrUpdateCollectionAction(formData: FormData) {
await requireAdmin()
const id = formData.get('id') as string | null
const slug = formData.get('slug') as string
const titleTr = formData.get('titleTr') as string
@@ -402,6 +460,7 @@ export async function createOrUpdateCollectionAction(formData: FormData) {
// Events Actions (Phase 3)
export async function deleteEventAction(id: string) {
await requireAdmin()
await mockDb.deleteEvent(id)
revalidatePath('/admin/events')
revalidatePath('/events')
@@ -409,6 +468,7 @@ export async function deleteEventAction(id: string) {
}
export async function createOrUpdateEventAction(formData: FormData) {
await requireAdmin()
const id = formData.get('id') as string | null
const slug = formData.get('slug') as string
const listingId = formData.get('listingId') as string | null || null
@@ -475,6 +535,7 @@ export async function createOrUpdateEventAction(formData: FormData) {
}
// Neighborhood Actions
export async function deleteNeighborhoodAction(id: string) {
await requireAdmin()
try {
const listings = await mockDb.getListings({ neighborhoodId: id })
if (listings && listings.length > 0) {
@@ -493,6 +554,7 @@ export async function deleteNeighborhoodAction(id: string) {
}
export async function createOrUpdateNeighborhoodAction(formData: FormData) {
await requireAdmin()
const id = formData.get('id') as string | null
const slug = formData.get('slug') as string
const nameTr = formData.get('nameTr') as string
@@ -525,72 +587,70 @@ export async function createOrUpdateNeighborhoodAction(formData: FormData) {
// AI Itinerary Planner Actions (Phase 3)
import crypto from 'crypto'
import { generateItineraryContent, type ItineraryCandidate } from '@/lib/deepseek'
async function generateMockItinerary(days: number, style: string, neighborhoodSlugs: string[]) {
const allListings = await mockDb.getListings()
const matchingListings = allListings.filter(l =>
!l.deletedAt && l.isLocalApproved &&
(neighborhoodSlugs.length === 0 ||
(l.neighborhood && neighborhoodSlugs.includes(l.neighborhood.slug)))
)
const pool = matchingListings.length > 0 ? matchingListings : allListings.filter(l => !l.deletedAt && l.isLocalApproved)
const MAX_ITINERARY_CANDIDATES = 30
let md = `# marmaris local kişisel gezi rotası 🌴\n\n`
md += `**gün sayısı:** ${days} gün | **gezi tarzı:** ${style === 'gastronomy' ? 'gurme' : style === 'relaxation' ? 'dinlenme' : 'macera'} | **keşif bölgeleri:** ${neighborhoodSlugs.join(', ')}\n\n`
md += `bu rota, marmaris local topluluğu tarafından onaylanmış yerel işletmeler temel alınarak oluşturulmuştur.\n\n---\n\n`
for (let day = 1; day <= days; day++) {
md += `## 🗓️ gün ${day}\n\n`
const dayListings = [...pool].sort(() => 0.5 - Math.random()).slice(0, 3)
if (dayListings.length >= 1) {
md += `### 🌅 sabah: kahvaltı ve başlangıç\n`
md += `güne yerel onaylı **[${dayListings[0].nameTr}](/${dayListings[0].category?.slug || 'isletme'}/${dayListings[0].slug})** işletmesinde başlayın. \n`
md += `> **yerel ipucu:** ${dayListings[0].descriptionTr}\n\n`
}
if (dayListings.length >= 2) {
md += `### ☀️ öğle: keşif zamanı\n`
md += `öğleden sonra **[${dayListings[1].nameTr}](/${dayListings[1].category?.slug || 'isletme'}/${dayListings[1].slug})** mekanına uğrayın ve çevreyi keşfedin.\n`
md += `> **editör notu:** ${dayListings[1].address}\n\n`
}
if (dayListings.length >= 3) {
md += `### 🌌 akşam: gün batımı ve akşam yemeği\n`
md += `akşamı şık bir akşam yemeğiyle taçlandırın: **[${dayListings[2].nameTr}](/${dayListings[2].category?.slug || 'isletme'}/${dayListings[2].slug})**.\n`
md += `> **özel detaylar:** rating: ★${dayListings[2].rating} • tel: ${dayListings[2].phone || 'belirtilmemiş'}\n\n`
}
md += `---\n\n`
}
md += `*not: seyahatiniz boyunca yerel rehberimizdeki işletmeleri ziyaret etmeyi ve favorilerinize eklemeyi unutmayın!*`
return {
content: md,
listingIds: pool.map(l => l.id)
}
}
export async function generateItineraryAction(days: number, style: string, neighborhoodSlugs: string[]) {
export async function generateItineraryAction(days: number, style: string, neighborhoodSlugs: string[], locale: string) {
const sorted = [...neighborhoodSlugs].sort()
const paramsHash = crypto.createHash('md5').update(JSON.stringify({ days, style, neighborhoodSlugs: sorted })).digest('hex')
const paramsHash = crypto.createHash('md5').update(JSON.stringify({ days, style, neighborhoodSlugs: sorted, locale })).digest('hex')
const existing = await mockDb.getItineraryByHash(paramsHash)
if (existing) {
return { success: true, id: existing.id }
}
// Generate new program
const { content, listingIds } = await generateMockItinerary(days, style, sorted)
// Cache hit above is free; only a genuinely new combination reaches the
// paid DeepSeek call, so rate-limit per IP to bound abuse cost.
const ip = await getClientIp()
if (!checkRateLimit(`itinerary:${ip}`, 10, 60 * 60_000)) {
return { success: false, error: 'Çok fazla plan oluşturuldu. Lütfen bir süre sonra tekrar deneyin.' }
}
const allListings = await mockDb.getListings()
const matchingListings = allListings.filter(l =>
!l.deletedAt && l.isLocalApproved &&
(sorted.length === 0 ||
(l.neighborhood && sorted.includes(l.neighborhood.slug)))
)
const pool = (matchingListings.length > 0 ? matchingListings : allListings.filter(l => !l.deletedAt && l.isLocalApproved))
.sort((a, b) => Number(b.isFeatured) - Number(a.isFeatured))
.slice(0, MAX_ITINERARY_CANDIDATES)
if (pool.length === 0) {
return { success: false, error: 'Seçtiğiniz bölgelerde yerel onaylı mekan bulunamadı.' }
}
const nameKey = locale === 'en' ? 'nameEn' : locale === 'ru' ? 'nameRu' : 'nameTr'
const descKey = locale === 'en' ? 'descriptionEn' : locale === 'ru' ? 'descriptionRu' : 'descriptionTr'
const candidates: ItineraryCandidate[] = pool.map(l => ({
id: l.id,
name: (l as any)[nameKey] || l.nameTr,
description: (l as any)[descKey] || l.descriptionTr,
category: l.category?.nameTr || '',
categorySlug: l.category?.slug || 'isletme',
neighborhood: l.neighborhood?.nameTr || '',
address: l.address,
priceRange: l.priceRange,
rating: l.rating ?? null,
slug: l.slug,
isFeatured: l.isFeatured,
}))
let content: string
try {
content = await generateItineraryContent({ days, style, locale, candidates })
} catch (e: any) {
console.error('DeepSeek itinerary generation error:', e)
return { success: false, error: 'Planınız oluşturulamadı, lütfen birazdan tekrar deneyin.' }
}
const newItin = await mockDb.createItinerary({
paramsHash,
params: { days, style, neighborhoodSlugs: sorted },
params: { days, style, neighborhoodSlugs: sorted, locale },
content,
listingIds
listingIds: pool.map(l => l.id)
})
return { success: true, id: newItin.id }
@@ -599,6 +659,7 @@ export async function generateItineraryAction(days: number, style: string, neigh
export async function createOrUpdateCategoryAction(formData: FormData) {
await requireAdmin()
const id = formData.get('id') as string
const slug = formData.get('slug') as string
const nameTr = formData.get('nameTr') as string
@@ -620,6 +681,7 @@ export async function createOrUpdateCategoryAction(formData: FormData) {
}
export async function deleteCategoryAction(formData: FormData) {
await requireAdmin()
const id = formData.get('id') as string
if (!id) return
await mockDb.deleteCategory(id)
@@ -627,12 +689,14 @@ export async function deleteCategoryAction(formData: FormData) {
}
export async function deleteSubmissionAction(id: string) {
await requireAdmin()
await mockDb.deleteSubmission(id)
revalidatePath('/admin/submissions')
return { success: true, error: undefined }
}
export async function deleteMessageAction(id: string) {
await requireAdmin()
await mockDb.deleteMessage(id)
revalidatePath('/admin/messages')
return { success: true, error: undefined }
@@ -664,6 +728,7 @@ export async function searchListingsAction(query: string) {
export async function saveWidgetPartnerAction(formData: FormData) {
await requireAdmin()
const id = formData.get('id') as string | null
const name = formData.get('name') as string
const url = formData.get('url') as string
@@ -697,6 +762,7 @@ export async function saveWidgetPartnerAction(formData: FormData) {
}
export async function deleteWidgetPartnerAction(id: string) {
await requireAdmin()
await mockDb.deleteWidgetPartner(id)
revalidatePath('/admin/widget-partners')
return { success: true, error: undefined }
+6 -2
View File
@@ -2,9 +2,13 @@ import { NextRequest, NextResponse } from 'next/server'
import { mockDb } from '@/lib/mockDb'
export async function POST(req: NextRequest) {
const secret = process.env.CRON_SECRET
if (!secret) {
console.error('CRON_SECRET env değişkeni tanımlı değil — instagram-sync endpoint devre dışı.')
return new NextResponse('Server misconfigured', { status: 500 })
}
const authHeader = req.headers.get('Authorization')
const secret = process.env.CRON_SECRET || 'secret-token-key-123'
if (authHeader !== `Bearer ${secret}`) {
return new NextResponse('Unauthorized', { status: 401 })
}
+6
View File
@@ -1,8 +1,14 @@
import { NextRequest, NextResponse } from 'next/server'
import { mockDb } from '@/lib/mockDb'
import { checkRateLimit } from '@/lib/rateLimit'
export async function POST(req: NextRequest) {
try {
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
if (!checkRateLimit(`events:${ip}`, 30, 60_000)) {
return NextResponse.json({ error: 'Too many requests' }, { status: 429 })
}
const body = await req.json()
const { listingId, actionType } = body
+27
View File
@@ -0,0 +1,27 @@
import { ImageResponse } from 'next/og'
import { BrandBadge } from '@/lib/ogImage'
export const size = { width: 180, height: 180 }
export const contentType = 'image/png'
// iOS masks apple-touch-icon into a rounded square over a solid background —
// unlike the browser favicon, this can't be a badge floating on transparency.
export default function AppleIcon() {
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
background: '#123238',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<BrandBadge size={140} />
</div>
),
size
)
}
+9
View File
@@ -0,0 +1,9 @@
import { ImageResponse } from 'next/og'
import { BrandBadge } from '@/lib/ogImage'
export const size = { width: 64, height: 64 }
export const contentType = 'image/png'
export default function Icon() {
return new ImageResponse(<BrandBadge size={64} ringWidth={2} />, size)
}
+41 -2
View File
@@ -64,13 +64,32 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
console.error('Sitemap listings fetch error:', e)
}
// 5. Neighborhood pages
// 5. Neighborhood pages & Programmatic Neighborhood x Category landing pages
try {
const neighborhoods = await mockDb.getNeighborhoods()
const categories = await mockDb.getCategories()
for (const neighborhood of neighborhoods) {
entries.push(
...localizedEntries(`/neighborhood/${neighborhood.slug}`, { changeFrequency: 'monthly', priority: 0.4 })
...localizedEntries(`/neighborhood/${neighborhood.slug}`, { changeFrequency: 'monthly', priority: 0.5 })
)
// Programmatic Neighborhood x Category combinations
for (const category of categories) {
const matchingListings = await mockDb.getListings({
neighborhoodId: neighborhood.id,
categoryId: category.id,
})
// Only include indexable programmatic pages in sitemap (at least 2 listings)
if (matchingListings.length >= 2) {
entries.push(
...localizedEntries(`/neighborhood/${neighborhood.slug}/${category.slug}`, {
changeFrequency: 'weekly',
priority: 0.7,
})
)
}
}
}
} catch (e) {
console.error('Sitemap neighborhoods fetch error:', e)
@@ -108,5 +127,25 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
console.error('Sitemap blog posts fetch error:', e)
}
// 8. AI-generated itineraries (long-tail SEO pages, PRD §3.3)
// Each plan is generated in a single language (see params.locale) — unlike
// other content types it has no translated counterpart, so it gets one
// sitemap entry for its own locale rather than the tr/en/ru trio.
try {
entries.push(...localizedEntries('/plan-olustur', { changeFrequency: 'monthly', priority: 0.6 }))
const itineraries = await mockDb.getItineraries()
for (const itinerary of itineraries) {
const itinLocale = LOCALES.includes((itinerary.params as any)?.locale) ? (itinerary.params as any).locale : 'tr'
entries.push({
url: `${SITE_URL}/${itinLocale}/plan/${itinerary.id}`,
lastModified: itinerary.createdAt,
changeFrequency: 'yearly',
priority: 0.3,
})
}
} catch (e) {
console.error('Sitemap itineraries fetch error:', e)
}
return entries
}