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,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "marmarislocal-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,4 +8,5 @@ docs
|
||||
README.md
|
||||
AGENTS.md
|
||||
fix-openinary-v2.ts
|
||||
create-admin.ts
|
||||
test-db.js
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"buildPath": "code"
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# Product
|
||||
|
||||
<!-- impeccable:product-schema 1 -->
|
||||
|
||||
## Platform
|
||||
|
||||
web
|
||||
|
||||
## Users
|
||||
International tourists (primarily British, Russian, and Turkish tourists visiting Marmaris) seeking curated, authentic local places and experiences; local business owners (restaurants, apart hotels, boat rentals, tours, dive centers) applying for listing curation.
|
||||
|
||||
## Product Purpose
|
||||
Marmaris Local is a curated directory and guide ("Turistin göremediği yerel bilgi") that brings together Marmaris's best restaurants, apart hotels, boat rentals, dive centers, and local tour operators under a single trusted platform.
|
||||
|
||||
## Positioning
|
||||
Not a generic user-review aggregator (like Yelp or TripAdvisor). Every listing features a human editorial curation layer ("Yerel Onaylı" / "Locally Approved" stamp), guaranteeing local verification and authentic editorial recommendations.
|
||||
|
||||
## Operating Context
|
||||
Multilingual (TR/EN/RU) mobile-first browser exploration for tourists on-the-go in Marmaris or planning before travel; admin dashboard workflow for local business submission review, approval, and listing management.
|
||||
|
||||
## Capabilities and Constraints
|
||||
- Multilingual content support (TR, EN, RU) via `next-intl`.
|
||||
- Listing categories (Restoranlar, Apartlar, Dalış, Tekne Kiralama, Tur Operatörü, Transfer, vb.) and Neighborhoods (Yat Limanı, İçmeler, Armutalan, Siteler, Turunç).
|
||||
- Business submission form (`/isletme-ekle`) with admin approval pipeline.
|
||||
- Contact form (`/iletisim`).
|
||||
- Interactive map integration with Leaflet / OpenStreetMap.
|
||||
- Cloudinary image gallery integration.
|
||||
- MVP ratings are admin-assigned curation scores (no public user reviews in MVP).
|
||||
- Multi-city ready architecture (`city` field defaulted to `"marmaris"`).
|
||||
|
||||
## Brand Commitments
|
||||
- Palette: Pine Night (`#123238`), Bay Turquoise (`#2E9C9A`), Shutter Blue (`#4F7C93`), Golden Hour (`#E8A23D`), Bougainvillea (`#E85D6E`), Limestone (`#EDEEE3`).
|
||||
- Typography: Unbounded (Headings, 800/600), Golos Text (Body), IBM Plex Mono (Data: prices, hours, phones).
|
||||
- Signature Mark: Circular "Yerel Onaylı" stamp accompanying curated listings.
|
||||
|
||||
## Evidence on Hand
|
||||
- `docs/prd.md` (Detailed product requirement document)
|
||||
- `docs/marmaris-local-brand.html` (Brand mockup and style specifications)
|
||||
- `docs/prd-2.md` & `docs/prd-3.md` (Supplementary specs)
|
||||
|
||||
## Product Principles
|
||||
1. **Editorial Trust First:** Every listing carries local verification ("Yerel Onaylı"); curation over noise.
|
||||
2. **First-Class Multilingual Experience:** RU and EN content must feel natively written, not an auto-translated afterthought.
|
||||
3. **Mobile-First Utility:** Instant access to phone/WhatsApp, hours, directions, and prices for tourists exploring on mobile devices.
|
||||
4. **Authentic Local Aesthetics:** Deep coastal hues and distinct typography that reflect Marmaris's natural pine and turquoise environment.
|
||||
|
||||
## Accessibility & Inclusion
|
||||
Full WCAG AA compliance, semantic HTML5, responsive layout across mobile and desktop viewports, clear focus indicators, and accessible color contrast ratios.
|
||||
@@ -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,13 +342,24 @@ 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">
|
||||
<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
|
||||
width="100%"
|
||||
@@ -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
|
||||
<Image
|
||||
src={post.coverImage}
|
||||
alt={title}
|
||||
className="w-full h-full object-cover"
|
||||
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)
|
||||
}
|
||||
+119
-53
@@ -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 MAX_ITINERARY_CANDIDATES = 30
|
||||
|
||||
const pool = matchingListings.length > 0 ? matchingListings : allListings.filter(l => !l.deletedAt && l.isLocalApproved)
|
||||
|
||||
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 }
|
||||
|
||||
@@ -2,9 +2,13 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
import { mockDb } from '@/lib/mockDb'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const authHeader = req.headers.get('Authorization')
|
||||
const secret = process.env.CRON_SECRET || 'secret-token-key-123'
|
||||
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')
|
||||
if (authHeader !== `Bearer ${secret}`) {
|
||||
return new NextResponse('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
+7
-23
@@ -14,7 +14,7 @@ export default async function Footer() {
|
||||
const nav = await getTranslations('nav')
|
||||
|
||||
return (
|
||||
<footer className="bg-pine text-stone/70 border-t border-white/10 pt-16 pb-8 font-sans">
|
||||
<footer className="bg-pine text-stone/85 border-t border-white/10 pt-16 pb-8 font-sans">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-10 mb-12">
|
||||
|
||||
@@ -29,7 +29,7 @@ export default async function Footer() {
|
||||
marmaris <span className="text-turquoise">local</span>
|
||||
</h2>
|
||||
</Link>
|
||||
<p className="text-xs text-stone/50 max-w-sm font-medium leading-relaxed">
|
||||
<p className="text-xs text-stone/80 max-w-sm font-medium leading-relaxed">
|
||||
{t('about')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -91,35 +91,19 @@ export default async function Footer() {
|
||||
|
||||
</div>
|
||||
|
||||
{/* Bottom Copyright & Branding */}
|
||||
<div className="border-t border-white/5 pt-8 flex flex-col sm:flex-row items-center justify-between text-xs text-stone/40 gap-4">
|
||||
{/* Bottom Copyright & Mandatory Branding */}
|
||||
<div className="border-t border-white/10 pt-8 flex flex-col sm:flex-row items-center justify-between text-xs text-stone/75 gap-4">
|
||||
<p>{t('rights')}</p>
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
<span className="opacity-75">Created by</span>
|
||||
<span className="opacity-90">Created by</span>
|
||||
<a
|
||||
href="https://ayris.tech"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-stone hover:text-turquoise transition-colors duration-200 hover:underline underline-offset-4"
|
||||
className="text-stone hover:text-turquoise transition-colors duration-200 hover:underline underline-offset-4 font-semibold"
|
||||
>
|
||||
Ayris Tech
|
||||
ayris.tech
|
||||
</a>
|
||||
|
||||
{/* Google Bot & Arama Motoru İndeksleme Katmanı (Görünmez / Screen-Reader Safe) */}
|
||||
<span className="sr-only">
|
||||
Legal AI & Solutions:{' '}
|
||||
<a href="https://ayris.legal" rel="noopener">
|
||||
AyrisLegal
|
||||
</a>
|
||||
,{' '}
|
||||
<a href="https://ayrislegal.com" rel="noopener">
|
||||
Ayris Legal AI
|
||||
</a>
|
||||
. The best local spots, hidden from plain sight. Marmaris Local{' '}
|
||||
<a href="https://marmarislocal.com" rel="noopener">
|
||||
Marmaris Local
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Invisible spam trap: real visitors never see or fill this field, so any
|
||||
// submission with it filled in is almost certainly a bot. Server actions
|
||||
// check `formData.get(HONEYPOT_FIELD_NAME)` and silently no-op if it's set.
|
||||
export const HONEYPOT_FIELD_NAME = 'website_url'
|
||||
|
||||
export default function HoneypotField() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{ position: 'absolute', left: '-9999px', top: 0, width: 0, height: 0, overflow: 'hidden' }}
|
||||
>
|
||||
<label htmlFor={HONEYPOT_FIELD_NAME}>Website</label>
|
||||
<input type="text" id={HONEYPOT_FIELD_NAME} name={HONEYPOT_FIELD_NAME} tabIndex={-1} autoComplete="off" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+27
-11
@@ -112,23 +112,33 @@ export default function ListingCard({ listing }: { listing: Listing }) {
|
||||
? listing.images[0].url
|
||||
: 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=800&auto=format&fit=crop&q=80'
|
||||
|
||||
const saveAriaLabel = isSaved
|
||||
? locale === 'ru'
|
||||
? 'Удалить из избранного'
|
||||
: locale === 'en'
|
||||
? 'Remove from saved'
|
||||
: 'Favorilerden çıkar'
|
||||
: locale === 'ru'
|
||||
? 'Добавить в избранное'
|
||||
: locale === 'en'
|
||||
? 'Save listing'
|
||||
: 'Favorilere ekle'
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/${categorySlug}/${listing.slug}`}
|
||||
className="group bg-paper rounded-2xl border border-pine/8 overflow-hidden flex flex-col relative shadow-sm hover:shadow-md hover:border-turquoise/35 transition-all duration-300 transform hover:-translate-y-0.5"
|
||||
>
|
||||
{/* Heart Save Button */}
|
||||
<div className="group bg-paper rounded-2xl border border-pine/8 overflow-hidden flex flex-col relative shadow-sm hover:shadow-md hover:border-turquoise/35 transition-all duration-300 transform hover:-translate-y-0.5">
|
||||
{/* Heart Save Button - Decoupled from card link for HTML valid semantics & 44px min touch area */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSave}
|
||||
className="absolute top-4 left-4 z-20 w-9 h-9 rounded-full bg-paper/95 border border-pine/8 flex items-center justify-center shadow-sm hover:bg-stone transition duration-150 text-pine"
|
||||
aria-label="Kaydet"
|
||||
className="absolute top-3 left-3 z-20 min-w-[44px] min-h-[44px] rounded-full bg-paper/95 border border-pine/10 flex items-center justify-center shadow-sm hover:bg-stone transition duration-150 text-pine cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-turquoise"
|
||||
aria-label={saveAriaLabel}
|
||||
>
|
||||
<Heart className={`w-4.5 h-4.5 transition duration-150 ${isSaved ? 'fill-bougainvillea text-bougainvillea' : 'text-pine/70 hover:text-pine'}`} />
|
||||
<Heart className={`w-5 h-5 transition duration-150 ${isSaved ? 'fill-bougainvillea text-bougainvillea' : 'text-pine/70 hover:text-pine'}`} />
|
||||
</button>
|
||||
|
||||
{/* Local Approved Seal */}
|
||||
{listing.isLocalApproved && (
|
||||
<div className="absolute top-4 right-4 z-10 w-[52px] h-[52px] rounded-full border-[1.5px] border-turquoise bg-paper flex items-center justify-center -rotate-12 shadow-sm shrink-0">
|
||||
<div className="absolute top-3 right-3 z-10 w-[52px] h-[52px] rounded-full border-[1.5px] border-turquoise bg-paper flex items-center justify-center -rotate-12 shadow-sm shrink-0 pointer-events-none">
|
||||
<div className="absolute inset-[2.5px] rounded-full border border-dashed border-turquoise/60" />
|
||||
<span className="font-mono text-[7px] text-center font-bold text-turquoise tracking-tight leading-[1.1] uppercase">
|
||||
YEREL<br />ONAYLI
|
||||
@@ -136,6 +146,11 @@ export default function ListingCard({ listing }: { listing: Listing }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Card Content Link */}
|
||||
<Link
|
||||
href={`/${categorySlug}/${listing.slug}`}
|
||||
className="flex-1 flex flex-col focus:outline-none focus-visible:ring-2 focus-visible:ring-turquoise"
|
||||
>
|
||||
{/* Image Preview */}
|
||||
<div className="aspect-[4/3] w-full relative bg-stone-deep overflow-hidden">
|
||||
<Image
|
||||
@@ -162,7 +177,7 @@ export default function ListingCard({ listing }: { listing: Listing }) {
|
||||
</h3>
|
||||
|
||||
{/* Neighborhood */}
|
||||
<div className="text-xs text-ink/60 font-medium mb-4">
|
||||
<div className="text-xs text-ink/70 font-medium mb-4">
|
||||
{neighborhoodName}
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,11 +189,12 @@ export default function ListingCard({ listing }: { listing: Listing }) {
|
||||
{listing.rating && (
|
||||
<div className="flex items-center gap-1 text-gold font-bold">
|
||||
<Star className="w-3.5 h-3.5 fill-gold stroke-gold" />
|
||||
<span>★ {listing.rating.toFixed(1)}</span>
|
||||
<span>{listing.rating.toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
import { Link } from '@/i18n/routing'
|
||||
|
||||
export interface MapListing {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
categorySlug: string
|
||||
categoryName: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
isLocalApproved: boolean
|
||||
image?: string
|
||||
}
|
||||
|
||||
function buildPinIcon(color: string) {
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
html: `
|
||||
<svg width="28" height="36" viewBox="0 0 28 36" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 0C6.3 0 0 6.3 0 14c0 10.5 14 22 14 22s14-11.5 14-22C28 6.3 21.7 0 14 0z" fill="${color}" stroke="#FBFAF6" stroke-width="1.5"/>
|
||||
<circle cx="14" cy="14" r="5" fill="#FBFAF6"/>
|
||||
</svg>
|
||||
`,
|
||||
iconSize: [28, 36],
|
||||
iconAnchor: [14, 34],
|
||||
popupAnchor: [0, -32],
|
||||
})
|
||||
}
|
||||
|
||||
const approvedIcon = buildPinIcon('#E8A23D')
|
||||
const defaultIcon = buildPinIcon('#2E9C9A')
|
||||
|
||||
interface ListingsMapProps {
|
||||
listings: MapListing[]
|
||||
approvedLabel: string
|
||||
viewLabel: string
|
||||
}
|
||||
|
||||
export default function ListingsMap({ listings, approvedLabel, viewLabel }: ListingsMapProps) {
|
||||
if (listings.length === 0) return null
|
||||
|
||||
const center: [number, number] = [
|
||||
listings.reduce((sum, l) => sum + l.latitude, 0) / listings.length,
|
||||
listings.reduce((sum, l) => sum + l.longitude, 0) / listings.length,
|
||||
]
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={13}
|
||||
scrollWheelZoom={false}
|
||||
className="w-full h-full"
|
||||
style={{ background: '#EDEEE3' }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
{listings.map((listing) => (
|
||||
<Marker
|
||||
key={listing.id}
|
||||
position={[listing.latitude, listing.longitude]}
|
||||
icon={listing.isLocalApproved ? approvedIcon : defaultIcon}
|
||||
>
|
||||
<Popup>
|
||||
<div className="space-y-1.5 min-w-[160px]">
|
||||
{listing.isLocalApproved && (
|
||||
<span className="inline-block text-[10px] font-bold uppercase tracking-wider text-[#E8A23D]">
|
||||
{approvedLabel}
|
||||
</span>
|
||||
)}
|
||||
<div className="font-semibold text-sm text-[#123238]">{listing.name}</div>
|
||||
<div className="text-xs text-[#4F7C93]">{listing.categoryName}</div>
|
||||
<Link
|
||||
href={`/${listing.categorySlug}/${listing.slug}`}
|
||||
className="inline-block text-xs font-semibold text-[#2E9C9A] hover:underline"
|
||||
>
|
||||
{viewLabel} →
|
||||
</Link>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { MapListing } from './ListingsMap'
|
||||
|
||||
// Leaflet touches `window` at import time, so it can only load client-side.
|
||||
// next/dynamic with ssr:false is only valid from a Client Component in the
|
||||
// App Router, which is why this thin loader exists separately from the page.
|
||||
const ListingsMap = dynamic(() => import('./ListingsMap'), {
|
||||
ssr: false,
|
||||
loading: () => <div className="w-full h-full animate-pulse bg-pine/5" />,
|
||||
})
|
||||
|
||||
interface ListingsMapLoaderProps {
|
||||
listings: MapListing[]
|
||||
approvedLabel: string
|
||||
viewLabel: string
|
||||
}
|
||||
|
||||
export default function ListingsMapLoader(props: ListingsMapLoaderProps) {
|
||||
return <ListingsMap {...props} />
|
||||
}
|
||||
+39
-22
@@ -61,6 +61,7 @@ export default function Navbar({ categories = [] }: { categories?: Category[] })
|
||||
...categoryItems,
|
||||
{ name: t('events'), href: '/events' },
|
||||
{ name: t('blog'), href: '/blog' },
|
||||
{ name: t('aiPlanner'), href: '/plan-olustur' },
|
||||
]
|
||||
|
||||
const handleLanguageChange = (localeCode: string) => {
|
||||
@@ -83,7 +84,7 @@ export default function Navbar({ categories = [] }: { categories?: Category[] })
|
||||
<p className="font-heading font-extrabold text-lg text-stone tracking-tight leading-none lowercase">
|
||||
marmaris <span className="text-turquoise">local</span>
|
||||
</p>
|
||||
<p className="text-[9px] font-mono text-shutter tracking-wider mt-0.5 uppercase">
|
||||
<p className="text-[10px] font-mono text-stone/75 font-medium tracking-wider mt-0.5 uppercase">
|
||||
local knowledge
|
||||
</p>
|
||||
</div>
|
||||
@@ -108,15 +109,16 @@ export default function Navbar({ categories = [] }: { categories?: Category[] })
|
||||
|
||||
{/* Action Items */}
|
||||
<div className="hidden lg:flex items-center gap-4">
|
||||
{/* Saved items trigger */}
|
||||
{/* Saved items trigger - 44px touch target */}
|
||||
<Link
|
||||
href="/saved"
|
||||
className="relative w-8 h-8 rounded-full border border-stone/20 hover:border-turquoise transition text-stone hover:text-turquoise flex items-center justify-center"
|
||||
className="relative w-11 h-11 rounded-full border border-stone/20 hover:border-turquoise transition text-stone hover:text-turquoise flex items-center justify-center shrink-0"
|
||||
title={t('saved')}
|
||||
aria-label={t('saved')}
|
||||
>
|
||||
<Heart className="w-4 h-4" />
|
||||
<Heart className="w-4.5 h-4.5" />
|
||||
{favoritesCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-bougainvillea text-stone text-[8px] font-mono font-bold flex items-center justify-center shadow-sm shrink-0">
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-bougainvillea text-stone text-[8px] font-mono font-bold flex items-center justify-center shadow-sm shrink-0">
|
||||
{favoritesCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -125,10 +127,14 @@ export default function Navbar({ categories = [] }: { categories?: Category[] })
|
||||
{/* Language Selector */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLangMenuOpen(!langMenuOpen)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full border border-stone/20 hover:border-turquoise transition text-xs font-mono"
|
||||
className="flex items-center gap-1.5 px-3.5 py-2.5 rounded-full border border-stone/20 hover:border-turquoise transition text-xs font-mono min-h-[44px] cursor-pointer"
|
||||
aria-label="Select language"
|
||||
aria-expanded={langMenuOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<Globe className="w-3.5 h-3.5 text-turquoise" />
|
||||
<Globe className="w-4 h-4 text-turquoise" />
|
||||
{activeLocale.toUpperCase()}
|
||||
</button>
|
||||
|
||||
@@ -139,12 +145,13 @@ export default function Navbar({ categories = [] }: { categories?: Category[] })
|
||||
{languages.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
type="button"
|
||||
onClick={() => handleLanguageChange(lang.code)}
|
||||
className={`w-full text-left px-4 py-2 text-xs hover:bg-stone transition flex items-center justify-between ${activeLocale === lang.code ? 'font-bold text-turquoise bg-stone/40' : 'text-ink/80'
|
||||
className={`w-full text-left px-4 py-2.5 text-xs hover:bg-stone transition flex items-center justify-between min-h-[40px] cursor-pointer ${activeLocale === lang.code ? 'font-bold text-turquoise bg-stone/40' : 'text-ink/80'
|
||||
}`}
|
||||
>
|
||||
{lang.label}
|
||||
<span className="font-mono text-[10px] text-shutter">{lang.code.toUpperCase()}</span>
|
||||
<span className="font-mono text-[10px] text-pine/70">{lang.code.toUpperCase()}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -155,47 +162,53 @@ export default function Navbar({ categories = [] }: { categories?: Category[] })
|
||||
{/* Add Business Button */}
|
||||
<Link
|
||||
href="/add-business"
|
||||
className="flex items-center gap-1.5 bg-turquoise hover:bg-turquoise/90 text-paper font-medium text-xs py-2 px-4 rounded-full transition-transform active:scale-95 shadow-sm"
|
||||
className="flex items-center gap-1.5 bg-turquoise hover:bg-turquoise/90 text-paper font-medium text-xs py-2.5 px-4 rounded-full transition-transform active:scale-95 shadow-sm min-h-[44px]"
|
||||
>
|
||||
<PlusCircle className="w-3.5 h-3.5" />
|
||||
<PlusCircle className="w-4 h-4" />
|
||||
{t('addBusiness')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<div className="flex items-center gap-3 lg:hidden">
|
||||
{/* Mobile Saved trigger */}
|
||||
<div className="flex items-center gap-2 lg:hidden">
|
||||
{/* Mobile Saved trigger - 44px touch target */}
|
||||
<Link
|
||||
href="/saved"
|
||||
className="relative w-8 h-8 rounded-full border border-stone/20 text-stone flex items-center justify-center"
|
||||
className="relative w-11 h-11 rounded-full border border-stone/20 text-stone flex items-center justify-center shrink-0"
|
||||
title={t('saved')}
|
||||
aria-label={t('saved')}
|
||||
>
|
||||
<Heart className="w-4 h-4" />
|
||||
<Heart className="w-4.5 h-4.5" />
|
||||
{favoritesCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1.5 w-3.5 h-3.5 rounded-full bg-bougainvillea text-stone text-[8px] font-mono font-bold flex items-center justify-center shadow-sm shrink-0">
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-bougainvillea text-stone text-[8px] font-mono font-bold flex items-center justify-center shadow-sm shrink-0">
|
||||
{favoritesCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Lang menu for mobile */}
|
||||
{/* Lang menu for mobile - 44px touch target */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLangMenuOpen(!langMenuOpen)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-full border border-stone/20 text-xs font-mono"
|
||||
className="flex items-center gap-1.5 px-3 py-2.5 rounded-full border border-stone/20 text-xs font-mono min-h-[44px] cursor-pointer"
|
||||
aria-label="Select language"
|
||||
aria-expanded={langMenuOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<Globe className="w-3.5 h-3.5 text-turquoise" />
|
||||
<Globe className="w-4 h-4 text-turquoise" />
|
||||
{activeLocale.toUpperCase()}
|
||||
</button>
|
||||
{langMenuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setLangMenuOpen(false)} />
|
||||
<div className="absolute right-0 mt-2 w-32 rounded-xl bg-paper text-ink shadow-lg ring-1 ring-black/5 z-20 py-1">
|
||||
<div className="absolute right-0 mt-2 w-36 rounded-xl bg-paper text-ink shadow-lg ring-1 ring-black/5 z-20 py-1">
|
||||
{languages.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
type="button"
|
||||
onClick={() => handleLanguageChange(lang.code)}
|
||||
className="w-full text-left px-3 py-1.5 text-xs hover:bg-stone"
|
||||
className="w-full text-left px-4 py-2.5 text-xs hover:bg-stone min-h-[40px] cursor-pointer"
|
||||
>
|
||||
{lang.label}
|
||||
</button>
|
||||
@@ -205,9 +218,13 @@ export default function Navbar({ categories = [] }: { categories?: Category[] })
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Toggle Button - 44px touch target */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="text-stone hover:text-turquoise focus:outline-none"
|
||||
className="w-11 h-11 flex items-center justify-center rounded-xl text-stone hover:text-turquoise hover:bg-white/10 transition cursor-pointer"
|
||||
aria-label="Toggle navigation menu"
|
||||
aria-expanded={mobileMenuOpen}
|
||||
>
|
||||
{mobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
const email = process.env.ADMIN_EMAIL || process.argv[2]
|
||||
const password = process.env.ADMIN_PASSWORD || process.argv[3]
|
||||
|
||||
if (!email || !password) {
|
||||
console.error('Kullanım: npx tsx create-admin.ts <email> <şifre> (ya da ADMIN_EMAIL / ADMIN_PASSWORD env değişkenleriyle)')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const hashed = await bcrypt.hash(password, 12)
|
||||
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email },
|
||||
update: { password: hashed, role: 'ADMIN' },
|
||||
create: { email, password: hashed, role: 'ADMIN', name: 'Admin' }
|
||||
})
|
||||
|
||||
console.log(`✅ Admin kullanıcı hazır: ${user.email}`)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { NextAuthConfig } from "next-auth"
|
||||
|
||||
/**
|
||||
* Edge-safe NextAuth config (no providers, no Prisma import) — used by
|
||||
* proxy.ts to read the session JWT in the Edge middleware runtime, where
|
||||
* @prisma/client cannot run. The Prisma-backed CredentialsProvider lives in
|
||||
* lib/auth.ts and only executes in the Node.js runtime (route handler,
|
||||
* server actions, server components).
|
||||
*/
|
||||
export const authConfig = {
|
||||
pages: {
|
||||
signIn: '/login'
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.role = (user as any).role
|
||||
}
|
||||
return token
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session.user && token.role) {
|
||||
(session.user as any).role = token.role
|
||||
}
|
||||
return session
|
||||
}
|
||||
},
|
||||
providers: [],
|
||||
} satisfies NextAuthConfig
|
||||
+27
-28
@@ -1,7 +1,12 @@
|
||||
import NextAuth from "next-auth"
|
||||
import CredentialsProvider from "next-auth/providers/credentials"
|
||||
import bcrypt from "bcryptjs"
|
||||
|
||||
import { authConfig } from "./auth.config"
|
||||
import { db } from "./db"
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
...authConfig,
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "Credentials",
|
||||
@@ -10,38 +15,32 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
password: { label: "Password", type: "password" }
|
||||
},
|
||||
async authorize(credentials) {
|
||||
// Boilerplate mock logic
|
||||
// TODO: In production, lookup user in Prisma and verify password using bcrypt
|
||||
// const user = await db.user.findUnique({ where: { email: credentials.email } })
|
||||
const email = credentials?.email as string | undefined
|
||||
const password = credentials?.password as string | undefined
|
||||
if (!email || !password) return null
|
||||
|
||||
const user = await db.user.findUnique({ where: { email } })
|
||||
if (!user?.password || user.role !== "ADMIN") return null
|
||||
|
||||
const isValid = await bcrypt.compare(password, user.password)
|
||||
if (!isValid) return null
|
||||
|
||||
if (credentials?.email === "admin@ayris.tech" && credentials?.password === "admin") {
|
||||
return {
|
||||
id: "1",
|
||||
name: "Admin User",
|
||||
email: "admin@ayris.tech",
|
||||
role: "ADMIN"
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
})
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.role = (user as any).role
|
||||
}
|
||||
return token
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session.user && token.role) {
|
||||
(session.user as any).role = token.role
|
||||
]
|
||||
})
|
||||
|
||||
/** Server actions / route handlers should call this before any admin-only mutation. */
|
||||
export async function requireAdmin() {
|
||||
const session = await auth()
|
||||
if (!session || (session.user as any)?.role !== "ADMIN") {
|
||||
throw new Error("Yetkisiz erişim: Bu işlem için admin girişi gerekli.")
|
||||
}
|
||||
return session
|
||||
}
|
||||
},
|
||||
pages: {
|
||||
signIn: '/login'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
const DEEPSEEK_API_URL = 'https://api.deepseek.com/chat/completions'
|
||||
|
||||
export interface ItineraryCandidate {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
category: string
|
||||
categorySlug: string
|
||||
neighborhood: string
|
||||
address: string
|
||||
priceRange: number
|
||||
rating: number | null
|
||||
slug: string
|
||||
isFeatured: boolean
|
||||
}
|
||||
|
||||
interface GenerateItineraryParams {
|
||||
days: number
|
||||
style: string
|
||||
locale: string
|
||||
candidates: ItineraryCandidate[]
|
||||
}
|
||||
|
||||
const LANGUAGE_NAMES: Record<string, string> = {
|
||||
tr: 'Turkish',
|
||||
en: 'English',
|
||||
ru: 'Russian',
|
||||
}
|
||||
|
||||
const STYLE_LABELS: Record<string, string> = {
|
||||
gastronomy: 'gastronomy and food-focused',
|
||||
relaxation: 'relaxation and slow-paced',
|
||||
adventure: 'adventure and outdoor-focused',
|
||||
}
|
||||
|
||||
/**
|
||||
* Grounded itinerary generation: the model may ONLY recommend businesses from
|
||||
* `candidates` — it never invents a place. This is the core anti-hallucination
|
||||
* rule from the product spec (docs/prd-3.md §3.3).
|
||||
*/
|
||||
export async function generateItineraryContent(params: GenerateItineraryParams): Promise<string> {
|
||||
const apiKey = process.env.DEEPSEEK_API_KEY
|
||||
if (!apiKey) {
|
||||
throw new Error('DEEPSEEK_API_KEY tanımlı değil')
|
||||
}
|
||||
|
||||
const languageName = LANGUAGE_NAMES[params.locale] || LANGUAGE_NAMES.tr
|
||||
const styleLabel = STYLE_LABELS[params.style] || params.style
|
||||
|
||||
const candidateList = params.candidates
|
||||
.map((c) => {
|
||||
const price = '₺'.repeat(c.priceRange)
|
||||
const featured = c.isFeatured ? ' [featured]' : ''
|
||||
return `- id:${c.id}${featured} | ${c.name} | ${c.category} | ${c.neighborhood} | ${price} | rating:${c.rating ?? 'n/a'} | link:/${c.categorySlug}/${c.slug}\n ${c.description}`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
const systemPrompt = `You are the local guide writer for "Marmaris Local", a curated directory of Marmaris, Turkey. You write personalized multi-day itineraries.
|
||||
|
||||
STRICT RULE: you may only recommend businesses from the CANDIDATE LIST below. Never invent, assume, or mention any place that is not in this list. If the list has fewer suitable places than needed, reuse the best candidates rather than inventing new ones.
|
||||
|
||||
For every place you recommend, include its markdown link exactly as given in the candidate list (e.g. [Place Name](/category-slug/place-slug)).
|
||||
|
||||
Write in ${languageName}. Output valid Markdown: use "## gün N" / "## day N" style headings per day (translate "day" to ${languageName}), short warm paragraphs, and occasional > blockquote for a "local tip". Avoid generic travel-blog clichés — be specific and grounded in the actual candidate descriptions. Places marked [featured] may be given slight preference when they fit, but relevance always comes first.`
|
||||
|
||||
const userPrompt = `Number of days: ${params.days}
|
||||
Travel style: ${styleLabel}
|
||||
|
||||
CANDIDATE LIST (choose only from these):
|
||||
${candidateList}
|
||||
|
||||
Write the full ${params.days}-day itinerary now, in Markdown, in ${languageName}.`
|
||||
|
||||
const res = await fetch(DEEPSEEK_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'deepseek-chat',
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
temperature: 0.7,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text()
|
||||
throw new Error(`DeepSeek API hatası (${res.status}): ${errText}`)
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
const content = json?.choices?.[0]?.message?.content
|
||||
if (!content) {
|
||||
throw new Error('DeepSeek yanıtı boş döndü')
|
||||
}
|
||||
|
||||
return linkifyItineraryContent(content, params.candidates)
|
||||
}
|
||||
|
||||
/**
|
||||
* The model reliably mentions candidate names but doesn't always wrap them in
|
||||
* the requested markdown link syntax. This deterministically links the first
|
||||
* mention of each candidate as a safety net, so every generated plan actually
|
||||
* drives traffic to listing pages regardless of how well the model complied.
|
||||
*/
|
||||
function linkifyItineraryContent(content: string, candidates: ItineraryCandidate[]): string {
|
||||
let result = content
|
||||
for (const c of candidates) {
|
||||
const href = `/${c.categorySlug}/${c.slug}`
|
||||
if (result.includes(`](${href})`)) continue // model already linked it correctly
|
||||
|
||||
const escapedName = c.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const pattern = new RegExp(`\\*{0,2}${escapedName}\\*{0,2}`)
|
||||
if (pattern.test(result)) {
|
||||
result = result.replace(pattern, `**[${c.name}](${href})**`)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Lightweight safe Markdown to HTML parsing function — shared by blog posts and AI itinerary results.
|
||||
export 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>')
|
||||
|
||||
// Blockquotes
|
||||
html = html.replace(/^> (.*?)$/gm, '<blockquote class="border-l-2 border-turquoise/40 pl-4 italic text-sm text-shutter my-3">$1</blockquote>')
|
||||
|
||||
// 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') || trimmed.startsWith('<blockquote')) {
|
||||
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
|
||||
}
|
||||
@@ -1589,5 +1589,12 @@ export const mockDb = {
|
||||
return newItin
|
||||
}
|
||||
return db.generatedItinerary.create({ data })
|
||||
},
|
||||
|
||||
async getItineraries() {
|
||||
if (this.isMock()) {
|
||||
return [...globalForMockDb.generatedItineraries]
|
||||
}
|
||||
return db.generatedItinerary.findMany()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Shared JSX used by app/icon.tsx, app/apple-icon.tsx and the per-locale
|
||||
// opengraph-image.tsx / twitter-image.tsx — keeps the brand badge (the same
|
||||
// circular "ML" mark used in Navbar) defined in exactly one place.
|
||||
|
||||
export function BrandBadge({ size, ringWidth = 3 }: { size: number; ringWidth?: number }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
background: '#FBFAF6',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: size * 0.8,
|
||||
height: size * 0.8,
|
||||
borderRadius: '50%',
|
||||
border: `${ringWidth}px dashed #2E9C9A`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: size * 0.36, fontWeight: 800, color: '#123238', letterSpacing: '-1px' }}>ML</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function OgImageContent({ tagline }: { tagline: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: '#123238',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 80,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', marginBottom: 40 }}>
|
||||
<BrandBadge size={140} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', fontSize: 64, fontWeight: 800, color: '#FBFAF6' }}>
|
||||
<span>marmaris </span>
|
||||
<span style={{ color: '#2E9C9A' }}>local</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', fontSize: 28, color: '#EDEEE3', marginTop: 20, textAlign: 'center' }}>
|
||||
{tagline}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// In-memory sliding-window rate limiter. Good enough for a single-instance
|
||||
// Docker deployment (Coolify) — resets on redeploy and doesn't share state
|
||||
// across replicas, but that's an acceptable MVP tradeoff for abuse-throttling
|
||||
// a public, unauthenticated endpoint (no external dependency like Redis needed).
|
||||
const hits = new Map<string, number[]>()
|
||||
|
||||
export function checkRateLimit(key: string, limit: number, windowMs: number): boolean {
|
||||
const now = Date.now()
|
||||
const timestamps = (hits.get(key) || []).filter((t) => now - t < windowMs)
|
||||
|
||||
if (timestamps.length >= limit) {
|
||||
hits.set(key, timestamps)
|
||||
return false
|
||||
}
|
||||
|
||||
timestamps.push(now)
|
||||
hits.set(key, timestamps)
|
||||
|
||||
// Opportunistic cleanup so the map doesn't grow unbounded.
|
||||
if (hits.size > 5000) {
|
||||
for (const [k, v] of hits) {
|
||||
if (v.every((t) => now - t > windowMs)) hits.delete(k)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
+11
-3
@@ -34,12 +34,20 @@ export function ogLocale(locale: string): string {
|
||||
* Metadata for pages that only need a localized title/description, but still
|
||||
* want their own Open Graph/Twitter tags instead of inheriting the layout's
|
||||
* generic sitewide fallback (which would otherwise show on every such page).
|
||||
*
|
||||
* Next.js doesn't deep-merge `openGraph`/`twitter` objects across the segment
|
||||
* tree — a page that returns its own `openGraph` here replaces (not extends)
|
||||
* the root layout's, which would silently drop the shared opengraph-image.tsx
|
||||
* fallback. So the share image is added back explicitly.
|
||||
*/
|
||||
export function basicMetadata(title: string, description: string): Metadata {
|
||||
export function basicMetadata(title: string, description: string, locale: string, pathSuffix: string = ''): Metadata {
|
||||
const image = `${SITE_URL}/${locale}/opengraph-image`
|
||||
const pathname = `/${locale}${pathSuffix.startsWith('/') ? pathSuffix : pathSuffix ? `/${pathSuffix}` : ''}`
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: { title, description, type: 'website' },
|
||||
twitter: { card: 'summary_large_image', title, description },
|
||||
alternates: buildAlternates(pathname),
|
||||
openGraph: { title, description, url: `${SITE_URL}${pathname}`, locale: ogLocale(locale), type: 'website', images: [image] },
|
||||
twitter: { card: 'summary_large_image', title, description, images: [image] },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Best-effort Telegram notification — a failed send never blocks the form
|
||||
// submission it's attached to (the message is already saved in the DB by
|
||||
// the time this runs; Telegram is a convenience notification, not the
|
||||
// source of truth).
|
||||
export async function sendTelegramMessage(text: string): Promise<void> {
|
||||
const token = process.env.TELEGRAM_BOT_TOKEN
|
||||
const chatId = process.env.TELEGRAM_CHAT_ID
|
||||
if (!token || !chatId) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chat_id: chatId, text, disable_web_page_preview: true }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
console.error('Telegram notification failed:', res.status, await res.text())
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Telegram notification error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function formatIstanbulDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat('tr-TR', {
|
||||
timeZone: 'Europe/Istanbul',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
export function formatContactMessageNotification(params: {
|
||||
name: string
|
||||
email: string
|
||||
subject: string
|
||||
message: string
|
||||
ip: string
|
||||
}): string {
|
||||
return [
|
||||
'⚡ [Marmaris Local] Yeni İletişim Talebi',
|
||||
'',
|
||||
'📍 Kaynak: marmarislocal.com / İletişim Formu',
|
||||
`👤 Ad Soyad: ${params.name}`,
|
||||
`📧 E-posta: ${params.email}`,
|
||||
`📋 Konu: ${params.subject}`,
|
||||
`🌐 IP Adresi: ${params.ip}`,
|
||||
`⏰ Tarih: ${formatIstanbulDate(new Date())}`,
|
||||
'',
|
||||
'📝 Mesaj:',
|
||||
params.message,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function formatBusinessSubmissionNotification(params: {
|
||||
businessName: string
|
||||
categoryName: string
|
||||
neighborhoodName: string
|
||||
address: string
|
||||
phone?: string | null
|
||||
whatsapp?: string | null
|
||||
description: string
|
||||
contactName: string
|
||||
contactEmail: string
|
||||
ip: string
|
||||
}): string {
|
||||
return [
|
||||
'🏢 [Marmaris Local] Yeni İşletme Başvurusu',
|
||||
'',
|
||||
'📍 Kaynak: marmarislocal.com / İşletme Ekle Formu',
|
||||
`🏷️ İşletme Adı: ${params.businessName}`,
|
||||
`📂 Kategori: ${params.categoryName}`,
|
||||
`📌 Mahalle: ${params.neighborhoodName}`,
|
||||
`🏠 Adres: ${params.address}`,
|
||||
...(params.phone ? [`☎️ Telefon: ${params.phone}`] : []),
|
||||
...(params.whatsapp ? [`💬 WhatsApp: ${params.whatsapp}`] : []),
|
||||
`👤 Başvuran: ${params.contactName}`,
|
||||
`📧 E-posta: ${params.contactEmail}`,
|
||||
`🌐 IP Adresi: ${params.ip}`,
|
||||
`⏰ Tarih: ${formatIstanbulDate(new Date())}`,
|
||||
'',
|
||||
'📝 Açıklama:',
|
||||
params.description,
|
||||
'',
|
||||
'Onaylamak için admin panel → Başvurular.',
|
||||
].join('\n')
|
||||
}
|
||||
+3
-1
@@ -33,6 +33,7 @@
|
||||
"generate": "Create Travel Plan",
|
||||
"generating": "Preparing your plan...",
|
||||
"viewPlan": "View Plan",
|
||||
"placesInPlan": "places in this plan",
|
||||
"styles": {
|
||||
"gastronomy": "Gourmet & Gastronomy",
|
||||
"relaxation": "Peace & Relaxation",
|
||||
@@ -70,7 +71,8 @@
|
||||
"address": "Address",
|
||||
"phone": "Phone",
|
||||
"price": "Price",
|
||||
"viewDetails": "View Details"
|
||||
"viewDetails": "View Details",
|
||||
"map": "Map View"
|
||||
},
|
||||
"detail": {
|
||||
"approved": "Local Approved Sealed Business",
|
||||
|
||||
+3
-1
@@ -33,6 +33,7 @@
|
||||
"generate": "Создать план поездки",
|
||||
"generating": "Подготовка вашего плана...",
|
||||
"viewPlan": "Посмотреть план",
|
||||
"placesInPlan": "места из этого плана",
|
||||
"styles": {
|
||||
"gastronomy": "Гурман и гастрономия",
|
||||
"relaxation": "Покой и отдых",
|
||||
@@ -70,7 +71,8 @@
|
||||
"address": "Адрес",
|
||||
"phone": "Телефон",
|
||||
"price": "Цена",
|
||||
"viewDetails": "Подробнее"
|
||||
"viewDetails": "Подробнее",
|
||||
"map": "Вид на карте"
|
||||
},
|
||||
"detail": {
|
||||
"approved": "Заведение со знаком «Одобрено местными»",
|
||||
|
||||
+3
-1
@@ -33,6 +33,7 @@
|
||||
"generate": "Seyahat Planı Oluştur",
|
||||
"generating": "Planınız Hazırlanıyor...",
|
||||
"viewPlan": "Planı Görüntüle",
|
||||
"placesInPlan": "planda geçen mekanlar",
|
||||
"styles": {
|
||||
"gastronomy": "Gurme ve Gastronomi",
|
||||
"relaxation": "Huzur ve Dinlenme",
|
||||
@@ -70,7 +71,8 @@
|
||||
"address": "Adres",
|
||||
"phone": "Telefon",
|
||||
"price": "Fiyat",
|
||||
"viewDetails": "Detayları Gör"
|
||||
"viewDetails": "Detayları Gör",
|
||||
"map": "Harita Görünümü"
|
||||
},
|
||||
"detail": {
|
||||
"approved": "Yerel Onaylı Mühürlü İşletme",
|
||||
|
||||
Generated
+117
-37
@@ -10,23 +10,28 @@
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.5.0",
|
||||
"@prisma/client": "^6.3.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cloudinary": "^2.10.0",
|
||||
"clsx": "^2.1.1",
|
||||
"developer-icons": "^7.0.1",
|
||||
"framer-motion": "^12.40.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.18.0",
|
||||
"next": "16.2.9",
|
||||
"next-auth": "^5.0.0-beta.31",
|
||||
"next-intl": "^4.13.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"shadcn": "^4.11.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
@@ -2680,7 +2685,7 @@
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz",
|
||||
"integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"c12": "3.1.0",
|
||||
@@ -2693,14 +2698,14 @@
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz",
|
||||
"integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/engines": {
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz",
|
||||
"integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -2714,14 +2719,14 @@
|
||||
"version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz",
|
||||
"integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/fetch-engine": {
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz",
|
||||
"integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.19.3",
|
||||
@@ -2733,12 +2738,23 @@
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz",
|
||||
"integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.19.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -2774,7 +2790,7 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/core-darwin-arm64": {
|
||||
@@ -3350,6 +3366,13 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bcryptjs": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -3357,6 +3380,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -3371,6 +3401,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz",
|
||||
"integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.43",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||
@@ -3385,7 +3425,7 @@
|
||||
"version": "19.2.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -4421,6 +4461,15 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
@@ -4529,7 +4578,7 @@
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
|
||||
"integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.3",
|
||||
@@ -4558,7 +4607,7 @@
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -4665,7 +4714,7 @@
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
@@ -4681,7 +4730,7 @@
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
|
||||
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"consola": "^3.2.3"
|
||||
@@ -4799,14 +4848,14 @@
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
|
||||
"integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/consola": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
|
||||
"integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.10.0"
|
||||
@@ -4940,7 +4989,7 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
@@ -5064,7 +5113,7 @@
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
@@ -5150,7 +5199,7 @@
|
||||
"version": "6.1.7",
|
||||
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz",
|
||||
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/depd": {
|
||||
@@ -5166,7 +5215,7 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
||||
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
@@ -5263,7 +5312,7 @@
|
||||
"version": "3.21.0",
|
||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
|
||||
"integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
@@ -5287,7 +5336,7 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz",
|
||||
"integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@@ -6122,14 +6171,14 @@
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz",
|
||||
"integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-check": {
|
||||
"version": "3.23.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
|
||||
"integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -6627,7 +6676,7 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz",
|
||||
"integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"citty": "^0.1.6",
|
||||
@@ -7584,7 +7633,7 @@
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
|
||||
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
@@ -7751,6 +7800,12 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
@@ -8496,6 +8551,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-intl/node_modules/@swc/helpers": {
|
||||
"version": "0.5.23",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
|
||||
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
@@ -8591,7 +8657,7 @@
|
||||
"version": "1.6.7",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
@@ -8635,7 +8701,7 @@
|
||||
"version": "0.6.8",
|
||||
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.8.tgz",
|
||||
"integrity": "sha512-Q9K4Diu6l5u6xJQogeFSs/zKtyMSgFKFtRQV+tHP4kL7KPm2grpBU0dFIwFaXwNxN0MtfKWc43VpCugAa+LPsw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"citty": "^0.2.2",
|
||||
@@ -8653,7 +8719,7 @@
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz",
|
||||
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/oauth4webapi": {
|
||||
@@ -8799,7 +8865,7 @@
|
||||
"version": "2.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
|
||||
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
@@ -9085,14 +9151,14 @@
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/perfect-debounce": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
@@ -9126,7 +9192,7 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz",
|
||||
"integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confbox": "^0.2.4",
|
||||
@@ -9251,7 +9317,7 @@
|
||||
"version": "6.19.3",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz",
|
||||
"integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -9334,7 +9400,7 @@
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||
"integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -9410,7 +9476,7 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
||||
"integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"defu": "^6.1.4",
|
||||
@@ -9445,11 +9511,25 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"dependencies": {
|
||||
"@react-leaflet/core": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.18.0"
|
||||
@@ -10471,7 +10551,7 @@
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
|
||||
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -10755,7 +10835,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
||||
@@ -11,23 +11,28 @@
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.5.0",
|
||||
"@prisma/client": "^6.3.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cloudinary": "^2.10.0",
|
||||
"clsx": "^2.1.1",
|
||||
"developer-icons": "^7.0.1",
|
||||
"framer-motion": "^12.40.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.18.0",
|
||||
"next": "16.2.9",
|
||||
"next-auth": "^5.0.0-beta.31",
|
||||
"next-intl": "^4.13.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"shadcn": "^4.11.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import createMiddleware from 'next-intl/middleware'
|
||||
import { auth } from '@/lib/auth'
|
||||
import NextAuth from 'next-auth'
|
||||
import { authConfig } from '@/lib/auth.config'
|
||||
import { routing } from '@/i18n/routing'
|
||||
|
||||
// Edge-safe session read only — the Prisma-backed provider in lib/auth.ts
|
||||
// cannot run in the middleware's Edge runtime, so this uses the bare config.
|
||||
const { auth } = NextAuth(authConfig)
|
||||
const intlMiddleware = createMiddleware(routing)
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
@@ -17,5 +21,9 @@ export async function proxy(request: NextRequest) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)']
|
||||
// icon / apple-icon are root-level, locale-independent metadata routes
|
||||
// (app/icon.tsx, app/apple-icon.tsx) — without this exclusion the intl
|
||||
// middleware treats them as un-prefixed pages and redirects them to
|
||||
// /tr/icon, which 404s and breaks the favicon/apple-touch-icon.
|
||||
matcher: ['/((?!api|_next|_vercel|icon|apple-icon|.*\\..*).*)']
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -9,14 +9,14 @@
|
||||
"orientation": "portrait",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/globe.svg",
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/svg+xml"
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/globe.svg",
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/svg+xml"
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -30,5 +30,5 @@
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules", "fix-openinary-v2.ts", "scripts"]
|
||||
"exclude": ["node_modules", "fix-openinary-v2.ts", "create-admin.ts", "scripts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user