Autosave: 20260302-135117

This commit is contained in:
Flatlogic Bot 2026-03-02 13:51:18 +00:00
parent 1905eb86bf
commit 2c504c5ff8
25 changed files with 2125 additions and 1321 deletions

0
.perm_test_apache Normal file
View File

0
.perm_test_exec Normal file
View File

View File

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

View File

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

View File

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

View File

@ -2,8 +2,10 @@ import { useEffect, useRef, useState, useMemo } from 'react';
import { ItineraryDay } from '@/db/api'; import { ItineraryDay } from '@/db/api';
import { initGoogleMaps } from '@/lib/google-maps-loader'; import { initGoogleMaps } from '@/lib/google-maps-loader';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ZoomIn, ZoomOut, Maximize2 } from 'lucide-react'; import { ZoomIn, ZoomOut, Maximize2, Navigation2, Map as MapIcon, Layers } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import api from '@/db/api';
import { cn } from '@/lib/utils';
interface MapProps { interface MapProps {
itinerary: { days: ItineraryDay[] }; itinerary: { days: ItineraryDay[] };
@ -11,24 +13,24 @@ interface MapProps {
onMarkerClick: (id: string) => void; onMarkerClick: (id: string) => void;
} }
// Color palette for different days // More standard and vibrant color palette for Wanderlog feel
const DAY_COLORS = [ const DAY_COLORS = [
'#3B82F6', // Blue - Day 1 '#EA580C', // Orange-600
'#10B981', // Green - Day 2 '#2563EB', // Blue-600
'#8B5CF6', // Purple - Day 3 '#059669', // Emerald-600
'#F59E0B', // Amber - Day 4 '#7C3AED', // Violet-600
'#EF4444', // Red - Day 5 '#DB2777', // Pink-600
]; ];
export function TripMap({ itinerary, activePlaceId, onMarkerClick }: MapProps) { export function TripMap({ itinerary, activePlaceId, onMarkerClick }: MapProps) {
const mapRef = useRef<HTMLDivElement>(null); const mapRef = useRef<HTMLDivElement>(null);
const [googleMap, setGoogleMap] = useState<google.maps.Map | null>(null); const [googleMap, setGoogleMap] = useState<google.maps.Map | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [mapType, setMapType] = useState<'roadmap' | 'satellite' | 'terrain'>('roadmap');
const markersRef = useRef<{ [key: string]: { marker: google.maps.Marker; dayIndex: number } }>({}); const markersRef = useRef<{ [key: string]: { marker: google.maps.Marker; dayIndex: number } }>({});
const polylinesRef = useRef<google.maps.Polyline[]>([]); const polylinesRef = useRef<google.maps.Polyline[]>([]);
const infoWindowRef = useRef<google.maps.InfoWindow | null>(null); const infoWindowRef = useRef<google.maps.InfoWindow | null>(null);
// Memoize itinerary to prevent unnecessary re-renders
const itineraryKey = useMemo(() => const itineraryKey = useMemo(() =>
JSON.stringify(itinerary.days.map(d => d.items.map(i => i.place_id))), JSON.stringify(itinerary.days.map(d => d.items.map(i => i.place_id))),
[itinerary] [itinerary]
@ -38,10 +40,8 @@ export function TripMap({ itinerary, activePlaceId, onMarkerClick }: MapProps) {
const loadMap = async () => { const loadMap = async () => {
try { try {
const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY; const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY;
if (!apiKey || apiKey === 'YOUR_GOOGLE_MAPS_API_KEY') { if (!apiKey || apiKey === 'YOUR_GOOGLE_MAPS_API_KEY') {
setError('Google Maps API anahtarı eksik. Lütfen .env dosyasına geçerli bir VITE_GOOGLE_MAPS_API_KEY ekleyin.'); setError('Google Maps API anahtarı eksik.');
console.error('Google Maps API anahtarı eksik. Lütfen .env dosyasına VITE_GOOGLE_MAPS_API_KEY ekleyin.');
return; return;
} }
@ -49,39 +49,39 @@ export function TripMap({ itinerary, activePlaceId, onMarkerClick }: MapProps) {
if (mapRef.current && !googleMap) { if (mapRef.current && !googleMap) {
const map = new google.maps.Map(mapRef.current, { const map = new google.maps.Map(mapRef.current, {
center: { lat: 38.6431, lng: 34.8347 }, // Central Cappadocia center: { lat: 38.6431, lng: 34.8347 },
zoom: 12, zoom: 12,
styles: [ styles: [
// Standard clean map style
{ {
"featureType": "poi", "featureType": "poi.business",
"elementType": "labels",
"stylers": [{ "visibility": "off" }] "stylers": [{ "visibility": "off" }]
},
{
"featureType": "poi.park",
"elementType": "labels.text",
"stylers": [{ "visibility": "on" }]
} }
], ],
mapTypeControl: false, mapTypeControl: false,
streetViewControl: false, streetViewControl: false,
fullscreenControl: false, fullscreenControl: false,
zoomControl: false, zoomControl: false,
clickableIcons: true,
}); });
setGoogleMap(map); setGoogleMap(map);
infoWindowRef.current = new google.maps.InfoWindow(); infoWindowRef.current = new google.maps.InfoWindow();
} }
} catch (error) { } catch (error) {
const errorMsg = 'Google Maps yüklenemedi. API anahtarınızı kontrol edin.'; setError('Google Maps yüklenemedi.');
setError(errorMsg);
console.error('Google Maps yüklenemedi:', error);
} }
}; };
if (!googleMap) loadMap();
if (!googleMap) {
loadMap();
}
}, [googleMap]); }, [googleMap]);
useEffect(() => { useEffect(() => {
if (!googleMap || !itinerary?.days) return; if (!googleMap || !itinerary?.days) return;
// Clear existing markers and polylines
Object.values(markersRef.current).forEach(({ marker }) => marker.setMap(null)); Object.values(markersRef.current).forEach(({ marker }) => marker.setMap(null));
markersRef.current = {}; markersRef.current = {};
polylinesRef.current.forEach(polyline => polyline.setMap(null)); polylinesRef.current.forEach(polyline => polyline.setMap(null));
@ -89,7 +89,6 @@ export function TripMap({ itinerary, activePlaceId, onMarkerClick }: MapProps) {
const bounds = new google.maps.LatLngBounds(); const bounds = new google.maps.LatLngBounds();
// Create markers and polylines for each day
itinerary.days.forEach((day, dayIndex) => { itinerary.days.forEach((day, dayIndex) => {
const dayColor = DAY_COLORS[dayIndex % DAY_COLORS.length]; const dayColor = DAY_COLORS[dayIndex % DAY_COLORS.length];
const dayPath: google.maps.LatLngLiteral[] = []; const dayPath: google.maps.LatLngLiteral[] = [];
@ -98,52 +97,48 @@ export function TripMap({ itinerary, activePlaceId, onMarkerClick }: MapProps) {
const position = { lat: item.lat, lng: item.lng }; const position = { lat: item.lat, lng: item.lng };
dayPath.push(position); dayPath.push(position);
// Create custom marker with SVG const isActive = item.place_id === activePlaceId;
const marker = new google.maps.Marker({ const marker = new google.maps.Marker({
position, position,
map: googleMap, map: googleMap,
title: item.name, title: item.name,
zIndex: isActive ? 1000 : 1,
label: { label: {
text: (itemIndex + 1).toString(), text: (itemIndex + 1).toString(),
color: 'white', color: 'white',
fontSize: '12px', fontSize: '11px',
fontWeight: 'bold', fontWeight: '900',
}, },
icon: { icon: {
path: google.maps.SymbolPath.CIRCLE, path: "M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z",
fillColor: dayColor, fillColor: dayColor,
fillOpacity: 1, fillOpacity: 1,
strokeWeight: 3, strokeWeight: 2,
strokeColor: '#ffffff', strokeColor: '#ffffff',
scale: 14, scale: isActive ? 1.8 : 1.4,
anchor: new google.maps.Point(12, 22),
labelOrigin: new google.maps.Point(12, 9),
}, },
animation: item.place_id === activePlaceId ? google.maps.Animation.BOUNCE : undefined, animation: isActive ? google.maps.Animation.BOUNCE : undefined,
}); });
marker.addListener('click', () => { marker.addListener('click', () => {
onMarkerClick(item.place_id); onMarkerClick(item.place_id);
if (infoWindowRef.current) { if (infoWindowRef.current) {
const photoUrl = item.photo_reference const photoUrl = item.photo_reference ? (
? `${window.location.origin}/api/photo?reference=${item.photo_reference}` item.photo_reference.startsWith('http') ? item.photo_reference : api.getPhotoUrl(item.photo_reference)
: ''; ) : '';
infoWindowRef.current.setContent(` infoWindowRef.current.setContent(`
<div style="color: black; max-width: 250px;"> <div style="padding: 8px; min-width: 200px; font-family: sans-serif;">
${photoUrl ? `<img src="${photoUrl}" alt="${item.name}" style="width: 100%; height: 120px; object-fit: cover; border-radius: 8px; margin-bottom: 8px;" />` : ''} ${photoUrl ? `<img src="${photoUrl}" style="width: 100%; height: 100px; object-fit: cover; border-radius: 8px; margin-bottom: 8px;" />` : ''}
<h4 style="font-weight: bold; margin-bottom: 4px; font-size: 14px;">${item.name}</h4> <h4 style="margin: 0 0 2px 0; font-size: 14px; font-weight: bold;">${item.name}</h4>
<p style="font-size: 12px; color: #6B7280; margin-bottom: 6px;">${item.formatted_address}</p> <p style="margin: 0 0 8px 0; font-size: 11px; color: #6B7280;">${item.category}</p>
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;"> <div style="display: flex; align-items: center; justify-content: space-between;">
<span style="color: ${dayColor}; font-weight: bold;"> ${item.rating || 'N/A'}</span> <span style="font-size: 11px; font-weight: bold;"> ${item.rating || 'N/A'}</span>
<span style="color: #6B7280; font-size: 12px;"> ${item.estimated_duration_minutes}dk</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> </div>
<a
href="https://www.google.com/maps/search/?api=1&query=${item.lat},${item.lng}&query_place_id=${item.place_id}"
target="_blank"
rel="noopener noreferrer"
style="display: inline-block; background: ${dayColor}; color: white; padding: 6px 12px; border-radius: 6px; text-decoration: none; font-size: 12px; font-weight: 500;"
>
Google Maps'te
</a>
</div> </div>
`); `);
infoWindowRef.current.open(googleMap, marker); infoWindowRef.current.open(googleMap, marker);
@ -154,14 +149,13 @@ export function TripMap({ itinerary, activePlaceId, onMarkerClick }: MapProps) {
bounds.extend(position); bounds.extend(position);
}); });
// Create polyline for this day
if (dayPath.length > 1) { if (dayPath.length > 1) {
const polyline = new google.maps.Polyline({ const polyline = new google.maps.Polyline({
path: dayPath, path: dayPath,
geodesic: true, geodesic: true,
strokeColor: dayColor, strokeColor: dayColor,
strokeOpacity: 0.7, strokeOpacity: 0.8,
strokeWeight: 4, strokeWeight: 3,
}); });
polyline.setMap(googleMap); polyline.setMap(googleMap);
polylinesRef.current.push(polyline); polylinesRef.current.push(polyline);
@ -169,127 +163,67 @@ export function TripMap({ itinerary, activePlaceId, onMarkerClick }: MapProps) {
}); });
if (Object.keys(markersRef.current).length > 0) { if (Object.keys(markersRef.current).length > 0) {
googleMap.fitBounds(bounds); googleMap.fitBounds(bounds, { top: 60, bottom: 60, left: 60, right: 60 });
} }
}, [googleMap, itineraryKey]);
}, [googleMap, itineraryKey]); // Use itineraryKey instead of itinerary
useEffect(() => { useEffect(() => {
if (googleMap && activePlaceId && markersRef.current[activePlaceId]) { if (googleMap && activePlaceId && markersRef.current[activePlaceId]) {
const { marker, dayIndex } = markersRef.current[activePlaceId]; const { marker, dayIndex } = markersRef.current[activePlaceId];
const dayColor = DAY_COLORS[dayIndex % DAY_COLORS.length];
googleMap.panTo(marker.getPosition()!); googleMap.panTo(marker.getPosition()!);
googleMap.setZoom(14);
// Update all markers - highlight active one
Object.keys(markersRef.current).forEach(id => { Object.keys(markersRef.current).forEach(id => {
const { marker: m, dayIndex: dIdx } = markersRef.current[id]; const { marker: m, dayIndex: dIdx } = markersRef.current[id];
const color = DAY_COLORS[dIdx % DAY_COLORS.length]; const color = DAY_COLORS[dIdx % DAY_COLORS.length];
const isActive = id === activePlaceId; const isActive = id === activePlaceId;
m.setIcon({ m.setIcon({
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: color, fillColor: color,
fillOpacity: isActive ? 1 : 0.8, fillOpacity: 1,
strokeWeight: isActive ? 4 : 3, strokeWeight: isActive ? 3 : 2,
strokeColor: '#ffffff', strokeColor: isActive ? '#000000' : '#ffffff',
scale: isActive ? 18 : 14, 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); m.setAnimation(isActive ? google.maps.Animation.BOUNCE : null);
}); });
} }
}, [activePlaceId, googleMap]); }, [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);
}
};
return ( return (
<div className="relative w-full h-full"> <div className="relative w-full h-full bg-gray-50">
{error ? ( {error ? (
<div className="absolute inset-0 flex items-center justify-center bg-muted"> <div className="absolute inset-0 flex items-center justify-center">
<div className="text-center p-8 max-w-md"> <p className="text-sm font-medium text-gray-500">{error}</p>
<div className="text-4xl mb-4">🗺</div>
<h3 className="text-lg font-semibold mb-2">Harita Yüklenemedi</h3>
<p className="text-sm text-muted-foreground mb-4">{error}</p>
<div className="text-xs text-left bg-card p-4 rounded-lg border">
<p className="font-mono mb-2">.env dosyasına ekleyin:</p>
<code className="text-primary">VITE_GOOGLE_MAPS_API_KEY=AIza...</code>
</div>
</div>
</div> </div>
) : ( ) : (
<> <>
<div ref={mapRef} className="absolute inset-0 w-full h-full" /> <div ref={mapRef} className="absolute inset-0 w-full h-full" />
{/* Map Controls */} {/* Compact Map Controls */}
<div className="absolute top-4 right-4 flex flex-col gap-2"> <div className="absolute top-4 right-4 flex flex-col gap-2">
<Button <div className="flex flex-col bg-white border rounded-lg shadow-sm overflow-hidden">
variant="secondary" <Button variant="ghost" size="icon" className="h-8 w-8 rounded-none border-b" onClick={() => googleMap?.setZoom((googleMap.getZoom() || 12) + 1)}>
size="icon" <ZoomIn className="h-4 w-4" />
className="bg-white shadow-lg hover:bg-gray-50" </Button>
onClick={handleZoomIn} <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" />
<ZoomIn className="h-4 w-4" /> </Button>
</Button> </div>
<Button
variant="secondary" <Button variant="outline" size="icon" className="h-8 w-8 bg-white shadow-sm" onClick={() => {
size="icon" if (googleMap && Object.keys(markersRef.current).length > 0) {
className="bg-white shadow-lg hover:bg-gray-50" const bounds = new google.maps.LatLngBounds();
onClick={handleZoomOut} Object.values(markersRef.current).forEach(({ marker }) => bounds.extend(marker.getPosition()!));
> googleMap.fitBounds(bounds);
<ZoomOut className="h-4 w-4" /> }
</Button> }}>
<Button
variant="secondary"
size="icon"
className="bg-white shadow-lg hover:bg-gray-50"
onClick={handleFitBounds}
>
<Maximize2 className="h-4 w-4" /> <Maximize2 className="h-4 w-4" />
</Button> </Button>
</div> </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>
</div>
))}
</div>
</div>
</> </>
)} )}
</div> </div>

View File

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

View File

@ -1,9 +1,9 @@
import { ItineraryDay, Place } from '@/db/api'; import { ItineraryDay, Place } from '@/db/api';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Star, Clock, MapPin, GripVertical, Car } from 'lucide-react'; import { Star, Clock, MapPin, GripVertical, Car, Compass, Camera, Zap, Trash2, Edit3, MessageSquare, MoreVertical, Coffee, Sun, Sunset, Moon } from 'lucide-react';
import api from '@/db/api'; import api from '@/db/api';
import { useMemo } from 'react'; import { useState } from 'react';
import { import {
DndContext, DndContext,
closestCenter, closestCenter,
@ -22,23 +22,50 @@ import {
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { motion } 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";
interface TimelineProps { interface TimelineProps {
itinerary: { days: ItineraryDay[] }; itinerary: { days: ItineraryDay[] };
onReorder: (dayIndex: number, newItems: Place[]) => void; onReorder: (dayIndex: number, newItems: Place[]) => void;
onAddPlace: (dayIndex: number, place: Place) => void;
onDeletePlace: (dayIndex: number, placeId: string) => void;
onUpdatePlaceNote: (dayIndex: number, placeId: string, note: string) => void;
onUpdateDayNote: (dayIndex: number, note: string) => void;
onPlaceClick: (id: string) => void; onPlaceClick: (id: string) => void;
activePlaceId: string | null; activePlaceId: string | null;
} }
export function Timeline({ itinerary, onReorder, onPlaceClick, activePlaceId }: TimelineProps) { export function Timeline({
itinerary,
onReorder,
onAddPlace,
onDeletePlace,
onUpdatePlaceNote,
onUpdateDayNote,
onPlaceClick,
activePlaceId
}: TimelineProps) {
return ( return (
<div className="p-6 space-y-8"> <div className="p-4 md:p-6 space-y-8">
{itinerary.days.map((day, dayIndex) => ( {itinerary.days.map((day, dayIndex) => (
<DaySection <DaySection
key={day.day} key={day.day}
day={day} day={day}
dayIndex={dayIndex} dayIndex={dayIndex}
onReorder={onReorder} onReorder={onReorder}
onAddPlace={onAddPlace}
onDeletePlace={onDeletePlace}
onUpdatePlaceNote={onUpdatePlaceNote}
onUpdateDayNote={onUpdateDayNote}
onPlaceClick={onPlaceClick} onPlaceClick={onPlaceClick}
activePlaceId={activePlaceId} activePlaceId={activePlaceId}
/> />
@ -47,13 +74,30 @@ export function Timeline({ itinerary, onReorder, onPlaceClick, activePlaceId }:
); );
} }
function DaySection({ day, dayIndex, onReorder, onPlaceClick, activePlaceId }: { function DaySection({
day,
dayIndex,
onReorder,
onAddPlace,
onDeletePlace,
onUpdatePlaceNote,
onUpdateDayNote,
onPlaceClick,
activePlaceId
}: {
day: ItineraryDay; day: ItineraryDay;
dayIndex: number; dayIndex: number;
onReorder: (dayIndex: number, newItems: Place[]) => void; onReorder: (dayIndex: number, newItems: Place[]) => void;
onAddPlace: (dayIndex: number, place: Place) => void;
onDeletePlace: (dayIndex: number, placeId: string) => void;
onUpdatePlaceNote: (dayIndex: number, placeId: string, note: string) => void;
onUpdateDayNote: (dayIndex: number, note: string) => void;
onPlaceClick: (id: string) => void; onPlaceClick: (id: string) => void;
activePlaceId: string | null; activePlaceId: string | null;
}) { }) {
const [isEditingDayNote, setIsEditingDayNote] = useState(false);
const [dayNote, setDayNote] = useState(day.notes || '');
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor), useSensor(PointerSensor),
useSensor(KeyboardSensor, { useSensor(KeyboardSensor, {
@ -73,48 +117,56 @@ function DaySection({ day, dayIndex, onReorder, onPlaceClick, activePlaceId }: {
} }
}; };
// Calculate estimated travel times between places (memoized to prevent re-renders) const handleSaveDayNote = () => {
const travelTimes = useMemo(() => { onUpdateDayNote(dayIndex, dayNote);
const times: { [key: number]: number } = {}; setIsEditingDayNote(false);
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]);
return ( return (
<div className="space-y-4"> <motion.div
{/* Day Header with gradient divider */} initial={{ opacity: 0, y: 10 }}
<div className="sticky top-0 bg-white/95 backdrop-blur-sm z-10 pb-3 border-b"> animate={{ opacity: 1, y: 0 }}
<div className="flex items-center justify-between mb-2"> className="space-y-6"
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-3"> >
Gün {day.day} {/* Day Notes */}
<Badge variant="secondary" className="font-normal text-sm"> <div className="relative group px-2">
{day.items.length} Mekan {isEditingDayNote ? (
</Badge> <div className="space-y-2">
</h2> <Textarea
</div> value={dayNote}
{(day.total_distance || day.total_duration) && ( onChange={(e) => setDayNote(e.target.value)}
<div className="flex items-center gap-3 text-xs text-muted-foreground"> placeholder="Bugün için planlarınızı buraya yazın..."
{day.total_distance && ( className="bg-gray-50 border-gray-200 rounded-xl p-3 text-sm font-medium min-h-[80px] focus:ring-orange-600/20 focus:border-orange-600"
<span className="flex items-center gap-1"> />
<MapPin className="h-3 w-3" /> <div className="flex justify-end gap-2">
{day.total_distance} <Button variant="ghost" size="sm" onClick={() => setIsEditingDayNote(false)} className="text-[10px] font-bold h-7">Vazgeç</Button>
</span> <Button size="sm" onClick={handleSaveDayNote} className="bg-orange-600 text-white text-[10px] font-bold h-7 px-3 rounded-lg">Kaydet</Button>
)} </div>
{day.total_duration && ( </div>
<span className="flex items-center gap-1"> ) : (
<Clock className="h-3 w-3" /> <div
{day.total_duration} onClick={() => setIsEditingDayNote(true)}
</span> className="flex items-start gap-3 p-3 rounded-xl hover:bg-gray-50 transition-all cursor-pointer group/note"
)} >
<MessageSquare className="h-4 w-4 text-gray-400 group-hover/note:text-orange-600 mt-0.5" />
<div className="flex-1">
{day.notes ? (
<p className="text-sm font-medium text-gray-600">"{day.notes}"</p>
) : (
<span className="text-[11px] font-bold text-gray-400 uppercase tracking-widest group-hover/note:text-orange-600 transition-colors">Bugün için not ekle...</span>
)}
</div>
<Edit3 className="h-3.5 w-3.5 text-gray-300 opacity-0 group-hover/note:opacity-100 transition-all" />
</div> </div>
)} )}
{/* Gradient divider */} </div>
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-primary/30 to-transparent" />
<div className="px-2 py-2">
<div className="flex items-center gap-3 text-[10px] font-bold text-orange-600 uppercase tracking-widest">
<MapPin className="h-3.5 w-3.5" />
BAŞLANGIÇ NOKTASI
</div>
<p className="ml-7 mt-1 text-sm font-bold text-gray-900">Göreme Merkez</p>
</div> </div>
<DndContext <DndContext
@ -126,33 +178,72 @@ function DaySection({ day, dayIndex, onReorder, onPlaceClick, activePlaceId }: {
items={day.items.map(i => i.place_id)} items={day.items.map(i => i.place_id)}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
<div className="space-y-3"> <div className="space-y-4 relative">
{day.items.map((item, index) => ( <div className="absolute left-[13px] top-4 bottom-4 w-0.5 bg-gray-100 -z-10" />
<div key={item.place_id}>
<SortableItem
item={item}
isActive={activePlaceId === item.place_id}
onClick={() => onPlaceClick(item.place_id)}
/>
{/* Travel time block between places */} <div className="flex items-center gap-3 text-[10px] font-bold text-orange-600 uppercase tracking-widest px-2 py-2">
{index < day.items.length - 1 && ( <Sun className="h-3.5 w-3.5" />
<div className="flex items-center gap-2 py-2 px-4 text-xs text-muted-foreground"> SABAH
<div className="w-0.5 h-6 bg-gradient-to-b from-primary/40 to-primary/10 mx-auto" /> </div>
<Car className="h-3 w-3" />
<span>{travelTimes[index + 1] || 10} dk sürüş</span> {day.items.map((item, index) => (
</div> <div key={item.place_id} id={`place-${item.place_id}`} className="relative group">
)} <div className={cn(
"absolute left-[9px] top-5 w-2.5 h-2.5 rounded-full border-2 z-10 transition-all",
activePlaceId === item.place_id ? "bg-orange-600 border-orange-600 scale-125 shadow-lg shadow-orange-600/40" : "bg-white border-gray-300"
)} />
<div className="pl-8">
<SortableItem
item={item}
isActive={activePlaceId === item.place_id}
onClick={() => onPlaceClick(item.place_id)}
onDelete={() => onDeletePlace(dayIndex, item.place_id)}
onUpdateNote={(note) => onUpdatePlaceNote(dayIndex, item.place_id, note)}
index={index}
/>
{index < day.items.length - 1 && (
<div className="my-4 ml-2 flex items-center gap-2 text-[10px] font-bold text-gray-400 uppercase tracking-widest italic">
<Car className="h-3.5 w-3.5" />
<span>~15 DK SÜRÜŞ</span>
</div>
)}
</div>
</div> </div>
))} ))}
<div className="pl-8 pt-2">
<PlaceSearch
onPlaceSelect={(place) => onAddPlace(dayIndex, place)}
placeholder="Yeni bir durak ekle..."
/>
</div>
</div> </div>
</SortableContext> </SortableContext>
</DndContext> </DndContext>
</div> </motion.div>
); );
} }
function SortableItem({ item, isActive, onClick }: { item: Place; isActive: boolean; onClick: () => void }) { function SortableItem({
item,
isActive,
onClick,
onDelete,
onUpdateNote,
index
}: {
item: Place;
isActive: boolean;
onClick: () => void;
onDelete: () => void;
onUpdateNote: (note: string) => void;
index: number
}) {
const [isEditingNote, setIsEditingNote] = useState(false);
const [note, setNote] = useState(item.notes || '');
const { const {
attributes, attributes,
listeners, listeners,
@ -169,81 +260,111 @@ function SortableItem({ item, isActive, onClick }: { item: Place; isActive: bool
opacity: isDragging ? 0.5 : 1, opacity: isDragging ? 0.5 : 1,
}; };
const photoUrl = item.photo_reference ? api.getPhotoUrl(item.photo_reference) : null; const handleSaveNote = (e: React.MouseEvent) => {
e.stopPropagation();
onUpdateNote(note);
setIsEditingNote(false);
};
const photoUrl = item.photo_reference ? (
item.photo_reference.startsWith('http') ? item.photo_reference : api.getPhotoUrl(item.photo_reference)
) : null;
return ( return (
<div ref={setNodeRef} style={style} className="group relative"> <div ref={setNodeRef} style={style} className="group/item relative">
<Card <Card
className={cn( className={cn(
"overflow-hidden cursor-pointer transition-all duration-200 border-2", "overflow-hidden cursor-pointer transition-all border rounded-2xl",
isDragging && "border-dashed", isDragging && "border-dashed",
isActive isActive
? "border-primary shadow-lg ring-2 ring-primary/20" ? "border-orange-600 bg-orange-50/30 shadow-md ring-1 ring-orange-600/10"
: "border-gray-200 hover:border-primary hover:shadow-md" : "border-gray-100 bg-white hover:border-gray-200 hover:shadow-sm"
)} )}
onClick={onClick} onClick={onClick}
> >
<CardContent className="p-0 flex"> <CardContent className="p-3 flex items-center gap-4">
{/* Image Section - 128px square */} <div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center text-gray-900 font-bold text-xs shrink-0">
<div className="w-32 h-32 shrink-0 relative overflow-hidden"> {index + 1}
{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>
</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}
</Badge>
</div>
)}
</div> </div>
{/* Content Section */} <div className="flex-1 min-w-0">
<div className="flex-1 p-4 flex flex-col justify-between min-w-0"> <div className="flex items-center justify-between">
<div> <div className="flex items-center gap-2">
<div className="flex items-start justify-between gap-2 mb-1"> <h4 className="text-sm font-bold text-gray-900 truncate">{item.name}</h4>
<h4 className="font-bold text-base text-gray-900 line-clamp-1 flex-1">{item.name}</h4> <Badge variant="secondary" className="bg-gray-100 text-gray-500 text-[9px] font-bold px-1.5 py-0 h-4">
<div {item.category}
{...attributes} </Badge>
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 hover:bg-gray-100 rounded transition-colors opacity-0 group-hover:opacity-100"
>
<GripVertical className="h-4 w-4 text-gray-400" />
</div> </div>
</div> <div className="flex items-center gap-1 opacity-0 group-hover/item:opacity-100 transition-all">
<p className="text-sm text-muted-foreground line-clamp-2 leading-relaxed">{item.description}</p> <div
</div> {...attributes}
{...listeners}
className="p-1 hover:bg-gray-100 rounded text-gray-400 cursor-grab active:cursor-grabbing"
>
<GripVertical className="h-3.5 w-3.5" />
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<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={() => setIsEditingNote(true)} className="text-xs font-bold gap-2">
<Edit3 className="h-3.5 w-3.5 text-orange-600" />
Not Düzenle
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onDelete(); }} className="text-xs font-bold gap-2 text-red-500">
<Trash2 className="h-3.5 w-3.5" />
Kaldır
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Meta Information */} <div className="flex items-center gap-3 mt-1">
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-2"> <div className="flex items-center gap-1 text-[10px] font-medium text-gray-500">
<div className="flex items-center gap-1"> <Clock className="h-3 w-3" />
<Clock className="h-3.5 w-3.5" /> {item.start_time || '09:00'} - 11:00
<span>{item.estimated_duration_minutes}dk</span> </div>
</div> {item.rating && (
<div className="flex items-center gap-1 truncate"> <div className="flex items-center gap-1 text-[10px] font-medium text-gray-500">
<MapPin className="h-3.5 w-3.5 shrink-0" /> <Star className="h-3 w-3 fill-orange-400 text-orange-400" />
<span className="truncate">{item.category}</span> {item.rating}
</div> </div>
</div> )}
</div>
{isEditingNote ? (
<div className="mt-2 space-y-2" onClick={(e) => e.stopPropagation()}>
<Textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Not ekle..."
className="bg-gray-50 border-gray-200 rounded-lg p-2 text-xs font-medium min-h-[50px]"
/>
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setIsEditingNote(false)} className="text-[10px] font-bold h-6">Vazgeç</Button>
<Button size="sm" onClick={handleSaveNote} className="bg-orange-600 text-white text-[10px] font-bold h-6 px-3 rounded-lg">Kaydet</Button>
</div>
</div>
) : (
item.notes && (
<div className="mt-2 p-2 bg-orange-50 rounded-lg border-l-2 border-orange-600">
<p className="text-[11px] font-medium italic text-orange-800">
{item.notes}
</p>
</div>
)
)}
</div> </div>
{photoUrl && (
<div className="w-16 h-16 rounded-xl overflow-hidden shrink-0">
<img src={photoUrl} alt={item.name} className="w-full h-full object-cover" />
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -13,6 +13,7 @@ export interface Place {
estimated_duration_minutes: number; estimated_duration_minutes: number;
start_time: string; start_time: string;
end_time: string; end_time: string;
notes?: string;
} }
export interface ItineraryDay { export interface ItineraryDay {
@ -20,6 +21,7 @@ export interface ItineraryDay {
items: Place[]; items: Place[];
total_distance?: string; total_distance?: string;
total_duration?: string; total_duration?: string;
notes?: string;
} }
export interface Trip { export interface Trip {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,19 +1,20 @@
import { useState, useMemo, useEffect, useRef, useCallback, memo, Suspense, lazy } from 'react'; import { useState, useMemo, useEffect, useRef, useCallback, memo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import api from '@/db/api'; import api from '@/db/api';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { Input } from '@/components/ui/input';
import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form'; import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod'; import * as z from 'zod';
import { format, differenceInDays } from 'date-fns'; import { format, differenceInDays } from 'date-fns';
import { MapPin, Loader2 } from 'lucide-react'; import { Loader2, ArrowRight, ArrowLeft, Sparkles, MapPin, Calendar, Users, Coffee, Heart } from 'lucide-react';
import { parseApiError } from '@/utils/errorHandler'; import { parseApiError } from '@/utils/errorHandler';
import { retryWithBackoff, withTimeout } from '@/utils/retryWithBackoff'; import { retryWithBackoff, withTimeout } from '@/utils/retryWithBackoff';
import { motion, AnimatePresence } from 'framer-motion';
import { Button } from '@/components/ui/button';
// Constants // Constants
import { LOADING_STEPS } from '@/constants/planner'; import { LOADING_STEPS } from '@/constants/planner';
@ -23,8 +24,6 @@ import { DateSelector } from '@/components/planner/DateSelector';
import { TravelerInput } from '@/components/planner/TravelerInput'; import { TravelerInput } from '@/components/planner/TravelerInput';
import { AccommodationSelector } from '@/components/planner/AccommodationSelector'; import { AccommodationSelector } from '@/components/planner/AccommodationSelector';
import { InterestsGrid } from '@/components/planner/InterestsGrid'; import { InterestsGrid } from '@/components/planner/InterestsGrid';
import { SubmitButton } from '@/components/planner/SubmitButton';
import { HeroSection } from '@/components/planner/HeroSection';
// Form validation schema // Form validation schema
const formSchema = z.object({ const formSchema = z.object({
@ -74,11 +73,19 @@ const formSchema = z.object({
type FormValues = z.infer<typeof formSchema>; type FormValues = z.infer<typeof formSchema>;
const STEPS = [
{ id: 'dates', title: 'Tarihler', icon: Calendar, description: 'Ne zaman gidiyorsunuz?' },
{ id: 'travelers', title: 'Seyahat Grubu', icon: Users, description: 'Kiminle seyahat ediyorsunuz?' },
{ id: 'accommodation', title: 'Konaklama', icon: Coffee, description: 'Nasıl bir konaklama istersiniz?' },
{ id: 'interests', title: 'İlgi Alanları', icon: Heart, description: 'Neleri keşfetmek istersiniz?' }
];
const PlannerPage = () => { const PlannerPage = () => {
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [loadingStep, setLoadingStep] = useState(0); const [loadingStep, setLoadingStep] = useState(0);
const [currentStep, setCurrentStep] = useState(0);
const [datePickerOpen, setDatePickerOpen] = useState(false); const [datePickerOpen, setDatePickerOpen] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
@ -95,40 +102,33 @@ const PlannerPage = () => {
}, },
}); });
// Load draft from localStorage
useEffect(() => {
const savedFormData = localStorage.getItem('planner_form_draft');
if (savedFormData) {
try {
const parsed = JSON.parse(savedFormData);
if (parsed.dateRange?.from) parsed.dateRange.from = new Date(parsed.dateRange.from);
if (parsed.dateRange?.to) parsed.dateRange.to = new Date(parsed.dateRange.to);
form.reset(parsed);
} catch (e) {
console.error('Draft loading error:', e);
}
}
}, [form]);
// Save draft to localStorage
useEffect(() => {
const subscription = form.watch((value) => {
localStorage.setItem('planner_form_draft', JSON.stringify(value));
});
return () => subscription.unsubscribe();
}, [form.watch]);
const watchedValues = form.watch(); const watchedValues = form.watch();
const formProgress = useMemo(() => { const nextStep = async () => {
let completed = 0; // Validate current step fields
const total = 4; const currentStepId = STEPS[currentStep].id;
if (watchedValues.dateRange?.from && watchedValues.dateRange?.to) completed++; let isValid = false;
if (watchedValues.travelers >= 1) completed++;
if (watchedValues.accommodation) completed++; if (currentStepId === 'dates') {
if (watchedValues.interests?.length > 0) completed++; isValid = await form.trigger('dateRange');
return Math.round((completed / total) * 100); } else if (currentStepId === 'travelers') {
}, [watchedValues]); isValid = await form.trigger('travelers');
} else if (currentStepId === 'accommodation') {
isValid = await form.trigger('accommodation');
} else if (currentStepId === 'interests') {
isValid = await form.trigger('interests');
}
if (isValid && currentStep < STEPS.length - 1) {
setCurrentStep(prev => prev + 1);
}
};
const prevStep = () => {
if (currentStep > 0) {
setCurrentStep(prev => prev - 1);
}
};
const handleInterestToggle = useCallback((interestId: string) => { const handleInterestToggle = useCallback((interestId: string) => {
const currentInterests = form.getValues('interests'); const currentInterests = form.getValues('interests');
@ -138,15 +138,6 @@ const PlannerPage = () => {
form.setValue('interests', newInterests, { shouldValidate: true }); form.setValue('interests', newInterests, { shouldValidate: true });
}, [form]); }, [form]);
const handleCancel = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setLoading(false);
setLoadingStep(0);
toast.info('Planlama iptal edildi');
}, []);
const simulateLoadingSteps = useCallback(() => { const simulateLoadingSteps = useCallback(() => {
setLoadingStep(0); setLoadingStep(0);
const interval = setInterval(() => { const interval = setInterval(() => {
@ -207,7 +198,6 @@ const PlannerPage = () => {
itinerary: result, itinerary: result,
}); });
tripId = savedTrip.id; tripId = savedTrip.id;
localStorage.removeItem('planner_form_draft');
} else { } else {
sessionStorage.setItem('pending_trip', JSON.stringify(result)); sessionStorage.setItem('pending_trip', JSON.stringify(result));
navigate('/login', { state: { from: '/planner', message: 'Planınızı kaydetmek için giriş yapın' } }); navigate('/login', { state: { from: '/planner', message: 'Planınızı kaydetmek için giriş yapın' } });
@ -227,127 +217,241 @@ const PlannerPage = () => {
} }
}; };
const progress = ((currentStep + 1) / STEPS.length) * 100;
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 z-0 opacity-20">
<img
src="https://images.unsplash.com/photo-1541167760496-1628856ab772?auto=format&fit=crop&q=80&w=2400"
alt="Loading bg"
className="w-full h-full object-cover grayscale"
/>
</div>
<div className="relative z-10 max-w-xl w-full px-6 text-center space-y-8">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="w-24 h-24 bg-primary rounded-2xl flex items-center justify-center mx-auto shadow-2xl shadow-primary/20"
>
<Loader2 className="h-12 w-12 text-white animate-spin" />
</motion.div>
<div className="space-y-4">
<h2 className="text-3xl md:text-4xl font-black text-white tracking-tighter uppercase leading-tight">
{LOADING_STEPS[loadingStep].label}
</h2>
<div className="w-full h-3 bg-white/10 rounded-full overflow-hidden border border-white/10">
<motion.div
className="h-full bg-primary"
initial={{ width: 0 }}
animate={{ width: `${LOADING_STEPS[loadingStep].progress}%` }}
transition={{ duration: 0.5 }}
/>
</div>
</div>
<p className="text-white/40 font-bold tracking-widest uppercase text-xs italic">
"Sizin için en kusursuz Kapadokya efsanesini kurguluyoruz..."
</p>
</div>
</div>
);
}
return ( return (
<div className="min-h-screen flex"> <div className="min-h-screen bg-background flex flex-col lg:flex-row overflow-hidden">
{/* Left Side - Form */} {/* Sidebar - Progress & Stats */}
<div className="w-full lg:w-[45%] xl:w-[40%] bg-white p-6 md:p-10 overflow-y-auto"> <div className="w-full lg:w-[320px] xl:w-[380px] bg-secondary p-8 lg:p-12 flex flex-col justify-between relative overflow-hidden shrink-0">
<div className="max-w-xl mx-auto space-y-8"> <div className="absolute top-0 left-0 w-full h-full bg-primary/5 rounded-full blur-[80px] -translate-x-1/2 -translate-y-1/2" />
<div className="space-y-2">
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 tracking-tight"> <div className="relative z-10 space-y-10">
Hayalinizdeki <span className="text-orange-600">Kapadokya</span> gezisi başlasın <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 text-white tracking-tighter uppercase">Kapadokya <span className="text-primary">Efsanesi</span></span>
</div>
<div className="space-y-6">
<h1 className="text-4xl font-black text-white leading-[0.95] tracking-tighter uppercase">
ROTANIZI <br /> <span className="text-primary">TASARLAYIN</span>
</h1> </h1>
<p className="text-gray-500 text-lg"> <p className="text-white/40 text-base font-medium italic">
Kişiselleştirilmiş, Google doğrulamalı ve yapay zeka destekli gezi planlayıcısı. "Her durak bir hikaye, her an bir anı. Size özel kurgulanmış seyahat mimarisi."
</p> </p>
</div> </div>
<div className="space-y-3"> <div className="space-y-5">
<div className="flex items-center justify-between text-sm"> {STEPS.map((step, i) => {
<span className="text-gray-600 font-medium">Planlama İlerlemesi</span> const Icon = step.icon;
<span className="text-orange-600 font-bold">{formProgress}%</span> const isActive = i === currentStep;
</div> const isCompleted = i < currentStep;
<Progress value={formProgress} className="h-2 bg-orange-100" />
</div>
<Form {...form}> return (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8"> <div key={step.id} className="flex items-center gap-5 group">
{/* Destination */} <div className={`w-12 h-12 rounded-xl flex items-center justify-center border-2 transition-luxury ${
<div className="space-y-3"> isActive ? 'bg-primary border-primary text-white scale-110' :
<Label className="text-sm font-bold text-gray-700">Gidilecek Yer</Label> isCompleted ? 'bg-white/5 border-primary/40 text-primary' :
<div className="relative"> 'bg-white/5 border-white/10 text-white/20'
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" /> }`}>
<Input <Icon className="h-5 w-5" />
value="Kapadokya, Nevşehir" </div>
disabled <div className="space-y-0.5">
className="pl-10 h-12 bg-gray-50 border-gray-200 font-medium" <div className={`text-[10px] font-black uppercase tracking-widest ${isActive ? 'text-white' : 'text-white/20'}`}>Step 0{i+1}</div>
/> <div className={`text-lg font-bold ${isActive ? 'text-white' : 'text-white/40'}`}>{step.title}</div>
</div>
</div>
{/* 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>
</div>
<InterestsGrid selectedInterests={field.value} onToggle={handleInterestToggle} />
<FormMessage />
</FormItem>
)}
/>
{loading && (
<div className="p-4 bg-orange-50 border border-orange-100 rounded-xl space-y-3 animate-in fade-in slide-in-from-bottom-2">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 text-orange-600 animate-spin" />
<span className="text-sm font-bold text-orange-900">{LOADING_STEPS[loadingStep].label}</span>
</div> </div>
<Progress value={LOADING_STEPS[loadingStep].progress} className="h-1.5 bg-orange-200" />
</div> </div>
)} );
})}
</div>
</div>
<div className="pt-4"> <div className="relative z-10 pt-10">
<SubmitButton <div className="p-6 bg-white/5 backdrop-blur-xl rounded-2xl border border-white/10 space-y-3">
loading={loading} <div className="flex justify-between items-center text-white/40 text-[10px] font-bold uppercase tracking-widest">
disabled={formProgress < 100} <span>Mükemmellik Oranı</span>
onCancel={handleCancel} <span>100%</span>
/> </div>
<div className="h-1.5 bg-white/10 rounded-full overflow-hidden">
<div className="h-full bg-primary" style={{ width: `${progress}%` }} />
</div>
</div>
</div>
</div>
{/* Main Form Area */}
<div className="flex-1 bg-white dark:bg-card p-8 lg:p-16 overflow-y-auto">
<div className="max-w-2xl mx-auto h-full flex flex-col">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 flex flex-col">
<AnimatePresence mode="wait">
<motion.div
key={currentStep}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.5, ease: "easeOut" }}
className="flex-1 space-y-10"
>
<div className="space-y-3">
<h2 className="text-3xl md:text-5xl font-black text-gray-900 dark:text-white tracking-tighter leading-[0.95] uppercase">
{STEPS[currentStep].description}
</h2>
<p className="text-lg text-gray-500 font-medium italic">
Lütfen tercihlerinizi belirleyin, biz sizin için kurgulayalım.
</p>
</div>
<div className="space-y-8 pt-6">
{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>
)}
/>
)}
{currentStep === 1 && (
<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>
)}
/>
)}
{currentStep === 2 && (
<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>
)}
/>
)}
{currentStep === 3 && (
<FormField
control={form.control}
name="interests"
render={({ field }) => (
<FormItem className="space-y-3">
<div className="flex justify-between items-end">
<Label className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">Kişisel İ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 Controls */}
<div className="pt-12 mt-auto flex items-center justify-between gap-4 border-t border-gray-100">
<Button
type="button"
variant="ghost"
size="lg"
onClick={prevStep}
disabled={currentStep === 0}
className="h-14 px-8 text-base font-bold rounded-xl hover:bg-gray-50 group"
>
<ArrowLeft className="mr-2 h-5 w-5 group-hover:-translate-x-1 transition-transform" />
Geri
</Button>
{currentStep < STEPS.length - 1 ? (
<Button
type="button"
size="lg"
onClick={nextStep}
className="h-14 px-10 text-base font-black bg-primary hover:bg-primary-dark rounded-xl shadow-lg shadow-primary/20 group uppercase tracking-widest"
>
Devam Et
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
</Button>
) : (
<Button
type="submit"
size="lg"
className="h-14 px-12 text-base font-black bg-primary hover:bg-primary-dark rounded-xl shadow-lg shadow-primary/20 group uppercase tracking-widest"
>
Rotayı Oluştur
<Sparkles className="ml-2 h-5 w-5 animate-pulse" />
</Button>
)}
</div> </div>
</form> </form>
</Form> </Form>
</div> </div>
</div> </div>
{/* Right Side - Hero */}
<HeroSection />
</div> </div>
); );
}; };

View File

@ -1,14 +1,15 @@
import { useEffect, useState } from 'react'; import { useEffect, useState, useCallback, useMemo } from 'react';
import { useParams } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import api, { Trip, Place } from '@/db/api'; import api, { Trip, Place, ItineraryDay } from '@/db/api';
import { Timeline } from '@/components/trip/Timeline'; import { Timeline } from '@/components/trip/Timeline';
import { TripMap } from '@/components/trip/Map'; import { TripMap } from '@/components/trip/Map';
import { Loader2, Share2, Download, Copy, Calendar, MapPin, Clock } from 'lucide-react'; import { Loader2, Share2, Download, Calendar, MapPin, Trash2, ChevronLeft, Zap, Plus, Compass, ChevronRight, Save, Wand2, Sparkles, LayoutGrid, RotateCcw, RotateCw } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { tr } from 'date-fns/locale'; import { tr } from 'date-fns/locale';
import { motion, AnimatePresence } from 'framer-motion';
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
@ -16,27 +17,37 @@ import {
SheetTitle, SheetTitle,
SheetTrigger, SheetTrigger,
} from '@/components/ui/sheet'; } from '@/components/ui/sheet';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { cn } from '@/lib/utils';
export default function TripDetailsPage() { export default function TripDetailsPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [trip, setTrip] = useState<Trip | null>(null); const [trip, setTrip] = useState<Trip | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activePlaceId, setActivePlaceId] = useState<string | null>(null); const [activePlaceId, setActivePlaceId] = useState<string | null>(null);
const [isMapSheetOpen, setIsMapSheetOpen] = useState(false); const [isMapSheetOpen, setIsMapSheetOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [selectedDayIndex, setSelectedDayIndex] = useState(0);
useEffect(() => { const loadTrip = useCallback(async (tripId: string) => {
if (id) {
loadTrip(id);
}
}, [id]);
const loadTrip = async (tripId: string) => {
try { try {
const data = await api.getTripById(tripId); const data = await api.getTripById(tripId);
if (data) { if (data) {
setTrip(data); setTrip(data);
} else { } else {
toast.error('Gezi bulunamadı'); toast.error('Gezi bulunamadı');
navigate('/explore');
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@ -44,165 +55,322 @@ export default function TripDetailsPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [navigate]);
const handleReorder = async (dayIndex: number, newItems: Place[]) => { useEffect(() => {
if (!trip) return; if (id) {
loadTrip(id);
const newItinerary = { ...trip.itinerary };
newItinerary.days[dayIndex].items = newItems;
// 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üş`;
}
}
} catch (error) {
console.warn('Failed to calculate directions:', error);
} }
}, [id, loadTrip]);
setTrip({ ...trip, itinerary: newItinerary }); const handleUpdateTrip = async (updatedTrip: Trip) => {
setTrip(updatedTrip);
try { try {
await api.updateTrip(trip.id, { itinerary: newItinerary }); await api.updateTrip(updatedTrip.id, { itinerary: updatedTrip.itinerary });
toast.success('Rota güncellendi');
} catch (error) { } catch (error) {
toast.error('Değişiklikler kaydedilemedi'); toast.error('Değişiklikler kaydedilemedi');
} }
}; };
const handleReorder = (dayIndex: number, newItems: Place[]) => {
if (!trip) return;
const newItinerary = { ...trip.itinerary };
newItinerary.days[dayIndex].items = newItems;
handleUpdateTrip({ ...trip, itinerary: newItinerary });
};
const handleAddPlace = (dayIndex: number, place: Place) => {
if (!trip) return;
const newItinerary = { ...trip.itinerary };
newItinerary.days[dayIndex].items = [...newItinerary.days[dayIndex].items, place];
handleUpdateTrip({ ...trip, itinerary: newItinerary });
toast.success(`${place.name} rotaya eklendi`);
};
const handleDeletePlace = (dayIndex: number, placeId: string) => {
if (!trip) return;
const newItinerary = { ...trip.itinerary };
newItinerary.days[dayIndex].items = newItinerary.days[dayIndex].items.filter(i => i.place_id !== placeId);
handleUpdateTrip({ ...trip, itinerary: newItinerary });
toast.success('Durak kaldırıldı');
};
const handleUpdatePlaceNote = (dayIndex: number, placeId: string, note: string) => {
if (!trip) return;
const newItinerary = { ...trip.itinerary };
const items = [...newItinerary.days[dayIndex].items];
const itemIndex = items.findIndex(i => i.place_id === placeId);
if (itemIndex > -1) {
items[itemIndex] = { ...items[itemIndex], notes: note };
newItinerary.days[dayIndex].items = items;
handleUpdateTrip({ ...trip, itinerary: newItinerary });
}
};
const handleUpdateDayNote = (dayIndex: number, note: string) => {
if (!trip) return;
const newItinerary = { ...trip.itinerary };
newItinerary.days[dayIndex] = { ...newItinerary.days[dayIndex], notes: note };
handleUpdateTrip({ ...trip, itinerary: newItinerary });
};
const handleAddDay = () => {
if (!trip) return;
const newItinerary = { ...trip.itinerary };
const nextDayNum = newItinerary.days.length + 1;
newItinerary.days.push({
day: nextDayNum,
items: []
});
handleUpdateTrip({ ...trip, itinerary: newItinerary });
toast.success(`Gün ${nextDayNum} eklendi`);
setSelectedDayIndex(newItinerary.days.length - 1);
};
const handleShare = () => { const handleShare = () => {
const url = window.location.href; const url = window.location.href;
navigator.clipboard.writeText(url); navigator.clipboard.writeText(url);
toast.success('Link kopyalandı'); toast.success('Link kopyalandı');
}; };
const handleExport = () => { const handleDelete = async () => {
toast.info('Dışa aktarma özelliği yakında eklenecek'); if (!trip) return;
setIsDeleting(true);
try {
await api.deleteTrip(trip.id);
toast.success('Gezi silindi');
navigate('/account');
} catch (error) {
toast.error('Gezi silinemedi');
setIsDeleting(false);
}
}; };
const handleDuplicate = () => { const totalPlaces = useMemo(() =>
toast.info('Kopyalama özelliği yakında eklenecek'); trip?.itinerary.days.reduce((sum, day) => sum + (day.items?.length || 0), 0) || 0,
}; [trip]
);
if (loading) { if (loading) {
return ( return (
<div className="flex h-screen items-center justify-center"> <div className="h-screen w-full flex flex-col items-center justify-center bg-background">
<Loader2 className="h-12 w-12 animate-spin text-primary" /> <Loader2 className="h-10 w-10 animate-spin text-primary" />
</div> </div>
); );
} }
if (!trip) return <div className="flex items-center justify-center min-h-screen">Gezi bulunamadı</div>; if (!trip) return null;
// Calculate trip stats
const totalPlaces = trip.itinerary.days.reduce((sum, day) => sum + day.items.length, 0);
const totalDistance = trip.itinerary.days.reduce((sum, day) => {
const dist = day.total_distance ? parseFloat(day.total_distance) : 0;
return sum + dist;
}, 0);
const dayCount = trip.itinerary.days.length;
return ( return (
<div className="flex flex-col h-screen overflow-hidden"> <div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden bg-background">
{/* Header Area */} {/* Sub-Header Navbar */}
<div className="bg-gradient-to-b from-gray-100 to-white border-b"> <div className="h-14 border-b bg-white flex items-center px-4 justify-between shrink-0">
<div className="max-w-7xl mx-auto px-6 py-6"> <div className="flex items-center gap-4">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4"> <div className="flex items-center gap-1 border rounded-lg p-0.5 bg-gray-50/50">
<div className="flex-1"> <Button variant="ghost" size="icon" className="h-7 w-7 text-gray-400 hover:text-gray-900" onClick={() => navigate(-1)}>
<h1 className="text-3xl font-bold text-gray-900 mb-2">{trip.title}</h1> <RotateCcw className="h-3.5 w-3.5" />
<div className="flex flex-wrap items-center gap-4 text-sm text-muted-foreground"> </Button>
<div className="flex items-center gap-1.5"> <Button variant="ghost" size="icon" className="h-7 w-7 text-gray-400 hover:text-gray-900">
<Calendar className="h-4 w-4" /> <RotateCw className="h-3.5 w-3.5" />
<span> </Button>
{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> </div>
<div className="h-4 w-px bg-gray-200 mx-1" />
<h1 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
{trip.title}
<LayoutGrid className="h-3.5 w-3.5 text-gray-400" />
</h1>
</div>
<div className="flex items-center gap-2">
<Button variant="default" className="bg-orange-600 hover:bg-orange-700 h-9 px-4 rounded-full text-xs font-bold gap-2">
Kaydetmek için giriş yap
</Button>
<Button variant="ghost" size="icon" className="h-9 w-9 text-gray-500" onClick={handleShare}>
<Share2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-9 w-9 text-gray-500" onClick={handleDelete}>
<Trash2 className="h-4 w-4" />
</Button>
<div className="h-4 w-px bg-gray-200 mx-1" />
<Button variant="outline" className="h-9 px-3 rounded-xl border-gray-200 text-xs font-bold gap-2">
<Sparkles className="h-3.5 w-3.5 text-purple-600" />
AI Route
</Button>
<Button variant="outline" className="h-9 px-3 rounded-xl border-gray-200 text-xs font-bold gap-2">
<Zap className="h-3.5 w-3.5 text-blue-600" />
Optimize
</Button>
<Button variant="ghost" size="icon" className="h-9 w-9 text-gray-500">
<Plus className="h-4 w-4" />
</Button>
</div> </div>
</div> </div>
<main className="flex flex-1 overflow-hidden"> <main className="flex-1 flex overflow-hidden">
{/* Timeline Panel */} {/* Left Sidebar: Days Selection */}
<div className="w-full lg:w-[40%] overflow-y-auto bg-white border-r"> <aside className="w-16 md:w-20 border-r bg-gray-50/30 flex flex-col shrink-0 overflow-y-auto custom-scrollbar">
<Timeline <div className="py-4 flex flex-col items-center">
itinerary={trip.itinerary} <span className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-4">Days</span>
onReorder={handleReorder} <div className="flex flex-col gap-3 w-full px-2">
onPlaceClick={(id) => setActivePlaceId(id)} {trip.itinerary.days.map((day, idx) => (
activePlaceId={activePlaceId} <button
/> key={day.day}
</div> onClick={() => setSelectedDayIndex(idx)}
className={cn(
"flex flex-col items-center justify-center py-3 rounded-xl transition-all relative group",
selectedDayIndex === idx
? "bg-orange-600 text-white shadow-lg shadow-orange-600/20"
: "text-gray-400 hover:bg-gray-100 hover:text-gray-900"
)}
>
<span className="text-[10px] font-bold uppercase tracking-tighter">Day {day.day}</span>
<span className="text-[9px] opacity-60 font-medium">
{format(new Date(trip.start_date), 'MM-dd', { locale: tr })}
</span>
<div className="mt-1 flex items-center justify-center">
<Badge variant="secondary" className={cn(
"text-[8px] px-1 py-0 h-3 min-w-[12px]",
selectedDayIndex === idx ? "bg-white/20 text-white" : "bg-gray-200 text-gray-500"
)}>
{day.items.length}
</Badge>
</div>
</button>
))}
<Button
variant="ghost"
size="icon"
onClick={handleAddDay}
className="h-10 w-full rounded-xl text-gray-400 hover:text-primary hover:bg-primary/5 border border-dashed border-gray-200"
>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
</aside>
{/* Map Panel - Desktop */} {/* Center Panel: Focused Day Timeline */}
<div className="hidden lg:block flex-1 relative bg-muted"> <section className="w-full lg:w-[45%] xl:w-[40%] overflow-y-auto bg-white border-r border-border custom-scrollbar flex flex-col">
<div className="p-6 border-b sticky top-0 bg-white/95 backdrop-blur-sm z-30 flex items-center justify-between">
<div className="space-y-0.5">
<h2 className="text-xl font-bold text-gray-900">
Day {trip.itinerary.days[selectedDayIndex].day} - Timeline
</h2>
<p className="text-xs text-gray-500 font-medium">{trip.itinerary.days[selectedDayIndex].items.length} places selected</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-8 rounded-lg text-xs font-bold gap-1.5 border-gray-200">
<Sparkles className="h-3.5 w-3.5 text-orange-500" />
AI
</Button>
<Button variant="default" size="sm" className="h-8 rounded-lg text-xs font-bold gap-1.5 bg-orange-600 hover:bg-orange-700">
<Plus className="h-3.5 w-3.5" />
Yer Ekle
</Button>
</div>
</div>
<div className="flex-1">
{/* Smart Suggestion Card (Placeholder for visual matching) */}
<div className="p-6 pb-0">
<div className="bg-orange-50/50 border border-orange-100 rounded-2xl p-4 space-y-3 relative overflow-hidden group">
<div className="absolute -right-4 -top-4 opacity-5 group-hover:rotate-12 transition-transform">
<Wand2 className="h-24 w-24 text-orange-600" />
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-orange-800">Akıllı Tur Önerisi</span>
<Badge className="bg-orange-100 text-orange-600 hover:bg-orange-100 border-none text-[9px] font-bold">%82 Doğruluk</Badge>
</div>
</div>
<p className="text-[11px] text-gray-600 leading-relaxed max-w-[90%]">
Planınız Green Tour rotasıyla %82 uyumlu. Yeraltı şehri ve Ihlara Vadisi gibi doğa ve...
</p>
<div className="flex items-center gap-4 text-[10px] font-bold text-gray-500">
<div className="flex items-center gap-1.5">
<MapPin className="h-3 w-3" /> ~19km
</div>
<div className="flex items-center gap-1.5">
<Zap className="h-3 w-3" /> ~2sa
</div>
</div>
<Button variant="default" className="w-full bg-orange-600 hover:bg-orange-700 h-9 rounded-xl text-[10px] font-bold gap-2">
<Sparkles className="h-3.5 w-3.5" />
Seçenekleri İncele
</Button>
</div>
</div>
<Timeline
itinerary={{ days: [trip.itinerary.days[selectedDayIndex]] }}
onReorder={(idx, items) => handleReorder(selectedDayIndex, items)}
onAddPlace={(idx, place) => handleAddPlace(selectedDayIndex, place)}
onDeletePlace={(idx, id) => handleDeletePlace(selectedDayIndex, id)}
onUpdatePlaceNote={(idx, id, note) => handleUpdatePlaceNote(selectedDayIndex, id, note)}
onUpdateDayNote={(idx, note) => handleUpdateDayNote(selectedDayIndex, note)}
onPlaceClick={(id) => setActivePlaceId(id)}
activePlaceId={activePlaceId}
/>
</div>
</section>
{/* Right Panel: Map */}
<section className="hidden lg:block flex-1 relative bg-secondary">
<TripMap <TripMap
itinerary={trip.itinerary} itinerary={{ days: [trip.itinerary.days[selectedDayIndex]] }}
activePlaceId={activePlaceId} activePlaceId={activePlaceId}
onMarkerClick={(id) => setActivePlaceId(id)} onMarkerClick={(id) => {
setActivePlaceId(id);
const element = document.getElementById(`place-${id}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}}
/> />
</div>
{/* Map Sheet - Mobile */} {/* Legend / Stats overlay */}
<div className="lg:hidden fixed bottom-4 right-4 z-50"> <div className="absolute top-6 left-6 z-10">
<div className="bg-white/90 backdrop-blur-md p-3 rounded-2xl shadow-xl border border-white/20 space-y-1">
<div className="text-[9px] font-black text-gray-400 uppercase tracking-widest px-1">ÖZET</div>
<div className="flex items-center gap-4">
<div className="flex flex-col">
<span className="text-xl font-black text-gray-900 tracking-tighter">{trip.itinerary.days[selectedDayIndex].items.length}</span>
<span className="text-[8px] font-bold text-gray-400 uppercase">DURAK</span>
</div>
<div className="w-px h-6 bg-gray-200" />
<div className="flex flex-col">
<span className="text-xl font-black text-gray-900 tracking-tighter">~{trip.itinerary.days[selectedDayIndex].items.length * 25}</span>
<span className="text-[8px] font-bold text-gray-400 uppercase">KM</span>
</div>
</div>
</div>
</div>
</section>
{/* Mobile Map Button */}
<div className="lg:hidden fixed bottom-6 right-6 z-50">
<Sheet open={isMapSheetOpen} onOpenChange={setIsMapSheetOpen}> <Sheet open={isMapSheetOpen} onOpenChange={setIsMapSheetOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button size="lg" className="rounded-full shadow-lg"> <Button size="lg" className="h-12 px-6 rounded-full shadow-2xl bg-orange-600 hover:bg-orange-700 text-white font-bold uppercase tracking-widest text-[10px]">
<MapPin className="h-5 w-5 mr-2" /> <MapPin className="h-4 w-4 mr-2" />
Haritayı Göster Haritayı
</Button> </Button>
</SheetTrigger> </SheetTrigger>
<SheetContent side="bottom" className="h-[70vh]"> <SheetContent side="bottom" className="h-[80vh] p-0 rounded-t-3xl overflow-hidden bg-white">
<SheetHeader> <SheetHeader className="p-4 border-b">
<SheetTitle>Rota Haritası</SheetTitle> <SheetTitle className="text-lg font-bold">Rota Haritası - Gün {trip.itinerary.days[selectedDayIndex].day}</SheetTitle>
</SheetHeader> </SheetHeader>
<div className="h-full mt-4"> <div className="h-full relative">
<TripMap <TripMap
itinerary={trip.itinerary} itinerary={{ days: [trip.itinerary.days[selectedDayIndex]] }}
activePlaceId={activePlaceId} activePlaceId={activePlaceId}
onMarkerClick={(id) => { onMarkerClick={(id) => {
setActivePlaceId(id); setActivePlaceId(id);
setIsMapSheetOpen(false); setIsMapSheetOpen(false);
const element = document.getElementById(`place-${id}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}} }}
/> />
</div> </div>

View File

@ -39,6 +39,71 @@ function normalizePlaceName(name: string): string {
.replace(/\s*(open air museum|underground city|valley|village|castle|church)\s*$/i, (match) => ' ' + match.trim().toLowerCase()) .replace(/\s*(open air museum|underground city|valley|village|castle|church)\s*$/i, (match) => ' ' + match.trim().toLowerCase())
} }
/**
* Generate a mock itinerary when OpenAI is unavailable.
*/
function generateMockItinerary(startDate: string, endDate: string, interests: string[]) {
const start = new Date(startDate);
const end = new Date(endDate);
const diffTime = Math.abs(end.getTime() - start.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
const numDays = Math.min(diffDays, 14);
const pool = [
{ place_name: "Göreme Open Air Museum", category: "museum", duration: 120, desc: "UNESCO World Heritage site with rock-cut churches." },
{ place_name: "Uçhisar Castle", category: "landmark", duration: 90, desc: "The highest point in Cappadocia with panoramic views." },
{ place_name: "Pasabag (Monks Valley)", category: "nature", duration: 60, desc: "Famous fairy chimneys with multiple caps." },
{ place_name: "Devrent Valley (Imagination Valley)", category: "nature", duration: 45, desc: "Unique rock formations resembling animals." },
{ 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: "Ihlara Valley", category: "nature", duration: 180, desc: "A stunning canyon with a river and rock-cut churches." },
{ place_name: "Selime Monastery", category: "history", duration: 60, desc: "The largest religious structure in Cappadocia." },
{ 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 phallic-shaped fairy chimneys." },
{ place_name: "Avanos Pottery Workshop", category: "culture", duration: 60, desc: "Traditional pottery making in the Red River town." },
{ place_name: "Zelve Open Air Museum", category: "museum", duration: 120, desc: "An abandoned cave town with ancient churches." },
{ place_name: "Ortahisar Castle", category: "landmark", duration: 60, desc: "A massive rock formation used as a fortress." },
{ place_name: "Rose Valley Sunset Hike", category: "nature", duration: 120, desc: "Beautiful valley that turns pink at sunset." },
{ place_name: "Red Valley", category: "nature", duration: 90, desc: "Stunning valley with sharp ridges and red hues." },
{ place_name: "Çavuşin Village", category: "culture", duration: 60, desc: "An old Greek village with a massive rock castle." },
];
const days = [];
for (let i = 1; i <= numDays; i++) {
const dailyItems = [];
// Always add Hot Air Balloon on Day 1 or 2 if duration is 120
if (i <= 2) {
dailyItems.push({
place_name: "Hot Air Balloon Flight",
category: "activity",
estimated_duration_minutes: 180,
description: "Breathtaking sunrise flight over the fairy chimneys."
});
}
// Pick 3-4 random items from the pool
const shuffled = [...pool].sort(() => 0.5 - Math.random());
const selected = shuffled.slice(0, 3);
selected.forEach(item => {
dailyItems.push({
place_name: item.place_name,
category: item.category,
estimated_duration_minutes: item.duration,
description: item.desc
});
});
days.push({
day: i,
items: dailyItems
});
}
return { days };
}
serve(async (req) => { serve(async (req) => {
if (req.method === 'OPTIONS') { if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders }) return new Response('ok', { headers: corsHeaders })
@ -53,57 +118,73 @@ serve(async (req) => {
SUPABASE_SERVICE_ROLE_KEY! SUPABASE_SERVICE_ROLE_KEY!
) )
// 1. Call OpenAI to generate itinerary let itinerary;
const systemPrompt = `You are an expert travel planner for Cappadocia, Turkey.
Generate a personalized travel itinerary based on user preferences.
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: // 1. Call OpenAI to generate itinerary (if API key exists)
{ if (OPENAI_API_KEY && OPENAI_API_KEY !== 'PASTE_YOUR_OPENAI_API_KEY_HERE') {
"days": [ try {
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:
{ {
"day": 1, "days": [
"items": [
{ {
"place_name": "Göreme Open Air Museum", "day": 1,
"category": "museum", "items": [
"estimated_duration_minutes": 120, {
"description": "UNESCO World Heritage site with rock-cut churches." "place_name": "Göreme Open Air Museum",
"category": "museum",
"estimated_duration_minutes": 120,
"description": "UNESCO World Heritage site with rock-cut churches."
}
]
} }
] ]
}`
const userPrompt = `Trip Dates: ${startDate} to ${endDate}. Preferences: ${preferences}`
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' }
}),
})
if (!openaiRes.ok) {
throw new Error(`OpenAI API error: ${openaiRes.statusText}`);
} }
]
}`
const userPrompt = `Trip Dates: ${startDate} to ${endDate}. Preferences: ${preferences}` const openaiData = await openaiRes.json()
itinerary = JSON.parse(openaiData.choices[0].message.content)
const openaiRes = await fetch('https://api.openai.com/v1/chat/completions', { } catch (err) {
method: 'POST', console.error('OpenAI failed, falling back to mock:', err)
headers: { itinerary = generateMockItinerary(startDate, endDate, interests)
'Authorization': `Bearer ${OPENAI_API_KEY}`, }
'Content-Type': 'application/json', } else {
}, console.log('No OpenAI API key found, using mock itinerary')
body: JSON.stringify({ itinerary = generateMockItinerary(startDate, endDate, interests)
model: 'gpt-4o-mini', }
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
],
temperature: 0.3,
response_format: { type: 'json_object' }
}),
})
const openaiData = await openaiRes.json()
const itinerary = JSON.parse(openaiData.choices[0].message.content)
// 2. Verify places with Google Places API (with cache layer) // 2. Verify places with Google Places API (with cache layer)
const verifiedDays = [] const verifiedDays = []
@ -139,25 +220,38 @@ serve(async (req) => {
// Cache MISS - fetching from Google and caching // Cache MISS - fetching from Google and caching
console.log(`Cache MISS for "${item.place_name}" (normalized: "${normalizedName}") - 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 let placeInfo = null;
const searchUrl = `https://maps.googleapis.com/maps/api/place/textsearch/json?query=${encodeURIComponent(item.place_name + ' Cappadocia')}&key=${GOOGLE_MAPS_API_KEY}`
const searchRes = await fetch(searchUrl)
const searchData = await searchRes.json()
if (searchData.results && searchData.results.length > 0) { if (GOOGLE_MAPS_API_KEY && GOOGLE_MAPS_API_KEY !== 'PASTE_YOUR_GOOGLE_MAPS_API_KEY_HERE') {
const place = searchData.results[0] try {
// Text Search to get place_id and basic info
const searchUrl = `https://maps.googleapis.com/maps/api/place/textsearch/json?query=${encodeURIComponent(item.place_name + ' Cappadocia')}&key=${GOOGLE_MAPS_API_KEY}`
const searchRes = await fetch(searchUrl)
const searchData = await searchRes.json()
if (searchData.results && searchData.results.length > 0) {
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,
user_ratings_total: place.user_ratings_total || null,
photo_reference: place.photos?.[0]?.photo_reference || null
}
}
} catch (err) {
console.error(`Google Places API error for ${item.place_name}:`, err)
}
}
if (placeInfo) {
// Store in cache for future use with normalized name // Store in cache for future use with normalized name
const cacheEntry = { const cacheEntry = {
place_name_normalized: normalizedName, place_name_normalized: normalizedName,
place_id: place.place_id, ...placeInfo
name: place.name,
formatted_address: place.formatted_address,
lat: place.geometry.location.lat,
lng: place.geometry.location.lng,
rating: place.rating || null,
user_ratings_total: place.user_ratings_total || null,
photo_reference: place.photos?.[0]?.photo_reference || null
} }
await supabase await supabase
@ -166,14 +260,13 @@ serve(async (req) => {
verifiedItems.push({ verifiedItems.push({
...item, ...item,
place_id: place.place_id, ...placeInfo
name: place.name, })
formatted_address: place.formatted_address, } else {
lat: place.geometry.location.lat, // No Google info found or key missing, use item as is but mark as unverified
lng: place.geometry.location.lng, verifiedItems.push({
rating: place.rating, ...item,
user_ratings_total: place.user_ratings_total, unverified: true
photo_reference: place.photos?.[0]?.photo_reference
}) })
} }
} }
@ -193,7 +286,7 @@ serve(async (req) => {
} }
const startTime = currentTime.toTimeString().slice(0, 5) const startTime = currentTime.toTimeString().slice(0, 5)
currentTime.setMinutes(currentTime.getMinutes() + item.estimated_duration_minutes) currentTime.setMinutes(currentTime.getMinutes() + (item.estimated_duration_minutes || 60))
const endTime = currentTime.toTimeString().slice(0, 5) const endTime = currentTime.toTimeString().slice(0, 5)
currentTime.setMinutes(currentTime.getMinutes() + 30) // 30 min travel/buffer currentTime.setMinutes(currentTime.getMinutes() + 30) // 30 min travel/buffer
@ -206,6 +299,7 @@ serve(async (req) => {
headers: { ...corsHeaders, 'Content-Type': 'application/json' }, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
}) })
} catch (error) { } catch (error) {
console.error('Final catch error:', error)
return new Response(JSON.stringify({ error: error.message }), { return new Response(JSON.stringify({ error: error.message }), {
status: 500, status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }, headers: { ...corsHeaders, 'Content-Type': 'application/json' },

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

17
db/config.php Normal file
View File

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