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>
253 lines
9.9 KiB
TypeScript
253 lines
9.9 KiB
TypeScript
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'
|
||
import LiveFilterForm from '@/components/LiveFilterForm'
|
||
import type { Metadata } from 'next'
|
||
import { basicMetadata } from '@/lib/seo'
|
||
|
||
interface PageProps {
|
||
params: Promise<{ locale: string, category: string }>
|
||
searchParams: Promise<{
|
||
search?: string
|
||
neighborhood?: string
|
||
price?: string
|
||
approved?: string
|
||
}>
|
||
}
|
||
|
||
export const dynamic = 'force-dynamic'
|
||
|
||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||
const { locale, category: categorySlug } = await params
|
||
const categories = await mockDb.getCategories()
|
||
const category = categories.find(c => c.slug === categorySlug)
|
||
if (!category) return {}
|
||
|
||
const name = locale === 'ru' ? category.nameRu : locale === 'en' ? category.nameEn : category.nameTr
|
||
|
||
const title =
|
||
locale === 'en'
|
||
? `${name} in Marmaris — Marmaris Local`
|
||
: locale === 'ru'
|
||
? `${name} в Мармарисе — Marmaris Local`
|
||
: `Marmaris ${name} Rehberi — Marmaris Local`
|
||
|
||
const description =
|
||
locale === 'en'
|
||
? `Browse the best ${name.toLowerCase()} in Marmaris, filtered by neighborhood and price — curated and locally approved.`
|
||
: locale === 'ru'
|
||
? `Лучшие места категории «${name}» в Мармарисе — с фильтрами по районам и ценам, проверено местными.`
|
||
: `Marmaris'teki en iyi ${name.toLowerCase()} listesi — mahalle ve fiyata göre filtrele, yerel onaylılardan seç.`
|
||
|
||
return basicMetadata(title, description, locale, `/${categorySlug}`)
|
||
}
|
||
|
||
export default async function DynamicCategoryPage({ params, searchParams }: PageProps) {
|
||
const { locale, category: categorySlug } = await params
|
||
setRequestLocale(locale)
|
||
|
||
const { search, neighborhood, price, approved } = await searchParams
|
||
|
||
const t = await getTranslations('categories')
|
||
const navT = await getTranslations('nav')
|
||
const heroT = await getTranslations('hero')
|
||
|
||
// Find Category Restoran
|
||
const categories = await mockDb.getCategories()
|
||
const currentCategory = categories.find(c => c.slug === categorySlug)
|
||
if (!currentCategory) notFound()
|
||
const categoryId = currentCategory?.id
|
||
|
||
// Get active neighborhoods for filter
|
||
const neighborhoods = await mockDb.getNeighborhoods()
|
||
|
||
// Selected filters
|
||
const selectedNeighborhoodId = neighborhood || undefined
|
||
const selectedPriceRange = price ? parseInt(price) : undefined
|
||
const isApprovedOnly = approved === 'true'
|
||
|
||
const listings = await mockDb.getListings({
|
||
categoryId,
|
||
neighborhoodId: selectedNeighborhoodId,
|
||
priceRange: selectedPriceRange,
|
||
isLocalApproved: isApprovedOnly ? true : undefined,
|
||
search: search
|
||
})
|
||
|
||
const getLocalizedName = (obj: any) => {
|
||
if (!obj) return ''
|
||
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">
|
||
|
||
{/* Header */}
|
||
<div className="mb-8">
|
||
<h1 className="text-3xl font-heading font-extrabold text-pine lowercase">
|
||
{getLocalizedName(currentCategory)}
|
||
</h1>
|
||
<p className="text-xs text-shutter font-mono uppercase tracking-wider mt-1">
|
||
marmaris local • {listings.length} {locale === 'tr' ? 'sonuç' : locale === 'en' ? 'results' : 'результатов'}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Filters Panel */}
|
||
<div className="bg-paper p-5 rounded-2xl border border-pine/8 shadow-sm mb-10">
|
||
<div className="flex items-center gap-2 mb-4 font-heading font-bold text-sm text-pine lowercase border-b border-dashed border-pine/8 pb-3">
|
||
<SlidersHorizontal className="w-4 h-4 text-turquoise" />
|
||
<span>filtreler</span>
|
||
</div>
|
||
|
||
<LiveFilterForm>
|
||
{/* Search Input */}
|
||
<div className="space-y-1.5">
|
||
<label className="block text-[11px] font-mono uppercase tracking-wider text-shutter">Arama</label>
|
||
<input
|
||
type="text"
|
||
name="search"
|
||
defaultValue={search || ''}
|
||
placeholder="İsim veya adres..."
|
||
className="w-full bg-stone border border-pine/10 rounded-xl px-3 py-2 text-xs focus:ring-1 focus:ring-turquoise outline-none"
|
||
/>
|
||
</div>
|
||
|
||
{/* Neighborhood select */}
|
||
<div className="space-y-1.5">
|
||
<label className="block text-[11px] font-mono uppercase tracking-wider text-shutter">{t('filterNeighborhood')}</label>
|
||
<select
|
||
name="neighborhood"
|
||
defaultValue={neighborhood || ''}
|
||
className="w-full bg-stone border border-pine/10 rounded-xl px-3 py-2 text-xs focus:ring-1 focus:ring-turquoise outline-none appearance-none"
|
||
>
|
||
<option value="">{t('allNeighborhoods')}</option>
|
||
{neighborhoods.map((n) => (
|
||
<option key={n.id} value={n.id}>
|
||
{getLocalizedName(n)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Price range select */}
|
||
<div className="space-y-1.5">
|
||
<label className="block text-[11px] font-mono uppercase tracking-wider text-shutter">{t('filterPrice')}</label>
|
||
<select
|
||
name="price"
|
||
defaultValue={price || ''}
|
||
className="w-full bg-stone border border-pine/10 rounded-xl px-3 py-2 text-xs focus:ring-1 focus:ring-turquoise outline-none"
|
||
>
|
||
<option value="">{t('allPrices')}</option>
|
||
<option value="1">₺ (Ekonomik)</option>
|
||
<option value="2">₺₺ (Orta)</option>
|
||
<option value="3">₺₺₺ (Lüks)</option>
|
||
</select>
|
||
</div>
|
||
|
||
{/* Submit / Checkbox area */}
|
||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-4">
|
||
<label className="flex items-center gap-2 cursor-pointer select-none text-xs font-semibold py-2.5">
|
||
<input
|
||
type="checkbox"
|
||
name="approved"
|
||
value="true"
|
||
defaultChecked={isApprovedOnly}
|
||
className="rounded border-pine/10 text-turquoise focus:ring-turquoise w-4 h-4"
|
||
/>
|
||
<span className="text-pine">{t('filterApproved')}</span>
|
||
</label>
|
||
|
||
<div className="flex-1" />
|
||
</div>
|
||
</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">
|
||
<p className="text-sm font-medium">{t('noResults')}</p>
|
||
</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>
|
||
)}
|
||
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|