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 { useParams, useNavigate } from 'react-router-dom';
|
||||||
import api, { Trip, Place, ItineraryDay } 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, 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 { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { format } from 'date-fns';
|
import { format, addDays } from 'date-fns';
|
||||||
import { tr } from 'date-fns/locale';
|
import { tr } from 'date-fns/locale';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger,
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
SheetTrigger,
|
|
||||||
} from '@/components/ui/sheet';
|
} from '@/components/ui/sheet';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||||
AlertDialogAction,
|
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogTrigger,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Undo / Redo hook
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
function useUndoRedo<T>(initial: T) {
|
||||||
|
const stack = useRef<T[]>([initial]);
|
||||||
|
const [ptr, setPtr] = useState(0);
|
||||||
|
|
||||||
|
const current = stack.current[ptr];
|
||||||
|
const canUndo = ptr > 0;
|
||||||
|
const canRedo = ptr < stack.current.length - 1;
|
||||||
|
|
||||||
|
const push = useCallback((next: T) => {
|
||||||
|
stack.current = stack.current.slice(0, ptr + 1);
|
||||||
|
stack.current.push(next);
|
||||||
|
setPtr(p => p + 1);
|
||||||
|
}, [ptr]);
|
||||||
|
|
||||||
|
const undo = useCallback(() => { if (canUndo) setPtr(p => p - 1); }, [canUndo]);
|
||||||
|
const redo = useCallback(() => { if (canRedo) setPtr(p => p + 1); }, [canRedo]);
|
||||||
|
|
||||||
|
const jumpTo = useCallback((index: number) => {
|
||||||
|
if (index >= 0 && index < stack.current.length) setPtr(index);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { current, push, undo, redo, canUndo, canRedo, jumpTo, ptr };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Component
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
export default function TripDetailsPage() {
|
export default function TripDetailsPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
const [trip, setTrip] = useState<Trip | null>(null);
|
const [trip, setTrip] = useState<Trip | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [activePlaceId, setActivePlaceId] = useState<string | null>(null);
|
const [activePlaceId, setActivePlaceId] = useState<string | null>(null);
|
||||||
const [isMapSheetOpen, setIsMapSheetOpen] = useState(false);
|
const [isMapSheetOpen, setIsMapSheetOpen] = useState(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
|
||||||
const [selectedDayIndex, setSelectedDayIndex] = useState(0);
|
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) => {
|
// History operates on the full itinerary object
|
||||||
try {
|
const history = useUndoRedo<Trip['itinerary'] | null>(null);
|
||||||
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]);
|
|
||||||
|
|
||||||
|
// ── Load ──────────────────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) {
|
if (!id) return;
|
||||||
loadTrip(id);
|
(async () => {
|
||||||
}
|
try {
|
||||||
}, [id, loadTrip]);
|
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) => {
|
// ── Persist (debounced) ───────────────────────────────────────────────────
|
||||||
setTrip(updatedTrip);
|
const scheduleWrite = useCallback((t: Trip) => {
|
||||||
try {
|
setSaveStatus('unsaved');
|
||||||
await api.updateTrip(updatedTrip.id, { itinerary: updatedTrip.itinerary });
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||||
} catch (error) {
|
saveTimer.current = setTimeout(async () => {
|
||||||
toast.error('Değişiklikler kaydedilemedi');
|
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[]) => {
|
const handleReorder = (dayIndex: number, newItems: Place[]) => {
|
||||||
if (!trip) return;
|
withDays(days => {
|
||||||
const newItinerary = { ...trip.itinerary };
|
days[dayIndex] = { ...days[dayIndex], items: newItems };
|
||||||
newItinerary.days[dayIndex].items = newItems;
|
return days;
|
||||||
handleUpdateTrip({ ...trip, itinerary: newItinerary });
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddPlace = (dayIndex: number, place: Place) => {
|
const handleAddPlace = (dayIndex: number, place: Place) => {
|
||||||
if (!trip) return;
|
withDays(days => {
|
||||||
const newItinerary = { ...trip.itinerary };
|
const day = days[dayIndex];
|
||||||
newItinerary.days[dayIndex].items = [...newItinerary.days[dayIndex].items, place];
|
const last = day.items[day.items.length - 1];
|
||||||
handleUpdateTrip({ ...trip, itinerary: newItinerary });
|
|
||||||
|
// 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`);
|
toast.success(`${place.name} rotaya eklendi`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeletePlace = (dayIndex: number, placeId: string) => {
|
const handleDeletePlace = (dayIndex: number, placeId: string) => {
|
||||||
if (!trip) return;
|
withDays(days => {
|
||||||
const newItinerary = { ...trip.itinerary };
|
days[dayIndex] = { ...days[dayIndex], items: days[dayIndex].items.filter(i => i.place_id !== placeId) };
|
||||||
newItinerary.days[dayIndex].items = newItinerary.days[dayIndex].items.filter(i => i.place_id !== placeId);
|
return days;
|
||||||
handleUpdateTrip({ ...trip, itinerary: newItinerary });
|
});
|
||||||
toast.success('Durak kaldırıldı');
|
toast.success('Durak kaldırıldı');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdatePlaceNote = (dayIndex: number, placeId: string, note: string) => {
|
const handleUpdatePlaceNote = (dayIndex: number, placeId: string, note: string) => {
|
||||||
if (!trip) return;
|
withDays(days => {
|
||||||
const newItinerary = { ...trip.itinerary };
|
const items = [...days[dayIndex].items];
|
||||||
const items = [...newItinerary.days[dayIndex].items];
|
const idx = items.findIndex(i => i.place_id === placeId);
|
||||||
const itemIndex = items.findIndex(i => i.place_id === placeId);
|
if (idx > -1) items[idx] = { ...items[idx], notes: note };
|
||||||
if (itemIndex > -1) {
|
days[dayIndex] = { ...days[dayIndex], items };
|
||||||
items[itemIndex] = { ...items[itemIndex], notes: note };
|
return days;
|
||||||
newItinerary.days[dayIndex].items = items;
|
});
|
||||||
handleUpdateTrip({ ...trip, itinerary: newItinerary });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateDayNote = (dayIndex: number, note: string) => {
|
const handleUpdateDayNote = (dayIndex: number, note: string) => {
|
||||||
if (!trip) return;
|
withDays(days => {
|
||||||
const newItinerary = { ...trip.itinerary };
|
days[dayIndex] = { ...days[dayIndex], notes: note };
|
||||||
newItinerary.days[dayIndex] = { ...newItinerary.days[dayIndex], notes: note };
|
return days;
|
||||||
handleUpdateTrip({ ...trip, itinerary: newItinerary });
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddDay = () => {
|
const handleAddDay = () => {
|
||||||
if (!trip) return;
|
if (!trip) return;
|
||||||
const newItinerary = { ...trip.itinerary };
|
const nextNum = trip.itinerary.days.length + 1;
|
||||||
const nextDayNum = newItinerary.days.length + 1;
|
withDays(days => [...days, { day: nextNum, items: [] }]);
|
||||||
newItinerary.days.push({
|
toast.success(`Gün ${nextNum} eklendi`);
|
||||||
day: nextDayNum,
|
setSelectedDayIndex(trip.itinerary.days.length); // new day
|
||||||
items: []
|
|
||||||
});
|
|
||||||
handleUpdateTrip({ ...trip, itinerary: newItinerary });
|
|
||||||
toast.success(`Gün ${nextDayNum} eklendi`);
|
|
||||||
setSelectedDayIndex(newItinerary.days.length - 1);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleShare = () => {
|
const handleShare = async () => {
|
||||||
const url = window.location.href;
|
try {
|
||||||
navigator.clipboard.writeText(url);
|
await navigator.clipboard.writeText(window.location.href);
|
||||||
toast.success('Link kopyalandı');
|
toast.success('Link panoya kopyalandı');
|
||||||
|
} catch {
|
||||||
|
toast.error('Kopyalama başarısız');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!trip) return;
|
if (!trip) return;
|
||||||
setIsDeleting(true);
|
|
||||||
try {
|
try {
|
||||||
await api.deleteTrip(trip.id);
|
await api.deleteTrip(trip.id);
|
||||||
toast.success('Gezi silindi');
|
toast.success('Gezi silindi');
|
||||||
navigate('/account');
|
navigate('/account');
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('Gezi silinemedi');
|
toast.error('Gezi silinemedi');
|
||||||
setIsDeleting(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalPlaces = useMemo(() =>
|
// ── Computed ──────────────────────────────────────────────────────────────
|
||||||
trip?.itinerary.days.reduce((sum, day) => sum + (day.items?.length || 0), 0) || 0,
|
const getDayDate = useCallback((idx: number) => {
|
||||||
[trip]
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-full flex flex-col items-center justify-center bg-background">
|
<div className="h-[calc(100vh-64px)] flex flex-col items-center justify-center gap-4 bg-background">
|
||||||
<Loader2 className="h-10 w-10 animate-spin text-primary" />
|
<div className="relative w-16 h-16">
|
||||||
|
<div className="absolute inset-0 rounded-full border-4 border-orange-100 border-t-orange-600 animate-spin" />
|
||||||
|
<Navigation className="absolute inset-0 m-auto h-6 w-6 text-orange-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-gray-400">Rota yükleniyor...</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!trip) return null;
|
if (!trip) return null;
|
||||||
|
|
||||||
|
const selectedDay = trip.itinerary.days[selectedDayIndex];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden bg-background">
|
<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">
|
{/* ── Sub-header ──────────────────────────────────────────────────── */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="h-14 border-b bg-white flex items-center px-4 gap-3 shrink-0 shadow-sm">
|
||||||
<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)}>
|
{/* 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" />
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</button>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-gray-400 hover:text-gray-900">
|
<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" />
|
<RotateCw className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</button>
|
||||||
</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">
|
<div className="h-4 w-px bg-gray-200" />
|
||||||
{trip.title}
|
|
||||||
<LayoutGrid className="h-3.5 w-3.5 text-gray-400" />
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
</h1>
|
<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>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
{/* Right actions */}
|
||||||
<Button variant="default" className="bg-orange-600 hover:bg-orange-700 h-9 px-4 rounded-full text-xs font-bold gap-2">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
Kaydetmek için giriş yap
|
{!user && (
|
||||||
</Button>
|
<Button size="sm" onClick={() => navigate('/login')}
|
||||||
<Button variant="ghost" size="icon" className="h-9 w-9 text-gray-500" onClick={handleShare}>
|
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" />
|
<Share2 className="h-4 w-4" />
|
||||||
</Button>
|
</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>
|
</Button>
|
||||||
<div className="h-4 w-px bg-gray-200 mx-1" />
|
<Button variant="outline" size="sm" className="h-8 px-3 rounded-xl border-gray-200 text-xs font-bold gap-1.5">
|
||||||
<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-500" />
|
||||||
<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
|
Optimize
|
||||||
</Button>
|
</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 ──────────────────────────────────────────────────────────── */}
|
||||||
<main className="flex-1 flex overflow-hidden">
|
<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">
|
{/* Day sidebar */}
|
||||||
<div className="py-4 flex flex-col items-center">
|
<aside className="w-16 md:w-[72px] border-r bg-gray-50/50 flex flex-col shrink-0 overflow-y-auto">
|
||||||
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-4">Days</span>
|
<div className="py-4 flex flex-col items-center gap-2">
|
||||||
<div className="flex flex-col gap-3 w-full px-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) => (
|
{trip.itinerary.days.map((day, idx) => (
|
||||||
<button
|
<motion.button
|
||||||
key={day.day}
|
key={day.day}
|
||||||
onClick={() => setSelectedDayIndex(idx)}
|
onClick={() => setSelectedDayIndex(idx)}
|
||||||
|
whileHover={{ scale: 1.04 }} whileTap={{ scale: 0.96 }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col items-center justify-center py-3 rounded-xl transition-all relative group",
|
"flex flex-col items-center justify-center py-3 rounded-xl transition-all",
|
||||||
selectedDayIndex === idx
|
selectedDayIndex === idx
|
||||||
? "bg-orange-600 text-white shadow-lg shadow-orange-600/20"
|
? "bg-orange-600 text-white shadow-lg shadow-orange-600/30"
|
||||||
: "text-gray-400 hover:bg-gray-100 hover:text-gray-900"
|
: "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] font-black uppercase tracking-tighter">Gün {day.day}</span>
|
||||||
<span className="text-[9px] opacity-60 font-medium">
|
<span className={cn("text-[8px] font-semibold mt-0.5",
|
||||||
{format(new Date(trip.start_date), 'MM-dd', { locale: tr })}
|
selectedDayIndex === idx ? "text-orange-200" : "text-gray-400"
|
||||||
|
)}>
|
||||||
|
{getDayDate(idx)}
|
||||||
</span>
|
</span>
|
||||||
<div className="mt-1 flex items-center justify-center">
|
<Badge className={cn(
|
||||||
<Badge variant="secondary" className={cn(
|
"mt-1.5 text-[8px] px-1.5 py-0 h-4 font-black border-0 rounded-full",
|
||||||
"text-[8px] px-1 py-0 h-3 min-w-[12px]",
|
selectedDayIndex === idx ? "bg-white/20 text-white" : "bg-gray-200 text-gray-500"
|
||||||
selectedDayIndex === idx ? "bg-white/20 text-white" : "bg-gray-200 text-gray-500"
|
)}>
|
||||||
)}>
|
{day.items.length}
|
||||||
{day.items.length}
|
</Badge>
|
||||||
</Badge>
|
</motion.button>
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
<Button
|
|
||||||
variant="ghost"
|
<button
|
||||||
size="icon"
|
|
||||||
onClick={handleAddDay}
|
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" />
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
<span className="text-[8px] font-black uppercase mt-1">Ekle</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Center Panel: Focused Day Timeline */}
|
{/* Timeline panel */}
|
||||||
<section className="w-full lg:w-[45%] xl:w-[40%] overflow-y-auto bg-white border-r border-border custom-scrollbar flex flex-col">
|
<section className="w-full lg:w-[45%] xl:w-[40%] overflow-y-auto bg-white border-r 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">
|
{/* Sticky day header */}
|
||||||
<div className="space-y-0.5">
|
<div className="px-6 py-4 border-b sticky top-0 bg-white/95 backdrop-blur-sm z-30">
|
||||||
<h2 className="text-xl font-bold text-gray-900">
|
<div className="flex items-start justify-between">
|
||||||
Day {trip.itinerary.days[selectedDayIndex].day} - Timeline
|
<div>
|
||||||
</h2>
|
<div className="flex items-center gap-2.5">
|
||||||
<p className="text-xs text-gray-500 font-medium">{trip.itinerary.days[selectedDayIndex].items.length} places selected</p>
|
<h2 className="text-lg font-black text-gray-900">Gün {selectedDay.day}</h2>
|
||||||
</div>
|
<span className="text-sm font-semibold text-gray-400">{getDayDate(selectedDayIndex)}</span>
|
||||||
<div className="flex items-center gap-2">
|
</div>
|
||||||
<Button variant="outline" size="sm" className="h-8 rounded-lg text-xs font-bold gap-1.5 border-gray-200">
|
<div className="flex items-center gap-3 mt-1.5">
|
||||||
<Sparkles className="h-3.5 w-3.5 text-orange-500" />
|
<span className="flex items-center gap-1.5 text-[11px] font-bold text-gray-500">
|
||||||
AI
|
<MapPin className="h-3 w-3 text-orange-500" />
|
||||||
</Button>
|
{dayStats?.places || 0} durak
|
||||||
<Button variant="default" size="sm" className="h-8 rounded-lg text-xs font-bold gap-1.5 bg-orange-600 hover:bg-orange-700">
|
</span>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
{dayStats && dayStats.places > 0 && (
|
||||||
Yer Ekle
|
<span className="flex items-center gap-1.5 text-[11px] font-bold text-gray-500">
|
||||||
</Button>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{/* Smart Suggestion Card (Placeholder for visual matching) */}
|
<Timeline
|
||||||
<div className="p-6 pb-0">
|
itinerary={{ days: [selectedDay] }}
|
||||||
<div className="bg-orange-50/50 border border-orange-100 rounded-2xl p-4 space-y-3 relative overflow-hidden group">
|
onReorder={(_, items) => handleReorder(selectedDayIndex, items)}
|
||||||
<div className="absolute -right-4 -top-4 opacity-5 group-hover:rotate-12 transition-transform">
|
onAddPlace={(_, place) => handleAddPlace(selectedDayIndex, place)}
|
||||||
<Wand2 className="h-24 w-24 text-orange-600" />
|
onDeletePlace={(_, id) => handleDeletePlace(selectedDayIndex, id)}
|
||||||
</div>
|
onUpdatePlaceNote={(_, id, note) => handleUpdatePlaceNote(selectedDayIndex, id, note)}
|
||||||
<div className="flex items-center justify-between">
|
onUpdateDayNote={(_, note) => handleUpdateDayNote(selectedDayIndex, note)}
|
||||||
<div className="flex items-center gap-2">
|
onPlaceClick={setActivePlaceId}
|
||||||
<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}
|
activePlaceId={activePlaceId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Right Panel: Map */}
|
{/* Map panel */}
|
||||||
<section className="hidden lg:block flex-1 relative bg-secondary">
|
<section className="hidden lg:block flex-1 relative bg-gray-100">
|
||||||
<TripMap
|
<TripMap
|
||||||
itinerary={{ days: [trip.itinerary.days[selectedDayIndex]] }}
|
itinerary={{ days: [selectedDay] }}
|
||||||
activePlaceId={activePlaceId}
|
activePlaceId={activePlaceId}
|
||||||
onMarkerClick={(id) => {
|
onMarkerClick={(id) => {
|
||||||
setActivePlaceId(id);
|
setActivePlaceId(id);
|
||||||
const element = document.getElementById(`place-${id}`);
|
document.getElementById(`place-${id}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
if (element) {
|
|
||||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Legend / Stats overlay */}
|
{/* Stats overlay */}
|
||||||
<div className="absolute top-6 left-6 z-10">
|
<div className="absolute top-4 left-4 z-10 pointer-events-none">
|
||||||
<div className="bg-white/90 backdrop-blur-md p-3 rounded-2xl shadow-xl border border-white/20 space-y-1">
|
<div className="bg-white/90 backdrop-blur-md px-4 py-3 rounded-2xl shadow-xl border border-white/50">
|
||||||
<div className="text-[9px] font-black text-gray-400 uppercase tracking-widest px-1">ÖZET</div>
|
<p className="text-[9px] font-black text-gray-400 uppercase tracking-widest mb-2">Özet</p>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex flex-col">
|
<div>
|
||||||
<span className="text-xl font-black text-gray-900 tracking-tighter">{trip.itinerary.days[selectedDayIndex].items.length}</span>
|
<p className="text-2xl font-black text-gray-900 leading-none">{selectedDay.items.length}</p>
|
||||||
<span className="text-[8px] font-bold text-gray-400 uppercase">DURAK</span>
|
<p className="text-[8px] font-bold text-gray-400 uppercase mt-0.5">Durak</p>
|
||||||
</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>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Mobile Map Button */}
|
{/* Mobile map sheet */}
|
||||||
<div className="lg:hidden fixed bottom-6 right-6 z-50">
|
<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="h-12 px-6 rounded-full shadow-2xl bg-orange-600 hover:bg-orange-700 text-white font-bold uppercase tracking-widest text-[10px]">
|
<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 mr-2" />
|
<MapPin className="h-4 w-4" />
|
||||||
Haritayı Aç
|
Haritayı Aç
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</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">
|
<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>
|
</SheetHeader>
|
||||||
<div className="h-full relative">
|
<div className="h-full relative">
|
||||||
<TripMap
|
<TripMap
|
||||||
itinerary={{ days: [trip.itinerary.days[selectedDayIndex]] }}
|
itinerary={{ days: [selectedDay] }}
|
||||||
activePlaceId={activePlaceId}
|
activePlaceId={activePlaceId}
|
||||||
onMarkerClick={(id) => {
|
onMarkerClick={(id) => {
|
||||||
setActivePlaceId(id);
|
setActivePlaceId(id);
|
||||||
setIsMapSheetOpen(false);
|
setIsMapSheetOpen(false);
|
||||||
const element = document.getElementById(`place-${id}`);
|
document.getElementById(`place-${id}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
if (element) {
|
|
||||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -380,4 +548,4 @@ export default function TripDetailsPage() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user