Compare commits

...

19 Commits
main ... ai-dev

Author SHA1 Message Date
Flatlogic Bot
b7bc6b853a Revert to version 773d823 2026-03-04 13:48:42 +00:00
Flatlogic Bot
0495927787 Autosave: 20260304-133057 2026-03-04 13:30:57 +00:00
Flatlogic Bot
a23dcbb154 Autosave: 20260304-124313 2026-03-04 12:43:13 +00:00
Flatlogic Bot
886283c152 Edit app-9xzmfic2e4g1/src/db/api.ts via Editor 2026-03-04 11:35:32 +00:00
Flatlogic Bot
773d823e75 Edit app-9xzmfic2e4g1/src/components/trip/Map.tsx via Editor 2026-03-04 11:34:16 +00:00
Flatlogic Bot
abc2e658a0 Edit app-9xzmfic2e4g1/src/pages/TripDetailsPage.tsx via Editor 2026-03-04 11:18:07 +00:00
Flatlogic Bot
78b491dc8d Edit app-9xzmfic2e4g1/src/components/trip/Map.tsx via Editor 2026-03-04 11:17:22 +00:00
Flatlogic Bot
fc0cb36f89 Edit app-9xzmfic2e4g1/src/pages/PlannerPage.tsx via Editor 2026-03-04 10:01:33 +00:00
Flatlogic Bot
57fa0f4ed7 Edit app-9xzmfic2e4g1/src/db/api.ts via Editor 2026-03-04 10:01:02 +00:00
Flatlogic Bot
5605be97a5 Edit app-9xzmfic2e4g1/supabase/functions/generate-itinerary/index.ts via Editor 2026-03-04 09:59:52 +00:00
Flatlogic Bot
0f83799cff Edit app-9xzmfic2e4g1/src/components/planner/BudgetSelector.tsx via Editor 2026-03-04 09:31:22 +00:00
Flatlogic Bot
da91dd826a Edit app-9xzmfic2e4g1/src/components/planner/TransportSelector.tsx via Editor 2026-03-04 09:30:52 +00:00
Flatlogic Bot
8e09b7ff5c Edit app-9xzmfic2e4g1/src/components/planner/TravelTypeSelector.tsx via Editor 2026-03-04 09:30:07 +00:00
Flatlogic Bot
b3b604af88 Edit app-9xzmfic2e4g1/src/pages/PlannerPage.tsx via Editor 2026-03-04 09:28:46 +00:00
Flatlogic Bot
6e3c36c25f Edit app-9xzmfic2e4g1/src/constants/planner.ts via Editor 2026-03-04 09:27:54 +00:00
Flatlogic Bot
3cb97d4eee Edit app-9xzmfic2e4g1/src/components/trip/Timeline.tsx via Editor 2026-03-03 12:28:18 +00:00
Flatlogic Bot
044b506f7a Edit app-9xzmfic2e4g1/src/components/trip/Timeline.tsx via Editor 2026-03-02 19:55:11 +00:00
Flatlogic Bot
1973786e9d Edit app-9xzmfic2e4g1/src/pages/TripDetailsPage.tsx via Editor 2026-03-02 19:53:49 +00:00
Flatlogic Bot
2c504c5ff8 Autosave: 20260302-135117 2026-03-02 13:51:18 +00:00
29 changed files with 3999 additions and 1622 deletions

0
.perm_test_apache Normal file
View File

0
.perm_test_exec Normal file
View File

View File

@ -2,6 +2,7 @@ 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;
@ -10,34 +11,48 @@ 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-3"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{ACCOMMODATION_OPTIONS.map((option) => { {ACCOMMODATION_OPTIONS.map((option, index) => {
const Icon = option.icon; const Icon = option.icon;
const isSelected = selectedId === option.id; const isSelected = selectedId === option.id;
return ( return (
<Button <motion.div
key={option.id} key={option.id}
type="button" initial={{ opacity: 0, scale: 0.95 }}
variant="outline" animate={{ opacity: 1, scale: 1 }}
className={cn( transition={{ delay: index * 0.1 }}
"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)}
> >
<Icon className={cn( <Button
"h-5 w-5", type="button"
isSelected ? "text-orange-600" : "text-gray-400" variant="outline"
)} /> className={cn(
<span className="text-sm font-medium">{option.label}</span> "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",
</Button> isSelected
? "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

@ -0,0 +1,106 @@
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,6 +2,7 @@ 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[];
@ -10,34 +11,48 @@ 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-3"> <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{INTEREST_OPTIONS.map((interest) => { {INTEREST_OPTIONS.map((interest, index) => {
const Icon = interest.icon; const Icon = interest.icon;
const isSelected = selectedInterests.includes(interest.id); const isSelected = selectedInterests.includes(interest.id);
return ( return (
<Button <motion.div
key={interest.id} key={interest.id}
type="button" initial={{ opacity: 0, y: 10 }}
variant="outline" animate={{ opacity: 1, y: 0 }}
className={cn( transition={{ delay: index * 0.05 }}
"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)}
> >
<Icon className={cn( <Button
"h-6 w-6", type="button"
isSelected ? "text-orange-600" : "text-gray-500" variant="outline"
)} /> className={cn(
<span className="text-xs font-semibold">{interest.label}</span> "h-auto py-6 w-full flex flex-col items-center gap-3 rounded-2xl border-2 transition-luxury group relative overflow-hidden",
</Button> isSelected
? "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

@ -0,0 +1,87 @@
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

@ -0,0 +1,89 @@
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,6 +1,7 @@
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;
@ -9,31 +10,43 @@ interface TravelerInputProps {
export const TravelerInput = memo(({ value, onChange }: TravelerInputProps) => { export const TravelerInput = memo(({ value, onChange }: TravelerInputProps) => {
return ( return (
<div className="flex items-center justify-between p-4 border border-gray-200 rounded-lg bg-gray-50/50"> <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 gap-3"> <div className="flex items-center gap-4">
<Users className="h-5 w-5 text-gray-400" /> <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-6 w-6" />
</div>
<div> <div>
<p className="text-sm font-semibold text-gray-700">Kişi Sayısı</p> <p className="text-lg font-black text-gray-900 dark:text-white tracking-tighter">Kişi Sayısı</p>
<p className="text-xs text-gray-500">{value} Yetişkin</p> <p className="text-sm text-gray-400 font-medium italic">{value} Yetişkin Gezgin</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="outline" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 rounded-full border-gray-300" 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"
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="outline" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 rounded-full border-gray-300" 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"
onClick={() => onChange(Math.min(15, value + 1))} onClick={() => onChange(Math.min(15, value + 1))}
disabled={value >= 15} disabled={value >= 15}
> >
@ -44,4 +57,4 @@ export const TravelerInput = memo(({ value, onChange }: TravelerInputProps) => {
); );
}); });
TravelerInput.displayName = 'TravelerInput'; TravelerInput.displayName = 'TravelerInput';

View File

@ -1,95 +1,212 @@
import { useEffect, useRef, useState, useMemo } from 'react'; import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
import { ItineraryDay } from '@/db/api'; import { ItineraryDay, Place } 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 } from 'lucide-react'; import { ZoomIn, ZoomOut, Maximize2, Plus, Star, Clock, ChevronRight, Lightbulb, CheckCircle2, Loader2, X, MessageSquare } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import api from '@/db/api';
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 = [
'#3B82F6', // Blue - Day 1 '#EA580C',
'#10B981', // Green - Day 2 '#2563EB',
'#8B5CF6', // Purple - Day 3 '#059669',
'#F59E0B', // Amber - Day 4 '#7C3AED',
'#EF4444', // Red - Day 5 '#DB2777',
]; ];
export function TripMap({ itinerary, activePlaceId, onMarkerClick }: MapProps) { interface PlaceDetail {
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 infoWindowRef = useRef<google.maps.InfoWindow | null>(null); const placesServiceRef = useRef<google.maps.places.PlacesService | null>(null);
// Memoize itinerary to prevent unnecessary re-renders // Detail panel state
const itineraryKey = useMemo(() => const [selectedPOI, setSelectedPOI] = useState<SelectedPOI | null>(null);
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. Lütfen .env dosyasına geçerli bir VITE_GOOGLE_MAPS_API_KEY ekleyin.'); setError('Google Maps API anahtarı eksik.');
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 }, // Central Cappadocia center: { lat: 38.6431, lng: 34.8347 },
zoom: 12, zoom: 12,
styles: [ styles: [
{ { featureType: 'poi.business', stylers: [{ visibility: 'off' }] },
"featureType": "poi", { featureType: 'poi.park', elementType: 'labels.text', stylers: [{ visibility: 'on' }] },
"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 (error) { } catch {
const errorMsg = 'Google Maps yüklenemedi. API anahtarınızı kontrol edin.'; setError('Google Maps yüklenemedi.');
setError(errorMsg);
console.error('Google Maps yüklenemedi:', error);
} }
}; };
if (!googleMap) { if (!googleMap) loadMap();
loadMap(); }, [googleMap, onAddPlace, fetchPlaceDetail]);
}
}, [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(polyline => polyline.setMap(null)); polylinesRef.current.forEach(p => p.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[] = [];
@ -97,71 +214,55 @@ export function TripMap({ itinerary, activePlaceId, onMarkerClick }: MapProps) {
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,
label: { zIndex: isActive ? 1000 : 1,
text: (itemIndex + 1).toString(), label: { text: (itemIndex + 1).toString(), color: 'white', fontSize: '11px', fontWeight: '900' },
color: 'white',
fontSize: '12px',
fontWeight: 'bold',
},
icon: { icon: {
path: google.maps.SymbolPath.CIRCLE, 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',
fillColor: dayColor, fillColor: dayColor,
fillOpacity: 1, fillOpacity: 1,
strokeWeight: 3, strokeWeight: 2,
strokeColor: '#ffffff', strokeColor: '#ffffff',
scale: 14, scale: isActive ? 1.8 : 1.4,
anchor: new google.maps.Point(12, 22),
labelOrigin: new google.maps.Point(12, 9),
}, },
animation: item.place_id === activePlaceId ? google.maps.Animation.BOUNCE : undefined, animation: isActive ? google.maps.Animation.BOUNCE : undefined,
}); });
marker.addListener('click', () => { marker.addListener('click', () => {
onMarkerClick(item.place_id); onMarkerClick(item.place_id);
if (infoWindowRef.current) { const photoUrl = item.photo_reference
const photoUrl = item.photo_reference ? (item.photo_reference.startsWith('http') ? item.photo_reference : api.getPhotoUrl(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;">
infoWindowRef.current.setContent(` ${photoUrl ? `<img src="${photoUrl}" style="width:100%;height:90px;object-fit:cover;border-radius:8px;margin-bottom:8px;" />` : ''}
<div style="color: black; max-width: 250px;"> <h4 style="margin:0 0 2px;font-size:13px;font-weight:bold;">${item.name}</h4>
${photoUrl ? `<img src="${photoUrl}" alt="${item.name}" style="width: 100%; height: 120px; object-fit: cover; border-radius: 8px; margin-bottom: 8px;" />` : ''} <p style="margin:0 0 6px;font-size:10px;color:#6B7280;">${item.category}</p>
<h4 style="font-weight: bold; margin-bottom: 4px; font-size: 14px;">${item.name}</h4> <div style="display:flex;align-items:center;justify-content:space-between;">
<p style="font-size: 12px; color: #6B7280; margin-bottom: 6px;">${item.formatted_address}</p> <span style="font-size:11px;font-weight:bold;color:#F59E0B;"> ${item.rating || 'N/A'}</span>
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;"> <a href="https://www.google.com/maps/dir/?api=1&destination=${item.lat},${item.lng}" target="_blank"
<span style="color: ${dayColor}; font-weight: bold;"> ${item.rating || 'N/A'}</span> style="color:#EA580C;font-size:11px;font-weight:bold;text-decoration:none;">Yol Tarifi </a>
<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, path: dayPath, geodesic: true,
geodesic: true, strokeColor: dayColor, strokeOpacity: 0.8, strokeWeight: 3,
strokeColor: dayColor,
strokeOpacity: 0.7,
strokeWeight: 4,
}); });
polyline.setMap(googleMap); polyline.setMap(googleMap);
polylinesRef.current.push(polyline); polylinesRef.current.push(polyline);
@ -169,129 +270,332 @@ export function TripMap({ itinerary, activePlaceId, onMarkerClick }: MapProps) {
}); });
if (Object.keys(markersRef.current).length > 0) { if (Object.keys(markersRef.current).length > 0) {
googleMap.fitBounds(bounds); googleMap.fitBounds(bounds, { top: 60, bottom: 60, left: 60, right: 60 });
} }
}, [googleMap, itineraryKey]);
}, [googleMap, itineraryKey]); // Use itineraryKey instead of itinerary // ── Active marker highlight ───────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (googleMap && activePlaceId && markersRef.current[activePlaceId]) { if (!googleMap || !activePlaceId || !markersRef.current[activePlaceId]) return;
const { marker, dayIndex } = markersRef.current[activePlaceId]; const { marker } = markersRef.current[activePlaceId];
const dayColor = DAY_COLORS[dayIndex % DAY_COLORS.length]; googleMap.panTo(marker.getPosition()!);
Object.keys(markersRef.current).forEach(id => {
googleMap.panTo(marker.getPosition()!); const { marker: m, dayIndex: dIdx } = markersRef.current[id];
googleMap.setZoom(14); const color = DAY_COLORS[dIdx % DAY_COLORS.length];
const isActive = id === activePlaceId;
// Update all markers - highlight active one m.setIcon({
Object.keys(markersRef.current).forEach(id => { 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',
const { marker: m, dayIndex: dIdx } = markersRef.current[id]; fillColor: color, fillOpacity: 1,
const color = DAY_COLORS[dIdx % DAY_COLORS.length]; strokeWeight: isActive ? 3 : 2,
const isActive = id === activePlaceId; strokeColor: isActive ? '#000000' : '#ffffff',
scale: isActive ? 1.8 : 1.4,
m.setIcon({ anchor: new google.maps.Point(12, 22),
path: google.maps.SymbolPath.CIRCLE, labelOrigin: new google.maps.Point(12, 9),
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]);
const handleZoomIn = () => { // ── Render ────────────────────────────────────────────────────────────────
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"> <div className="relative w-full h-full bg-gray-50 overflow-hidden">
{error ? ( {error ? (
<div className="absolute inset-0 flex items-center justify-center bg-muted"> <div className="absolute inset-0 flex items-center justify-center">
<div className="text-center p-8 max-w-md"> <p className="text-sm font-medium text-gray-500">{error}</p>
<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" />
{/* Map Controls */} {/* İpucu */}
<div className="absolute top-4 right-4 flex flex-col gap-2"> {onAddPlace && !selectedPOI && (
<Button <div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 pointer-events-none">
variant="secondary" <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">
size="icon" <Plus className="h-3.5 w-3.5 text-orange-500 shrink-0" />
className="bg-white shadow-lg hover:bg-gray-50" <span className="text-[11px] font-bold text-gray-600">Haritada bir yere tıklayarak detayları görün</span>
onClick={handleZoomIn} </div>
> </div>
<ZoomIn className="h-4 w-4" /> )}
</Button>
<Button {/* Zoom */}
variant="secondary" <div className="absolute top-4 right-4 flex flex-col gap-2 z-10">
size="icon" <div className="flex flex-col bg-white border rounded-lg shadow-sm overflow-hidden">
className="bg-white shadow-lg hover:bg-gray-50" <Button variant="ghost" size="icon" className="h-8 w-8 rounded-none border-b"
onClick={handleZoomOut} onClick={() => googleMap?.setZoom((googleMap.getZoom() || 12) + 1)}>
> <ZoomIn className="h-4 w-4" />
<ZoomOut className="h-4 w-4" /> </Button>
</Button> <Button variant="ghost" size="icon" className="h-8 w-8 rounded-none"
<Button onClick={() => googleMap?.setZoom((googleMap.getZoom() || 12) - 1)}>
variant="secondary" <ZoomOut className="h-4 w-4" />
size="icon" </Button>
className="bg-white shadow-lg hover:bg-gray-50" </div>
onClick={handleFitBounds} <Button variant="outline" size="icon" className="h-8 w-8 bg-white shadow-sm"
> 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>
{/* Day Legend */} {/* ── Detay Paneli ───────────────────────────────────────────────── */}
<div className="absolute top-4 left-4 bg-white/95 backdrop-blur-sm rounded-lg shadow-lg p-3"> <AnimatePresence>
<div className="space-y-2"> {selectedPOI && (
{itinerary.days.map((day, index) => ( <motion.div
<div key={day.day} className="flex items-center gap-2"> initial={{ x: '100%', opacity: 0 }}
<div animate={{ x: 0, opacity: 1 }}
className="w-3 h-3 rounded-full" exit={{ x: '100%', opacity: 0 }}
style={{ backgroundColor: DAY_COLORS[index % DAY_COLORS.length] }} transition={{ type: 'spring', damping: 28, stiffness: 300 }}
/> 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"> >
Gün {day.day} {/* Fotoğraf header */}
</span> <div className="relative h-44 shrink-0 bg-gray-100">
<Badge variant="secondary" className="text-xs font-normal"> {selectedPOI.photoUrl ? (
{day.items.length} <img
</Badge> src={selectedPOI.photoUrl}
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>
))}
</div> {/* Rating + tabs */}
</div> <div className="px-4 pt-3 pb-0 border-b shrink-0">
<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

@ -0,0 +1,168 @@
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,251 +1,444 @@
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 { Star, Clock, MapPin, GripVertical, Car } from 'lucide-react';
import api from '@/db/api';
import { useMemo } from 'react';
import { import {
DndContext, Star, Clock, MapPin, GripVertical, Car, Trash2, Edit3,
closestCenter, MessageSquare, MoreVertical, Sun, Sunset, Moon, Coffee,
KeyboardSensor, Package, Wand2
PointerSensor, } from 'lucide-react';
useSensor, import api from '@/db/api';
useSensors, import { useState, useMemo } from 'react';
DragEndEvent, import {
DndContext, closestCenter, KeyboardSensor, PointerSensor,
useSensor, useSensors, DragEndEvent,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { import {
arrayMove, arrayMove, SortableContext, sortableKeyboardCoordinates,
SortableContext, verticalListSortingStrategy, useSortable,
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-6 space-y-8"> <div className="p-4 md:p-6 space-y-8">
{itinerary.days.map((day, dayIndex) => ( {props.itinerary.days.map((day, dayIndex) => (
<DaySection <DaySection key={day.day} {...props} day={day} dayIndex={dayIndex} />
key={day.day}
day={day}
dayIndex={dayIndex}
onReorder={onReorder}
onPlaceClick={onPlaceClick}
activePlaceId={activePlaceId}
/>
))} ))}
</div> </div>
); );
} }
function DaySection({ day, dayIndex, onReorder, onPlaceClick, activePlaceId }: { // ────────────────────────────────────────────────────────────────────────────
day: ItineraryDay; // DaySection
dayIndex: number; // ────────────────────────────────────────────────────────────────────────────
onReorder: (dayIndex: number, newItems: Place[]) => void; function DaySection({
onPlaceClick: (id: string) => void; day, dayIndex,
activePlaceId: string | null; onReorder, onAddPlace, onDeletePlace,
}) { 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, { useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
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 oldIndex = day.items.findIndex((i) => i.place_id === active.id); const oldIdx = day.items.findIndex(i => i.place_id === active.id);
const newIndex = day.items.findIndex((i) => i.place_id === over.id); const newIdx = 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);
} }
}; };
// Calculate estimated travel times between places (memoized to prevent re-renders) const grouped = useMemo(() => {
const travelTimes = useMemo(() => { const segments: Record<string, Place[]> = {};
const times: { [key: number]: number } = {}; for (const item of day.items) {
day.items.forEach((_, index) => { const seg = getSegment(item.start_time || '09:00');
if (index > 0 && index < day.items.length) { if (!segments[seg]) segments[seg] = [];
// Rough estimate: 5-15 minutes between places segments[seg].push(item);
times[index] = Math.floor(Math.random() * 10) + 5; }
} return segments;
}); }, [day.items]);
return times;
}, [day.items.length]); const segmentOrder: Array<'morning' | 'afternoon' | 'evening'> = ['morning', 'afternoon', 'evening'];
return ( return (
<div className="space-y-4"> <motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="space-y-5">
{/* Day Header with gradient divider */}
<div className="sticky top-0 bg-white/95 backdrop-blur-sm z-10 pb-3 border-b"> {/* Day note */}
<div className="flex items-center justify-between mb-2"> <div className="px-1">
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-3"> {isEditingNote ? (
Gün {day.day} <div className="space-y-2">
<Badge variant="secondary" className="font-normal text-sm"> <Textarea
{day.items.length} Mekan value={noteText}
</Badge> onChange={e => setNoteText(e.target.value)}
</h2> placeholder="Bugün için notlarınızı yazın..."
</div> 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"
{(day.total_distance || day.total_duration) && ( />
<div className="flex items-center gap-3 text-xs text-muted-foreground"> <div className="flex justify-end gap-2">
{day.total_distance && ( <Button variant="ghost" size="sm" onClick={() => setIsEditingNote(false)} className="h-7 text-[10px] font-bold">Vazgeç</Button>
<span className="flex items-center gap-1"> <Button size="sm" className="h-7 bg-orange-600 text-white text-[10px] font-bold px-3 rounded-lg"
<MapPin className="h-3 w-3" /> onClick={() => { onUpdateDayNote(dayIndex, noteText); setIsEditingNote(false); }}>
{day.total_distance} Kaydet
</span> </Button>
)} </div>
{day.total_duration && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{day.total_duration}
</span>
)}
</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>
)}
</button>
)} )}
{/* 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>
<DndContext {/* Empty state */}
sensors={sensors} {day.items.length === 0 && (
collisionDetection={closestCenter} <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">
onDragEnd={handleDragEnd} <div className="w-12 h-12 rounded-full bg-orange-50 flex items-center justify-center">
> <Package className="h-6 w-6 text-orange-300" />
<SortableContext
items={day.items.map(i => i.place_id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{day.items.map((item, index) => (
<div key={item.place_id}>
<SortableItem
item={item}
isActive={activePlaceId === item.place_id}
onClick={() => onPlaceClick(item.place_id)}
/>
{/* Travel time block between places */}
{index < day.items.length - 1 && (
<div className="flex items-center gap-2 py-2 px-4 text-xs text-muted-foreground">
<div className="w-0.5 h-6 bg-gradient-to-b from-primary/40 to-primary/10 mx-auto" />
<Car className="h-3 w-3" />
<span>{travelTimes[index + 1] || 10} dk sürüş</span>
</div>
)}
</div>
))}
</div> </div>
</SortableContext> <div className="text-center">
</DndContext> <p className="text-sm font-bold text-gray-500">Henüz durak yok</p>
</div> <p className="text-xs text-gray-400 mt-1">Aşağıdan bir yer ekleyerek başlayın</p>
</div>
</div>
)}
{/* Grouped items */}
{day.items.length > 0 && (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={day.items.map(i => i.place_id)} strategy={verticalListSortingStrategy}>
<div className="space-y-1 relative">
{/* Vertical line */}
<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" />
{segmentOrder.map(seg => {
const items = grouped[seg];
if (!items?.length) return null;
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>
</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 className="flex items-center gap-2">
<span className="text-xs font-black text-orange-800">Akıllı Tur Önerisi</span>
<Badge className="bg-orange-100 text-orange-600 hover:bg-orange-100 border-0 text-[9px] font-black">%82 Uyum</Badge>
</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 }) { // ────────────────────────────────────────────────────────────────────────────
const { // SortableItem — Wanderlog stili küçük thumbnail
attributes, // ────────────────────────────────────────────────────────────────────────────
listeners, function SortableItem({
setNodeRef, item, index, isActive, onClick, onDelete, onUpdateNote
transform, }: {
transition, item: Place;
isDragging index: number;
} = useSortable({ id: item.place_id }); isActive: boolean;
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.5 : 1, opacity: isDragging ? 0.4 : 1,
}; };
const photoUrl = item.photo_reference ? api.getPhotoUrl(item.photo_reference) : null; const photoUrl = useMemo(() => {
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} className="group relative"> <div ref={setNodeRef} style={style}>
<Card <Card
className={cn(
"overflow-hidden cursor-pointer transition-all duration-200 border-2",
isDragging && "border-dashed",
isActive
? "border-primary shadow-lg ring-2 ring-primary/20"
: "border-gray-200 hover:border-primary hover:shadow-md"
)}
onClick={onClick} onClick={onClick}
className={cn(
"overflow-hidden cursor-pointer transition-all duration-200 rounded-2xl border",
isDragging && "opacity-50 border-dashed",
isActive
? "border-orange-500 shadow-lg shadow-orange-500/15 ring-2 ring-orange-500/10"
: "border-gray-100 hover:border-gray-200 hover:shadow-md"
)}
> >
<CardContent className="p-0 flex"> <CardContent className="p-3">
{/* Image Section - 128px square */} {/* Ana satır */}
<div className="w-32 h-32 shrink-0 relative overflow-hidden"> <div className="flex items-start gap-2">
{photoUrl ? (
<img {/* Index numarası */}
src={photoUrl} <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">
alt={item.name} {index + 1}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-primary/10 to-primary/5 flex items-center justify-center">
<MapPin className="text-primary/30 h-10 w-10" />
</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> </div>
{/* Rating badge - top right */} {/* Bilgi alanı */}
{item.rating && ( <div className="flex-1 min-w-0">
<div className="absolute top-2 right-2"> <h4 className="text-sm font-black text-gray-900 truncate">{item.name}</h4>
<Badge className="bg-primary/90 backdrop-blur-sm text-white border-none hover:bg-primary shadow-sm"> <div className="flex items-center gap-1.5 mt-0.5 flex-wrap">
<Star className="h-3 w-3 fill-white mr-1" /> {item.start_time && (
{item.rating} <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> </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>
)} )}
{/* Aksiyonlar (hover'da görünür) */}
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-all shrink-0">
<div
{...attributes} {...listeners}
className="p-1.5 hover:bg-gray-100 rounded-lg text-gray-400 cursor-grab active:cursor-grabbing"
onClick={e => e.stopPropagation()}
>
<GripVertical className="h-3.5 w-3.5" />
</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>
{/* Content Section */} {/* Not düzenleme alanı */}
<div className="flex-1 p-4 flex flex-col justify-between min-w-0"> <AnimatePresence>
<div> {isEditingNote && (
<div className="flex items-start justify-between gap-2 mb-1"> <motion.div
<h4 className="font-bold text-base text-gray-900 line-clamp-1 flex-1">{item.name}</h4> initial={{ opacity: 0, height: 0 }}
<div animate={{ opacity: 1, height: 'auto' }}
{...attributes} exit={{ opacity: 0, height: 0 }}
{...listeners} className="mt-3 space-y-2 overflow-hidden"
className="cursor-grab active:cursor-grabbing p-1 hover:bg-gray-100 rounded transition-colors opacity-0 group-hover:opacity-100" onClick={e => e.stopPropagation()}
> >
<GripVertical className="h-4 w-4 text-gray-400" /> <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> </div>
</div> </motion.div>
<p className="text-sm text-muted-foreground line-clamp-2 leading-relaxed">{item.description}</p> )}
</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> </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>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
); );
} }

View File

@ -1,23 +1,529 @@
import { Hotel, Home, Navigation, Plane, Trees, Building2, Camera, Bike, UtensilsCrossed } from 'lucide-react'; import { useState, useMemo, useCallback, memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import api from '@/db/api';
import { Label } from '@/components/ui/label';
import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form';
import { toast } from 'sonner';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'
import * as z from 'zod';
import { format, differenceInDays } from 'date-fns';
import {
Loader2, ArrowRight, ArrowLeft, Sparkles,
MapPin, Calendar, Users, Coffee, Heart,
Car, Wallet, CheckCircle2, ChevronRight,
PersonStanding,
} from 'lucide-react';
import { parseApiError } from '@/utils/errorHandler';
import { retryWithBackoff, withTimeout } from '@/utils/retryWithBackoff';
import { motion, AnimatePresence } from 'framer-motion';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export const ACCOMMODATION_OPTIONS = [ import { LOADING_STEPS, TRAVEL_TYPE_OPTIONS, BUDGET_OPTIONS, TRANSPORT_OPTIONS, ACCOMMODATION_OPTIONS, INTEREST_OPTIONS } from '@/constants/planner';
{ id: 'hotel', label: 'Otel', icon: Hotel }, import { DateSelector } from '@/components/planner/DateSelector';
{ id: 'airbnb', label: 'Airbnb / Adres', icon: Home }, import { TravelerInput } from '@/components/planner/TravelerInput';
{ id: 'center', label: 'Merkezden Başla', icon: Navigation }, import { AccommodationSelector } from '@/components/planner/AccommodationSelector';
import { InterestsGrid } from '@/components/planner/InterestsGrid';
import { TravelTypeSelector } from '@/components/planner/TravelTypeSelector';
import { TransportSelector } from '@/components/planner/TransportSelector';
import { BudgetSelector } from '@/components/planner/BudgetSelector';
// ─── Schema ───────────────────────────────────────────────────────────────────
const formSchema = z.object({
dateRange: z.object({
from: z.date({ required_error: 'Başlangıç tarihi gereklidir' }),
to: z.date({ required_error: 'Bitiş tarihi gereklidir' }),
})
.refine(d => d.from >= new Date(new Date().setHours(0, 0, 0, 0)), {
message: 'Başlangıç tarihi bugünden önce olamaz',
})
.refine(d => d.to > d.from, {
message: 'Bitiş tarihi başlangıç tarihinden sonra olmalıdır',
})
.refine(d => {
const days = differenceInDays(d.to, d.from) + 1;
return days >= 1 && days <= 14;
}, { message: 'Seyahat süresi 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;
export const INTEREST_OPTIONS = [ // ─── Summary label helpers ────────────────────────────────────────────────────
{ id: 'balloon', label: 'Sıcak Hava Balonu', icon: Plane }, function getSummaryLabel(stepId: string, values: Partial<FormValues>): string | null {
{ id: 'nature', label: 'Doğa ve Yürüyüş', icon: Trees }, switch (stepId) {
{ id: 'history', label: 'Tarih ve Kültür', icon: Building2 }, case 'dates':
{ id: 'photography', label: 'Fotoğraf Çekimi', icon: Camera }, if (values.dateRange?.from && values.dateRange?.to)
{ id: 'adventure', label: 'Aktivite/Macera', icon: Bike }, return `${format(values.dateRange.from, 'd MMM')} ${format(values.dateRange.to, 'd MMM')}`;
{ id: 'gastronomy', label: 'Gastronomi', icon: UtensilsCrossed }, return null;
] as const; case 'travelType':
return TRAVEL_TYPE_OPTIONS.find(o => o.id === values.travelType)?.label ?? null;
case 'travelers':
return values.travelers ? `${values.travelers} kişi` : null;
case 'transport':
return TRANSPORT_OPTIONS.find(o => o.id === values.transport)?.label ?? null;
case 'budget':
return BUDGET_OPTIONS.find(o => o.id === values.budget)?.label ?? null;
case 'interests':
return values.interests?.length ? `${values.interests.length} seçildi` : null;
default:
return null;
}
}
export const LOADING_STEPS = [ // ─── PlannerPage ──────────────────────────────────────────────────────────────
{ label: 'Form hazırlanıyor...', progress: 0 }, const PlannerPage = () => {
{ label: 'Rotanız oluşturuluyor...', progress: 33 }, const { user } = useAuth();
{ label: 'Mekanlar belirleniyor...', progress: 66 }, const navigate = useNavigate();
{ label: 'Son kontroller yapılıyor...', progress: 90 }, const [loading, setLoading] = useState(false);
] as const; const [loadingStep, setLoadingStep] = useState(0);
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,6 +13,7 @@ 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 {
@ -20,6 +21,7 @@ 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 {
@ -90,7 +92,11 @@ const api = {
endDate: string; endDate: string;
interests: string[]; interests: string[];
dailySchedule: string; dailySchedule: string;
preferences: string; travelType: 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,
@ -121,4 +127,4 @@ const api = {
} }
}; };
export default api; export default api;

View File

@ -4,35 +4,35 @@
@layer base { @layer base {
:root { :root {
/* Backgrounds */ /* Backgrounds - Warmer White */
--background: 0 0% 98%; --background: 30 20% 98%;
--foreground: 222 47% 11%; --foreground: 24 30% 10%;
/* Card & Surfaces */ /* Card & Surfaces */
--card: 0 0% 100%; --card: 0 0% 100%;
--card-foreground: 222 47% 11%; --card-foreground: 24 30% 10%;
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 222 47% 11%; --popover-foreground: 24 30% 10%;
/* Primary - Blue (#3B82F6) */ /* Primary - Cappadocia Terracotta (#E36414) */
--primary: 217 91% 60%; --primary: 24 85% 50%;
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%;
--primary-dark: 221 83% 53%; --primary-dark: 24 85% 40%;
/* Secondary - Pink (#EC4899) */ /* Secondary - Deep Slate Blue for contrast (#1B263B) */
--secondary: 328 86% 70%; --secondary: 220 37% 17%;
--secondary-foreground: 0 0% 100%; --secondary-foreground: 0 0% 100%;
/* Muted & Neutral */ /* Muted & Neutral */
--muted: 220 14% 96%; --muted: 30 20% 94%;
--muted-foreground: 220 9% 46%; --muted-foreground: 24 10% 45%;
/* Accent - Very light blue */ /* Accent - Golden Hour (#FFB703) */
--accent: 217 91% 95%; --accent: 45 100% 51%;
--accent-foreground: 217 91% 30%; --accent-foreground: 24 30% 10%;
/* Status Colors */ /* Status Colors */
--destructive: 0 84.2% 60.2%; --destructive: 0 84% 60%;
--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,41 +42,45 @@
--info-foreground: 0 0% 100%; --info-foreground: 0 0% 100%;
/* Borders & Inputs */ /* Borders & Inputs */
--border: 220 13% 91%; --border: 24 20% 90%;
--input: 220 13% 91%; --input: 24 20% 90%;
--ring: 217 91% 60%; --ring: 24 85% 50%;
/* Radius */ /* Radius - Standard but soft */
--radius: 1rem; --radius: 0.75rem;
/* 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 */ /* Backgrounds - Deep Night Blue/Grey */
--background: 222 47% 11%; --background: 224 45% 8%;
--foreground: 0 0% 98%; --foreground: 30 20% 98%;
/* Card & Surfaces */ /* Card & Surfaces */
--card: 222 47% 13%; --card: 224 45% 12%;
--card-foreground: 0 0% 98%; --card-foreground: 30 20% 98%;
--popover: 222 47% 13%; --popover: 224 45% 12%;
--popover-foreground: 0 0% 98%; --popover-foreground: 30 20% 98%;
/* Primary - Blue */ /* Primary - Stays consistent but slightly more vibrant */
--primary: 217 91% 60%; --primary: 24 85% 55%;
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%;
--primary-dark: 221 83% 53%; --primary-dark: 24 85% 45%;
/* Secondary - Pink */ /* Secondary */
--secondary: 328 86% 70%; --secondary: 224 30% 20%;
--secondary-foreground: 0 0% 100%; --secondary-foreground: 30 20% 98%;
/* Muted & Neutral */ /* Muted & Neutral */
--muted: 217 33% 17%; --muted: 224 30% 15%;
--muted-foreground: 215 20% 65%; --muted-foreground: 224 15% 70%;
/* Accent - Dark blue */ /* Accent */
--accent: 217 91% 20%; --accent: 45 100% 60%;
--accent-foreground: 217 91% 85%; --accent-foreground: 224 45% 8%;
/* Status Colors */ /* Status Colors */
--destructive: 0 62.8% 30.6%; --destructive: 0 62.8% 30.6%;
@ -89,9 +93,11 @@
--info-foreground: 0 0% 100%; --info-foreground: 0 0% 100%;
/* Borders & Inputs */ /* Borders & Inputs */
--border: 217 33% 17%; --border: 224 30% 18%;
--input: 217 33% 17%; --input: 224 30% 18%;
--ring: 217 91% 60%; --ring: 24 85% 55%;
--gradient-surface: linear-gradient(180deg, rgba(30,41,59,0.8) 0%, rgba(30,41,59,0.4) 100%);
} }
} }
@ -100,90 +106,55 @@
@apply border-border; @apply border-border;
} }
body { body {
@apply bg-background text-foreground antialiased; @apply bg-background text-foreground antialiased selection:bg-primary/20 selection:text-primary;
} }
} }
.gradient-text { /* Custom Utilities */
background: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--secondary))); .glass {
-webkit-background-clip: text; @apply bg-white/10 backdrop-blur-xl border border-white/20;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
} }
.cappadocia-bg { .glass-dark {
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'); @apply bg-black/20 backdrop-blur-xl border border-white/10;
}
.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;
} }
.glassmorphism { /* Custom Scrollbar */
background: rgba(255, 255, 255, 0.85); ::-webkit-scrollbar {
backdrop-filter: blur(20px); width: 8px;
-webkit-backdrop-filter: blur(20px); height: 8px;
border: 1px solid rgba(255, 255, 255, 0.3);
} }
.dark .glassmorphism { ::-webkit-scrollbar-track {
background: rgba(31, 41, 55, 0.85); @apply bg-transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
} }
@keyframes float { ::-webkit-scrollbar-thumb {
0%, 100% { @apply bg-muted-foreground/20 rounded-full hover:bg-muted-foreground/40 transition-colors;
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
} }
.animate-float { /* Smooth Transitions */
animation: float 3s ease-in-out infinite; .transition-luxury {
} 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,10 +5,11 @@ 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, Compass } from 'lucide-react'; import { Loader2, Calendar, MapPin, Trash2, ChevronRight, PlusCircle, Zap, Compass as ExploreIcon } 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,
@ -56,16 +57,23 @@ export default function AccountPage() {
if (!user) { if (!user) {
return ( return (
<div className="min-h-screen flex items-center justify-center p-6"> <div className="min-h-screen flex items-center justify-center p-6 bg-secondary relative overflow-hidden">
<Card className="max-w-md w-full p-8 text-center space-y-6"> <div className="absolute inset-0 z-0 opacity-10">
<div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto"> <img
<Compass className="h-8 w-8 text-orange-600" /> src="https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=2400"
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-2xl font-bold">Hesabım</h2> <h2 className="text-3xl font-black text-white tracking-tighter uppercase leading-none">HESABIM</h2>
<p className="text-gray-500">Gezilerinizi kaydetmek ve yönetmek için giriş yapmalısınız.</p> <p className="text-white/60 font-medium italic text-sm">Gezilerinizi yönetmek için giriş yapmalısınız.</p>
</div> </div>
<Button className="w-full bg-orange-600" asChild> <Button className="w-full h-14 bg-primary hover:bg-primary-dark text-white font-black uppercase tracking-widest rounded-xl" asChild>
<Link to="/login">Giriş Yap</Link> <Link to="/login">Giriş Yap</Link>
</Button> </Button>
</Card> </Card>
@ -74,135 +82,153 @@ export default function AccountPage() {
} }
return ( return (
<div className="min-h-screen bg-gray-50 pt-20 pb-12"> <div className="min-h-screen bg-background pt-20 pb-12">
<div className="max-w-5xl mx-auto px-6"> <div className="max-w-4xl mx-auto px-6 space-y-10">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8"> <div className="flex flex-col md:flex-row md:items-end justify-between gap-6 pb-10 border-b border-border">
<div> <div className="space-y-3">
<h1 className="text-3xl font-bold text-gray-900">Gezilerim</h1> <div className="flex items-center gap-2">
<p className="text-gray-500 mt-1">Planladığınız ve kaydettiğiniz tüm Kapadokya rotaları.</p> <span className="text-[10px] font-black text-primary uppercase tracking-widest">Kişisel Arşiv</span>
<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="bg-orange-600 shadow-lg shadow-orange-200" asChild> <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>
<Link to="/planner"> <Link to="/planner">
<PlusCircle className="h-4 w-4 mr-2" /> <PlusCircle className="h-5 w-5 group-hover:rotate-90 transition-transform" />
Yeni Plan Oluştur Yeni Plan
</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-10 w-10 animate-spin text-orange-600" /> <Loader2 className="h-12 w-12 animate-spin text-primary opacity-40" />
<p className="text-gray-500 font-medium">Gezileriniz yükleniyor...</p> <p className="text-gray-400 font-black uppercase tracking-widest text-[9px]">Yükleniyor...</p>
</div> </div>
) : trips.length === 0 ? ( ) : trips.length === 0 ? (
<Card className="border-dashed border-2 py-20 text-center space-y-4"> <motion.div
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto"> initial={{ opacity: 0, y: 15 }}
<Compass className="h-8 w-8 text-gray-400" /> animate={{ opacity: 1, y: 0 }}
</div> >
<div className="space-y-2"> <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">
<h3 className="text-xl font-bold text-gray-900">Henüz bir planınız yok</h3> <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">
<p className="text-gray-500 max-w-sm mx-auto"> <ExploreIcon className="h-8 w-8 text-gray-200" />
İlk Kapadokya rotanızı oluşturmak için hemen planlayıcıyı kullanmaya başlayın. </div>
</p> <div className="space-y-2">
</div> <h3 className="text-2xl font-black text-gray-900 dark:text-white tracking-tighter uppercase">Planınız yok</h3>
<Button variant="outline" className="mt-4" asChild> <p className="text-gray-500 font-medium italic text-sm max-w-xs mx-auto">
<Link to="/planner">Planlamaya Başla</Link> İlk Kapadokya rotanızı oluşturun.
</Button> </p>
</Card> </div>
<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">
{trips.map((trip) => ( <AnimatePresence>
<Card key={trip.id} className="group overflow-hidden hover:shadow-xl transition-all duration-300 border-none shadow-sm"> {trips.map((trip, idx) => (
<CardContent className="p-0"> <motion.div
<div className="flex flex-col md:flex-row"> key={trip.id}
{/* Preview Image */} initial={{ opacity: 0, y: 15 }}
<div className="md:w-64 h-48 md:h-auto bg-gray-200 relative overflow-hidden"> animate={{ opacity: 1, y: 0 }}
<img transition={{ delay: idx * 0.1 }}
src="https://miaoda-site-img.s3cdn.medo.dev/images/KLing_8ea8dda5-57a3-4533-bd11-28440db86c34.jpg" >
alt={trip.title} <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">
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" <CardContent className="p-0">
/> <div className="flex flex-col sm:flex-row">
<div className="absolute inset-0 bg-black/20" /> <div className="sm:w-56 h-48 sm:h-auto bg-gray-200 relative overflow-hidden shrink-0">
<div className="absolute top-4 left-4"> <img
<Badge className="bg-white/90 text-gray-900 hover:bg-white border-none shadow-sm"> src="https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=2400"
{trip.itinerary.days.length} Gün alt={trip.title}
</Badge> className="w-full h-full object-cover group-hover:scale-105 transition-luxury duration-700"
</div> />
</div> <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]">
{/* Content */} <Zap className="h-2 w-2 mr-1" />
<div className="flex-1 p-6 md:p-8 flex flex-col justify-between"> {trip.itinerary.days.length} GÜN
<div className="flex items-start justify-between gap-4"> </Badge>
<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>
</div> </div>
<AlertDialog> <div className="flex-1 p-6 flex flex-col justify-between">
<AlertDialogTrigger asChild> <div className="flex items-start justify-between gap-4">
<Button variant="ghost" size="icon" className="text-gray-400 hover:text-red-600 hover:bg-red-50"> <div className="space-y-1">
<Trash2 className="h-5 w-5" /> <div className="flex items-center gap-2">
</Button> <span className="text-[9px] font-black text-primary uppercase tracking-widest">{trip.destination}</span>
</AlertDialogTrigger> </div>
<AlertDialogContent> <h3 className="text-2xl font-black text-gray-900 dark:text-white tracking-tighter uppercase leading-none group-hover:text-primary transition-colors">
<AlertDialogHeader> {trip.title}
<AlertDialogTitle>Planı silmek istediğinize emin misiniz?</AlertDialogTitle> </h3>
<AlertDialogDescription> <div className="flex items-center gap-4 pt-1">
Bu işlem geri alınamaz. Kaydettiğiniz tüm rota verileri silinecektir. <div className="flex items-center gap-1.5 text-[10px] font-bold text-gray-500 italic">
</AlertDialogDescription> <Calendar className="h-3.5 w-3.5 text-primary" />
</AlertDialogHeader> <span>{format(new Date(trip.start_date), 'd MMM yyyy', { locale: tr })}</span>
<AlertDialogFooter> </div>
<AlertDialogCancel>İptal</AlertDialogCancel> </div>
<AlertDialogAction </div>
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"> <AlertDialog>
<div className="flex -space-x-2"> <AlertDialogTrigger asChild>
{trip.itinerary.days[0].items.slice(0, 3).map((item, i) => ( <Button variant="ghost" size="icon" className="h-10 w-10 rounded-xl text-gray-300 hover:text-red-600">
<div key={i} className="w-8 h-8 rounded-full border-2 border-white bg-gray-100 flex items-center justify-center overflow-hidden"> <Trash2 className="h-5 w-5" />
<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')} /> </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> </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>
{trip.itinerary.days[0].items.length > 3 && ( <Link to={`/trip/${trip.id}`}>
<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} <ChevronRight className="h-4 w-4" />
</div> </Link>
)} </Button>
</div>
</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>
</div> </Card>
</CardContent> </motion.div>
</Card> ))}
))} </AnimatePresence>
</div> </div>
)} )}
</div> </div>
</div> </div>
); );
} }

View File

@ -3,7 +3,8 @@ 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, MapPin, Star, Clock, Filter, Sparkles, Compass } from 'lucide-react'; import { Search, Star, Clock, Filter, Sparkles, Heart, Share2, ArrowUpRight, Camera } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
const CATEGORIES = [ const CATEGORIES = [
{ id: 'all', label: 'Tümü' }, { id: 'all', label: 'Tümü' },
@ -33,7 +34,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\'nın en yüksek noktası. Tüm bölgeyi panoramik olarak görebileceğiniz devasa bir doğal kaya kalesi.', description: 'Kapadokya\'s en yüksek noktası. Tüm bölgeyi panoramik olarak görebileceğiniz devasa bir doğal kaya kalesi.',
}, },
{ {
id: '3', id: '3',
@ -89,125 +90,188 @@ export default function ExplorePage() {
}); });
return ( return (
<div className="min-h-screen bg-gray-50 pt-24 pb-12"> <div className="min-h-screen bg-background selection:bg-primary/20 pb-20">
<div className="max-w-7xl mx-auto px-6"> {/* Immersive Header */}
{/* Header Section */} <section className="relative h-[40vh] flex items-center justify-center overflow-hidden mb-12">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6 mb-12"> <div className="absolute inset-0 z-0 scale-105">
<div className="space-y-4 max-w-2xl"> <img
<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"> src="https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=2400"
<Sparkles className="h-3 w-3" /> alt="Explore Hero"
Keşfet className="w-full h-full object-cover grayscale-[0.2]"
</div> />
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 tracking-tight"> <div className="absolute inset-0 bg-gradient-to-t from-background via-background/20 to-transparent z-10" />
Kapadokya'nın <span className="text-orange-600">En İyileri</span> </div>
</h1>
<p className="text-gray-500 text-lg leading-relaxed"> <div className="container relative z-20 px-6 text-center space-y-6">
Bölgenin en popüler noktalarını ve saklı kalmış eşsiz rotalarını keşfedin. Google onaylı verilerle seyahatinizi zenginleştirin. <motion.div
</p> initial={{ opacity: 0, y: 15 }}
</div> animate={{ opacity: 1, y: 0 }}
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>
<div className="relative w-full md:w-80"> <motion.h1
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" /> initial={{ opacity: 0, y: 15 }}
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="Mekan veya aktivite ara..." placeholder="Bir efsane veya aktivite arayın..."
className="pl-10 h-12 bg-white border-gray-200 rounded-xl shadow-sm focus:ring-orange-500" 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"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
</div> </motion.div>
</div> </div>
</section>
{/* Categories Bar */} <div className="container px-6">
<div className="flex items-center gap-2 overflow-x-auto pb-4 mb-8 scrollbar-hide"> {/* Categories Carousel */}
<Button variant="outline" size="sm" className="shrink-0 border-gray-200 bg-white"> <div className="flex items-center gap-3 overflow-x-auto pb-6 mb-12 no-scrollbar">
<Filter className="h-4 w-4 mr-2" /> <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-3.5 w-3.5 mr-2 text-primary" />
Filtrele Filtrele
</Button> </Button>
<div className="w-px h-6 bg-gray-200 mx-2 shrink-0" /> <div className="w-px h-6 bg-gray-100 dark:bg-white/10 mx-1 shrink-0" />
{CATEGORIES.map((cat) => ( {CATEGORIES.map((cat) => (
<Button <Button
key={cat.id} key={cat.id}
variant={selectedCategory === cat.id ? 'default' : 'outline'}
size="sm"
className={`shrink-0 rounded-full font-medium ${
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)} onClick={() => setSelectedCategory(cat.id)}
className={`h-11 px-8 rounded-xl font-black uppercase tracking-widest text-[10px] shrink-0 transition-luxury ${selectedCategory === cat.id
? 'bg-primary text-white shadow-lg shadow-primary/20'
: 'bg-white/5 border-2 border-gray-100 dark:border-white/10 text-gray-400 hover:border-primary/40 hover:text-primary'
}`}
> >
{cat.label} {cat.label}
</Button> </Button>
))} ))}
</div> </div>
{/* Places Grid */} {/* Results Grid */}
{filteredPlaces.length > 0 ? ( <AnimatePresence mode="wait">
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8"> {filteredPlaces.length > 0 ? (
{filteredPlaces.map((place) => ( <motion.div
<Card key={place.id} className="group overflow-hidden border-none shadow-sm hover:shadow-xl transition-all duration-300 rounded-2xl bg-white"> key="grid"
<CardContent className="p-0"> initial={{ opacity: 0 }}
<div className="relative h-64 overflow-hidden"> animate={{ opacity: 1 }}
<img exit={{ opacity: 0 }}
src={place.image} className="grid md:grid-cols-2 lg:grid-cols-3 gap-8"
alt={place.name} >
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" {filteredPlaces.map((place, index) => (
/> <motion.div
<div className="absolute top-4 left-4 flex gap-2"> key={place.id}
<Badge className="bg-white/90 backdrop-blur-sm text-gray-900 border-none shadow-sm capitalize"> initial={{ opacity: 0, y: 20 }}
{CATEGORIES.find(c => c.id === place.category)?.label} animate={{ opacity: 1, y: 0 }}
</Badge> transition={{ delay: index * 0.1 }}
</div> >
<div className="absolute bottom-4 right-4"> <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">
<Badge className="bg-orange-600/90 backdrop-blur-sm text-white border-none shadow-sm flex items-center gap-1"> <CardContent className="p-0">
<Star className="h-3 w-3 fill-white" /> <div className="relative h-64 overflow-hidden">
{place.rating} <img
</Badge> src={place.image}
</div> alt={place.name}
</div> className="w-full h-full object-cover transition-luxury duration-700 group-hover:scale-105"
<div className="p-6 space-y-3"> />
<div className="flex items-start justify-between gap-4"> <div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-luxury" />
<h3 className="text-xl font-bold text-gray-900 group-hover:text-orange-600 transition-colors">
{place.name} <div className="absolute top-4 left-4">
</h3> <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">
</div> {CATEGORIES.find(c => c.id === place.category)?.label}
<p className="text-gray-500 text-sm line-clamp-2 leading-relaxed"> </Badge>
{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">
<Compass className="h-3.5 w-3.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">
<span>{place.reviews.toLocaleString()} Yorum</span> <div className="flex items-center gap-2">
<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">
Detaylar <div className="p-6 md:p-8 space-y-4">
</Button> <div className="space-y-1">
</div> <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}
</CardContent> </h3>
</Card> <p className="text-gray-500 font-medium italic leading-relaxed text-sm line-clamp-2">
))} "{place.description}"
</div> </p>
) : ( </div>
<div className="py-24 text-center space-y-4">
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-6"> <div className="pt-6 flex items-center justify-between border-t border-gray-50 dark:border-white/5">
<Search className="h-10 w-10 text-gray-300" /> <div className="flex flex-wrap items-center gap-4">
</div> <div className="flex items-center gap-1.5 text-[9px] font-black text-gray-400 uppercase tracking-widest">
<h3 className="text-2xl font-bold text-gray-900">Sonuç Bulunamadı</h3> <Clock className="h-3.5 w-3.5 text-primary" />
<p className="text-gray-500 max-w-sm mx-auto"> <span>{place.duration}</span>
Aramanıza uygun mekan bulunamadı. Farklı bir anahtar kelime veya kategori deneyin. </div>
</p> <div className="flex items-center gap-1.5 text-[9px] font-black text-gray-400 uppercase tracking-widest">
<Button variant="link" onClick={() => { setSearchQuery(''); setSelectedCategory('all'); }} className="text-orange-600 font-bold"> <Camera className="h-3.5 w-3.5 text-primary" />
Tüm Mekanları Göster <span>Görsel</span>
</Button> </div>
</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,170 +1,268 @@
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, Heart } from 'lucide-react'; import { Sparkles, MapPin, Calendar, Compass, ShieldCheck, Zap, ArrowRight, Star, ArrowUpRight, Play, Globe } from 'lucide-react';
import { motion } from 'framer-motion'; import { motion, useScroll, useTransform } from 'framer-motion';
import { useRef } from 'react';
const FeatureCard = ({ icon: Icon, title, description }: { icon: any, title: string, description: string }) => ( const FeatureCard = ({ icon: Icon, title, description, index }: { icon: any, title: string, description: string, index: number }) => (
<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"> <motion.div
<div className="w-14 h-14 bg-orange-100 rounded-xl flex items-center justify-center mb-6"> initial={{ opacity: 0, y: 20 }}
<Icon className="h-7 w-7 text-orange-600" /> whileInView={{ opacity: 1, y: 0 }}
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 mb-3">{title}</h3> <h3 className="text-xl font-bold text-gray-900 dark:text-white mb-3 tracking-tight">{title}</h3>
<p className="text-gray-600 leading-relaxed">{description}</p> <p className="text-gray-600 dark:text-gray-400 leading-relaxed text-sm font-medium">{description}</p>
</div> </motion.div>
); );
export default function LandingPage() { export default function LandingPage() {
return ( const containerRef = useRef<HTMLDivElement>(null);
<div className="min-h-screen bg-[#FFF9F5]"> const { scrollYProgress } = useScroll({
{/* Hero Section */} target: containerRef,
<section className="relative pt-32 pb-20 lg:pt-48 lg:pb-32 overflow-hidden"> offset: ["start start", "end end"]
{/* Background blobs */} });
<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" />
<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" />
<div className="max-w-7xl mx-auto px-6 text-center"> const heroY = useTransform(scrollYProgress, [0, 1], [0, 200]);
const heroOpacity = useTransform(scrollYProgress, [0, 0.2], [1, 0]);
return (
<div className="min-h-screen bg-background selection:bg-primary/20" ref={containerRef}>
{/* Hero Section */}
<section className="relative h-[90vh] flex items-center justify-center overflow-hidden">
{/* Cinematic Background */}
<motion.div
style={{ y: heroY, opacity: heroOpacity }}
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="container relative z-20 px-6 text-center">
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.6 }} transition={{ duration: 0.8, ease: "easeOut" }}
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" 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"
> >
<Sparkles className="h-4 w-4" /> <Sparkles className="h-3.5 w-3.5 text-accent" />
Yapay Zeka Destekli Gezi Planlayıcısı Yapay Zeka Destekli Premium Deneyim
</motion.div> </motion.div>
<motion.h1 <motion.h1
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.1 }} transition={{ duration: 0.8, delay: 0.2 }}
className="text-5xl lg:text-7xl font-extrabold text-gray-900 mb-8 leading-[1.1] tracking-tight" className="text-5xl md:text-7xl lg:text-8xl font-black text-white mb-8 leading-[0.95] tracking-tighter"
> >
Kapadokya'yı <br /> KAPADOKYA <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-orange-600 to-amber-500"> <span className="text-transparent bg-clip-text bg-gradient-to-r from-primary via-accent to-primary animate-gradient">
Yeniden Keşfedin EFSANESİ
</span> </span>
</motion.h1> </motion.h1>
<motion.p <motion.p
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }} transition={{ duration: 0.8, delay: 0.4 }}
className="text-xl text-gray-600 max-w-2xl mx-auto mb-12 leading-relaxed" className="text-lg md:text-xl text-white/80 max-w-2xl mx-auto mb-12 leading-relaxed font-medium italic"
> >
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. "Sıradan bir gezi değil, ruhunuza dokunacak bir keşif hikayesi. Saniyeler içinde size özel kurgulanmış premium rotalar."
</motion.p> </motion.p>
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3 }} transition={{ duration: 0.8, delay: 0.6 }}
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-10 text-lg font-bold bg-orange-600 hover:bg-orange-700 shadow-xl shadow-orange-200 rounded-xl" asChild> <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>
<Link to="/planner"> <Link to="/planner">
Hemen Planla Hemen Keşfet
<ArrowRight className="ml-2 h-5 w-5" /> <ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
</Link> </Link>
</Button> </Button>
<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> <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">
<Link to="/explore">Keşfetmeye Başla</Link> <Play className="h-5 w-5 fill-white" />
</Button> Tanıtımı İzle
</button>
</motion.div> </motion.div>
</div>
{/* Social Proof */} {/* Scroll Indicator */}
<motion.div <motion.div
initial={{ opacity: 0 }} animate={{ y: [0, 8, 0] }}
animate={{ opacity: 1 }} transition={{ repeat: Infinity, duration: 2 }}
transition={{ duration: 1, delay: 0.8 }} className="absolute bottom-8 left-1/2 -translate-x-1/2 text-white/40"
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="flex -space-x-3"> <div className="w-1 h-1 bg-white/40 rounded-full" />
{[1, 2, 3, 4, 5].map((i) => ( </div>
<div key={i} className="w-12 h-12 rounded-full border-4 border-[#FFF9F5] bg-gray-200 overflow-hidden shadow-sm"> </motion.div>
<img src={`https://i.pravatar.cc/150?u=${i}`} alt="user" /> </section>
{/* 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="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"> <div className="text-white/60 font-bold tracking-widest uppercase text-[10px]">{stat.label}</div>
+2k </motion.div>
</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-24 bg-white/40"> <section className="py-20 bg-background relative overflow-hidden">
<div className="max-w-7xl mx-auto px-6"> <div className="container px-6">
<div className="text-center mb-16 space-y-4"> <div className="max-w-2xl mx-auto text-center mb-16 space-y-4">
<h2 className="text-3xl lg:text-4xl font-bold text-gray-900">Neden Bizimle Planlamalısınız?</h2> <motion.span
<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> initial={{ opacity: 0 }}
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 Hızı" title="Yapay Zeka Mimari"
description="OpenAI altyapısıyla saniyeler içinde seyahat sürenize ve ilgi alanlarınıza özel rota oluşturun." description="OpenAI 4o-mini altyapısıyla her detayı düşünülmüş, tutarlı ve akıcı bir seyahat programı."
/> />
<FeatureCard <FeatureCard
index={1}
icon={ShieldCheck} icon={ShieldCheck}
title="Google Doğrulaması" title="Premium Veri"
description="Önerilen tüm mekanlar Google Places API ile doğrulanır; güncel fotoğraflar ve gerçek yorumlarla sunulur." description="Google Places verileriyle gerçek fotoğraflar, anlık çalışma saatleri ve güvenilir yorumlar."
/> />
<FeatureCard <FeatureCard
index={2}
icon={MapPin} icon={MapPin}
title="Akıllı Lojistik" title="Akıllı Navigasyon"
description="Google Directions API ile rotanızdaki mesafe ve süreler otomatik hesaplanır, zamanınız boşa gitmez." description="Sadece rota değil, Google Directions ile en mantıklı sıralama ve ulaşım süreleri."
/> />
<FeatureCard <FeatureCard
index={3}
icon={Compass} icon={Compass}
title="Gizli Cevherler" title="Kürasyon"
description="Sadece turistik yerleri değil, Kapadokya'nın saklı kalmış eşsiz köşelerini de keşfedin." description="Yerel rehberlerin gizli favorileri ve turistik kalabalıkların ötesindeki özel duraklar."
/> />
<FeatureCard <FeatureCard
index={4}
icon={Calendar} icon={Calendar}
title="Esnek Yönetim" title="Dinamik Kontrol"
description="Planınızı dilediğiniz gibi düzenleyin, sürükle-bırak özelliğiyle mekanların sırasını değiştirin." description="Planınızı dilediğiniz zaman sürükle-bırak yöntemiyle revize edin, her an güncel kalın."
/> />
<FeatureCard <FeatureCard
icon={Heart} index={5}
title="Ücretsiz ve Hızlı" icon={Sparkles}
description="Herhangi bir gizli ücret yok. En iyi Kapadokya deneyimi için tasarlandı." title="Modern Estetik"
description="En yüksek standartlarda kullanıcı deneyimi için tasarlanmış akıcı ve şık arayüz."
/> />
</div> </div>
</div> </div>
</section> </section>
{/* CTA Section */} {/* Luxury CTA */}
<section className="py-24 px-6"> <section className="py-20 px-6">
<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"> <motion.div
{/* Decorative circles */} initial={{ opacity: 0, scale: 0.95 }}
<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" /> whileInView={{ opacity: 1, scale: 1 }}
<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" /> viewport={{ once: true }}
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-5xl font-bold text-white leading-tight"> <h2 className="text-4xl lg:text-7xl font-black text-white leading-[0.95] tracking-tighter uppercase">
Maceranıza Başlamak İçin <br /> Hazır mısınız? BİR SONRAKİ <br /> <span className="text-primary">EFSANENİZİ</span> YAZIN
</h2> </h2>
<p className="text-white/80 text-lg max-w-xl mx-auto"> <p className="text-white/60 text-lg md:text-xl max-w-xl mx-auto font-medium leading-relaxed">
Kapadokya'nın büyüleyici atmosferine adım atmadan önce rotanızı profesyonelce hazırlayın. Kapadokya'nın zamansız ruhunu, modern teknolojinin gücüyle birleştirin. Bugün başlayın.
</p> </p>
<Button size="lg" className="h-14 px-12 text-lg font-bold bg-white text-orange-600 hover:bg-orange-50 rounded-xl" asChild> <div className="flex flex-col sm:flex-row items-center justify-center gap-4 pt-4">
<Link to="/planner">Rotamı Oluştur</Link> <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>
</Button> <Link to="/planner" className="flex items-center gap-3">
Rotanı Oluştur
<ArrowUpRight className="h-6 w-6 group-hover:rotate-45 transition-transform" />
</Link>
</Button>
</div>
</div> </div>
</div> </motion.div>
</section> </section>
{/* Footer */} {/* Modern Footer */}
<footer className="py-12 border-t bg-white"> <footer className="py-16 border-t border-border bg-background">
<div className="max-w-7xl mx-auto px-6 text-center text-gray-500 text-sm"> <div className="container px-6">
<p>© 2026 Cappadocia AI Travel Planner. Tüm hakları saklıdır.</p> <div className="flex flex-col md:flex-row justify-between items-center gap-8">
<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,12 +1,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation, Link } 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, Rocket, Map, Save, Users, Eye, EyeOff, Shield } from 'lucide-react'; import { Loader2, MapPin, Sparkles, Shield, Eye, EyeOff, ArrowRight } 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);
@ -20,7 +21,6 @@ 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,7 +29,6 @@ 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;
@ -61,232 +60,172 @@ 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) {
const errorMessage = error.message || 'Bir hata oluştu. Lütfen tekrar deneyin.'; toast.error(error.message || 'Bir hata oluştu');
toast.error(errorMessage);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
return ( return (
<div className="min-h-screen flex"> <div className="min-h-screen flex bg-background selection:bg-primary/20">
{/* Sol Taraf - Hero Section (Sadece Desktop) */} {/* Left Side - Cinematic Hero */}
<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="hidden lg:flex lg:w-[50%] xl:w-[55%] relative overflow-hidden group">
{/* Dekoratif arka plan */} <div className="absolute inset-0 z-0 transition-luxury duration-1000 group-hover:scale-105">
<div className="absolute inset-0 opacity-10"> <img
<div className="absolute top-20 left-20 w-72 h-72 bg-white rounded-full blur-3xl" /> src="https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=2400"
<div className="absolute bottom-20 right-20 w-96 h-96 bg-white rounded-full blur-3xl" /> alt="Cappadocia Login"
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-10"> <div className="relative z-20 w-full p-12 xl:p-16 flex flex-col justify-between">
{/* Logo */} <Link to="/" className="flex items-center gap-3 group/logo">
<div className="flex items-center space-x-3 mb-16"> <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="p-3 bg-white/20 backdrop-blur-sm rounded-2xl"> <MapPin className="h-6 w-6" />
<MapPin className="h-8 w-8" />
</div> </div>
<span className="text-2xl font-bold">Cappadocia AI</span> <span className="text-2xl font-black text-white tracking-tighter uppercase">Kapadokya <span className="text-primary">Efsanesi</span></span>
</div> </Link>
{/* Ana Başlık */} <div className="max-w-lg space-y-6">
<div className="space-y-6 max-w-md"> <motion.div
<h1 className="text-5xl font-bold leading-tight"> initial={{ opacity: 0, x: -20 }}
Kapadokya'ya Hoşgeldin animate={{ opacity: 1, x: 0 }}
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-xl text-blue-100"> <p className="text-lg text-white/60 font-medium italic leading-relaxed">
Yapay zeka rehberliğinde unutulmaz deneyimler yarat "Efsaneler, sadece cesaret edenler ve keşfedenler için yazılır. Rotanızı kaydedin ve Kapadokya'yı yaşayın."
</p> </p>
{/* Özellikler Listesi */}
<div className="space-y-4 pt-8">
<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>
{/* Alt Bilgi */} <div className="flex items-center gap-3 text-white/40 text-[10px] font-black uppercase tracking-widest">
<div className="relative z-10 flex items-center space-x-2 text-sm text-blue-100"> <Shield className="h-3.5 w-3.5 text-primary" />
<Shield className="h-4 w-4" /> <span>Premium Güvenlik Protokolü Aktif</span>
<span>Güvenlik: Verileriniz 256-bit SSL ile korunur</span> </div>
</div> </div>
</div> </div>
{/* Sağ Taraf - Login Form */} {/* Right Side - Luxury Form */}
<div className="w-full lg:w-1/2 flex items-center justify-center p-8 bg-background"> <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 max-w-md space-y-8"> <div className="w-full max-w-sm relative z-10 space-y-10">
{/* Mobile Logo */} {/* Mobile Brand Header */}
<div className="lg:hidden flex justify-center mb-8"> <div className="lg:hidden flex flex-col items-center gap-3 text-center mb-10">
<div className="flex items-center space-x-2"> <div className="w-14 h-14 bg-primary rounded-xl flex items-center justify-center text-white shadow-xl shadow-primary/20">
<MapPin className="h-8 w-8 text-primary" /> <MapPin className="h-8 w-8" />
<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>
{/* Form Header */} <div className="space-y-3">
<div className="space-y-2 text-center lg:text-left"> <h2 className="text-3xl md:text-4xl font-black text-gray-900 dark:text-white tracking-tighter uppercase leading-none">
<h2 className="text-3xl font-bold tracking-tight"> {isLogin ? 'HOŞ GELDİNİZ' : 'BİZE KATILIN'}
{isLogin ? 'Giriş Yap' : 'Hesap Oluştur'}
</h2> </h2>
<p className="text-muted-foreground"> <p className="text-base text-gray-500 font-medium italic">
{isLogin ? 'Rotanızı yönetin ve seyahatinizi planlayın' : 'Kapadokya maceranıza başlayın'} {isLogin ? 'Efsane kaldığı yerden devam ediyor.' : 'Kendi Kapadokya hikayenizi başlatın.'}
</p> </p>
</div> </div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{/* Kullanıcı Adı */} <div className="space-y-5">
<div className="space-y-2"> <div className="space-y-2.5">
<Label htmlFor="username" className="text-sm font-semibold"> <Label htmlFor="username" className="text-[10px] font-black uppercase tracking-widest text-primary">Kullanıcı Kimliği</Label>
Kullanıcı Adı <Input
</Label> id="username"
<Input placeholder="kullanici_adi"
id="username" required
placeholder="kullaniciadi" value={username}
required onChange={e => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))}
value={username} 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"
onChange={e => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))} />
className="h-11 text-base" </div>
autoComplete="username"
/>
<p className="text-xs text-muted-foreground">
Sadece harf, rakam ve alt çizgi kullanılabilir
</p>
</div>
{/* Şifre */} <div className="space-y-2.5">
<div className="space-y-2"> <div className="flex items-center justify-between">
<div className="flex items-center justify-between"> <Label htmlFor="password" className="text-[10px] font-black uppercase tracking-widest text-primary">Güvenli Şifre</Label>
<Label htmlFor="password" className="text-sm font-semibold"> </div>
Şifre <div className="relative">
</Label> <Input
{isLogin && ( id="password"
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"
className="text-xs text-primary hover:underline" onClick={() => setShowPassword(!showPassword)}
onClick={() => toast.info('Şifre sıfırlama özelliği yakında eklenecek')} className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-primary transition-colors"
> >
Şifremi unuttum {showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button> </button>
)} </div>
</div> </div>
<div className="relative">
<Input {isLogin && (
id="password" <div className="flex items-center justify-between">
type={showPassword ? 'text' : 'password'} <div className="flex items-center space-x-2.5">
placeholder="••••••••" <Checkbox
required id="remember"
value={password} checked={rememberMe}
onChange={e => setPassword(e.target.value)} onCheckedChange={(checked) => setRememberMe(checked as boolean)}
className="h-11 text-base pr-10" className="w-4 h-4 rounded-md border-2 border-gray-300 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
autoComplete={isLogin ? 'current-password' : 'new-password'} />
/> <Label htmlFor="remember" className="text-xs font-bold text-gray-500 cursor-pointer">Beni Hatırla</Label>
<button </div>
type="button" <button type="button" className="text-[10px] font-black uppercase tracking-widest text-primary hover:opacity-70 transition-opacity">Şifremi Unuttum</button>
onClick={() => setShowPassword(!showPassword)} </div>
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-11 text-base font-semibold" 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"
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...'}
</>
) : ( ) : (
isLogin ? 'Giriş Yap' : 'Hesap Oluştur' <div className="flex items-center gap-2">
{isLogin ? 'Giriş Yap' : 'Hesabı Oluştur'}
<ArrowRight className="h-5 w-5 group-hover:translate-x-1 transition-transform" />
</div>
)} )}
</Button> </Button>
</form> </form>
{/* Divider */} <div className="pt-6 text-center border-t border-gray-100">
<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-sm text-muted-foreground hover:text-primary transition-colors" className="text-xs font-bold text-gray-400 hover:text-primary transition-luxury group"
> >
{isLogin ? ( {isLogin ? (
<> <>
Hesabınız yok mu?{' '} Hesabınız yok mu?{' '}
<span className="font-semibold text-primary">Kayıt ol</span> <span className="font-black text-primary uppercase tracking-widest ml-1 group-hover:underline">Kayıt Ol</span>
</> </>
) : ( ) : (
<> <>
Zaten hesabınız var mı?{' '} Hesabınız var mı?{' '}
<span className="font-semibold text-primary">Giriş yap</span> <span className="font-black text-primary uppercase tracking-widest ml-1 group-hover:underline">Giriş Yap</span>
</> </>
)} )}
</button> </button>
@ -295,4 +234,4 @@ export default function LoginPage() {
</div> </div>
</div> </div>
); );
} }

View File

@ -1,355 +1,543 @@
import { useState, useMemo, useEffect, useRef, useCallback, memo, Suspense, lazy } from 'react'; import { useState, useMemo, useCallback, memo } 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 { MapPin, Loader2 } from 'lucide-react'; import {
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';
// Constants import { LOADING_STEPS, TRAVEL_TYPE_OPTIONS, BUDGET_OPTIONS, TRANSPORT_OPTIONS, ACCOMMODATION_OPTIONS, INTEREST_OPTIONS } from '@/constants/planner';
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 { SubmitButton } from '@/components/planner/SubmitButton'; import { TravelTypeSelector } from '@/components/planner/TravelTypeSelector';
import { HeroSection } from '@/components/planner/HeroSection'; import { TransportSelector } from '@/components/planner/TransportSelector';
import { BudgetSelector } from '@/components/planner/BudgetSelector';
// Form validation schema // ─── Schema ───────────────────────────────────────────────────────────────────
const formSchema = z.object({ const formSchema = z.object({
dateRange: z.object({ dateRange: z.object({
from: z.date({ from: z.date({ required_error: 'Başlangıç tarihi gereklidir' }),
required_error: 'Başlangıç tarihi gereklidir', to: z.date({ required_error: 'Bitiş tarihi gereklidir' }),
}), })
to: z.date({ .refine(d => d.from >= new Date(new Date().setHours(0, 0, 0, 0)), {
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( .refine(d => d.to > d.from, {
(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( .refine(d => {
(data) => { const days = differenceInDays(d.to, d.from) + 1;
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'),
message: 'Seyahat süresi 1-14 gün arasında olmalıdır', travelers: z.number().min(1).max(15),
}
),
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(),
interests: z.array(z.string()) transport: z.string().min(1, 'Ulaşım tercihi seçiniz'),
.min(1, 'En az 1 ilgi alanı seçmelisiniz') budget: z.string().min(1, 'Bütçe aralığı seçiniz'),
.max(6, 'Maksimum 6 ilgi alanı seçebilirsiniz'), interests: z.array(z.string()).min(1, 'En az 1 ilgi alanı seçiniz').max(6),
}); });
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: { dateRange: { from: undefined, to: undefined },
from: undefined, travelType: '',
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();
const formProgress = useMemo(() => { // ── Navigation ──────────────────────────────────────────────────────────────
let completed = 0; const nextStep = async () => {
const total = 4; const stepId = STEPS[currentStep].id;
if (watchedValues.dateRange?.from && watchedValues.dateRange?.to) completed++; const fieldMap: Record<string, keyof FormValues | (keyof FormValues)[]> = {
if (watchedValues.travelers >= 1) completed++; dates: 'dateRange',
if (watchedValues.accommodation) completed++; travelType: 'travelType',
if (watchedValues.interests?.length > 0) completed++; travelers: ['travelers', 'accommodation'],
return Math.round((completed / total) * 100); transport: 'transport',
}, [watchedValues]); 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 handleInterestToggle = useCallback((interestId: string) => { const prevStep = () => { if (currentStep > 0) setCurrentStep(p => p - 1); };
const currentInterests = form.getValues('interests');
const newInterests = currentInterests.includes(interestId) const handleInterestToggle = useCallback((id: string) => {
? currentInterests.filter(id => id !== interestId) const current = form.getValues('interests');
: [...currentInterests, interestId]; const next = current.includes(id) ? current.filter(i => i !== id) : [...current, id];
form.setValue('interests', newInterests, { shouldValidate: true }); form.setValue('interests', next, { 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 interval = setInterval(() => { const iv = setInterval(() => {
setLoadingStep(prev => { setLoadingStep(prev => {
if (prev < LOADING_STEPS.length - 1) return prev + 1; if (prev < LOADING_STEPS.length - 1) return prev + 1;
clearInterval(interval); clearInterval(iv);
return prev; return prev;
}); });
}, 2500); }, 2500);
return interval; return iv;
}, []); }, []);
// ── Submit ──────────────────────────────────────────────────────────────────
const onSubmit = async (data: FormValues) => { const onSubmit = async (data: FormValues) => {
setLoading(true); setLoading(true);
setLoadingStep(0); const iv = simulateLoadingSteps();
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(
async () => { () => withTimeout(
return await withTimeout( api.generateItinerary({
api.generateItinerary(formData), startDate,
45000, endDate,
new Error('Sunucu yanıt vermiyor, lütfen tekrar deneyin.') interests: data.interests,
); dailySchedule: data.travelType === 'family' ? 'relaxed' : data.budget === 'luxury' ? 'relaxed' : 'moderate',
}, travelType: data.travelType,
{ accommodation: data.accommodation,
maxRetries: 2, transport: data.transport,
initialDelay: 1000, budget: data.budget,
maxDelay: 5000 travelers: data.travelers,
} }),
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 savedTrip = await api.saveTrip({ const saved = 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: formData, preferences: {
startDate,
endDate,
interests: data.interests,
travelType: data.travelType,
accommodation: data.accommodation,
transport: data.transport,
budget: data.budget,
travelers: data.travelers,
},
itinerary: result, itinerary: result,
}); });
tripId = savedTrip.id; navigate(`/trip/${saved.id}`);
localStorage.removeItem('planner_form_draft'); toast.success('Rotanız hazır!');
} 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(loadingInterval); clearInterval(iv);
if (err instanceof Error && err.name === 'AbortError') return; if (err instanceof Error && err.name === 'AbortError') return;
const apiError = parseApiError(err); toast.error('Hata oluştu', { description: parseApiError(err).userMessage });
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 flex"> <div className="min-h-screen bg-background flex flex-col lg:flex-row overflow-hidden">
{/* Left Side - Form */}
<div className="w-full lg:w-[45%] xl:w-[40%] bg-white p-6 md:p-10 overflow-y-auto"> {/* ── Sidebar ─────────────────────────────────────────────────────────── */}
<div className="max-w-xl mx-auto space-y-8"> <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"> <div className="space-y-2">
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 tracking-tight"> <h1 className="text-3xl xl:text-4xl font-black text-white leading-none tracking-tighter uppercase">
Hayalinizdeki <span className="text-orange-600">Kapadokya</span> gezisi başlasın ROTANIZI<br /><span className="text-primary">TASARLAYIN</span>
</h1> </h1>
<p className="text-gray-500 text-lg"> <p className="text-white/35 text-sm font-medium italic">
Kişiselleştirilmiş, Google doğrulamalı ve yapay zeka destekli gezi planlayıcısı. "Size özel kurgulanmış seyahat mimarisi."
</p> </p>
</div> </div>
<div className="space-y-3"> {/* Step list */}
<div className="flex items-center justify-between text-sm"> <div className="flex-1 space-y-1.5">
<span className="text-gray-600 font-medium">Planlama İlerlemesi</span> {STEPS.map((step, i) => {
<span className="text-orange-600 font-bold">{formProgress}%</span> const Icon = step.icon;
</div> const isActive = i === currentStep;
<Progress value={formProgress} className="h-2 bg-orange-100" /> 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> </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="space-y-8"> <form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 flex flex-col">
{/* 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>
{/* Date Selection */} <AnimatePresence mode="wait">
<FormField <motion.div
control={form.control} key={currentStep}
name="dateRange" initial={{ opacity: 0, x: 24 }}
render={({ field }) => ( animate={{ opacity: 1, x: 0 }}
<FormItem className="space-y-3"> exit={{ opacity: 0, x: -24 }}
<Label className="text-sm font-bold text-gray-700">Ne Zaman Gidiyorsunuz?</Label> transition={{ duration: 0.35, ease: 'easeOut' }}
<DateSelector className="flex-1 space-y-10"
date={field.value} >
onDateChange={field.onChange} {/* Step header */}
isOpen={datePickerOpen} <div className="space-y-2">
onOpenChange={setDatePickerOpen} <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>
<FormMessage /> <span className="w-12 h-0.5 bg-primary/20 rounded-full" />
</FormItem> <span>{STEPS[currentStep].title}</span>
)}
/>
{/* 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> </div>
<InterestsGrid selectedInterests={field.value} onToggle={handleInterestToggle} /> <h2 className="text-3xl md:text-4xl xl:text-5xl font-black text-gray-900 dark:text-white tracking-tighter leading-[0.95] uppercase">
<FormMessage /> {STEPS[currentStep].description}
</FormItem> </h2>
)}
/>
{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> </div>
<Progress value={LOADING_STEPS[loadingStep].progress} className="h-1.5 bg-orange-200" />
</div>
)}
<div className="pt-4"> {/* Step content */}
<SubmitButton <div className="pt-2">
loading={loading}
disabled={formProgress < 100} {/* Step 0 — Dates */}
onCancel={handleCancel} {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> </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,208 +1,549 @@
import { useEffect, useState } from 'react'; import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { useParams } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import api, { Trip, Place } from '@/db/api'; import api, { Trip, Place, ItineraryDay } 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 { Loader2, Share2, Download, Copy, Calendar, MapPin, Clock } from 'lucide-react'; import {
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 } from 'date-fns'; import { format, addDays } from 'date-fns';
import { tr } from 'date-fns/locale'; import { tr } from 'date-fns/locale';
import { motion, AnimatePresence } from 'framer-motion';
import { import {
Sheet, Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger,
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) { if (!id) return;
loadTrip(id); (async () => {
} 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]);
const loadTrip = async (tripId: string) => { // ── Persist (debounced) ───────────────────────────────────────────────────
try { const scheduleWrite = useCallback((t: Trip) => {
const data = await api.getTripById(tripId); setSaveStatus('unsaved');
if (data) { if (saveTimer.current) clearTimeout(saveTimer.current);
setTrip(data); saveTimer.current = setTimeout(async () => {
} else { setSaveStatus('saving');
toast.error('Gezi bulunamadı'); try {
await api.updateTrip(t.id, { itinerary: t.itinerary });
setSaveStatus('saved');
} catch {
setSaveStatus('unsaved');
toast.error('Değişiklikler kaydedilemedi');
} }
} catch (error) { }, 1500);
console.error(error); }, []);
toast.error('Gezi yüklenemedi');
} finally {
setLoading(false);
}
};
const handleReorder = async (dayIndex: number, newItems: Place[]) => { // ── Apply an itinerary snapshot (used by all mutators + undo/redo) ────────
const applyItinerary = useCallback((itinerary: Trip['itinerary'], pushToHistory = true) => {
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; if (!trip) return;
applyItinerary({ ...trip.itinerary, days: fn([...trip.itinerary.days]) });
}, [trip, applyItinerary]);
const newItinerary = { ...trip.itinerary }; const handleReorder = (dayIndex: number, newItems: Place[]) => {
newItinerary.days[dayIndex].items = newItems; withDays(days => {
days[dayIndex] = { ...days[dayIndex], items: newItems };
return days;
});
};
// Recalculate distance and duration const handleAddPlace = (dayIndex: number, place: Place) => {
try { withDays(days => {
if (newItems.length > 1) { const day = days[dayIndex];
const origin = `${newItems[0].lat},${newItems[0].lng}`; const last = day.items[day.items.length - 1];
const destination = `${newItems[newItems.length - 1].lat},${newItems[newItems.length - 1].lng}`;
const waypoints = newItems.slice(1, -1).map(i => `${i.lat},${i.lng}`); // Auto-schedule start_time after last item ends
let startMin = 9 * 60;
const directions = await api.getDirections({ origin, destination, waypoints }); if (last) {
if (directions.routes && directions.routes[0]) { const [h, m] = (last.start_time || '09:00').split(':').map(Number);
const leg = directions.routes[0].legs.reduce((acc: any, curr: any) => ({ startMin = h * 60 + m + (last.estimated_duration_minutes || 60) + 20;
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üş`;
}
} }
} catch (error) { const toHHMM = (mins: number) =>
console.warn('Failed to calculate directions:', error); `${String(Math.floor(mins / 60) % 24).padStart(2, '0')}:${String(mins % 60).padStart(2, '0')}`;
}
setTrip({ ...trip, itinerary: newItinerary }); const newPlace = {
...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 api.updateTrip(trip.id, { itinerary: newItinerary }); await navigator.clipboard.writeText(window.location.href);
toast.success('Rota güncellendi'); toast.success('Link panoya kopyalandı');
} catch (error) { } catch {
toast.error('Değişiklikler kaydedilemedi'); toast.error('Kopyalama başarısız');
} }
}; };
const handleShare = () => { const handleDelete = async () => {
const url = window.location.href; if (!trip) return;
navigator.clipboard.writeText(url); try {
toast.success('Link kopyalandı'); await api.deleteTrip(trip.id);
toast.success('Gezi silindi');
navigate('/account');
} catch {
toast.error('Gezi silinemedi');
}
}; };
const handleExport = () => { // ── Computed ──────────────────────────────────────────────────────────────
toast.info('Dışa aktarma özelliği yakında eklenecek'); const getDayDate = useCallback((idx: number) => {
}; if (!trip) return '';
try {
return format(addDays(new Date(trip.start_date), idx), 'd MMM', { locale: tr });
} catch {
return '';
}
}, [trip]);
const handleDuplicate = () => { const dayStats = useMemo(() => {
toast.info('Kopyalama özelliği yakında eklenecek'); if (!trip) return null;
}; 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="flex h-screen items-center justify-center"> <div className="h-[calc(100vh-64px)] flex flex-col items-center justify-center gap-4 bg-background">
<Loader2 className="h-12 w-12 animate-spin text-primary" /> <div className="relative w-16 h-16">
<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 <div className="flex items-center justify-center min-h-screen">Gezi bulunamadı</div>; if (!trip) return null;
// Calculate trip stats const selectedDay = trip.itinerary.days[selectedDayIndex];
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-screen overflow-hidden"> <div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden bg-background">
{/* Header Area */}
<div className="bg-gradient-to-b from-gray-100 to-white border-b"> {/* ── Sub-header ──────────────────────────────────────────────────── */}
<div className="max-w-7xl mx-auto px-6 py-6"> <div className="h-14 border-b bg-white flex items-center px-4 gap-3 shrink-0 shadow-sm">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div className="flex-1"> {/* Left */}
<h1 className="text-3xl font-bold text-gray-900 mb-2">{trip.title}</h1> <div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1.5"> {/* Undo / Redo */}
<Calendar className="h-4 w-4" /> <div className="flex items-center gap-0.5 bg-gray-100 rounded-lg p-0.5">
<span> <button
{format(new Date(trip.start_date), 'd MMM', { locale: tr })} - {format(new Date(trip.end_date), 'd MMM yyyy', { locale: tr })} onClick={handleUndo}
</span> disabled={!history.canUndo}
</div> title="Geri al (Ctrl+Z)"
<Badge variant="secondary" className="font-normal"> className={cn(
{dayCount} Gün "h-7 w-7 rounded-md flex items-center justify-center transition-all",
</Badge> history.canUndo
<Badge variant="secondary" className="font-normal"> ? "text-gray-700 hover:bg-white hover:shadow-sm cursor-pointer"
{totalPlaces} Mekan : "text-gray-300 cursor-not-allowed"
</Badge> )}
{totalDistance > 0 && ( >
<Badge variant="secondary" className="font-normal"> <RotateCcw className="h-3.5 w-3.5" />
{totalDistance.toFixed(1)} km </button>
</Badge> <button
)} onClick={handleRedo}
</div> disabled={!history.canRedo}
</div> title="İleri al (Ctrl+Shift+Z)"
className={cn(
<div className="flex items-center gap-2"> "h-7 w-7 rounded-md flex items-center justify-center transition-all",
<Button variant="outline" size="sm" onClick={handleExport}> history.canRedo
<Download className="h-4 w-4 mr-2" /> ? "text-gray-700 hover:bg-white hover:shadow-sm cursor-pointer"
Dışa Aktar : "text-gray-300 cursor-not-allowed"
</Button> )}
<Button variant="outline" size="sm" onClick={handleShare}> >
<Share2 className="h-4 w-4 mr-2" /> <RotateCw className="h-3.5 w-3.5" />
Paylaş </button>
</Button>
<Button variant="outline" size="sm" onClick={handleDuplicate}>
<Copy className="h-4 w-4 mr-2" />
Kopyala
</Button>
</div>
</div> </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>
</AlertDialogTrigger>
<AlertDialogContent className="rounded-2xl">
<AlertDialogHeader>
<AlertDialogTitle className="font-bold">Geziyi sil?</AlertDialogTitle>
<AlertDialogDescription>
<strong>"{trip.title}"</strong> kalıcı olarak silinecek. Bu işlem geri alınamaz.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="rounded-xl font-bold">Vazgeç</AlertDialogCancel>
<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 className="flex flex-1 overflow-hidden"> {/* ── Main ──────────────────────────────────────────────────────────── */}
{/* Timeline Panel */} <main className="flex-1 flex overflow-hidden">
<div className="w-full lg:w-[40%] overflow-y-auto bg-white border-r">
<Timeline
itinerary={trip.itinerary}
onReorder={handleReorder}
onPlaceClick={(id) => setActivePlaceId(id)}
activePlaceId={activePlaceId}
/>
</div>
{/* Map Panel - Desktop */} {/* Day sidebar */}
<div className="hidden lg:block flex-1 relative bg-muted"> <aside className="w-16 md:w-[72px] border-r bg-gray-50/50 flex flex-col shrink-0 overflow-y-auto">
<TripMap <div className="py-4 flex flex-col items-center gap-2">
itinerary={trip.itinerary} <span className="text-[9px] font-black text-gray-400 uppercase tracking-widest">Günler</span>
activePlaceId={activePlaceId} <div className="flex flex-col gap-2 w-full px-2 mt-1">
onMarkerClick={(id) => setActivePlaceId(id)} {trip.itinerary.days.map((day, idx) => (
/> <motion.button
</div> 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>
))}
{/* Map Sheet - Mobile */} <button
<div className="lg:hidden fixed bottom-4 right-4 z-50"> 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}
onMarkerClick={(id) => {
setActivePlaceId(id);
document.getElementById(`place-${id}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}}
onAddPlace={(place) => handleAddPlace(selectedDayIndex, place)}
/>
{/* Stats overlay */}
<div className="absolute top-4 left-4 z-10 pointer-events-none">
<div className="bg-white/90 backdrop-blur-md px-4 py-3 rounded-2xl shadow-xl border border-white/50">
<p className="text-[9px] font-black text-gray-400 uppercase tracking-widest mb-2">Özet</p>
<div className="flex items-center gap-4">
<div>
<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 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 */}
<div className="lg:hidden fixed bottom-6 right-6 z-50">
<Sheet open={isMapSheetOpen} onOpenChange={setIsMapSheetOpen}> <Sheet open={isMapSheetOpen} onOpenChange={setIsMapSheetOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button size="lg" className="rounded-full shadow-lg"> <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">
<MapPin className="h-5 w-5 mr-2" /> <MapPin className="h-4 w-4" />
Haritayı Göster Haritayı
</Button> </Button>
</SheetTrigger> </SheetTrigger>
<SheetContent side="bottom" className="h-[70vh]"> <SheetContent side="bottom" className="h-[80vh] p-0 rounded-t-3xl overflow-hidden">
<SheetHeader> <SheetHeader className="p-4 border-b">
<SheetTitle>Rota Haritası</SheetTitle> <SheetTitle className="text-base font-bold">Rota Gün {selectedDay.day}</SheetTitle>
</SheetHeader> </SheetHeader>
<div className="h-full mt-4"> <div className="h-full relative">
<TripMap <TripMap
itinerary={trip.itinerary} itinerary={{ days: [selectedDay] }}
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>
@ -212,4 +553,4 @@ export default function TripDetailsPage() {
</main> </main>
</div> </div>
); );
} }

View File

@ -11,119 +11,300 @@ 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() .toLowerCase().trim()
.trim() .replace(/ğ/g, 'g').replace(/ü/g, 'u').replace(/ş/g, 's')
// Normalize Turkish characters to ASCII equivalents .replace(/ı/g, 'i').replace(/ö/g, 'o').replace(/ç/g, 'c')
.replace(/ğ/g, 'g') .replace(/Ğ/g, 'g').replace(/Ü/g, 'u').replace(/Ş/g, 's')
.replace(/ü/g, 'u') .replace(/İ/g, 'i').replace(/Ö/g, 'o').replace(/Ç/g, 'c')
.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 { startDate, endDate, interests, dailySchedule, preferences } = await req.json() const {
startDate,
endDate,
interests = [],
dailySchedule = 'moderate',
travelType = 'couple',
accommodation = 'center',
transport = 'mixed',
budget = 'moderate',
travelers = 2,
} = await req.json()
// Initialize Supabase client with service role const supabase = createClient(SUPABASE_URL!, SUPABASE_SERVICE_ROLE_KEY!)
const supabase = createClient(
SUPABASE_URL!,
SUPABASE_SERVICE_ROLE_KEY!
)
// 1. Call OpenAI to generate itinerary let itinerary
const systemPrompt = `You are an expert travel planner for Cappadocia, Turkey.
Generate a personalized travel itinerary based on user preferences. if (OPENAI_API_KEY && OPENAI_API_KEY !== 'PASTE_YOUR_OPENAI_API_KEY_HERE') {
Allowed regions: Göreme, Uçhisar, Ürgüp, Avanos, Ortahisar, Çavuşin, Derinkuyu, Kaymaklı. try {
Rules: const systemPrompt = `You are an expert travel planner for Cappadocia, Turkey.
- Balloon rides must be at sunrise (05:00 - 08:00) on Day 1 or 2. Generate a highly personalized travel itinerary strictly based on the user's preferences below.
- Sunset valley visits after 17:30.
- Max 5 places per day. === USER PROFILE ===
- No duplicate places. Travel Type: ${travelType} ${getTravelTypeGuidance(travelType)}
- Only return JSON in the specified format. Budget: ${budget} ${getBudgetGuidance(budget)}
- Interests: ${interests.join(', ')}. Transport: ${transport} ${getTransportGuidance(transport)}
- Daily Schedule: ${dailySchedule}. Group Size: ${travelers} people
Accommodation: ${accommodation}
Response Format: Daily Schedule pace: ${dailySchedule}
=== 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": [
{ {
"days": [ "day": 1,
"items": [
{ {
"day": 1, "place_name": "Göreme Open Air Museum",
"items": [ "category": "museum",
{ "estimated_duration_minutes": 120,
"place_name": "Göreme Open Air Museum", "description": "UNESCO World Heritage site with rock-cut churches."
"category": "museum",
"estimated_duration_minutes": 120,
"description": "UNESCO World Heritage site with rock-cut churches."
}
]
} }
] ]
}` }
]
}`
const userPrompt = `Trip Dates: ${startDate} to ${endDate}. Preferences: ${preferences}` const userPrompt = `Trip: ${startDate} to ${endDate}. Travelers: ${travelers}. Interests: ${interests.join(', ')}. Budget: ${budget}. Transport: ${transport}. Travel type: ${travelType}.`
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.3, temperature: 0.4,
response_format: { type: 'json_object' } response_format: { type: 'json_object' },
}), }),
}) })
const openaiData = await openaiRes.json() if (!openaiRes.ok) throw new Error(`OpenAI error: ${openaiRes.statusText}`)
const itinerary = JSON.parse(openaiData.choices[0].message.content)
// 2. Verify places with Google Places API (with cache layer) const openaiData = await openaiRes.json()
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)
// Check cache first const { data: cachedPlace } = await supabase
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 && !cacheError) { if (cachedPlace) {
// 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,
@ -132,71 +313,65 @@ serve(async (req) => {
lat: cachedPlace.lat, lat: cachedPlace.lat,
lng: cachedPlace.lng, lng: cachedPlace.lng,
rating: cachedPlace.rating, rating: cachedPlace.rating,
user_ratings_total: cachedPlace.user_ratings_total, photo_reference: cachedPlace.photo_reference,
photo_reference: cachedPlace.photo_reference
}) })
} else { } else {
// Cache MISS - fetching from Google and caching let placeInfo = null
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 (searchData.results && searchData.results.length > 0) { if (GOOGLE_MAPS_API_KEY && GOOGLE_MAPS_API_KEY !== 'PASTE_YOUR_GOOGLE_MAPS_API_KEY_HERE') {
const place = searchData.results[0] try {
const searchUrl = `https://maps.googleapis.com/maps/api/place/textsearch/json?query=${encodeURIComponent(item.place_name + ' Cappadocia')}&key=${GOOGLE_MAPS_API_KEY}`
// Store in cache for future use with normalized name const searchRes = await fetch(searchUrl)
const cacheEntry = { const searchData = await searchRes.json()
place_name_normalized: normalizedName,
place_id: place.place_id, if (searchData.results?.length > 0) {
name: place.name, const place = searchData.results[0]
formatted_address: place.formatted_address, placeInfo = {
lat: place.geometry.location.lat, place_id: place.place_id,
lng: place.geometry.location.lng, name: place.name,
rating: place.rating || null, formatted_address: place.formatted_address,
user_ratings_total: place.user_ratings_total || null, lat: place.geometry.location.lat,
photo_reference: place.photos?.[0]?.photo_reference || null 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)
} }
}
await supabase
.from('places_cache') if (placeInfo) {
.insert(cacheEntry) await supabase.from('places_cache').insert({
place_name_normalized: normalizedName,
verifiedItems.push({ ...placeInfo,
...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 })
} }
// 3. Add time slots (Simple heuristic) // ── Time slot assignment ───────────────────────────────────────────────────
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')) {
const start = "05:00" return { ...item, start_time: '05:00', end_time: '08: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) currentTime.setMinutes(currentTime.getMinutes() + (item.estimated_duration_minutes || 60))
const endTime = currentTime.toTimeString().slice(0, 5) const endTime = currentTime.toTimeString().slice(0, 5)
currentTime.setMinutes(currentTime.getMinutes() + 30) // 30 min travel/buffer currentTime.setMinutes(currentTime.getMinutes() + 30) // 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 }
@ -206,9 +381,10 @@ serve(async (req) => {
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,35 +44,62 @@ serve(async (req) => {
} }
} }
// Cache miss or expired - call Google Directions API // Cache miss or expired - call Google Directions API (if key exists)
console.log('Cache miss for:', cache_key) if (GOOGLE_MAPS_API_KEY && GOOGLE_MAPS_API_KEY !== 'PASTE_YOUR_GOOGLE_MAPS_API_KEY_HERE') {
let url = `https://maps.googleapis.com/maps/api/directions/json?origin=${origin}&destination=${destination}&key=${GOOGLE_MAPS_API_KEY}&mode=driving` try {
console.log('Cache miss for:', cache_key)
if (waypoints && waypoints.length > 0) { let url = `https://maps.googleapis.com/maps/api/directions/json?origin=${origin}&destination=${destination}&key=${GOOGLE_MAPS_API_KEY}&mode=driving`
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)
}
} }
const res = await fetch(url) // No key or error - return dummy response
const data = await res.json() const dummyResponse = {
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: "" }
}]
}
// Upsert the result into cache return new Response(JSON.stringify(dummyResponse), {
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,6 +10,12 @@ 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 })
@ -20,7 +26,9 @@ serve(async (req) => {
const photoReference = urlObj.searchParams.get('photo_reference') const photoReference = urlObj.searchParams.get('photo_reference')
if (!photoReference) { if (!photoReference) {
return new Response('Missing photo_reference', { status: 400 }) // If missing photo_reference, redirect to a random fallback image
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
@ -37,51 +45,57 @@ 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:', headError) console.log('File not in cache, fetching from Google...')
} }
// File doesn't exist in storage, fetch from Google // File doesn't exist in storage, fetch from Google (if key exists)
const googlePhotoUrl = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=800&photo_reference=${photoReference}&key=${GOOGLE_MAPS_API_KEY}` if (GOOGLE_MAPS_API_KEY && GOOGLE_MAPS_API_KEY !== 'PASTE_YOUR_GOOGLE_MAPS_API_KEY_HERE') {
try {
const googleResponse = await fetch(googlePhotoUrl) const googlePhotoUrl = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=800&photo_reference=${photoReference}&key=${GOOGLE_MAPS_API_KEY}`
if (!googleResponse.ok) { const googleResponse = await fetch(googlePhotoUrl)
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)
}
} }
// Get the image blob // If key is missing or fetch fails, redirect to a random fallback image
const imageBlob = await googleResponse.blob() const fallbackUrl = FALLBACK_PHOTO_URLS[Math.floor(Math.random() * FALLBACK_PHOTO_URLS.length)]
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)
return new Response(JSON.stringify({ error: error.message }), { // Always fallback to something visual
status: 500, const fallbackUrl = FALLBACK_PHOTO_URLS[Math.floor(Math.random() * FALLBACK_PHOTO_URLS.length)]
headers: { ...corsHeaders, 'Content-Type': 'application/json' }, return Response.redirect(fallbackUrl, 302)
})
} }
}) })

View File

@ -15,4 +15,9 @@ 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

@ -0,0 +1,9 @@
<!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.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

17
db/config.php Normal file
View File

@ -0,0 +1,17 @@
<?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;
}