- 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
734 lines
30 KiB
TypeScript
734 lines
30 KiB
TypeScript
'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>
|
||
);
|
||
}
|