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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AyrisAI
2026-08-24 00:01:06 +03:00
co-authored by Claude Sonnet 5
parent 390bd699a6
commit 1b8cfeda95
62 changed files with 2216 additions and 411 deletions
+159
View File
@@ -0,0 +1,159 @@
'use client'
import { useState } from 'react'
import { useRouter } from '@/i18n/routing'
import { generateItineraryAction } from '@/app/actions'
import { Utensils, Waves, Mountain, Loader2 } from 'lucide-react'
interface Option {
value: string
label: string
}
interface Translations {
days: string
style: string
neighborhoods: string
generate: string
generating: string
gastronomy: string
relaxation: string
adventure: string
}
interface FormProps {
locale: string
translations: Translations
neighborhoods: Option[]
}
const STYLES = [
{ value: 'gastronomy', icon: Utensils },
{ value: 'relaxation', icon: Waves },
{ value: 'adventure', icon: Mountain },
] as const
export default function PlannerForm({ locale, translations, neighborhoods }: FormProps) {
const router = useRouter()
const [days, setDays] = useState(3)
const [style, setStyle] = useState<string>('gastronomy')
const [selectedNeighborhoods, setSelectedNeighborhoods] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const styleLabels: Record<string, string> = {
gastronomy: translations.gastronomy,
relaxation: translations.relaxation,
adventure: translations.adventure,
}
const toggleNeighborhood = (slug: string) => {
setSelectedNeighborhoods(prev =>
prev.includes(slug) ? prev.filter(s => s !== slug) : [...prev, slug]
)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError('')
try {
const res = await generateItineraryAction(days, style, selectedNeighborhoods, locale)
if (res.success && res.id) {
router.push(`/plan/${res.id}`)
} else {
setError(res.error || 'Bir hata oluştu. Lütfen tekrar deneyin.')
setLoading(false)
}
} catch (err) {
setError('Bir hata oluştu. Lütfen tekrar deneyin.')
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-8">
{error && (
<div className="bg-bougainvillea/10 border border-bougainvillea/20 text-bougainvillea p-4 rounded-xl text-xs font-semibold">
{error}
</div>
)}
{/* Days */}
<div className="space-y-2">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.days}</label>
<div className="flex gap-2">
{[1, 2, 3, 4, 5].map(n => (
<button
key={n}
type="button"
onClick={() => setDays(n)}
className={`w-12 h-12 rounded-xl border text-sm font-bold font-mono transition ${
days === n
? 'bg-turquoise border-turquoise text-paper'
: 'bg-stone border-pine/10 text-ink hover:border-turquoise/40'
}`}
>
{n}
</button>
))}
</div>
</div>
{/* Style */}
<div className="space-y-2">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.style}</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{STYLES.map(({ value, icon: Icon }) => (
<button
key={value}
type="button"
onClick={() => setStyle(value)}
className={`flex flex-col items-center gap-2 rounded-xl border p-4 text-center transition ${
style === value
? 'bg-turquoise/10 border-turquoise text-pine'
: 'bg-stone border-pine/10 text-ink hover:border-turquoise/40'
}`}
>
<Icon className="w-5 h-5" />
<span className="text-xs font-semibold leading-tight">{styleLabels[value]}</span>
</button>
))}
</div>
</div>
{/* Neighborhoods */}
{neighborhoods.length > 0 && (
<div className="space-y-2">
<label className="block text-xs font-mono uppercase tracking-wider text-shutter">{translations.neighborhoods}</label>
<div className="flex flex-wrap gap-2">
{neighborhoods.map(n => (
<button
key={n.value}
type="button"
onClick={() => toggleNeighborhood(n.value)}
className={`px-3 py-2 rounded-lg border text-xs font-semibold transition ${
selectedNeighborhoods.includes(n.value)
? 'bg-pine border-pine text-stone'
: 'bg-stone border-pine/10 text-ink hover:border-turquoise/40'
}`}
>
{n.label}
</button>
))}
</div>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full flex items-center justify-center gap-2 bg-turquoise hover:bg-turquoise/90 disabled:opacity-75 disabled:cursor-not-allowed text-paper font-bold text-sm py-4.5 px-4 rounded-xl transition duration-300 shadow-sm"
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
{loading ? translations.generating : translations.generate}
</button>
</form>
)
}
+65
View File
@@ -0,0 +1,65 @@
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { mockDb } from '@/lib/mockDb'
import PlannerForm from './PlannerForm'
import type { Metadata } from 'next'
import { basicMetadata } from '@/lib/seo'
interface Props {
params: Promise<{ locale: string }>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: 'planner' })
return basicMetadata(`${t('title')} — Marmaris Local`, t('subtitle'), locale)
}
export default async function PlanOlusturPage({ params }: Props) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations('planner')
const neighborhoods = await mockDb.getNeighborhoods()
const getLocalizedName = (obj: any) => {
if (!obj) return ''
return locale === 'ru' ? obj.nameRu : locale === 'en' ? obj.nameEn : obj.nameTr
}
const neighborhoodOptions = neighborhoods.map(n => ({
value: n.slug,
label: getLocalizedName(n)
}))
return (
<div className="flex-1 flex flex-col min-h-screen bg-stone text-ink">
<main className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8 py-12 flex-1 w-full">
<div className="bg-paper p-8 rounded-3xl border border-pine/8 shadow-sm">
<div className="mb-8 border-b border-dashed border-pine/8 pb-6">
<h1 className="text-3xl font-heading font-extrabold text-pine lowercase">
{t('title')}
</h1>
<p className="text-sm text-shutter mt-2">
{t('subtitle')}
</p>
</div>
<PlannerForm
locale={locale}
translations={{
days: t('days'),
style: t('style'),
neighborhoods: t('neighborhoods'),
generate: t('generate'),
generating: t('generating'),
gastronomy: t('styles.gastronomy'),
relaxation: t('styles.relaxation'),
adventure: t('styles.adventure'),
}}
neighborhoods={neighborhoodOptions}
/>
</div>
</main>
</div>
)
}