'use client'; import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Plane, MapPin, Calendar, Sparkles, Loader2, X, ArrowUp, Bookmark, Map as MapIcon, Plus, } from 'lucide-react'; import { useTravelChat, type TravelChatMessage } from '@/lib/hooks/useTravelChat'; import { TravelWidgetRenderer } from '@/components/TravelWidgets'; import { TravelMap } from '@/components/TravelMap'; import { fetchTrips, createTrip, deleteTrip } from '@/lib/api'; import type { Trip, RoutePoint, GeoLocation, EventCard, POICard, HotelCard, TransportOption } from '@/lib/types'; import ReactMarkdown from 'react-markdown'; const quickPrompts = [ { icon: '🏖️', text: 'Пляжный отдых на неделю', query: 'Хочу пляжный отдых на неделю, тёплое море, красивые закаты' }, { icon: '🏔️', text: 'Горы и природа', query: 'Поездка в горы, активный отдых, пешие маршруты, красивые виды' }, { icon: '🏛️', text: 'Культурное путешествие', query: 'Культурное путешествие, музеи, архитектура, история' }, { icon: '🎢', text: 'С детьми', query: 'Семейный отдых с детьми, развлечения, парки, безопасно' }, ]; interface TripCardProps { trip: Trip; onClick: () => void; onDelete: () => void; } function TripCard({ trip, onClick, onDelete }: TripCardProps) { const formatDate = (date: string) => { return new Date(date).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }); }; return ( {trip.title || trip.destination} {trip.destination} { e.stopPropagation(); onDelete(); }} className="opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-error/20 text-muted hover:text-error transition-all" aria-label="Удалить поездку" > {formatDate(trip.startDate)} - {formatDate(trip.endDate)} {trip.route && trip.route.length > 0 && ( {trip.route.length} точек )} ); } interface AssistantMessageProps { message: TravelChatMessage; onAddEventToMap: (event: EventCard) => void; onAddPOIToMap: (poi: POICard) => void; onSelectHotel: (hotel: HotelCard) => void; onSelectTransport: (option: TransportOption) => void; onClarifyingAnswer: (field: string, value: string) => void; onAction: (kind: string) => void; selectedEventIds: Set; selectedPOIIds: Set; selectedHotelId?: string; selectedTransportId?: string; } function AssistantMessage({ message, onAddEventToMap, onAddPOIToMap, onSelectHotel, onSelectTransport, onClarifyingAnswer, onAction, selectedEventIds, selectedPOIIds, selectedHotelId, selectedTransportId, }: AssistantMessageProps) { const travelWidgets = useMemo( () => message.widgets.filter((w) => w.type.startsWith('travel_')), [message.widgets] ); return ( {message.content && ( {message.content} )} {message.isStreaming && !message.content && ( Планирую маршрут... )} {travelWidgets.length > 0 && ( {travelWidgets.map((widget) => ( ))} )} {message.isStreaming && message.content && ( Собираю данные... )} ); } export default function TravelPage() { const [inputValue, setInputValue] = useState(''); const [showMap, setShowMap] = useState(true); const [trips, setTrips] = useState([]); const [planOptions, setPlanOptions] = useState({ startDate: '', endDate: '', travelers: 2, budget: 0, }); const [showOptions, setShowOptions] = useState(false); const [selectedEventIds, setSelectedEventIds] = useState>(new Set()); const [selectedPOIIds, setSelectedPOIIds] = useState>(new Set()); const [selectedHotelId, setSelectedHotelId] = useState(); const [selectedTransportId, setSelectedTransportId] = useState(); const [userLocation, setUserLocation] = useState(null); const messagesEndRef = useRef(null); const textareaRef = useRef(null); useEffect(() => { if (!navigator.geolocation) return; navigator.geolocation.getCurrentPosition( (position) => { setUserLocation({ lat: position.coords.latitude, lng: position.coords.longitude, name: 'Моё местоположение', }); }, () => {}, { enableHighAccuracy: false, timeout: 10000, maximumAge: 300000 }, ); }, []); const { messages, isLoading, isResearching, currentRoute, routeDirection, routeSegments, sendMessage, stopGeneration, clearChat, addRoutePoint, answerClarifying, handleAction, addEventToRoute, addPOIToRoute, selectHotelOnRoute, } = useTravelChat({ onRouteUpdate: (route) => { if (route.length > 0) setShowMap(true); }, }); useEffect(() => { loadTrips(); }, []); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); const loadTrips = async () => { try { const { trips: data } = await fetchTrips(10, 0); setTrips(data || []); } catch { // silently fail } }; const handleSend = useCallback(() => { if (!inputValue.trim() || isLoading) return; sendMessage(inputValue, { startDate: planOptions.startDate || undefined, endDate: planOptions.endDate || undefined, travelers: planOptions.travelers || undefined, budget: planOptions.budget || undefined, userLocation: userLocation || undefined, }); setInputValue(''); if (textareaRef.current) { textareaRef.current.style.height = 'auto'; } }, [inputValue, isLoading, sendMessage, planOptions, userLocation]); const handleQuickPrompt = useCallback((query: string) => { sendMessage(query, { startDate: planOptions.startDate || undefined, endDate: planOptions.endDate || undefined, travelers: planOptions.travelers || undefined, budget: planOptions.budget || undefined, userLocation: userLocation || undefined, }); }, [sendMessage, planOptions, userLocation]); const handleAddEventToMap = useCallback((event: EventCard) => { setSelectedEventIds((prev) => { const next = new Set(prev); if (next.has(event.id)) { next.delete(event.id); } else { next.add(event.id); addEventToRoute(event); } return next; }); }, [addEventToRoute]); const handleAddPOIToMap = useCallback((poi: POICard) => { setSelectedPOIIds((prev) => { const next = new Set(prev); if (next.has(poi.id)) { next.delete(poi.id); } else { next.add(poi.id); addPOIToRoute(poi); } return next; }); }, [addPOIToRoute]); const handleSelectHotel = useCallback((hotel: HotelCard) => { setSelectedHotelId((prev) => { const newId = prev === hotel.id ? undefined : hotel.id; if (newId) selectHotelOnRoute(hotel); return newId; }); }, [selectHotelOnRoute]); const handleSelectTransport = useCallback((option: TransportOption) => { setSelectedTransportId((prev) => (prev === option.id ? undefined : option.id)); }, []); const handleMapClick = useCallback((location: GeoLocation) => { const point: RoutePoint = { id: `${Date.now()}`, lat: location.lat, lng: location.lng, name: location.name || 'Новая точка', type: 'custom', order: currentRoute.length, }; addRoutePoint(point); }, [addRoutePoint, currentRoute.length]); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const handleSaveTrip = async () => { if (currentRoute.length === 0) return; setSaveStatus('saving'); const lastMessage = messages.find((m) => m.role === 'assistant' && !m.isStreaming); const trip: Partial = { title: 'Новое путешествие', destination: currentRoute[0]?.name || 'Неизвестно', startDate: planOptions.startDate || new Date().toISOString(), endDate: planOptions.endDate || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), route: currentRoute, currency: 'RUB', status: 'planned', aiSummary: lastMessage?.content, }; try { const created = await createTrip(trip); setTrips((prev) => [created, ...prev]); setSaveStatus('saved'); setTimeout(() => setSaveStatus('idle'), 2000); } catch { setSaveStatus('error'); setTimeout(() => setSaveStatus('idle'), 3000); } }; const handleDeleteTrip = async (id: string) => { try { await deleteTrip(id); setTrips((prev) => prev.filter((t) => t.id !== id)); } catch { // silently fail } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }; const handleInput = () => { if (textareaRef.current) { textareaRef.current.style.height = 'auto'; textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`; } }; const hasMessages = messages.length > 0; return ( {!hasMessages ? ( Куда отправимся? AI спланирует идеальное путешествие с маршрутом, отелями, мероприятиями и достопримечательностями setInputValue(e.target.value)} onKeyDown={handleKeyDown} onInput={handleInput} placeholder="Опишите своё идеальное путешествие..." className="flex-1 bg-transparent text-[15px] text-primary resize-none focus:outline-none min-h-[28px] max-h-[120px] placeholder:text-muted leading-relaxed" rows={1} autoFocus /> setShowOptions(!showOptions)} className={`flex items-center gap-2 px-3 py-1.5 text-xs rounded-lg transition-all ${ showOptions ? 'bg-accent/20 text-accent' : 'bg-surface/50 text-secondary hover:text-primary' }`} > Параметры setShowMap(!showMap)} className={`flex items-center gap-2 px-3 py-1.5 text-xs rounded-lg transition-all ${ showMap ? 'bg-accent/20 text-accent' : 'bg-surface/50 text-secondary hover:text-primary' }`} > Карта {showOptions && ( Вылет setPlanOptions({ ...planOptions, startDate: e.target.value })} className="w-full px-3 py-2 bg-surface/50 border border-border/40 rounded-lg text-sm text-primary" /> Возврат setPlanOptions({ ...planOptions, endDate: e.target.value })} className="w-full px-3 py-2 bg-surface/50 border border-border/40 rounded-lg text-sm text-primary" /> Путешественники setPlanOptions({ ...planOptions, travelers: Number(e.target.value) })} className="w-full px-3 py-2 bg-surface/50 border border-border/40 rounded-lg text-sm text-primary" > {[1, 2, 3, 4, 5, 6].map((n) => ( {n} ))} Бюджет setPlanOptions({ ...planOptions, budget: Number(e.target.value) })} placeholder="Без лимита" className="w-full px-3 py-2 bg-surface/50 border border-border/40 rounded-lg text-sm text-primary" /> )} {quickPrompts.map((prompt, i) => ( handleQuickPrompt(prompt.query)} className="flex items-center gap-2 px-4 py-3 bg-elevated/40 border border-border/40 rounded-xl hover:border-border hover:bg-elevated/60 transition-all text-left" > {prompt.icon} {prompt.text} ))} {trips.length > 0 && ( Мои поездки {trips.slice(0, 4).map((trip) => ( { if (trip.route && trip.route.length > 0) { setShowMap(true); } }} onDelete={() => handleDeleteTrip(trip.id)} /> ))} )} ) : ( {isResearching && ( Исследую маршруты, мероприятия и отели... )} {messages.map((message, i) => ( {message.role === 'user' ? ( {message.content} ) : ( )} ))} {currentRoute.length > 0 && ( {saveStatus === 'saving' ? ( ) : ( )} {saveStatus === 'saved' ? 'Сохранено!' : saveStatus === 'error' ? 'Ошибка' : 'Сохранить поездку'} )} setShowMap(!showMap)} className={`flex items-center gap-2 px-3 py-1.5 text-xs rounded-lg transition-colors ${ showMap ? 'bg-accent/20 text-accent' : 'bg-surface/50 text-secondary' }`} > {showMap ? 'Скрыть карту' : 'Показать карту'} {currentRoute.length > 0 && ( {currentRoute.length} )} { clearChat(); setInputValue(''); setSaveStatus('idle'); }} className="flex items-center gap-2 px-3 py-1.5 text-xs bg-surface/50 text-secondary rounded-lg hover:text-primary transition-colors" > Новый план setInputValue(e.target.value)} onKeyDown={handleKeyDown} onInput={handleInput} placeholder="Уточните детали или задайте вопрос..." className="flex-1 bg-transparent text-[15px] text-primary resize-none focus:outline-none min-h-[28px] max-h-[120px] placeholder:text-muted leading-relaxed" rows={1} disabled={isLoading} /> {isLoading ? ( ) : ( )} {showMap && ( )} )} ); }
{trip.destination}
AI спланирует идеальное путешествие с маршрутом, отелями, мероприятиями и достопримечательностями
{message.content}