2026-03-02 13:51:18 +00:00

459 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useMemo, useEffect, useRef, useCallback, memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import api from '@/db/api';
import { Label } from '@/components/ui/label';
import { Progress } from '@/components/ui/progress';
import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form';
import { toast } from 'sonner';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { format, differenceInDays } from 'date-fns';
import { Loader2, ArrowRight, ArrowLeft, Sparkles, MapPin, Calendar, Users, Coffee, Heart } from 'lucide-react';
import { parseApiError } from '@/utils/errorHandler';
import { retryWithBackoff, withTimeout } from '@/utils/retryWithBackoff';
import { motion, AnimatePresence } from 'framer-motion';
import { Button } from '@/components/ui/button';
// Constants
import { LOADING_STEPS } from '@/constants/planner';
// Components
import { DateSelector } from '@/components/planner/DateSelector';
import { TravelerInput } from '@/components/planner/TravelerInput';
import { AccommodationSelector } from '@/components/planner/AccommodationSelector';
import { InterestsGrid } from '@/components/planner/InterestsGrid';
// Form validation schema
const formSchema = z.object({
dateRange: z.object({
from: z.date({
required_error: 'Başlangıç tarihi gereklidir',
}),
to: z.date({
required_error: 'Bitiş tarihi gereklidir',
}),
}).refine(
(data) => {
if (!data.from || !data.to) return false;
const today = new Date();
today.setHours(0, 0, 0, 0);
return data.from >= today;
},
{
message: 'Başlangıç tarihi bugünden önce olamaz',
}
).refine(
(data) => {
if (!data.from || !data.to) return false;
return data.to > data.from;
},
{
message: 'Bitiş tarihi başlangıç tarihinden sonra olmalıdır',
}
).refine(
(data) => {
if (!data.from || !data.to) return false;
const days = differenceInDays(data.to, data.from) + 1;
return days >= 1 && days <= 14;
},
{
message: 'Seyahat süresi 1-14 gün arasında olmalıdır',
}
),
travelers: z.number()
.min(1, 'En az 1 yolcu olmalıdır')
.max(15, '15+ kişi için lütfen bizimle iletişime geçin'),
accommodation: z.string(),
interests: z.array(z.string())
.min(1, 'En az 1 ilgi alanı seçmelisiniz')
.max(6, 'Maksimum 6 ilgi alanı seçebilirsiniz'),
});
type FormValues = z.infer<typeof formSchema>;
const STEPS = [
{ id: 'dates', title: 'Tarihler', icon: Calendar, description: 'Ne zaman gidiyorsunuz?' },
{ id: 'travelers', title: 'Seyahat Grubu', icon: Users, description: 'Kiminle seyahat ediyorsunuz?' },
{ id: 'accommodation', title: 'Konaklama', icon: Coffee, description: 'Nasıl bir konaklama istersiniz?' },
{ id: 'interests', title: 'İlgi Alanları', icon: Heart, description: 'Neleri keşfetmek istersiniz?' }
];
const PlannerPage = () => {
const { user } = useAuth();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [loadingStep, setLoadingStep] = useState(0);
const [currentStep, setCurrentStep] = useState(0);
const [datePickerOpen, setDatePickerOpen] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
dateRange: {
from: undefined,
to: undefined,
},
travelers: 2,
accommodation: 'center',
interests: [],
},
});
const watchedValues = form.watch();
const nextStep = async () => {
// Validate current step fields
const currentStepId = STEPS[currentStep].id;
let isValid = false;
if (currentStepId === 'dates') {
isValid = await form.trigger('dateRange');
} else if (currentStepId === 'travelers') {
isValid = await form.trigger('travelers');
} else if (currentStepId === 'accommodation') {
isValid = await form.trigger('accommodation');
} else if (currentStepId === 'interests') {
isValid = await form.trigger('interests');
}
if (isValid && currentStep < STEPS.length - 1) {
setCurrentStep(prev => prev + 1);
}
};
const prevStep = () => {
if (currentStep > 0) {
setCurrentStep(prev => prev - 1);
}
};
const handleInterestToggle = useCallback((interestId: string) => {
const currentInterests = form.getValues('interests');
const newInterests = currentInterests.includes(interestId)
? currentInterests.filter(id => id !== interestId)
: [...currentInterests, interestId];
form.setValue('interests', newInterests, { shouldValidate: true });
}, [form]);
const simulateLoadingSteps = useCallback(() => {
setLoadingStep(0);
const interval = setInterval(() => {
setLoadingStep(prev => {
if (prev < LOADING_STEPS.length - 1) return prev + 1;
clearInterval(interval);
return prev;
});
}, 2500);
return interval;
}, []);
const onSubmit = async (data: FormValues) => {
setLoading(true);
setLoadingStep(0);
abortControllerRef.current = new AbortController();
const loadingInterval = simulateLoadingSteps();
try {
const startDate = format(data.dateRange.from, 'yyyy-MM-dd');
const endDate = format(data.dateRange.to, 'yyyy-MM-dd');
const formData = {
startDate,
endDate,
interests: data.interests,
dailySchedule: 'moderate',
preferences: `Accommodation: ${data.accommodation}, Travelers: ${data.travelers}`,
};
const result = await retryWithBackoff(
async () => {
return await withTimeout(
api.generateItinerary(formData),
45000,
new Error('Sunucu yanıt vermiyor, lütfen tekrar deneyin.')
);
},
{
maxRetries: 2,
initialDelay: 1000,
maxDelay: 5000
}
);
clearInterval(loadingInterval);
let tripId = '';
if (user) {
const savedTrip = await api.saveTrip({
user_id: user.id,
title: `Kapadokya Gezisi`,
destination: 'Cappadocia',
start_date: startDate,
end_date: endDate,
preferences: formData,
itinerary: result,
});
tripId = savedTrip.id;
} else {
sessionStorage.setItem('pending_trip', JSON.stringify(result));
navigate('/login', { state: { from: '/planner', message: 'Planınızı kaydetmek için giriş yapın' } });
return;
}
navigate(`/trip/${tripId}`);
toast.success('Rotanız hazır!');
} catch (err) {
clearInterval(loadingInterval);
if (err instanceof Error && err.name === 'AbortError') return;
const apiError = parseApiError(err);
toast.error('Hata oluştu', { description: apiError.userMessage });
} finally {
setLoading(false);
setLoadingStep(0);
}
};
const progress = ((currentStep + 1) / STEPS.length) * 100;
if (loading) {
return (
<div className="h-screen w-full flex flex-col items-center justify-center bg-secondary relative overflow-hidden">
<div className="absolute inset-0 z-0 opacity-20">
<img
src="https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=2400"
alt="Loading bg"
className="w-full h-full object-cover grayscale"
/>
</div>
<div className="relative z-10 max-w-xl w-full px-6 text-center space-y-8">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="w-24 h-24 bg-primary rounded-2xl flex items-center justify-center mx-auto shadow-2xl shadow-primary/20"
>
<Loader2 className="h-12 w-12 text-white animate-spin" />
</motion.div>
<div className="space-y-4">
<h2 className="text-3xl md:text-4xl font-black text-white tracking-tighter uppercase leading-tight">
{LOADING_STEPS[loadingStep].label}
</h2>
<div className="w-full h-3 bg-white/10 rounded-full overflow-hidden border border-white/10">
<motion.div
className="h-full bg-primary"
initial={{ width: 0 }}
animate={{ width: `${LOADING_STEPS[loadingStep].progress}%` }}
transition={{ duration: 0.5 }}
/>
</div>
</div>
<p className="text-white/40 font-bold tracking-widest uppercase text-xs italic">
"Sizin için en kusursuz Kapadokya efsanesini kurguluyoruz..."
</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-background flex flex-col lg:flex-row overflow-hidden">
{/* Sidebar - Progress & Stats */}
<div className="w-full lg:w-[320px] xl:w-[380px] bg-secondary p-8 lg:p-12 flex flex-col justify-between relative overflow-hidden shrink-0">
<div className="absolute top-0 left-0 w-full h-full bg-primary/5 rounded-full blur-[80px] -translate-x-1/2 -translate-y-1/2" />
<div className="relative z-10 space-y-10">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-primary rounded-xl flex items-center justify-center text-white">
<MapPin className="h-5 w-5" />
</div>
<span className="text-xl font-black text-white tracking-tighter uppercase">Kapadokya <span className="text-primary">Efsanesi</span></span>
</div>
<div className="space-y-6">
<h1 className="text-4xl font-black text-white leading-[0.95] tracking-tighter uppercase">
ROTANIZI <br /> <span className="text-primary">TASARLAYIN</span>
</h1>
<p className="text-white/40 text-base font-medium italic">
"Her durak bir hikaye, her an bir anı. Size özel kurgulanmış seyahat mimarisi."
</p>
</div>
<div className="space-y-5">
{STEPS.map((step, i) => {
const Icon = step.icon;
const isActive = i === currentStep;
const isCompleted = i < currentStep;
return (
<div key={step.id} className="flex items-center gap-5 group">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center border-2 transition-luxury ${
isActive ? 'bg-primary border-primary text-white scale-110' :
isCompleted ? 'bg-white/5 border-primary/40 text-primary' :
'bg-white/5 border-white/10 text-white/20'
}`}>
<Icon className="h-5 w-5" />
</div>
<div className="space-y-0.5">
<div className={`text-[10px] font-black uppercase tracking-widest ${isActive ? 'text-white' : 'text-white/20'}`}>Step 0{i+1}</div>
<div className={`text-lg font-bold ${isActive ? 'text-white' : 'text-white/40'}`}>{step.title}</div>
</div>
</div>
);
})}
</div>
</div>
<div className="relative z-10 pt-10">
<div className="p-6 bg-white/5 backdrop-blur-xl rounded-2xl border border-white/10 space-y-3">
<div className="flex justify-between items-center text-white/40 text-[10px] font-bold uppercase tracking-widest">
<span>Mükemmellik Oranı</span>
<span>100%</span>
</div>
<div className="h-1.5 bg-white/10 rounded-full overflow-hidden">
<div className="h-full bg-primary" style={{ width: `${progress}%` }} />
</div>
</div>
</div>
</div>
{/* Main Form Area */}
<div className="flex-1 bg-white dark:bg-card p-8 lg:p-16 overflow-y-auto">
<div className="max-w-2xl mx-auto h-full flex flex-col">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 flex flex-col">
<AnimatePresence mode="wait">
<motion.div
key={currentStep}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.5, ease: "easeOut" }}
className="flex-1 space-y-10"
>
<div className="space-y-3">
<h2 className="text-3xl md:text-5xl font-black text-gray-900 dark:text-white tracking-tighter leading-[0.95] uppercase">
{STEPS[currentStep].description}
</h2>
<p className="text-lg text-gray-500 font-medium italic">
Lütfen tercihlerinizi belirleyin, biz sizin için kurgulayalım.
</p>
</div>
<div className="space-y-8 pt-6">
{currentStep === 0 && (
<FormField
control={form.control}
name="dateRange"
render={({ field }) => (
<FormItem className="space-y-3">
<Label className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">Seyahat Takvimi</Label>
<DateSelector
date={field.value}
onDateChange={field.onChange}
isOpen={datePickerOpen}
onOpenChange={setDatePickerOpen}
/>
<FormMessage />
</FormItem>
)}
/>
)}
{currentStep === 1 && (
<FormField
control={form.control}
name="travelers"
render={({ field }) => (
<FormItem className="space-y-3">
<Label className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">Grup Büyüklüğü</Label>
<TravelerInput value={field.value} onChange={field.onChange} />
<FormMessage />
</FormItem>
)}
/>
)}
{currentStep === 2 && (
<FormField
control={form.control}
name="accommodation"
render={({ field }) => (
<FormItem className="space-y-3">
<Label className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">Konaklama Tarzı</Label>
<AccommodationSelector selectedId={field.value} onSelect={field.onChange} />
<FormMessage />
</FormItem>
)}
/>
)}
{currentStep === 3 && (
<FormField
control={form.control}
name="interests"
render={({ field }) => (
<FormItem className="space-y-3">
<div className="flex justify-between items-end">
<Label className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">Kişisel İlgi Alanları</Label>
<span className="text-[10px] font-bold text-gray-400">{field.value.length}/6 Seçildi</span>
</div>
<InterestsGrid selectedInterests={field.value} onToggle={handleInterestToggle} />
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</motion.div>
</AnimatePresence>
{/* Navigation Controls */}
<div className="pt-12 mt-auto flex items-center justify-between gap-4 border-t border-gray-100">
<Button
type="button"
variant="ghost"
size="lg"
onClick={prevStep}
disabled={currentStep === 0}
className="h-14 px-8 text-base font-bold rounded-xl hover:bg-gray-50 group"
>
<ArrowLeft className="mr-2 h-5 w-5 group-hover:-translate-x-1 transition-transform" />
Geri
</Button>
{currentStep < STEPS.length - 1 ? (
<Button
type="button"
size="lg"
onClick={nextStep}
className="h-14 px-10 text-base font-black bg-primary hover:bg-primary-dark rounded-xl shadow-lg shadow-primary/20 group uppercase tracking-widest"
>
Devam Et
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
</Button>
) : (
<Button
type="submit"
size="lg"
className="h-14 px-12 text-base font-black bg-primary hover:bg-primary-dark rounded-xl shadow-lg shadow-primary/20 group uppercase tracking-widest"
>
Rotayı Oluştur
<Sparkles className="ml-2 h-5 w-5 animate-pulse" />
</Button>
)}
</div>
</form>
</Form>
</div>
</div>
</div>
);
};
export default memo(PlannerPage);