Files
gooseek/backend/webui/src/app/(main)/travel/page.tsx
home 08bd41e75c feat: travel service with 2GIS routing, POI, hotels + finance providers + UI overhaul
- Add travel-svc microservice (Amadeus, TravelPayouts, 2GIS, OpenRouteService)
- Add travel orchestrator with parallel collectors (events, POI, hotels, flights)
- Add 2GIS road routing with transport cost calculation (car/bus/taxi)
- Add TravelMap (2GIS MapGL) and TravelWidgets components
- Add useTravelChat hook for streaming travel agent responses
- Add finance heatmap providers refactor
- Add SearXNG settings, API proxy routes, Docker compose updates
- Update Dockerfiles, config, types, and all UI pages for consistency

Made-with: Cursor
2026-03-01 21:58:32 +03:00

734 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="group bg-elevated/40 border border-border/40 rounded-xl p-3 hover:border-border transition-all cursor-pointer"
onClick={onClick}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg active-gradient flex items-center justify-center">
<Plane className="w-4 h-4" />
</div>
<div>
<h4 className="text-sm font-medium text-primary truncate max-w-[140px]">
{trip.title || trip.destination}
</h4>
<p className="text-xs text-muted">{trip.destination}</p>
</div>
</div>
<button
onClick={(e) => { 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="Удалить поездку"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex items-center gap-2 text-xs text-muted">
<Calendar className="w-3 h-3" />
<span>{formatDate(trip.startDate)} - {formatDate(trip.endDate)}</span>
</div>
{trip.route && trip.route.length > 0 && (
<div className="flex items-center gap-1 mt-2">
<MapPin className="w-3 h-3 text-accent" />
<span className="text-xs text-secondary">{trip.route.length} точек</span>
</div>
)}
</motion.div>
);
}
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<string>;
selectedPOIIds: Set<string>;
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 (
<div className="max-w-full w-full">
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-lg active-gradient flex items-center justify-center flex-shrink-0">
<Sparkles className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
{message.content && (
<div className="prose prose-sm prose-invert max-w-none">
<ReactMarkdown>{message.content}</ReactMarkdown>
</div>
)}
{message.isStreaming && !message.content && (
<div className="flex items-center gap-2 mt-2">
<Loader2 className="w-4 h-4 animate-spin text-accent" />
<span className="text-xs text-muted">Планирую маршрут...</span>
</div>
)}
</div>
</div>
{travelWidgets.length > 0 && (
<div className="mt-4 ml-11 space-y-3">
{travelWidgets.map((widget) => (
<TravelWidgetRenderer
key={widget.id}
widget={widget}
onAddEventToMap={onAddEventToMap}
onAddPOIToMap={onAddPOIToMap}
onSelectHotel={onSelectHotel}
onSelectTransport={onSelectTransport}
onClarifyingAnswer={onClarifyingAnswer}
onAction={onAction}
selectedEventIds={selectedEventIds}
selectedPOIIds={selectedPOIIds}
selectedHotelId={selectedHotelId}
selectedTransportId={selectedTransportId}
/>
))}
</div>
)}
{message.isStreaming && message.content && (
<div className="flex items-center gap-2 mt-3 ml-11">
<Loader2 className="w-4 h-4 animate-spin text-accent" />
<span className="text-xs text-muted">Собираю данные...</span>
</div>
)}
</div>
);
}
export default function TravelPage() {
const [inputValue, setInputValue] = useState('');
const [showMap, setShowMap] = useState(true);
const [trips, setTrips] = useState<Trip[]>([]);
const [planOptions, setPlanOptions] = useState({
startDate: '',
endDate: '',
travelers: 2,
budget: 0,
});
const [showOptions, setShowOptions] = useState(false);
const [selectedEventIds, setSelectedEventIds] = useState<Set<string>>(new Set());
const [selectedPOIIds, setSelectedPOIIds] = useState<Set<string>>(new Set());
const [selectedHotelId, setSelectedHotelId] = useState<string>();
const [selectedTransportId, setSelectedTransportId] = useState<string>();
const [userLocation, setUserLocation] = useState<GeoLocation | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(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<Trip> = {
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 (
<div className="flex flex-col h-full bg-gradient-main">
<AnimatePresence mode="wait">
{!hasMessages ? (
<motion.div
key="welcome"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, y: -20 }}
className="flex-1 overflow-y-auto"
>
<div className="flex flex-col items-center justify-center min-h-full px-4 sm:px-6 py-8">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="text-center mb-8"
>
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl active-gradient flex items-center justify-center">
<Plane className="w-8 h-8" />
</div>
<h1 className="text-2xl sm:text-3xl font-bold text-primary tracking-tight">
Куда <span className="text-gradient">отправимся?</span>
</h1>
<p className="text-sm text-secondary mt-2 max-w-md">
AI спланирует идеальное путешествие с маршрутом, отелями, мероприятиями и достопримечательностями
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="w-full max-w-2xl px-2"
>
<div className="bg-elevated/60 backdrop-blur-xl border border-border/50 rounded-2xl overflow-hidden">
<div className="flex items-end gap-3 p-4">
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => 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
/>
<button
onClick={handleSend}
disabled={!inputValue.trim()}
className={`w-10 h-10 flex items-center justify-center rounded-xl transition-all ${
inputValue.trim()
? 'btn-gradient text-accent'
: 'bg-surface/50 text-muted border border-border/50'
}`}
aria-label="Отправить"
>
<ArrowUp className="w-5 h-5" />
</button>
</div>
<div className="px-4 pb-4 flex items-center gap-2 flex-wrap">
<button
onClick={() => 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'
}`}
>
<Calendar className="w-3.5 h-3.5" />
Параметры
</button>
<button
onClick={() => 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'
}`}
>
<MapIcon className="w-3.5 h-3.5" />
Карта
</button>
</div>
<AnimatePresence>
{showOptions && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden border-t border-border/30"
>
<div className="p-4 grid grid-cols-2 sm:grid-cols-4 gap-3">
<div>
<label className="text-xs text-muted mb-1 block">Вылет</label>
<input
type="date"
value={planOptions.startDate}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-xs text-muted mb-1 block">Возврат</label>
<input
type="date"
value={planOptions.endDate}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-xs text-muted mb-1 block">Путешественники</label>
<select
value={planOptions.travelers}
onChange={(e) => 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) => (
<option key={n} value={n}>{n}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-muted mb-1 block">Бюджет</label>
<input
type="number"
value={planOptions.budget || ''}
onChange={(e) => 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"
/>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="mt-6 grid grid-cols-2 gap-2">
{quickPrompts.map((prompt, i) => (
<motion.button
key={i}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 + i * 0.05 }}
onClick={() => 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"
>
<span className="text-xl">{prompt.icon}</span>
<span className="text-sm text-secondary">{prompt.text}</span>
</motion.button>
))}
</div>
</motion.div>
{trips.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="w-full max-w-2xl mt-8 px-2"
>
<div className="flex items-center gap-2 mb-4">
<Bookmark className="w-4 h-4 text-accent" />
<h3 className="text-sm font-medium text-primary">Мои поездки</h3>
</div>
<div className="grid grid-cols-2 gap-3">
{trips.slice(0, 4).map((trip) => (
<TripCard
key={trip.id}
trip={trip}
onClick={() => {
if (trip.route && trip.route.length > 0) {
setShowMap(true);
}
}}
onDelete={() => handleDeleteTrip(trip.id)}
/>
))}
</div>
</motion.div>
)}
</div>
</motion.div>
) : (
<motion.div
key="chat"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col h-full"
>
<div className="flex-1 flex overflow-hidden">
<div className={`flex flex-col min-w-0 ${showMap ? 'w-full lg:w-1/2' : 'w-full'}`}>
{isResearching && (
<div className="px-4 py-2 bg-accent/5 border-b border-accent/20">
<div className="flex items-center gap-2 max-w-3xl mx-auto">
<Loader2 className="w-4 h-4 animate-spin text-accent" />
<span className="text-xs text-accent">Исследую маршруты, мероприятия и отели...</span>
</div>
</div>
)}
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-6">
<div className="max-w-3xl mx-auto space-y-6">
{messages.map((message, i) => (
<motion.div
key={message.id}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.02 }}
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
{message.role === 'user' ? (
<div className="max-w-[85%] px-4 py-3 bg-accent/20 border border-accent/30 rounded-2xl rounded-br-md">
<p className="text-sm text-primary whitespace-pre-wrap">{message.content}</p>
</div>
) : (
<AssistantMessage
message={message}
onAddEventToMap={handleAddEventToMap}
onAddPOIToMap={handleAddPOIToMap}
onSelectHotel={handleSelectHotel}
onSelectTransport={handleSelectTransport}
onClarifyingAnswer={answerClarifying}
onAction={handleAction}
selectedEventIds={selectedEventIds}
selectedPOIIds={selectedPOIIds}
selectedHotelId={selectedHotelId}
selectedTransportId={selectedTransportId}
/>
)}
</motion.div>
))}
<div ref={messagesEndRef} />
</div>
</div>
<div className="sticky bottom-0 px-4 sm:px-6 pb-4 pt-4 bg-gradient-to-t from-base via-base/95 to-transparent">
<div className="max-w-3xl mx-auto">
<div className="flex items-center gap-2 mb-3">
{currentRoute.length > 0 && (
<button
onClick={handleSaveTrip}
disabled={saveStatus === 'saving'}
className={`flex items-center gap-2 px-3 py-1.5 text-xs rounded-lg transition-colors ${
saveStatus === 'saved'
? 'bg-green-500/20 text-green-600'
: saveStatus === 'error'
? 'bg-error/20 text-error'
: 'bg-accent/20 text-accent hover:bg-accent/30'
}`}
>
{saveStatus === 'saving' ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Bookmark className="w-3.5 h-3.5" />
)}
{saveStatus === 'saved' ? 'Сохранено!' : saveStatus === 'error' ? 'Ошибка' : 'Сохранить поездку'}
</button>
)}
<button
onClick={() => 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'
}`}
>
<MapIcon className="w-3.5 h-3.5" />
{showMap ? 'Скрыть карту' : 'Показать карту'}
{currentRoute.length > 0 && (
<span className="ml-1 px-1.5 py-0.5 bg-accent/30 rounded text-[10px]">
{currentRoute.length}
</span>
)}
</button>
<button
onClick={() => {
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"
>
<Plus className="w-3.5 h-3.5" />
Новый план
</button>
</div>
<div className="bg-elevated/60 backdrop-blur-xl border border-border/50 rounded-2xl overflow-hidden">
<div className="flex items-end gap-3 p-4">
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => 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 ? (
<button
onClick={stopGeneration}
className="w-10 h-10 flex items-center justify-center rounded-xl bg-error/10 text-error border border-error/30"
aria-label="Остановить"
>
<X className="w-5 h-5" />
</button>
) : (
<button
onClick={handleSend}
disabled={!inputValue.trim()}
className={`w-10 h-10 flex items-center justify-center rounded-xl transition-all ${
inputValue.trim()
? 'btn-gradient text-accent'
: 'bg-surface/50 text-muted border border-border/50'
}`}
aria-label="Отправить"
>
<ArrowUp className="w-5 h-5" />
</button>
)}
</div>
</div>
</div>
</div>
</div>
<div
className={`hidden lg:block border-l border-border/30 transition-all duration-300 ${
showMap ? 'w-1/2 opacity-100' : 'w-0 opacity-0 overflow-hidden'
}`}
>
<TravelMap
route={currentRoute}
routeDirection={routeDirection ?? undefined}
onMapClick={handleMapClick}
className="h-full"
userLocation={userLocation}
/>
</div>
</div>
{showMap && (
<div className="lg:hidden border-t border-border/30">
<TravelMap
route={currentRoute}
routeDirection={routeDirection ?? undefined}
onMapClick={handleMapClick}
className="h-[300px]"
userLocation={userLocation}
/>
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
}