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,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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user