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
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user