diff --git a/app-9xzmfic2e4g1/src/pages/PlannerPage.tsx b/app-9xzmfic2e4g1/src/pages/PlannerPage.tsx index d3de30a..979b0cf 100644 --- a/app-9xzmfic2e4g1/src/pages/PlannerPage.tsx +++ b/app-9xzmfic2e4g1/src/pages/PlannerPage.tsx @@ -1,164 +1,543 @@ +import { useState, useMemo, 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 { 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 { - Hotel, Home, Navigation, Plane, Trees, Building2, Camera, - Bike, UtensilsCrossed, Users, Heart, Baby, - Car, Bus, Shuffle, Gem, Wallet, CreditCard, Crown, - PersonStanding + Loader2, ArrowRight, ArrowLeft, Sparkles, + MapPin, Calendar, Users, Coffee, Heart, + Car, Wallet, CheckCircle2, ChevronRight, + PersonStanding, } 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'; +import { cn } from '@/lib/utils'; -export const ACCOMMODATION_OPTIONS = [ - { - id: 'hotel', - label: 'Otel', - description: 'Konforlu & servis odaklı', - icon: Hotel, - }, - { - id: 'airbnb', - label: 'Airbnb / Ev', - description: 'Yerel & özgün deneyim', - icon: Home, - }, - { - id: 'center', - label: 'Merkezi', - description: 'Her şeye yakın konum', - icon: Navigation, - }, +import { LOADING_STEPS, TRAVEL_TYPE_OPTIONS, BUDGET_OPTIONS, TRANSPORT_OPTIONS, ACCOMMODATION_OPTIONS, INTEREST_OPTIONS } from '@/constants/planner'; +import { DateSelector } from '@/components/planner/DateSelector'; +import { TravelerInput } from '@/components/planner/TravelerInput'; +import { AccommodationSelector } from '@/components/planner/AccommodationSelector'; +import { InterestsGrid } from '@/components/planner/InterestsGrid'; +import { TravelTypeSelector } from '@/components/planner/TravelTypeSelector'; +import { TransportSelector } from '@/components/planner/TransportSelector'; +import { BudgetSelector } from '@/components/planner/BudgetSelector'; + +// ─── 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(d => d.from >= new Date(new Date().setHours(0, 0, 0, 0)), { + message: 'Başlangıç tarihi bugünden önce olamaz', + }) + .refine(d => d.to > d.from, { + message: 'Bitiş tarihi başlangıç tarihinden sonra olmalıdır', + }) + .refine(d => { + const days = differenceInDays(d.to, d.from) + 1; + return days >= 1 && days <= 14; + }, { message: 'Seyahat süresi 1–14 gün arasında olmalıdır' }), + travelType: z.string().min(1, 'Seyahat tipi seçiniz'), + travelers: z.number().min(1).max(15), + accommodation: z.string(), + transport: z.string().min(1, 'Ulaşım tercihi seçiniz'), + budget: z.string().min(1, 'Bütçe aralığı seçiniz'), + interests: z.array(z.string()).min(1, 'En az 1 ilgi alanı seçiniz').max(6), +}); + +type FormValues = z.infer; + +// ─── Steps ──────────────────────────────────────────────────────────────────── +const STEPS = [ + { id: 'dates', title: 'Tarihler', icon: Calendar, description: 'Ne zaman gidiyorsunuz?' }, + { id: 'travelType', title: 'Seyahat Tipi', icon: PersonStanding, description: 'Nasıl bir seyahat?' }, + { id: 'travelers', title: 'Grup & Konaklama', icon: Users, description: 'Kiminle, nerede kalıyorsunuz?' }, + { id: 'transport', title: 'Ulaşım', icon: Car, description: 'Nasıl seyahat edeceksiniz?' }, + { id: 'budget', title: 'Bütçe', icon: Wallet, description: 'Ne kadar harcamayı planlıyorsunuz?' }, + { id: 'interests', title: 'İlgi Alanları', icon: Heart, description: 'Neleri keşfetmek istersiniz?' }, ] as const; -export const TRAVEL_TYPE_OPTIONS = [ - { - id: 'solo', - label: 'Solo', - description: 'Kendi tempomda özgür', - icon: PersonStanding, - gradient: 'from-violet-500 to-purple-600', - bg: 'bg-violet-50', - border: 'border-violet-200', - text: 'text-violet-600', - }, - { - id: 'couple', - label: 'Çift', - description: 'Romantik & özel anlar', - icon: Heart, - gradient: 'from-rose-500 to-pink-600', - bg: 'bg-rose-50', - border: 'border-rose-200', - text: 'text-rose-600', - }, - { - id: 'family', - label: 'Aile', - description: 'Çocuklar dahil herkes', - icon: Baby, - gradient: 'from-amber-500 to-orange-500', - bg: 'bg-amber-50', - border: 'border-amber-200', - text: 'text-amber-600', - }, - { - id: 'friends', - label: 'Arkadaşlar', - description: 'Grup enerjisi & macera', - icon: Users, - gradient: 'from-emerald-500 to-teal-600', - bg: 'bg-emerald-50', - border: 'border-emerald-200', - text: 'text-emerald-600', - }, -] as const; +// ─── Summary label helpers ──────────────────────────────────────────────────── +function getSummaryLabel(stepId: string, values: Partial): string | null { + switch (stepId) { + case 'dates': + if (values.dateRange?.from && values.dateRange?.to) + return `${format(values.dateRange.from, 'd MMM')} – ${format(values.dateRange.to, 'd MMM')}`; + return null; + case 'travelType': + return TRAVEL_TYPE_OPTIONS.find(o => o.id === values.travelType)?.label ?? null; + case 'travelers': + return values.travelers ? `${values.travelers} kişi` : null; + case 'transport': + return TRANSPORT_OPTIONS.find(o => o.id === values.transport)?.label ?? null; + case 'budget': + return BUDGET_OPTIONS.find(o => o.id === values.budget)?.label ?? null; + case 'interests': + return values.interests?.length ? `${values.interests.length} seçildi` : null; + default: + return null; + } +} -export const TRANSPORT_OPTIONS = [ - { - id: 'rental', - label: 'Araç Kiralama', - description: 'Özgür & esnek rota', - icon: Car, - }, - { - id: 'transfer', - label: 'Özel Transfer', - description: 'Konforlu & zahmetsiz', - icon: Gem, - }, - { - id: 'shuttle', - label: 'Servis / Minibüs', - description: 'Ekonomik grup ulaşımı', - icon: Bus, - }, - { - id: 'mixed', - label: 'Karma', - description: 'Duruma göre en iyisi', - icon: Shuffle, - }, -] as const; +// ─── PlannerPage ────────────────────────────────────────────────────────────── +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); -export const BUDGET_OPTIONS = [ - { - id: 'budget', - label: 'Ekonomik', - description: 'Akıllı harcama, tam deneyim', - range: '₺500 – ₺1.000 / gün', - icon: Wallet, - tier: 1, - color: 'text-sky-600', - activeBg: 'bg-sky-50', - activeBorder: 'border-sky-400', - dot: 'bg-sky-400', - }, - { - id: 'moderate', - label: 'Orta', - description: 'Kalite & tasarruf dengesi', - range: '₺1.000 – ₺2.500 / gün', - icon: CreditCard, - tier: 2, - color: 'text-emerald-600', - activeBg: 'bg-emerald-50', - activeBorder: 'border-emerald-400', - dot: 'bg-emerald-400', - }, - { - id: 'comfort', - label: 'Konforlu', - description: 'Ekstra konfor & özel deneyim', - range: '₺2.500 – ₺5.000 / gün', - icon: Gem, - tier: 3, - color: 'text-orange-600', - activeBg: 'bg-orange-50', - activeBorder: 'border-orange-400', - dot: 'bg-orange-400', - }, - { - id: 'luxury', - label: 'Lüks', - description: 'Sınırsız & ayrıcalıklı', - range: '₺5.000+ / gün', - icon: Crown, - tier: 4, - color: 'text-amber-600', - activeBg: 'bg-amber-50', - activeBorder: 'border-amber-400', - dot: 'bg-amber-400', - }, -] as const; + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + dateRange: { from: undefined, to: undefined }, + travelType: '', + travelers: 2, + accommodation: 'center', + transport: '', + budget: '', + interests: [], + }, + }); -export const INTEREST_OPTIONS = [ - { id: 'balloon', label: 'Sıcak Hava Balonu', icon: Plane }, - { id: 'nature', label: 'Doğa & Yürüyüş', icon: Trees }, - { id: 'history', label: 'Tarih & Kültür', icon: Building2 }, - { id: 'photography', label: 'Fotoğraf', icon: Camera }, - { id: 'adventure', label: 'Macera', icon: Bike }, - { id: 'gastronomy', label: 'Gastronomi', icon: UtensilsCrossed }, -] as const; + const watchedValues = form.watch(); -export const LOADING_STEPS = [ - { label: 'Tercihleriniz analiz ediliyor...', progress: 0 }, - { label: 'Rotanız oluşturuluyor...', progress: 33 }, - { label: 'Mekanlar seçiliyor...', progress: 66 }, - { label: 'Son rötuşlar yapılıyor...', progress: 90 }, -] as const; \ No newline at end of file + // ── Navigation ────────────────────────────────────────────────────────────── + const nextStep = async () => { + const stepId = STEPS[currentStep].id; + const fieldMap: Record = { + dates: 'dateRange', + travelType: 'travelType', + travelers: ['travelers', 'accommodation'], + transport: 'transport', + budget: 'budget', + interests: 'interests', + }; + const fields = fieldMap[stepId]; + const isValid = await form.trigger(Array.isArray(fields) ? fields : [fields]); + if (isValid && currentStep < STEPS.length - 1) setCurrentStep(p => p + 1); + }; + + const prevStep = () => { if (currentStep > 0) setCurrentStep(p => p - 1); }; + + const handleInterestToggle = useCallback((id: string) => { + const current = form.getValues('interests'); + const next = current.includes(id) ? current.filter(i => i !== id) : [...current, id]; + form.setValue('interests', next, { shouldValidate: true }); + }, [form]); + + const simulateLoadingSteps = useCallback(() => { + setLoadingStep(0); + const iv = setInterval(() => { + setLoadingStep(prev => { + if (prev < LOADING_STEPS.length - 1) return prev + 1; + clearInterval(iv); + return prev; + }); + }, 2500); + return iv; + }, []); + + // ── Submit ────────────────────────────────────────────────────────────────── + const onSubmit = async (data: FormValues) => { + setLoading(true); + const iv = simulateLoadingSteps(); + try { + const startDate = format(data.dateRange.from, 'yyyy-MM-dd'); + const endDate = format(data.dateRange.to, 'yyyy-MM-dd'); + const result = await retryWithBackoff( + () => withTimeout( + api.generateItinerary({ + startDate, + endDate, + interests: data.interests, + dailySchedule: data.travelType === 'family' ? 'relaxed' : data.budget === 'luxury' ? 'relaxed' : 'moderate', + travelType: data.travelType, + accommodation: data.accommodation, + transport: data.transport, + budget: data.budget, + travelers: data.travelers, + }), + 45000, + new Error('Sunucu yanıt vermiyor, lütfen tekrar deneyin.') + ), + { maxRetries: 2, initialDelay: 1000, maxDelay: 5000 } + ); + clearInterval(iv); + if (user) { + const saved = await api.saveTrip({ + user_id: user.id, + title: 'Kapadokya Gezisi', + destination: 'Cappadocia', + start_date: startDate, + end_date: endDate, + preferences: { + startDate, + endDate, + interests: data.interests, + travelType: data.travelType, + accommodation: data.accommodation, + transport: data.transport, + budget: data.budget, + travelers: data.travelers, + }, + itinerary: result, + }); + navigate(`/trip/${saved.id}`); + toast.success('Rotanız hazır!'); + } else { + sessionStorage.setItem('pending_trip', JSON.stringify(result)); + navigate('/login', { state: { from: '/planner', message: 'Planınızı kaydetmek için giriş yapın' } }); + } + } catch (err) { + clearInterval(iv); + if (err instanceof Error && err.name === 'AbortError') return; + toast.error('Hata oluştu', { description: parseApiError(err).userMessage }); + } finally { + setLoading(false); + setLoadingStep(0); + } + }; + + const progress = ((currentStep + 1) / STEPS.length) * 100; + + // ── Loading screen ────────────────────────────────────────────────────────── + if (loading) { + return ( +
+
+ +
+
+ +
+ +
+
+ +
+ + +
+ + + {LOADING_STEPS[loadingStep].label} + + + +
+ +
+ +
+ Hazırlanıyor + {LOADING_STEPS[loadingStep].progress}% +
+
+ +

+ Size özel Kapadokya efsanesi kurguluyoruz... +

+
+
+ ); + } + + // ── Main layout ───────────────────────────────────────────────────────────── + return ( +
+ + {/* ── Sidebar ─────────────────────────────────────────────────────────── */} +
+ {/* Decorative orbs */} +
+
+ +
+ {/* Logo */} +
+
+ +
+ + Kapadokya Efsanesi + +
+ + {/* Headline */} +
+

+ ROTANIZI
TASARLAYIN +

+

+ "Size özel kurgulanmış seyahat mimarisi." +

+
+ + {/* Step list */} +
+ {STEPS.map((step, i) => { + const Icon = step.icon; + const isActive = i === currentStep; + const isCompleted = i < currentStep; + const summaryLabel = getSummaryLabel(step.id, watchedValues); + + return ( + + {/* Step indicator */} +
+ {isCompleted + ? + : + } +
+ + {/* Labels */} +
+
+ {String(i + 1).padStart(2, '0')} +
+
+ {step.title} +
+
+ + {/* Summary pill */} + {isCompleted && summaryLabel && ( + + {summaryLabel} + + )} + + {isActive && ( + + )} +
+ ); + })} +
+ + {/* Progress bar */} +
+
+ İlerleme + {Math.round(progress)}% +
+
+ +
+
+
+
+ + {/* ── Main Form Area ──────────────────────────────────────────────────── */} +
+
+
+ + + + + {/* Step header */} +
+
+ Adım {currentStep + 1}/{STEPS.length} + + {STEPS[currentStep].title} +
+

+ {STEPS[currentStep].description} +

+
+ + {/* Step content */} +
+ + {/* Step 0 — Dates */} + {currentStep === 0 && ( + ( + + + + + + )} /> + )} + + {/* Step 1 — Travel Type */} + {currentStep === 1 && ( + ( + + + + + + )} /> + )} + + {/* Step 2 — Travelers & Accommodation */} + {currentStep === 2 && ( +
+ ( + + + + + + )} /> + ( + + + + + + )} /> +
+ )} + + {/* Step 3 — Transport */} + {currentStep === 3 && ( + ( + + + + + + )} /> + )} + + {/* Step 4 — Budget */} + {currentStep === 4 && ( + ( + + + + + + )} /> + )} + + {/* Step 5 — Interests */} + {currentStep === 5 && ( + ( + +
+ + {field.value.length}/6 Seçildi +
+ + +
+ )} /> + )} +
+
+
+ + {/* Navigation */} +
+ + + {currentStep < STEPS.length - 1 ? ( + + ) : ( + + )} +
+
+ +
+
+
+ ); +}; + +export default memo(PlannerPage); \ No newline at end of file