diff --git a/app-9xzmfic2e4g1/.env b/app-9xzmfic2e4g1/.env index fe0e137..9c770a4 100644 --- a/app-9xzmfic2e4g1/.env +++ b/app-9xzmfic2e4g1/.env @@ -1,5 +1,5 @@ VITE_SUPABASE_URL=https://ofqojaxiopqxahfvxpmx.supabase.co VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9mcW9qYXhpb3BxeGFoZnZ4cG14Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzIyODExMjAsImV4cCI6MjA4Nzg1NzEyMH0.CVyjWPp9ldCd5qxA4TbViD5MJ0axbEWfGr-1n1pPjn0 -VITE_GOOGLE_MAPS_API_KEY=AIzaSyCLPiqNWwFSUS0X15YvTdHZxrb-2LXoYlw +VITE_GOOGLE_MAPS_API_KEY=AIzaSyBbXWk7VhZfzn9txrAr9N-faAPuKy_LnKw VITE_APP_ID=app-9xzmfic2e4g1 VITE_FORM_ID=form-9xzmfic2e4g1 \ No newline at end of file diff --git a/app-9xzmfic2e4g1/src/components/layout/Navbar.tsx b/app-9xzmfic2e4g1/src/components/layout/Navbar.tsx index fbcdbeb..aa87a8e 100644 --- a/app-9xzmfic2e4g1/src/components/layout/Navbar.tsx +++ b/app-9xzmfic2e4g1/src/components/layout/Navbar.tsx @@ -2,8 +2,7 @@ import { Link, useNavigate } from 'react-router-dom'; import { useAuth } from '@/contexts/AuthContext'; import { Button } from '@/components/ui/button'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; -import { Separator } from '@/components/ui/separator'; -import { MapPin, History, Compass, User, LogOut, Search, Settings, FileText } from 'lucide-react'; +import { MapPin, History, User, LogOut, Search, Settings } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, @@ -24,7 +23,6 @@ export function Navbar() { const [searchOpen, setSearchOpen] = useState(false); const [scrolled, setScrolled] = useState(false); - // Scroll efekti için useEffect(() => { const handleScroll = () => { setScrolled(window.scrollY > 10); @@ -40,117 +38,113 @@ export function Navbar() { return ( <> - {/* Navbar yüksekliği kadar boşluk bırak */}
- {/* Arama modalı */} ); diff --git a/app-9xzmfic2e4g1/src/components/planner/LeadCaptureModal.tsx b/app-9xzmfic2e4g1/src/components/planner/LeadCaptureModal.tsx new file mode 100644 index 0000000..d2619a3 --- /dev/null +++ b/app-9xzmfic2e4g1/src/components/planner/LeadCaptureModal.tsx @@ -0,0 +1,268 @@ +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { + Form, + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select'; +import { Checkbox } from '@/components/ui/checkbox'; +import { leadService } from '@/domain/lead/leadService'; +import { LeadCategory } from '@/types/lead'; +import { toast } from 'sonner'; +import { Loader2, MessageCircle, Send } from 'lucide-react'; + +const leadFormSchema = z.object({ + full_name: z.string().min(2, 'İsim en az 2 karakter olmalıdır'), + phone: z.string().min(10, 'Geçerli bir telefon numarası giriniz'), + whatsapp_available: z.boolean(), + participants: z.number().min(1).max(50), + budget_range: z.string().optional(), + preferred_date: z.string().optional(), +}); + +type LeadFormValues = { + full_name: string; + phone: string; + whatsapp_available: boolean; + participants: number; + budget_range?: string; + preferred_date?: string; +}; + +interface LeadCaptureModalProps { + isOpen: boolean; + onClose: () => void; + category: LeadCategory; + categoryLabel: string; + userId?: string; + tripId?: string; + defaultParticipants?: number; + defaultDate?: string; + tripDurationDays?: number; +} + +export const LeadCaptureModal = ({ + isOpen, + onClose, + category, + categoryLabel, + userId, + tripId, + defaultParticipants = 2, + defaultDate, + tripDurationDays = 0 +}: LeadCaptureModalProps) => { + const [isSubmitting, setIsSubmitting] = useState(false); + + const form = useForm({ + resolver: zodResolver(leadFormSchema), + defaultValues: { + full_name: '', + phone: '', + whatsapp_available: true, + participants: defaultParticipants, + budget_range: 'moderate', + preferred_date: defaultDate || '', + }, + }); + + const onSubmit = async (data: LeadFormValues) => { + setIsSubmitting(true); + try { + await leadService.createLead({ + full_name: data.full_name, + phone: data.phone, + whatsapp_available: data.whatsapp_available, + participants: data.participants, + budget_range: data.budget_range, + preferred_date: data.preferred_date, + category, + user_id: userId, + trip_id: tripId, + }, tripDurationDays); + + toast.success('Talebiniz alındı!', { + description: 'En kısa sürede WhatsApp üzerinden sizinle iletişime geçilecektir.' + }); + onClose(); + } catch (error) { + console.error('Error creating lead:', error); + toast.error('Talep gönderilirken bir hata oluştu.'); + } finally { + setIsSubmitting(false); + } + }; + + const getTitle = () => { + switch (category) { + case 'balloon': return 'Balon Turu İçin Özel Teklif Alın'; + case 'atv': + case 'horse': return 'Aktiviteler İçin En İyi Fiyatı Alın'; + case 'hotel': return 'WhatsApp\'a Özel Otel İndirimi Alın'; + default: return `${categoryLabel} İçin Teklif Alın`; + } + }; + + const getDescription = () => { + switch (category) { + case 'balloon': return 'Kapadokya\'nın en güvenilir operatörlerinden size özel fiyat teklifleri hazırlayalım.'; + case 'hotel': return 'Seçtiğiniz tarihler için otellerden size özel indirimli fiyatlar alın.'; + default: return 'Yerel uzmanlarımız en iyi seçenekleri sizin için değerlendirsin.'; + } + }; + + return ( + + +
+ + + {getTitle()} + + + {getDescription()} + + +
+ +
+ +
+ ( + + Ad Soyad + + + + + + )} + /> + + ( + + Telefon / WhatsApp + + + + + + )} + /> + + ( + + Kişi Sayısı + + field.onChange(parseInt(e.target.value) || 0)} + onBlur={field.onBlur} + name={field.name} + ref={field.ref} + className="h-12 rounded-xl bg-zinc-50 border-zinc-100" + /> + + + + )} + /> + + ( + + Bütçe Aralığı + + + + )} + /> + + ( + + + + +
+ + + WhatsApp üzerinden dönüş almak istiyorum + +
+
+ )} + /> +
+ + + + +
+ +
+
+ ); +}; \ No newline at end of file diff --git a/app-9xzmfic2e4g1/src/constants/planner.ts b/app-9xzmfic2e4g1/src/constants/planner.ts index f12c9e7..4172daa 100644 --- a/app-9xzmfic2e4g1/src/constants/planner.ts +++ b/app-9xzmfic2e4g1/src/constants/planner.ts @@ -1,63 +1,169 @@ -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 { - Loader2, ArrowRight, ArrowLeft, Sparkles, - MapPin, Calendar, Users, Coffee, Heart, - Car, Wallet, CheckCircle2, ChevronRight, - PersonStanding, + Hotel, Home, Navigation, Plane, Trees, Building2, Camera, + Bike, UtensilsCrossed, Users, Heart, Baby, + Car, Bus, Shuffle, Gem, Wallet, CreditCard, Crown, + PersonStanding, Calendar } 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'; -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'; +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, + }, +] as const; -// ─── 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), -}); +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; -type FormValues = z.infer; +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; -// ─── Steps ──────────────────────────────────────────────────────────────────── -const STEPS = [ +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; + +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; + +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; + +export 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?' }, @@ -65,465 +171,3 @@ const STEPS = [ { 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; - -// ─── 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; - } -} - -// ─── 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); - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - dateRange: { from: undefined, to: undefined }, - travelType: '', - travelers: 2, - accommodation: 'center', - transport: '', - budget: '', - interests: [], - }, - }); - - const watchedValues = form.watch(); - - // ── 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: 'moderate', - preferences: `Type:${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, dailySchedule: 'moderate', preferences: '' }, - 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 diff --git a/app-9xzmfic2e4g1/src/domain/lead/leadService.ts b/app-9xzmfic2e4g1/src/domain/lead/leadService.ts new file mode 100644 index 0000000..cb3b4db --- /dev/null +++ b/app-9xzmfic2e4g1/src/domain/lead/leadService.ts @@ -0,0 +1,155 @@ + +import { supabase } from '../../db/supabase'; +import { Lead, LeadCategory, Operator, LeadStatus } from '../../types/lead'; + +export const leadService = { + /** + * Calculates a lead score based on the criteria: + * - 2+ kişi: +10 + * - 3+ gün kalış: +15 + * - Balon seçmiş: +25 + * - Budget belirtilmiş: +20 + * - Tarih net: +15 + */ + scoreLead(lead: Partial, tripDurationDays: number = 0): number { + let score = 0; + + // 2+ kişi: +10 + if (lead.participants && lead.participants >= 2) { + score += 10; + } + + // 3+ gün kalış: +15 (if trip context is provided) + if (tripDurationDays >= 3) { + score += 15; + } + + // Balon seçmiş: +25 + if (lead.category === 'balloon') { + score += 25; + } + + // Budget belirtilmiş: +20 + if (lead.budget_range && lead.budget_range !== 'not_sure') { + score += 20; + } + + // Tarih net: +15 + if (lead.preferred_date) { + score += 15; + } + + return score; + }, + + /** + * Creates a new lead in the database and triggers scoring and assignment. + */ + async createLead(leadData: Partial, tripDurationDays: number = 0): Promise { + const score = this.scoreLead(leadData, tripDurationDays); + + const { data: lead, error } = await supabase + .from('leads') + .insert({ + ...leadData, + score, + status: 'new' as LeadStatus + }) + .select() + .single(); + + if (error) throw error; + + // Log creation event + await this.logEvent(lead.id, 'created', { score }); + + // Auto-assign operator + const assignedOperator = await this.assignOperator(lead); + + if (assignedOperator) { + const { data: updatedLead, error: updateError } = await supabase + .from('leads') + .update({ + assigned_operator_id: assignedOperator.id, + status: 'assigned' as LeadStatus + }) + .eq('id', lead.id) + .select() + .single(); + + if (updateError) throw updateError; + + await this.logEvent(lead.id, 'assigned', { operator_id: assignedOperator.id }); + await this.notifyOperator(assignedOperator, updatedLead); + + return updatedLead; + } + + return lead; + }, + + /** + * Assigns the best available operator based on category, active status, daily limit, and priority. + */ + async assignOperator(lead: Lead): Promise { + // 1. Get active operators for the category + const { data: operators, error } = await supabase + .from('operators') + .select('*') + .eq('category', lead.category) + .eq('active', true) + .order('priority_score', { ascending: false }); + + if (error) throw error; + if (!operators || operators.length === 0) return null; + + // 2. Check daily limits (simplified: count leads assigned today) + const today = new Date().toISOString().split('T')[0]; + + for (const operator of operators) { + const { count, error: countError } = await supabase + .from('leads') + .select('*', { count: 'exact', head: true }) + .eq('assigned_operator_id', operator.id) + .gte('created_at', today); + + if (countError) continue; + + if ((count || 0) < operator.max_daily_leads) { + return operator; + } + } + + return null; + }, + + /** + * Notifies the operator via WhatsApp (simulated/template link) or email. + */ + async notifyOperator(operator: Operator, lead: Lead) { + const message = `Yeni ${lead.category.toUpperCase()} Lead:\nTarih: ${lead.preferred_date}\nKişi: ${lead.participants}\nBütçe: ${lead.budget_range}\nAd: ${lead.full_name}\nTelefon: ${lead.phone}`; + + // In a real app, this would trigger an Edge Function or Twilio/WhatsApp API + console.log(`Notifying Operator ${operator.name} (${operator.whatsapp}): ${message}`); + + await this.logEvent(lead.id, 'notified', { + operator_id: operator.id, + method: operator.whatsapp ? 'whatsapp' : 'email' + }); + }, + + /** + * Logs events for analytics. + */ + async logEvent(leadId: string, eventType: string, metadata: any = {}) { + const { error } = await supabase + .from('lead_events') + .insert({ + lead_id: leadId, + event_type: eventType, + metadata + }); + + if (error) console.error('Error logging lead event:', error); + } +}; diff --git a/app-9xzmfic2e4g1/src/pages/AccountPage.tsx b/app-9xzmfic2e4g1/src/pages/AccountPage.tsx index c41a99b..576a132 100644 --- a/app-9xzmfic2e4g1/src/pages/AccountPage.tsx +++ b/app-9xzmfic2e4g1/src/pages/AccountPage.tsx @@ -5,7 +5,7 @@ import api, { Trip } from '@/db/api'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; -import { Loader2, Calendar, MapPin, Trash2, ChevronRight, PlusCircle, Zap, Compass as ExploreIcon } from 'lucide-react'; +import { Loader2, Calendar, MapPin, Trash2, ChevronRight, PlusCircle, Zap, Compass as ExploreIcon, History, User } from 'lucide-react'; import { format } from 'date-fns'; import { tr } from 'date-fns/locale'; import { toast } from 'sonner'; @@ -57,23 +57,16 @@ export default function AccountPage() { if (!user) { return ( -
-
- bg -
- -
- +
+ +
+
-
-

HESABIM

-

Gezilerinizi yönetmek için giriş yapmalısınız.

+
+

HESABINIZ

+

Gezilerinizi görüntülemek ve yönetmek için giriş yapın.

- @@ -82,93 +75,91 @@ export default function AccountPage() { } return ( -
-
-
-
+
+
+
+
- Kişisel Arşiv - - {trips.length} Rota + + {trips.length} KAYITLI ROTA
-

+

GEZİLERİM

-

Planladığınız Kapadokya efsaneleri.

+

Kişisel Kapadokya arşiviniz.

- -
+ {loading ? ( -
- -

Yükleniyor...

+
+ +

Veriler Getiriliyor...

) : trips.length === 0 ? ( - -
- + +
+
-
-

Planınız yok

-

- İlk Kapadokya rotanızı oluşturun. +

+

HENÜZ PLAN YOK

+

+ Henüz bir seyahat rotası oluşturmadınız.

- ) : ( -
+
{trips.map((trip, idx) => ( - +
-
+
{trip.title} -
- - +
+ {trip.itinerary.days.length} GÜN
-
-
-
+
+
+
- {trip.destination} + {trip.destination}
-

+

{trip.title}

-
-
- +
+
+ {format(new Date(trip.start_date), 'd MMM yyyy', { locale: tr })}
@@ -176,34 +167,34 @@ export default function AccountPage() { - - - - Planı Sil? - - Silmek istediğinize emin misiniz? + + + GEZİYİ SİL? + + Bu rotayı kalıcı olarak silmek istediğinize emin misiniz? Bu işlem geri alınamaz. - - Hayır + + İptal handleDelete(trip.id)} - className="h-12 px-8 rounded-xl font-black bg-red-600 hover:bg-red-700 text-white" + className="h-14 px-10 rounded-2xl font-black uppercase tracking-widest text-[11px] bg-red-600 hover:bg-red-700 text-white border-none shadow-lg shadow-red-600/20" > - Evet, Sil + Silmeyi Onayla
-
-
- {trip.itinerary.days[0].items.slice(0, 3).map((item, i) => ( -
+
+
+ {trip.itinerary.days[0].items.slice(0, 4).map((item, i) => ( +
))} + {trip.itinerary.days[0].items.length > 4 && ( +
+ +{trip.itinerary.days[0].items.length - 4} +
+ )}
- @@ -231,4 +227,4 @@ export default function AccountPage() {
); -} \ No newline at end of file +} diff --git a/app-9xzmfic2e4g1/src/pages/ExplorePage.tsx b/app-9xzmfic2e4g1/src/pages/ExplorePage.tsx index 8e719d7..2293585 100644 --- a/app-9xzmfic2e4g1/src/pages/ExplorePage.tsx +++ b/app-9xzmfic2e4g1/src/pages/ExplorePage.tsx @@ -3,8 +3,9 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; -import { Search, Star, Clock, Filter, Sparkles, Heart, Share2, ArrowUpRight, Camera } from 'lucide-react'; +import { Search, Star, Clock, Filter, Sparkles, Heart, Share2, ArrowUpRight, Camera, MapPin, ChevronRight } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; +import { Link } from 'react-router-dom'; const CATEGORIES = [ { id: 'all', label: 'Tümü' }, @@ -23,7 +24,7 @@ const PLACES = [ rating: 4.8, reviews: 12400, duration: '2-3 Saat', - image: 'https://miaoda-site-img.s3cdn.medo.dev/images/KLing_2736e413-5912-4327-880c-f8494d625176.jpg', + image: 'https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=800', description: 'UNESCO Dünya Mirası listesinde yer alan, 10. ve 12. yüzyıllar arasında oyulmuş eşsiz kiliseler topluluğu.', }, { @@ -33,7 +34,7 @@ const PLACES = [ rating: 4.7, reviews: 8500, duration: '1 Saat', - image: 'https://miaoda-site-img.s3cdn.medo.dev/images/KLing_8ea8dda5-57a3-4533-bd11-28440db86c34.jpg', + image: 'https://images.unsplash.com/photo-1502602898657-3e91760cbb34?auto=format&fit=crop&q=80&w=800', description: 'Kapadokya\'s en yüksek noktası. Tüm bölgeyi panoramik olarak görebileceğiniz devasa bir doğal kaya kalesi.', }, { @@ -43,7 +44,7 @@ const PLACES = [ rating: 4.9, reviews: 15200, duration: '1.5 Saat', - image: 'https://miaoda-site-img.s3cdn.medo.dev/images/KLing_9c62deaa-2f09-44b8-a9e4-1d03ae155cce.jpg', + image: 'https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=800', description: 'Binlerce yıl önce insanların saldırılardan korunmak için inşa ettiği, yerin 85 metre altına kadar uzanan muazzam bir yapı.', }, { @@ -53,7 +54,7 @@ const PLACES = [ rating: 4.6, reviews: 6300, duration: '4-5 Saat', - image: 'https://miaoda-site-img.s3cdn.medo.dev/images/KLing_a0a63c27-9e25-4f1a-89ef-aea901ec645d.jpg', + image: 'https://images.unsplash.com/photo-1502602898657-3e91760cbb34?auto=format&fit=crop&q=80&w=800', description: 'Melendiz Çayı\'nın aşındırmasıyla oluşmuş 14 kilometrelik kanyon. İçerisinde onlarca kaya oyma kilise bulunur.', }, { @@ -63,7 +64,7 @@ const PLACES = [ rating: 4.8, reviews: 9100, duration: '1 Saat', - image: 'https://miaoda-site-img.s3cdn.medo.dev/images/KLing_8cfa89df-0b41-4cb0-9e72-f7c2e4ebda81.jpg', + image: 'https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=800', description: 'Kapadokya\'nın en karakteristik peribacalarının bulunduğu yer. Bazı peribacaları üç şapkalı yapıya sahiptir.', }, { @@ -73,7 +74,7 @@ const PLACES = [ rating: 4.5, reviews: 4200, duration: '1-2 Saat', - image: 'https://miaoda-site-img.s3cdn.medo.dev/images/KLing_dd8b8233-fb7c-4dc3-90fe-eb24b5e136ee.jpg', + image: 'https://images.unsplash.com/photo-1502602898657-3e91760cbb34?auto=format&fit=crop&q=80&w=800', description: 'Kızılırmak\'tan çıkan kızıl toprakla binlerce yıldır devam eden çömlekçilik geleneğini deneyimleyin.', } ]; @@ -90,69 +91,58 @@ export default function ExplorePage() { }); return ( -
- {/* Immersive Header */} -
-
- Explore Hero -
-
- -
- - - Efsanevi Duraklar - - - - KAPADOKYA KEŞFİ - - - - - setSearchQuery(e.target.value)} - /> - +
+ {/* Minimalist Header */} +
+
+
+
+ + + KEŞİF REHBERİ + + + Kapadokya'yı
Keşfedin +
+
+ + + + setSearchQuery(e.target.value)} + /> + +
-
+
{/* Categories Carousel */} -
- -
+
{CATEGORIES.map((cat) => ( -
-
- - +
+ + {place.rating}
-
-
-

+
+
+

{place.name}

-

- "{place.description}" +

+ {place.description}

-
-
-
- +
+
+
+ {place.duration}
-
- - Görsel -
- +
@@ -251,28 +225,21 @@ export default function ExplorePage() { key="empty" initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} - className="py-20 text-center space-y-6" + className="py-24 text-center space-y-6" > -
- +
+
-

BULUNAMADI

-

- Farklı bir keşif terimi deneyin. +

SONUÇ YOK

+

+ Aramanıza uygun mekan bulunamadı.

- )}
); -} +} \ No newline at end of file diff --git a/app-9xzmfic2e4g1/src/pages/LandingPage.tsx b/app-9xzmfic2e4g1/src/pages/LandingPage.tsx index c2604d4..d6aaab1 100644 --- a/app-9xzmfic2e4g1/src/pages/LandingPage.tsx +++ b/app-9xzmfic2e4g1/src/pages/LandingPage.tsx @@ -1,8 +1,7 @@ import { Link } from 'react-router-dom'; import { Button } from '@/components/ui/button'; -import { Sparkles, MapPin, Calendar, Compass, ShieldCheck, Zap, ArrowRight, Star, ArrowUpRight, Play, Globe } from 'lucide-react'; -import { motion, useScroll, useTransform } from 'framer-motion'; -import { useRef } from 'react'; +import { Sparkles, MapPin, Calendar, Compass, ShieldCheck, Zap, ArrowRight, Star, Globe, ChevronRight } from 'lucide-react'; +import { motion } from 'framer-motion'; const FeatureCard = ({ icon: Icon, title, description, index }: { icon: any, title: string, description: string, index: number }) => ( -
- +
+
-

{title}

-

{description}

+

{title}

+

{description}

); export default function LandingPage() { - const containerRef = useRef(null); - const { scrollYProgress } = useScroll({ - target: containerRef, - offset: ["start start", "end end"] - }); - - const heroY = useTransform(scrollYProgress, [0, 1], [0, 200]); - const heroOpacity = useTransform(scrollYProgress, [0, 0.2], [1, 0]); - return ( -
+
{/* Hero Section */} -
- {/* Cinematic Background */} - -
- Cappadocia - - - {/* Hero Content */} -
- - - Yapay Zeka Destekli Premium Deneyim - - - - KAPADOKYA
- - EFSANESİ - -
- - - "Sıradan bir gezi değil, ruhunuza dokunacak bir keşif hikayesi. Saniyeler içinde size özel kurgulanmış premium rotalar." - - - - - - +
+ {/* Subtle background patterns */} +
+ + + + + + + +
- {/* Scroll Indicator */} - -
-
+
+
+ + + Yapay Zeka Destekli Seyahat Rehberiniz + + + + Kapadokya'yı
+ Yeniden Keşfedin +
+ + + Kişiselleştirilmiş rotalar, anlık öneriler ve kusursuz bir planlama deneyimi. Yapay zeka ile her detayı düşünülmüş bir Kapadokya efsanesi. + + + + + +
- +
- {/* Stats Section */} -
-
-
-
- {[ - { label: "Mutlu Gezgin", value: "25k+", icon: Globe }, - { label: "Kişiye Özel Rota", value: "100k+", icon: Compass }, - { label: "Doğrulanmış Mekan", value: "1.2k", icon: MapPin }, - { label: "Müşteri Puanı", value: "4.9", icon: Star }, - ].map((stat, i) => ( - -
- -
-
{stat.value}
-
{stat.label}
-
- ))} + {/* Trust Stats */} +
+
+
+
+ + TOP DESTINATION 2026 +
+
+ + GLOBAL REACH +
+
+ + SECURE BOOKING +
{/* Features Grid */} -
+
-
+
- Kusursuz Mühendislik + Neden Biz? - Seyahatinizi Sanata
Dönüştürüyoruz + Seyahat Planlamanın
Geleceği Burada
-
+
- {/* Luxury CTA */} -
+ {/* Modern CTA */} +
-
- CTA -
+ {/* Subtle noise/texture overlay */} +
-
-

- BİR SONRAKİ
EFSANENİZİ YAZIN +
+

+ BÜYÜLÜ BİR GEZİ
SİZİ BEKLİYOR

-

- Kapadokya'nın zamansız ruhunu, modern teknolojinin gücüyle birleştirin. Bugün başlayın. +

+ Kapadokya'nın masalsı dokusunu modern teknolojinin kolaylığıyla keşfedin. Planlamaya bugün başlayın.

-
@@ -233,36 +200,58 @@ export default function LandingPage() {

- {/* Modern Footer */} -
); -} \ No newline at end of file +} diff --git a/app-9xzmfic2e4g1/src/types/lead.ts b/app-9xzmfic2e4g1/src/types/lead.ts new file mode 100644 index 0000000..5585b2d --- /dev/null +++ b/app-9xzmfic2e4g1/src/types/lead.ts @@ -0,0 +1,45 @@ + +export type LeadCategory = 'balloon' | 'atv' | 'horse' | 'hotel' | 'restaurant' | 'transfer' | 'other'; + +export type LeadStatus = 'new' | 'assigned' | 'contacted' | 'converted' | 'lost'; + +export interface Lead { + id?: string; + user_id?: string; + trip_id?: string; + category: LeadCategory; + budget_range?: string; + participants: number; + preferred_date?: string; + status: LeadStatus; + score: number; + assigned_operator_id?: string; + full_name: string; + phone: string; + whatsapp_available: boolean; + email?: string; + metadata?: any; + created_at?: string; + updated_at?: string; +} + +export interface Operator { + id: string; + name: string; + category: LeadCategory; + whatsapp?: string; + email?: string; + max_daily_leads: number; + priority_score: number; + active: boolean; + created_at: string; + updated_at: string; +} + +export interface LeadEvent { + id?: string; + lead_id: string; + event_type: string; + metadata?: any; + created_at?: string; +} diff --git a/app-9xzmfic2e4g1/supabase/migrations/00007_lead_marketplace.sql b/app-9xzmfic2e4g1/supabase/migrations/00007_lead_marketplace.sql new file mode 100644 index 0000000..f3b46b1 --- /dev/null +++ b/app-9xzmfic2e4g1/supabase/migrations/00007_lead_marketplace.sql @@ -0,0 +1,98 @@ + +-- Lead Categories +CREATE TYPE public.lead_category AS ENUM ('balloon', 'atv', 'horse', 'hotel', 'restaurant', 'transfer', 'other'); + +-- Lead Status +CREATE TYPE public.lead_status AS ENUM ('new', 'assigned', 'contacted', 'converted', 'lost'); + +-- Operators Table +CREATE TABLE public.operators ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + name text NOT NULL, + category public.lead_category NOT NULL, + whatsapp text, + email text, + max_daily_leads int DEFAULT 10, + priority_score int DEFAULT 0, + active boolean DEFAULT true, + created_at timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + updated_at timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL +); + +-- Leads Table +CREATE TABLE public.leads ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + user_id uuid REFERENCES public.profiles(id) ON DELETE SET NULL, + trip_id uuid REFERENCES public.trips(id) ON DELETE SET NULL, + category public.lead_category NOT NULL, + budget_range text, + participants int DEFAULT 1, + preferred_date date, + status public.lead_status DEFAULT 'new'::public.lead_status, + score int DEFAULT 0, + assigned_operator_id uuid REFERENCES public.operators(id) ON DELETE SET NULL, + + -- User details captured at lead point + full_name text, + phone text, + whatsapp_available boolean DEFAULT false, + email text, + metadata jsonb DEFAULT '{}'::jsonb, + + created_at timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL, + updated_at timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL +); + +-- Lead Events Table (for Analytics) +CREATE TABLE public.lead_events ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + lead_id uuid REFERENCES public.leads(id) ON DELETE CASCADE, + event_type text NOT NULL, -- e.g., 'created', 'scored', 'assigned', 'notified', 'status_changed' + metadata jsonb DEFAULT '{}'::jsonb, + created_at timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL +); + +-- Enable RLS +ALTER TABLE public.operators ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.leads ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.lead_events ENABLE ROW LEVEL SECURITY; + +-- Operators Policies (Admins only for management) +CREATE POLICY "Admins can manage operators" ON public.operators + FOR ALL TO authenticated USING (is_admin(auth.uid())); + +CREATE POLICY "Public can view active operators (for assignment logic check)" ON public.operators + FOR SELECT TO authenticated USING (active = true); + +-- Leads Policies +CREATE POLICY "Users can view their own leads" ON public.leads + FOR SELECT TO authenticated USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert their own leads" ON public.leads + FOR INSERT TO authenticated WITH CHECK (auth.uid() = user_id OR auth.uid() IS NULL); + +CREATE POLICY "Admins have full access to leads" ON public.leads + FOR ALL TO authenticated USING (is_admin(auth.uid())); + +-- Lead Events Policies +CREATE POLICY "Admins have full access to lead events" ON public.lead_events + FOR ALL TO authenticated USING (is_admin(auth.uid())); + +-- Functions +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER update_operators_updated_at + BEFORE UPDATE ON operators + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_leads_updated_at + BEFORE UPDATE ON leads + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); diff --git a/app-9xzmfic2e4g1/supabase/migrations/00008_seed_operators.sql b/app-9xzmfic2e4g1/supabase/migrations/00008_seed_operators.sql new file mode 100644 index 0000000..2864b47 --- /dev/null +++ b/app-9xzmfic2e4g1/supabase/migrations/00008_seed_operators.sql @@ -0,0 +1,10 @@ + +-- Seed Operators +INSERT INTO public.operators (name, category, whatsapp, email, max_daily_leads, priority_score) +VALUES +('Kapadokya Balon Efsanesi', 'balloon', '+905001112233', 'info@balonefsanesi.com', 20, 100), +('Sultan Balloon Tours', 'balloon', '+905002223344', 'booking@sultanballoons.com', 15, 80), +('Vadiler Safari (ATV)', 'atv', '+905003334455', 'safari@cappadocia.com', 50, 90), +('Kapadokya At Çiftliği', 'horse', '+905004445566', 'at@cappadocia.com', 30, 85), +('Cave Legend Hotel', 'hotel', '+905005556677', 'stay@cavelegend.com', 10, 95), +('Görsel Sanatlar Restoran', 'restaurant', '+905006667788', 'food@cappadocia.com', 100, 70); diff --git a/app-9xzmfic2e4g1/supabase/migrations/00009_create_place_details_cache.sql b/app-9xzmfic2e4g1/supabase/migrations/00009_create_place_details_cache.sql new file mode 100644 index 0000000..05a0067 --- /dev/null +++ b/app-9xzmfic2e4g1/supabase/migrations/00009_create_place_details_cache.sql @@ -0,0 +1,6 @@ +-- Create table to cache Google Places API details +create table place_details_cache ( + place_id text primary key, + details jsonb not null, + created_at timestamptz default now() +);