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 { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { motion } from 'framer-motion';
interface AccommodationSelectorProps {
selectedId: string;
@ -10,34 +11,48 @@ interface AccommodationSelectorProps {
export const AccommodationSelector = memo(({ selectedId, onSelect }: AccommodationSelectorProps) => {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{ACCOMMODATION_OPTIONS.map((option) => {
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{ACCOMMODATION_OPTIONS.map((option, index) => {
const Icon = option.icon;
const isSelected = selectedId === option.id;
return (
<Button
<motion.div
key={option.id}
type="button"
variant="outline"
className={cn(
"h-auto py-3 flex items-center justify-start gap-3 border-2 transition-all duration-200",
isSelected
? "border-orange-500 bg-orange-50 text-orange-700 hover:bg-orange-100 hover:text-orange-800"
: "border-gray-100 hover:border-orange-200 hover:bg-orange-50/50"
)}
onClick={() => onSelect(option.id)}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.1 }}
>
<Icon className={cn(
"h-5 w-5",
isSelected ? "text-orange-600" : "text-gray-400"
)} />
<span className="text-sm font-medium">{option.label}</span>
</Button>
<Button
type="button"
variant="outline"
className={cn(
"h-auto p-6 w-full flex flex-col items-center justify-center gap-4 rounded-2xl border-2 transition-luxury group relative overflow-hidden",
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>
);
});
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 { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { motion } from 'framer-motion';
interface InterestsGridProps {
selectedInterests: string[];
@ -10,34 +11,48 @@ interface InterestsGridProps {
export const InterestsGrid = memo(({ selectedInterests, onToggle }: InterestsGridProps) => {
return (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{INTEREST_OPTIONS.map((interest) => {
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{INTEREST_OPTIONS.map((interest, index) => {
const Icon = interest.icon;
const isSelected = selectedInterests.includes(interest.id);
return (
<Button
<motion.div
key={interest.id}
type="button"
variant="outline"
className={cn(
"h-auto py-4 flex flex-col items-center gap-2 border-2 transition-all duration-200",
isSelected
? "border-orange-500 bg-orange-50 text-orange-700 hover:bg-orange-100 hover:text-orange-800"
: "border-gray-100 hover:border-orange-200 hover:bg-orange-50/50"
)}
onClick={() => onToggle(interest.id)}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
>
<Icon className={cn(
"h-6 w-6",
isSelected ? "text-orange-600" : "text-gray-500"
)} />
<span className="text-xs font-semibold">{interest.label}</span>
</Button>
<Button
type="button"
variant="outline"
className={cn(
"h-auto py-6 w-full flex flex-col items-center gap-3 rounded-2xl border-2 transition-luxury group relative overflow-hidden",
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>
);
});
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 { Button } from '@/components/ui/button';
import { Users, Minus, Plus } from 'lucide-react';
import { motion } from 'framer-motion';
interface TravelerInputProps {
value: number;
@ -9,31 +10,43 @@ interface TravelerInputProps {
export const TravelerInput = memo(({ value, onChange }: TravelerInputProps) => {
return (
<div className="flex items-center justify-between p-4 border border-gray-200 rounded-lg bg-gray-50/50">
<div className="flex items-center gap-3">
<Users className="h-5 w-5 text-gray-400" />
<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-4">
<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>
<p className="text-sm font-semibold text-gray-700">Kişi Sayısı</p>
<p className="text-xs text-gray-500">{value} Yetişkin</p>
<p className="text-lg font-black text-gray-900 dark:text-white tracking-tighter">Kişi Sayısı</p>
<p className="text-sm text-gray-400 font-medium italic">{value} Yetişkin Gezgin</p>
</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
type="button"
variant="outline"
variant="ghost"
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))}
disabled={value <= 1}
>
<Minus className="h-4 w-4" />
</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
type="button"
variant="outline"
variant="ghost"
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))}
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 { ItineraryDay } from '@/db/api';
import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
import { ItineraryDay, Place } from '@/db/api';
import { initGoogleMaps } from '@/lib/google-maps-loader';
import { Button } from '@/components/ui/button';
import { ZoomIn, ZoomOut, Maximize2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { ZoomIn, ZoomOut, Maximize2, Plus, Star, Clock, ChevronRight, Lightbulb, CheckCircle2, Loader2, X, MessageSquare } from 'lucide-react';
import api from '@/db/api';
import { cn } from '@/lib/utils';
import { motion, AnimatePresence } from 'framer-motion';
interface MapProps {
itinerary: { days: ItineraryDay[] };
activePlaceId: string | null;
onMarkerClick: (id: string) => void;
onAddPlace?: (place: Place) => void;
}
// Color palette for different days
const DAY_COLORS = [
'#3B82F6', // Blue - Day 1
'#10B981', // Green - Day 2
'#8B5CF6', // Purple - Day 3
'#F59E0B', // Amber - Day 4
'#EF4444', // Red - Day 5
'#EA580C',
'#2563EB',
'#059669',
'#7C3AED',
'#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 [googleMap, setGoogleMap] = useState<google.maps.Map | null>(null);
const [error, setError] = useState<string | null>(null);
const markersRef = useRef<{ [key: string]: { marker: google.maps.Marker; dayIndex: number } }>({});
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
const itineraryKey = useMemo(() =>
// Detail panel state
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))),
[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(() => {
const loadMap = async () => {
try {
const apiKey = import.meta.env.VITE_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.');
console.error('Google Maps API anahtarı eksik. Lütfen .env dosyasına VITE_GOOGLE_MAPS_API_KEY ekleyin.');
setError('Google Maps API anahtarı eksik.');
return;
}
await initGoogleMaps(apiKey);
if (mapRef.current && !googleMap) {
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,
styles: [
{
"featureType": "poi",
"elementType": "labels",
"stylers": [{ "visibility": "off" }]
}
{ featureType: 'poi.business', stylers: [{ visibility: 'off' }] },
{ featureType: 'poi.park', elementType: 'labels.text', stylers: [{ visibility: 'on' }] },
],
mapTypeControl: false,
streetViewControl: false,
fullscreenControl: 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);
infoWindowRef.current = new google.maps.InfoWindow();
}
} catch (error) {
const errorMsg = 'Google Maps yüklenemedi. API anahtarınızı kontrol edin.';
setError(errorMsg);
console.error('Google Maps yüklenemedi:', error);
} catch {
setError('Google Maps yüklenemedi.');
}
};
if (!googleMap) {
loadMap();
}
}, [googleMap]);
if (!googleMap) loadMap();
}, [googleMap, onAddPlace, fetchPlaceDetail]);
// ── Markers & polylines ───────────────────────────────────────────────────
useEffect(() => {
if (!googleMap || !itinerary?.days) return;
// Clear existing markers and polylines
Object.values(markersRef.current).forEach(({ marker }) => marker.setMap(null));
markersRef.current = {};
polylinesRef.current.forEach(polyline => polyline.setMap(null));
polylinesRef.current.forEach(p => p.setMap(null));
polylinesRef.current = [];
const bounds = new google.maps.LatLngBounds();
const infoWindow = new google.maps.InfoWindow();
// Create markers and polylines for each day
itinerary.days.forEach((day, dayIndex) => {
const dayColor = DAY_COLORS[dayIndex % DAY_COLORS.length];
const dayPath: google.maps.LatLngLiteral[] = [];
@ -97,71 +214,55 @@ export function TripMap({ itinerary, activePlaceId, onMarkerClick }: MapProps) {
day.items.forEach((item, itemIndex) => {
const position = { lat: item.lat, lng: item.lng };
dayPath.push(position);
const isActive = item.place_id === activePlaceId;
// Create custom marker with SVG
const marker = new google.maps.Marker({
position,
map: googleMap,
title: item.name,
label: {
text: (itemIndex + 1).toString(),
color: 'white',
fontSize: '12px',
fontWeight: 'bold',
},
zIndex: isActive ? 1000 : 1,
label: { text: (itemIndex + 1).toString(), color: 'white', fontSize: '11px', fontWeight: '900' },
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,
fillOpacity: 1,
strokeWeight: 3,
strokeWeight: 2,
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', () => {
onMarkerClick(item.place_id);
if (infoWindowRef.current) {
const photoUrl = item.photo_reference
? `${window.location.origin}/api/photo?reference=${item.photo_reference}`
: '';
infoWindowRef.current.setContent(`
<div style="color: black; max-width: 250px;">
${photoUrl ? `<img src="${photoUrl}" alt="${item.name}" style="width: 100%; height: 120px; object-fit: cover; border-radius: 8px; margin-bottom: 8px;" />` : ''}
<h4 style="font-weight: bold; margin-bottom: 4px; font-size: 14px;">${item.name}</h4>
<p style="font-size: 12px; color: #6B7280; margin-bottom: 6px;">${item.formatted_address}</p>
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="color: ${dayColor}; font-weight: bold;"> ${item.rating || 'N/A'}</span>
<span style="color: #6B7280; font-size: 12px;"> ${item.estimated_duration_minutes}dk</span>
</div>
<a
href="https://www.google.com/maps/search/?api=1&query=${item.lat},${item.lng}&query_place_id=${item.place_id}"
target="_blank"
rel="noopener noreferrer"
style="display: inline-block; background: ${dayColor}; color: white; padding: 6px 12px; border-radius: 6px; text-decoration: none; font-size: 12px; font-weight: 500;"
>
Google Maps'te
</a>
const photoUrl = item.photo_reference
? (item.photo_reference.startsWith('http') ? item.photo_reference : api.getPhotoUrl(item.photo_reference))
: '';
infoWindow.setContent(`
<div style="padding:8px;min-width:200px;font-family:sans-serif;">
${photoUrl ? `<img src="${photoUrl}" style="width:100%;height:90px;object-fit:cover;border-radius:8px;margin-bottom:8px;" />` : ''}
<h4 style="margin:0 0 2px;font-size:13px;font-weight:bold;">${item.name}</h4>
<p style="margin:0 0 6px;font-size:10px;color:#6B7280;">${item.category}</p>
<div style="display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:11px;font-weight:bold;color:#F59E0B;"> ${item.rating || 'N/A'}</span>
<a href="https://www.google.com/maps/dir/?api=1&destination=${item.lat},${item.lng}" target="_blank"
style="color:#EA580C;font-size:11px;font-weight:bold;text-decoration:none;">Yol Tarifi </a>
</div>
`);
infoWindowRef.current.open(googleMap, marker);
}
</div>
`);
infoWindow.open(googleMap, marker);
});
markersRef.current[item.place_id] = { marker, dayIndex };
bounds.extend(position);
});
// Create polyline for this day
if (dayPath.length > 1) {
const polyline = new google.maps.Polyline({
path: dayPath,
geodesic: true,
strokeColor: dayColor,
strokeOpacity: 0.7,
strokeWeight: 4,
path: dayPath, geodesic: true,
strokeColor: dayColor, strokeOpacity: 0.8, strokeWeight: 3,
});
polyline.setMap(googleMap);
polylinesRef.current.push(polyline);
@ -169,129 +270,332 @@ export function TripMap({ itinerary, activePlaceId, onMarkerClick }: MapProps) {
});
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(() => {
if (googleMap && activePlaceId && markersRef.current[activePlaceId]) {
const { marker, dayIndex } = markersRef.current[activePlaceId];
const dayColor = DAY_COLORS[dayIndex % DAY_COLORS.length];
googleMap.panTo(marker.getPosition()!);
googleMap.setZoom(14);
// Update all markers - highlight active one
Object.keys(markersRef.current).forEach(id => {
const { marker: m, dayIndex: dIdx } = markersRef.current[id];
const color = DAY_COLORS[dIdx % DAY_COLORS.length];
const isActive = id === activePlaceId;
m.setIcon({
path: google.maps.SymbolPath.CIRCLE,
fillColor: color,
fillOpacity: isActive ? 1 : 0.8,
strokeWeight: isActive ? 4 : 3,
strokeColor: '#ffffff',
scale: isActive ? 18 : 14,
});
m.setAnimation(isActive ? google.maps.Animation.BOUNCE : null);
if (!googleMap || !activePlaceId || !markersRef.current[activePlaceId]) return;
const { marker } = markersRef.current[activePlaceId];
googleMap.panTo(marker.getPosition()!);
Object.keys(markersRef.current).forEach(id => {
const { marker: m, dayIndex: dIdx } = markersRef.current[id];
const color = DAY_COLORS[dIdx % DAY_COLORS.length];
const isActive = id === activePlaceId;
m.setIcon({
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: color, fillOpacity: 1,
strokeWeight: isActive ? 3 : 2,
strokeColor: isActive ? '#000000' : '#ffffff',
scale: isActive ? 1.8 : 1.4,
anchor: new google.maps.Point(12, 22),
labelOrigin: new google.maps.Point(12, 9),
});
}
m.setZIndex(isActive ? 1000 : 1);
m.setAnimation(isActive ? google.maps.Animation.BOUNCE : null);
});
}, [activePlaceId, googleMap]);
const handleZoomIn = () => {
if (googleMap) {
googleMap.setZoom((googleMap.getZoom() || 12) + 1);
}
};
const handleZoomOut = () => {
if (googleMap) {
googleMap.setZoom((googleMap.getZoom() || 12) - 1);
}
};
const handleFitBounds = () => {
if (googleMap && Object.keys(markersRef.current).length > 0) {
const bounds = new google.maps.LatLngBounds();
Object.values(markersRef.current).forEach(({ marker }) => {
const pos = marker.getPosition();
if (pos) bounds.extend(pos);
});
googleMap.fitBounds(bounds);
}
};
// ── Render ────────────────────────────────────────────────────────────────
return (
<div className="relative w-full h-full">
<div className="relative w-full h-full bg-gray-50 overflow-hidden">
{error ? (
<div className="absolute inset-0 flex items-center justify-center bg-muted">
<div className="text-center p-8 max-w-md">
<div className="text-4xl mb-4">🗺</div>
<h3 className="text-lg font-semibold mb-2">Harita Yüklenemedi</h3>
<p className="text-sm text-muted-foreground mb-4">{error}</p>
<div className="text-xs text-left bg-card p-4 rounded-lg border">
<p className="font-mono mb-2">.env dosyasına ekleyin:</p>
<code className="text-primary">VITE_GOOGLE_MAPS_API_KEY=AIza...</code>
</div>
</div>
<div className="absolute inset-0 flex items-center justify-center">
<p className="text-sm font-medium text-gray-500">{error}</p>
</div>
) : (
<>
<div ref={mapRef} className="absolute inset-0 w-full h-full" />
{/* Map Controls */}
<div className="absolute top-4 right-4 flex flex-col gap-2">
<Button
variant="secondary"
size="icon"
className="bg-white shadow-lg hover:bg-gray-50"
onClick={handleZoomIn}
>
<ZoomIn className="h-4 w-4" />
</Button>
<Button
variant="secondary"
size="icon"
className="bg-white shadow-lg hover:bg-gray-50"
onClick={handleZoomOut}
>
<ZoomOut className="h-4 w-4" />
</Button>
<Button
variant="secondary"
size="icon"
className="bg-white shadow-lg hover:bg-gray-50"
onClick={handleFitBounds}
>
{/* İpucu */}
{onAddPlace && !selectedPOI && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 pointer-events-none">
<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">
<Plus className="h-3.5 w-3.5 text-orange-500 shrink-0" />
<span className="text-[11px] font-bold text-gray-600">Haritada bir yere tıklayarak detayları görün</span>
</div>
</div>
)}
{/* Zoom */}
<div className="absolute top-4 right-4 flex flex-col gap-2 z-10">
<div className="flex flex-col bg-white border rounded-lg shadow-sm overflow-hidden">
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-none border-b"
onClick={() => googleMap?.setZoom((googleMap.getZoom() || 12) + 1)}>
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-none"
onClick={() => googleMap?.setZoom((googleMap.getZoom() || 12) - 1)}>
<ZoomOut className="h-4 w-4" />
</Button>
</div>
<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" />
</Button>
</div>
{/* Day Legend */}
<div className="absolute top-4 left-4 bg-white/95 backdrop-blur-sm rounded-lg shadow-lg p-3">
<div className="space-y-2">
{itinerary.days.map((day, index) => (
<div key={day.day} className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: DAY_COLORS[index % DAY_COLORS.length] }}
/>
<span className="text-xs font-medium text-gray-700">
Gün {day.day}
</span>
<Badge variant="secondary" className="text-xs font-normal">
{day.items.length}
</Badge>
{/* ── Detay Paneli ───────────────────────────────────────────────── */}
<AnimatePresence>
{selectedPOI && (
<motion.div
initial={{ x: '100%', opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: '100%', opacity: 0 }}
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"
>
{/* Fotoğraf header */}
<div className="relative h-44 shrink-0 bg-gray-100">
{selectedPOI.photoUrl ? (
<img
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 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>
);
}
}

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 { Card, CardContent } from '@/components/ui/card';
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 {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
Star, Clock, MapPin, GripVertical, Car, Trash2, Edit3,
MessageSquare, MoreVertical, Sun, Sunset, Moon, Coffee,
Package, Wand2
} from 'lucide-react';
import api from '@/db/api';
import { useState, useMemo } from 'react';
import {
DndContext, closestCenter, KeyboardSensor, PointerSensor,
useSensor, useSensors, DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
arrayMove, SortableContext, sortableKeyboardCoordinates,
verticalListSortingStrategy, useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
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 {
itinerary: { days: ItineraryDay[] };
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;
activePlaceId: string | null;
dayStartDate?: string;
}
export function Timeline({ itinerary, onReorder, onPlaceClick, activePlaceId }: TimelineProps) {
// ────────────────────────────────────────────────────────────────────────────
// Timeline root
// ────────────────────────────────────────────────────────────────────────────
export function Timeline(props: TimelineProps) {
return (
<div className="p-6 space-y-8">
{itinerary.days.map((day, dayIndex) => (
<DaySection
key={day.day}
day={day}
dayIndex={dayIndex}
onReorder={onReorder}
onPlaceClick={onPlaceClick}
activePlaceId={activePlaceId}
/>
<div className="p-4 md:p-6 space-y-8">
{props.itinerary.days.map((day, dayIndex) => (
<DaySection key={day.day} {...props} day={day} dayIndex={dayIndex} />
))}
</div>
);
}
function DaySection({ day, dayIndex, onReorder, onPlaceClick, activePlaceId }: {
day: ItineraryDay;
dayIndex: number;
onReorder: (dayIndex: number, newItems: Place[]) => void;
onPlaceClick: (id: string) => void;
activePlaceId: string | null;
}) {
// ────────────────────────────────────────────────────────────────────────────
// DaySection
// ────────────────────────────────────────────────────────────────────────────
function DaySection({
day, dayIndex,
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(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = day.items.findIndex((i) => i.place_id === active.id);
const newIndex = day.items.findIndex((i) => i.place_id === over.id);
const newItems = arrayMove(day.items, oldIndex, newIndex);
onReorder(dayIndex, newItems);
const oldIdx = day.items.findIndex(i => i.place_id === active.id);
const newIdx = day.items.findIndex(i => i.place_id === over.id);
onReorder(dayIndex, arrayMove(day.items, oldIdx, newIdx));
}
};
// Calculate estimated travel times between places (memoized to prevent re-renders)
const travelTimes = useMemo(() => {
const times: { [key: number]: number } = {};
day.items.forEach((_, index) => {
if (index > 0 && index < day.items.length) {
// Rough estimate: 5-15 minutes between places
times[index] = Math.floor(Math.random() * 10) + 5;
}
});
return times;
}, [day.items.length]);
const grouped = useMemo(() => {
const segments: Record<string, Place[]> = {};
for (const item of day.items) {
const seg = getSegment(item.start_time || '09:00');
if (!segments[seg]) segments[seg] = [];
segments[seg].push(item);
}
return segments;
}, [day.items]);
const segmentOrder: Array<'morning' | 'afternoon' | 'evening'> = ['morning', 'afternoon', 'evening'];
return (
<div className="space-y-4">
{/* Day Header with gradient divider */}
<div className="sticky top-0 bg-white/95 backdrop-blur-sm z-10 pb-3 border-b">
<div className="flex items-center justify-between mb-2">
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
Gün {day.day}
<Badge variant="secondary" className="font-normal text-sm">
{day.items.length} Mekan
</Badge>
</h2>
</div>
{(day.total_distance || day.total_duration) && (
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{day.total_distance && (
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
{day.total_distance}
</span>
)}
{day.total_duration && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{day.total_duration}
</span>
)}
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} className="space-y-5">
{/* Day note */}
<div className="px-1">
{isEditingNote ? (
<div className="space-y-2">
<Textarea
value={noteText}
onChange={e => setNoteText(e.target.value)}
placeholder="Bugün için notlarınızı yazın..."
className="bg-amber-50 border-amber-200 rounded-xl text-sm font-medium min-h-[72px] focus:ring-orange-600/20 focus:border-orange-600"
/>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setIsEditingNote(false)} className="h-7 text-[10px] font-bold">Vazgeç</Button>
<Button size="sm" className="h-7 bg-orange-600 text-white text-[10px] font-bold px-3 rounded-lg"
onClick={() => { onUpdateDayNote(dayIndex, noteText); setIsEditingNote(false); }}>
Kaydet
</Button>
</div>
</div>
) : (
<button
onClick={() => setIsEditingNote(true)}
className="w-full flex items-start gap-3 p-2.5 rounded-xl hover:bg-gray-50 transition-all group text-left"
>
<MessageSquare className="h-4 w-4 text-gray-300 group-hover:text-orange-500 mt-0.5 transition-colors shrink-0" />
{day.notes ? (
<p className="text-sm font-medium text-gray-600 italic">"{day.notes}"</p>
) : (
<span className="text-[11px] font-bold text-gray-400 uppercase tracking-widest group-hover:text-orange-500 transition-colors">
Bugüne not ekle...
</span>
)}
</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>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<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>
))}
{/* Empty state */}
{day.items.length === 0 && (
<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">
<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" />
</div>
</SortableContext>
</DndContext>
</div>
<div className="text-center">
<p className="text-sm font-bold text-gray-500">Henüz durak yok</p>
<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 {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({ id: item.place_id });
// ────────────────────────────────────────────────────────────────────────────
// SortableItem — Wanderlog stili küçük thumbnail
// ────────────────────────────────────────────────────────────────────────────
function SortableItem({
item, index, isActive, onClick, onDelete, onUpdateNote
}: {
item: Place;
index: number;
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 = {
transform: CSS.Transform.toString(transform),
transition,
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 (
<div ref={setNodeRef} style={style} className="group relative">
<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"
)}
<div ref={setNodeRef} style={style}>
<Card
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">
{/* Image Section - 128px square */}
<div className="w-32 h-32 shrink-0 relative overflow-hidden">
{photoUrl ? (
<img
src={photoUrl}
alt={item.name}
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>
<CardContent className="p-3">
{/* Ana satır */}
<div className="flex items-start gap-2">
{/* Index numarası */}
<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">
{index + 1}
</div>
{/* Rating badge - top right */}
{item.rating && (
<div className="absolute top-2 right-2">
<Badge className="bg-primary/90 backdrop-blur-sm text-white border-none hover:bg-primary shadow-sm">
<Star className="h-3 w-3 fill-white mr-1" />
{item.rating}
{/* Bilgi alanı */}
<div className="flex-1 min-w-0">
<h4 className="text-sm font-black text-gray-900 truncate">{item.name}</h4>
<div className="flex items-center gap-1.5 mt-0.5 flex-wrap">
{item.start_time && (
<span className="text-[10px] font-bold text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded-md">
{item.start_time} {endTime}
</span>
)}
<Badge variant="secondary" className="bg-gray-100 text-gray-500 text-[9px] font-bold px-1.5 py-0 h-4 border-0">
{item.category}
</Badge>
{item.estimated_duration_minutes && (
<span className="flex items-center gap-0.5 text-[9px] font-bold text-gray-400">
<Clock className="h-2.5 w-2.5" />
{item.estimated_duration_minutes}dk
</span>
)}
{item.rating && (
<span className="flex items-center gap-0.5 text-[9px] font-bold text-gray-400">
<Star className="h-2.5 w-2.5 fill-amber-400 text-amber-400" />
{item.rating}
</span>
)}
</div>
</div>
{/* Küçük thumbnail */}
{photoUrl && !imgError && (
<div className="relative w-16 h-16 rounded-xl overflow-hidden bg-gray-100 shrink-0">
{!imgLoaded && (
<div className="absolute inset-0 bg-gradient-to-r from-gray-200 via-gray-100 to-gray-200 animate-pulse" />
)}
<img
src={photoUrl}
alt={item.name}
onLoad={() => setImgLoaded(true)}
onError={() => setImgError(true)}
className={cn(
"w-full h-full object-cover transition-opacity duration-300",
imgLoaded ? "opacity-100" : "opacity-0"
)}
/>
</div>
)}
{/* 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>
{/* Content Section */}
<div className="flex-1 p-4 flex flex-col justify-between min-w-0">
<div>
<div className="flex items-start justify-between gap-2 mb-1">
<h4 className="font-bold text-base text-gray-900 line-clamp-1 flex-1">{item.name}</h4>
<div
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 hover:bg-gray-100 rounded transition-colors opacity-0 group-hover:opacity-100"
>
<GripVertical className="h-4 w-4 text-gray-400" />
{/* Not düzenleme alanı */}
<AnimatePresence>
{isEditingNote && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mt-3 space-y-2 overflow-hidden"
onClick={e => e.stopPropagation()}
>
<Textarea
value={note}
onChange={e => setNote(e.target.value)}
placeholder="Not ekle..."
className="bg-gray-50 border-gray-200 rounded-xl text-xs font-medium min-h-[60px]"
autoFocus
/>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setIsEditingNote(false)} className="h-6 text-[10px] font-bold">Vazgeç</Button>
<Button size="sm" className="h-6 bg-orange-600 text-white text-[10px] font-bold px-3 rounded-lg"
onClick={() => { onUpdateNote(note); setIsEditingNote(false); }}>
Kaydet
</Button>
</div>
</div>
<p className="text-sm text-muted-foreground line-clamp-2 leading-relaxed">{item.description}</p>
</motion.div>
)}
</AnimatePresence>
{/* Mevcut not gösterimi */}
{!isEditingNote && item.notes && (
<div className="mt-2.5 p-2 bg-orange-50 rounded-xl border-l-2 border-orange-500">
<p className="text-[11px] font-medium italic text-orange-800">{item.notes}</p>
</div>
{/* 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>
</Card>
</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 = [
{ id: 'hotel', label: 'Otel', icon: Hotel },
{ id: 'airbnb', label: 'Airbnb / Adres', icon: Home },
{ id: 'center', label: 'Merkezden Başla', icon: Navigation },
import { LOADING_STEPS, TRAVEL_TYPE_OPTIONS, BUDGET_OPTIONS, TRANSPORT_OPTIONS, ACCOMMODATION_OPTIONS, INTEREST_OPTIONS } from '@/constants/planner';
import { DateSelector } from '@/components/planner/DateSelector';
import { TravelerInput } from '@/components/planner/TravelerInput';
import { AccommodationSelector } from '@/components/planner/AccommodationSelector';
import { InterestsGrid } from '@/components/planner/InterestsGrid';
import { TravelTypeSelector } from '@/components/planner/TravelTypeSelector';
import { TransportSelector } from '@/components/planner/TransportSelector';
import { BudgetSelector } from '@/components/planner/BudgetSelector';
// ─── Schema ───────────────────────────────────────────────────────────────────
const formSchema = z.object({
dateRange: z.object({
from: z.date({ required_error: 'Başlangıç tarihi gereklidir' }),
to: z.date({ required_error: 'Bitiş tarihi gereklidir' }),
})
.refine(d => d.from >= new Date(new Date().setHours(0, 0, 0, 0)), {
message: 'Başlangıç tarihi bugünden önce olamaz',
})
.refine(d => d.to > d.from, {
message: 'Bitiş tarihi başlangıç tarihinden sonra olmalıdır',
})
.refine(d => {
const days = differenceInDays(d.to, d.from) + 1;
return days >= 1 && days <= 14;
}, { message: 'Seyahat süresi 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;
export const INTEREST_OPTIONS = [
{ id: 'balloon', label: 'Sıcak Hava Balonu', icon: Plane },
{ id: 'nature', label: 'Doğa ve Yürüyüş', icon: Trees },
{ id: 'history', label: 'Tarih ve Kültür', icon: Building2 },
{ id: 'photography', label: 'Fotoğraf Çekimi', icon: Camera },
{ id: 'adventure', label: 'Aktivite/Macera', icon: Bike },
{ id: 'gastronomy', label: 'Gastronomi', icon: UtensilsCrossed },
] 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;
}
}
export const LOADING_STEPS = [
{ label: 'Form hazırlanıyor...', progress: 0 },
{ label: 'Rotanız oluşturuluyor...', progress: 33 },
{ label: 'Mekanlar belirleniyor...', progress: 66 },
{ label: 'Son kontroller yapılıyor...', progress: 90 },
] as const;
// ─── PlannerPage ──────────────────────────────────────────────────────────────
const PlannerPage = () => {
const { user } = useAuth();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [loadingStep, setLoadingStep] = useState(0);
const [currentStep, setCurrentStep] = useState(0);
const [datePickerOpen, setDatePickerOpen] = useState(false);
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;
start_time: string;
end_time: string;
notes?: string;
}
export interface ItineraryDay {
@ -20,6 +21,7 @@ export interface ItineraryDay {
items: Place[];
total_distance?: string;
total_duration?: string;
notes?: string;
}
export interface Trip {
@ -90,7 +92,11 @@ const api = {
endDate: string;
interests: string[];
dailySchedule: string;
preferences: string;
travelType: string;
accommodation: string;
transport: string;
budget: string;
travelers: number;
}) {
const { data, error } = await supabase.functions.invoke('generate-itinerary', {
body: params,
@ -121,4 +127,4 @@ const api = {
}
};
export default api;
export default api;

View File

@ -4,35 +4,35 @@
@layer base {
:root {
/* Backgrounds */
--background: 0 0% 98%;
--foreground: 222 47% 11%;
/* Backgrounds - Warmer White */
--background: 30 20% 98%;
--foreground: 24 30% 10%;
/* Card & Surfaces */
--card: 0 0% 100%;
--card-foreground: 222 47% 11%;
--card-foreground: 24 30% 10%;
--popover: 0 0% 100%;
--popover-foreground: 222 47% 11%;
--popover-foreground: 24 30% 10%;
/* Primary - Blue (#3B82F6) */
--primary: 217 91% 60%;
/* Primary - Cappadocia Terracotta (#E36414) */
--primary: 24 85% 50%;
--primary-foreground: 0 0% 100%;
--primary-dark: 221 83% 53%;
--primary-dark: 24 85% 40%;
/* Secondary - Pink (#EC4899) */
--secondary: 328 86% 70%;
/* Secondary - Deep Slate Blue for contrast (#1B263B) */
--secondary: 220 37% 17%;
--secondary-foreground: 0 0% 100%;
/* Muted & Neutral */
--muted: 220 14% 96%;
--muted-foreground: 220 9% 46%;
--muted: 30 20% 94%;
--muted-foreground: 24 10% 45%;
/* Accent - Very light blue */
--accent: 217 91% 95%;
--accent-foreground: 217 91% 30%;
/* Accent - Golden Hour (#FFB703) */
--accent: 45 100% 51%;
--accent-foreground: 24 30% 10%;
/* Status Colors */
--destructive: 0 84.2% 60.2%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--success: 142 71% 45%;
--success-foreground: 0 0% 100%;
@ -42,41 +42,45 @@
--info-foreground: 0 0% 100%;
/* Borders & Inputs */
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 217 91% 60%;
--border: 24 20% 90%;
--input: 24 20% 90%;
--ring: 24 85% 50%;
/* Radius */
--radius: 1rem;
/* Radius - Standard but soft */
--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 {
/* Backgrounds */
--background: 222 47% 11%;
--foreground: 0 0% 98%;
/* Backgrounds - Deep Night Blue/Grey */
--background: 224 45% 8%;
--foreground: 30 20% 98%;
/* Card & Surfaces */
--card: 222 47% 13%;
--card-foreground: 0 0% 98%;
--popover: 222 47% 13%;
--popover-foreground: 0 0% 98%;
--card: 224 45% 12%;
--card-foreground: 30 20% 98%;
--popover: 224 45% 12%;
--popover-foreground: 30 20% 98%;
/* Primary - Blue */
--primary: 217 91% 60%;
/* Primary - Stays consistent but slightly more vibrant */
--primary: 24 85% 55%;
--primary-foreground: 0 0% 100%;
--primary-dark: 221 83% 53%;
--primary-dark: 24 85% 45%;
/* Secondary - Pink */
--secondary: 328 86% 70%;
--secondary-foreground: 0 0% 100%;
/* Secondary */
--secondary: 224 30% 20%;
--secondary-foreground: 30 20% 98%;
/* Muted & Neutral */
--muted: 217 33% 17%;
--muted-foreground: 215 20% 65%;
--muted: 224 30% 15%;
--muted-foreground: 224 15% 70%;
/* Accent - Dark blue */
--accent: 217 91% 20%;
--accent-foreground: 217 91% 85%;
/* Accent */
--accent: 45 100% 60%;
--accent-foreground: 224 45% 8%;
/* Status Colors */
--destructive: 0 62.8% 30.6%;
@ -89,9 +93,11 @@
--info-foreground: 0 0% 100%;
/* Borders & Inputs */
--border: 217 33% 17%;
--input: 217 33% 17%;
--ring: 217 91% 60%;
--border: 224 30% 18%;
--input: 224 30% 18%;
--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;
}
body {
@apply bg-background text-foreground antialiased;
@apply bg-background text-foreground antialiased selection:bg-primary/20 selection:text-primary;
}
}
.gradient-text {
background: linear-gradient(135deg, hsl(var(--primary)), hsl(var(--secondary)));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
/* Custom Utilities */
.glass {
@apply bg-white/10 backdrop-blur-xl border border-white/20;
}
.cappadocia-bg {
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');
.glass-dark {
@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-position: center;
background-attachment: fixed;
}
.glassmorphism {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.3);
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.dark .glassmorphism {
background: rgba(31, 41, 55, 0.85);
border: 1px solid rgba(255, 255, 255, 0.1);
::-webkit-scrollbar-track {
@apply bg-transparent;
}
@keyframes float {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
::-webkit-scrollbar-thumb {
@apply bg-muted-foreground/20 rounded-full hover:bg-muted-foreground/40 transition-colors;
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
/* 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));
}
/* Smooth Transitions */
.transition-luxury {
transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1);
}

View File

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

View File

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

View File

@ -1,170 +1,268 @@
import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Sparkles, MapPin, Calendar, Compass, ShieldCheck, Zap, ArrowRight, Heart } from 'lucide-react';
import { motion } from 'framer-motion';
import { Sparkles, MapPin, Calendar, Compass, ShieldCheck, Zap, ArrowRight, Star, ArrowUpRight, Play, Globe } from 'lucide-react';
import { motion, useScroll, useTransform } from 'framer-motion';
import { useRef } from 'react';
const FeatureCard = ({ icon: Icon, title, description }: { icon: any, title: string, description: string }) => (
<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">
<div className="w-14 h-14 bg-orange-100 rounded-xl flex items-center justify-center mb-6">
<Icon className="h-7 w-7 text-orange-600" />
const FeatureCard = ({ icon: Icon, title, description, index }: { icon: any, title: string, description: string, index: number }) => (
<motion.div
initial={{ opacity: 0, y: 20 }}
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>
<h3 className="text-xl font-bold text-gray-900 mb-3">{title}</h3>
<p className="text-gray-600 leading-relaxed">{description}</p>
</div>
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-3 tracking-tight">{title}</h3>
<p className="text-gray-600 dark:text-gray-400 leading-relaxed text-sm font-medium">{description}</p>
</motion.div>
);
export default function LandingPage() {
return (
<div className="min-h-screen bg-[#FFF9F5]">
{/* Hero Section */}
<section className="relative pt-32 pb-20 lg:pt-48 lg:pb-32 overflow-hidden">
{/* 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" />
const containerRef = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: containerRef,
offset: ["start start", "end end"]
});
<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
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
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"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.8, ease: "easeOut" }}
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" />
Yapay Zeka Destekli Gezi Planlayıcısı
<Sparkles className="h-3.5 w-3.5 text-accent" />
Yapay Zeka Destekli Premium Deneyim
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.1 }}
className="text-5xl lg:text-7xl font-extrabold text-gray-900 mb-8 leading-[1.1] tracking-tight"
transition={{ duration: 0.8, delay: 0.2 }}
className="text-5xl md:text-7xl lg:text-8xl font-black text-white mb-8 leading-[0.95] tracking-tighter"
>
Kapadokya'yı <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-orange-600 to-amber-500">
Yeniden Keşfedin
KAPADOKYA <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary via-accent to-primary animate-gradient">
EFSANESİ
</span>
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
className="text-xl text-gray-600 max-w-2xl mx-auto mb-12 leading-relaxed"
transition={{ duration: 0.8, delay: 0.4 }}
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.div
initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 30 }}
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"
>
<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">
Hemen Planla
<ArrowRight className="ml-2 h-5 w-5" />
Hemen Keşfet
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
</Link>
</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>
<Link to="/explore">Keşfetmeye Başla</Link>
</Button>
<button className="h-14 px-8 text-lg font-bold bg-white/10 backdrop-blur-xl border border-white/20 text-white rounded-2xl hover:bg-white/20 transition-luxury flex items-center gap-2">
<Play className="h-5 w-5 fill-white" />
Tanıtımı İzle
</button>
</motion.div>
</div>
{/* Social Proof */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1, delay: 0.8 }}
className="mt-20 flex flex-col items-center gap-4"
>
<div className="flex -space-x-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="w-12 h-12 rounded-full border-4 border-[#FFF9F5] bg-gray-200 overflow-hidden shadow-sm">
<img src={`https://i.pravatar.cc/150?u=${i}`} alt="user" />
{/* Scroll Indicator */}
<motion.div
animate={{ y: [0, 8, 0] }}
transition={{ repeat: Infinity, duration: 2 }}
className="absolute bottom-8 left-1/2 -translate-x-1/2 text-white/40"
>
<div className="w-5 h-8 border-2 border-white/20 rounded-full flex justify-center p-1">
<div className="w-1 h-1 bg-white/40 rounded-full" />
</div>
</motion.div>
</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 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">
+2k
</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 className="text-3xl md:text-4xl font-black text-primary">{stat.value}</div>
<div className="text-white/60 font-bold tracking-widest uppercase text-[10px]">{stat.label}</div>
</motion.div>
))}
</div>
</div>
</section>
{/* Features Grid */}
<section className="py-24 bg-white/40">
<div className="max-w-7xl mx-auto px-6">
<div className="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>
<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>
<section className="py-20 bg-background relative overflow-hidden">
<div className="container px-6">
<div className="max-w-2xl mx-auto text-center mb-16 space-y-4">
<motion.span
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 className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
<FeatureCard
index={0}
icon={Zap}
title="Yapay Zeka Hızı"
description="OpenAI altyapısıyla saniyeler içinde seyahat sürenize ve ilgi alanlarınıza özel rota oluşturun."
title="Yapay Zeka Mimari"
description="OpenAI 4o-mini altyapısıyla her detayı düşünülmüş, tutarlı ve akıcı bir seyahat programı."
/>
<FeatureCard
index={1}
icon={ShieldCheck}
title="Google Doğrulaması"
description="Önerilen tüm mekanlar Google Places API ile doğrulanır; güncel fotoğraflar ve gerçek yorumlarla sunulur."
title="Premium Veri"
description="Google Places verileriyle gerçek fotoğraflar, anlık çalışma saatleri ve güvenilir yorumlar."
/>
<FeatureCard
index={2}
icon={MapPin}
title="Akıllı Lojistik"
description="Google Directions API ile rotanızdaki mesafe ve süreler otomatik hesaplanır, zamanınız boşa gitmez."
title="Akıllı Navigasyon"
description="Sadece rota değil, Google Directions ile en mantıklı sıralama ve ulaşım süreleri."
/>
<FeatureCard
index={3}
icon={Compass}
title="Gizli Cevherler"
description="Sadece turistik yerleri değil, Kapadokya'nın saklı kalmış eşsiz köşelerini de keşfedin."
title="Kürasyon"
description="Yerel rehberlerin gizli favorileri ve turistik kalabalıkların ötesindeki özel duraklar."
/>
<FeatureCard
index={4}
icon={Calendar}
title="Esnek Yönetim"
description="Planınızı dilediğiniz gibi düzenleyin, sürükle-bırak özelliğiyle mekanların sırasını değiştirin."
title="Dinamik Kontrol"
description="Planınızı dilediğiniz zaman sürükle-bırak yöntemiyle revize edin, her an güncel kalın."
/>
<FeatureCard
icon={Heart}
title="Ücretsiz ve Hızlı"
description="Herhangi bir gizli ücret yok. En iyi Kapadokya deneyimi için tasarlandı."
index={5}
icon={Sparkles}
title="Modern Estetik"
description="En yüksek standartlarda kullanıcı deneyimi için tasarlanmış akıcı ve şık arayüz."
/>
</div>
</div>
</section>
{/* CTA Section */}
<section className="py-24 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">
{/* Decorative circles */}
<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" />
<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" />
{/* Luxury CTA */}
<section className="py-20 px-6">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
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">
<h2 className="text-4xl lg:text-5xl font-bold text-white leading-tight">
Maceranıza Başlamak İçin <br /> Hazır mısınız?
<h2 className="text-4xl lg:text-7xl font-black text-white leading-[0.95] tracking-tighter uppercase">
BİR SONRAKİ <br /> <span className="text-primary">EFSANENİZİ</span> YAZIN
</h2>
<p className="text-white/80 text-lg max-w-xl mx-auto">
Kapadokya'nın büyüleyici atmosferine adım atmadan önce rotanızı profesyonelce hazırlayın.
<p className="text-white/60 text-lg md:text-xl max-w-xl mx-auto font-medium leading-relaxed">
Kapadokya'nın zamansız ruhunu, modern teknolojinin gücüyle birleştirin. Bugün başlayın.
</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>
<Link to="/planner">Rotamı Oluştur</Link>
</Button>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 pt-4">
<Button size="lg" className="h-16 px-10 text-xl font-bold bg-primary hover:bg-primary-dark rounded-2xl shadow-2xl shadow-primary/20 transition-luxury group" asChild>
<Link to="/planner" 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>
</motion.div>
</section>
{/* Footer */}
<footer className="py-12 border-t bg-white">
<div className="max-w-7xl mx-auto px-6 text-center text-gray-500 text-sm">
<p>© 2026 Cappadocia AI Travel Planner. Tüm hakları saklıdır.</p>
{/* Modern Footer */}
<footer className="py-16 border-t border-border bg-background">
<div className="container px-6">
<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>
</footer>
</div>
);
}
}

View File

@ -1,12 +1,13 @@
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 { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
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() {
const [isLogin, setIsLogin] = useState(true);
@ -20,7 +21,6 @@ export default function LoginPage() {
const location = useLocation();
const from = location.state?.from || '/explore';
// Eğer kullanıcı zaten giriş yapmışsa yönlendir
if (user) {
navigate(from, { replace: true });
}
@ -29,7 +29,6 @@ export default function LoginPage() {
e.preventDefault();
if (!username || !password) return;
// Kullanıcı adı validasyonu
if (!/^[a-z0-9_]+$/.test(username)) {
toast.error('Kullanıcı adı sadece harf, rakam ve alt çizgi içerebilir');
return;
@ -61,232 +60,172 @@ export default function LoginPage() {
throw error;
}
toast.success('Hesap oluşturuldu! Giriş yapılıyor...');
// Otomatik giriş yap
const { error: signInError } = await signInWithUsername(username, password);
if (!signInError) {
navigate(from, { replace: true });
}
}
} catch (error: any) {
const errorMessage = error.message || 'Bir hata oluştu. Lütfen tekrar deneyin.';
toast.error(errorMessage);
toast.error(error.message || 'Bir hata oluştu');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex">
{/* Sol Taraf - Hero Section (Sadece Desktop) */}
<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">
{/* Dekoratif arka plan */}
<div className="absolute inset-0 opacity-10">
<div className="absolute top-20 left-20 w-72 h-72 bg-white rounded-full blur-3xl" />
<div className="absolute bottom-20 right-20 w-96 h-96 bg-white rounded-full blur-3xl" />
<div className="min-h-screen flex bg-background selection:bg-primary/20">
{/* Left Side - Cinematic Hero */}
<div className="hidden lg:flex lg:w-[50%] xl:w-[55%] relative overflow-hidden group">
<div className="absolute inset-0 z-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="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 className="relative z-10">
{/* Logo */}
<div className="flex items-center space-x-3 mb-16">
<div className="p-3 bg-white/20 backdrop-blur-sm rounded-2xl">
<MapPin className="h-8 w-8" />
<div className="relative z-20 w-full p-12 xl:p-16 flex flex-col justify-between">
<Link to="/" className="flex items-center gap-3 group/logo">
<div className="w-12 h-12 bg-primary rounded-xl flex items-center justify-center text-white shadow-xl shadow-primary/40 group-hover/logo:scale-110 transition-luxury">
<MapPin className="h-6 w-6" />
</div>
<span className="text-2xl font-bold">Cappadocia AI</span>
</div>
<span className="text-2xl font-black text-white tracking-tighter uppercase">Kapadokya <span className="text-primary">Efsanesi</span></span>
</Link>
{/* Ana Başlık */}
<div className="space-y-6 max-w-md">
<h1 className="text-5xl font-bold leading-tight">
Kapadokya'ya Hoşgeldin
<div className="max-w-lg space-y-6">
<motion.div
initial={{ opacity: 0, x: -20 }}
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>
<p className="text-xl text-blue-100">
Yapay zeka rehberliğinde unutulmaz deneyimler yarat
<p className="text-lg text-white/60 font-medium italic leading-relaxed">
"Efsaneler, sadece cesaret edenler ve keşfedenler için yazılır. Rotanızı kaydedin ve Kapadokya'yı yaşayın."
</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>
{/* Alt Bilgi */}
<div className="relative z-10 flex items-center space-x-2 text-sm text-blue-100">
<Shield className="h-4 w-4" />
<span>Güvenlik: Verileriniz 256-bit SSL ile korunur</span>
<div className="flex items-center gap-3 text-white/40 text-[10px] font-black uppercase tracking-widest">
<Shield className="h-3.5 w-3.5 text-primary" />
<span>Premium Güvenlik Protokolü Aktif</span>
</div>
</div>
</div>
{/* Sağ Taraf - Login Form */}
<div className="w-full lg:w-1/2 flex items-center justify-center p-8 bg-background">
<div className="w-full max-w-md space-y-8">
{/* Mobile Logo */}
<div className="lg:hidden flex justify-center mb-8">
<div className="flex items-center space-x-2">
<MapPin className="h-8 w-8 text-primary" />
<span className="text-2xl font-bold">Cappadocia AI</span>
{/* Right Side - Luxury Form */}
<div className="w-full lg:w-[50%] xl:w-[45%] flex items-center justify-center p-8 md:p-16 relative overflow-hidden bg-white dark:bg-card">
<div className="w-full max-w-sm relative z-10 space-y-10">
{/* Mobile Brand Header */}
<div className="lg:hidden flex flex-col items-center gap-3 text-center mb-10">
<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" />
</div>
<h2 className="text-2xl font-black tracking-tighter uppercase">Kapadokya <span className="text-primary">Efsanesi</span></h2>
</div>
{/* Form Header */}
<div className="space-y-2 text-center lg:text-left">
<h2 className="text-3xl font-bold tracking-tight">
{isLogin ? 'Giriş Yap' : 'Hesap Oluştur'}
<div className="space-y-3">
<h2 className="text-3xl md:text-4xl font-black text-gray-900 dark:text-white tracking-tighter uppercase leading-none">
{isLogin ? 'HOŞ GELDİNİZ' : 'BİZE KATILIN'}
</h2>
<p className="text-muted-foreground">
{isLogin ? 'Rotanızı yönetin ve seyahatinizi planlayın' : 'Kapadokya maceranıza başlayın'}
<p className="text-base text-gray-500 font-medium italic">
{isLogin ? 'Efsane kaldığı yerden devam ediyor.' : 'Kendi Kapadokya hikayenizi başlatın.'}
</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Kullanıcı Adı */}
<div className="space-y-2">
<Label htmlFor="username" className="text-sm font-semibold">
Kullanıcı Adı
</Label>
<Input
id="username"
placeholder="kullaniciadi"
required
value={username}
onChange={e => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))}
className="h-11 text-base"
autoComplete="username"
/>
<p className="text-xs text-muted-foreground">
Sadece harf, rakam ve alt çizgi kullanılabilir
</p>
</div>
<div className="space-y-5">
<div className="space-y-2.5">
<Label htmlFor="username" className="text-[10px] font-black uppercase tracking-widest text-primary">Kullanıcı Kimliği</Label>
<Input
id="username"
placeholder="kullanici_adi"
required
value={username}
onChange={e => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, ''))}
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"
/>
</div>
{/* Şifre */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password" className="text-sm font-semibold">
Şifre
</Label>
{isLogin && (
<div className="space-y-2.5">
<div className="flex items-center justify-between">
<Label htmlFor="password" className="text-[10px] font-black uppercase tracking-widest text-primary">Güvenli Şifre</Label>
</div>
<div className="relative">
<Input
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
type="button"
className="text-xs text-primary hover:underline"
onClick={() => toast.info('Şifre sıfırlama özelliği yakında eklenecek')}
onClick={() => setShowPassword(!showPassword)}
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>
)}
</div>
</div>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="••••••••"
required
value={password}
onChange={e => setPassword(e.target.value)}
className="h-11 text-base pr-10"
autoComplete={isLogin ? 'current-password' : 'new-password'}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{!isLogin && password && (
<p className="text-xs text-muted-foreground">
{password.length < 6 ? '⚠️ En az 6 karakter gerekli' : '✓ Şifre uygun'}
</p>
{isLogin && (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2.5">
<Checkbox
id="remember"
checked={rememberMe}
onCheckedChange={(checked) => setRememberMe(checked as boolean)}
className="w-4 h-4 rounded-md border-2 border-gray-300 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
<Label htmlFor="remember" className="text-xs font-bold text-gray-500 cursor-pointer">Beni Hatırla</Label>
</div>
<button type="button" className="text-[10px] font-black uppercase tracking-widest text-primary hover:opacity-70 transition-opacity">Şifremi Unuttum</button>
</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
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}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{isLogin ? 'Giriş yapılıyor...' : 'Hesap oluşturuluyor...'}
</>
<Loader2 className="h-6 w-6 animate-spin" />
) : (
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>
</form>
{/* Divider */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">veya</span>
</div>
</div>
{/* Toggle Login/Signup */}
<div className="text-center">
<div className="pt-6 text-center border-t border-gray-100">
<button
type="button"
onClick={() => {
setIsLogin(!isLogin);
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 ? (
<>
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ı?{' '}
<span className="font-semibold text-primary">Giriş yap</span>
Hesabınız var mı?{' '}
<span className="font-black text-primary uppercase tracking-widest ml-1 group-hover:underline">Giriş Yap</span>
</>
)}
</button>
@ -295,4 +234,4 @@ export default function LoginPage() {
</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 { useAuth } from '@/contexts/AuthContext';
import api from '@/db/api';
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 { toast } from 'sonner';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { zodResolver } from '@hookform/resolvers/zod'
import * as z from 'zod';
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 { 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 } from '@/constants/planner';
// Components
import { LOADING_STEPS, TRAVEL_TYPE_OPTIONS, BUDGET_OPTIONS, TRANSPORT_OPTIONS, ACCOMMODATION_OPTIONS, INTEREST_OPTIONS } from '@/constants/planner';
import { DateSelector } from '@/components/planner/DateSelector';
import { TravelerInput } from '@/components/planner/TravelerInput';
import { AccommodationSelector } from '@/components/planner/AccommodationSelector';
import { InterestsGrid } from '@/components/planner/InterestsGrid';
import { SubmitButton } from '@/components/planner/SubmitButton';
import { HeroSection } from '@/components/planner/HeroSection';
import { TravelTypeSelector } from '@/components/planner/TravelTypeSelector';
import { TransportSelector } from '@/components/planner/TransportSelector';
import { BudgetSelector } from '@/components/planner/BudgetSelector';
// Form validation schema
// ─── Schema ───────────────────────────────────────────────────────────────────
const formSchema = z.object({
dateRange: z.object({
from: z.date({
required_error: 'Başlangıç tarihi gereklidir',
}),
to: z.date({
required_error: 'Bitiş tarihi gereklidir',
}),
}).refine(
(data) => {
if (!data.from || !data.to) return false;
const today = new Date();
today.setHours(0, 0, 0, 0);
return data.from >= today;
},
{
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(
(data) => {
if (!data.from || !data.to) return false;
return data.to > data.from;
},
{
})
.refine(d => d.to > d.from, {
message: 'Bitiş tarihi başlangıç tarihinden sonra olmalıdır',
}
).refine(
(data) => {
if (!data.from || !data.to) return false;
const days = differenceInDays(data.to, data.from) + 1;
})
.refine(d => {
const days = differenceInDays(d.to, d.from) + 1;
return days >= 1 && days <= 14;
},
{
message: 'Seyahat süresi 1-14 gün arasında olmalıdır',
}
),
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'),
}, { 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(),
interests: z.array(z.string())
.min(1, 'En az 1 ilgi alanı seçmelisiniz')
.max(6, 'Maksimum 6 ilgi alanı seçebilirsiniz'),
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;
// ─── 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 { user } = useAuth();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [loadingStep, setLoadingStep] = useState(0);
const [currentStep, setCurrentStep] = useState(0);
const [datePickerOpen, setDatePickerOpen] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
dateRange: {
from: undefined,
to: undefined,
},
dateRange: { from: undefined, to: undefined },
travelType: '',
travelers: 2,
accommodation: 'center',
transport: '',
budget: '',
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 formProgress = useMemo(() => {
let completed = 0;
const total = 4;
if (watchedValues.dateRange?.from && watchedValues.dateRange?.to) completed++;
if (watchedValues.travelers >= 1) completed++;
if (watchedValues.accommodation) completed++;
if (watchedValues.interests?.length > 0) completed++;
return Math.round((completed / total) * 100);
}, [watchedValues]);
// ── 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 handleInterestToggle = useCallback((interestId: string) => {
const currentInterests = form.getValues('interests');
const newInterests = currentInterests.includes(interestId)
? currentInterests.filter(id => id !== interestId)
: [...currentInterests, interestId];
form.setValue('interests', newInterests, { shouldValidate: true });
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 handleCancel = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setLoading(false);
setLoadingStep(0);
toast.info('Planlama iptal edildi');
}, []);
const simulateLoadingSteps = useCallback(() => {
setLoadingStep(0);
const interval = setInterval(() => {
const iv = setInterval(() => {
setLoadingStep(prev => {
if (prev < LOADING_STEPS.length - 1) return prev + 1;
clearInterval(interval);
clearInterval(iv);
return prev;
});
}, 2500);
return interval;
return iv;
}, []);
// ── Submit ──────────────────────────────────────────────────────────────────
const onSubmit = async (data: FormValues) => {
setLoading(true);
setLoadingStep(0);
abortControllerRef.current = new AbortController();
const loadingInterval = simulateLoadingSteps();
const iv = simulateLoadingSteps();
try {
const startDate = format(data.dateRange.from, 'yyyy-MM-dd');
const endDate = format(data.dateRange.to, 'yyyy-MM-dd');
const formData = {
startDate,
endDate,
interests: data.interests,
dailySchedule: 'moderate',
preferences: `Accommodation: ${data.accommodation}, Travelers: ${data.travelers}`,
};
const endDate = format(data.dateRange.to, 'yyyy-MM-dd');
const result = await retryWithBackoff(
async () => {
return await withTimeout(
api.generateItinerary(formData),
45000,
new Error('Sunucu yanıt vermiyor, lütfen tekrar deneyin.')
);
},
{
maxRetries: 2,
initialDelay: 1000,
maxDelay: 5000
}
() => withTimeout(
api.generateItinerary({
startDate,
endDate,
interests: data.interests,
dailySchedule: data.travelType === 'family' ? 'relaxed' : data.budget === 'luxury' ? 'relaxed' : 'moderate',
travelType: data.travelType,
accommodation: data.accommodation,
transport: data.transport,
budget: data.budget,
travelers: data.travelers,
}),
45000,
new Error('Sunucu yanıt vermiyor, lütfen tekrar deneyin.')
),
{ maxRetries: 2, initialDelay: 1000, maxDelay: 5000 }
);
clearInterval(loadingInterval);
let tripId = '';
clearInterval(iv);
if (user) {
const savedTrip = await api.saveTrip({
const saved = await api.saveTrip({
user_id: user.id,
title: `Kapadokya Gezisi`,
title: 'Kapadokya Gezisi',
destination: 'Cappadocia',
start_date: startDate,
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,
});
tripId = savedTrip.id;
localStorage.removeItem('planner_form_draft');
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' } });
return;
}
navigate(`/trip/${tripId}`);
toast.success('Rotanız hazır!');
} catch (err) {
clearInterval(loadingInterval);
clearInterval(iv);
if (err instanceof Error && err.name === 'AbortError') return;
const apiError = parseApiError(err);
toast.error('Hata oluştu', { description: apiError.userMessage });
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 flex">
{/* Left Side - Form */}
<div className="w-full lg:w-[45%] xl:w-[40%] bg-white p-6 md:p-10 overflow-y-auto">
<div className="max-w-xl mx-auto space-y-8">
<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 md:text-4xl font-bold text-gray-900 tracking-tight">
Hayalinizdeki <span className="text-orange-600">Kapadokya</span> gezisi başlasın
<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-gray-500 text-lg">
Kişiselleştirilmiş, Google doğrulamalı ve yapay zeka destekli gezi planlayıcısı.
<p className="text-white/35 text-sm font-medium italic">
"Size özel kurgulanmış seyahat mimarisi."
</p>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600 font-medium">Planlama İlerlemesi</span>
<span className="text-orange-600 font-bold">{formProgress}%</span>
</div>
<Progress value={formProgress} className="h-2 bg-orange-100" />
{/* 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="space-y-8">
{/* Destination */}
<div className="space-y-3">
<Label className="text-sm font-bold text-gray-700">Gidilecek Yer</Label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
<Input
value="Kapadokya, Nevşehir"
disabled
className="pl-10 h-12 bg-gray-50 border-gray-200 font-medium"
/>
</div>
</div>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 flex flex-col">
{/* Date Selection */}
<FormField
control={form.control}
name="dateRange"
render={({ field }) => (
<FormItem className="space-y-3">
<Label className="text-sm font-bold text-gray-700">Ne Zaman Gidiyorsunuz?</Label>
<DateSelector
date={field.value}
onDateChange={field.onChange}
isOpen={datePickerOpen}
onOpenChange={setDatePickerOpen}
/>
<FormMessage />
</FormItem>
)}
/>
{/* 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>
<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>
<InterestsGrid selectedInterests={field.value} onToggle={handleInterestToggle} />
<FormMessage />
</FormItem>
)}
/>
{loading && (
<div className="p-4 bg-orange-50 border border-orange-100 rounded-xl space-y-3 animate-in fade-in slide-in-from-bottom-2">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 text-orange-600 animate-spin" />
<span className="text-sm font-bold text-orange-900">{LOADING_STEPS[loadingStep].label}</span>
<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>
<Progress value={LOADING_STEPS[loadingStep].progress} className="h-1.5 bg-orange-200" />
</div>
)}
<div className="pt-4">
<SubmitButton
loading={loading}
disabled={formProgress < 100}
onCancel={handleCancel}
/>
{/* 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>
{/* Right Side - Hero */}
<HeroSection />
</div>
);
};
export default memo(PlannerPage);
export default memo(PlannerPage);

View File

@ -1,208 +1,549 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import api, { Trip, Place } from '@/db/api';
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import api, { Trip, Place, ItineraryDay } from '@/db/api';
import { Timeline } from '@/components/trip/Timeline';
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 { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { format } from 'date-fns';
import { format, addDays } from 'date-fns';
import { tr } from 'date-fns/locale';
import { motion, AnimatePresence } from 'framer-motion';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger,
} 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() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const [trip, setTrip] = useState<Trip | null>(null);
const [loading, setLoading] = useState(true);
const [activePlaceId, setActivePlaceId] = useState<string | null>(null);
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(() => {
if (id) {
loadTrip(id);
}
if (!id) return;
(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]);
const loadTrip = async (tripId: string) => {
try {
const data = await api.getTripById(tripId);
if (data) {
setTrip(data);
} else {
toast.error('Gezi bulunamadı');
// ── Persist (debounced) ───────────────────────────────────────────────────
const scheduleWrite = useCallback((t: Trip) => {
setSaveStatus('unsaved');
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(async () => {
setSaveStatus('saving');
try {
await api.updateTrip(t.id, { itinerary: t.itinerary });
setSaveStatus('saved');
} catch {
setSaveStatus('unsaved');
toast.error('Değişiklikler kaydedilemedi');
}
} catch (error) {
console.error(error);
toast.error('Gezi yüklenemedi');
} finally {
setLoading(false);
}
};
}, 1500);
}, []);
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;
applyItinerary({ ...trip.itinerary, days: fn([...trip.itinerary.days]) });
}, [trip, applyItinerary]);
const newItinerary = { ...trip.itinerary };
newItinerary.days[dayIndex].items = newItems;
const handleReorder = (dayIndex: number, newItems: Place[]) => {
withDays(days => {
days[dayIndex] = { ...days[dayIndex], items: newItems };
return days;
});
};
// Recalculate distance and duration
try {
if (newItems.length > 1) {
const origin = `${newItems[0].lat},${newItems[0].lng}`;
const destination = `${newItems[newItems.length - 1].lat},${newItems[newItems.length - 1].lng}`;
const waypoints = newItems.slice(1, -1).map(i => `${i.lat},${i.lng}`);
const directions = await api.getDirections({ origin, destination, waypoints });
if (directions.routes && directions.routes[0]) {
const leg = directions.routes[0].legs.reduce((acc: any, curr: any) => ({
distance: acc.distance + curr.distance.value,
duration: acc.duration + curr.duration.value
}), { distance: 0, duration: 0 });
newItinerary.days[dayIndex].total_distance = `${(leg.distance / 1000).toFixed(1)} km`;
newItinerary.days[dayIndex].total_duration = `${Math.round(leg.duration / 60)} dk sürüş`;
}
const handleAddPlace = (dayIndex: number, place: Place) => {
withDays(days => {
const day = days[dayIndex];
const last = day.items[day.items.length - 1];
// Auto-schedule start_time after last item ends
let startMin = 9 * 60;
if (last) {
const [h, m] = (last.start_time || '09:00').split(':').map(Number);
startMin = h * 60 + m + (last.estimated_duration_minutes || 60) + 20;
}
} catch (error) {
console.warn('Failed to calculate directions:', error);
}
const toHHMM = (mins: number) =>
`${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 {
await api.updateTrip(trip.id, { itinerary: newItinerary });
toast.success('Rota güncellendi');
} catch (error) {
toast.error('Değişiklikler kaydedilemedi');
await navigator.clipboard.writeText(window.location.href);
toast.success('Link panoya kopyalandı');
} catch {
toast.error('Kopyalama başarısız');
}
};
const handleShare = () => {
const url = window.location.href;
navigator.clipboard.writeText(url);
toast.success('Link kopyalandı');
const handleDelete = async () => {
if (!trip) return;
try {
await api.deleteTrip(trip.id);
toast.success('Gezi silindi');
navigate('/account');
} catch {
toast.error('Gezi silinemedi');
}
};
const handleExport = () => {
toast.info('Dışa aktarma özelliği yakında eklenecek');
};
// ── Computed ──────────────────────────────────────────────────────────────
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 = () => {
toast.info('Kopyalama özelliği yakında eklenecek');
};
const dayStats = useMemo(() => {
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) {
return (
<div className="flex h-screen items-center justify-center">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<div className="h-[calc(100vh-64px)] flex flex-col items-center justify-center gap-4 bg-background">
<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>
);
}
if (!trip) return <div className="flex items-center justify-center min-h-screen">Gezi bulunamadı</div>;
if (!trip) return null;
// Calculate trip stats
const totalPlaces = trip.itinerary.days.reduce((sum, day) => sum + day.items.length, 0);
const totalDistance = trip.itinerary.days.reduce((sum, day) => {
const dist = day.total_distance ? parseFloat(day.total_distance) : 0;
return sum + dist;
}, 0);
const dayCount = trip.itinerary.days.length;
const selectedDay = trip.itinerary.days[selectedDayIndex];
return (
<div className="flex flex-col h-screen overflow-hidden">
{/* Header Area */}
<div className="bg-gradient-to-b from-gray-100 to-white border-b">
<div className="max-w-7xl mx-auto px-6 py-6">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div className="flex-1">
<h1 className="text-3xl font-bold text-gray-900 mb-2">{trip.title}</h1>
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1.5">
<Calendar className="h-4 w-4" />
<span>
{format(new Date(trip.start_date), 'd MMM', { locale: tr })} - {format(new Date(trip.end_date), 'd MMM yyyy', { locale: tr })}
</span>
</div>
<Badge variant="secondary" className="font-normal">
{dayCount} Gün
</Badge>
<Badge variant="secondary" className="font-normal">
{totalPlaces} Mekan
</Badge>
{totalDistance > 0 && (
<Badge variant="secondary" className="font-normal">
{totalDistance.toFixed(1)} km
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="h-4 w-4 mr-2" />
Dışa Aktar
</Button>
<Button variant="outline" size="sm" onClick={handleShare}>
<Share2 className="h-4 w-4 mr-2" />
Paylaş
</Button>
<Button variant="outline" size="sm" onClick={handleDuplicate}>
<Copy className="h-4 w-4 mr-2" />
Kopyala
</Button>
</div>
<div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden bg-background">
{/* ── Sub-header ──────────────────────────────────────────────────── */}
<div className="h-14 border-b bg-white flex items-center px-4 gap-3 shrink-0 shadow-sm">
{/* Left */}
<div className="flex items-center gap-3 flex-1 min-w-0">
{/* Undo / Redo */}
<div className="flex items-center gap-0.5 bg-gray-100 rounded-lg p-0.5">
<button
onClick={handleUndo}
disabled={!history.canUndo}
title="Geri al (Ctrl+Z)"
className={cn(
"h-7 w-7 rounded-md flex items-center justify-center transition-all",
history.canUndo
? "text-gray-700 hover:bg-white hover:shadow-sm cursor-pointer"
: "text-gray-300 cursor-not-allowed"
)}
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
<button
onClick={handleRedo}
disabled={!history.canRedo}
title="İleri al (Ctrl+Shift+Z)"
className={cn(
"h-7 w-7 rounded-md flex items-center justify-center transition-all",
history.canRedo
? "text-gray-700 hover:bg-white hover:shadow-sm cursor-pointer"
: "text-gray-300 cursor-not-allowed"
)}
>
<RotateCw className="h-3.5 w-3.5" />
</button>
</div>
<div className="h-4 w-px bg-gray-200" />
<div className="flex items-center gap-2 min-w-0">
<h1 className="text-sm font-bold text-gray-900 truncate">{trip.title}</h1>
<LayoutGrid className="h-3.5 w-3.5 text-gray-400 shrink-0" />
</div>
{/* Save indicator */}
<AnimatePresence mode="wait">
{saveStatus === 'saving' && (
<motion.span key="saving" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="flex items-center gap-1.5 text-[10px] font-bold text-gray-400 uppercase tracking-widest"
>
<Loader2 className="h-3 w-3 animate-spin" /> Kaydediliyor...
</motion.span>
)}
{saveStatus === 'saved' && (
<motion.span key="saved" initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0 }}
className="flex items-center gap-1.5 text-[10px] font-bold text-green-500"
>
<CheckCircle2 className="h-3 w-3" /> Kaydedildi
</motion.span>
)}
{saveStatus === 'unsaved' && (
<motion.div key="dot" initial={{ opacity: 0 }} animate={{ opacity: 1 }}
className="w-2 h-2 rounded-full bg-orange-400"
/>
)}
</AnimatePresence>
</div>
{/* Right actions */}
<div className="flex items-center gap-1.5 shrink-0">
{!user && (
<Button size="sm" onClick={() => navigate('/login')}
className="bg-orange-600 hover:bg-orange-700 h-8 px-4 rounded-full text-xs font-bold gap-1.5">
Kaydetmek için giriş yap
</Button>
)}
<Button variant="ghost" size="icon" className="h-8 w-8 text-gray-500 hover:text-gray-900" onClick={handleShare}>
<Share2 className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-gray-400 hover:text-red-500 hover:bg-red-50">
<Trash2 className="h-4 w-4" />
</Button>
</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>
<main className="flex flex-1 overflow-hidden">
{/* Timeline Panel */}
<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>
{/* ── Main ──────────────────────────────────────────────────────────── */}
<main className="flex-1 flex overflow-hidden">
{/* Map Panel - Desktop */}
<div className="hidden lg:block flex-1 relative bg-muted">
<TripMap
itinerary={trip.itinerary}
activePlaceId={activePlaceId}
onMarkerClick={(id) => setActivePlaceId(id)}
/>
</div>
{/* Day sidebar */}
<aside className="w-16 md:w-[72px] border-r bg-gray-50/50 flex flex-col shrink-0 overflow-y-auto">
<div className="py-4 flex flex-col items-center gap-2">
<span className="text-[9px] font-black text-gray-400 uppercase tracking-widest">Günler</span>
<div className="flex flex-col gap-2 w-full px-2 mt-1">
{trip.itinerary.days.map((day, idx) => (
<motion.button
key={day.day}
onClick={() => setSelectedDayIndex(idx)}
whileHover={{ scale: 1.04 }} whileTap={{ scale: 0.96 }}
className={cn(
"flex flex-col items-center justify-center py-3 rounded-xl transition-all",
selectedDayIndex === idx
? "bg-orange-600 text-white shadow-lg shadow-orange-600/30"
: "text-gray-400 hover:bg-white hover:text-gray-900 hover:shadow-sm"
)}
>
<span className="text-[9px] font-black uppercase tracking-tighter">Gün {day.day}</span>
<span className={cn("text-[8px] font-semibold mt-0.5",
selectedDayIndex === idx ? "text-orange-200" : "text-gray-400"
)}>
{getDayDate(idx)}
</span>
<Badge className={cn(
"mt-1.5 text-[8px] px-1.5 py-0 h-4 font-black border-0 rounded-full",
selectedDayIndex === idx ? "bg-white/20 text-white" : "bg-gray-200 text-gray-500"
)}>
{day.items.length}
</Badge>
</motion.button>
))}
{/* Map Sheet - Mobile */}
<div className="lg:hidden fixed bottom-4 right-4 z-50">
<button
onClick={handleAddDay}
className="flex flex-col items-center justify-center py-3 rounded-xl border border-dashed border-gray-200 text-gray-400 hover:text-orange-600 hover:bg-orange-50 hover:border-orange-300 transition-all"
>
<Plus className="h-4 w-4" />
<span className="text-[8px] font-black uppercase mt-1">Ekle</span>
</button>
</div>
</div>
</aside>
{/* Timeline panel */}
<section className="w-full lg:w-[45%] xl:w-[40%] overflow-y-auto bg-white border-r flex flex-col">
{/* Sticky day header */}
<div className="px-6 py-4 border-b sticky top-0 bg-white/95 backdrop-blur-sm z-30">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2.5">
<h2 className="text-lg font-black text-gray-900">Gün {selectedDay.day}</h2>
<span className="text-sm font-semibold text-gray-400">{getDayDate(selectedDayIndex)}</span>
</div>
<div className="flex items-center gap-3 mt-1.5">
<span className="flex items-center gap-1.5 text-[11px] font-bold text-gray-500">
<MapPin className="h-3 w-3 text-orange-500" />
{dayStats?.places || 0} durak
</span>
{dayStats && dayStats.places > 0 && (
<span className="flex items-center gap-1.5 text-[11px] font-bold text-gray-500">
<Clock className="h-3 w-3 text-orange-500" />
~{dayStats.duration}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-8 rounded-xl text-xs font-bold gap-1.5 border-gray-100">
<Sparkles className="h-3 w-3 text-orange-500" />AI
</Button>
<Button size="sm" className="h-8 rounded-xl text-xs font-bold gap-1.5 bg-orange-600 hover:bg-orange-700">
<Plus className="h-3 w-3" />Yer Ekle
</Button>
</div>
</div>
</div>
<div className="flex-1">
<Timeline
itinerary={{ days: [selectedDay] }}
onReorder={(_, items) => handleReorder(selectedDayIndex, items)}
onAddPlace={(_, place) => handleAddPlace(selectedDayIndex, place)}
onDeletePlace={(_, id) => handleDeletePlace(selectedDayIndex, id)}
onUpdatePlaceNote={(_, id, note) => handleUpdatePlaceNote(selectedDayIndex, id, note)}
onUpdateDayNote={(_, note) => handleUpdateDayNote(selectedDayIndex, note)}
onPlaceClick={setActivePlaceId}
activePlaceId={activePlaceId}
/>
</div>
</section>
{/* Map panel */}
<section className="hidden lg:block flex-1 relative bg-gray-100">
<TripMap
itinerary={{ days: [selectedDay] }}
activePlaceId={activePlaceId}
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}>
<SheetTrigger asChild>
<Button size="lg" className="rounded-full shadow-lg">
<MapPin className="h-5 w-5 mr-2" />
Haritayı Göster
<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-4 w-4" />
Haritayı
</Button>
</SheetTrigger>
<SheetContent side="bottom" className="h-[70vh]">
<SheetHeader>
<SheetTitle>Rota Haritası</SheetTitle>
<SheetContent side="bottom" className="h-[80vh] p-0 rounded-t-3xl overflow-hidden">
<SheetHeader className="p-4 border-b">
<SheetTitle className="text-base font-bold">Rota Gün {selectedDay.day}</SheetTitle>
</SheetHeader>
<div className="h-full mt-4">
<TripMap
itinerary={trip.itinerary}
<div className="h-full relative">
<TripMap
itinerary={{ days: [selectedDay] }}
activePlaceId={activePlaceId}
onMarkerClick={(id) => {
setActivePlaceId(id);
setIsMapSheetOpen(false);
document.getElementById(`place-${id}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}}
onAddPlace={(place) => {
handleAddPlace(selectedDayIndex, place);
setIsMapSheetOpen(false);
}}
/>
</div>
@ -212,4 +553,4 @@ export default function TripDetailsPage() {
</main>
</div>
);
}
}

View File

@ -11,119 +11,300 @@ const corsHeaders = {
'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 {
return name
.toLowerCase()
.trim()
// Normalize Turkish characters to ASCII equivalents
.replace(/ğ/g, 'g')
.replace(/ü/g, 'u')
.replace(/ş/g, 's')
.replace(/ı/g, 'i')
.replace(/ö/g, 'o')
.replace(/ç/g, 'c')
// Also handle uppercase Turkish characters
.replace(/Ğ/g, 'g')
.replace(/Ü/g, 'u')
.replace(/Ş/g, 's')
.replace(/İ/g, 'i')
.replace(/Ö/g, 'o')
.replace(/Ç/g, 'c')
// Remove extra spaces
.toLowerCase().trim()
.replace(/ğ/g, 'g').replace(/ü/g, 'u').replace(/ş/g, 's')
.replace(/ı/g, 'i').replace(/ö/g, 'o').replace(/ç/g, 'c')
.replace(/Ğ/g, 'g').replace(/Ü/g, 'u').replace(/Ş/g, 's')
.replace(/İ/g, 'i').replace(/Ö/g, 'o').replace(/Ç/g, 'c')
.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) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
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
const systemPrompt = `You are an expert travel planner for Cappadocia, Turkey.
Generate a personalized travel itinerary based on user preferences.
Allowed regions: Göreme, Uçhisar, Ürgüp, Avanos, Ortahisar, Çavuşin, Derinkuyu, Kaymaklı.
Rules:
- Balloon rides must be at sunrise (05:00 - 08:00) on Day 1 or 2.
- Sunset valley visits after 17:30.
- Max 5 places per day.
- No duplicate places.
- Only return JSON in the specified format.
- Interests: ${interests.join(', ')}.
- Daily Schedule: ${dailySchedule}.
Response Format:
let itinerary
if (OPENAI_API_KEY && OPENAI_API_KEY !== 'PASTE_YOUR_OPENAI_API_KEY_HERE') {
try {
const systemPrompt = `You are an expert travel planner for Cappadocia, Turkey.
Generate a highly personalized travel itinerary strictly based on the user's preferences below.
=== USER PROFILE ===
Travel Type: ${travelType} ${getTravelTypeGuidance(travelType)}
Budget: ${budget} ${getBudgetGuidance(budget)}
Transport: ${transport} ${getTransportGuidance(transport)}
Group Size: ${travelers} people
Accommodation: ${accommodation}
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,
"items": [
{
"place_name": "Göreme Open Air Museum",
"category": "museum",
"estimated_duration_minutes": 120,
"description": "UNESCO World Heritage site with rock-cut churches."
}
]
"place_name": "Göreme Open Air Museum",
"category": "museum",
"estimated_duration_minutes": 120,
"description": "UNESCO World Heritage site with rock-cut churches."
}
]
}`
}
]
}`
const userPrompt = `Trip 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', {
method: 'POST',
headers: {
'Authorization': `Bearer ${OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
],
temperature: 0.3,
response_format: { type: 'json_object' }
}),
})
const openaiRes = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature: 0.4,
response_format: { type: 'json_object' },
}),
})
const openaiData = await openaiRes.json()
const itinerary = JSON.parse(openaiData.choices[0].message.content)
if (!openaiRes.ok) throw new Error(`OpenAI error: ${openaiRes.statusText}`)
// 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 = []
for (const day of itinerary.days) {
const verifiedItems = []
for (const item of day.items) {
// Normalize place name for cache lookup using robust normalization
const normalizedName = normalizePlaceName(item.place_name)
// Check cache first
const { data: cachedPlace, error: cacheError } = await supabase
const { data: cachedPlace } = await supabase
.from('places_cache')
.select('*')
.eq('place_name_normalized', normalizedName)
.limit(1)
.maybeSingle()
if (cachedPlace && !cacheError) {
// Cache HIT - skipping Google API call
console.log(`Cache HIT for "${item.place_name}" (normalized: "${normalizedName}") - skipping Google API call`)
if (cachedPlace) {
verifiedItems.push({
...item,
place_id: cachedPlace.place_id,
@ -132,71 +313,65 @@ serve(async (req) => {
lat: cachedPlace.lat,
lng: cachedPlace.lng,
rating: cachedPlace.rating,
user_ratings_total: cachedPlace.user_ratings_total,
photo_reference: cachedPlace.photo_reference
photo_reference: cachedPlace.photo_reference,
})
} else {
// Cache MISS - fetching from Google and caching
console.log(`Cache MISS for "${item.place_name}" (normalized: "${normalizedName}") - fetching from Google and caching`)
// Text Search to get place_id and basic info
const searchUrl = `https://maps.googleapis.com/maps/api/place/textsearch/json?query=${encodeURIComponent(item.place_name + ' Cappadocia')}&key=${GOOGLE_MAPS_API_KEY}`
const searchRes = await fetch(searchUrl)
const searchData = await searchRes.json()
let placeInfo = null
if (searchData.results && searchData.results.length > 0) {
const place = searchData.results[0]
// Store in cache for future use with normalized name
const cacheEntry = {
place_name_normalized: normalizedName,
place_id: place.place_id,
name: place.name,
formatted_address: place.formatted_address,
lat: place.geometry.location.lat,
lng: place.geometry.location.lng,
rating: place.rating || null,
user_ratings_total: place.user_ratings_total || null,
photo_reference: place.photos?.[0]?.photo_reference || null
if (GOOGLE_MAPS_API_KEY && GOOGLE_MAPS_API_KEY !== 'PASTE_YOUR_GOOGLE_MAPS_API_KEY_HERE') {
try {
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?.length > 0) {
const place = searchData.results[0]
placeInfo = {
place_id: place.place_id,
name: place.name,
formatted_address: place.formatted_address,
lat: place.geometry.location.lat,
lng: place.geometry.location.lng,
rating: place.rating || null,
photo_reference: place.photos?.[0]?.photo_reference || null,
}
}
} catch (err) {
console.error(`Google Places error for ${item.place_name}:`, err)
}
await supabase
.from('places_cache')
.insert(cacheEntry)
verifiedItems.push({
...item,
place_id: place.place_id,
name: place.name,
formatted_address: place.formatted_address,
lat: place.geometry.location.lat,
lng: place.geometry.location.lng,
rating: place.rating,
user_ratings_total: place.user_ratings_total,
photo_reference: place.photos?.[0]?.photo_reference
}
if (placeInfo) {
await supabase.from('places_cache').insert({
place_name_normalized: normalizedName,
...placeInfo,
})
verifiedItems.push({ ...item, ...placeInfo })
} else {
verifiedItems.push({ ...item, unverified: true })
}
}
}
verifiedDays.push({ ...day, items: verifiedItems })
}
// 3. Add time slots (Simple heuristic)
// ── Time slot assignment ───────────────────────────────────────────────────
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 => {
// Handle special cases: Balloon
if (item.place_name.toLowerCase().includes('balloon')) {
const start = "05:00"
const end = "08:00"
return { ...item, start_time: start, end_time: end }
return { ...item, start_time: '05:00', end_time: '08:00' }
}
// 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)
currentTime.setMinutes(currentTime.getMinutes() + item.estimated_duration_minutes)
currentTime.setMinutes(currentTime.getMinutes() + (item.estimated_duration_minutes || 60))
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 { ...day, items: itemsWithTime }
@ -206,9 +381,10 @@ serve(async (req) => {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
} catch (error) {
console.error('Error:', error)
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
})
})

View File

@ -44,35 +44,62 @@ serve(async (req) => {
}
}
// Cache miss or expired - call Google Directions API
console.log('Cache miss for:', cache_key)
let url = `https://maps.googleapis.com/maps/api/directions/json?origin=${origin}&destination=${destination}&key=${GOOGLE_MAPS_API_KEY}&mode=driving`
if (waypoints && waypoints.length > 0) {
url += `&waypoints=${waypoints.join('|')}`
// Cache miss or expired - call Google Directions API (if key exists)
if (GOOGLE_MAPS_API_KEY && GOOGLE_MAPS_API_KEY !== 'PASTE_YOUR_GOOGLE_MAPS_API_KEY_HERE') {
try {
console.log('Cache miss for:', cache_key)
let url = `https://maps.googleapis.com/maps/api/directions/json?origin=${origin}&destination=${destination}&key=${GOOGLE_MAPS_API_KEY}&mode=driving`
if (waypoints && waypoints.length > 0) {
url += `&waypoints=${waypoints.join('|')}`
}
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)
const data = await res.json()
// No key or error - return dummy response
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
await supabase
.from('directions_cache')
.upsert({
cache_key,
response: data,
created_at: new Date().toISOString()
}, {
onConflict: 'cache_key'
})
return new Response(JSON.stringify(data), {
return new Response(JSON.stringify(dummyResponse), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
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',
}
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) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
@ -20,7 +26,9 @@ serve(async (req) => {
const photoReference = urlObj.searchParams.get('photo_reference')
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
@ -37,51 +45,57 @@ serve(async (req) => {
// Try to HEAD the file to check if it exists
try {
const headResponse = await fetch(publicUrlData.publicUrl, { method: 'HEAD' })
if (headResponse.ok) {
// File exists in storage, redirect to it
return Response.redirect(publicUrlData.publicUrl, 302)
}
} catch (headError) {
// 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
const googlePhotoUrl = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=800&photo_reference=${photoReference}&key=${GOOGLE_MAPS_API_KEY}`
const googleResponse = await fetch(googlePhotoUrl)
if (!googleResponse.ok) {
throw new Error(`Failed to fetch photo from Google: ${googleResponse.statusText}`)
// File doesn't exist in storage, fetch from Google (if key exists)
if (GOOGLE_MAPS_API_KEY && GOOGLE_MAPS_API_KEY !== 'PASTE_YOUR_GOOGLE_MAPS_API_KEY_HERE') {
try {
const googlePhotoUrl = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=800&photo_reference=${photoReference}&key=${GOOGLE_MAPS_API_KEY}`
const googleResponse = await fetch(googlePhotoUrl)
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
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) {
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)
// If key is missing or fetch fails, redirect to a random fallback image
const fallbackUrl = FALLBACK_PHOTO_URLS[Math.floor(Math.random() * FALLBACK_PHOTO_URLS.length)]
return Response.redirect(fallbackUrl, 302)
} catch (error) {
console.error('Error in get-place-photo:', error)
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
// Always fallback to something visual
const fallbackUrl = FALLBACK_PHOTO_URLS[Math.floor(Math.random() * FALLBACK_PHOTO_URLS.length)]
return Response.redirect(fallbackUrl, 302)
}
})
})

View File

@ -15,4 +15,9 @@ export default defineConfig({
'@': 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;
}