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>
160 lines
5.1 KiB
TypeScript
160 lines
5.1 KiB
TypeScript
'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>
|
|
)
|
|
}
|