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