Compare commits

..

No commits in common. "ai-dev" and "main" have entirely different histories.
ai-dev ... main

29 changed files with 1625 additions and 4002 deletions

View File

View File

View File

@ -2,7 +2,6 @@ import { memo } from 'react';
import { ACCOMMODATION_OPTIONS } from '@/constants/planner'; import { ACCOMMODATION_OPTIONS } from '@/constants/planner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { motion } from 'framer-motion';
interface AccommodationSelectorProps { interface AccommodationSelectorProps {
selectedId: string; selectedId: string;
@ -11,48 +10,34 @@ interface AccommodationSelectorProps {
export const AccommodationSelector = memo(({ selectedId, onSelect }: AccommodationSelectorProps) => { export const AccommodationSelector = memo(({ selectedId, onSelect }: AccommodationSelectorProps) => {
return ( return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{ACCOMMODATION_OPTIONS.map((option, index) => { {ACCOMMODATION_OPTIONS.map((option) => {
const Icon = option.icon; const Icon = option.icon;
const isSelected = selectedId === option.id; const isSelected = selectedId === option.id;
return ( return (
<motion.div <Button
key={option.id} key={option.id}
initial={{ opacity: 0, scale: 0.95 }} type="button"
animate={{ opacity: 1, scale: 1 }} variant="outline"
transition={{ delay: index * 0.1 }} className={cn(
"h-auto py-3 flex items-center justify-start gap-3 border-2 transition-all duration-200",
isSelected
? "border-orange-500 bg-orange-50 text-orange-700 hover:bg-orange-100 hover:text-orange-800"
: "border-gray-100 hover:border-orange-200 hover:bg-orange-50/50"
)}
onClick={() => onSelect(option.id)}
> >
<Button <Icon className={cn(
type="button" "h-5 w-5",
variant="outline" isSelected ? "text-orange-600" : "text-gray-400"
className={cn( )} />
"h-auto p-6 w-full flex flex-col items-center justify-center gap-4 rounded-2xl border-2 transition-luxury group relative overflow-hidden", <span className="text-sm font-medium">{option.label}</span>
isSelected </Button>
? "border-primary bg-primary/5 text-primary shadow-xl shadow-primary/5"
: "border-gray-50 dark:border-white/5 bg-gray-50/50 dark:bg-white/5 text-gray-500 hover:border-primary/30 hover:bg-primary/5"
)}
onClick={() => onSelect(option.id)}
>
<div className={cn(
"w-14 h-14 rounded-xl flex items-center justify-center transition-luxury group-hover:scale-105 group-hover:rotate-3 shadow-sm",
isSelected ? "bg-primary text-white" : "bg-gray-100 dark:bg-white/10 text-gray-400"
)}>
<Icon className="h-7 w-7" />
</div>
<div className="text-center space-y-0.5">
<span className="text-base font-black uppercase tracking-widest block">{option.label}</span>
<span className="text-xs font-medium italic opacity-60">Tercih Edilen Tarz</span>
</div>
{isSelected && (
<div className="absolute top-3 right-3 w-2 h-2 bg-primary rounded-full animate-pulse" />
)}
</Button>
</motion.div>
); );
})} })}
</div> </div>
); );
}); });
AccommodationSelector.displayName = 'AccommodationSelector'; AccommodationSelector.displayName = 'AccommodationSelector';

View File

@ -1,106 +0,0 @@
import { memo } from 'react';
import { BUDGET_OPTIONS } from '@/constants/planner';
import { cn } from '@/lib/utils';
import { motion } from 'framer-motion';
interface BudgetSelectorProps {
selectedId: string;
onSelect: (id: string) => void;
}
export const BudgetSelector = memo(({ selectedId, onSelect }: BudgetSelectorProps) => {
const selectedIndex = BUDGET_OPTIONS.findIndex(o => o.id === selectedId);
return (
<div className="space-y-4">
{/* Visual tier bar */}
<div className="flex gap-1.5 mb-6">
{BUDGET_OPTIONS.map((option, i) => (
<motion.div
key={option.id}
className={cn(
'h-1.5 flex-1 rounded-full transition-all duration-300',
i <= selectedIndex ? option.dot : 'bg-gray-100'
)}
animate={{ scaleX: i <= selectedIndex ? 1 : 0.6 }}
transition={{ duration: 0.3, delay: i * 0.05 }}
/>
))}
</div>
<div className="grid grid-cols-1 gap-3">
{BUDGET_OPTIONS.map((option, index) => {
const Icon = option.icon;
const isSelected = selectedId === option.id;
return (
<motion.button
key={option.id}
type="button"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.06, duration: 0.3 }}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.98 }}
onClick={() => onSelect(option.id)}
className={cn(
'relative group flex items-center gap-4 px-5 py-4 rounded-2xl border-2 text-left transition-all duration-200 w-full overflow-hidden',
isSelected
? `${option.activeBorder} ${option.activeBg} shadow-md`
: 'border-gray-100 bg-gray-50/60 hover:border-gray-200 hover:bg-white hover:shadow-sm'
)}
>
{/* Tier dots */}
<div className="absolute top-4 right-4 flex gap-1">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className={cn(
'w-1.5 h-1.5 rounded-full transition-all duration-200',
i < option.tier
? isSelected ? option.dot : 'bg-gray-300'
: 'bg-gray-100'
)}
/>
))}
</div>
{/* Icon */}
<div className={cn(
'w-11 h-11 rounded-xl flex items-center justify-center transition-all duration-200 shrink-0',
isSelected
? `${option.activeBg} ${option.color} border-2 ${option.activeBorder}`
: 'bg-white text-gray-400 border border-gray-100 shadow-sm'
)}>
<Icon className="h-5 w-5" />
</div>
{/* Text */}
<div className="flex-1 min-w-0 pr-12">
<p className={cn(
'text-sm font-black uppercase tracking-wider',
isSelected ? option.color : 'text-gray-700'
)}>
{option.label}
</p>
<p className="text-xs font-medium text-gray-400 mt-0.5">
{option.description}
</p>
</div>
{/* Price range */}
<div className={cn(
'absolute bottom-3 right-4 text-[10px] font-bold tracking-wide transition-colors',
isSelected ? option.color : 'text-gray-300'
)}>
{option.range}
</div>
</motion.button>
);
})}
</div>
</div>
);
});
BudgetSelector.displayName = 'BudgetSelector';

View File

@ -2,7 +2,6 @@ import { memo } from 'react';
import { INTEREST_OPTIONS } from '@/constants/planner'; import { INTEREST_OPTIONS } from '@/constants/planner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { motion } from 'framer-motion';
interface InterestsGridProps { interface InterestsGridProps {
selectedInterests: string[]; selectedInterests: string[];
@ -11,48 +10,34 @@ interface InterestsGridProps {
export const InterestsGrid = memo(({ selectedInterests, onToggle }: InterestsGridProps) => { export const InterestsGrid = memo(({ selectedInterests, onToggle }: InterestsGridProps) => {
return ( return (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4"> <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{INTEREST_OPTIONS.map((interest, index) => { {INTEREST_OPTIONS.map((interest) => {
const Icon = interest.icon; const Icon = interest.icon;
const isSelected = selectedInterests.includes(interest.id); const isSelected = selectedInterests.includes(interest.id);
return ( return (
<motion.div <Button
key={interest.id} key={interest.id}
initial={{ opacity: 0, y: 10 }} type="button"
animate={{ opacity: 1, y: 0 }} variant="outline"
transition={{ delay: index * 0.05 }} className={cn(
"h-auto py-4 flex flex-col items-center gap-2 border-2 transition-all duration-200",
isSelected
? "border-orange-500 bg-orange-50 text-orange-700 hover:bg-orange-100 hover:text-orange-800"
: "border-gray-100 hover:border-orange-200 hover:bg-orange-50/50"
)}
onClick={() => onToggle(interest.id)}
> >
<Button <Icon className={cn(
type="button" "h-6 w-6",
variant="outline" isSelected ? "text-orange-600" : "text-gray-500"
className={cn( )} />
"h-auto py-6 w-full flex flex-col items-center gap-3 rounded-2xl border-2 transition-luxury group relative overflow-hidden", <span className="text-xs font-semibold">{interest.label}</span>
isSelected </Button>
? "border-primary bg-primary/5 text-primary shadow-lg shadow-primary/5"
: "border-gray-50 dark:border-white/5 bg-gray-50/50 dark:bg-white/5 text-gray-500 hover:border-primary/30 hover:bg-primary/5"
)}
onClick={() => onToggle(interest.id)}
>
{isSelected && (
<motion.div
layoutId="selected-bg"
className="absolute inset-0 bg-gradient-to-br from-primary/5 to-accent/5 -z-10"
/>
)}
<div className={cn(
"w-10 h-10 rounded-lg flex items-center justify-center transition-luxury group-hover:scale-105",
isSelected ? "bg-primary text-white" : "bg-gray-100 dark:bg-white/10 text-gray-400 group-hover:text-primary"
)}>
<Icon className="h-5 w-5" />
</div>
<span className="text-xs font-black uppercase tracking-widest">{interest.label}</span>
</Button>
</motion.div>
); );
})} })}
</div> </div>
); );
}); });
InterestsGrid.displayName = 'InterestsGrid'; InterestsGrid.displayName = 'InterestsGrid';

View File

@ -1,87 +0,0 @@
import { memo } from 'react';
import { TRANSPORT_OPTIONS } from '@/constants/planner';
import { cn } from '@/lib/utils';
import { motion } from 'framer-motion';
import { CheckCircle2 } from 'lucide-react';
interface TransportSelectorProps {
selectedId: string;
onSelect: (id: string) => void;
}
export const TransportSelector = memo(({ selectedId, onSelect }: TransportSelectorProps) => {
return (
<div className="grid grid-cols-1 gap-3">
{TRANSPORT_OPTIONS.map((option, index) => {
const Icon = option.icon;
const isSelected = selectedId === option.id;
return (
<motion.button
key={option.id}
type="button"
initial={{ opacity: 0, x: -16 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.07, duration: 0.3, ease: 'easeOut' }}
whileHover={{ x: 4 }}
whileTap={{ scale: 0.98 }}
onClick={() => onSelect(option.id)}
className={cn(
'relative group flex items-center gap-4 px-5 py-4 rounded-2xl border-2 text-left transition-all duration-200 w-full overflow-hidden',
isSelected
? 'border-orange-400 bg-orange-50 shadow-md shadow-orange-100'
: 'border-gray-100 bg-gray-50/60 hover:border-gray-200 hover:bg-white hover:shadow-sm'
)}
>
{/* Animated left accent bar */}
<motion.div
className="absolute left-0 top-0 bottom-0 w-1 bg-orange-500 rounded-l-2xl"
initial={{ scaleY: 0 }}
animate={{ scaleY: isSelected ? 1 : 0 }}
transition={{ duration: 0.25 }}
/>
{/* Icon */}
<div className={cn(
'w-11 h-11 rounded-xl flex items-center justify-center transition-all duration-200 shrink-0',
isSelected
? 'bg-orange-500 text-white shadow-lg shadow-orange-200'
: 'bg-white text-gray-400 group-hover:text-gray-500 shadow-sm border border-gray-100'
)}>
<Icon className="h-5 w-5" />
</div>
{/* Text */}
<div className="flex-1 min-w-0">
<p className={cn(
'text-sm font-black uppercase tracking-wider',
isSelected ? 'text-orange-700' : 'text-gray-700'
)}>
{option.label}
</p>
<p className={cn(
'text-xs font-medium mt-0.5',
isSelected ? 'text-orange-500' : 'text-gray-400'
)}>
{option.description}
</p>
</div>
{/* Check */}
{isSelected && (
<motion.div
initial={{ scale: 0, rotate: -90 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
>
<CheckCircle2 className="h-5 w-5 text-orange-500 shrink-0" />
</motion.div>
)}
</motion.button>
);
})}
</div>
);
});
TransportSelector.displayName = 'TransportSelector';

View File

@ -1,89 +0,0 @@
import { memo } from 'react';
import { TRAVEL_TYPE_OPTIONS } from '@/constants/planner';
import { cn } from '@/lib/utils';
import { motion } from 'framer-motion';
import { CheckCircle2 } from 'lucide-react';
interface TravelTypeSelectorProps {
selectedId: string;
onSelect: (id: string) => void;
}
export const TravelTypeSelector = memo(({ selectedId, onSelect }: TravelTypeSelectorProps) => {
return (
<div className="grid grid-cols-2 gap-4">
{TRAVEL_TYPE_OPTIONS.map((option, index) => {
const Icon = option.icon;
const isSelected = selectedId === option.id;
return (
<motion.button
key={option.id}
type="button"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.07, duration: 0.35, ease: 'easeOut' }}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
onClick={() => onSelect(option.id)}
className={cn(
'relative group flex flex-col items-start gap-3 p-5 rounded-2xl border-2 text-left transition-all duration-200 overflow-hidden',
isSelected
? `${option.border} ${option.bg} shadow-md`
: 'border-gray-100 bg-gray-50/60 hover:border-gray-200 hover:bg-white hover:shadow-sm'
)}
>
{/* Background glow when selected */}
{isSelected && (
<motion.div
layoutId="travel-type-glow"
className={cn('absolute inset-0 opacity-10 bg-gradient-to-br', option.gradient)}
initial={false}
transition={{ duration: 0.3 }}
/>
)}
{/* Icon */}
<div className={cn(
'relative w-12 h-12 rounded-xl flex items-center justify-center transition-all duration-200',
isSelected
? `bg-gradient-to-br ${option.gradient} text-white shadow-lg`
: 'bg-white text-gray-400 group-hover:text-gray-500 shadow-sm border border-gray-100'
)}>
<Icon className="h-6 w-6" />
</div>
{/* Text */}
<div className="relative space-y-0.5">
<p className={cn(
'text-sm font-black uppercase tracking-widest',
isSelected ? option.text : 'text-gray-700'
)}>
{option.label}
</p>
<p className={cn(
'text-xs font-medium',
isSelected ? 'text-gray-500' : 'text-gray-400'
)}>
{option.description}
</p>
</div>
{/* Check badge */}
{isSelected && (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="absolute top-3 right-3"
>
<CheckCircle2 className={cn('h-5 w-5', option.text)} />
</motion.div>
)}
</motion.button>
);
})}
</div>
);
});
TravelTypeSelector.displayName = 'TravelTypeSelector';

View File

@ -1,7 +1,6 @@
import { memo } from 'react'; import { memo } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Users, Minus, Plus } from 'lucide-react'; import { Users, Minus, Plus } from 'lucide-react';
import { motion } from 'framer-motion';
interface TravelerInputProps { interface TravelerInputProps {
value: number; value: number;
@ -10,43 +9,31 @@ interface TravelerInputProps {
export const TravelerInput = memo(({ value, onChange }: TravelerInputProps) => { export const TravelerInput = memo(({ value, onChange }: TravelerInputProps) => {
return ( return (
<div className="flex flex-col md:flex-row items-center justify-between p-6 border-2 border-gray-50 dark:border-white/5 rounded-2xl bg-gray-50/50 dark:bg-white/5 gap-6"> <div className="flex items-center justify-between p-4 border border-gray-200 rounded-lg bg-gray-50/50">
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
<div className="w-12 h-12 bg-primary rounded-xl flex items-center justify-center text-white shadow-lg shadow-primary/20"> <Users className="h-5 w-5 text-gray-400" />
<Users className="h-6 w-6" />
</div>
<div> <div>
<p className="text-lg font-black text-gray-900 dark:text-white tracking-tighter">Kişi Sayısı</p> <p className="text-sm font-semibold text-gray-700">Kişi Sayısı</p>
<p className="text-sm text-gray-400 font-medium italic">{value} Yetişkin Gezgin</p> <p className="text-xs text-gray-500">{value} Yetişkin</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-6 bg-white dark:bg-black/20 p-2 rounded-xl shadow-sm border-2 border-gray-50 dark:border-white/5">
<Button <Button
type="button" type="button"
variant="ghost" variant="outline"
size="icon" size="icon"
className="h-10 w-10 rounded-lg bg-gray-50 dark:bg-white/5 hover:bg-primary hover:text-white transition-luxury disabled:opacity-20" className="h-8 w-8 rounded-full border-gray-300"
onClick={() => onChange(Math.max(1, value - 1))} onClick={() => onChange(Math.max(1, value - 1))}
disabled={value <= 1} disabled={value <= 1}
> >
<Minus className="h-4 w-4" /> <Minus className="h-4 w-4" />
</Button> </Button>
<span className="text-lg font-bold w-4 text-center">{value}</span>
<motion.span
key={value}
initial={{ scale: 1.1, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="text-2xl font-black w-8 text-center tracking-tighter"
>
{value}
</motion.span>
<Button <Button
type="button" type="button"
variant="ghost" variant="outline"
size="icon" size="icon"
className="h-10 w-10 rounded-lg bg-gray-50 dark:bg-white/5 hover:bg-primary hover:text-white transition-luxury disabled:opacity-20" className="h-8 w-8 rounded-full border-gray-300"
onClick={() => onChange(Math.min(15, value + 1))} onClick={() => onChange(Math.min(15, value + 1))}
disabled={value >= 15} disabled={value >= 15}
> >
@ -57,4 +44,4 @@ export const TravelerInput = memo(({ value, onChange }: TravelerInputProps) => {
); );
}); });
TravelerInput.displayName = 'TravelerInput'; TravelerInput.displayName = 'TravelerInput';

View File

@ -1,212 +1,95 @@
import { useEffect, useRef, useState, useMemo, useCallback } from 'react'; import { useEffect, useRef, useState, useMemo } from 'react';
import { ItineraryDay, Place } from '@/db/api'; import { ItineraryDay } from '@/db/api';
import { initGoogleMaps } from '@/lib/google-maps-loader'; import { initGoogleMaps } from '@/lib/google-maps-loader';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ZoomIn, ZoomOut, Maximize2, Plus, Star, Clock, ChevronRight, Lightbulb, CheckCircle2, Loader2, X, MessageSquare } from 'lucide-react'; import { ZoomIn, ZoomOut, Maximize2 } from 'lucide-react';
import api from '@/db/api'; import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { motion, AnimatePresence } from 'framer-motion';
interface MapProps { interface MapProps {
itinerary: { days: ItineraryDay[] }; itinerary: { days: ItineraryDay[] };
activePlaceId: string | null; activePlaceId: string | null;
onMarkerClick: (id: string) => void; onMarkerClick: (id: string) => void;
onAddPlace?: (place: Place) => void;
} }
// Color palette for different days
const DAY_COLORS = [ const DAY_COLORS = [
'#EA580C', '#3B82F6', // Blue - Day 1
'#2563EB', '#10B981', // Green - Day 2
'#059669', '#8B5CF6', // Purple - Day 3
'#7C3AED', '#F59E0B', // Amber - Day 4
'#DB2777', '#EF4444', // Red - Day 5
]; ];
interface PlaceDetail { export function TripMap({ itinerary, activePlaceId, onMarkerClick }: MapProps) {
place_id: string;
name: string;
summary?: string;
rating?: number;
total_ratings?: number;
is_open_now?: boolean | null;
opening_hours?: string[] | null;
why_visit: string[];
tips: string[];
reviews: { author: string; rating: number; text: string; time: string }[];
}
interface SelectedPOI {
place_id: string;
name: string;
lat: number;
lng: number;
photoUrl: string;
category: string;
formatted_address?: string;
rating?: number;
}
export function TripMap({ itinerary, activePlaceId, onMarkerClick, onAddPlace }: MapProps) {
const mapRef = useRef<HTMLDivElement>(null); const mapRef = useRef<HTMLDivElement>(null);
const [googleMap, setGoogleMap] = useState<google.maps.Map | null>(null); const [googleMap, setGoogleMap] = useState<google.maps.Map | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const markersRef = useRef<{ [key: string]: { marker: google.maps.Marker; dayIndex: number } }>({}); const markersRef = useRef<{ [key: string]: { marker: google.maps.Marker; dayIndex: number } }>({});
const polylinesRef = useRef<google.maps.Polyline[]>([]); const polylinesRef = useRef<google.maps.Polyline[]>([]);
const placesServiceRef = useRef<google.maps.places.PlacesService | null>(null); const infoWindowRef = useRef<google.maps.InfoWindow | null>(null);
// Detail panel state // Memoize itinerary to prevent unnecessary re-renders
const [selectedPOI, setSelectedPOI] = useState<SelectedPOI | null>(null); const itineraryKey = useMemo(() =>
const [placeDetail, setPlaceDetail] = useState<PlaceDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [added, setAdded] = useState(false);
const [activeTab, setActiveTab] = useState<'about' | 'reviews'>('about');
const itineraryKey = useMemo(() =>
JSON.stringify(itinerary.days.map(d => d.items.map(i => i.place_id))), JSON.stringify(itinerary.days.map(d => d.items.map(i => i.place_id))),
[itinerary] [itinerary]
); );
// ── Reset panel on itinerary change ──────────────────────────────────────
useEffect(() => {
setSelectedPOI(null);
setPlaceDetail(null);
setAdded(false);
}, [itineraryKey]);
// ── Fetch rich details ────────────────────────────────────────────────────
const fetchPlaceDetail = useCallback(async (poi: SelectedPOI) => {
setDetailLoading(true);
setPlaceDetail(null);
setActiveTab('about');
try {
const data = await api.getPlaceDetails({
place_id: poi.place_id,
name: poi.name,
category: poi.category,
});
setPlaceDetail(data);
} catch (e) {
console.error('Place detail fetch error:', e);
} finally {
setDetailLoading(false);
}
}, []);
// ── Handle add ────────────────────────────────────────────────────────────
const handleAdd = useCallback(() => {
if (!selectedPOI || !onAddPlace) return;
const place: Place = {
place_id: selectedPOI.place_id,
name: selectedPOI.name,
lat: selectedPOI.lat,
lng: selectedPOI.lng,
rating: selectedPOI.rating,
formatted_address: selectedPOI.formatted_address || '',
photo_reference: selectedPOI.photoUrl,
description: selectedPOI.category,
category: selectedPOI.category,
estimated_duration_minutes: 60,
start_time: '09:00',
end_time: '10:00',
};
onAddPlace(place);
setAdded(true);
setTimeout(() => {
setSelectedPOI(null);
setPlaceDetail(null);
setAdded(false);
}, 1500);
}, [selectedPOI, onAddPlace]);
// ── Map init ──────────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
const loadMap = async () => { const loadMap = async () => {
try { try {
const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY; const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY;
if (!apiKey || apiKey === 'YOUR_GOOGLE_MAPS_API_KEY') { if (!apiKey || apiKey === 'YOUR_GOOGLE_MAPS_API_KEY') {
setError('Google Maps API anahtarı eksik.'); setError('Google Maps API anahtarı eksik. Lütfen .env dosyasına geçerli bir VITE_GOOGLE_MAPS_API_KEY ekleyin.');
console.error('Google Maps API anahtarı eksik. Lütfen .env dosyasına VITE_GOOGLE_MAPS_API_KEY ekleyin.');
return; return;
} }
await initGoogleMaps(apiKey); await initGoogleMaps(apiKey);
if (mapRef.current && !googleMap) { if (mapRef.current && !googleMap) {
const map = new google.maps.Map(mapRef.current, { const map = new google.maps.Map(mapRef.current, {
center: { lat: 38.6431, lng: 34.8347 }, center: { lat: 38.6431, lng: 34.8347 }, // Central Cappadocia
zoom: 12, zoom: 12,
styles: [ styles: [
{ featureType: 'poi.business', stylers: [{ visibility: 'off' }] }, {
{ featureType: 'poi.park', elementType: 'labels.text', stylers: [{ visibility: 'on' }] }, "featureType": "poi",
"elementType": "labels",
"stylers": [{ "visibility": "off" }]
}
], ],
mapTypeControl: false, mapTypeControl: false,
streetViewControl: false, streetViewControl: false,
fullscreenControl: false, fullscreenControl: false,
zoomControl: false, zoomControl: false,
clickableIcons: true,
}); });
placesServiceRef.current = new google.maps.places.PlacesService(map);
// ── POI tıklama ───────────────────────────────────────────────────
if (onAddPlace) {
map.addListener('click', (e: google.maps.MapMouseEvent & { placeId?: string }) => {
if (!e.placeId) return;
e.stop?.();
const placeId = e.placeId;
setAdded(false);
// Önce temel bilgiyi Google'dan çek, sonra panel aç
placesServiceRef.current?.getDetails(
{
placeId,
fields: ['place_id', 'name', 'formatted_address', 'geometry', 'rating', 'photos', 'types'],
},
(place, status) => {
if (status !== google.maps.places.PlacesServiceStatus.OK || !place?.geometry?.location) return;
const photoUrl = place.photos?.[0]?.getUrl({ maxWidth: 600 }) || '';
const category = (place.types?.[0] || 'point_of_interest').replace(/_/g, ' ');
const poi: SelectedPOI = {
place_id: place.place_id || placeId,
name: place.name || '',
lat: place.geometry.location.lat(),
lng: place.geometry.location.lng(),
photoUrl,
category,
formatted_address: place.formatted_address || '',
rating: place.rating,
};
setSelectedPOI(poi);
fetchPlaceDetail(poi);
}
);
});
}
setGoogleMap(map); setGoogleMap(map);
infoWindowRef.current = new google.maps.InfoWindow();
} }
} catch { } catch (error) {
setError('Google Maps yüklenemedi.'); const errorMsg = 'Google Maps yüklenemedi. API anahtarınızı kontrol edin.';
setError(errorMsg);
console.error('Google Maps yüklenemedi:', error);
} }
}; };
if (!googleMap) loadMap(); if (!googleMap) {
}, [googleMap, onAddPlace, fetchPlaceDetail]); loadMap();
}
}, [googleMap]);
// ── Markers & polylines ───────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!googleMap || !itinerary?.days) return; if (!googleMap || !itinerary?.days) return;
// Clear existing markers and polylines
Object.values(markersRef.current).forEach(({ marker }) => marker.setMap(null)); Object.values(markersRef.current).forEach(({ marker }) => marker.setMap(null));
markersRef.current = {}; markersRef.current = {};
polylinesRef.current.forEach(p => p.setMap(null)); polylinesRef.current.forEach(polyline => polyline.setMap(null));
polylinesRef.current = []; polylinesRef.current = [];
const bounds = new google.maps.LatLngBounds(); const bounds = new google.maps.LatLngBounds();
const infoWindow = new google.maps.InfoWindow();
// Create markers and polylines for each day
itinerary.days.forEach((day, dayIndex) => { itinerary.days.forEach((day, dayIndex) => {
const dayColor = DAY_COLORS[dayIndex % DAY_COLORS.length]; const dayColor = DAY_COLORS[dayIndex % DAY_COLORS.length];
const dayPath: google.maps.LatLngLiteral[] = []; const dayPath: google.maps.LatLngLiteral[] = [];
@ -214,55 +97,71 @@ export function TripMap({ itinerary, activePlaceId, onMarkerClick, onAddPlace }:
day.items.forEach((item, itemIndex) => { day.items.forEach((item, itemIndex) => {
const position = { lat: item.lat, lng: item.lng }; const position = { lat: item.lat, lng: item.lng };
dayPath.push(position); dayPath.push(position);
const isActive = item.place_id === activePlaceId;
// Create custom marker with SVG
const marker = new google.maps.Marker({ const marker = new google.maps.Marker({
position, position,
map: googleMap, map: googleMap,
title: item.name, title: item.name,
zIndex: isActive ? 1000 : 1, label: {
label: { text: (itemIndex + 1).toString(), color: 'white', fontSize: '11px', fontWeight: '900' }, text: (itemIndex + 1).toString(),
color: 'white',
fontSize: '12px',
fontWeight: 'bold',
},
icon: { icon: {
path: 'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z', path: google.maps.SymbolPath.CIRCLE,
fillColor: dayColor, fillColor: dayColor,
fillOpacity: 1, fillOpacity: 1,
strokeWeight: 2, strokeWeight: 3,
strokeColor: '#ffffff', strokeColor: '#ffffff',
scale: isActive ? 1.8 : 1.4, scale: 14,
anchor: new google.maps.Point(12, 22),
labelOrigin: new google.maps.Point(12, 9),
}, },
animation: isActive ? google.maps.Animation.BOUNCE : undefined, animation: item.place_id === activePlaceId ? google.maps.Animation.BOUNCE : undefined,
}); });
marker.addListener('click', () => { marker.addListener('click', () => {
onMarkerClick(item.place_id); onMarkerClick(item.place_id);
const photoUrl = item.photo_reference if (infoWindowRef.current) {
? (item.photo_reference.startsWith('http') ? item.photo_reference : api.getPhotoUrl(item.photo_reference)) const photoUrl = item.photo_reference
: ''; ? `${window.location.origin}/api/photo?reference=${item.photo_reference}`
infoWindow.setContent(` : '';
<div style="padding:8px;min-width:200px;font-family:sans-serif;">
${photoUrl ? `<img src="${photoUrl}" style="width:100%;height:90px;object-fit:cover;border-radius:8px;margin-bottom:8px;" />` : ''} infoWindowRef.current.setContent(`
<h4 style="margin:0 0 2px;font-size:13px;font-weight:bold;">${item.name}</h4> <div style="color: black; max-width: 250px;">
<p style="margin:0 0 6px;font-size:10px;color:#6B7280;">${item.category}</p> ${photoUrl ? `<img src="${photoUrl}" alt="${item.name}" style="width: 100%; height: 120px; object-fit: cover; border-radius: 8px; margin-bottom: 8px;" />` : ''}
<div style="display:flex;align-items:center;justify-content:space-between;"> <h4 style="font-weight: bold; margin-bottom: 4px; font-size: 14px;">${item.name}</h4>
<span style="font-size:11px;font-weight:bold;color:#F59E0B;"> ${item.rating || 'N/A'}</span> <p style="font-size: 12px; color: #6B7280; margin-bottom: 6px;">${item.formatted_address}</p>
<a href="https://www.google.com/maps/dir/?api=1&destination=${item.lat},${item.lng}" target="_blank" <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
style="color:#EA580C;font-size:11px;font-weight:bold;text-decoration:none;">Yol Tarifi </a> <span style="color: ${dayColor}; font-weight: bold;"> ${item.rating || 'N/A'}</span>
<span style="color: #6B7280; font-size: 12px;"> ${item.estimated_duration_minutes}dk</span>
</div>
<a
href="https://www.google.com/maps/search/?api=1&query=${item.lat},${item.lng}&query_place_id=${item.place_id}"
target="_blank"
rel="noopener noreferrer"
style="display: inline-block; background: ${dayColor}; color: white; padding: 6px 12px; border-radius: 6px; text-decoration: none; font-size: 12px; font-weight: 500;"
>
Google Maps'te
</a>
</div> </div>
</div> `);
`); infoWindowRef.current.open(googleMap, marker);
infoWindow.open(googleMap, marker); }
}); });
markersRef.current[item.place_id] = { marker, dayIndex }; markersRef.current[item.place_id] = { marker, dayIndex };
bounds.extend(position); bounds.extend(position);
}); });
// Create polyline for this day
if (dayPath.length > 1) { if (dayPath.length > 1) {
const polyline = new google.maps.Polyline({ const polyline = new google.maps.Polyline({
path: dayPath, geodesic: true, path: dayPath,
strokeColor: dayColor, strokeOpacity: 0.8, strokeWeight: 3, geodesic: true,
strokeColor: dayColor,
strokeOpacity: 0.7,
strokeWeight: 4,
}); });
polyline.setMap(googleMap); polyline.setMap(googleMap);
polylinesRef.current.push(polyline); polylinesRef.current.push(polyline);
@ -270,332 +169,129 @@ export function TripMap({ itinerary, activePlaceId, onMarkerClick, onAddPlace }:
}); });
if (Object.keys(markersRef.current).length > 0) { if (Object.keys(markersRef.current).length > 0) {
googleMap.fitBounds(bounds, { top: 60, bottom: 60, left: 60, right: 60 }); googleMap.fitBounds(bounds);
} }
}, [googleMap, itineraryKey]);
// ── Active marker highlight ─────────────────────────────────────────────── }, [googleMap, itineraryKey]); // Use itineraryKey instead of itinerary
useEffect(() => { useEffect(() => {
if (!googleMap || !activePlaceId || !markersRef.current[activePlaceId]) return; if (googleMap && activePlaceId && markersRef.current[activePlaceId]) {
const { marker } = markersRef.current[activePlaceId]; const { marker, dayIndex } = markersRef.current[activePlaceId];
googleMap.panTo(marker.getPosition()!); const dayColor = DAY_COLORS[dayIndex % DAY_COLORS.length];
Object.keys(markersRef.current).forEach(id => {
const { marker: m, dayIndex: dIdx } = markersRef.current[id]; googleMap.panTo(marker.getPosition()!);
const color = DAY_COLORS[dIdx % DAY_COLORS.length]; googleMap.setZoom(14);
const isActive = id === activePlaceId;
m.setIcon({ // Update all markers - highlight active one
path: 'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z', Object.keys(markersRef.current).forEach(id => {
fillColor: color, fillOpacity: 1, const { marker: m, dayIndex: dIdx } = markersRef.current[id];
strokeWeight: isActive ? 3 : 2, const color = DAY_COLORS[dIdx % DAY_COLORS.length];
strokeColor: isActive ? '#000000' : '#ffffff', const isActive = id === activePlaceId;
scale: isActive ? 1.8 : 1.4,
anchor: new google.maps.Point(12, 22), m.setIcon({
labelOrigin: new google.maps.Point(12, 9), path: google.maps.SymbolPath.CIRCLE,
fillColor: color,
fillOpacity: isActive ? 1 : 0.8,
strokeWeight: isActive ? 4 : 3,
strokeColor: '#ffffff',
scale: isActive ? 18 : 14,
});
m.setAnimation(isActive ? google.maps.Animation.BOUNCE : null);
}); });
m.setZIndex(isActive ? 1000 : 1); }
m.setAnimation(isActive ? google.maps.Animation.BOUNCE : null);
});
}, [activePlaceId, googleMap]); }, [activePlaceId, googleMap]);
// ── Render ──────────────────────────────────────────────────────────────── const handleZoomIn = () => {
if (googleMap) {
googleMap.setZoom((googleMap.getZoom() || 12) + 1);
}
};
const handleZoomOut = () => {
if (googleMap) {
googleMap.setZoom((googleMap.getZoom() || 12) - 1);
}
};
const handleFitBounds = () => {
if (googleMap && Object.keys(markersRef.current).length > 0) {
const bounds = new google.maps.LatLngBounds();
Object.values(markersRef.current).forEach(({ marker }) => {
const pos = marker.getPosition();
if (pos) bounds.extend(pos);
});
googleMap.fitBounds(bounds);
}
};
return ( return (
<div className="relative w-full h-full bg-gray-50 overflow-hidden"> <div className="relative w-full h-full">
{error ? ( {error ? (
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center bg-muted">
<p className="text-sm font-medium text-gray-500">{error}</p> <div className="text-center p-8 max-w-md">
<div className="text-4xl mb-4">🗺</div>
<h3 className="text-lg font-semibold mb-2">Harita Yüklenemedi</h3>
<p className="text-sm text-muted-foreground mb-4">{error}</p>
<div className="text-xs text-left bg-card p-4 rounded-lg border">
<p className="font-mono mb-2">.env dosyasına ekleyin:</p>
<code className="text-primary">VITE_GOOGLE_MAPS_API_KEY=AIza...</code>
</div>
</div>
</div> </div>
) : ( ) : (
<> <>
<div ref={mapRef} className="absolute inset-0 w-full h-full" /> <div ref={mapRef} className="absolute inset-0 w-full h-full" />
{/* İpucu */} {/* Map Controls */}
{onAddPlace && !selectedPOI && ( <div className="absolute top-4 right-4 flex flex-col gap-2">
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 pointer-events-none"> <Button
<div className="bg-white/90 backdrop-blur-md px-4 py-2 rounded-full shadow-lg border border-white/60 flex items-center gap-2"> variant="secondary"
<Plus className="h-3.5 w-3.5 text-orange-500 shrink-0" /> size="icon"
<span className="text-[11px] font-bold text-gray-600">Haritada bir yere tıklayarak detayları görün</span> className="bg-white shadow-lg hover:bg-gray-50"
</div> onClick={handleZoomIn}
</div> >
)} <ZoomIn className="h-4 w-4" />
</Button>
{/* Zoom */} <Button
<div className="absolute top-4 right-4 flex flex-col gap-2 z-10"> variant="secondary"
<div className="flex flex-col bg-white border rounded-lg shadow-sm overflow-hidden"> size="icon"
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-none border-b" className="bg-white shadow-lg hover:bg-gray-50"
onClick={() => googleMap?.setZoom((googleMap.getZoom() || 12) + 1)}> onClick={handleZoomOut}
<ZoomIn className="h-4 w-4" /> >
</Button> <ZoomOut className="h-4 w-4" />
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-none" </Button>
onClick={() => googleMap?.setZoom((googleMap.getZoom() || 12) - 1)}> <Button
<ZoomOut className="h-4 w-4" /> variant="secondary"
</Button> size="icon"
</div> className="bg-white shadow-lg hover:bg-gray-50"
<Button variant="outline" size="icon" className="h-8 w-8 bg-white shadow-sm" onClick={handleFitBounds}
onClick={() => { >
if (googleMap && Object.keys(markersRef.current).length > 0) {
const bounds = new google.maps.LatLngBounds();
Object.values(markersRef.current).forEach(({ marker }) => bounds.extend(marker.getPosition()!));
googleMap.fitBounds(bounds);
}
}}>
<Maximize2 className="h-4 w-4" /> <Maximize2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
{/* ── Detay Paneli ───────────────────────────────────────────────── */} {/* Day Legend */}
<AnimatePresence> <div className="absolute top-4 left-4 bg-white/95 backdrop-blur-sm rounded-lg shadow-lg p-3">
{selectedPOI && ( <div className="space-y-2">
<motion.div {itinerary.days.map((day, index) => (
initial={{ x: '100%', opacity: 0 }} <div key={day.day} className="flex items-center gap-2">
animate={{ x: 0, opacity: 1 }} <div
exit={{ x: '100%', opacity: 0 }} className="w-3 h-3 rounded-full"
transition={{ type: 'spring', damping: 28, stiffness: 300 }} style={{ backgroundColor: DAY_COLORS[index % DAY_COLORS.length] }}
className="absolute top-0 right-0 bottom-0 w-[340px] bg-white shadow-2xl z-20 flex flex-col overflow-hidden" />
> <span className="text-xs font-medium text-gray-700">
{/* Fotoğraf header */} Gün {day.day}
<div className="relative h-44 shrink-0 bg-gray-100"> </span>
{selectedPOI.photoUrl ? ( <Badge variant="secondary" className="text-xs font-normal">
<img {day.items.length}
src={selectedPOI.photoUrl} </Badge>
alt={selectedPOI.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-orange-100 to-amber-50 flex items-center justify-center">
<span className="text-4xl">🗺</span>
</div>
)}
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
{/* Kapat */}
<button
onClick={() => { setSelectedPOI(null); setPlaceDetail(null); setAdded(false); }}
className="absolute top-3 right-3 w-8 h-8 rounded-full bg-black/40 backdrop-blur-sm flex items-center justify-center text-white hover:bg-black/60 transition-all"
>
<X className="h-4 w-4" />
</button>
{/* Açık/Kapalı badge */}
{placeDetail?.is_open_now != null && (
<div className={cn(
'absolute top-3 left-3 px-2.5 py-1 rounded-full text-[10px] font-black backdrop-blur-sm',
placeDetail.is_open_now
? 'bg-green-500/90 text-white'
: 'bg-red-500/90 text-white'
)}>
{placeDetail.is_open_now ? '● Açık' : '● Kapalı'}
</div>
)}
{/* İsim */}
<div className="absolute bottom-3 left-3 right-3">
<p className="text-[10px] font-bold text-white/70 uppercase tracking-widest mb-0.5">
{selectedPOI.category}
</p>
<h3 className="text-lg font-black text-white leading-tight line-clamp-2">
{selectedPOI.name}
</h3>
</div>
</div> </div>
))}
{/* Rating + tabs */} </div>
<div className="px-4 pt-3 pb-0 border-b shrink-0"> </div>
<div className="flex items-center justify-between mb-3">
{(selectedPOI.rating || placeDetail?.rating) && (
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-0.5">
{[1,2,3,4,5].map(i => (
<Star key={i}
className={cn('h-3.5 w-3.5', i <= Math.round(selectedPOI.rating || placeDetail?.rating || 0)
? 'fill-amber-400 text-amber-400'
: 'text-gray-200 fill-gray-200'
)}
/>
))}
</div>
<span className="text-sm font-black text-gray-800">
{(selectedPOI.rating || placeDetail?.rating)?.toFixed(1)}
</span>
{placeDetail?.total_ratings && (
<span className="text-xs text-gray-400">({placeDetail.total_ratings.toLocaleString()})</span>
)}
</div>
)}
</div>
{/* Tabs */}
<div className="flex gap-1">
{(['about', 'reviews'] as const).map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
'px-3 py-1.5 text-[11px] font-black uppercase tracking-wider rounded-t-lg transition-all border-b-2',
activeTab === tab
? 'text-orange-600 border-orange-600'
: 'text-gray-400 border-transparent hover:text-gray-600'
)}
>
{tab === 'about' ? 'Hakkında' : 'Yorumlar'}
</button>
))}
</div>
</div>
{/* İçerik */}
<div className="flex-1 overflow-y-auto">
{detailLoading ? (
<div className="flex flex-col items-center justify-center h-48 gap-3">
<Loader2 className="h-8 w-8 text-orange-500 animate-spin" />
<p className="text-xs font-bold text-gray-400">Detaylar yükleniyor...</p>
</div>
) : placeDetail ? (
<AnimatePresence mode="wait">
{activeTab === 'about' ? (
<motion.div
key="about"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="p-4 space-y-5"
>
{/* Özet */}
{placeDetail.summary && (
<p className="text-sm text-gray-600 leading-relaxed">{placeDetail.summary}</p>
)}
{/* Neden gitmelisiniz */}
{placeDetail.why_visit?.length > 0 && (
<div className="space-y-2">
<h4 className="text-[11px] font-black text-gray-900 uppercase tracking-widest flex items-center gap-1.5">
<ChevronRight className="h-3.5 w-3.5 text-orange-500" />
Neden gitmelisiniz?
</h4>
<div className="space-y-2">
{placeDetail.why_visit.map((reason, i) => (
<div key={i} className="flex items-start gap-2.5">
<div className="w-5 h-5 rounded-full bg-orange-100 text-orange-600 flex items-center justify-center text-[10px] font-black shrink-0 mt-0.5">
{i + 1}
</div>
<p className="text-[12px] text-gray-700 leading-relaxed">{reason}</p>
</div>
))}
</div>
</div>
)}
{/* Gitmeden önce */}
{placeDetail.tips?.length > 0 && (
<div className="space-y-2">
<h4 className="text-[11px] font-black text-gray-900 uppercase tracking-widest flex items-center gap-1.5">
<Lightbulb className="h-3.5 w-3.5 text-amber-500" />
Gitmeden önce bilin
</h4>
<div className="space-y-1.5 bg-amber-50 rounded-xl p-3 border border-amber-100">
{placeDetail.tips.map((tip, i) => (
<div key={i} className="flex items-start gap-2">
<span className="text-amber-500 mt-0.5 shrink-0 text-xs"></span>
<p className="text-[12px] text-gray-700 leading-relaxed">{tip}</p>
</div>
))}
</div>
</div>
)}
{/* Çalışma saatleri */}
{placeDetail.opening_hours?.length > 0 && (
<div className="space-y-2">
<h4 className="text-[11px] font-black text-gray-900 uppercase tracking-widest flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-blue-500" />
Çalışma Saatleri
</h4>
<div className="space-y-1">
{placeDetail.opening_hours.map((h, i) => (
<p key={i} className="text-[11px] text-gray-500">{h}</p>
))}
</div>
</div>
)}
</motion.div>
) : (
<motion.div
key="reviews"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="p-4 space-y-4"
>
{placeDetail.reviews?.length > 0 ? (
placeDetail.reviews.map((review, i) => (
<div key={i} className="space-y-2 pb-4 border-b border-gray-100 last:border-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-full bg-orange-100 flex items-center justify-center text-orange-600 font-black text-xs shrink-0">
{review.author?.[0]?.toUpperCase() || '?'}
</div>
<div>
<p className="text-xs font-bold text-gray-800">{review.author}</p>
<p className="text-[10px] text-gray-400">{review.time}</p>
</div>
</div>
<div className="flex items-center gap-0.5">
{[1,2,3,4,5].map(s => (
<Star key={s}
className={cn('h-3 w-3', s <= review.rating
? 'fill-amber-400 text-amber-400'
: 'text-gray-200 fill-gray-200'
)}
/>
))}
</div>
</div>
<p className="text-[12px] text-gray-600 leading-relaxed line-clamp-4">{review.text}</p>
</div>
))
) : (
<div className="flex flex-col items-center justify-center h-32 gap-2 text-gray-400">
<MessageSquare className="h-8 w-8 opacity-30" />
<p className="text-xs font-medium">Yorum bulunamadı</p>
</div>
)}
</motion.div>
)}
</AnimatePresence>
) : null}
</div>
{/* Adres + Plana ekle butonu */}
<div className="p-4 border-t bg-white shrink-0 space-y-3">
{selectedPOI.formatted_address && (
<p className="text-[11px] text-gray-400 leading-snug line-clamp-2">
📍 {selectedPOI.formatted_address}
</p>
)}
{onAddPlace && (
<Button
onClick={handleAdd}
disabled={added}
className={cn(
'w-full h-11 rounded-xl font-black text-sm gap-2 transition-all',
added
? 'bg-green-500 hover:bg-green-500 text-white'
: 'bg-orange-600 hover:bg-orange-700 text-white shadow-lg shadow-orange-200'
)}
>
{added ? (
<><CheckCircle2 className="h-4 w-4" /> Plana Eklendi!</>
) : (
<><Plus className="h-4 w-4" /> Güne Ekle</>
)}
</Button>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</> </>
)} )}
</div> </div>
); );
} }

View File

@ -1,168 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { Search, MapPin, Star, Plus, Loader2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Place } from '@/db/api';
import { cn } from '@/lib/utils';
import { motion, AnimatePresence } from 'framer-motion';
interface PlaceSearchProps {
onPlaceSelect: (place: Place) => void;
className?: string;
placeholder?: string;
}
export function PlaceSearch({ onPlaceSelect, className, placeholder = "Yeni bir durak ara..." }: PlaceSearchProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<google.maps.places.AutocompletePrediction[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const autocompleteService = useRef<google.maps.places.AutocompleteService | null>(null);
const placesService = useRef<google.maps.places.PlacesService | null>(null);
useEffect(() => {
if (window.google && !autocompleteService.current) {
autocompleteService.current = new window.google.maps.places.AutocompleteService();
// PlacesService requires an HTML element, even if invisible
const dummyElement = document.createElement('div');
placesService.current = new window.google.maps.places.PlacesService(dummyElement);
}
}, []);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSearch = async (value: string) => {
setQuery(value);
if (!value || !autocompleteService.current) {
setResults([]);
setIsOpen(false);
return;
}
setLoading(true);
autocompleteService.current.getPlacePredictions(
{
input: value,
locationBias: { lat: 38.6431, lng: 34.8347, radius: 50000 }, // Bias towards Cappadocia
componentRestrictions: { country: 'tr' }
},
(predictions, status) => {
setLoading(false);
if (status === window.google.maps.places.PlacesServiceStatus.OK && predictions) {
setResults(predictions);
setIsOpen(true);
} else {
setResults([]);
setIsOpen(false);
}
}
);
};
const handleSelectResult = (prediction: google.maps.places.AutocompletePrediction) => {
if (!placesService.current) return;
setLoading(true);
placesService.current.getDetails(
{
placeId: prediction.place_id,
fields: ['name', 'formatted_address', 'geometry', 'rating', 'photos', 'types']
},
(place, status) => {
setLoading(false);
if (status === window.google.maps.places.PlacesServiceStatus.OK && place && place.geometry?.location) {
const newPlace: Place = {
place_id: prediction.place_id,
name: place.name || '',
lat: place.geometry.location.lat(),
lng: place.geometry.location.lng(),
rating: place.rating,
formatted_address: place.formatted_address || '',
photo_reference: place.photos?.[0]?.getUrl(), // Note: In a real app, you'd store the reference or proxy the URL
description: place.types?.join(', ') || 'Turistik Nokta',
category: (place.types?.[0] || 'point_of_interest').replace(/_/g, ' '),
estimated_duration_minutes: 60,
start_time: '10:00',
end_time: '11:00',
};
onPlaceSelect(newPlace);
setQuery('');
setIsOpen(false);
setResults([]);
}
}
);
};
return (
<div ref={containerRef} className={cn("relative z-50", className)}>
<div className="relative group">
<div className="absolute inset-y-0 left-6 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400 group-focus-within:text-primary transition-colors" />
</div>
<Input
value={query}
onChange={(e) => handleSearch(e.target.value)}
onFocus={() => query && results.length > 0 && setIsOpen(true)}
placeholder={placeholder}
className="h-16 pl-14 pr-6 rounded-2xl bg-white dark:bg-white/5 border-2 border-gray-100 dark:border-white/10 focus:border-primary focus:ring-4 focus:ring-primary/10 transition-luxury text-lg font-medium italic"
/>
{loading && (
<div className="absolute inset-y-0 right-6 flex items-center">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
</div>
)}
</div>
<AnimatePresence>
{isOpen && results.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="absolute top-full left-0 right-0 mt-3 bg-white dark:bg-secondary border border-gray-100 dark:border-white/10 rounded-[2rem] shadow-3xl overflow-hidden z-50"
>
<div className="p-4 max-h-[400px] overflow-y-auto custom-scrollbar">
{results.map((result) => (
<button
key={result.place_id}
onClick={() => handleSelectResult(result)}
className="w-full flex items-start gap-4 p-4 hover:bg-primary/5 rounded-2xl transition-luxury text-left group"
>
<div className="w-12 h-12 rounded-xl bg-gray-50 dark:bg-white/5 flex items-center justify-center border border-gray-100 dark:border-white/10 group-hover:bg-primary group-hover:text-white transition-luxury shrink-0">
<MapPin className="h-6 w-6" />
</div>
<div className="flex-1 min-w-0">
<div className="font-black text-gray-900 dark:text-white uppercase tracking-tighter truncate">
{result.structured_formatting.main_text}
</div>
<div className="text-sm text-gray-400 font-medium italic truncate">
{result.structured_formatting.secondary_text}
</div>
</div>
<div className="w-10 h-10 rounded-full border-2 border-primary/20 flex items-center justify-center group-hover:bg-primary group-hover:border-primary transition-luxury">
<Plus className="h-5 w-5 text-primary group-hover:text-white" />
</div>
</button>
))}
</div>
<div className="p-4 bg-gray-50 dark:bg-white/5 border-t border-gray-100 dark:border-white/10 text-center">
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest">
Google Places Tarafından Desteklenmektedir
</span>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@ -1,444 +1,251 @@
import { ItineraryDay, Place } from '@/db/api'; import { ItineraryDay, Place } from '@/db/api';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import { Star, Clock, MapPin, GripVertical, Car } from 'lucide-react';
Star, Clock, MapPin, GripVertical, Car, Trash2, Edit3,
MessageSquare, MoreVertical, Sun, Sunset, Moon, Coffee,
Package, Wand2
} from 'lucide-react';
import api from '@/db/api'; import api from '@/db/api';
import { useState, useMemo } from 'react'; import { useMemo } from 'react';
import { import {
DndContext, closestCenter, KeyboardSensor, PointerSensor, DndContext,
useSensor, useSensors, DragEndEvent, closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { import {
arrayMove, SortableContext, sortableKeyboardCoordinates, arrayMove,
verticalListSortingStrategy, useSortable, SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { motion, AnimatePresence } from 'framer-motion';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { PlaceSearch } from './PlaceSearch';
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
// ────────────────────────────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────────────────────────────
function parseMinutes(time: string): number {
const [h = 0, m = 0] = time.split(':').map(Number);
return h * 60 + m;
}
function formatTime(mins: number): string {
return `${String(Math.floor(mins / 60) % 24).padStart(2, '0')}:${String(mins % 60).padStart(2, '0')}`;
}
function calcEndTime(item: Place): string {
const start = parseMinutes(item.start_time || '09:00');
return formatTime(start + (item.estimated_duration_minutes || 60));
}
function getSegment(time: string): 'morning' | 'afternoon' | 'evening' {
const mins = parseMinutes(time);
if (mins < 12 * 60) return 'morning';
if (mins < 18 * 60) return 'afternoon';
return 'evening';
}
const SEGMENT_META = {
morning: { label: 'Sabah', icon: Sun, color: 'text-amber-500' },
afternoon: { label: 'Öğleden Sonra', icon: Coffee, color: 'text-orange-500' },
evening: { label: 'Akşam', icon: Sunset, color: 'text-rose-500' },
};
// ────────────────────────────────────────────────────────────────────────────
// Props
// ────────────────────────────────────────────────────────────────────────────
interface TimelineProps { interface TimelineProps {
itinerary: { days: ItineraryDay[] }; itinerary: { days: ItineraryDay[] };
onReorder: (dayIndex: number, newItems: Place[]) => void; onReorder: (dayIndex: number, newItems: Place[]) => void;
onAddPlace: (dayIndex: number, place: Place) => void;
onDeletePlace: (dayIndex: number, placeId: string) => void;
onUpdatePlaceNote: (dayIndex: number, placeId: string, note: string) => void;
onUpdateDayNote: (dayIndex: number, note: string) => void;
onPlaceClick: (id: string) => void; onPlaceClick: (id: string) => void;
activePlaceId: string | null; activePlaceId: string | null;
dayStartDate?: string;
} }
// ──────────────────────────────────────────────────────────────────────────── export function Timeline({ itinerary, onReorder, onPlaceClick, activePlaceId }: TimelineProps) {
// Timeline root
// ────────────────────────────────────────────────────────────────────────────
export function Timeline(props: TimelineProps) {
return ( return (
<div className="p-4 md:p-6 space-y-8"> <div className="p-6 space-y-8">
{props.itinerary.days.map((day, dayIndex) => ( {itinerary.days.map((day, dayIndex) => (
<DaySection key={day.day} {...props} day={day} dayIndex={dayIndex} /> <DaySection
key={day.day}
day={day}
dayIndex={dayIndex}
onReorder={onReorder}
onPlaceClick={onPlaceClick}
activePlaceId={activePlaceId}
/>
))} ))}
</div> </div>
); );
} }
// ──────────────────────────────────────────────────────────────────────────── function DaySection({ day, dayIndex, onReorder, onPlaceClick, activePlaceId }: {
// DaySection day: ItineraryDay;
// ──────────────────────────────────────────────────────────────────────────── dayIndex: number;
function DaySection({ onReorder: (dayIndex: number, newItems: Place[]) => void;
day, dayIndex, onPlaceClick: (id: string) => void;
onReorder, onAddPlace, onDeletePlace, activePlaceId: string | null;
onUpdatePlaceNote, onUpdateDayNote, }) {
onPlaceClick, activePlaceId,
}: TimelineProps & { day: ItineraryDay; dayIndex: number }) {
const [isEditingNote, setIsEditingNote] = useState(false);
const [noteText, setNoteText] = useState(day.notes || '');
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor), useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
); );
const handleDragEnd = (event: DragEndEvent) => { const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
if (over && active.id !== over.id) { if (over && active.id !== over.id) {
const oldIdx = day.items.findIndex(i => i.place_id === active.id); const oldIndex = day.items.findIndex((i) => i.place_id === active.id);
const newIdx = day.items.findIndex(i => i.place_id === over.id); const newIndex = day.items.findIndex((i) => i.place_id === over.id);
onReorder(dayIndex, arrayMove(day.items, oldIdx, newIdx));
const newItems = arrayMove(day.items, oldIndex, newIndex);
onReorder(dayIndex, newItems);
} }
}; };
const grouped = useMemo(() => { // Calculate estimated travel times between places (memoized to prevent re-renders)
const segments: Record<string, Place[]> = {}; const travelTimes = useMemo(() => {
for (const item of day.items) { const times: { [key: number]: number } = {};
const seg = getSegment(item.start_time || '09:00'); day.items.forEach((_, index) => {
if (!segments[seg]) segments[seg] = []; if (index > 0 && index < day.items.length) {
segments[seg].push(item); // Rough estimate: 5-15 minutes between places
} times[index] = Math.floor(Math.random() * 10) + 5;
return segments; }
}, [day.items]); });
return times;
const segmentOrder: Array<'morning' | 'afternoon' | 'evening'> = ['morning', 'afternoon', 'evening']; }, [day.items.length]);
return ( return (
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="space-y-5"> <div className="space-y-4">
{/* Day Header with gradient divider */}
{/* Day note */} <div className="sticky top-0 bg-white/95 backdrop-blur-sm z-10 pb-3 border-b">
<div className="px-1"> <div className="flex items-center justify-between mb-2">
{isEditingNote ? ( <h2 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
<div className="space-y-2"> Gün {day.day}
<Textarea <Badge variant="secondary" className="font-normal text-sm">
value={noteText} {day.items.length} Mekan
onChange={e => setNoteText(e.target.value)} </Badge>
placeholder="Bugün için notlarınızı yazın..." </h2>
className="bg-amber-50 border-amber-200 rounded-xl text-sm font-medium min-h-[72px] focus:ring-orange-600/20 focus:border-orange-600" </div>
/> {(day.total_distance || day.total_duration) && (
<div className="flex justify-end gap-2"> <div className="flex items-center gap-3 text-xs text-muted-foreground">
<Button variant="ghost" size="sm" onClick={() => setIsEditingNote(false)} className="h-7 text-[10px] font-bold">Vazgeç</Button> {day.total_distance && (
<Button size="sm" className="h-7 bg-orange-600 text-white text-[10px] font-bold px-3 rounded-lg" <span className="flex items-center gap-1">
onClick={() => { onUpdateDayNote(dayIndex, noteText); setIsEditingNote(false); }}> <MapPin className="h-3 w-3" />
Kaydet {day.total_distance}
</Button>
</div>
</div>
) : (
<button
onClick={() => setIsEditingNote(true)}
className="w-full flex items-start gap-3 p-2.5 rounded-xl hover:bg-gray-50 transition-all group text-left"
>
<MessageSquare className="h-4 w-4 text-gray-300 group-hover:text-orange-500 mt-0.5 transition-colors shrink-0" />
{day.notes ? (
<p className="text-sm font-medium text-gray-600 italic">"{day.notes}"</p>
) : (
<span className="text-[11px] font-bold text-gray-400 uppercase tracking-widest group-hover:text-orange-500 transition-colors">
Bugüne not ekle...
</span> </span>
)} )}
</button> {day.total_duration && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{day.total_duration}
</span>
)}
</div>
)} )}
{/* Gradient divider */}
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-primary/30 to-transparent" />
</div> </div>
{/* Empty state */} <DndContext
{day.items.length === 0 && ( sensors={sensors}
<div className="mx-2 py-12 flex flex-col items-center gap-3 border-2 border-dashed border-gray-100 rounded-2xl bg-gray-50/50"> collisionDetection={closestCenter}
<div className="w-12 h-12 rounded-full bg-orange-50 flex items-center justify-center"> onDragEnd={handleDragEnd}
<Package className="h-6 w-6 text-orange-300" /> >
</div> <SortableContext
<div className="text-center"> items={day.items.map(i => i.place_id)}
<p className="text-sm font-bold text-gray-500">Henüz durak yok</p> strategy={verticalListSortingStrategy}
<p className="text-xs text-gray-400 mt-1">Aşağıdan bir yer ekleyerek başlayın</p> >
</div> <div className="space-y-3">
</div> {day.items.map((item, index) => (
)} <div key={item.place_id}>
<SortableItem
{/* Grouped items */} item={item}
{day.items.length > 0 && ( isActive={activePlaceId === item.place_id}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> onClick={() => onPlaceClick(item.place_id)}
<SortableContext items={day.items.map(i => i.place_id)} strategy={verticalListSortingStrategy}> />
<div className="space-y-1 relative">
{/* Vertical line */} {/* Travel time block between places */}
<div className="absolute left-[28px] top-0 bottom-0 w-0.5 bg-gradient-to-b from-orange-200 via-gray-100 to-transparent -z-10" /> {index < day.items.length - 1 && (
<div className="flex items-center gap-2 py-2 px-4 text-xs text-muted-foreground">
{segmentOrder.map(seg => { <div className="w-0.5 h-6 bg-gradient-to-b from-primary/40 to-primary/10 mx-auto" />
const items = grouped[seg]; <Car className="h-3 w-3" />
if (!items?.length) return null; <span>{travelTimes[index + 1] || 10} dk sürüş</span>
const meta = SEGMENT_META[seg];
const SegIcon = meta.icon;
return (
<div key={seg} className="space-y-3">
{/* Segment label */}
<div className={cn("flex items-center gap-2 px-2 py-1.5 text-[10px] font-black uppercase tracking-widest", meta.color)}>
<SegIcon className="h-3.5 w-3.5" />
{meta.label}
</div>
{items.map((item) => {
const globalIndex = day.items.findIndex(i => i.place_id === item.place_id);
return (
<div key={item.place_id} id={`place-${item.place_id}`} className="relative group">
{/* Timeline dot */}
<div className={cn(
"absolute left-6 top-6 w-3 h-3 rounded-full border-2 z-10 transition-all duration-200",
activePlaceId === item.place_id
? "bg-orange-600 border-orange-600 scale-150 shadow-lg shadow-orange-600/40"
: "bg-white border-gray-300 group-hover:border-orange-400"
)} />
<div className="pl-12 pr-2">
<SortableItem
item={item}
index={globalIndex}
isActive={activePlaceId === item.place_id}
onClick={() => onPlaceClick(item.place_id)}
onDelete={() => onDeletePlace(dayIndex, item.place_id)}
onUpdateNote={note => onUpdatePlaceNote(dayIndex, item.place_id, note)}
/>
{/* Travel time connector */}
{globalIndex < day.items.length - 1 && (
<div className="my-3 ml-1 flex items-center gap-2 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<Car className="h-3.5 w-3.5" />
<span>~15 dk sürüş</span>
</div>
)}
</div>
</div>
);
})}
</div> </div>
); )}
})} </div>
</div> ))}
</SortableContext>
</DndContext>
)}
{/* AI suggestion card */}
<div className="mx-2">
<div className="bg-gradient-to-br from-orange-50 to-amber-50 border border-orange-100 rounded-2xl p-4 space-y-3 relative overflow-hidden group">
<div className="absolute -right-3 -top-3 opacity-5">
<Wand2 className="h-20 w-20 text-orange-600" />
</div> </div>
<div className="flex items-center gap-2"> </SortableContext>
<span className="text-xs font-black text-orange-800">Akıllı Tur Önerisi</span> </DndContext>
<Badge className="bg-orange-100 text-orange-600 hover:bg-orange-100 border-0 text-[9px] font-black">%82 Uyum</Badge> </div>
</div>
<p className="text-[11px] text-gray-600 leading-relaxed">
Planınız Green Tour rotasıyla %82 uyumlu. Yeraltı şehri ve Ihlara Vadisi eklenebilir.
</p>
<Button className="w-full bg-orange-600 hover:bg-orange-700 h-8 rounded-xl text-[10px] font-black gap-1.5">
<Wand2 className="h-3.5 w-3.5" />
Seçenekleri İncele
</Button>
</div>
</div>
{/* Add place search */}
<div className="mx-2">
<PlaceSearch
onPlaceSelect={place => onAddPlace(dayIndex, place)}
placeholder="Yeni bir durak ekle..."
/>
</div>
</motion.div>
); );
} }
// ──────────────────────────────────────────────────────────────────────────── function SortableItem({ item, isActive, onClick }: { item: Place; isActive: boolean; onClick: () => void }) {
// SortableItem — Wanderlog stili küçük thumbnail const {
// ──────────────────────────────────────────────────────────────────────────── attributes,
function SortableItem({ listeners,
item, index, isActive, onClick, onDelete, onUpdateNote setNodeRef,
}: { transform,
item: Place; transition,
index: number; isDragging
isActive: boolean; } = useSortable({ id: item.place_id });
onClick: () => void;
onDelete: () => void;
onUpdateNote: (note: string) => void;
}) {
const [isEditingNote, setIsEditingNote] = useState(false);
const [note, setNote] = useState(item.notes || '');
const [imgLoaded, setImgLoaded] = useState(false);
const [imgError, setImgError] = useState(false);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.place_id });
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
zIndex: isDragging ? 50 : 'auto', zIndex: isDragging ? 50 : 'auto',
opacity: isDragging ? 0.4 : 1, opacity: isDragging ? 0.5 : 1,
}; };
const photoUrl = useMemo(() => { const photoUrl = item.photo_reference ? api.getPhotoUrl(item.photo_reference) : null;
if (!item.photo_reference) return null;
return item.photo_reference.startsWith('http')
? item.photo_reference
: api.getPhotoUrl(item.photo_reference);
}, [item.photo_reference]);
const endTime = calcEndTime(item);
return ( return (
<div ref={setNodeRef} style={style}> <div ref={setNodeRef} style={style} className="group relative">
<Card <Card
onClick={onClick}
className={cn( className={cn(
"overflow-hidden cursor-pointer transition-all duration-200 rounded-2xl border", "overflow-hidden cursor-pointer transition-all duration-200 border-2",
isDragging && "opacity-50 border-dashed", isDragging && "border-dashed",
isActive isActive
? "border-orange-500 shadow-lg shadow-orange-500/15 ring-2 ring-orange-500/10" ? "border-primary shadow-lg ring-2 ring-primary/20"
: "border-gray-100 hover:border-gray-200 hover:shadow-md" : "border-gray-200 hover:border-primary hover:shadow-md"
)} )}
onClick={onClick}
> >
<CardContent className="p-3"> <CardContent className="p-0 flex">
{/* Ana satır */} {/* Image Section - 128px square */}
<div className="flex items-start gap-2"> <div className="w-32 h-32 shrink-0 relative overflow-hidden">
{photoUrl ? (
{/* Index numarası */} <img
<div className="w-6 h-6 rounded-full bg-orange-100 flex items-center justify-center text-orange-700 font-black text-[10px] shrink-0 mt-0.5"> src={photoUrl}
{index + 1} alt={item.name}
</div> className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
{/* Bilgi alanı */} ) : (
<div className="flex-1 min-w-0"> <div className="w-full h-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center">
<h4 className="text-sm font-black text-gray-900 truncate">{item.name}</h4> <MapPin className="text-primary/30 h-10 w-10" />
<div className="flex items-center gap-1.5 mt-0.5 flex-wrap">
{item.start_time && (
<span className="text-[10px] font-bold text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded-md">
{item.start_time} {endTime}
</span>
)}
<Badge variant="secondary" className="bg-gray-100 text-gray-500 text-[9px] font-bold px-1.5 py-0 h-4 border-0">
{item.category}
</Badge>
{item.estimated_duration_minutes && (
<span className="flex items-center gap-0.5 text-[9px] font-bold text-gray-400">
<Clock className="h-2.5 w-2.5" />
{item.estimated_duration_minutes}dk
</span>
)}
{item.rating && (
<span className="flex items-center gap-0.5 text-[9px] font-bold text-gray-400">
<Star className="h-2.5 w-2.5 fill-amber-400 text-amber-400" />
{item.rating}
</span>
)}
</div>
</div>
{/* Küçük thumbnail */}
{photoUrl && !imgError && (
<div className="relative w-16 h-16 rounded-xl overflow-hidden bg-gray-100 shrink-0">
{!imgLoaded && (
<div className="absolute inset-0 bg-gradient-to-r from-gray-200 via-gray-100 to-gray-200 animate-pulse" />
)}
<img
src={photoUrl}
alt={item.name}
onLoad={() => setImgLoaded(true)}
onError={() => setImgError(true)}
className={cn(
"w-full h-full object-cover transition-opacity duration-300",
imgLoaded ? "opacity-100" : "opacity-0"
)}
/>
</div> </div>
)} )}
{/* Time badge overlay */}
<div className="absolute top-2 left-2">
<Badge className="bg-white/90 backdrop-blur-sm text-gray-900 border-none hover:bg-white shadow-sm">
{item.start_time}
</Badge>
</div>
{/* Aksiyonlar (hover'da görünür) */} {/* Rating badge - top right */}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-all shrink-0"> {item.rating && (
<div <div className="absolute top-2 right-2">
{...attributes} {...listeners} <Badge className="bg-primary/90 backdrop-blur-sm text-white border-none hover:bg-primary shadow-sm">
className="p-1.5 hover:bg-gray-100 rounded-lg text-gray-400 cursor-grab active:cursor-grabbing" <Star className="h-3 w-3 fill-white mr-1" />
onClick={e => e.stopPropagation()} {item.rating}
> </Badge>
<GripVertical className="h-3.5 w-3.5" /> </div>
)}
</div>
{/* Content Section */}
<div className="flex-1 p-4 flex flex-col justify-between min-w-0">
<div>
<div className="flex items-start justify-between gap-2 mb-1">
<h4 className="font-bold text-base text-gray-900 line-clamp-1 flex-1">{item.name}</h4>
<div
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 hover:bg-gray-100 rounded transition-colors opacity-0 group-hover:opacity-100"
>
<GripVertical className="h-4 w-4 text-gray-400" />
</div>
</div>
<p className="text-sm text-muted-foreground line-clamp-2 leading-relaxed">{item.description}</p>
</div>
{/* Meta Information */}
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-2">
<div className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
<span>{item.estimated_duration_minutes}dk</span>
</div>
<div className="flex items-center gap-1 truncate">
<MapPin className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{item.category}</span>
</div> </div>
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={e => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-lg">
<MoreVertical className="h-3.5 w-3.5 text-gray-400" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36 rounded-xl p-1">
<DropdownMenuItem
onClick={e => { e.stopPropagation(); setIsEditingNote(true); }}
className="text-xs font-bold gap-2 rounded-lg"
>
<Edit3 className="h-3.5 w-3.5 text-orange-500" />Not Düzenle
</DropdownMenuItem>
<DropdownMenuItem
onClick={e => { e.stopPropagation(); onDelete(); }}
className="text-xs font-bold gap-2 text-red-500 rounded-lg focus:text-red-600 focus:bg-red-50"
>
<Trash2 className="h-3.5 w-3.5" />Kaldır
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>
{/* Not düzenleme alanı */}
<AnimatePresence>
{isEditingNote && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mt-3 space-y-2 overflow-hidden"
onClick={e => e.stopPropagation()}
>
<Textarea
value={note}
onChange={e => setNote(e.target.value)}
placeholder="Not ekle..."
className="bg-gray-50 border-gray-200 rounded-xl text-xs font-medium min-h-[60px]"
autoFocus
/>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setIsEditingNote(false)} className="h-6 text-[10px] font-bold">Vazgeç</Button>
<Button size="sm" className="h-6 bg-orange-600 text-white text-[10px] font-bold px-3 rounded-lg"
onClick={() => { onUpdateNote(note); setIsEditingNote(false); }}>
Kaydet
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Mevcut not gösterimi */}
{!isEditingNote && item.notes && (
<div className="mt-2.5 p-2 bg-orange-50 rounded-xl border-l-2 border-orange-500">
<p className="text-[11px] font-medium italic text-orange-800">{item.notes}</p>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
); );
} }

View File

@ -1,529 +1,23 @@
import { useState, useMemo, useCallback, memo } from 'react'; import { Hotel, Home, Navigation, Plane, Trees, Building2, Camera, Bike, UtensilsCrossed } from 'lucide-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,
} 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'; export const ACCOMMODATION_OPTIONS = [
import { DateSelector } from '@/components/planner/DateSelector'; { id: 'hotel', label: 'Otel', icon: Hotel },
import { TravelerInput } from '@/components/planner/TravelerInput'; { id: 'airbnb', label: 'Airbnb / Adres', icon: Home },
import { AccommodationSelector } from '@/components/planner/AccommodationSelector'; { id: 'center', label: 'Merkezden Başla', icon: Navigation },
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 114 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<typeof formSchema>;
// ─── 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; ] as const;
// ─── Summary label helpers ──────────────────────────────────────────────────── export const INTEREST_OPTIONS = [
function getSummaryLabel(stepId: string, values: Partial<FormValues>): string | null { { id: 'balloon', label: 'Sıcak Hava Balonu', icon: Plane },
switch (stepId) { { id: 'nature', label: 'Doğa ve Yürüyüş', icon: Trees },
case 'dates': { id: 'history', label: 'Tarih ve Kültür', icon: Building2 },
if (values.dateRange?.from && values.dateRange?.to) { id: 'photography', label: 'Fotoğraf Çekimi', icon: Camera },
return `${format(values.dateRange.from, 'd MMM')} ${format(values.dateRange.to, 'd MMM')}`; { id: 'adventure', label: 'Aktivite/Macera', icon: Bike },
return null; { id: 'gastronomy', label: 'Gastronomi', icon: UtensilsCrossed },
case 'travelType': ] as const;
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 ────────────────────────────────────────────────────────────── export const LOADING_STEPS = [
const PlannerPage = () => { { label: 'Form hazırlanıyor...', progress: 0 },
const { user } = useAuth(); { label: 'Rotanız oluşturuluyor...', progress: 33 },
const navigate = useNavigate(); { label: 'Mekanlar belirleniyor...', progress: 66 },
const [loading, setLoading] = useState(false); { label: 'Son kontroller yapılıyor...', progress: 90 },
const [loadingStep, setLoadingStep] = useState(0); ] as const;
const [currentStep, setCurrentStep] = useState(0);
const [datePickerOpen, setDatePickerOpen] = useState(false);
const form = useForm<FormValues>({
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<string, keyof FormValues | (keyof FormValues)[]> = {
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 (
<div className="h-screen w-full flex flex-col items-center justify-center bg-secondary relative overflow-hidden">
<div className="absolute inset-0 opacity-15">
<img
src="https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=2400"
alt=""
className="w-full h-full object-cover grayscale"
/>
</div>
<div className="absolute inset-0 bg-secondary/60" />
<div className="relative z-10 max-w-md w-full px-8 text-center space-y-10">
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
className="relative mx-auto w-24 h-24"
>
<div className="absolute inset-0 bg-primary rounded-2xl animate-pulse opacity-30 scale-110" />
<div className="w-24 h-24 bg-primary rounded-2xl flex items-center justify-center shadow-2xl shadow-primary/30">
<Loader2 className="h-11 w-11 text-white animate-spin" />
</div>
</motion.div>
<div className="space-y-5">
<AnimatePresence mode="wait">
<motion.h2
key={loadingStep}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="text-3xl font-black text-white tracking-tighter uppercase"
>
{LOADING_STEPS[loadingStep].label}
</motion.h2>
</AnimatePresence>
<div className="w-full h-2 bg-white/10 rounded-full overflow-hidden">
<motion.div
className="h-full bg-primary rounded-full"
initial={{ width: 0 }}
animate={{ width: `${LOADING_STEPS[loadingStep].progress}%` }}
transition={{ duration: 0.6, ease: 'easeInOut' }}
/>
</div>
<div className="flex justify-between text-[10px] font-bold text-white/30 uppercase tracking-widest px-1">
<span>Hazırlanıyor</span>
<span>{LOADING_STEPS[loadingStep].progress}%</span>
</div>
</div>
<p className="text-white/30 text-xs font-medium italic">
Size özel Kapadokya efsanesi kurguluyoruz...
</p>
</div>
</div>
);
}
// ── Main layout ─────────────────────────────────────────────────────────────
return (
<div className="min-h-screen bg-background flex flex-col lg:flex-row overflow-hidden">
{/* ── Sidebar ─────────────────────────────────────────────────────────── */}
<div className="w-full lg:w-[300px] xl:w-[360px] bg-secondary flex flex-col relative overflow-hidden shrink-0">
{/* Decorative orbs */}
<div className="absolute top-0 left-0 w-72 h-72 bg-primary/10 rounded-full blur-3xl -translate-x-1/2 -translate-y-1/2 pointer-events-none" />
<div className="absolute bottom-0 right-0 w-64 h-64 bg-accent/5 rounded-full blur-3xl translate-x-1/3 translate-y-1/3 pointer-events-none" />
<div className="relative z-10 flex flex-col h-full p-8 lg:p-10 gap-8">
{/* Logo */}
<div className="flex items-center gap-3">
<div className="w-9 h-9 bg-primary rounded-xl flex items-center justify-center text-white shadow-lg shadow-primary/20">
<MapPin className="h-4 w-4" />
</div>
<span className="text-base font-black text-white tracking-tight uppercase">
Kapadokya <span className="text-primary">Efsanesi</span>
</span>
</div>
{/* Headline */}
<div className="space-y-2">
<h1 className="text-3xl xl:text-4xl font-black text-white leading-none tracking-tighter uppercase">
ROTANIZI<br /><span className="text-primary">TASARLAYIN</span>
</h1>
<p className="text-white/35 text-sm font-medium italic">
"Size özel kurgulanmış seyahat mimarisi."
</p>
</div>
{/* Step list */}
<div className="flex-1 space-y-1.5">
{STEPS.map((step, i) => {
const Icon = step.icon;
const isActive = i === currentStep;
const isCompleted = i < currentStep;
const summaryLabel = getSummaryLabel(step.id, watchedValues);
return (
<motion.div
key={step.id}
animate={{ opacity: isActive || isCompleted ? 1 : 0.35 }}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-200 group',
isActive && 'bg-white/8'
)}
>
{/* Step indicator */}
<div className={cn(
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0 transition-all duration-300',
isActive ? 'bg-primary text-white shadow-md shadow-primary/30 scale-110'
: isCompleted ? 'bg-white/10 text-primary'
: 'bg-white/5 text-white/20'
)}>
{isCompleted
? <CheckCircle2 className="h-4 w-4" />
: <Icon className="h-4 w-4" />
}
</div>
{/* Labels */}
<div className="flex-1 min-w-0">
<div className={cn(
'text-[9px] font-black uppercase tracking-[0.15em]',
isActive ? 'text-primary' : 'text-white/25'
)}>
{String(i + 1).padStart(2, '0')}
</div>
<div className={cn(
'text-sm font-bold leading-tight truncate',
isActive ? 'text-white' : isCompleted ? 'text-white/60' : 'text-white/30'
)}>
{step.title}
</div>
</div>
{/* Summary pill */}
{isCompleted && summaryLabel && (
<span className="text-[9px] font-black text-primary/70 bg-primary/10 px-2 py-0.5 rounded-full shrink-0 max-w-[80px] truncate">
{summaryLabel}
</span>
)}
{isActive && (
<ChevronRight className="h-3.5 w-3.5 text-primary/60 shrink-0" />
)}
</motion.div>
);
})}
</div>
{/* Progress bar */}
<div className="space-y-2 pt-2 border-t border-white/8">
<div className="flex justify-between text-[10px] font-bold text-white/25 uppercase tracking-widest">
<span>İlerleme</span>
<span>{Math.round(progress)}%</span>
</div>
<div className="h-1 bg-white/8 rounded-full overflow-hidden">
<motion.div
className="h-full bg-primary rounded-full"
animate={{ width: `${progress}%` }}
transition={{ duration: 0.5, ease: 'easeOut' }}
/>
</div>
</div>
</div>
</div>
{/* ── Main Form Area ──────────────────────────────────────────────────── */}
<div className="flex-1 bg-white dark:bg-card overflow-y-auto">
<div className="max-w-2xl mx-auto min-h-full flex flex-col px-8 py-10 lg:py-14 lg:px-16">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 flex flex-col">
<AnimatePresence mode="wait">
<motion.div
key={currentStep}
initial={{ opacity: 0, x: 24 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -24 }}
transition={{ duration: 0.35, ease: 'easeOut' }}
className="flex-1 space-y-10"
>
{/* Step header */}
<div className="space-y-2">
<div className="flex items-center gap-2 text-[10px] font-black text-primary uppercase tracking-[0.2em]">
<span>Adım {currentStep + 1}/{STEPS.length}</span>
<span className="w-12 h-0.5 bg-primary/20 rounded-full" />
<span>{STEPS[currentStep].title}</span>
</div>
<h2 className="text-3xl md:text-4xl xl:text-5xl font-black text-gray-900 dark:text-white tracking-tighter leading-[0.95] uppercase">
{STEPS[currentStep].description}
</h2>
</div>
{/* Step content */}
<div className="pt-2">
{/* Step 0 — Dates */}
{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>
)} />
)}
{/* Step 1 — Travel Type */}
{currentStep === 1 && (
<FormField control={form.control} name="travelType" render={({ field }) => (
<FormItem className="space-y-3">
<Label className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">Seyahat Tarzı</Label>
<TravelTypeSelector selectedId={field.value} onSelect={field.onChange} />
<FormMessage />
</FormItem>
)} />
)}
{/* Step 2 — Travelers & Accommodation */}
{currentStep === 2 && (
<div className="space-y-8">
<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>
)} />
<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>
)} />
</div>
)}
{/* Step 3 — Transport */}
{currentStep === 3 && (
<FormField control={form.control} name="transport" render={({ field }) => (
<FormItem className="space-y-3">
<Label className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">Ulaşım Tercihi</Label>
<TransportSelector selectedId={field.value} onSelect={field.onChange} />
<FormMessage />
</FormItem>
)} />
)}
{/* Step 4 — Budget */}
{currentStep === 4 && (
<FormField control={form.control} name="budget" render={({ field }) => (
<FormItem className="space-y-3">
<Label className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">Günlük Bütçe</Label>
<BudgetSelector selectedId={field.value} onSelect={field.onChange} />
<FormMessage />
</FormItem>
)} />
)}
{/* Step 5 — Interests */}
{currentStep === 5 && (
<FormField control={form.control} name="interests" render={({ field }) => (
<FormItem className="space-y-3">
<div className="flex justify-between items-center">
<Label className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">İ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 */}
<div className="pt-10 mt-auto flex items-center justify-between gap-4 border-t border-gray-100 dark:border-white/8">
<Button
type="button"
variant="ghost"
size="lg"
onClick={prevStep}
disabled={currentStep === 0}
className="h-13 px-7 text-sm font-bold rounded-xl hover:bg-gray-50 group disabled:opacity-30"
>
<ArrowLeft className="mr-2 h-4 w-4 group-hover:-translate-x-1 transition-transform" />
Geri
</Button>
{currentStep < STEPS.length - 1 ? (
<Button
type="button"
size="lg"
onClick={nextStep}
className="h-13 px-10 text-sm font-black bg-primary hover:bg-primary/90 rounded-xl shadow-lg shadow-primary/20 group uppercase tracking-widest"
>
Devam Et
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform" />
</Button>
) : (
<Button
type="submit"
size="lg"
className="h-13 px-12 text-sm font-black bg-primary hover:bg-primary/90 rounded-xl shadow-lg shadow-primary/20 uppercase tracking-widest"
>
Rotayı Oluştur
<Sparkles className="ml-2 h-4 w-4 animate-pulse" />
</Button>
)}
</div>
</form>
</Form>
</div>
</div>
</div>
);
};
export default memo(PlannerPage);

View File

@ -13,7 +13,6 @@ export interface Place {
estimated_duration_minutes: number; estimated_duration_minutes: number;
start_time: string; start_time: string;
end_time: string; end_time: string;
notes?: string;
} }
export interface ItineraryDay { export interface ItineraryDay {
@ -21,7 +20,6 @@ export interface ItineraryDay {
items: Place[]; items: Place[];
total_distance?: string; total_distance?: string;
total_duration?: string; total_duration?: string;
notes?: string;
} }
export interface Trip { export interface Trip {
@ -92,11 +90,7 @@ const api = {
endDate: string; endDate: string;
interests: string[]; interests: string[];
dailySchedule: string; dailySchedule: string;
travelType: string; preferences: string;
accommodation: string;
transport: string;
budget: string;
travelers: number;
}) { }) {
const { data, error } = await supabase.functions.invoke('generate-itinerary', { const { data, error } = await supabase.functions.invoke('generate-itinerary', {
body: params, body: params,
@ -127,4 +121,4 @@ const api = {
} }
}; };
export default api; export default api;

View File

@ -4,35 +4,35 @@
@layer base { @layer base {
:root { :root {
/* Backgrounds - Warmer White */ /* Backgrounds */
--background: 30 20% 98%; --background: 0 0% 98%;
--foreground: 24 30% 10%; --foreground: 222 47% 11%;
/* Card & Surfaces */ /* Card & Surfaces */
--card: 0 0% 100%; --card: 0 0% 100%;
--card-foreground: 24 30% 10%; --card-foreground: 222 47% 11%;
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 24 30% 10%; --popover-foreground: 222 47% 11%;
/* Primary - Cappadocia Terracotta (#E36414) */ /* Primary - Blue (#3B82F6) */
--primary: 24 85% 50%; --primary: 217 91% 60%;
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%;
--primary-dark: 24 85% 40%; --primary-dark: 221 83% 53%;
/* Secondary - Deep Slate Blue for contrast (#1B263B) */ /* Secondary - Pink (#EC4899) */
--secondary: 220 37% 17%; --secondary: 328 86% 70%;
--secondary-foreground: 0 0% 100%; --secondary-foreground: 0 0% 100%;
/* Muted & Neutral */ /* Muted & Neutral */
--muted: 30 20% 94%; --muted: 220 14% 96%;
--muted-foreground: 24 10% 45%; --muted-foreground: 220 9% 46%;
/* Accent - Golden Hour (#FFB703) */ /* Accent - Very light blue */
--accent: 45 100% 51%; --accent: 217 91% 95%;
--accent-foreground: 24 30% 10%; --accent-foreground: 217 91% 30%;
/* Status Colors */ /* Status Colors */
--destructive: 0 84% 60%; --destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 100%; --destructive-foreground: 0 0% 100%;
--success: 142 71% 45%; --success: 142 71% 45%;
--success-foreground: 0 0% 100%; --success-foreground: 0 0% 100%;
@ -42,45 +42,41 @@
--info-foreground: 0 0% 100%; --info-foreground: 0 0% 100%;
/* Borders & Inputs */ /* Borders & Inputs */
--border: 24 20% 90%; --border: 220 13% 91%;
--input: 24 20% 90%; --input: 220 13% 91%;
--ring: 24 85% 50%; --ring: 217 91% 60%;
/* Radius - Standard but soft */ /* Radius */
--radius: 0.75rem; --radius: 1rem;
/* Custom Gradients */
--gradient-primary: linear-gradient(135deg, hsl(24 85% 50%), hsl(45 100% 51%));
--gradient-surface: linear-gradient(180deg, rgba(255,255,255,0.8) 0%, rgba(255,255,255,0.4) 100%);
} }
.dark { .dark {
/* Backgrounds - Deep Night Blue/Grey */ /* Backgrounds */
--background: 224 45% 8%; --background: 222 47% 11%;
--foreground: 30 20% 98%; --foreground: 0 0% 98%;
/* Card & Surfaces */ /* Card & Surfaces */
--card: 224 45% 12%; --card: 222 47% 13%;
--card-foreground: 30 20% 98%; --card-foreground: 0 0% 98%;
--popover: 224 45% 12%; --popover: 222 47% 13%;
--popover-foreground: 30 20% 98%; --popover-foreground: 0 0% 98%;
/* Primary - Stays consistent but slightly more vibrant */ /* Primary - Blue */
--primary: 24 85% 55%; --primary: 217 91% 60%;
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%;
--primary-dark: 24 85% 45%; --primary-dark: 221 83% 53%;
/* Secondary */ /* Secondary - Pink */
--secondary: 224 30% 20%; --secondary: 328 86% 70%;
--secondary-foreground: 30 20% 98%; --secondary-foreground: 0 0% 100%;
/* Muted & Neutral */ /* Muted & Neutral */
--muted: 224 30% 15%; --muted: 217 33% 17%;
--muted-foreground: 224 15% 70%; --muted-foreground: 215 20% 65%;
/* Accent */ /* Accent - Dark blue */
--accent: 45 100% 60%; --accent: 217 91% 20%;
--accent-foreground: 224 45% 8%; --accent-foreground: 217 91% 85%;
/* Status Colors */ /* Status Colors */
--destructive: 0 62.8% 30.6%; --destructive: 0 62.8% 30.6%;
@ -93,11 +89,9 @@
--info-foreground: 0 0% 100%; --info-foreground: 0 0% 100%;
/* Borders & Inputs */ /* Borders & Inputs */
--border: 224 30% 18%; --border: 217 33% 17%;
--input: 224 30% 18%; --input: 217 33% 17%;
--ring: 24 85% 55%; --ring: 217 91% 60%;
--gradient-surface: linear-gradient(180deg, rgba(30,41,59,0.8) 0%, rgba(30,41,59,0.4) 100%);
} }
} }
@ -106,55 +100,90 @@
@apply border-border; @apply border-border;
} }
body { body {
@apply bg-background text-foreground antialiased selection:bg-primary/20 selection:text-primary; @apply bg-background text-foreground antialiased;
} }
} }
/* Custom Utilities */ .gradient-text {
.glass { background: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--secondary)));
@apply bg-white/10 backdrop-blur-xl border border-white/20; -webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
} }
.glass-dark { .cappadocia-bg {
@apply bg-black/20 backdrop-blur-xl border border-white/10; background-image: linear-gradient(rgba(0,0,0,0.3), rgba(0,0,0,0.3)), url('https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=2000');
}
.card-shine {
@apply relative overflow-hidden before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_2s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/10 before:to-transparent;
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
.text-gradient {
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary to-accent;
}
.cappadocia-hero {
background: linear-gradient(to bottom, rgba(0,0,0,0.2), rgba(0,0,0,0.7)), url('https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=2000');
background-size: cover; background-size: cover;
background-position: center; background-position: center;
background-attachment: fixed;
} }
/* Custom Scrollbar */ .glassmorphism {
::-webkit-scrollbar { background: rgba(255, 255, 255, 0.85);
width: 8px; backdrop-filter: blur(20px);
height: 8px; -webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
} }
::-webkit-scrollbar-track { .dark .glassmorphism {
@apply bg-transparent; background: rgba(31, 41, 55, 0.85);
border: 1px solid rgba(255, 255, 255, 0.1);
} }
::-webkit-scrollbar-thumb { @keyframes float {
@apply bg-muted-foreground/20 rounded-full hover:bg-muted-foreground/40 transition-colors; 0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
} }
/* Smooth Transitions */ .animate-float {
.transition-luxury { animation: float 3s ease-in-out infinite;
transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1); }
}
/* Hide scrollbar for horizontal scroll */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Navbar shadow on scroll */
.navbar-shadow {
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
}
/* Smooth transitions */
.transition-smooth {
transition: all 200ms ease;
}
/* Backdrop blur */
.backdrop-blur-navbar {
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
/* Notification badge */
.notification-badge {
position: absolute;
top: -4px;
right: -4px;
display: flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 4px;
font-size: 10px;
font-weight: 600;
color: white;
background-color: hsl(var(--destructive));
border-radius: 9999px;
border: 2px solid hsl(var(--background));
}

View File

@ -5,11 +5,10 @@ import api, { Trip } from '@/db/api';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; 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, Compass } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { tr } from 'date-fns/locale'; import { tr } from 'date-fns/locale';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { motion, AnimatePresence } from 'framer-motion';
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -57,23 +56,16 @@ export default function AccountPage() {
if (!user) { if (!user) {
return ( return (
<div className="min-h-screen flex items-center justify-center p-6 bg-secondary relative overflow-hidden"> <div className="min-h-screen flex items-center justify-center p-6">
<div className="absolute inset-0 z-0 opacity-10"> <Card className="max-w-md w-full p-8 text-center space-y-6">
<img <div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto">
src="https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=2400" <Compass className="h-8 w-8 text-orange-600" />
alt="bg"
className="w-full h-full object-cover grayscale"
/>
</div>
<Card className="max-w-sm w-full p-8 text-center space-y-6 bg-white/5 backdrop-blur-xl border-white/10 rounded-3xl shadow-2xl relative z-10">
<div className="w-16 h-16 bg-primary/20 rounded-2xl flex items-center justify-center mx-auto border border-primary/40">
<ExploreIcon className="h-8 w-8 text-primary" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<h2 className="text-3xl font-black text-white tracking-tighter uppercase leading-none">HESABIM</h2> <h2 className="text-2xl font-bold">Hesabım</h2>
<p className="text-white/60 font-medium italic text-sm">Gezilerinizi yönetmek için giriş yapmalısınız.</p> <p className="text-gray-500">Gezilerinizi kaydetmek ve yönetmek için giriş yapmalısınız.</p>
</div> </div>
<Button className="w-full h-14 bg-primary hover:bg-primary-dark text-white font-black uppercase tracking-widest rounded-xl" asChild> <Button className="w-full bg-orange-600" asChild>
<Link to="/login">Giriş Yap</Link> <Link to="/login">Giriş Yap</Link>
</Button> </Button>
</Card> </Card>
@ -82,153 +74,135 @@ export default function AccountPage() {
} }
return ( return (
<div className="min-h-screen bg-background pt-20 pb-12"> <div className="min-h-screen bg-gray-50 pt-20 pb-12">
<div className="max-w-4xl mx-auto px-6 space-y-10"> <div className="max-w-5xl mx-auto px-6">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-10 border-b border-border"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
<div className="space-y-3"> <div>
<div className="flex items-center gap-2"> <h1 className="text-3xl font-bold text-gray-900">Gezilerim</h1>
<span className="text-[10px] font-black text-primary uppercase tracking-widest">Kişisel Arşiv</span> <p className="text-gray-500 mt-1">Planladığınız ve kaydettiğiniz tüm Kapadokya rotaları.</p>
<Badge className="bg-primary/10 text-primary border-none font-black px-2 py-0.5 rounded-full uppercase text-[9px]">
{trips.length} Rota
</Badge>
</div>
<h1 className="text-4xl md:text-5xl font-black text-gray-900 dark:text-white tracking-tighter uppercase leading-none">
GEZİLERİM
</h1>
<p className="text-lg text-gray-500 font-medium italic">Planladığınız Kapadokya efsaneleri.</p>
</div> </div>
<Button className="h-14 px-8 bg-primary hover:bg-primary-dark text-white font-black uppercase tracking-widest rounded-xl shadow-lg shadow-primary/20 gap-2 group transition-luxury" asChild> <Button className="bg-orange-600 shadow-lg shadow-orange-200" asChild>
<Link to="/planner"> <Link to="/planner">
<PlusCircle className="h-5 w-5 group-hover:rotate-90 transition-transform" /> <PlusCircle className="h-4 w-4 mr-2" />
Yeni Plan Yeni Plan Oluştur
</Link> </Link>
</Button> </Button>
</div> </div>
{loading ? ( {loading ? (
<div className="flex flex-col items-center justify-center py-20 gap-4"> <div className="flex flex-col items-center justify-center py-20 gap-4">
<Loader2 className="h-12 w-12 animate-spin text-primary opacity-40" /> <Loader2 className="h-10 w-10 animate-spin text-orange-600" />
<p className="text-gray-400 font-black uppercase tracking-widest text-[9px]">Yükleniyor...</p> <p className="text-gray-500 font-medium">Gezileriniz yükleniyor...</p>
</div> </div>
) : trips.length === 0 ? ( ) : trips.length === 0 ? (
<motion.div <Card className="border-dashed border-2 py-20 text-center space-y-4">
initial={{ opacity: 0, y: 15 }} <div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto">
animate={{ opacity: 1, y: 0 }} <Compass className="h-8 w-8 text-gray-400" />
> </div>
<Card className="bg-gray-50 dark:bg-white/5 border-dashed border-2 border-gray-100 dark:border-white/10 py-20 text-center space-y-6 rounded-3xl"> <div className="space-y-2">
<div className="w-20 h-20 bg-white dark:bg-white/5 rounded-2xl flex items-center justify-center mx-auto border border-gray-100 dark:border-white/10"> <h3 className="text-xl font-bold text-gray-900">Henüz bir planınız yok</h3>
<ExploreIcon className="h-8 w-8 text-gray-200" /> <p className="text-gray-500 max-w-sm mx-auto">
</div> İlk Kapadokya rotanızı oluşturmak için hemen planlayıcıyı kullanmaya başlayın.
<div className="space-y-2"> </p>
<h3 className="text-2xl font-black text-gray-900 dark:text-white tracking-tighter uppercase">Planınız yok</h3> </div>
<p className="text-gray-500 font-medium italic text-sm max-w-xs mx-auto"> <Button variant="outline" className="mt-4" asChild>
İlk Kapadokya rotanızı oluşturun. <Link to="/planner">Planlamaya Başla</Link>
</p> </Button>
</div> </Card>
<Button variant="outline" className="h-12 px-8 rounded-xl border-2 border-primary text-primary hover:bg-primary hover:text-white font-black uppercase tracking-widest transition-luxury" asChild>
<Link to="/planner">Hemen Planla</Link>
</Button>
</Card>
</motion.div>
) : ( ) : (
<div className="grid gap-6"> <div className="grid gap-6">
<AnimatePresence> {trips.map((trip) => (
{trips.map((trip, idx) => ( <Card key={trip.id} className="group overflow-hidden hover:shadow-xl transition-all duration-300 border-none shadow-sm">
<motion.div <CardContent className="p-0">
key={trip.id} <div className="flex flex-col md:flex-row">
initial={{ opacity: 0, y: 15 }} {/* Preview Image */}
animate={{ opacity: 1, y: 0 }} <div className="md:w-64 h-48 md:h-auto bg-gray-200 relative overflow-hidden">
transition={{ delay: idx * 0.1 }} <img
> src="https://miaoda-site-img.s3cdn.medo.dev/images/KLing_8ea8dda5-57a3-4533-bd11-28440db86c34.jpg"
<Card className="group overflow-hidden bg-white dark:bg-white/5 border-2 border-gray-50 dark:border-white/5 rounded-3xl shadow-md hover:shadow-xl transition-luxury"> alt={trip.title}
<CardContent className="p-0"> className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
<div className="flex flex-col sm:flex-row"> />
<div className="sm:w-56 h-48 sm:h-auto bg-gray-200 relative overflow-hidden shrink-0"> <div className="absolute inset-0 bg-black/20" />
<img <div className="absolute top-4 left-4">
src="https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=2400" <Badge className="bg-white/90 text-gray-900 hover:bg-white border-none shadow-sm">
alt={trip.title} {trip.itinerary.days.length} Gün
className="w-full h-full object-cover group-hover:scale-105 transition-luxury duration-700" </Badge>
/>
<div className="absolute top-4 left-4">
<Badge className="bg-primary text-white border-none font-black px-2 py-0.5 rounded-full uppercase text-[8px]">
<Zap className="h-2 w-2 mr-1" />
{trip.itinerary.days.length} GÜN
</Badge>
</div>
</div>
<div className="flex-1 p-6 flex flex-col justify-between">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-[9px] font-black text-primary uppercase tracking-widest">{trip.destination}</span>
</div>
<h3 className="text-2xl font-black text-gray-900 dark:text-white tracking-tighter uppercase leading-none group-hover:text-primary transition-colors">
{trip.title}
</h3>
<div className="flex items-center gap-4 pt-1">
<div className="flex items-center gap-1.5 text-[10px] font-bold text-gray-500 italic">
<Calendar className="h-3.5 w-3.5 text-primary" />
<span>{format(new Date(trip.start_date), 'd MMM yyyy', { locale: tr })}</span>
</div>
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10 rounded-xl text-gray-300 hover:text-red-600">
<Trash2 className="h-5 w-5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="rounded-3xl p-8 bg-secondary border-white/10">
<AlertDialogHeader className="space-y-3">
<AlertDialogTitle className="text-2xl font-black text-white tracking-tighter uppercase">Planı Sil?</AlertDialogTitle>
<AlertDialogDescription className="text-white/60 font-medium italic text-base">
Silmek istediğinize emin misiniz?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="pt-6">
<AlertDialogCancel className="h-12 px-8 rounded-xl font-bold bg-white/5 text-white border-white/10">Hayır</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(trip.id)}
className="h-12 px-8 rounded-xl font-black bg-red-600 hover:bg-red-700 text-white"
>
Evet, Sil
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<div className="mt-8 flex items-center justify-between border-t border-gray-50 dark:border-white/5 pt-6">
<div className="flex -space-x-2">
{trip.itinerary.days[0].items.slice(0, 3).map((item, i) => (
<div key={i} className="w-10 h-10 rounded-xl border-2 border-white dark:border-secondary bg-gray-100 overflow-hidden shadow-md">
<img
src={item.photo_reference ? (item.photo_reference.startsWith('http') ? item.photo_reference : api.getPhotoUrl(item.photo_reference)) : 'https://via.placeholder.com/100'}
alt=""
className="w-full h-full object-cover"
/>
</div>
))}
</div>
<Button className="h-11 px-6 rounded-xl bg-secondary dark:bg-white/10 hover:bg-primary hover:text-white transition-luxury font-black uppercase tracking-widest text-[9px] gap-2" asChild>
<Link to={`/trip/${trip.id}`}>
<ChevronRight className="h-4 w-4" />
</Link>
</Button>
</div>
</div>
</div> </div>
</CardContent> </div>
</Card>
</motion.div> {/* Content */}
))} <div className="flex-1 p-6 md:p-8 flex flex-col justify-between">
</AnimatePresence> <div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<h3 className="text-2xl font-bold text-gray-900 group-hover:text-orange-600 transition-colors">
{trip.title}
</h3>
<div className="flex items-center gap-3 text-sm text-gray-500">
<div className="flex items-center gap-1.5">
<Calendar className="h-4 w-4" />
<span>{format(new Date(trip.start_date), 'd MMMM yyyy', { locale: tr })}</span>
</div>
<div className="flex items-center gap-1.5">
<MapPin className="h-4 w-4" />
<span>{trip.destination}</span>
</div>
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="text-gray-400 hover:text-red-600 hover:bg-red-50">
<Trash2 className="h-5 w-5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Planı silmek istediğinize emin misiniz?</AlertDialogTitle>
<AlertDialogDescription>
Bu işlem geri alınamaz. Kaydettiğiniz tüm rota verileri silinecektir.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>İptal</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDelete(trip.id)}
className="bg-red-600 hover:bg-red-700"
>
Sil
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<div className="mt-8 flex items-center justify-between">
<div className="flex -space-x-2">
{trip.itinerary.days[0].items.slice(0, 3).map((item, i) => (
<div key={i} className="w-8 h-8 rounded-full border-2 border-white bg-gray-100 flex items-center justify-center overflow-hidden">
<img src={api.getPhotoUrl(item.place_id)} alt="" className="w-full h-full object-cover" onError={(e) => (e.currentTarget.src = 'https://via.placeholder.com/100')} />
</div>
))}
{trip.itinerary.days[0].items.length > 3 && (
<div className="w-8 h-8 rounded-full border-2 border-white bg-gray-100 flex items-center justify-center text-[10px] font-bold text-gray-500">
+{trip.itinerary.days[0].items.length - 3}
</div>
)}
</div>
<Button className="rounded-xl group/btn" asChild>
<Link to={`/trip/${trip.id}`}>
Görüntüle
<ChevronRight className="ml-1 h-4 w-4 group-hover/btn:translate-x-1 transition-transform" />
</Link>
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div> </div>
)} )}
</div> </div>
</div> </div>
); );
} }

View File

@ -3,8 +3,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Search, Star, Clock, Filter, Sparkles, Heart, Share2, ArrowUpRight, Camera } from 'lucide-react'; import { Search, MapPin, Star, Clock, Filter, Sparkles, Compass } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
const CATEGORIES = [ const CATEGORIES = [
{ id: 'all', label: 'Tümü' }, { id: 'all', label: 'Tümü' },
@ -34,7 +33,7 @@ const PLACES = [
reviews: 8500, reviews: 8500,
duration: '1 Saat', duration: '1 Saat',
image: 'https://miaoda-site-img.s3cdn.medo.dev/images/KLing_8ea8dda5-57a3-4533-bd11-28440db86c34.jpg', image: 'https://miaoda-site-img.s3cdn.medo.dev/images/KLing_8ea8dda5-57a3-4533-bd11-28440db86c34.jpg',
description: 'Kapadokya\'s en yüksek noktası. Tüm bölgeyi panoramik olarak görebileceğiniz devasa bir doğal kaya kalesi.', description: 'Kapadokya\'nın en yüksek noktası. Tüm bölgeyi panoramik olarak görebileceğiniz devasa bir doğal kaya kalesi.',
}, },
{ {
id: '3', id: '3',
@ -90,188 +89,125 @@ export default function ExplorePage() {
}); });
return ( return (
<div className="min-h-screen bg-background selection:bg-primary/20 pb-20"> <div className="min-h-screen bg-gray-50 pt-24 pb-12">
{/* Immersive Header */} <div className="max-w-7xl mx-auto px-6">
<section className="relative h-[40vh] flex items-center justify-center overflow-hidden mb-12"> {/* Header Section */}
<div className="absolute inset-0 z-0 scale-105"> <div className="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-12">
<img <div className="space-y-4 max-w-2xl">
src="https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=2400" <div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-orange-100 text-orange-700 text-xs font-bold uppercase tracking-wider">
alt="Explore Hero" <Sparkles className="h-3 w-3" />
className="w-full h-full object-cover grayscale-[0.2]" Keşfet
/> </div>
<div className="absolute inset-0 bg-gradient-to-t from-background via-background/20 to-transparent z-10" /> <h1 className="text-4xl lg:text-5xl font-bold text-gray-900 tracking-tight">
</div> Kapadokya'nın <span className="text-orange-600">En İyileri</span>
</h1>
<div className="container relative z-20 px-6 text-center space-y-6"> <p className="text-gray-500 text-lg leading-relaxed">
<motion.div Bölgenin en popüler noktalarını ve saklı kalmış eşsiz rotalarını keşfedin. Google onaylı verilerle seyahatinizi zenginleştirin.
initial={{ opacity: 0, y: 15 }} </p>
animate={{ opacity: 1, y: 0 }} </div>
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-white/10 backdrop-blur-md border border-white/20 text-white text-[9px] font-black uppercase tracking-widest"
>
<Sparkles className="h-3.5 w-3.5 text-accent" />
Efsanevi Duraklar
</motion.div>
<motion.h1 <div className="relative w-full md:w-80">
initial={{ opacity: 0, y: 15 }} <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="text-4xl md:text-6xl font-black text-white tracking-tighter leading-none uppercase"
>
KAPADOKYA <span className="text-primary uppercase">KEŞFİ</span>
</motion.h1>
<motion.div
initial={{ opacity: 0, y: 15 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="max-w-xl mx-auto relative group"
>
<Search className="absolute left-5 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400 group-focus-within:text-primary transition-colors" />
<Input <Input
placeholder="Bir efsane veya aktivite arayın..." placeholder="Mekan veya aktivite ara..."
className="w-full h-14 pl-12 pr-6 bg-white/10 backdrop-blur-xl border-white/20 text-white placeholder:text-white/40 rounded-2xl text-base font-bold focus:bg-white focus:text-gray-900 transition-luxury shadow-2xl" className="pl-10 h-12 bg-white border-gray-200 rounded-xl shadow-sm focus:ring-orange-500"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
</motion.div> </div>
</div> </div>
</section>
<div className="container px-6"> {/* Categories Bar */}
{/* Categories Carousel */} <div className="flex items-center gap-2 overflow-x-auto pb-4 mb-8 scrollbar-hide">
<div className="flex items-center gap-3 overflow-x-auto pb-6 mb-12 no-scrollbar"> <Button variant="outline" size="sm" className="shrink-0 border-gray-200 bg-white">
<Button variant="outline" className="h-11 px-6 rounded-xl border-2 border-gray-100 dark:border-white/10 font-black uppercase tracking-widest text-[10px] shrink-0 bg-white/5 backdrop-blur-md"> <Filter className="h-4 w-4 mr-2" />
<Filter className="h-3.5 w-3.5 mr-2 text-primary" />
Filtrele Filtrele
</Button> </Button>
<div className="w-px h-6 bg-gray-100 dark:bg-white/10 mx-1 shrink-0" /> <div className="w-px h-6 bg-gray-200 mx-2 shrink-0" />
{CATEGORIES.map((cat) => ( {CATEGORIES.map((cat) => (
<Button <Button
key={cat.id} key={cat.id}
onClick={() => setSelectedCategory(cat.id)} variant={selectedCategory === cat.id ? 'default' : 'outline'}
className={`h-11 px-8 rounded-xl font-black uppercase tracking-widest text-[10px] shrink-0 transition-luxury ${selectedCategory === cat.id size="sm"
? 'bg-primary text-white shadow-lg shadow-primary/20' className={`shrink-0 rounded-full font-medium ${
: 'bg-white/5 border-2 border-gray-100 dark:border-white/10 text-gray-400 hover:border-primary/40 hover:text-primary' selectedCategory === cat.id
? 'bg-orange-600 hover:bg-orange-700 border-orange-600'
: 'bg-white border-gray-200 hover:border-orange-200 hover:bg-orange-50'
}`} }`}
onClick={() => setSelectedCategory(cat.id)}
> >
{cat.label} {cat.label}
</Button> </Button>
))} ))}
</div> </div>
{/* Results Grid */} {/* Places Grid */}
<AnimatePresence mode="wait"> {filteredPlaces.length > 0 ? (
{filteredPlaces.length > 0 ? ( <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
<motion.div {filteredPlaces.map((place) => (
key="grid" <Card key={place.id} className="group overflow-hidden border-none shadow-sm hover:shadow-xl transition-all duration-300 rounded-2xl bg-white">
initial={{ opacity: 0 }} <CardContent className="p-0">
animate={{ opacity: 1 }} <div className="relative h-64 overflow-hidden">
exit={{ opacity: 0 }} <img
className="grid md:grid-cols-2 lg:grid-cols-3 gap-8" src={place.image}
> alt={place.name}
{filteredPlaces.map((place, index) => ( className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
<motion.div />
key={place.id} <div className="absolute top-4 left-4 flex gap-2">
initial={{ opacity: 0, y: 20 }} <Badge className="bg-white/90 backdrop-blur-sm text-gray-900 border-none shadow-sm capitalize">
animate={{ opacity: 1, y: 0 }} {CATEGORIES.find(c => c.id === place.category)?.label}
transition={{ delay: index * 0.1 }} </Badge>
> </div>
<Card className="group overflow-hidden border-2 border-gray-50 dark:border-white/5 bg-white dark:bg-white/5 rounded-3xl shadow-lg hover:shadow-2xl hover:-translate-y-1 transition-luxury"> <div className="absolute bottom-4 right-4">
<CardContent className="p-0"> <Badge className="bg-orange-600/90 backdrop-blur-sm text-white border-none shadow-sm flex items-center gap-1">
<div className="relative h-64 overflow-hidden"> <Star className="h-3 w-3 fill-white" />
<img {place.rating}
src={place.image} </Badge>
alt={place.name} </div>
className="w-full h-full object-cover transition-luxury duration-700 group-hover:scale-105" </div>
/> <div className="p-6 space-y-3">
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-luxury" /> <div className="flex items-start justify-between gap-4">
<h3 className="text-xl font-bold text-gray-900 group-hover:text-orange-600 transition-colors">
<div className="absolute top-4 left-4"> {place.name}
<Badge className="bg-white/10 backdrop-blur-md text-white border-white/20 font-black uppercase tracking-widest text-[9px] px-3 py-0.5 rounded-full"> </h3>
{CATEGORIES.find(c => c.id === place.category)?.label} </div>
</Badge> <p className="text-gray-500 text-sm line-clamp-2 leading-relaxed">
{place.description}
</p>
<div className="pt-4 flex items-center justify-between border-t border-gray-50">
<div className="flex items-center gap-4 text-xs text-gray-400 font-medium">
<div className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
<span>{place.duration}</span>
</div> </div>
<div className="flex items-center gap-1.5">
<div className="absolute bottom-4 left-4 right-4 flex items-center justify-between opacity-0 group-hover:opacity-100 translate-y-2 group-hover:translate-y-0 transition-luxury"> <Compass className="h-3.5 w-3.5" />
<div className="flex items-center gap-2"> <span>{place.reviews.toLocaleString()} Yorum</span>
<div className="w-8 h-8 rounded-full bg-white/10 backdrop-blur-md flex items-center justify-center text-white hover:bg-primary transition-colors cursor-pointer">
<Heart className="h-4 w-4" />
</div>
<div className="w-8 h-8 rounded-full bg-white/10 backdrop-blur-md flex items-center justify-center text-white hover:bg-primary transition-colors cursor-pointer">
<Share2 className="h-4 w-4" />
</div>
</div>
<Button className="bg-primary text-white font-black uppercase tracking-widest text-[9px] rounded-full h-8 px-4">
Planla
</Button>
</div>
<div className="absolute top-4 right-4">
<Badge className="bg-accent text-gray-900 border-none font-black text-xs px-2 py-0.5 rounded-lg flex items-center gap-1 shadow-lg">
<Star className="h-3 w-3 fill-gray-900" />
{place.rating}
</Badge>
</div> </div>
</div> </div>
<Button variant="ghost" size="sm" className="text-orange-600 font-bold hover:text-orange-700 hover:bg-orange-50 p-0 h-auto">
<div className="p-6 md:p-8 space-y-4"> Detaylar
<div className="space-y-1"> </Button>
<h3 className="text-2xl font-black text-gray-900 dark:text-white tracking-tighter uppercase leading-tight group-hover:text-primary transition-colors"> </div>
{place.name} </div>
</h3> </CardContent>
<p className="text-gray-500 font-medium italic leading-relaxed text-sm line-clamp-2"> </Card>
"{place.description}" ))}
</p> </div>
</div> ) : (
<div className="py-24 text-center space-y-4">
<div className="pt-6 flex items-center justify-between border-t border-gray-50 dark:border-white/5"> <div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-6">
<div className="flex flex-wrap items-center gap-4"> <Search className="h-10 w-10 text-gray-300" />
<div className="flex items-center gap-1.5 text-[9px] font-black text-gray-400 uppercase tracking-widest"> </div>
<Clock className="h-3.5 w-3.5 text-primary" /> <h3 className="text-2xl font-bold text-gray-900">Sonuç Bulunamadı</h3>
<span>{place.duration}</span> <p className="text-gray-500 max-w-sm mx-auto">
</div> Aramanıza uygun mekan bulunamadı. Farklı bir anahtar kelime veya kategori deneyin.
<div className="flex items-center gap-1.5 text-[9px] font-black text-gray-400 uppercase tracking-widest"> </p>
<Camera className="h-3.5 w-3.5 text-primary" /> <Button variant="link" onClick={() => { setSearchQuery(''); setSelectedCategory('all'); }} className="text-orange-600 font-bold">
<span>Görsel</span> Tüm Mekanları Göster
</div> </Button>
</div> </div>
<button className="w-10 h-10 rounded-xl bg-gray-50 dark:bg-white/10 flex items-center justify-center text-gray-400 hover:bg-primary hover:text-white transition-luxury group/btn"> )}
<ArrowUpRight className="h-5 w-5 group-hover/btn:rotate-45 transition-transform" />
</button>
</div>
</div>
</CardContent>
</Card>
</motion.div>
))}
</motion.div>
) : (
<motion.div
key="empty"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="py-20 text-center space-y-6"
>
<div className="w-24 h-24 bg-gray-50 dark:bg-white/5 rounded-2xl flex items-center justify-center mx-auto mb-6 border-2 border-dashed border-gray-200 dark:border-white/10">
<Search className="h-10 w-10 text-gray-200" />
</div>
<div className="space-y-2">
<h3 className="text-2xl font-black text-gray-900 dark:text-white tracking-tighter uppercase">BULUNAMADI</h3>
<p className="text-base text-gray-500 font-medium italic max-w-xs mx-auto">
Farklı bir keşif terimi deneyin.
</p>
</div>
<Button
variant="outline"
onClick={() => { setSearchQuery(''); setSelectedCategory('all'); }}
className="h-12 px-8 rounded-xl font-black uppercase tracking-widest text-[10px] border-2 border-primary text-primary hover:bg-primary hover:text-white transition-luxury"
>
Tümü
</Button>
</motion.div>
)}
</AnimatePresence>
</div> </div>
</div> </div>
); );

View File

@ -1,268 +1,170 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Sparkles, MapPin, Calendar, Compass, ShieldCheck, Zap, ArrowRight, Star, ArrowUpRight, Play, Globe } from 'lucide-react'; import { Sparkles, MapPin, Calendar, Compass, ShieldCheck, Zap, ArrowRight, Heart } from 'lucide-react';
import { motion, useScroll, useTransform } from 'framer-motion'; import { motion } from 'framer-motion';
import { useRef } from 'react';
const FeatureCard = ({ icon: Icon, title, description, index }: { icon: any, title: string, description: string, index: number }) => ( const FeatureCard = ({ icon: Icon, title, description }: { icon: any, title: string, description: string }) => (
<motion.div <div className="p-8 bg-white/60 backdrop-blur-md rounded-2xl border border-white/40 shadow-sm hover:shadow-xl transition-all duration-300 hover:-translate-y-1">
initial={{ opacity: 0, y: 20 }} <div className="w-14 h-14 bg-orange-100 rounded-xl flex items-center justify-center mb-6">
whileInView={{ opacity: 1, y: 0 }} <Icon className="h-7 w-7 text-orange-600" />
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}
className="group p-6 bg-white/40 dark:bg-white/5 backdrop-blur-xl rounded-3xl border border-white/20 dark:border-white/10 shadow-luxury hover:shadow-xl transition-luxury"
>
<div className="w-12 h-12 bg-gradient-to-br from-primary/20 to-accent/20 rounded-xl flex items-center justify-center mb-6 group-hover:scale-110 transition-luxury">
<Icon className="h-6 w-6 text-primary" />
</div> </div>
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-3 tracking-tight">{title}</h3> <h3 className="text-xl font-bold text-gray-900 mb-3">{title}</h3>
<p className="text-gray-600 dark:text-gray-400 leading-relaxed text-sm font-medium">{description}</p> <p className="text-gray-600 leading-relaxed">{description}</p>
</motion.div> </div>
); );
export default function LandingPage() { export default function LandingPage() {
const containerRef = useRef<HTMLDivElement>(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 ( return (
<div className="min-h-screen bg-background selection:bg-primary/20" ref={containerRef}> <div className="min-h-screen bg-[#FFF9F5]">
{/* Hero Section */} {/* Hero Section */}
<section className="relative h-[90vh] flex items-center justify-center overflow-hidden"> <section className="relative pt-32 pb-20 lg:pt-48 lg:pb-32 overflow-hidden">
{/* Cinematic Background */} {/* Background blobs */}
<motion.div <div className="absolute top-0 right-0 -translate-y-1/4 translate-x-1/4 w-[600px] h-[600px] bg-orange-200/30 rounded-full blur-3xl -z-10" />
style={{ y: heroY, opacity: heroOpacity }} <div className="absolute bottom-0 left-0 translate-y-1/4 -translate-x-1/4 w-[500px] h-[500px] bg-blue-100/30 rounded-full blur-3xl -z-10" />
className="absolute inset-0 z-0"
>
<div className="absolute inset-0 bg-black/40 z-10" />
<img
src="https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=2400"
alt="Cappadocia"
className="w-full h-full object-cover scale-105"
/>
</motion.div>
{/* Hero Content */} <div className="max-w-7xl mx-auto px-6 text-center">
<div className="container relative z-20 px-6 text-center">
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, ease: "easeOut" }} transition={{ duration: 0.6 }}
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-white/10 backdrop-blur-md border border-white/20 text-white text-[10px] font-bold mb-8 tracking-widest uppercase" className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-orange-100 text-orange-700 text-sm font-bold mb-8"
> >
<Sparkles className="h-3.5 w-3.5 text-accent" /> <Sparkles className="h-4 w-4" />
Yapay Zeka Destekli Premium Deneyim Yapay Zeka Destekli Gezi Planlayıcısı
</motion.div> </motion.div>
<motion.h1 <motion.h1
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2 }} transition={{ duration: 0.6, delay: 0.1 }}
className="text-5xl md:text-7xl lg:text-8xl font-black text-white mb-8 leading-[0.95] tracking-tighter" className="text-5xl lg:text-7xl font-extrabold text-gray-900 mb-8 leading-[1.1] tracking-tight"
> >
KAPADOKYA <br /> Kapadokya'yı <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary via-accent to-primary animate-gradient"> <span className="text-transparent bg-clip-text bg-gradient-to-r from-orange-600 to-amber-500">
EFSANESİ Yeniden Keşfedin
</span> </span>
</motion.h1> </motion.h1>
<motion.p <motion.p
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.4 }} transition={{ duration: 0.6, delay: 0.2 }}
className="text-lg md:text-xl text-white/80 max-w-2xl mx-auto mb-12 leading-relaxed font-medium italic" className="text-xl text-gray-600 max-w-2xl mx-auto mb-12 leading-relaxed"
> >
"Sıradan bir gezi değil, ruhunuza dokunacak bir keşif hikayesi. Saniyeler içinde size özel kurgulanmış premium rotalar." Saniyeler içinde kişiselleştirilmiş rotalar oluşturun. Google Maps tarafından doğrulanmış mekanlar ve yapay zeka tarafından hazırlanan akıllı programlar.
</motion.p> </motion.p>
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.6 }} transition={{ duration: 0.6, delay: 0.3 }}
className="flex flex-col sm:flex-row items-center justify-center gap-4" className="flex flex-col sm:flex-row items-center justify-center gap-4"
> >
<Button size="lg" className="h-14 px-8 text-lg font-bold bg-primary hover:bg-primary-dark shadow-xl shadow-primary/20 rounded-2xl transition-luxury group" asChild> <Button size="lg" className="h-14 px-10 text-lg font-bold bg-orange-600 hover:bg-orange-700 shadow-xl shadow-orange-200 rounded-xl" asChild>
<Link to="/planner"> <Link to="/planner">
Hemen Keşfet Hemen Planla
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" /> <ArrowRight className="ml-2 h-5 w-5" />
</Link> </Link>
</Button> </Button>
<button className="h-14 px-8 text-lg font-bold bg-white/10 backdrop-blur-xl border border-white/20 text-white rounded-2xl hover:bg-white/20 transition-luxury flex items-center gap-2"> <Button size="lg" variant="outline" className="h-14 px-10 text-lg font-bold border-2 rounded-xl bg-white/50 backdrop-blur-sm" asChild>
<Play className="h-5 w-5 fill-white" /> <Link to="/explore">Keşfetmeye Başla</Link>
Tanıtımı İzle </Button>
</button>
</motion.div> </motion.div>
</div>
{/* Scroll Indicator */} {/* Social Proof */}
<motion.div <motion.div
animate={{ y: [0, 8, 0] }} initial={{ opacity: 0 }}
transition={{ repeat: Infinity, duration: 2 }} animate={{ opacity: 1 }}
className="absolute bottom-8 left-1/2 -translate-x-1/2 text-white/40" transition={{ duration: 1, delay: 0.8 }}
> className="mt-20 flex flex-col items-center gap-4"
<div className="w-5 h-8 border-2 border-white/20 rounded-full flex justify-center p-1"> >
<div className="w-1 h-1 bg-white/40 rounded-full" /> <div className="flex -space-x-3">
</div> {[1, 2, 3, 4, 5].map((i) => (
</motion.div> <div key={i} className="w-12 h-12 rounded-full border-4 border-[#FFF9F5] bg-gray-200 overflow-hidden shadow-sm">
</section> <img src={`https://i.pravatar.cc/150?u=${i}`} alt="user" />
{/* Stats Section */}
<section className="py-16 bg-secondary text-white relative overflow-hidden">
<div className="absolute top-0 right-0 w-[600px] h-[600px] bg-primary/10 rounded-full blur-[100px] -translate-y-1/2 translate-x-1/2" />
<div className="container relative z-10 px-6">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8 text-center">
{[
{ 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) => (
<motion.div
key={i}
initial={{ opacity: 0, scale: 0.5 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: i * 0.1 }}
className="space-y-3"
>
<div className="w-10 h-10 mx-auto bg-white/5 rounded-xl flex items-center justify-center">
<stat.icon className="h-5 w-5 text-accent" />
</div> </div>
<div className="text-3xl md:text-4xl font-black text-primary">{stat.value}</div> ))}
<div className="text-white/60 font-bold tracking-widest uppercase text-[10px]">{stat.label}</div> <div className="w-12 h-12 rounded-full border-4 border-[#FFF9F5] bg-orange-600 flex items-center justify-center text-white text-xs font-bold shadow-sm">
</motion.div> +2k
))} </div>
</div> </div>
<p className="text-sm font-medium text-gray-500">
2,000'den fazla gezgin Kapadokya rotasını bizimle planladı.
</p>
</motion.div>
</div> </div>
</section> </section>
{/* Features Grid */} {/* Features Grid */}
<section className="py-20 bg-background relative overflow-hidden"> <section className="py-24 bg-white/40">
<div className="container px-6"> <div className="max-w-7xl mx-auto px-6">
<div className="max-w-2xl mx-auto text-center mb-16 space-y-4"> <div className="text-center mb-16 space-y-4">
<motion.span <h2 className="text-3xl lg:text-4xl font-bold text-gray-900">Neden Bizimle Planlamalısınız?</h2>
initial={{ opacity: 0 }} <p className="text-gray-600 max-w-2xl mx-auto">Seyahatinizi unutulmaz kılmak için teknolojiyi Kapadokya'nın büyüsüyle birleştiriyoruz.</p>
whileInView={{ opacity: 1 }}
className="text-primary font-black tracking-widest uppercase text-xs"
>
Kusursuz Mühendislik
</motion.span>
<motion.h2
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
className="text-3xl md:text-5xl font-black text-gray-900 dark:text-white leading-tight"
>
Seyahatinizi Sanata <br /> <span className="text-gradient">Dönüştürüyoruz</span>
</motion.h2>
</div> </div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
<FeatureCard <FeatureCard
index={0}
icon={Zap} icon={Zap}
title="Yapay Zeka Mimari" title="Yapay Zeka Hızı"
description="OpenAI 4o-mini altyapısıyla her detayı düşünülmüş, tutarlı ve akıcı bir seyahat programı." description="OpenAI altyapısıyla saniyeler içinde seyahat sürenize ve ilgi alanlarınıza özel rota oluşturun."
/> />
<FeatureCard <FeatureCard
index={1}
icon={ShieldCheck} icon={ShieldCheck}
title="Premium Veri" title="Google Doğrulaması"
description="Google Places verileriyle gerçek fotoğraflar, anlık çalışma saatleri ve güvenilir yorumlar." description="Önerilen tüm mekanlar Google Places API ile doğrulanır; güncel fotoğraflar ve gerçek yorumlarla sunulur."
/> />
<FeatureCard <FeatureCard
index={2}
icon={MapPin} icon={MapPin}
title="Akıllı Navigasyon" title="Akıllı Lojistik"
description="Sadece rota değil, Google Directions ile en mantıklı sıralama ve ulaşım süreleri." description="Google Directions API ile rotanızdaki mesafe ve süreler otomatik hesaplanır, zamanınız boşa gitmez."
/> />
<FeatureCard <FeatureCard
index={3}
icon={Compass} icon={Compass}
title="Kürasyon" title="Gizli Cevherler"
description="Yerel rehberlerin gizli favorileri ve turistik kalabalıkların ötesindeki özel duraklar." description="Sadece turistik yerleri değil, Kapadokya'nın saklı kalmış eşsiz köşelerini de keşfedin."
/> />
<FeatureCard <FeatureCard
index={4}
icon={Calendar} icon={Calendar}
title="Dinamik Kontrol" title="Esnek Yönetim"
description="Planınızı dilediğiniz zaman sürükle-bırak yöntemiyle revize edin, her an güncel kalın." description="Planınızı dilediğiniz gibi düzenleyin, sürükle-bırak özelliğiyle mekanların sırasını değiştirin."
/> />
<FeatureCard <FeatureCard
index={5} icon={Heart}
icon={Sparkles} title="Ücretsiz ve Hızlı"
title="Modern Estetik" description="Herhangi bir gizli ücret yok. En iyi Kapadokya deneyimi için tasarlandı."
description="En yüksek standartlarda kullanıcı deneyimi için tasarlanmış akıcı ve şık arayüz."
/> />
</div> </div>
</div> </div>
</section> </section>
{/* Luxury CTA */} {/* CTA Section */}
<section className="py-20 px-6"> <section className="py-24 px-6">
<motion.div <div className="max-w-5xl mx-auto rounded-3xl bg-gradient-to-br from-orange-600 to-amber-500 p-12 lg:p-20 text-center relative overflow-hidden">
initial={{ opacity: 0, scale: 0.95 }} {/* Decorative circles */}
whileInView={{ opacity: 1, scale: 1 }} <div className="absolute top-0 left-0 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-white/10 rounded-full blur-3xl" />
viewport={{ once: true }} <div className="absolute bottom-0 right-0 translate-x-1/2 translate-y-1/2 w-96 h-96 bg-black/10 rounded-full blur-3xl" />
className="max-w-6xl mx-auto rounded-[2.5rem] bg-secondary p-12 lg:p-20 text-center relative overflow-hidden group"
>
<div className="absolute inset-0 opacity-20 grayscale hover:grayscale-0 transition-luxury duration-1000 group-hover:scale-105">
<img src="https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=2400" alt="CTA" className="w-full h-full object-cover" />
</div>
<div className="relative z-10 space-y-8"> <div className="relative z-10 space-y-8">
<h2 className="text-4xl lg:text-7xl font-black text-white leading-[0.95] tracking-tighter uppercase"> <h2 className="text-4xl lg:text-5xl font-bold text-white leading-tight">
BİR SONRAKİ <br /> <span className="text-primary">EFSANENİZİ</span> YAZIN Maceranıza Başlamak İçin <br /> Hazır mısınız?
</h2> </h2>
<p className="text-white/60 text-lg md:text-xl max-w-xl mx-auto font-medium leading-relaxed"> <p className="text-white/80 text-lg max-w-xl mx-auto">
Kapadokya'nın zamansız ruhunu, modern teknolojinin gücüyle birleştirin. Bugün başlayın. Kapadokya'nın büyüleyici atmosferine adım atmadan önce rotanızı profesyonelce hazırlayın.
</p> </p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 pt-4"> <Button size="lg" className="h-14 px-12 text-lg font-bold bg-white text-orange-600 hover:bg-orange-50 rounded-xl" asChild>
<Button size="lg" className="h-16 px-10 text-xl font-bold bg-primary hover:bg-primary-dark rounded-2xl shadow-2xl shadow-primary/20 transition-luxury group" asChild> <Link to="/planner">Rotamı Oluştur</Link>
<Link to="/planner" className="flex items-center gap-3"> </Button>
Rotanı Oluştur
<ArrowUpRight className="h-6 w-6 group-hover:rotate-45 transition-transform" />
</Link>
</Button>
</div>
</div> </div>
</motion.div> </div>
</section> </section>
{/* Modern Footer */} {/* Footer */}
<footer className="py-16 border-t border-border bg-background"> <footer className="py-12 border-t bg-white">
<div className="container px-6"> <div className="max-w-7xl mx-auto px-6 text-center text-gray-500 text-sm">
<div className="flex flex-col md:flex-row justify-between items-center gap-8"> <p>© 2026 Cappadocia AI Travel Planner. Tüm hakları saklıdır.</p>
<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 tracking-tighter uppercase dark:text-white">
Kapadokya <span className="text-primary">Efsanesi</span>
</span>
</div>
<div className="flex items-center gap-8 text-[10px] font-bold text-gray-400 uppercase tracking-widest">
<Link to="/explore" className="hover:text-primary transition-colors">Keşfet</Link>
<Link to="/planner" className="hover:text-primary transition-colors">Planla</Link>
<Link to="/account" className="hover:text-primary transition-colors">Hesabım</Link>
</div>
</div>
<div className="mt-16 pt-10 border-t border-border flex flex-col md:flex-row justify-between items-center gap-4 text-gray-500 text-xs font-medium">
<p>© 2026 Cappadocia Legend. Her anı bir hikaye.</p>
<div className="flex items-center gap-6">
<span>TR</span>
<div className="h-3 w-px bg-border" />
<span>USD</span>
</div>
</div>
</div> </div>
</footer> </footer>
</div> </div>
); );
} }

View File

@ -1,13 +1,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate, useLocation, Link } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Loader2, MapPin, Sparkles, Shield, Eye, EyeOff, ArrowRight } from 'lucide-react'; import { Loader2, MapPin, Rocket, Map, Save, Users, Eye, EyeOff, Shield } from 'lucide-react';
import { motion } from 'framer-motion';
export default function LoginPage() { export default function LoginPage() {
const [isLogin, setIsLogin] = useState(true); const [isLogin, setIsLogin] = useState(true);
@ -21,6 +20,7 @@ export default function LoginPage() {
const location = useLocation(); const location = useLocation();
const from = location.state?.from || '/explore'; const from = location.state?.from || '/explore';
// Eğer kullanıcı zaten giriş yapmışsa yönlendir
if (user) { if (user) {
navigate(from, { replace: true }); navigate(from, { replace: true });
} }
@ -29,6 +29,7 @@ export default function LoginPage() {
e.preventDefault(); e.preventDefault();
if (!username || !password) return; if (!username || !password) return;
// Kullanıcı adı validasyonu
if (!/^[a-z0-9_]+$/.test(username)) { if (!/^[a-z0-9_]+$/.test(username)) {
toast.error('Kullanıcı adı sadece harf, rakam ve alt çizgi içerebilir'); toast.error('Kullanıcı adı sadece harf, rakam ve alt çizgi içerebilir');
return; return;
@ -60,172 +61,232 @@ export default function LoginPage() {
throw error; throw error;
} }
toast.success('Hesap oluşturuldu! Giriş yapılıyor...'); toast.success('Hesap oluşturuldu! Giriş yapılıyor...');
// Otomatik giriş yap
const { error: signInError } = await signInWithUsername(username, password); const { error: signInError } = await signInWithUsername(username, password);
if (!signInError) { if (!signInError) {
navigate(from, { replace: true }); navigate(from, { replace: true });
} }
} }
} catch (error: any) { } catch (error: any) {
toast.error(error.message || 'Bir hata oluştu'); const errorMessage = error.message || 'Bir hata oluştu. Lütfen tekrar deneyin.';
toast.error(errorMessage);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
return ( return (
<div className="min-h-screen flex bg-background selection:bg-primary/20"> <div className="min-h-screen flex">
{/* Left Side - Cinematic Hero */} {/* Sol Taraf - Hero Section (Sadece Desktop) */}
<div className="hidden lg:flex lg:w-[50%] xl:w-[55%] relative overflow-hidden group"> <div className="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-blue-500 via-blue-600 to-blue-800 p-12 flex-col justify-between text-white relative overflow-hidden">
<div className="absolute inset-0 z-0 transition-luxury duration-1000 group-hover:scale-105"> {/* Dekoratif arka plan */}
<img <div className="absolute inset-0 opacity-10">
src="https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=2400" <div className="absolute top-20 left-20 w-72 h-72 bg-white rounded-full blur-3xl" />
alt="Cappadocia Login" <div className="absolute bottom-20 right-20 w-96 h-96 bg-white rounded-full blur-3xl" />
className="w-full h-full object-cover grayscale-[0.2]"
/>
<div className="absolute inset-0 bg-gradient-to-br from-secondary/80 via-secondary/40 to-transparent z-10" />
</div> </div>
<div className="relative z-20 w-full p-12 xl:p-16 flex flex-col justify-between"> <div className="relative z-10">
<Link to="/" className="flex items-center gap-3 group/logo"> {/* Logo */}
<div className="w-12 h-12 bg-primary rounded-xl flex items-center justify-center text-white shadow-xl shadow-primary/40 group-hover/logo:scale-110 transition-luxury"> <div className="flex items-center space-x-3 mb-16">
<MapPin className="h-6 w-6" /> <div className="p-3 bg-white/20 backdrop-blur-sm rounded-2xl">
<MapPin className="h-8 w-8" />
</div> </div>
<span className="text-2xl font-black text-white tracking-tighter uppercase">Kapadokya <span className="text-primary">Efsanesi</span></span> <span className="text-2xl font-bold">Cappadocia AI</span>
</Link> </div>
<div className="max-w-lg space-y-6"> {/* Ana Başlık */}
<motion.div <div className="space-y-6 max-w-md">
initial={{ opacity: 0, x: -20 }} <h1 className="text-5xl font-bold leading-tight">
animate={{ opacity: 1, x: 0 }} Kapadokya'ya Hoşgeldin
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-white/10 backdrop-blur-md border border-white/20 text-white text-[10px] font-black uppercase tracking-widest"
>
<Sparkles className="h-3.5 w-3.5 text-accent" />
Sadece Size Özel Deneyim
</motion.div>
<h1 className="text-5xl xl:text-7xl font-black text-white leading-[0.9] tracking-tighter uppercase">
BİR SONRAKİ <br /> <span className="text-primary">MACERAYA</span> <br /> ADIM ATIN
</h1> </h1>
<p className="text-lg text-white/60 font-medium italic leading-relaxed"> <p className="text-xl text-blue-100">
"Efsaneler, sadece cesaret edenler ve keşfedenler için yazılır. Rotanızı kaydedin ve Kapadokya'yı yaşayın." Yapay zeka rehberliğinde unutulmaz deneyimler yarat
</p> </p>
</div>
<div className="flex items-center gap-3 text-white/40 text-[10px] font-black uppercase tracking-widest"> {/* Özellikler Listesi */}
<Shield className="h-3.5 w-3.5 text-primary" /> <div className="space-y-4 pt-8">
<span>Premium Güvenlik Protokolü Aktif</span> <div className="flex items-center space-x-3">
<div className="p-2 bg-white/20 backdrop-blur-sm rounded-lg">
<Rocket className="h-5 w-5" />
</div>
<span className="text-lg">AI Destekli Planlama</span>
</div>
<div className="flex items-center space-x-3">
<div className="p-2 bg-white/20 backdrop-blur-sm rounded-lg">
<Map className="h-5 w-5" />
</div>
<span className="text-lg">Gerçek Zamanlı Haritalar</span>
</div>
<div className="flex items-center space-x-3">
<div className="p-2 bg-white/20 backdrop-blur-sm rounded-lg">
<Save className="h-5 w-5" />
</div>
<span className="text-lg">Planlarını Kaydet</span>
</div>
<div className="flex items-center space-x-3">
<div className="p-2 bg-white/20 backdrop-blur-sm rounded-lg">
<Users className="h-5 w-5" />
</div>
<span className="text-lg">Arkadaşlarınla Paylaş</span>
</div>
</div>
</div> </div>
</div> </div>
{/* Alt Bilgi */}
<div className="relative z-10 flex items-center space-x-2 text-sm text-blue-100">
<Shield className="h-4 w-4" />
<span>Güvenlik: Verileriniz 256-bit SSL ile korunur</span>
</div>
</div> </div>
{/* Right Side - Luxury Form */} {/* Sağ Taraf - Login Form */}
<div className="w-full lg:w-[50%] xl:w-[45%] flex items-center justify-center p-8 md:p-16 relative overflow-hidden bg-white dark:bg-card"> <div className="w-full lg:w-1/2 flex items-center justify-center p-8 bg-background">
<div className="w-full max-w-sm relative z-10 space-y-10"> <div className="w-full max-w-md space-y-8">
{/* Mobile Brand Header */} {/* Mobile Logo */}
<div className="lg:hidden flex flex-col items-center gap-3 text-center mb-10"> <div className="lg:hidden flex justify-center mb-8">
<div className="w-14 h-14 bg-primary rounded-xl flex items-center justify-center text-white shadow-xl shadow-primary/20"> <div className="flex items-center space-x-2">
<MapPin className="h-8 w-8" /> <MapPin className="h-8 w-8 text-primary" />
<span className="text-2xl font-bold">Cappadocia AI</span>
</div> </div>
<h2 className="text-2xl font-black tracking-tighter uppercase">Kapadokya <span className="text-primary">Efsanesi</span></h2>
</div> </div>
<div className="space-y-3"> {/* Form Header */}
<h2 className="text-3xl md:text-4xl font-black text-gray-900 dark:text-white tracking-tighter uppercase leading-none"> <div className="space-y-2 text-center lg:text-left">
{isLogin ? 'HOŞ GELDİNİZ' : 'BİZE KATILIN'} <h2 className="text-3xl font-bold tracking-tight">
{isLogin ? 'Giriş Yap' : 'Hesap Oluştur'}
</h2> </h2>
<p className="text-base text-gray-500 font-medium italic"> <p className="text-muted-foreground">
{isLogin ? 'Efsane kaldığı yerden devam ediyor.' : 'Kendi Kapadokya hikayenizi başlatın.'} {isLogin ? 'Rotanızı yönetin ve seyahatinizi planlayın' : 'Kapadokya maceranıza başlayın'}
</p> </p>
</div> </div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-5"> {/* Kullanıcı Adı */}
<div className="space-y-2.5"> <div className="space-y-2">
<Label htmlFor="username" className="text-[10px] font-black uppercase tracking-widest text-primary">Kullanıcı Kimliği</Label> <Label htmlFor="username" className="text-sm font-semibold">
<Input Kullanıcı Adı
id="username" </Label>
placeholder="kullanici_adi" <Input
required id="username"
value={username} placeholder="kullaniciadi"
onChange={e => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))} required
className="h-14 rounded-xl border-2 border-gray-100 bg-gray-50/50 focus:border-primary px-5 text-base font-bold transition-luxury" value={username}
/> onChange={e => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))}
</div> className="h-11 text-base"
autoComplete="username"
/>
<p className="text-xs text-muted-foreground">
Sadece harf, rakam ve alt çizgi kullanılabilir
</p>
</div>
<div className="space-y-2.5"> {/* Şifre */}
<div className="flex items-center justify-between"> <div className="space-y-2">
<Label htmlFor="password" className="text-[10px] font-black uppercase tracking-widest text-primary">Güvenli Şifre</Label> <div className="flex items-center justify-between">
</div> <Label htmlFor="password" className="text-sm font-semibold">
<div className="relative"> Şifre
<Input </Label>
id="password" {isLogin && (
type={showPassword ? 'text' : 'password'}
placeholder="••••••••"
required
value={password}
onChange={e => setPassword(e.target.value)}
className="h-14 rounded-xl border-2 border-gray-100 bg-gray-50/50 focus:border-primary px-5 text-base font-bold transition-luxury pr-12"
/>
<button <button
type="button" type="button"
onClick={() => setShowPassword(!showPassword)} className="text-xs text-primary hover:underline"
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-primary transition-colors" onClick={() => toast.info('Şifre sıfırlama özelliği yakında eklenecek')}
> >
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />} Şifremi unuttum
</button> </button>
</div> )}
</div> </div>
<div className="relative">
{isLogin && ( <Input
<div className="flex items-center justify-between"> id="password"
<div className="flex items-center space-x-2.5"> type={showPassword ? 'text' : 'password'}
<Checkbox placeholder="••••••••"
id="remember" required
checked={rememberMe} value={password}
onCheckedChange={(checked) => setRememberMe(checked as boolean)} onChange={e => setPassword(e.target.value)}
className="w-4 h-4 rounded-md border-2 border-gray-300 data-[state=checked]:bg-primary data-[state=checked]:border-primary" className="h-11 text-base pr-10"
/> autoComplete={isLogin ? 'current-password' : 'new-password'}
<Label htmlFor="remember" className="text-xs font-bold text-gray-500 cursor-pointer">Beni Hatırla</Label> />
</div> <button
<button type="button" className="text-[10px] font-black uppercase tracking-widest text-primary hover:opacity-70 transition-opacity">Şifremi Unuttum</button> type="button"
</div> onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{!isLogin && password && (
<p className="text-xs text-muted-foreground">
{password.length < 6 ? '⚠️ En az 6 karakter gerekli' : '✓ Şifre uygun'}
</p>
)} )}
</div> </div>
{/* Beni Hatırla */}
{isLogin && (
<div className="flex items-center space-x-2">
<Checkbox
id="remember"
checked={rememberMe}
onCheckedChange={(checked) => setRememberMe(checked as boolean)}
/>
<Label
htmlFor="remember"
className="text-sm font-normal cursor-pointer"
>
Beni hatırla
</Label>
</div>
)}
{/* Submit Button */}
<Button <Button
type="submit" type="submit"
className="w-full h-16 text-lg font-black bg-primary hover:bg-primary-dark rounded-xl shadow-lg shadow-primary/20 transition-luxury group" className="w-full h-11 text-base font-semibold"
disabled={loading} disabled={loading}
> >
{loading ? ( {loading ? (
<Loader2 className="h-6 w-6 animate-spin" /> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{isLogin ? 'Giriş yapılıyor...' : 'Hesap oluşturuluyor...'}
</>
) : ( ) : (
<div className="flex items-center gap-2"> isLogin ? 'Giriş Yap' : 'Hesap Oluştur'
{isLogin ? 'Giriş Yap' : 'Hesabı Oluştur'}
<ArrowRight className="h-5 w-5 group-hover:translate-x-1 transition-transform" />
</div>
)} )}
</Button> </Button>
</form> </form>
<div className="pt-6 text-center border-t border-gray-100"> {/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">veya</span>
</div>
</div>
{/* Toggle Login/Signup */}
<div className="text-center">
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setIsLogin(!isLogin); setIsLogin(!isLogin);
setPassword(''); setPassword('');
}} }}
className="text-xs font-bold text-gray-400 hover:text-primary transition-luxury group" className="text-sm text-muted-foreground hover:text-primary transition-colors"
> >
{isLogin ? ( {isLogin ? (
<> <>
Hesabınız yok mu?{' '} Hesabınız yok mu?{' '}
<span className="font-black text-primary uppercase tracking-widest ml-1 group-hover:underline">Kayıt Ol</span> <span className="font-semibold text-primary">Kayıt ol</span>
</> </>
) : ( ) : (
<> <>
Hesabınız var mı?{' '} Zaten hesabınız var mı?{' '}
<span className="font-black text-primary uppercase tracking-widest ml-1 group-hover:underline">Giriş Yap</span> <span className="font-semibold text-primary">Giriş yap</span>
</> </>
)} )}
</button> </button>
@ -234,4 +295,4 @@ export default function LoginPage() {
</div> </div>
</div> </div>
); );
} }

View File

@ -1,543 +1,355 @@
import { useState, useMemo, useCallback, memo } from 'react'; import { useState, useMemo, useEffect, useRef, useCallback, memo, Suspense, lazy } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import api from '@/db/api'; import api from '@/db/api';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Progress } from '@/components/ui/progress';
import { Input } from '@/components/ui/input';
import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form'; import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod'; import * as z from 'zod';
import { format, differenceInDays } from 'date-fns'; import { format, differenceInDays } from 'date-fns';
import { import { MapPin, Loader2 } from 'lucide-react';
Loader2, ArrowRight, ArrowLeft, Sparkles,
MapPin, Calendar, Users, Coffee, Heart,
Car, Wallet, CheckCircle2, ChevronRight,
PersonStanding,
} from 'lucide-react';
import { parseApiError } from '@/utils/errorHandler'; import { parseApiError } from '@/utils/errorHandler';
import { retryWithBackoff, withTimeout } from '@/utils/retryWithBackoff'; 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'; // Constants
import { LOADING_STEPS } from '@/constants/planner';
// Components
import { DateSelector } from '@/components/planner/DateSelector'; import { DateSelector } from '@/components/planner/DateSelector';
import { TravelerInput } from '@/components/planner/TravelerInput'; import { TravelerInput } from '@/components/planner/TravelerInput';
import { AccommodationSelector } from '@/components/planner/AccommodationSelector'; import { AccommodationSelector } from '@/components/planner/AccommodationSelector';
import { InterestsGrid } from '@/components/planner/InterestsGrid'; import { InterestsGrid } from '@/components/planner/InterestsGrid';
import { TravelTypeSelector } from '@/components/planner/TravelTypeSelector'; import { SubmitButton } from '@/components/planner/SubmitButton';
import { TransportSelector } from '@/components/planner/TransportSelector'; import { HeroSection } from '@/components/planner/HeroSection';
import { BudgetSelector } from '@/components/planner/BudgetSelector';
// ─── Schema ─────────────────────────────────────────────────────────────────── // Form validation schema
const formSchema = z.object({ const formSchema = z.object({
dateRange: z.object({ dateRange: z.object({
from: z.date({ required_error: 'Başlangıç tarihi gereklidir' }), from: z.date({
to: z.date({ required_error: 'Bitiş tarihi gereklidir' }), required_error: 'Başlangıç tarihi gereklidir',
}) }),
.refine(d => d.from >= new Date(new Date().setHours(0, 0, 0, 0)), { 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', message: 'Başlangıç tarihi bugünden önce olamaz',
}) }
.refine(d => d.to > d.from, { ).refine(
(data) => {
if (!data.from || !data.to) return false;
return data.to > data.from;
},
{
message: 'Bitiş tarihi başlangıç tarihinden sonra olmalıdır', message: 'Bitiş tarihi başlangıç tarihinden sonra olmalıdır',
}) }
.refine(d => { ).refine(
const days = differenceInDays(d.to, d.from) + 1; (data) => {
if (!data.from || !data.to) return false;
const days = differenceInDays(data.to, data.from) + 1;
return days >= 1 && days <= 14; return days >= 1 && days <= 14;
}, { message: 'Seyahat süresi 114 gün arasında olmalıdır' }), },
travelType: z.string().min(1, 'Seyahat tipi seçiniz'), {
travelers: z.number().min(1).max(15), 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(), accommodation: z.string(),
transport: z.string().min(1, 'Ulaşım tercihi seçiniz'), interests: z.array(z.string())
budget: z.string().min(1, 'Bütçe aralığı seçiniz'), .min(1, 'En az 1 ilgi alanı seçmelisiniz')
interests: z.array(z.string()).min(1, 'En az 1 ilgi alanı seçiniz').max(6), .max(6, 'Maksimum 6 ilgi alanı seçebilirsiniz'),
}); });
type FormValues = z.infer<typeof formSchema>; type FormValues = z.infer<typeof formSchema>;
// ─── 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;
// ─── Summary label helpers ────────────────────────────────────────────────────
function getSummaryLabel(stepId: string, values: Partial<FormValues>): 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 PlannerPage = () => {
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadingStep, setLoadingStep] = useState(0); const [loadingStep, setLoadingStep] = useState(0);
const [currentStep, setCurrentStep] = useState(0);
const [datePickerOpen, setDatePickerOpen] = useState(false); const [datePickerOpen, setDatePickerOpen] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const form = useForm<FormValues>({ const form = useForm<FormValues>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
dateRange: { from: undefined, to: undefined }, dateRange: {
travelType: '', from: undefined,
to: undefined,
},
travelers: 2, travelers: 2,
accommodation: 'center', accommodation: 'center',
transport: '',
budget: '',
interests: [], interests: [],
}, },
}); });
// Load draft from localStorage
useEffect(() => {
const savedFormData = localStorage.getItem('planner_form_draft');
if (savedFormData) {
try {
const parsed = JSON.parse(savedFormData);
if (parsed.dateRange?.from) parsed.dateRange.from = new Date(parsed.dateRange.from);
if (parsed.dateRange?.to) parsed.dateRange.to = new Date(parsed.dateRange.to);
form.reset(parsed);
} catch (e) {
console.error('Draft loading error:', e);
}
}
}, [form]);
// Save draft to localStorage
useEffect(() => {
const subscription = form.watch((value) => {
localStorage.setItem('planner_form_draft', JSON.stringify(value));
});
return () => subscription.unsubscribe();
}, [form.watch]);
const watchedValues = form.watch(); const watchedValues = form.watch();
// ── Navigation ────────────────────────────────────────────────────────────── const formProgress = useMemo(() => {
const nextStep = async () => { let completed = 0;
const stepId = STEPS[currentStep].id; const total = 4;
const fieldMap: Record<string, keyof FormValues | (keyof FormValues)[]> = { if (watchedValues.dateRange?.from && watchedValues.dateRange?.to) completed++;
dates: 'dateRange', if (watchedValues.travelers >= 1) completed++;
travelType: 'travelType', if (watchedValues.accommodation) completed++;
travelers: ['travelers', 'accommodation'], if (watchedValues.interests?.length > 0) completed++;
transport: 'transport', return Math.round((completed / total) * 100);
budget: 'budget', }, [watchedValues]);
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((interestId: string) => {
const currentInterests = form.getValues('interests');
const handleInterestToggle = useCallback((id: string) => { const newInterests = currentInterests.includes(interestId)
const current = form.getValues('interests'); ? currentInterests.filter(id => id !== interestId)
const next = current.includes(id) ? current.filter(i => i !== id) : [...current, id]; : [...currentInterests, interestId];
form.setValue('interests', next, { shouldValidate: true }); form.setValue('interests', newInterests, { shouldValidate: true });
}, [form]); }, [form]);
const handleCancel = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setLoading(false);
setLoadingStep(0);
toast.info('Planlama iptal edildi');
}, []);
const simulateLoadingSteps = useCallback(() => { const simulateLoadingSteps = useCallback(() => {
setLoadingStep(0); setLoadingStep(0);
const iv = setInterval(() => { const interval = setInterval(() => {
setLoadingStep(prev => { setLoadingStep(prev => {
if (prev < LOADING_STEPS.length - 1) return prev + 1; if (prev < LOADING_STEPS.length - 1) return prev + 1;
clearInterval(iv); clearInterval(interval);
return prev; return prev;
}); });
}, 2500); }, 2500);
return iv; return interval;
}, []); }, []);
// ── Submit ──────────────────────────────────────────────────────────────────
const onSubmit = async (data: FormValues) => { const onSubmit = async (data: FormValues) => {
setLoading(true); setLoading(true);
const iv = simulateLoadingSteps(); setLoadingStep(0);
abortControllerRef.current = new AbortController();
const loadingInterval = simulateLoadingSteps();
try { try {
const startDate = format(data.dateRange.from, 'yyyy-MM-dd'); const startDate = format(data.dateRange.from, 'yyyy-MM-dd');
const endDate = format(data.dateRange.to, '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( const result = await retryWithBackoff(
() => withTimeout( async () => {
api.generateItinerary({ return await withTimeout(
startDate, api.generateItinerary(formData),
endDate, 45000,
interests: data.interests, new Error('Sunucu yanıt vermiyor, lütfen tekrar deneyin.')
dailySchedule: data.travelType === 'family' ? 'relaxed' : data.budget === 'luxury' ? 'relaxed' : 'moderate', );
travelType: data.travelType, },
accommodation: data.accommodation, {
transport: data.transport, maxRetries: 2,
budget: data.budget, initialDelay: 1000,
travelers: data.travelers, maxDelay: 5000
}), }
45000,
new Error('Sunucu yanıt vermiyor, lütfen tekrar deneyin.')
),
{ maxRetries: 2, initialDelay: 1000, maxDelay: 5000 }
); );
clearInterval(iv);
clearInterval(loadingInterval);
let tripId = '';
if (user) { if (user) {
const saved = await api.saveTrip({ const savedTrip = await api.saveTrip({
user_id: user.id, user_id: user.id,
title: 'Kapadokya Gezisi', title: `Kapadokya Gezisi`,
destination: 'Cappadocia', destination: 'Cappadocia',
start_date: startDate, start_date: startDate,
end_date: endDate, end_date: endDate,
preferences: { preferences: formData,
startDate,
endDate,
interests: data.interests,
travelType: data.travelType,
accommodation: data.accommodation,
transport: data.transport,
budget: data.budget,
travelers: data.travelers,
},
itinerary: result, itinerary: result,
}); });
navigate(`/trip/${saved.id}`); tripId = savedTrip.id;
toast.success('Rotanız hazır!'); localStorage.removeItem('planner_form_draft');
} else { } else {
sessionStorage.setItem('pending_trip', JSON.stringify(result)); sessionStorage.setItem('pending_trip', JSON.stringify(result));
navigate('/login', { state: { from: '/planner', message: 'Planınızı kaydetmek için giriş yapın' } }); 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) { } catch (err) {
clearInterval(iv); clearInterval(loadingInterval);
if (err instanceof Error && err.name === 'AbortError') return; if (err instanceof Error && err.name === 'AbortError') return;
toast.error('Hata oluştu', { description: parseApiError(err).userMessage }); const apiError = parseApiError(err);
toast.error('Hata oluştu', { description: apiError.userMessage });
} finally { } finally {
setLoading(false); setLoading(false);
setLoadingStep(0); setLoadingStep(0);
} }
}; };
const progress = ((currentStep + 1) / STEPS.length) * 100;
// ── Loading screen ──────────────────────────────────────────────────────────
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 opacity-15">
<img
src="https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=2400"
alt=""
className="w-full h-full object-cover grayscale"
/>
</div>
<div className="absolute inset-0 bg-secondary/60" />
<div className="relative z-10 max-w-md w-full px-8 text-center space-y-10">
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
className="relative mx-auto w-24 h-24"
>
<div className="absolute inset-0 bg-primary rounded-2xl animate-pulse opacity-30 scale-110" />
<div className="w-24 h-24 bg-primary rounded-2xl flex items-center justify-center shadow-2xl shadow-primary/30">
<Loader2 className="h-11 w-11 text-white animate-spin" />
</div>
</motion.div>
<div className="space-y-5">
<AnimatePresence mode="wait">
<motion.h2
key={loadingStep}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="text-3xl font-black text-white tracking-tighter uppercase"
>
{LOADING_STEPS[loadingStep].label}
</motion.h2>
</AnimatePresence>
<div className="w-full h-2 bg-white/10 rounded-full overflow-hidden">
<motion.div
className="h-full bg-primary rounded-full"
initial={{ width: 0 }}
animate={{ width: `${LOADING_STEPS[loadingStep].progress}%` }}
transition={{ duration: 0.6, ease: 'easeInOut' }}
/>
</div>
<div className="flex justify-between text-[10px] font-bold text-white/30 uppercase tracking-widest px-1">
<span>Hazırlanıyor</span>
<span>{LOADING_STEPS[loadingStep].progress}%</span>
</div>
</div>
<p className="text-white/30 text-xs font-medium italic">
Size özel Kapadokya efsanesi kurguluyoruz...
</p>
</div>
</div>
);
}
// ── Main layout ─────────────────────────────────────────────────────────────
return ( return (
<div className="min-h-screen bg-background flex flex-col lg:flex-row overflow-hidden"> <div className="min-h-screen flex">
{/* Left Side - Form */}
{/* ── Sidebar ─────────────────────────────────────────────────────────── */} <div className="w-full lg:w-[45%] xl:w-[40%] bg-white p-6 md:p-10 overflow-y-auto">
<div className="w-full lg:w-[300px] xl:w-[360px] bg-secondary flex flex-col relative overflow-hidden shrink-0"> <div className="max-w-xl mx-auto space-y-8">
{/* Decorative orbs */}
<div className="absolute top-0 left-0 w-72 h-72 bg-primary/10 rounded-full blur-3xl -translate-x-1/2 -translate-y-1/2 pointer-events-none" />
<div className="absolute bottom-0 right-0 w-64 h-64 bg-accent/5 rounded-full blur-3xl translate-x-1/3 translate-y-1/3 pointer-events-none" />
<div className="relative z-10 flex flex-col h-full p-8 lg:p-10 gap-8">
{/* Logo */}
<div className="flex items-center gap-3">
<div className="w-9 h-9 bg-primary rounded-xl flex items-center justify-center text-white shadow-lg shadow-primary/20">
<MapPin className="h-4 w-4" />
</div>
<span className="text-base font-black text-white tracking-tight uppercase">
Kapadokya <span className="text-primary">Efsanesi</span>
</span>
</div>
{/* Headline */}
<div className="space-y-2"> <div className="space-y-2">
<h1 className="text-3xl xl:text-4xl font-black text-white leading-none tracking-tighter uppercase"> <h1 className="text-3xl md:text-4xl font-bold text-gray-900 tracking-tight">
ROTANIZI<br /><span className="text-primary">TASARLAYIN</span> Hayalinizdeki <span className="text-orange-600">Kapadokya</span> gezisi başlasın
</h1> </h1>
<p className="text-white/35 text-sm font-medium italic"> <p className="text-gray-500 text-lg">
"Size özel kurgulanmış seyahat mimarisi." Kişiselleştirilmiş, Google doğrulamalı ve yapay zeka destekli gezi planlayıcısı.
</p> </p>
</div> </div>
{/* Step list */} <div className="space-y-3">
<div className="flex-1 space-y-1.5"> <div className="flex items-center justify-between text-sm">
{STEPS.map((step, i) => { <span className="text-gray-600 font-medium">Planlama İlerlemesi</span>
const Icon = step.icon; <span className="text-orange-600 font-bold">{formProgress}%</span>
const isActive = i === currentStep; </div>
const isCompleted = i < currentStep; <Progress value={formProgress} className="h-2 bg-orange-100" />
const summaryLabel = getSummaryLabel(step.id, watchedValues);
return (
<motion.div
key={step.id}
animate={{ opacity: isActive || isCompleted ? 1 : 0.35 }}
className={cn(
'flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all duration-200 group',
isActive && 'bg-white/8'
)}
>
{/* Step indicator */}
<div className={cn(
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0 transition-all duration-300',
isActive ? 'bg-primary text-white shadow-md shadow-primary/30 scale-110'
: isCompleted ? 'bg-white/10 text-primary'
: 'bg-white/5 text-white/20'
)}>
{isCompleted
? <CheckCircle2 className="h-4 w-4" />
: <Icon className="h-4 w-4" />
}
</div>
{/* Labels */}
<div className="flex-1 min-w-0">
<div className={cn(
'text-[9px] font-black uppercase tracking-[0.15em]',
isActive ? 'text-primary' : 'text-white/25'
)}>
{String(i + 1).padStart(2, '0')}
</div>
<div className={cn(
'text-sm font-bold leading-tight truncate',
isActive ? 'text-white' : isCompleted ? 'text-white/60' : 'text-white/30'
)}>
{step.title}
</div>
</div>
{/* Summary pill */}
{isCompleted && summaryLabel && (
<span className="text-[9px] font-black text-primary/70 bg-primary/10 px-2 py-0.5 rounded-full shrink-0 max-w-[80px] truncate">
{summaryLabel}
</span>
)}
{isActive && (
<ChevronRight className="h-3.5 w-3.5 text-primary/60 shrink-0" />
)}
</motion.div>
);
})}
</div> </div>
{/* Progress bar */}
<div className="space-y-2 pt-2 border-t border-white/8">
<div className="flex justify-between text-[10px] font-bold text-white/25 uppercase tracking-widest">
<span>İlerleme</span>
<span>{Math.round(progress)}%</span>
</div>
<div className="h-1 bg-white/8 rounded-full overflow-hidden">
<motion.div
className="h-full bg-primary rounded-full"
animate={{ width: `${progress}%` }}
transition={{ duration: 0.5, ease: 'easeOut' }}
/>
</div>
</div>
</div>
</div>
{/* ── Main Form Area ──────────────────────────────────────────────────── */}
<div className="flex-1 bg-white dark:bg-card overflow-y-auto">
<div className="max-w-2xl mx-auto min-h-full flex flex-col px-8 py-10 lg:py-14 lg:px-16">
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 flex flex-col"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* Destination */}
<div className="space-y-3">
<Label className="text-sm font-bold text-gray-700">Gidilecek Yer</Label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<Input
value="Kapadokya, Nevşehir"
disabled
className="pl-10 h-12 bg-gray-50 border-gray-200 font-medium"
/>
</div>
</div>
<AnimatePresence mode="wait"> {/* Date Selection */}
<motion.div <FormField
key={currentStep} control={form.control}
initial={{ opacity: 0, x: 24 }} name="dateRange"
animate={{ opacity: 1, x: 0 }} render={({ field }) => (
exit={{ opacity: 0, x: -24 }} <FormItem className="space-y-3">
transition={{ duration: 0.35, ease: 'easeOut' }} <Label className="text-sm font-bold text-gray-700">Ne Zaman Gidiyorsunuz?</Label>
className="flex-1 space-y-10" <DateSelector
> date={field.value}
{/* Step header */} onDateChange={field.onChange}
<div className="space-y-2"> isOpen={datePickerOpen}
<div className="flex items-center gap-2 text-[10px] font-black text-primary uppercase tracking-[0.2em]"> onOpenChange={setDatePickerOpen}
<span>Adım {currentStep + 1}/{STEPS.length}</span> />
<span className="w-12 h-0.5 bg-primary/20 rounded-full" /> <FormMessage />
<span>{STEPS[currentStep].title}</span> </FormItem>
</div>
<h2 className="text-3xl md:text-4xl xl:text-5xl font-black text-gray-900 dark:text-white tracking-tighter leading-[0.95] uppercase">
{STEPS[currentStep].description}
</h2>
</div>
{/* Step content */}
<div className="pt-2">
{/* Step 0 — Dates */}
{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>
)} />
)}
{/* Step 1 — Travel Type */}
{currentStep === 1 && (
<FormField control={form.control} name="travelType" render={({ field }) => (
<FormItem className="space-y-3">
<Label className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">Seyahat Tarzı</Label>
<TravelTypeSelector selectedId={field.value} onSelect={field.onChange} />
<FormMessage />
</FormItem>
)} />
)}
{/* Step 2 — Travelers & Accommodation */}
{currentStep === 2 && (
<div className="space-y-8">
<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>
)} />
<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>
)} />
</div>
)}
{/* Step 3 — Transport */}
{currentStep === 3 && (
<FormField control={form.control} name="transport" render={({ field }) => (
<FormItem className="space-y-3">
<Label className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">Ulaşım Tercihi</Label>
<TransportSelector selectedId={field.value} onSelect={field.onChange} />
<FormMessage />
</FormItem>
)} />
)}
{/* Step 4 — Budget */}
{currentStep === 4 && (
<FormField control={form.control} name="budget" render={({ field }) => (
<FormItem className="space-y-3">
<Label className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">Günlük Bütçe</Label>
<BudgetSelector selectedId={field.value} onSelect={field.onChange} />
<FormMessage />
</FormItem>
)} />
)}
{/* Step 5 — Interests */}
{currentStep === 5 && (
<FormField control={form.control} name="interests" render={({ field }) => (
<FormItem className="space-y-3">
<div className="flex justify-between items-center">
<Label className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">İ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 */}
<div className="pt-10 mt-auto flex items-center justify-between gap-4 border-t border-gray-100 dark:border-white/8">
<Button
type="button"
variant="ghost"
size="lg"
onClick={prevStep}
disabled={currentStep === 0}
className="h-13 px-7 text-sm font-bold rounded-xl hover:bg-gray-50 group disabled:opacity-30"
>
<ArrowLeft className="mr-2 h-4 w-4 group-hover:-translate-x-1 transition-transform" />
Geri
</Button>
{currentStep < STEPS.length - 1 ? (
<Button
type="button"
size="lg"
onClick={nextStep}
className="h-13 px-10 text-sm font-black bg-primary hover:bg-primary/90 rounded-xl shadow-lg shadow-primary/20 group uppercase tracking-widest"
>
Devam Et
<ArrowRight className="ml-2 h-4 w-4 group-hover:translate-x-1 transition-transform" />
</Button>
) : (
<Button
type="submit"
size="lg"
className="h-13 px-12 text-sm font-black bg-primary hover:bg-primary/90 rounded-xl shadow-lg shadow-primary/20 uppercase tracking-widest"
>
Rotayı Oluştur
<Sparkles className="ml-2 h-4 w-4 animate-pulse" />
</Button>
)} )}
/>
{/* Travelers */}
<FormField
control={form.control}
name="travelers"
render={({ field }) => (
<FormItem className="space-y-3">
<Label className="text-sm font-bold text-gray-700">Kiminle Gidiyorsunuz?</Label>
<TravelerInput value={field.value} onChange={field.onChange} />
<FormMessage />
</FormItem>
)}
/>
{/* Accommodation */}
<FormField
control={form.control}
name="accommodation"
render={({ field }) => (
<FormItem className="space-y-3">
<Label className="text-sm font-bold text-gray-700">Konaklama Tercihi</Label>
<AccommodationSelector selectedId={field.value} onSelect={field.onChange} />
<FormMessage />
</FormItem>
)}
/>
{/* Interests */}
<FormField
control={form.control}
name="interests"
render={({ field }) => (
<FormItem className="space-y-3">
<div className="flex justify-between items-end">
<Label className="text-sm font-bold text-gray-700">Nelerle İlgilenirsiniz?</Label>
<span className="text-xs text-gray-500 font-medium">{field.value.length}/6 Seçildi</span>
</div>
<InterestsGrid selectedInterests={field.value} onToggle={handleInterestToggle} />
<FormMessage />
</FormItem>
)}
/>
{loading && (
<div className="p-4 bg-orange-50 border border-orange-100 rounded-xl space-y-3 animate-in fade-in slide-in-from-bottom-2">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 text-orange-600 animate-spin" />
<span className="text-sm font-bold text-orange-900">{LOADING_STEPS[loadingStep].label}</span>
</div>
<Progress value={LOADING_STEPS[loadingStep].progress} className="h-1.5 bg-orange-200" />
</div>
)}
<div className="pt-4">
<SubmitButton
loading={loading}
disabled={formProgress < 100}
onCancel={handleCancel}
/>
</div> </div>
</form> </form>
</Form> </Form>
</div> </div>
</div> </div>
{/* Right Side - Hero */}
<HeroSection />
</div> </div>
); );
}; };
export default memo(PlannerPage); export default memo(PlannerPage);

View File

@ -1,549 +1,208 @@
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import api, { Trip, Place, ItineraryDay } from '@/db/api'; import api, { Trip, Place } from '@/db/api';
import { Timeline } from '@/components/trip/Timeline'; import { Timeline } from '@/components/trip/Timeline';
import { TripMap } from '@/components/trip/Map'; import { TripMap } from '@/components/trip/Map';
import { import { Loader2, Share2, Download, Copy, Calendar, MapPin, Clock } from 'lucide-react';
Loader2, Share2, MapPin, Trash2, Zap,
Plus, Sparkles, LayoutGrid, RotateCcw, RotateCw,
CheckCircle2, Clock, Navigation
} from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { format, addDays } from 'date-fns'; import { format } from 'date-fns';
import { tr } from 'date-fns/locale'; import { tr } from 'date-fns/locale';
import { motion, AnimatePresence } from 'framer-motion';
import { import {
Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet'; } from '@/components/ui/sheet';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { cn } from '@/lib/utils';
import { useAuth } from '@/contexts/AuthContext';
// ────────────────────────────────────────────────────────────────────────────
// Undo / Redo hook
// ────────────────────────────────────────────────────────────────────────────
function useUndoRedo<T>(initial: T) {
const stack = useRef<T[]>([initial]);
const [ptr, setPtr] = useState(0);
const current = stack.current[ptr];
const canUndo = ptr > 0;
const canRedo = ptr < stack.current.length - 1;
const push = useCallback((next: T) => {
stack.current = stack.current.slice(0, ptr + 1);
stack.current.push(next);
setPtr(p => p + 1);
}, [ptr]);
const undo = useCallback(() => { if (canUndo) setPtr(p => p - 1); }, [canUndo]);
const redo = useCallback(() => { if (canRedo) setPtr(p => p + 1); }, [canRedo]);
const jumpTo = useCallback((index: number) => {
if (index >= 0 && index < stack.current.length) setPtr(index);
}, []);
return { current, push, undo, redo, canUndo, canRedo, jumpTo, ptr };
}
// ────────────────────────────────────────────────────────────────────────────
// Component
// ────────────────────────────────────────────────────────────────────────────
export default function TripDetailsPage() { export default function TripDetailsPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const [trip, setTrip] = useState<Trip | null>(null); const [trip, setTrip] = useState<Trip | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activePlaceId, setActivePlaceId] = useState<string | null>(null); const [activePlaceId, setActivePlaceId] = useState<string | null>(null);
const [isMapSheetOpen, setIsMapSheetOpen] = useState(false); const [isMapSheetOpen, setIsMapSheetOpen] = useState(false);
const [selectedDayIndex, setSelectedDayIndex] = useState(0);
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved');
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// History operates on the full itinerary object
const history = useUndoRedo<Trip['itinerary'] | null>(null);
// ── Load ──────────────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!id) return; if (id) {
(async () => { loadTrip(id);
try { }
const data = await api.getTripById(id);
if (data) {
setTrip(data);
history.push(data.itinerary);
} else {
toast.error('Gezi bulunamadı');
navigate('/explore');
}
} catch {
toast.error('Gezi yüklenemedi');
} finally {
setLoading(false);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]); }, [id]);
// ── Persist (debounced) ─────────────────────────────────────────────────── const loadTrip = async (tripId: string) => {
const scheduleWrite = useCallback((t: Trip) => { try {
setSaveStatus('unsaved'); const data = await api.getTripById(tripId);
if (saveTimer.current) clearTimeout(saveTimer.current); if (data) {
saveTimer.current = setTimeout(async () => { setTrip(data);
setSaveStatus('saving'); } else {
try { toast.error('Gezi bulunamadı');
await api.updateTrip(t.id, { itinerary: t.itinerary });
setSaveStatus('saved');
} catch {
setSaveStatus('unsaved');
toast.error('Değişiklikler kaydedilemedi');
} }
}, 1500); } catch (error) {
}, []); console.error(error);
toast.error('Gezi yüklenemedi');
// ── Apply an itinerary snapshot (used by all mutators + undo/redo) ──────── } finally {
const applyItinerary = useCallback((itinerary: Trip['itinerary'], pushToHistory = true) => { setLoading(false);
setTrip(prev => { }
if (!prev) return prev;
const next = { ...prev, itinerary };
scheduleWrite(next);
return next;
});
if (pushToHistory) history.push(itinerary);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scheduleWrite]);
// ── Undo / Redo ────────────────────────────────────────────────────────────
const handleUndo = useCallback(() => {
if (!history.canUndo) return;
history.undo();
const prev = history.current;
if (prev) applyItinerary(prev, false);
}, [history, applyItinerary]);
const handleRedo = useCallback(() => {
if (!history.canRedo) return;
history.redo();
const next = history.current;
if (next) applyItinerary(next, false);
}, [history, applyItinerary]);
// Keyboard shortcuts
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (!(e.ctrlKey || e.metaKey)) return;
if (e.key === 'z' && !e.shiftKey) { e.preventDefault(); handleUndo(); }
if ((e.key === 'z' && e.shiftKey) || e.key === 'y') { e.preventDefault(); handleRedo(); }
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [handleUndo, handleRedo]);
// ── Itinerary mutators ────────────────────────────────────────────────────
const withDays = useCallback((fn: (days: ItineraryDay[]) => ItineraryDay[]) => {
if (!trip) return;
applyItinerary({ ...trip.itinerary, days: fn([...trip.itinerary.days]) });
}, [trip, applyItinerary]);
const handleReorder = (dayIndex: number, newItems: Place[]) => {
withDays(days => {
days[dayIndex] = { ...days[dayIndex], items: newItems };
return days;
});
}; };
const handleAddPlace = (dayIndex: number, place: Place) => { const handleReorder = async (dayIndex: number, newItems: Place[]) => {
withDays(days => { if (!trip) return;
const day = days[dayIndex];
const last = day.items[day.items.length - 1];
// Auto-schedule start_time after last item ends const newItinerary = { ...trip.itinerary };
let startMin = 9 * 60; newItinerary.days[dayIndex].items = newItems;
if (last) {
const [h, m] = (last.start_time || '09:00').split(':').map(Number); // Recalculate distance and duration
startMin = h * 60 + m + (last.estimated_duration_minutes || 60) + 20; try {
if (newItems.length > 1) {
const origin = `${newItems[0].lat},${newItems[0].lng}`;
const destination = `${newItems[newItems.length - 1].lat},${newItems[newItems.length - 1].lng}`;
const waypoints = newItems.slice(1, -1).map(i => `${i.lat},${i.lng}`);
const directions = await api.getDirections({ origin, destination, waypoints });
if (directions.routes && directions.routes[0]) {
const leg = directions.routes[0].legs.reduce((acc: any, curr: any) => ({
distance: acc.distance + curr.distance.value,
duration: acc.duration + curr.duration.value
}), { distance: 0, duration: 0 });
newItinerary.days[dayIndex].total_distance = `${(leg.distance / 1000).toFixed(1)} km`;
newItinerary.days[dayIndex].total_duration = `${Math.round(leg.duration / 60)} dk sürüş`;
}
} }
const toHHMM = (mins: number) => } catch (error) {
`${String(Math.floor(mins / 60) % 24).padStart(2, '0')}:${String(mins % 60).padStart(2, '0')}`; console.warn('Failed to calculate directions:', error);
}
const newPlace = { setTrip({ ...trip, itinerary: newItinerary });
...place,
start_time: toHHMM(startMin),
end_time: toHHMM(startMin + (place.estimated_duration_minutes || 60)),
};
days[dayIndex] = { ...day, items: [...day.items, newPlace] };
return days;
});
toast.success(`${place.name} rotaya eklendi`);
};
const handleDeletePlace = (dayIndex: number, placeId: string) => {
withDays(days => {
days[dayIndex] = { ...days[dayIndex], items: days[dayIndex].items.filter(i => i.place_id !== placeId) };
return days;
});
toast.success('Durak kaldırıldı');
};
const handleUpdatePlaceNote = (dayIndex: number, placeId: string, note: string) => {
withDays(days => {
const items = [...days[dayIndex].items];
const idx = items.findIndex(i => i.place_id === placeId);
if (idx > -1) items[idx] = { ...items[idx], notes: note };
days[dayIndex] = { ...days[dayIndex], items };
return days;
});
};
const handleUpdateDayNote = (dayIndex: number, note: string) => {
withDays(days => {
days[dayIndex] = { ...days[dayIndex], notes: note };
return days;
});
};
const handleAddDay = () => {
if (!trip) return;
const nextNum = trip.itinerary.days.length + 1;
withDays(days => [...days, { day: nextNum, items: [] }]);
toast.success(`Gün ${nextNum} eklendi`);
setSelectedDayIndex(trip.itinerary.days.length); // new day
};
const handleShare = async () => {
try { try {
await navigator.clipboard.writeText(window.location.href); await api.updateTrip(trip.id, { itinerary: newItinerary });
toast.success('Link panoya kopyalandı'); toast.success('Rota güncellendi');
} catch { } catch (error) {
toast.error('Kopyalama başarısız'); toast.error('Değişiklikler kaydedilemedi');
} }
}; };
const handleDelete = async () => { const handleShare = () => {
if (!trip) return; const url = window.location.href;
try { navigator.clipboard.writeText(url);
await api.deleteTrip(trip.id); toast.success('Link kopyalandı');
toast.success('Gezi silindi');
navigate('/account');
} catch {
toast.error('Gezi silinemedi');
}
}; };
// ── Computed ────────────────────────────────────────────────────────────── const handleExport = () => {
const getDayDate = useCallback((idx: number) => { toast.info('Dışa aktarma özelliği yakında eklenecek');
if (!trip) return ''; };
try {
return format(addDays(new Date(trip.start_date), idx), 'd MMM', { locale: tr });
} catch {
return '';
}
}, [trip]);
const dayStats = useMemo(() => { const handleDuplicate = () => {
if (!trip) return null; toast.info('Kopyalama özelliği yakında eklenecek');
const day = trip.itinerary.days[selectedDayIndex]; };
if (!day) return null;
const mins = day.items.reduce((s, p) => s + (p.estimated_duration_minutes || 60), 0);
const h = Math.floor(mins / 60), m = mins % 60;
return {
places: day.items.length,
duration: h > 0 ? `${h}s${m > 0 ? ` ${m}dk` : ''}` : `${m}dk`,
};
}, [trip, selectedDayIndex]);
// ── Render ────────────────────────────────────────────────────────────────
if (loading) { if (loading) {
return ( return (
<div className="h-[calc(100vh-64px)] flex flex-col items-center justify-center gap-4 bg-background"> <div className="flex h-screen items-center justify-center">
<div className="relative w-16 h-16"> <Loader2 className="h-12 w-12 animate-spin text-primary" />
<div className="absolute inset-0 rounded-full border-4 border-orange-100 border-t-orange-600 animate-spin" />
<Navigation className="absolute inset-0 m-auto h-6 w-6 text-orange-600" />
</div>
<p className="text-sm font-semibold text-gray-400">Rota yükleniyor...</p>
</div> </div>
); );
} }
if (!trip) return null; if (!trip) return <div className="flex items-center justify-center min-h-screen">Gezi bulunamadı</div>;
const selectedDay = trip.itinerary.days[selectedDayIndex]; // Calculate trip stats
const totalPlaces = trip.itinerary.days.reduce((sum, day) => sum + day.items.length, 0);
const totalDistance = trip.itinerary.days.reduce((sum, day) => {
const dist = day.total_distance ? parseFloat(day.total_distance) : 0;
return sum + dist;
}, 0);
const dayCount = trip.itinerary.days.length;
return ( return (
<div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden bg-background"> <div className="flex flex-col h-screen overflow-hidden">
{/* Header Area */}
{/* ── Sub-header ──────────────────────────────────────────────────── */} <div className="bg-gradient-to-b from-gray-100 to-white border-b">
<div className="h-14 border-b bg-white flex items-center px-4 gap-3 shrink-0 shadow-sm"> <div className="max-w-7xl mx-auto px-6 py-6">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
{/* Left */} <div className="flex-1">
<div className="flex items-center gap-3 flex-1 min-w-0"> <h1 className="text-3xl font-bold text-gray-900 mb-2">{trip.title}</h1>
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
{/* Undo / Redo */} <div className="flex items-center gap-1.5">
<div className="flex items-center gap-0.5 bg-gray-100 rounded-lg p-0.5"> <Calendar className="h-4 w-4" />
<button <span>
onClick={handleUndo} {format(new Date(trip.start_date), 'd MMM', { locale: tr })} - {format(new Date(trip.end_date), 'd MMM yyyy', { locale: tr })}
disabled={!history.canUndo} </span>
title="Geri al (Ctrl+Z)" </div>
className={cn( <Badge variant="secondary" className="font-normal">
"h-7 w-7 rounded-md flex items-center justify-center transition-all", {dayCount} Gün
history.canUndo </Badge>
? "text-gray-700 hover:bg-white hover:shadow-sm cursor-pointer" <Badge variant="secondary" className="font-normal">
: "text-gray-300 cursor-not-allowed" {totalPlaces} Mekan
)} </Badge>
> {totalDistance > 0 && (
<RotateCcw className="h-3.5 w-3.5" /> <Badge variant="secondary" className="font-normal">
</button> {totalDistance.toFixed(1)} km
<button </Badge>
onClick={handleRedo} )}
disabled={!history.canRedo} </div>
title="İleri al (Ctrl+Shift+Z)" </div>
className={cn(
"h-7 w-7 rounded-md flex items-center justify-center transition-all", <div className="flex items-center gap-2">
history.canRedo <Button variant="outline" size="sm" onClick={handleExport}>
? "text-gray-700 hover:bg-white hover:shadow-sm cursor-pointer" <Download className="h-4 w-4 mr-2" />
: "text-gray-300 cursor-not-allowed" Dışa Aktar
)}
>
<RotateCw className="h-3.5 w-3.5" />
</button>
</div>
<div className="h-4 w-px bg-gray-200" />
<div className="flex items-center gap-2 min-w-0">
<h1 className="text-sm font-bold text-gray-900 truncate">{trip.title}</h1>
<LayoutGrid className="h-3.5 w-3.5 text-gray-400 shrink-0" />
</div>
{/* Save indicator */}
<AnimatePresence mode="wait">
{saveStatus === 'saving' && (
<motion.span key="saving" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="flex items-center gap-1.5 text-[10px] font-bold text-gray-400 uppercase tracking-widest"
>
<Loader2 className="h-3 w-3 animate-spin" /> Kaydediliyor...
</motion.span>
)}
{saveStatus === 'saved' && (
<motion.span key="saved" initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0 }}
className="flex items-center gap-1.5 text-[10px] font-bold text-green-500"
>
<CheckCircle2 className="h-3 w-3" /> Kaydedildi
</motion.span>
)}
{saveStatus === 'unsaved' && (
<motion.div key="dot" initial={{ opacity: 0 }} animate={{ opacity: 1 }}
className="w-2 h-2 rounded-full bg-orange-400"
/>
)}
</AnimatePresence>
</div>
{/* Right actions */}
<div className="flex items-center gap-1.5 shrink-0">
{!user && (
<Button size="sm" onClick={() => navigate('/login')}
className="bg-orange-600 hover:bg-orange-700 h-8 px-4 rounded-full text-xs font-bold gap-1.5">
Kaydetmek için giriş yap
</Button>
)}
<Button variant="ghost" size="icon" className="h-8 w-8 text-gray-500 hover:text-gray-900" onClick={handleShare}>
<Share2 className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-gray-400 hover:text-red-500 hover:bg-red-50">
<Trash2 className="h-4 w-4" />
</Button> </Button>
</AlertDialogTrigger> <Button variant="outline" size="sm" onClick={handleShare}>
<AlertDialogContent className="rounded-2xl"> <Share2 className="h-4 w-4 mr-2" />
<AlertDialogHeader> Paylaş
<AlertDialogTitle className="font-bold">Geziyi sil?</AlertDialogTitle> </Button>
<AlertDialogDescription> <Button variant="outline" size="sm" onClick={handleDuplicate}>
<strong>"{trip.title}"</strong> kalıcı olarak silinecek. Bu işlem geri alınamaz. <Copy className="h-4 w-4 mr-2" />
</AlertDialogDescription> Kopyala
</AlertDialogHeader> </Button>
<AlertDialogFooter> </div>
<AlertDialogCancel className="rounded-xl font-bold">Vazgeç</AlertDialogCancel> </div>
<AlertDialogAction onClick={handleDelete} className="bg-red-600 hover:bg-red-700 rounded-xl font-bold">
Evet, Sil
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div className="h-4 w-px bg-gray-200 mx-0.5" />
<Button variant="outline" size="sm" className="h-8 px-3 rounded-xl border-gray-200 text-xs font-bold gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
AI Rota
</Button>
<Button variant="outline" size="sm" className="h-8 px-3 rounded-xl border-gray-200 text-xs font-bold gap-1.5">
<Zap className="h-3.5 w-3.5 text-blue-500" />
Optimize
</Button>
</div> </div>
</div> </div>
{/* ── Main ──────────────────────────────────────────────────────────── */} <main className="flex flex-1 overflow-hidden">
<main className="flex-1 flex overflow-hidden"> {/* Timeline Panel */}
<div className="w-full lg:w-[40%] overflow-y-auto bg-white border-r">
{/* Day sidebar */} <Timeline
<aside className="w-16 md:w-[72px] border-r bg-gray-50/50 flex flex-col shrink-0 overflow-y-auto"> itinerary={trip.itinerary}
<div className="py-4 flex flex-col items-center gap-2"> onReorder={handleReorder}
<span className="text-[9px] font-black text-gray-400 uppercase tracking-widest">Günler</span> onPlaceClick={(id) => setActivePlaceId(id)}
<div className="flex flex-col gap-2 w-full px-2 mt-1">
{trip.itinerary.days.map((day, idx) => (
<motion.button
key={day.day}
onClick={() => setSelectedDayIndex(idx)}
whileHover={{ scale: 1.04 }} whileTap={{ scale: 0.96 }}
className={cn(
"flex flex-col items-center justify-center py-3 rounded-xl transition-all",
selectedDayIndex === idx
? "bg-orange-600 text-white shadow-lg shadow-orange-600/30"
: "text-gray-400 hover:bg-white hover:text-gray-900 hover:shadow-sm"
)}
>
<span className="text-[9px] font-black uppercase tracking-tighter">Gün {day.day}</span>
<span className={cn("text-[8px] font-semibold mt-0.5",
selectedDayIndex === idx ? "text-orange-200" : "text-gray-400"
)}>
{getDayDate(idx)}
</span>
<Badge className={cn(
"mt-1.5 text-[8px] px-1.5 py-0 h-4 font-black border-0 rounded-full",
selectedDayIndex === idx ? "bg-white/20 text-white" : "bg-gray-200 text-gray-500"
)}>
{day.items.length}
</Badge>
</motion.button>
))}
<button
onClick={handleAddDay}
className="flex flex-col items-center justify-center py-3 rounded-xl border border-dashed border-gray-200 text-gray-400 hover:text-orange-600 hover:bg-orange-50 hover:border-orange-300 transition-all"
>
<Plus className="h-4 w-4" />
<span className="text-[8px] font-black uppercase mt-1">Ekle</span>
</button>
</div>
</div>
</aside>
{/* Timeline panel */}
<section className="w-full lg:w-[45%] xl:w-[40%] overflow-y-auto bg-white border-r flex flex-col">
{/* Sticky day header */}
<div className="px-6 py-4 border-b sticky top-0 bg-white/95 backdrop-blur-sm z-30">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2.5">
<h2 className="text-lg font-black text-gray-900">Gün {selectedDay.day}</h2>
<span className="text-sm font-semibold text-gray-400">{getDayDate(selectedDayIndex)}</span>
</div>
<div className="flex items-center gap-3 mt-1.5">
<span className="flex items-center gap-1.5 text-[11px] font-bold text-gray-500">
<MapPin className="h-3 w-3 text-orange-500" />
{dayStats?.places || 0} durak
</span>
{dayStats && dayStats.places > 0 && (
<span className="flex items-center gap-1.5 text-[11px] font-bold text-gray-500">
<Clock className="h-3 w-3 text-orange-500" />
~{dayStats.duration}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-8 rounded-xl text-xs font-bold gap-1.5 border-gray-100">
<Sparkles className="h-3 w-3 text-orange-500" />AI
</Button>
<Button size="sm" className="h-8 rounded-xl text-xs font-bold gap-1.5 bg-orange-600 hover:bg-orange-700">
<Plus className="h-3 w-3" />Yer Ekle
</Button>
</div>
</div>
</div>
<div className="flex-1">
<Timeline
itinerary={{ days: [selectedDay] }}
onReorder={(_, items) => handleReorder(selectedDayIndex, items)}
onAddPlace={(_, place) => handleAddPlace(selectedDayIndex, place)}
onDeletePlace={(_, id) => handleDeletePlace(selectedDayIndex, id)}
onUpdatePlaceNote={(_, id, note) => handleUpdatePlaceNote(selectedDayIndex, id, note)}
onUpdateDayNote={(_, note) => handleUpdateDayNote(selectedDayIndex, note)}
onPlaceClick={setActivePlaceId}
activePlaceId={activePlaceId}
/>
</div>
</section>
{/* Map panel */}
<section className="hidden lg:block flex-1 relative bg-gray-100">
<TripMap
itinerary={{ days: [selectedDay] }}
activePlaceId={activePlaceId} activePlaceId={activePlaceId}
onMarkerClick={(id) => {
setActivePlaceId(id);
document.getElementById(`place-${id}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}}
onAddPlace={(place) => handleAddPlace(selectedDayIndex, place)}
/> />
</div>
{/* Stats overlay */} {/* Map Panel - Desktop */}
<div className="absolute top-4 left-4 z-10 pointer-events-none"> <div className="hidden lg:block flex-1 relative bg-muted">
<div className="bg-white/90 backdrop-blur-md px-4 py-3 rounded-2xl shadow-xl border border-white/50"> <TripMap
<p className="text-[9px] font-black text-gray-400 uppercase tracking-widest mb-2">Özet</p> itinerary={trip.itinerary}
<div className="flex items-center gap-4"> activePlaceId={activePlaceId}
<div> onMarkerClick={(id) => setActivePlaceId(id)}
<p className="text-2xl font-black text-gray-900 leading-none">{selectedDay.items.length}</p> />
<p className="text-[8px] font-bold text-gray-400 uppercase mt-0.5">Durak</p> </div>
</div>
<div className="w-px h-8 bg-gray-200" />
<div>
<p className="text-2xl font-black text-gray-900 leading-none">{dayStats?.duration || '—'}</p>
<p className="text-[8px] font-bold text-gray-400 uppercase mt-0.5">Süre</p>
</div>
</div>
</div>
</div>
</section>
{/* Mobile map sheet */} {/* Map Sheet - Mobile */}
<div className="lg:hidden fixed bottom-6 right-6 z-50"> <div className="lg:hidden fixed bottom-4 right-4 z-50">
<Sheet open={isMapSheetOpen} onOpenChange={setIsMapSheetOpen}> <Sheet open={isMapSheetOpen} onOpenChange={setIsMapSheetOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button size="lg" className="h-12 px-6 rounded-full shadow-2xl bg-orange-600 hover:bg-orange-700 font-black text-[10px] uppercase tracking-wider gap-2"> <Button size="lg" className="rounded-full shadow-lg">
<MapPin className="h-4 w-4" /> <MapPin className="h-5 w-5 mr-2" />
Haritayı Haritayı Göster
</Button> </Button>
</SheetTrigger> </SheetTrigger>
<SheetContent side="bottom" className="h-[80vh] p-0 rounded-t-3xl overflow-hidden"> <SheetContent side="bottom" className="h-[70vh]">
<SheetHeader className="p-4 border-b"> <SheetHeader>
<SheetTitle className="text-base font-bold">Rota Gün {selectedDay.day}</SheetTitle> <SheetTitle>Rota Haritası</SheetTitle>
</SheetHeader> </SheetHeader>
<div className="h-full relative"> <div className="h-full mt-4">
<TripMap <TripMap
itinerary={{ days: [selectedDay] }} itinerary={trip.itinerary}
activePlaceId={activePlaceId} activePlaceId={activePlaceId}
onMarkerClick={(id) => { onMarkerClick={(id) => {
setActivePlaceId(id); setActivePlaceId(id);
setIsMapSheetOpen(false); setIsMapSheetOpen(false);
document.getElementById(`place-${id}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}}
onAddPlace={(place) => {
handleAddPlace(selectedDayIndex, place);
setIsMapSheetOpen(false);
}} }}
/> />
</div> </div>
@ -553,4 +212,4 @@ export default function TripDetailsPage() {
</main> </main>
</div> </div>
); );
} }

View File

@ -11,300 +11,119 @@ const corsHeaders = {
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
} }
/**
* Normalize place names for consistent cache lookups.
* Handles Turkish characters, accents, and spelling variations.
*/
function normalizePlaceName(name: string): string { function normalizePlaceName(name: string): string {
return name return name
.toLowerCase().trim() .toLowerCase()
.replace(/ğ/g, 'g').replace(/ü/g, 'u').replace(/ş/g, 's') .trim()
.replace(/ı/g, 'i').replace(/ö/g, 'o').replace(/ç/g, 'c') // Normalize Turkish characters to ASCII equivalents
.replace(/Ğ/g, 'g').replace(/Ü/g, 'u').replace(/Ş/g, 's') .replace(/ğ/g, 'g')
.replace(/İ/g, 'i').replace(/Ö/g, 'o').replace(/Ç/g, 'c') .replace(/ü/g, 'u')
.replace(/ş/g, 's')
.replace(/ı/g, 'i')
.replace(/ö/g, 'o')
.replace(/ç/g, 'c')
// Also handle uppercase Turkish characters
.replace(/Ğ/g, 'g')
.replace(/Ü/g, 'u')
.replace(/Ş/g, 's')
.replace(/İ/g, 'i')
.replace(/Ö/g, 'o')
.replace(/Ç/g, 'c')
// Remove extra spaces
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
// Normalize common suffix variations (preserve them but ensure consistent spacing)
.replace(/\s*(open air museum|underground city|valley|village|castle|church)\s*$/i, (match) => ' ' + match.trim().toLowerCase())
} }
// ─── Preference helpers ────────────────────────────────────────────────────────
function getTravelTypeGuidance(travelType: string): string {
switch (travelType) {
case 'solo':
return 'Solo traveler: prioritize flexible, independent activities. Include hiking, photography spots, local cafes. Avoid group-only tours.'
case 'couple':
return 'Romantic couple trip: include sunset viewpoints, cave restaurants, hot air balloon, wine tasting, scenic valleys. Prioritize atmosphere and intimacy.'
case 'family':
return 'Family with children: only child-friendly activities. Avoid long strenuous hikes. Include interactive museums, easy nature walks, open-air museums. Max 4 stops per day.'
case 'friends':
return 'Friend group: include adventure activities (ATV, horse riding), nightlife, group tours, lively restaurants and social experiences.'
default:
return ''
}
}
function getBudgetGuidance(budget: string): string {
switch (budget) {
case 'budget':
return 'Budget traveler (₺500-1000/day): prioritize free or low-cost attractions (valleys, viewpoints, open-air sites). Avoid expensive private tours or luxury restaurants.'
case 'moderate':
return 'Moderate budget (₺1000-2500/day): mix of paid and free attractions. Include some paid guided tours and mid-range restaurants.'
case 'comfort':
return 'Comfort budget (₺2500-5000/day): include premium experiences like private guided tours, cave hotel visits, sunset dinners. Prioritize quality over quantity.'
case 'luxury':
return 'Luxury budget (₺5000+/day): include exclusive experiences: private balloon flight, VIP cave restaurant dinners, private guided tours, spa, premium viewpoints with private transfers.'
default:
return ''
}
}
function getTransportGuidance(transport: string): string {
switch (transport) {
case 'rental':
return 'Has a rental car: can reach remote locations. Include distant valleys (Ihlara, Soganli), off-the-beaten-path spots. No need to cluster locations geographically.'
case 'transfer':
return 'Using private transfers: comfortable but planned routes. Group locations by area per day to minimize travel time. Can reach all sites.'
case 'shuttle':
return 'Using shuttle/minibus: stick to popular tourist routes. Cluster stops near Göreme, Ürgüp, Avanos. Avoid remote locations not on standard routes.'
case 'mixed':
return 'Mixed transport: balance between accessible and remote locations. Include a mix of central and off-the-beaten-path sites.'
default:
return ''
}
}
function getInterestGuidance(interests: string[]): string {
const map: Record<string, string> = {
balloon: 'MUST include a hot air balloon flight on Day 1 or 2 at sunrise (05:00-08:00)',
nature: 'Prioritize valleys, hiking routes (Rose Valley, Red Valley, Pigeon Valley, Ihlara Valley)',
history: 'Prioritize underground cities (Derinkuyu, Kaymakli), rock churches, Selime Monastery',
photography: 'Include best photo spots: Uchisar Castle, Rose Valley sunset, Pasabag fairy chimneys, panoramic viewpoints',
adventure: 'Include ATV tours, horse riding, hiking, zip-lining where available',
gastronomy: 'Include local pottery workshop in Avanos, traditional restaurants, wine tasting in Ürgüp, local market visits',
}
return interests.map(i => map[i] || '').filter(Boolean).join('. ')
}
function getDailyPlaceCount(budget: string, travelType: string): number {
if (travelType === 'family') return 3
if (budget === 'luxury') return 3 // fewer but premium
if (budget === 'budget') return 5 // more self-guided stops
return 4
}
// ─── Mock itinerary (interest + budget aware) ─────────────────────────────────
function generateMockItinerary(
startDate: string,
endDate: string,
interests: string[],
travelType: string,
budget: string,
transport: string
) {
const diffDays = Math.ceil(
(new Date(endDate).getTime() - new Date(startDate).getTime()) / 86400000
) + 1
const numDays = Math.min(diffDays, 14)
// Place pools by category
const pools: Record<string, { place_name: string; category: string; duration: number; desc: string }[]> = {
nature: [
{ place_name: 'Rose Valley Sunset Hike', category: 'nature', duration: 120, desc: 'Beautiful valley that turns pink at sunset.' },
{ place_name: 'Pigeon Valley', category: 'nature', duration: 90, desc: 'Valley named after the countless man-made dovecotes.' },
{ place_name: 'Love Valley', category: 'nature', duration: 60, desc: 'Famous for its fairy chimneys.' },
{ place_name: 'Red Valley', category: 'nature', duration: 90, desc: 'Stunning valley with red hues.' },
{ place_name: 'Ihlara Valley', category: 'nature', duration: 180, desc: 'A stunning canyon with a river and rock-cut churches.' },
{ place_name: 'Devrent Valley', category: 'nature', duration: 45, desc: 'Unique rock formations resembling animals.' },
],
history: [
{ place_name: 'Kaymakli Underground City', category: 'history', duration: 90, desc: 'Ancient multi-level underground city.' },
{ place_name: 'Derinkuyu Underground City', category: 'history', duration: 120, desc: 'The deepest underground city in the region.' },
{ place_name: 'Selime Monastery', category: 'history', duration: 60, desc: 'The largest religious structure in Cappadocia.' },
{ place_name: 'Çavuşin Village', category: 'culture', duration: 60, desc: 'An old Greek village with a massive rock castle.' },
],
museum: [
{ place_name: 'Göreme Open Air Museum', category: 'museum', duration: 120, desc: 'UNESCO World Heritage site with rock-cut churches.' },
{ place_name: 'Zelve Open Air Museum', category: 'museum', duration: 120, desc: 'An abandoned cave town with ancient churches.' },
],
landmark: [
{ place_name: 'Uçhisar Castle', category: 'landmark', duration: 90, desc: 'The highest point in Cappadocia with panoramic views.' },
{ place_name: 'Ortahisar Castle', category: 'landmark', duration: 60, desc: 'A massive rock formation used as a fortress.' },
{ place_name: 'Pasabag (Monks Valley)', category: 'nature', duration: 60, desc: 'Famous fairy chimneys with multiple caps.' },
],
gastronomy: [
{ place_name: 'Avanos Pottery Workshop', category: 'culture', duration: 60, desc: 'Traditional pottery making in the Red River town.' },
{ place_name: 'Ürgüp Wine Tasting', category: 'gastronomy', duration: 90, desc: 'Local Cappadocian wine tasting experience.' },
{ place_name: 'Göreme Local Market', category: 'gastronomy', duration: 60, desc: 'Fresh local produce and Turkish delights.' },
],
luxury: [
{ place_name: 'Private Guided Valley Tour', category: 'activity', duration: 180, desc: 'Exclusive private tour of the most scenic valleys.' },
{ place_name: 'Cave Restaurant Dinner', category: 'gastronomy', duration: 120, desc: 'Fine dining in a traditional Cappadocian cave.' },
{ place_name: 'Turkish Bath (Hamam)', category: 'wellness', duration: 120, desc: 'Traditional Ottoman bath experience.' },
],
}
// Build priority pool based on interests
const priorityPool: typeof pools.nature = []
if (interests.includes('nature')) priorityPool.push(...pools.nature)
if (interests.includes('history')) priorityPool.push(...pools.history)
if (interests.includes('photography')) priorityPool.push(...pools.landmark)
if (interests.includes('gastronomy')) priorityPool.push(...pools.gastronomy)
if (budget === 'luxury' || budget === 'comfort') priorityPool.push(...pools.luxury)
// Always add museums as fallback
priorityPool.push(...pools.museum, ...pools.landmark)
const usedPlaces = new Set<string>()
const maxPerDay = getDailyPlaceCount(budget, travelType)
const days = []
for (let i = 1; i <= numDays; i++) {
const dailyItems: { place_name: string; category: string; estimated_duration_minutes: number; description: string }[] = []
// Balloon on day 1 if interest selected
if (i === 1 && interests.includes('balloon')) {
dailyItems.push({
place_name: 'Hot Air Balloon Flight',
category: 'activity',
estimated_duration_minutes: budget === 'luxury' ? 90 : 60,
description: budget === 'luxury'
? 'Private luxury sunrise balloon flight over the fairy chimneys.'
: 'Breathtaking sunrise flight over the fairy chimneys.',
})
}
// Fill remaining slots
const available = priorityPool.filter(p => !usedPlaces.has(p.place_name))
const shuffled = [...available].sort(() => 0.5 - Math.random())
const slots = maxPerDay - dailyItems.length
for (let j = 0; j < Math.min(slots, shuffled.length); j++) {
usedPlaces.add(shuffled[j].place_name)
dailyItems.push({
place_name: shuffled[j].place_name,
category: shuffled[j].category,
estimated_duration_minutes: shuffled[j].duration,
description: shuffled[j].desc,
})
}
days.push({ day: i, items: dailyItems })
}
return { days }
}
// ─── Main handler ──────────────────────────────────────────────────────────────
serve(async (req) => { serve(async (req) => {
if (req.method === 'OPTIONS') { if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders }) return new Response('ok', { headers: corsHeaders })
} }
try { try {
const { const { startDate, endDate, interests, dailySchedule, preferences } = await req.json()
startDate,
endDate,
interests = [],
dailySchedule = 'moderate',
travelType = 'couple',
accommodation = 'center',
transport = 'mixed',
budget = 'moderate',
travelers = 2,
} = await req.json()
const supabase = createClient(SUPABASE_URL!, SUPABASE_SERVICE_ROLE_KEY!) // Initialize Supabase client with service role
const supabase = createClient(
SUPABASE_URL!,
SUPABASE_SERVICE_ROLE_KEY!
)
let itinerary // 1. Call OpenAI to generate itinerary
const systemPrompt = `You are an expert travel planner for Cappadocia, Turkey.
if (OPENAI_API_KEY && OPENAI_API_KEY !== 'PASTE_YOUR_OPENAI_API_KEY_HERE') { Generate a personalized travel itinerary based on user preferences.
try { Allowed regions: Göreme, Uçhisar, Ürgüp, Avanos, Ortahisar, Çavuşin, Derinkuyu, Kaymaklı.
const systemPrompt = `You are an expert travel planner for Cappadocia, Turkey. Rules:
Generate a highly personalized travel itinerary strictly based on the user's preferences below. - Balloon rides must be at sunrise (05:00 - 08:00) on Day 1 or 2.
- Sunset valley visits after 17:30.
=== USER PROFILE === - Max 5 places per day.
Travel Type: ${travelType} ${getTravelTypeGuidance(travelType)} - No duplicate places.
Budget: ${budget} ${getBudgetGuidance(budget)} - Only return JSON in the specified format.
Transport: ${transport} ${getTransportGuidance(transport)} - Interests: ${interests.join(', ')}.
Group Size: ${travelers} people - Daily Schedule: ${dailySchedule}.
Accommodation: ${accommodation}
Daily Schedule pace: ${dailySchedule} Response Format:
=== INTERESTS (strictly prioritize these) ===
${getInterestGuidance(interests)}
=== RULES ===
- Max ${getDailyPlaceCount(budget, travelType)} places per day
- No duplicate places across days
- Balloon rides ONLY at sunrise (05:0008:00) on Day 1 or 2, ONLY if balloon is in interests
- Sunset visits (Rose Valley, Red Valley) after 17:30
- Allowed regions: Göreme, Uçhisar, Ürgüp, Avanos, Ortahisar, Çavuşin, Derinkuyu, Kaymaklı, Ihlara
- For luxury budget: include premium/private experiences
- For budget travelers: focus on free/low-cost sites (valleys, viewpoints)
- For families: ONLY child-safe, easy-access locations
- Return ONLY valid JSON, no markdown, no explanation
=== RESPONSE FORMAT ===
{
"days": [
{ {
"day": 1, "days": [
"items": [
{ {
"place_name": "Göreme Open Air Museum", "day": 1,
"category": "museum", "items": [
"estimated_duration_minutes": 120, {
"description": "UNESCO World Heritage site with rock-cut churches." "place_name": "Göreme Open Air Museum",
"category": "museum",
"estimated_duration_minutes": 120,
"description": "UNESCO World Heritage site with rock-cut churches."
}
]
} }
] ]
} }`
]
}`
const userPrompt = `Trip: ${startDate} to ${endDate}. Travelers: ${travelers}. Interests: ${interests.join(', ')}. Budget: ${budget}. Transport: ${transport}. Travel type: ${travelType}.` const userPrompt = `Trip Dates: ${startDate} to ${endDate}. Preferences: ${preferences}`
const openaiRes = await fetch('https://api.openai.com/v1/chat/completions', { const openaiRes = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${OPENAI_API_KEY}`, 'Authorization': `Bearer ${OPENAI_API_KEY}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
model: 'gpt-4o-mini', model: 'gpt-4o-mini',
messages: [ messages: [
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }, { role: 'user', content: userPrompt }
], ],
temperature: 0.4, temperature: 0.3,
response_format: { type: 'json_object' }, response_format: { type: 'json_object' }
}), }),
}) })
if (!openaiRes.ok) throw new Error(`OpenAI error: ${openaiRes.statusText}`) const openaiData = await openaiRes.json()
const itinerary = JSON.parse(openaiData.choices[0].message.content)
const openaiData = await openaiRes.json() // 2. Verify places with Google Places API (with cache layer)
itinerary = JSON.parse(openaiData.choices[0].message.content)
} catch (err) {
console.error('OpenAI failed, falling back to mock:', err)
itinerary = generateMockItinerary(startDate, endDate, interests, travelType, budget, transport)
}
} else {
itinerary = generateMockItinerary(startDate, endDate, interests, travelType, budget, transport)
}
// ── Google Places verification with cache ──────────────────────────────────
const verifiedDays = [] const verifiedDays = []
for (const day of itinerary.days) { for (const day of itinerary.days) {
const verifiedItems = [] const verifiedItems = []
for (const item of day.items) { for (const item of day.items) {
// Normalize place name for cache lookup using robust normalization
const normalizedName = normalizePlaceName(item.place_name) const normalizedName = normalizePlaceName(item.place_name)
const { data: cachedPlace } = await supabase // Check cache first
const { data: cachedPlace, error: cacheError } = await supabase
.from('places_cache') .from('places_cache')
.select('*') .select('*')
.eq('place_name_normalized', normalizedName) .eq('place_name_normalized', normalizedName)
.limit(1) .limit(1)
.maybeSingle() .maybeSingle()
if (cachedPlace) { if (cachedPlace && !cacheError) {
// Cache HIT - skipping Google API call
console.log(`Cache HIT for "${item.place_name}" (normalized: "${normalizedName}") - skipping Google API call`)
verifiedItems.push({ verifiedItems.push({
...item, ...item,
place_id: cachedPlace.place_id, place_id: cachedPlace.place_id,
@ -313,65 +132,71 @@ ${getInterestGuidance(interests)}
lat: cachedPlace.lat, lat: cachedPlace.lat,
lng: cachedPlace.lng, lng: cachedPlace.lng,
rating: cachedPlace.rating, rating: cachedPlace.rating,
photo_reference: cachedPlace.photo_reference, user_ratings_total: cachedPlace.user_ratings_total,
photo_reference: cachedPlace.photo_reference
}) })
} else { } else {
let placeInfo = null // Cache MISS - fetching from Google and caching
console.log(`Cache MISS for "${item.place_name}" (normalized: "${normalizedName}") - fetching from Google and caching`)
// Text Search to get place_id and basic info
const searchUrl = `https://maps.googleapis.com/maps/api/place/textsearch/json?query=${encodeURIComponent(item.place_name + ' Cappadocia')}&key=${GOOGLE_MAPS_API_KEY}`
const searchRes = await fetch(searchUrl)
const searchData = await searchRes.json()
if (GOOGLE_MAPS_API_KEY && GOOGLE_MAPS_API_KEY !== 'PASTE_YOUR_GOOGLE_MAPS_API_KEY_HERE') { if (searchData.results && searchData.results.length > 0) {
try { const place = searchData.results[0]
const searchUrl = `https://maps.googleapis.com/maps/api/place/textsearch/json?query=${encodeURIComponent(item.place_name + ' Cappadocia')}&key=${GOOGLE_MAPS_API_KEY}`
const searchRes = await fetch(searchUrl) // Store in cache for future use with normalized name
const searchData = await searchRes.json() const cacheEntry = {
if (searchData.results?.length > 0) {
const place = searchData.results[0]
placeInfo = {
place_id: place.place_id,
name: place.name,
formatted_address: place.formatted_address,
lat: place.geometry.location.lat,
lng: place.geometry.location.lng,
rating: place.rating || null,
photo_reference: place.photos?.[0]?.photo_reference || null,
}
}
} catch (err) {
console.error(`Google Places error for ${item.place_name}:`, err)
}
}
if (placeInfo) {
await supabase.from('places_cache').insert({
place_name_normalized: normalizedName, place_name_normalized: normalizedName,
...placeInfo, place_id: place.place_id,
name: place.name,
formatted_address: place.formatted_address,
lat: place.geometry.location.lat,
lng: place.geometry.location.lng,
rating: place.rating || null,
user_ratings_total: place.user_ratings_total || null,
photo_reference: place.photos?.[0]?.photo_reference || null
}
await supabase
.from('places_cache')
.insert(cacheEntry)
verifiedItems.push({
...item,
place_id: place.place_id,
name: place.name,
formatted_address: place.formatted_address,
lat: place.geometry.location.lat,
lng: place.geometry.location.lng,
rating: place.rating,
user_ratings_total: place.user_ratings_total,
photo_reference: place.photos?.[0]?.photo_reference
}) })
verifiedItems.push({ ...item, ...placeInfo })
} else {
verifiedItems.push({ ...item, unverified: true })
} }
} }
} }
verifiedDays.push({ ...day, items: verifiedItems }) verifiedDays.push({ ...day, items: verifiedItems })
} }
// ── Time slot assignment ─────────────────────────────────────────────────── // 3. Add time slots (Simple heuristic)
const finalDays = verifiedDays.map(day => { const finalDays = verifiedDays.map(day => {
let currentTime = new Date('2026-01-01T09:00:00') let currentTime = new Date(`2026-01-01T09:00:00`)
const itemsWithTime = day.items.map(item => { const itemsWithTime = day.items.map(item => {
// Handle special cases: Balloon
if (item.place_name.toLowerCase().includes('balloon')) { if (item.place_name.toLowerCase().includes('balloon')) {
return { ...item, start_time: '05:00', end_time: '08:00' } const start = "05:00"
const end = "08:00"
return { ...item, start_time: start, end_time: end }
} }
// Push sunset spots to evening
const isSunsetSpot = /rose valley|red valley|sunset/i.test(item.place_name)
if (isSunsetSpot) {
return { ...item, start_time: '17:30', end_time: '19:30' }
}
const startTime = currentTime.toTimeString().slice(0, 5) const startTime = currentTime.toTimeString().slice(0, 5)
currentTime.setMinutes(currentTime.getMinutes() + (item.estimated_duration_minutes || 60)) currentTime.setMinutes(currentTime.getMinutes() + item.estimated_duration_minutes)
const endTime = currentTime.toTimeString().slice(0, 5) const endTime = currentTime.toTimeString().slice(0, 5)
currentTime.setMinutes(currentTime.getMinutes() + 30) // travel buffer currentTime.setMinutes(currentTime.getMinutes() + 30) // 30 min travel/buffer
return { ...item, start_time: startTime, end_time: endTime } return { ...item, start_time: startTime, end_time: endTime }
}) })
return { ...day, items: itemsWithTime } return { ...day, items: itemsWithTime }
@ -381,10 +206,9 @@ ${getInterestGuidance(interests)}
headers: { ...corsHeaders, 'Content-Type': 'application/json' }, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}) })
} catch (error) { } catch (error) {
console.error('Error:', error)
return new Response(JSON.stringify({ error: error.message }), { return new Response(JSON.stringify({ error: error.message }), {
status: 500, status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}) })
} }
}) })

View File

@ -44,62 +44,35 @@ serve(async (req) => {
} }
} }
// Cache miss or expired - call Google Directions API (if key exists) // Cache miss or expired - call Google Directions API
if (GOOGLE_MAPS_API_KEY && GOOGLE_MAPS_API_KEY !== 'PASTE_YOUR_GOOGLE_MAPS_API_KEY_HERE') { console.log('Cache miss for:', cache_key)
try { let url = `https://maps.googleapis.com/maps/api/directions/json?origin=${origin}&destination=${destination}&key=${GOOGLE_MAPS_API_KEY}&mode=driving`
console.log('Cache miss for:', cache_key)
let url = `https://maps.googleapis.com/maps/api/directions/json?origin=${origin}&destination=${destination}&key=${GOOGLE_MAPS_API_KEY}&mode=driving` if (waypoints && waypoints.length > 0) {
url += `&waypoints=${waypoints.join('|')}`
if (waypoints && waypoints.length > 0) {
url += `&waypoints=${waypoints.join('|')}`
}
const res = await fetch(url)
const data = await res.json()
if (data.status === 'OK') {
// Upsert the result into cache
await supabase
.from('directions_cache')
.upsert({
cache_key,
response: data,
created_at: new Date().toISOString()
}, {
onConflict: 'cache_key'
})
}
return new Response(JSON.stringify(data), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
} catch (err) {
console.error('Google Directions API error, falling back to dummy:', err)
}
} }
// No key or error - return dummy response const res = await fetch(url)
const dummyResponse = { const data = await res.json()
status: "OK",
routes: [{
summary: "Dummy route for demonstration",
legs: [{
distance: { text: "10 km", value: 10000 },
duration: { text: "15 mins", value: 900 },
steps: []
}],
overview_polyline: { points: "" }
}]
}
return new Response(JSON.stringify(dummyResponse), { // Upsert the result into cache
await supabase
.from('directions_cache')
.upsert({
cache_key,
response: data,
created_at: new Date().toISOString()
}, {
onConflict: 'cache_key'
})
return new Response(JSON.stringify(data), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' }, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}) })
} catch (error) { } catch (error) {
return new Response(JSON.stringify({ error: error.message }), { return new Response(JSON.stringify({ error: error.message }), {
status: 500, status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}) })
} }
}) })

View File

@ -10,12 +10,6 @@ const corsHeaders = {
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
} }
const FALLBACK_PHOTO_URLS = [
'https://images.unsplash.com/photo-1544833316-64d88e00182a?q=80&w=800&auto=format&fit=crop', // Cappadocia Valley
'https://images.unsplash.com/photo-1570168007204-dfb528c6958f?q=80&w=800&auto=format&fit=crop', // Hot Air Balloons
'https://images.unsplash.com/photo-1524231757912-21f4fe3a7200?q=80&w=800&auto=format&fit=crop', // Stone Houses
]
serve(async (req) => { serve(async (req) => {
if (req.method === 'OPTIONS') { if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders }) return new Response('ok', { headers: corsHeaders })
@ -26,9 +20,7 @@ serve(async (req) => {
const photoReference = urlObj.searchParams.get('photo_reference') const photoReference = urlObj.searchParams.get('photo_reference')
if (!photoReference) { if (!photoReference) {
// If missing photo_reference, redirect to a random fallback image return new Response('Missing photo_reference', { status: 400 })
const fallbackUrl = FALLBACK_PHOTO_URLS[Math.floor(Math.random() * FALLBACK_PHOTO_URLS.length)]
return Response.redirect(fallbackUrl, 302)
} }
// Initialize Supabase client with service role // Initialize Supabase client with service role
@ -45,57 +37,51 @@ serve(async (req) => {
// Try to HEAD the file to check if it exists // Try to HEAD the file to check if it exists
try { try {
const headResponse = await fetch(publicUrlData.publicUrl, { method: 'HEAD' }) const headResponse = await fetch(publicUrlData.publicUrl, { method: 'HEAD' })
if (headResponse.ok) { if (headResponse.ok) {
// File exists in storage, redirect to it // File exists in storage, redirect to it
return Response.redirect(publicUrlData.publicUrl, 302) return Response.redirect(publicUrlData.publicUrl, 302)
} }
} catch (headError) { } catch (headError) {
// File doesn't exist, continue to fetch from Google // File doesn't exist, continue to fetch from Google
console.log('File not in cache, fetching from Google...') console.log('File not in cache, fetching from Google:', headError)
} }
// File doesn't exist in storage, fetch from Google (if key exists) // File doesn't exist in storage, fetch from Google
if (GOOGLE_MAPS_API_KEY && GOOGLE_MAPS_API_KEY !== 'PASTE_YOUR_GOOGLE_MAPS_API_KEY_HERE') { const googlePhotoUrl = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=800&photo_reference=${photoReference}&key=${GOOGLE_MAPS_API_KEY}`
try {
const googlePhotoUrl = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=800&photo_reference=${photoReference}&key=${GOOGLE_MAPS_API_KEY}` const googleResponse = await fetch(googlePhotoUrl)
const googleResponse = await fetch(googlePhotoUrl) if (!googleResponse.ok) {
throw new Error(`Failed to fetch photo from Google: ${googleResponse.statusText}`)
if (googleResponse.ok) {
// Get the image blob
const imageBlob = await googleResponse.blob()
// Upload to Supabase Storage
const { error: uploadError } = await supabase.storage
.from('place-photos')
.upload(storagePath, imageBlob, {
contentType: 'image/jpeg',
cacheControl: '31536000', // Cache for 1 year
upsert: false
})
if (!uploadError) {
// Successfully uploaded, redirect to storage URL
return Response.redirect(publicUrlData.publicUrl, 302)
} else {
console.error('Failed to upload to storage:', uploadError)
// If upload fails, still redirect to Google URL as fallback
return Response.redirect(googlePhotoUrl, 302)
}
}
} catch (err) {
console.error('Failed to fetch photo from Google, using fallback:', err)
}
} }
// If key is missing or fetch fails, redirect to a random fallback image // Get the image blob
const fallbackUrl = FALLBACK_PHOTO_URLS[Math.floor(Math.random() * FALLBACK_PHOTO_URLS.length)] const imageBlob = await googleResponse.blob()
return Response.redirect(fallbackUrl, 302)
// Upload to Supabase Storage
const { error: uploadError } = await supabase.storage
.from('place-photos')
.upload(storagePath, imageBlob, {
contentType: 'image/jpeg',
cacheControl: '31536000', // Cache for 1 year
upsert: false
})
if (uploadError) {
console.error('Failed to upload to storage:', uploadError)
// If upload fails, still redirect to Google URL as fallback
return Response.redirect(googlePhotoUrl, 302)
}
// Successfully uploaded, redirect to storage URL
return Response.redirect(publicUrlData.publicUrl, 302)
} catch (error) { } catch (error) {
console.error('Error in get-place-photo:', error) console.error('Error in get-place-photo:', error)
// Always fallback to something visual return new Response(JSON.stringify({ error: error.message }), {
const fallbackUrl = FALLBACK_PHOTO_URLS[Math.floor(Math.random() * FALLBACK_PHOTO_URLS.length)] status: 500,
return Response.redirect(fallbackUrl, 302) headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
} }
}) })

View File

@ -15,9 +15,4 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'), '@': path.resolve(__dirname, './src'),
}, },
}, },
server: { });
host: '0.0.0.0',
port: 3001,
allowedHosts: ['.dev.flatlogic.app', '.dev.appwizzy.dev', 'localhost', '127.0.0.1']
}
});

View File

@ -1,9 +0,0 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html><head>
<title>403 Forbidden</title>
</head><body>
<h1>Forbidden</h1>
<p>You don't have permission to access this resource.</p>
<hr>
<address>Apache/2.4.66 (Debian) Server at app-9xzmfic2e4g1appversion-9y0h01so4s8w-273b.dev.appwizzy.dev Port 80</address>
</body></html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

View File

@ -1,17 +0,0 @@
<?php
// Generated by setup_mariadb_project.sh — edit as needed.
define('DB_HOST', '127.0.0.1');
define('DB_NAME', 'app_38913');
define('DB_USER', 'app_38913');
define('DB_PASS', '9caf05b4-928b-4060-979c-c3d0cac2e16d');
function db() {
static $pdo;
if (!$pdo) {
$pdo = new PDO('mysql:host='.DB_HOST.';dbname='.DB_NAME.';charset=utf8mb4', DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
}
return $pdo;
}