feat: CI/CD pipeline + Learning/Medicine/Travel services
Some checks failed
Build and Deploy GooSeek / build-backend (push) Failing after 1m4s
Build and Deploy GooSeek / build-webui (push) Failing after 1m2s
Build and Deploy GooSeek / deploy (push) Has been skipped

- Add Gitea Actions workflow for automated build & deploy
- Add K8s manifests: webui, travel-svc, medicine-svc, sandbox-svc
- Update kustomization for localhost:5000 registry
- Add ingress for gooseek.ru and api.gooseek.ru
- Learning cabinet with onboarding, courses, sandbox integration
- Medicine service with symptom analysis and doctor matching
- Travel service with itinerary planning
- Server setup scripts (NVIDIA/CUDA, K3s, Gitea runner)

Made-with: Cursor
This commit is contained in:
home
2026-03-02 20:25:44 +03:00
parent 08bd41e75c
commit ab48a0632b
92 changed files with 15562 additions and 2198 deletions

View File

@@ -0,0 +1,954 @@
'use client';
import { useState, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragOverlay,
useDroppable,
type DragStartEvent,
type DragEndEvent,
type DragOverEvent,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
GripVertical,
X,
Plus,
Check,
RotateCcw,
Loader2,
AlertTriangle,
Lightbulb,
Search,
MapPin,
Ticket,
Utensils,
Camera,
ShoppingBag,
Coffee,
Footprints,
ChevronDown,
ChevronUp,
Clock,
Ban,
} from 'lucide-react';
import type {
ItineraryItem,
POICard,
EventCard,
DailyForecast,
} from '@/lib/types';
import {
useEditableItinerary,
validateItemPlacement,
type EditableDay,
type ValidationResult,
type LLMValidationResponse,
type LLMValidationWarning,
type CustomItemType,
} from '@/lib/hooks/useEditableItinerary';
import type { ItineraryDay } from '@/lib/types';
// --- Sortable Item ---
interface SortableItemProps {
item: ItineraryItem;
itemId: string;
dayIdx: number;
itemIdx: number;
onRemove: () => void;
warning?: LLMValidationWarning;
}
function SortableItem({ item, itemId, dayIdx, itemIdx, onRemove, warning }: SortableItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: itemId });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={`group flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
warning
? 'border-amber-500/40 bg-amber-500/5'
: 'border-border/30 bg-elevated/20 hover:border-border/50'
}`}
>
<button
{...attributes}
{...listeners}
className="flex-shrink-0 p-0.5 text-muted hover:text-secondary cursor-grab active:cursor-grabbing touch-none"
aria-label="Перетащить"
>
<GripVertical className="w-4 h-4" />
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{item.startTime && (
<span className="text-[10px] text-accent font-mono bg-accent/8 px-1.5 py-px rounded flex-shrink-0">
{item.startTime}
</span>
)}
<span className="text-[12px] font-medium text-primary truncate">{item.title}</span>
<span className="text-[9px] text-muted bg-surface/50 px-1.5 py-px rounded-full flex-shrink-0">
{item.refType}
</span>
</div>
{warning && (
<div className="flex items-center gap-1 mt-1">
<AlertTriangle className="w-3 h-3 text-amber-400 flex-shrink-0" />
<span className="text-[10px] text-amber-400">{warning.message}</span>
</div>
)}
</div>
<button
onClick={onRemove}
className="flex-shrink-0 p-1 rounded text-muted hover:text-error hover:bg-error/10 opacity-0 group-hover:opacity-100 transition-all"
aria-label="Удалить"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
);
}
// --- Drag Overlay Item ---
function DragOverlayItem({ item }: { item: ItineraryItem }) {
return (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-accent/50 bg-elevated shadow-lg shadow-accent/10">
<GripVertical className="w-4 h-4 text-accent" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{item.startTime && (
<span className="text-[10px] text-accent font-mono bg-accent/8 px-1.5 py-px rounded">
{item.startTime}
</span>
)}
<span className="text-[12px] font-medium text-primary truncate">{item.title}</span>
</div>
</div>
</div>
);
}
// --- Validation Toast ---
function ValidationToast({ result, onDismiss }: { result: ValidationResult; onDismiss: () => void }) {
if (result.valid) return null;
return (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="fixed bottom-24 left-1/2 -translate-x-1/2 z-50 max-w-sm"
>
<div className="flex items-center gap-3 px-4 py-3 rounded-xl bg-red-950/90 border border-red-500/40 shadow-lg backdrop-blur-sm">
<Ban className="w-5 h-5 text-red-400 flex-shrink-0" />
<p className="text-sm text-red-200">{result.reason}</p>
<button onClick={onDismiss} className="p-1 text-red-400 hover:text-red-300 flex-shrink-0">
<X className="w-4 h-4" />
</button>
</div>
</motion.div>
);
}
// --- Add Item Panel ---
interface AddItemPanelProps {
pois: POICard[];
events: EventCard[];
dayIdx: number;
dayDate: string;
dayItems: ItineraryItem[];
poisMap: Map<string, POICard>;
eventsMap: Map<string, EventCard>;
onAdd: (dayIdx: number, item: ItineraryItem) => ValidationResult;
onAddCustom: (dayIdx: number, title: string, refType: CustomItemType, duration: number) => ValidationResult;
onClose: () => void;
}
type AddTab = 'poi' | 'event' | 'custom';
const customTypeOptions: { value: CustomItemType; label: string; icon: typeof Utensils }[] = [
{ value: 'food', label: 'Еда', icon: Utensils },
{ value: 'walk', label: 'Прогулка', icon: Footprints },
{ value: 'rest', label: 'Отдых', icon: Coffee },
{ value: 'shopping', label: 'Шопинг', icon: ShoppingBag },
{ value: 'custom', label: 'Другое', icon: MapPin },
];
function AddItemPanel({
pois,
events,
dayIdx,
dayDate,
dayItems,
poisMap,
eventsMap,
onAdd,
onAddCustom,
onClose,
}: AddItemPanelProps) {
const [tab, setTab] = useState<AddTab>('poi');
const [searchQuery, setSearchQuery] = useState('');
const [categoryFilter, setCategoryFilter] = useState<POICategory>('all');
const [customTitle, setCustomTitle] = useState('');
const [customType, setCustomType] = useState<CustomItemType>('food');
const [customDuration, setCustomDuration] = useState(60);
const [lastError, setLastError] = useState<string | null>(null);
const filteredPois = useMemo(() => {
let result = pois;
if (categoryFilter !== 'all') {
result = result.filter((p) => p.category.toLowerCase().includes(categoryFilter));
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.category.toLowerCase().includes(q) ||
(p.address && p.address.toLowerCase().includes(q)),
);
}
return result;
}, [pois, searchQuery, categoryFilter]);
const filteredEvents = useMemo(() => {
if (!searchQuery.trim()) return events;
const q = searchQuery.toLowerCase();
return events.filter(
(e) =>
e.title.toLowerCase().includes(q) ||
(e.address && e.address.toLowerCase().includes(q)),
);
}, [events, searchQuery]);
const handleAddPOI = useCallback(
(poi: POICard) => {
const item: ItineraryItem = {
refType: 'poi',
refId: poi.id,
title: poi.name,
lat: poi.lat,
lng: poi.lng,
note: poi.description || '',
cost: poi.price || 0,
currency: poi.currency || 'RUB',
};
const result = onAdd(dayIdx, item);
if (!result.valid) {
setLastError(result.reason || 'Невозможно добавить');
setTimeout(() => setLastError(null), 3000);
} else {
setLastError(null);
}
},
[dayIdx, onAdd],
);
const handleAddEvent = useCallback(
(event: EventCard) => {
const item: ItineraryItem = {
refType: 'event',
refId: event.id,
title: event.title,
lat: event.lat || 0,
lng: event.lng || 0,
note: event.description || '',
cost: event.price || 0,
currency: event.currency || 'RUB',
};
const result = onAdd(dayIdx, item);
if (!result.valid) {
setLastError(result.reason || 'Невозможно добавить');
setTimeout(() => setLastError(null), 3000);
} else {
setLastError(null);
}
},
[dayIdx, onAdd],
);
const handleAddCustom = useCallback(() => {
if (!customTitle.trim()) return;
const result = onAddCustom(dayIdx, customTitle.trim(), customType, customDuration);
if (!result.valid) {
setLastError(result.reason || 'Невозможно добавить');
setTimeout(() => setLastError(null), 3000);
} else {
setCustomTitle('');
setLastError(null);
}
}, [dayIdx, customTitle, customType, customDuration, onAddCustom]);
const isItemAlreadyInDay = useCallback(
(refId: string) => dayItems.some((i) => i.refId === refId),
[dayItems],
);
return (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="p-3 border border-border/30 rounded-lg bg-elevated/30 mt-2">
<div className="flex items-center justify-between mb-3">
<div className="flex gap-1">
{[
{ key: 'poi' as AddTab, label: 'Места', icon: Camera },
{ key: 'event' as AddTab, label: 'События', icon: Ticket },
{ key: 'custom' as AddTab, label: 'Своё', icon: Plus },
].map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => { setTab(key); setSearchQuery(''); }}
className={`flex items-center gap-1.5 px-3 py-1.5 text-[11px] rounded-lg transition-colors ${
tab === key
? 'bg-accent/20 text-accent'
: 'text-muted hover:text-secondary hover:bg-surface/30'
}`}
>
<Icon className="w-3 h-3" />
{label}
</button>
))}
</div>
<button onClick={onClose} className="p-1 text-muted hover:text-primary">
<X className="w-4 h-4" />
</button>
</div>
{lastError && (
<div className="flex items-center gap-2 px-3 py-2 mb-2 rounded-lg bg-red-500/10 border border-red-500/20">
<Ban className="w-3.5 h-3.5 text-red-400 flex-shrink-0" />
<span className="text-[11px] text-red-300">{lastError}</span>
</div>
)}
{tab !== 'custom' && (
<div className="space-y-2 mb-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={tab === 'poi' ? 'Поиск мест...' : 'Поиск событий...'}
className="w-full pl-8 pr-3 py-2 bg-surface/40 border border-border/30 rounded-lg text-[12px] text-primary placeholder:text-muted focus:outline-none focus:border-accent/40"
/>
</div>
{tab === 'poi' && (
<div className="flex gap-1 flex-wrap">
{categoryFilters.map((cf) => (
<button
key={cf.value}
onClick={() => setCategoryFilter(cf.value)}
className={`px-2 py-1 text-[9px] rounded-md transition-colors ${
categoryFilter === cf.value
? 'bg-accent/20 text-accent'
: 'bg-surface/30 text-muted hover:text-secondary'
}`}
>
{cf.label}
</button>
))}
</div>
)}
</div>
)}
{tab === 'poi' && (
<div className="max-h-[200px] overflow-y-auto space-y-1 scrollbar-thin">
{filteredPois.length === 0 ? (
<p className="text-[11px] text-muted text-center py-4">Нет доступных мест</p>
) : (
filteredPois.map((poi) => {
const alreadyAdded = isItemAlreadyInDay(poi.id);
const precheck = validateItemPlacement(
{ refType: 'poi', refId: poi.id, title: poi.name, lat: poi.lat, lng: poi.lng },
dayDate,
dayItems,
poisMap,
eventsMap,
);
return (
<button
key={poi.id}
onClick={() => !alreadyAdded && precheck.valid && handleAddPOI(poi)}
disabled={alreadyAdded || !precheck.valid}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors ${
alreadyAdded
? 'opacity-50 cursor-not-allowed bg-surface/20'
: !precheck.valid
? 'opacity-60 cursor-not-allowed bg-red-500/5'
: 'hover:bg-surface/30'
}`}
>
<Camera className="w-3.5 h-3.5 text-accent flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-[11px] font-medium text-primary truncate block">{poi.name}</span>
{!precheck.valid && (
<span className="text-[9px] text-red-400">{precheck.reason}</span>
)}
</div>
{alreadyAdded ? (
<Check className="w-3.5 h-3.5 text-green-400 flex-shrink-0" />
) : (
<Plus className="w-3.5 h-3.5 text-muted flex-shrink-0" />
)}
</button>
);
})
)}
</div>
)}
{tab === 'event' && (
<div className="max-h-[200px] overflow-y-auto space-y-1 scrollbar-thin">
{filteredEvents.length === 0 ? (
<p className="text-[11px] text-muted text-center py-4">Нет доступных событий</p>
) : (
filteredEvents.map((event) => {
const alreadyAdded = isItemAlreadyInDay(event.id);
const precheck = validateItemPlacement(
{ refType: 'event', refId: event.id, title: event.title, lat: event.lat || 0, lng: event.lng || 0 },
dayDate,
dayItems,
poisMap,
eventsMap,
);
return (
<button
key={event.id}
onClick={() => !alreadyAdded && precheck.valid && handleAddEvent(event)}
disabled={alreadyAdded || !precheck.valid}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors ${
alreadyAdded
? 'opacity-50 cursor-not-allowed bg-surface/20'
: !precheck.valid
? 'opacity-60 cursor-not-allowed bg-red-500/5'
: 'hover:bg-surface/30'
}`}
>
<Ticket className="w-3.5 h-3.5 text-accent flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-[11px] font-medium text-primary truncate block">{event.title}</span>
{event.dateStart && (
<span className="text-[9px] text-muted">{event.dateStart}</span>
)}
{!precheck.valid && (
<span className="text-[9px] text-red-400 block">{precheck.reason}</span>
)}
</div>
{alreadyAdded ? (
<Check className="w-3.5 h-3.5 text-green-400 flex-shrink-0" />
) : (
<Plus className="w-3.5 h-3.5 text-muted flex-shrink-0" />
)}
</button>
);
})
)}
</div>
)}
{tab === 'custom' && (
<div className="space-y-3">
<input
type="text"
value={customTitle}
onChange={(e) => setCustomTitle(e.target.value)}
placeholder="Название пункта..."
className="w-full px-3 py-2 bg-surface/40 border border-border/30 rounded-lg text-[12px] text-primary placeholder:text-muted focus:outline-none focus:border-accent/40"
/>
<div className="flex flex-wrap gap-1.5">
{customTypeOptions.map(({ value, label, icon: Icon }) => (
<button
key={value}
onClick={() => setCustomType(value)}
className={`flex items-center gap-1 px-2.5 py-1.5 text-[10px] rounded-lg transition-colors ${
customType === value
? 'bg-accent/20 text-accent border border-accent/30'
: 'bg-surface/30 text-muted hover:text-secondary border border-border/20'
}`}
>
<Icon className="w-3 h-3" />
{label}
</button>
))}
</div>
<div className="flex items-center gap-2">
<Clock className="w-3.5 h-3.5 text-muted" />
<input
type="number"
value={customDuration}
onChange={(e) => setCustomDuration(Math.max(15, Number(e.target.value)))}
min={15}
max={480}
step={15}
className="w-20 px-2 py-1.5 bg-surface/40 border border-border/30 rounded-lg text-[11px] text-primary focus:outline-none focus:border-accent/40"
/>
<span className="text-[10px] text-muted">мин</span>
</div>
<button
onClick={handleAddCustom}
disabled={!customTitle.trim()}
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-accent/20 text-accent rounded-lg text-[11px] font-medium hover:bg-accent/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<Plus className="w-3.5 h-3.5" />
Добавить
</button>
</div>
)}
</div>
</motion.div>
);
}
// --- Droppable Day Container ---
function DroppableDayZone({ dayIdx, children }: { dayIdx: number; children: React.ReactNode }) {
const { setNodeRef, isOver } = useDroppable({
id: `droppable-day-${dayIdx}`,
data: { dayIdx },
});
return (
<div
ref={setNodeRef}
className={`transition-colors rounded-lg ${isOver ? 'ring-2 ring-accent/40 bg-accent/5' : ''}`}
>
{children}
</div>
);
}
// --- Category filter for AddItemPanel ---
type POICategory = 'all' | 'attraction' | 'museum' | 'park' | 'restaurant' | 'shopping' | 'entertainment' | 'religious';
const categoryFilters: { value: POICategory; label: string }[] = [
{ value: 'all', label: 'Все' },
{ value: 'attraction', label: 'Достопримечательности' },
{ value: 'museum', label: 'Музеи' },
{ value: 'park', label: 'Парки' },
{ value: 'restaurant', label: 'Рестораны' },
{ value: 'shopping', label: 'Шопинг' },
{ value: 'entertainment', label: 'Развлечения' },
];
// --- Main EditableItinerary Component ---
interface EditableItineraryProps {
days: ItineraryDay[];
pois: POICard[];
events: EventCard[];
dailyForecast?: DailyForecast[];
onApply: (days: EditableDay[]) => void;
onCancel: () => void;
onValidateWithLLM?: (days: EditableDay[]) => Promise<LLMValidationResponse | null>;
}
export function EditableItinerary({
days,
pois,
events,
dailyForecast,
onApply,
onCancel,
onValidateWithLLM,
}: EditableItineraryProps) {
const poisMap = useMemo(() => new Map(pois.map((p) => [p.id, p])), [pois]);
const eventsMap = useMemo(() => new Map(events.map((e) => [e.id, e])), [events]);
const {
editableDays,
isEditing,
hasChanges,
isValidating,
llmWarnings,
llmSuggestions,
startEditing,
stopEditing,
resetChanges,
moveItem,
addItem,
removeItem,
addCustomItem,
applyChanges,
} = useEditableItinerary({
initialDays: days,
poisMap,
eventsMap,
dailyForecast,
onValidateWithLLM,
});
const [activeItem, setActiveItem] = useState<ItineraryItem | null>(null);
const [activeDragId, setActiveDragId] = useState<string | null>(null);
const [validationToast, setValidationToast] = useState<ValidationResult | null>(null);
const [addPanelDayIdx, setAddPanelDayIdx] = useState<number | null>(null);
const [expandedDays, setExpandedDays] = useState<Set<number>>(new Set([0]));
const [showLLMResults, setShowLLMResults] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
// Start editing on mount
if (!isEditing) {
startEditing();
}
const getItemId = (dayIdx: number, itemIdx: number): string =>
`day-${dayIdx}-item-${itemIdx}`;
const parseItemId = (id: string): { dayIdx: number; itemIdx: number } | null => {
const match = id.match(/^day-(\d+)-item-(\d+)$/);
if (!match) return null;
return { dayIdx: parseInt(match[1], 10), itemIdx: parseInt(match[2], 10) };
};
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const parsed = parseItemId(String(event.active.id));
if (!parsed) return;
const item = editableDays[parsed.dayIdx]?.items[parsed.itemIdx];
if (item) {
setActiveItem(item);
setActiveDragId(String(event.active.id));
}
},
[editableDays],
);
const handleDragOver = useCallback(
(event: DragOverEvent) => {
const { active, over } = event;
if (!over) return;
const from = parseItemId(String(active.id));
if (!from) return;
const overId = String(over.id);
let toDayIdx: number | null = null;
if (overId.startsWith('droppable-day-')) {
toDayIdx = parseInt(overId.replace('droppable-day-', ''), 10);
} else {
const to = parseItemId(overId);
if (to) toDayIdx = to.dayIdx;
}
if (toDayIdx !== null && toDayIdx !== from.dayIdx) {
const toItemIdx = editableDays[toDayIdx]?.items.length ?? 0;
const result = moveItem(from.dayIdx, from.itemIdx, toDayIdx, toItemIdx);
if (!result.valid) {
setValidationToast(result);
setTimeout(() => setValidationToast(null), 4000);
}
}
},
[editableDays, moveItem],
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setActiveItem(null);
setActiveDragId(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
const from = parseItemId(String(active.id));
const to = parseItemId(String(over.id));
if (!from || !to) return;
if (from.dayIdx === to.dayIdx) {
const result = moveItem(from.dayIdx, from.itemIdx, to.dayIdx, to.itemIdx);
if (!result.valid) {
setValidationToast(result);
setTimeout(() => setValidationToast(null), 4000);
}
}
},
[moveItem],
);
const handleApply = useCallback(async () => {
if (onValidateWithLLM) {
const result = await applyChanges();
if (result) {
setShowLLMResults(true);
const hasCritical = result.warnings.length > 0;
if (!hasCritical) {
onApply(editableDays);
}
} else {
onApply(editableDays);
}
} else {
onApply(editableDays);
}
}, [applyChanges, editableDays, onApply, onValidateWithLLM]);
const handleCancel = useCallback(() => {
stopEditing();
onCancel();
}, [stopEditing, onCancel]);
const toggleDay = useCallback((idx: number) => {
setExpandedDays((prev) => {
const next = new Set(prev);
if (next.has(idx)) next.delete(idx);
else next.add(idx);
return next;
});
}, []);
const getWarningForItem = useCallback(
(dayIdx: number, itemIdx: number): LLMValidationWarning | undefined =>
llmWarnings.find((w) => w.dayIdx === dayIdx && w.itemIdx === itemIdx),
[llmWarnings],
);
if (!isEditing || editableDays.length === 0) return null;
return (
<div className="space-y-2">
{/* Toolbar */}
<div className="flex items-center justify-between gap-2 px-1 pb-2 border-b border-border/30">
<div className="flex items-center gap-2">
<button
onClick={handleApply}
disabled={!hasChanges || isValidating}
className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-medium bg-accent/20 text-accent rounded-lg hover:bg-accent/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{isValidating ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Check className="w-3.5 h-3.5" />
)}
{isValidating ? 'Проверка...' : 'Применить'}
</button>
<button
onClick={resetChanges}
disabled={!hasChanges}
className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] text-muted hover:text-secondary rounded-lg hover:bg-surface/30 transition-colors disabled:opacity-40"
>
<RotateCcw className="w-3.5 h-3.5" />
Сбросить
</button>
</div>
<button
onClick={handleCancel}
className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] text-muted hover:text-primary rounded-lg hover:bg-surface/30 transition-colors"
>
<X className="w-3.5 h-3.5" />
Отмена
</button>
</div>
{/* LLM Validation Results */}
<AnimatePresence>
{showLLMResults && (llmWarnings.length > 0 || llmSuggestions.length > 0) && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="space-y-2 pb-2">
{llmWarnings.length > 0 && (
<div className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-4 h-4 text-amber-400" />
<span className="text-[11px] font-medium text-amber-300">Предупреждения</span>
</div>
<ul className="space-y-1">
{llmWarnings.map((w, i) => (
<li key={i} className="text-[10px] text-amber-200/80 flex items-start gap-1.5">
<span className="text-amber-400 flex-shrink-0"></span>
{w.message}
</li>
))}
</ul>
<button
onClick={() => { onApply(editableDays); setShowLLMResults(false); }}
className="mt-2 text-[10px] text-accent hover:underline"
>
Применить всё равно
</button>
</div>
)}
{llmSuggestions.length > 0 && (
<div className="p-3 rounded-lg bg-accent/5 border border-accent/20">
<div className="flex items-center gap-2 mb-2">
<Lightbulb className="w-4 h-4 text-accent" />
<span className="text-[11px] font-medium text-accent">Рекомендации</span>
</div>
<ul className="space-y-1">
{llmSuggestions.map((s, i) => (
<li key={i} className="text-[10px] text-secondary flex items-start gap-1.5">
<span className="text-accent flex-shrink-0"></span>
{s.message}
</li>
))}
</ul>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{/* DnD Context */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
{editableDays.map((day, dayIdx) => {
const isExpanded = expandedDays.has(dayIdx);
const itemIds = day.items.map((_, i) => getItemId(dayIdx, i));
return (
<DroppableDayZone key={`edit-day-${dayIdx}`} dayIdx={dayIdx}>
<div
className="border border-border/30 rounded-xl overflow-hidden bg-elevated/20"
>
<button
onClick={() => toggleDay(dayIdx)}
className="w-full flex items-center justify-between px-3.5 py-2.5 hover:bg-surface/30 transition-colors"
>
<div className="flex items-center gap-2.5">
<span className="w-6 h-6 rounded-md bg-accent text-white text-[10px] font-bold flex items-center justify-center">
{dayIdx + 1}
</span>
<span className="text-[13px] font-medium text-primary">{day.date}</span>
<span className="text-[11px] text-muted">{day.items.length} мест</span>
</div>
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-muted" />
) : (
<ChevronDown className="w-4 h-4 text-muted" />
)}
</button>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-3.5 pb-3 pt-1 space-y-1.5">
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
{day.items.map((item, itemIdx) => (
<SortableItem
key={getItemId(dayIdx, itemIdx)}
item={item}
itemId={getItemId(dayIdx, itemIdx)}
dayIdx={dayIdx}
itemIdx={itemIdx}
onRemove={() => removeItem(dayIdx, itemIdx)}
warning={getWarningForItem(dayIdx, itemIdx)}
/>
))}
</SortableContext>
{day.items.length === 0 && (
<div className="py-4 text-center text-[11px] text-muted">
Пусто добавьте пункты
</div>
)}
<button
onClick={() =>
setAddPanelDayIdx(addPanelDayIdx === dayIdx ? null : dayIdx)
}
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 border border-dashed border-border/40 rounded-lg text-[11px] text-muted hover:text-accent hover:border-accent/30 transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Добавить пункт
</button>
<AnimatePresence>
{addPanelDayIdx === dayIdx && (
<AddItemPanel
pois={pois}
events={events}
dayIdx={dayIdx}
dayDate={day.date}
dayItems={day.items}
poisMap={poisMap}
eventsMap={eventsMap}
onAdd={addItem}
onAddCustom={addCustomItem}
onClose={() => setAddPanelDayIdx(null)}
/>
)}
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</DroppableDayZone>
);
})}
<DragOverlay>
{activeItem ? <DragOverlayItem item={activeItem} /> : null}
</DragOverlay>
</DndContext>
{/* Validation Toast */}
<AnimatePresence>
{validationToast && !validationToast.valid && (
<ValidationToast
result={validationToast}
onDismiss={() => setValidationToast(null)}
/>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,668 @@
'use client';
import { useMemo, useState, useCallback } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import {
Activity,
AlertTriangle,
ArrowUpRight,
Beaker,
Calendar,
ChevronDown,
ChevronUp,
CircleAlert,
ExternalLink,
HeartPulse,
HelpCircle,
Home,
Loader2,
MapPin,
Monitor,
Pill,
Shield,
Stethoscope,
Thermometer,
Zap,
} from 'lucide-react';
import type { MedicineWidget } from '@/lib/hooks/useMedicineChat';
type TabId = 'assessment' | 'doctors' | 'booking' | 'reference';
interface TabDef {
id: TabId;
label: string;
icon: typeof HeartPulse;
emptyLabel: string;
}
const TABS: TabDef[] = [
{ id: 'assessment', label: 'Оценка', icon: HeartPulse, emptyLabel: 'Оценка симптомов появится после запроса' },
{ id: 'doctors', label: 'Врачи', icon: Stethoscope, emptyLabel: 'Специалисты подбираются после анализа' },
{ id: 'booking', label: 'Запись', icon: Calendar, emptyLabel: 'Ссылки на запись появятся после подбора врачей' },
{ id: 'reference', label: 'Справка', icon: Pill, emptyLabel: 'Справочная информация появится после ответа' },
];
interface ConditionItem {
name: string;
likelihood: string;
why: string;
}
interface SpecialtyItem {
specialty: string;
reason: string;
priority: string;
}
interface DoctorOption {
id: string;
name: string;
specialty: string;
clinic: string;
city: string;
address?: string;
sourceUrl: string;
sourceName: string;
snippet?: string;
}
interface BookingLink {
id: string;
doctorId: string;
doctor: string;
specialty: string;
clinic: string;
bookUrl: string;
remote: boolean;
}
interface MedicationInfoItem {
name: string;
forWhat: string;
notes: string;
}
interface SupplementInfoItem {
name: string;
purpose: string;
evidence: string;
notes: string;
}
interface ProcedureInfoItem {
name: string;
purpose: string;
whenUseful: string;
}
const TRIAGE_CONFIG: Record<string, { label: string; color: string; bg: string; border: string; icon: typeof Zap }> = {
low: { label: 'Низкий', color: 'text-green-400', bg: 'bg-green-500/10', border: 'border-green-500/30', icon: Shield },
medium: { label: 'Средний', color: 'text-amber-400', bg: 'bg-amber-500/10', border: 'border-amber-500/30', icon: Activity },
high: { label: 'Высокий', color: 'text-orange-400', bg: 'bg-orange-500/10', border: 'border-orange-500/30', icon: AlertTriangle },
emergency: { label: 'Экстренный', color: 'text-red-400', bg: 'bg-red-500/10', border: 'border-red-500/30', icon: Zap },
};
const LIKELIHOOD_CONFIG: Record<string, { label: string; width: string; color: string }> = {
low: { label: 'Маловероятно', width: 'w-1/4', color: 'bg-blue-500/60' },
medium: { label: 'Возможно', width: 'w-2/4', color: 'bg-amber-500/60' },
high: { label: 'Вероятно', width: 'w-3/4', color: 'bg-orange-500/60' },
};
const EVIDENCE_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
low: { label: 'Слабая доказательность', color: 'text-zinc-400', bg: 'bg-zinc-500/15' },
medium: { label: 'Умеренная доказательность', color: 'text-amber-400', bg: 'bg-amber-500/15' },
high: { label: 'Высокая доказательность', color: 'text-green-400', bg: 'bg-green-500/15' },
};
function CollapsibleSection({
title,
icon: Icon,
children,
defaultOpen = true,
count,
}: {
title: string;
icon: typeof HeartPulse;
children: React.ReactNode;
defaultOpen?: boolean;
count?: number;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="border border-border/20 rounded-xl overflow-hidden">
<button
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-surface/30 hover:bg-surface/50 transition-colors"
>
<div className="flex items-center gap-2 text-sm font-medium text-primary">
<Icon className="w-4 h-4 text-accent" />
{title}
{count !== undefined && count > 0 && (
<span className="text-xs text-muted bg-surface/50 px-1.5 py-0.5 rounded-md">{count}</span>
)}
</div>
{open ? <ChevronUp className="w-3.5 h-3.5 text-muted" /> : <ChevronDown className="w-3.5 h-3.5 text-muted" />}
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="p-3 space-y-2">{children}</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function TriageBadge({ level }: { level: string }) {
const cfg = TRIAGE_CONFIG[level] || TRIAGE_CONFIG.medium;
const Icon = cfg.icon;
return (
<div className={`flex items-center gap-3 p-4 rounded-xl border ${cfg.bg} ${cfg.border}`}>
<div className={`w-10 h-10 rounded-xl ${cfg.bg} flex items-center justify-center`}>
<Icon className={`w-5 h-5 ${cfg.color}`} />
</div>
<div>
<div className="text-xs text-muted uppercase tracking-wider">Уровень приоритета</div>
<div className={`text-lg font-bold ${cfg.color}`}>{cfg.label}</div>
</div>
</div>
);
}
function LikelihoodBar({ likelihood }: { likelihood: string }) {
const cfg = LIKELIHOOD_CONFIG[likelihood] || LIKELIHOOD_CONFIG.medium;
return (
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-surface/40 rounded-full overflow-hidden">
<div className={`h-full ${cfg.width} ${cfg.color} rounded-full transition-all`} />
</div>
<span className="text-xs text-muted whitespace-nowrap">{cfg.label}</span>
</div>
);
}
function AssessmentTab({ widget }: { widget: MedicineWidget | undefined }) {
if (!widget) return null;
const p = widget.params;
const triageLevel = String(p.triageLevel || 'medium');
const urgentSigns = (p.urgentSigns || []) as string[];
const conditions = (p.possibleConditions || []) as ConditionItem[];
const specialists = (p.recommendedSpecialists || []) as SpecialtyItem[];
const questions = (p.questionsToClarify || []) as string[];
const homeCare = (p.homeCare || []) as string[];
const disclaimer = String(p.disclaimer || '');
return (
<div className="space-y-3">
<TriageBadge level={triageLevel} />
{urgentSigns.length > 0 && (
<div className="p-3 rounded-xl bg-red-500/5 border border-red-500/20">
<div className="flex items-center gap-2 mb-2">
<CircleAlert className="w-4 h-4 text-red-400" />
<span className="text-sm font-medium text-red-400">Красные флаги</span>
</div>
<div className="space-y-1">
{urgentSigns.map((sign, i) => (
<div key={`us-${i}`} className="flex items-start gap-2 text-xs text-secondary">
<span className="text-red-400 mt-0.5">!</span>
<span>{sign} <strong className="text-red-400">вызывайте 103/112</strong></span>
</div>
))}
</div>
</div>
)}
{conditions.length > 0 && (
<CollapsibleSection title="Вероятные состояния" icon={Thermometer} count={conditions.length}>
{conditions.map((c, i) => (
<div key={`cond-${i}`} className="p-3 rounded-lg bg-surface/20 border border-border/15 space-y-2">
<div className="flex items-start justify-between gap-2">
<span className="text-sm font-medium text-primary">{c.name}</span>
</div>
<LikelihoodBar likelihood={c.likelihood} />
{c.why && <p className="text-xs text-muted leading-relaxed">{c.why}</p>}
</div>
))}
</CollapsibleSection>
)}
{specialists.length > 0 && (
<CollapsibleSection title="Рекомендуемые специалисты" icon={Stethoscope} count={specialists.length}>
{specialists.map((sp, i) => (
<div key={`sp-${i}`} className="flex items-start gap-3 p-2.5 rounded-lg bg-surface/20 border border-border/15">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ${sp.priority === 'high' ? 'bg-accent/15' : 'bg-surface/40'}`}>
<Stethoscope className={`w-4 h-4 ${sp.priority === 'high' ? 'text-accent' : 'text-muted'}`} />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-primary">{sp.specialty}</span>
{sp.priority === 'high' && (
<span className="text-3xs px-1.5 py-0.5 rounded bg-accent/15 text-accent font-medium">Приоритет</span>
)}
</div>
<p className="text-xs text-muted mt-0.5">{sp.reason}</p>
</div>
</div>
))}
</CollapsibleSection>
)}
{questions.length > 0 && (
<CollapsibleSection title="Уточняющие вопросы" icon={HelpCircle} count={questions.length} defaultOpen={false}>
<p className="text-xs text-muted mb-2">Ответы на эти вопросы помогут уточнить оценку:</p>
{questions.map((q, i) => (
<div key={`q-${i}`} className="flex items-start gap-2 p-2 rounded-lg bg-surface/20">
<HelpCircle className="w-3.5 h-3.5 text-accent mt-0.5 shrink-0" />
<span className="text-xs text-secondary">{q}</span>
</div>
))}
</CollapsibleSection>
)}
{homeCare.length > 0 && (
<CollapsibleSection title="Что делать дома до визита" icon={Home} count={homeCare.length} defaultOpen={false}>
{homeCare.map((tip, i) => (
<div key={`hc-${i}`} className="flex items-start gap-2 p-2 rounded-lg bg-surface/20">
<span className="text-green-400 mt-0.5 text-xs">+</span>
<span className="text-xs text-secondary">{tip}</span>
</div>
))}
</CollapsibleSection>
)}
{disclaimer && (
<div className="p-3 rounded-xl bg-surface/20 border border-border/15">
<p className="text-xs text-muted italic leading-relaxed">{disclaimer}</p>
</div>
)}
</div>
);
}
function DoctorsTab({ widget }: { widget: MedicineWidget | undefined }) {
if (!widget) return null;
const p = widget.params;
const doctors = (p.doctors || []) as DoctorOption[];
const specialists = (p.specialists || []) as SpecialtyItem[];
const city = String(p.city || '');
const grouped = useMemo(() => {
const map = new Map<string, { specialist: SpecialtyItem | null; doctors: DoctorOption[] }>();
for (const d of doctors) {
const key = d.specialty;
if (!map.has(key)) {
const sp = specialists.find((s) => s.specialty === key) || null;
map.set(key, { specialist: sp, doctors: [] });
}
map.get(key)!.doctors.push(d);
}
return Array.from(map.entries());
}, [doctors, specialists]);
if (doctors.length === 0) {
return (
<div className="text-center py-8">
<Stethoscope className="w-8 h-8 text-muted/30 mx-auto mb-2" />
<p className="text-sm text-muted">Специалисты не найдены</p>
<p className="text-xs text-muted/70 mt-1">Попробуйте указать другой город</p>
</div>
);
}
return (
<div className="space-y-4">
{city && (
<div className="flex items-center gap-2 text-xs text-muted">
<MapPin className="w-3.5 h-3.5" />
<span>Поиск в городе: <strong className="text-secondary">{city}</strong></span>
<span className="text-muted/50">|</span>
<span>Найдено: <strong className="text-secondary">{doctors.length}</strong></span>
</div>
)}
{grouped.map(([specialty, group]) => (
<CollapsibleSection
key={specialty}
title={specialty}
icon={Stethoscope}
count={group.doctors.length}
>
{group.specialist && (
<div className="flex items-center gap-2 mb-2 px-1">
<span className="text-xs text-muted">Причина направления:</span>
<span className="text-xs text-secondary">{group.specialist.reason}</span>
{group.specialist.priority === 'high' && (
<span className="text-3xs px-1.5 py-0.5 rounded bg-accent/15 text-accent font-medium">Приоритет</span>
)}
</div>
)}
{group.doctors.map((d) => (
<div key={d.id} className="p-3 rounded-lg bg-surface/20 border border-border/15 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium text-primary truncate">{d.clinic || d.name}</p>
{d.sourceName && (
<p className="text-xs text-muted mt-0.5">Источник: {d.sourceName}</p>
)}
</div>
{d.sourceUrl && (
<a
href={d.sourceUrl}
target="_blank"
rel="noreferrer"
className="shrink-0 flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium text-accent bg-accent/10 hover:bg-accent/20 border border-accent/20 rounded-lg transition-colors"
>
Записаться
<ArrowUpRight className="w-3 h-3" />
</a>
)}
</div>
{d.snippet && (
<p className="text-xs text-muted leading-relaxed line-clamp-3">{d.snippet}</p>
)}
</div>
))}
</CollapsibleSection>
))}
</div>
);
}
function BookingTab({ widget }: { widget: MedicineWidget | undefined }) {
if (!widget) return null;
const p = widget.params;
const bookingLinks = (p.bookingLinks || p.slots || []) as BookingLink[];
if (bookingLinks.length === 0) {
return (
<div className="text-center py-8">
<Calendar className="w-8 h-8 text-muted/30 mx-auto mb-2" />
<p className="text-sm text-muted">Ссылки на запись не найдены</p>
<p className="text-xs text-muted/70 mt-1">Попробуйте уточнить город или специальность</p>
</div>
);
}
const grouped = useMemo(() => {
const map = new Map<string, BookingLink[]>();
for (const link of bookingLinks) {
const key = link.specialty || 'Другое';
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(link);
}
return Array.from(map.entries());
}, [bookingLinks]);
return (
<div className="space-y-4">
<div className="p-3 rounded-xl bg-accent/5 border border-accent/15">
<div className="flex items-start gap-2">
<Calendar className="w-4 h-4 text-accent mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium text-primary">Запись к специалистам</p>
<p className="text-xs text-muted mt-0.5">
Нажмите на ссылку, чтобы перейти на сайт клиники и выбрать удобное время.
</p>
</div>
</div>
</div>
{grouped.map(([specialty, links]) => (
<CollapsibleSection key={specialty} title={specialty} icon={Stethoscope} count={links.length}>
{links.map((link) => (
<a
key={link.id}
href={link.bookUrl}
target="_blank"
rel="noreferrer"
className="flex items-center gap-3 p-3 rounded-lg bg-surface/20 border border-border/15 hover:border-accent/30 hover:bg-accent/5 transition-all group"
>
<div className="w-9 h-9 rounded-lg bg-surface/40 flex items-center justify-center shrink-0 group-hover:bg-accent/15 transition-colors">
{link.remote ? (
<Monitor className="w-4 h-4 text-muted group-hover:text-accent transition-colors" />
) : (
<MapPin className="w-4 h-4 text-muted group-hover:text-accent transition-colors" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-primary truncate">{link.doctor}</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted truncate">{link.clinic}</span>
{link.remote && (
<span className="text-3xs px-1.5 py-0.5 rounded bg-blue-500/15 text-blue-400 font-medium shrink-0">Онлайн</span>
)}
</div>
</div>
<ExternalLink className="w-4 h-4 text-muted group-hover:text-accent transition-colors shrink-0" />
</a>
))}
</CollapsibleSection>
))}
</div>
);
}
function ReferenceTab({ widget }: { widget: MedicineWidget | undefined }) {
if (!widget) return null;
const p = widget.params;
const medications = (p.medicationInfo || []) as MedicationInfoItem[];
const supplements = (p.supplementInfo || []) as SupplementInfoItem[];
const procedures = (p.procedureInfo || []) as ProcedureInfoItem[];
const note = String(p.note || '');
const hasContent = medications.length > 0 || supplements.length > 0 || procedures.length > 0;
if (!hasContent) {
return (
<div className="text-center py-8">
<Pill className="w-8 h-8 text-muted/30 mx-auto mb-2" />
<p className="text-sm text-muted">Справочная информация отсутствует</p>
</div>
);
}
return (
<div className="space-y-3">
{note && (
<div className="p-3 rounded-xl bg-amber-500/5 border border-amber-500/15">
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-amber-500 mt-0.5 shrink-0" />
<p className="text-xs text-muted leading-relaxed">{note}</p>
</div>
</div>
)}
{medications.length > 0 && (
<CollapsibleSection title="Лекарственные препараты" icon={Pill} count={medications.length}>
<p className="text-xs text-muted mb-2">Только справочная информация. Назначения делает врач.</p>
{medications.map((m, i) => (
<div key={`med-${i}`} className="p-3 rounded-lg bg-surface/20 border border-border/15 space-y-1.5">
<div className="flex items-center gap-2">
<Pill className="w-3.5 h-3.5 text-blue-400" />
<span className="text-sm font-medium text-primary">{m.name}</span>
</div>
<p className="text-xs text-secondary"><strong>Применяется:</strong> {m.forWhat}</p>
{m.notes && <p className="text-xs text-muted italic">{m.notes}</p>}
</div>
))}
</CollapsibleSection>
)}
{supplements.length > 0 && (
<CollapsibleSection title="БАДы и добавки" icon={Beaker} count={supplements.length}>
{supplements.map((s, i) => {
const ev = EVIDENCE_CONFIG[s.evidence] || EVIDENCE_CONFIG.low;
return (
<div key={`sup-${i}`} className="p-3 rounded-lg bg-surface/20 border border-border/15 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Beaker className="w-3.5 h-3.5 text-green-400" />
<span className="text-sm font-medium text-primary">{s.name}</span>
</div>
<span className={`text-3xs px-1.5 py-0.5 rounded font-medium ${ev.bg} ${ev.color}`}>
{ev.label}
</span>
</div>
<p className="text-xs text-secondary"><strong>Назначение:</strong> {s.purpose}</p>
{s.notes && <p className="text-xs text-muted italic">{s.notes}</p>}
</div>
);
})}
</CollapsibleSection>
)}
{procedures.length > 0 && (
<CollapsibleSection title="Обследования и процедуры" icon={Activity} count={procedures.length}>
{procedures.map((pr, i) => (
<div key={`proc-${i}`} className="p-3 rounded-lg bg-surface/20 border border-border/15 space-y-1.5">
<div className="flex items-center gap-2">
<Activity className="w-3.5 h-3.5 text-purple-400" />
<span className="text-sm font-medium text-primary">{pr.name}</span>
</div>
<p className="text-xs text-secondary"><strong>Что покажет:</strong> {pr.purpose}</p>
{pr.whenUseful && (
<p className="text-xs text-muted"><strong>Когда назначают:</strong> {pr.whenUseful}</p>
)}
</div>
))}
</CollapsibleSection>
)}
</div>
);
}
interface MedicineWidgetTabsProps {
widgets: MedicineWidget[];
isLoading?: boolean;
}
export function MedicineWidgetTabs({ widgets, isLoading }: MedicineWidgetTabsProps) {
const [activeTab, setActiveTab] = useState<TabId>('assessment');
const byType = useMemo(() => {
const out = new Map<string, MedicineWidget>();
for (const w of widgets) {
if (w.type.startsWith('medicine_')) out.set(w.type, w);
}
return out;
}, [widgets]);
const assessment = byType.get('medicine_assessment');
const doctors = byType.get('medicine_doctors');
const appointments = byType.get('medicine_appointments');
const reference = byType.get('medicine_reference');
const tabHasData = useCallback(
(id: TabId): boolean => {
switch (id) {
case 'assessment': return Boolean(assessment);
case 'doctors': return Boolean(doctors);
case 'booking': return Boolean(appointments);
case 'reference': return Boolean(reference);
default: return false;
}
},
[assessment, doctors, appointments, reference],
);
const tabCounts = useMemo((): Record<TabId, number> => {
const doctorList = (doctors?.params.doctors || []) as DoctorOption[];
const bookingList = (appointments?.params.bookingLinks || appointments?.params.slots || []) as BookingLink[];
const conditions = (assessment?.params.possibleConditions || []) as ConditionItem[];
const meds = (reference?.params.medicationInfo || []) as MedicationInfoItem[];
const sups = (reference?.params.supplementInfo || []) as SupplementInfoItem[];
const procs = (reference?.params.procedureInfo || []) as ProcedureInfoItem[];
return {
assessment: conditions.length,
doctors: doctorList.length,
booking: bookingList.length,
reference: meds.length + sups.length + procs.length,
};
}, [assessment, doctors, appointments, reference]);
return (
<div className="h-full min-h-0 rounded-xl border border-border/50 bg-base overflow-hidden flex flex-col">
<div className="h-11 shrink-0 flex items-center gap-0.5 px-2 pt-2 pb-0 overflow-x-auto scrollbar-hide bg-base border-b border-border/30">
{TABS.map((tab) => {
const isActive = activeTab === tab.id;
const Icon = tab.icon;
const count = tabCounts[tab.id];
const hasData = tabHasData(tab.id);
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`h-9 flex items-center gap-1.5 px-3 text-ui-sm font-medium rounded-t-lg transition-all whitespace-nowrap flex-shrink-0 border-b-2 ${
isActive
? 'bg-surface/60 text-primary border-accent'
: 'text-muted hover:text-secondary hover:bg-surface/30 border-transparent'
}`}
>
<Icon className="w-3.5 h-3.5" />
{tab.label}
{hasData && count > 0 && (
<span
className={`min-w-[18px] h-[14px] px-1.5 text-3xs rounded-full inline-flex items-center justify-center ${
isActive ? 'bg-accent/20 text-accent' : 'bg-surface/50 text-muted'
}`}
>
{count}
</span>
)}
{hasData && count === 0 && <span className="w-1.5 h-1.5 rounded-full bg-accent" />}
</button>
);
})}
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
className="p-3"
>
{isLoading && widgets.length === 0 ? (
<div className="flex items-center justify-center gap-2 py-8 text-muted">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-xs">Анализ симптомов...</span>
</div>
) : !tabHasData(activeTab) ? (
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted">
<CircleAlert className="w-5 h-5 opacity-40" />
<span className="text-xs">{TABS.find((t) => t.id === activeTab)?.emptyLabel}</span>
</div>
) : (
<>
{activeTab === 'assessment' && <AssessmentTab widget={assessment} />}
{activeTab === 'doctors' && <DoctorsTab widget={doctors} />}
{activeTab === 'booking' && <BookingTab widget={appointments} />}
{activeTab === 'reference' && <ReferenceTab widget={reference} />}
</>
)}
</motion.div>
</AnimatePresence>
</div>
</div>
);
}

View File

@@ -0,0 +1,643 @@
'use client';
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import dynamic from 'next/dynamic';
import {
FileCode,
Terminal,
Play,
CheckCircle2,
XCircle,
Loader2,
ChevronRight,
ChevronDown,
File,
Folder,
RefreshCw,
Save,
Plus,
} from 'lucide-react';
import { useTheme } from '@/lib/contexts/ThemeContext';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
const MonacoEditor = dynamic(() => import('@monaco-editor/react'), { ssr: false });
function getAuthHeaders(): HeadersInit {
const headers: HeadersInit = { 'Content-Type': 'application/json' };
if (typeof window !== 'undefined') {
const token = localStorage.getItem('token');
if (token) headers['Authorization'] = `Bearer ${token}`;
}
return headers;
}
interface FileNode {
name: string;
path: string;
type: 'file' | 'dir';
children?: FileNode[];
}
interface SandboxPanelProps {
sessionId: string;
onVerifyResult?: (passed: boolean, result: Record<string, unknown>) => void;
}
export default function SandboxPanel({ sessionId, onVerifyResult }: SandboxPanelProps) {
const { theme } = useTheme();
const [activeTab, setActiveTab] = useState<'editor' | 'terminal'>('editor');
const [files, setFiles] = useState<FileNode[]>([]);
const [isLoadingFiles, setIsLoadingFiles] = useState(false);
const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set());
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set(['/home/user']));
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileContent, setFileContent] = useState('');
const [originalFileContent, setOriginalFileContent] = useState<string>('');
const [isLoadingFile, setIsLoadingFile] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [terminalOutput, setTerminalOutput] = useState('');
const [terminalCmd, setTerminalCmd] = useState('');
const [isRunningCmd, setIsRunningCmd] = useState(false);
const [cmdHistory, setCmdHistory] = useState<string[]>([]);
const [historyIndex, setHistoryIndex] = useState<number>(-1);
const [isVerifying, setIsVerifying] = useState(false);
const [verifyResult, setVerifyResult] = useState<{ passed: boolean; stdout: string } | null>(null);
const terminalRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<unknown>(null);
const isDirty = useMemo(() => selectedFile != null && fileContent !== originalFileContent, [
selectedFile,
fileContent,
originalFileContent,
]);
const parseFileNodes = useCallback((data: unknown): FileNode[] => {
const rawEntries: unknown[] = Array.isArray(data)
? data
: (data as { entries?: unknown[] } | null)?.entries && Array.isArray((data as { entries?: unknown[] }).entries)
? ((data as { entries?: unknown[] }).entries as unknown[])
: [];
return rawEntries
.map((e) => {
const entry = e as { name?: unknown; path?: unknown; type?: unknown };
const name = typeof entry.name === 'string' ? entry.name : '';
const path = typeof entry.path === 'string' ? entry.path : '';
const typeRaw = typeof entry.type === 'string' ? entry.type : 'file';
const type: 'file' | 'dir' = typeRaw === 'dir' || typeRaw === 'directory' ? 'dir' : 'file';
if (!name || !path) return null;
return { name, path, type } satisfies FileNode;
})
.filter((v): v is FileNode => v != null)
.sort((a, b) => {
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
return a.name.localeCompare(b.name);
});
}, []);
const updateNodeChildren = useCallback((nodes: FileNode[], dirPath: string, children: FileNode[]): FileNode[] => {
return nodes.map((n) => {
if (n.type === 'dir' && n.path === dirPath) {
return { ...n, children };
}
if (n.type === 'dir' && n.children) {
return { ...n, children: updateNodeChildren(n.children, dirPath, children) };
}
return n;
});
}, []);
const fetchDir = useCallback(
async (path: string): Promise<FileNode[]> => {
const resp = await fetch(
`${API_BASE}/api/v1/sandbox/sessions/${sessionId}/files?path=${encodeURIComponent(path)}`,
{ headers: getAuthHeaders() }
);
if (!resp.ok) {
throw new Error(`list files failed: ${resp.status}`);
}
const data: unknown = await resp.json();
return parseFileNodes(data);
},
[sessionId, parseFileNodes]
);
const loadFiles = useCallback(async (path = '/home/user') => {
setIsLoadingFiles(true);
setErrorMessage(null);
try {
const nodes = await fetchDir(path);
if (path === '/home/user') setFiles(nodes);
return nodes;
} catch (e) {
const msg = e instanceof Error ? e.message : 'failed to load files';
setErrorMessage(msg);
return [];
} finally {
setIsLoadingFiles(false);
}
}, [fetchDir]);
useEffect(() => {
loadFiles();
}, [loadFiles]);
const openFile = useCallback(async (path: string) => {
setSelectedFile(path);
setActiveTab('editor');
setIsLoadingFile(true);
setErrorMessage(null);
try {
const resp = await fetch(`${API_BASE}/api/v1/sandbox/sessions/${sessionId}/file?path=${encodeURIComponent(path)}`, {
headers: getAuthHeaders(),
});
if (!resp.ok) throw new Error(`read file failed: ${resp.status}`);
const data: unknown = await resp.json();
const content = (data as { content?: unknown } | null)?.content;
const text = typeof content === 'string' ? content : '';
setFileContent(text);
setOriginalFileContent(text);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Error loading file';
setErrorMessage(msg);
setFileContent('');
setOriginalFileContent('');
} finally {
setIsLoadingFile(false);
}
}, [sessionId]);
const saveFile = useCallback(async () => {
if (!selectedFile) return;
setIsSaving(true);
setErrorMessage(null);
try {
const resp = await fetch(`${API_BASE}/api/v1/sandbox/sessions/${sessionId}/file`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ path: selectedFile, content: fileContent }),
});
if (!resp.ok) throw new Error(`write file failed: ${resp.status}`);
setOriginalFileContent(fileContent);
} finally {
setIsSaving(false);
}
}, [sessionId, selectedFile, fileContent]);
const toggleDir = useCallback(
async (node: FileNode) => {
if (node.type !== 'dir') return;
const willExpand = !expandedDirs.has(node.path);
setExpandedDirs((prev) => {
const next = new Set(prev);
if (next.has(node.path)) next.delete(node.path);
else next.add(node.path);
return next;
});
if (!willExpand) return;
if (node.children && node.children.length > 0) return;
setLoadingDirs((prev) => new Set(prev).add(node.path));
try {
const children = await fetchDir(node.path);
setFiles((prev) => updateNodeChildren(prev, node.path, children));
} catch (e) {
const msg = e instanceof Error ? e.message : 'failed to load directory';
setErrorMessage(msg);
} finally {
setLoadingDirs((prev) => {
const next = new Set(prev);
next.delete(node.path);
return next;
});
}
},
[expandedDirs, fetchDir, updateNodeChildren]
);
const runCommand = useCallback(async () => {
if (!terminalCmd.trim()) return;
const cmd = terminalCmd.trim();
setTerminalCmd('');
setIsRunningCmd(true);
setHistoryIndex(-1);
setCmdHistory((prev) => (prev[prev.length - 1] === cmd ? prev : [...prev, cmd]));
setTerminalOutput((prev) => prev + `$ ${cmd}\n`);
try {
const resp = await fetch(`${API_BASE}/api/v1/sandbox/sessions/${sessionId}/commands/run`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ command: cmd }),
});
if (!resp.ok) {
setTerminalOutput((prev) => prev + `Error: ${resp.status}\n`);
return;
}
const data = await resp.json();
const stdout = data.stdout || '';
const stderr = data.stderr || '';
const exitCode = data.exit_code ?? 0;
setTerminalOutput((prev) =>
prev + stdout + (stderr ? `\nstderr: ${stderr}` : '') + `\n[exit: ${exitCode}]\n\n`
);
} catch (err) {
setTerminalOutput((prev) => prev + `Error: ${err}\n`);
} finally {
setIsRunningCmd(false);
}
}, [sessionId, terminalCmd]);
const runVerify = useCallback(async () => {
setIsVerifying(true);
setVerifyResult(null);
setErrorMessage(null);
try {
if (isDirty && selectedFile) {
await saveFile();
}
const resp = await fetch(`${API_BASE}/api/v1/sandbox/sessions/${sessionId}/verify`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({}),
});
if (!resp.ok) {
setVerifyResult({ passed: false, stdout: `Error: ${resp.status}` });
return;
}
const data = await resp.json();
const result = {
passed: data.passed === true,
stdout: (data.result?.stdout || '') as string,
};
setVerifyResult(result);
onVerifyResult?.(result.passed, data.result || {});
} catch {
setVerifyResult({ passed: false, stdout: 'Verification failed' });
} finally {
setIsVerifying(false);
}
}, [sessionId, onVerifyResult, isDirty, selectedFile, saveFile]);
useEffect(() => {
terminalRef.current?.scrollTo(0, terminalRef.current.scrollHeight);
}, [terminalOutput]);
const fileName = useMemo(() => {
if (!selectedFile) return '';
return selectedFile.split('/').pop() || '';
}, [selectedFile]);
const editorLanguage = useMemo(() => detectLanguage(selectedFile), [selectedFile]);
const createNewFile = useCallback(async () => {
const p = window.prompt('Путь нового файла (пример: /home/user/main.go)');
if (!p) return;
setErrorMessage(null);
try {
const resp = await fetch(`${API_BASE}/api/v1/sandbox/sessions/${sessionId}/file`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ path: p, content: '' }),
});
if (!resp.ok) throw new Error(`create file failed: ${resp.status}`);
await loadFiles('/home/user');
await openFile(p);
} catch (e) {
const msg = e instanceof Error ? e.message : 'create file failed';
setErrorMessage(msg);
}
}, [sessionId, loadFiles, openFile]);
return (
<div className="flex flex-col h-full bg-background border-l border-border/30">
{/* Toolbar */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border/30 bg-elevated/40">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded icon-gradient flex items-center justify-center">
<FileCode className="w-3 h-3" />
</div>
<span className="text-xs font-medium text-primary">Песочница</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => loadFiles()}
className="p-1.5 rounded-md text-muted hover:text-primary hover:bg-surface/60 transition-all"
title="Обновить файлы"
>
<RefreshCw className="w-3 h-3" />
</button>
<button
onClick={createNewFile}
className="p-1.5 rounded-md text-muted hover:text-primary hover:bg-surface/60 transition-all"
title="Новый файл"
>
<Plus className="w-3 h-3" />
</button>
<button
onClick={runVerify}
disabled={isVerifying}
className="flex items-center gap-1 px-2 py-1 text-[10px] btn-gradient rounded-md"
>
{isVerifying ? (
<Loader2 className="w-3 h-3 animate-spin btn-gradient-text" />
) : (
<Play className="w-3 h-3 btn-gradient-text" />
)}
<span className="btn-gradient-text">Проверить</span>
</button>
</div>
</div>
{/* Error banner */}
<AnimatePresence>
{errorMessage && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="px-3 py-2 text-xs bg-error/10 text-error border-b border-error/20 overflow-hidden"
>
{errorMessage}
</motion.div>
)}
</AnimatePresence>
{/* Verify result banner */}
<AnimatePresence>
{verifyResult && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className={`px-3 py-2 text-xs flex items-center gap-2 overflow-hidden ${
verifyResult.passed
? 'bg-success/10 text-success border-b border-success/20'
: 'bg-error/10 text-error border-b border-error/20'
}`}
>
{verifyResult.passed ? (
<CheckCircle2 className="w-3.5 h-3.5 flex-shrink-0" />
) : (
<XCircle className="w-3.5 h-3.5 flex-shrink-0" />
)}
{verifyResult.passed ? 'Тест пройден!' : 'Тест не пройден'}
</motion.div>
)}
</AnimatePresence>
{/* Main area */}
<div className="flex flex-1 overflow-hidden">
{/* File tree */}
<div className="w-[180px] border-r border-border/30 overflow-y-auto bg-surface/20">
<div className="px-2 py-1.5 text-[10px] text-muted uppercase tracking-wider">Файлы</div>
{isLoadingFiles ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-3 h-3 animate-spin text-muted" />
</div>
) : (
<div className="text-xs">
{files.map((node) => (
<FileTreeNode
key={node.path || node.name}
node={node}
expanded={expandedDirs}
loading={loadingDirs}
selected={selectedFile}
onToggle={toggleDir}
onSelect={openFile}
depth={0}
/>
))}
</div>
)}
</div>
{/* Editor / Terminal */}
<div className="flex-1 flex flex-col min-w-0">
{/* Tabs */}
<div className="flex items-center gap-0 border-b border-border/30 bg-elevated/20">
<button
onClick={() => setActiveTab('editor')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-[11px] border-b-2 transition-all ${
activeTab === 'editor'
? 'border-accent text-primary'
: 'border-transparent text-muted hover:text-secondary'
}`}
>
<FileCode className="w-3 h-3" />
{fileName || 'Редактор'}
{isDirty && <span className="text-[10px] text-warning ml-1"></span>}
</button>
<button
onClick={() => setActiveTab('terminal')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-[11px] border-b-2 transition-all ${
activeTab === 'terminal'
? 'border-accent text-primary'
: 'border-transparent text-muted hover:text-secondary'
}`}
>
<Terminal className="w-3 h-3" />
Терминал
</button>
{activeTab === 'editor' && selectedFile && (
<button
onClick={saveFile}
disabled={isSaving}
className="ml-auto mr-2 p-1 text-muted hover:text-primary transition-all"
title="Сохранить"
>
{isSaving ? <Loader2 className="w-3 h-3 animate-spin" /> : <Save className="w-3 h-3" />}
</button>
)}
</div>
{/* Content */}
{activeTab === 'editor' ? (
<div className="flex-1 overflow-auto p-0">
{selectedFile ? (
isLoadingFile ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="w-5 h-5 animate-spin text-muted" />
</div>
) : (
<div className="w-full h-full">
<MonacoEditor
value={fileContent}
onChange={(v) => setFileContent(v ?? '')}
language={editorLanguage}
theme={theme === 'dim' ? 'vs-dark' : 'vs'}
options={{
minimap: { enabled: false },
fontSize: 12,
wordWrap: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
}}
onMount={(editor, monaco) => {
editorRef.current = editor as unknown;
const editorLike = editor as unknown as { addCommand: (keybinding: number, handler: () => void) => void };
const monacoLike = monaco as unknown as { KeyMod: { CtrlCmd: number }; KeyCode: { KeyS: number } };
editorLike.addCommand(monacoLike.KeyMod.CtrlCmd | monacoLike.KeyCode.KeyS, () => {
void saveFile();
});
}}
/>
</div>
)
) : (
<div className="flex flex-col items-center justify-center h-full text-center">
<FileCode className="w-8 h-8 text-muted mb-2" />
<p className="text-xs text-muted">Выберите файл</p>
</div>
)}
</div>
) : (
<div className="flex-1 flex flex-col">
<div
ref={terminalRef}
className={`flex-1 overflow-auto p-3 font-mono text-xs whitespace-pre-wrap ${
theme === 'dim'
? 'text-emerald-300 bg-black/30'
: 'text-emerald-700 bg-elevated/50 border border-border/30'
}`}
>
{terminalOutput || '$ Введите команду...\n'}
</div>
<div className="flex items-center gap-2 px-3 py-2 border-t border-border/30 bg-surface/20">
<span className={`text-xs font-mono ${theme === 'dim' ? 'text-emerald-300' : 'text-emerald-700'}`}>$</span>
<input
value={terminalCmd}
onChange={(e) => setTerminalCmd(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
runCommand();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (cmdHistory.length === 0) return;
const nextIndex = historyIndex < 0 ? cmdHistory.length - 1 : Math.max(0, historyIndex - 1);
setHistoryIndex(nextIndex);
setTerminalCmd(cmdHistory[nextIndex] || '');
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (cmdHistory.length === 0) return;
if (historyIndex < 0) return;
const nextIndex = Math.min(cmdHistory.length - 1, historyIndex + 1);
setHistoryIndex(nextIndex);
setTerminalCmd(cmdHistory[nextIndex] || '');
}
}}
placeholder="Введите команду..."
className="flex-1 bg-transparent text-xs font-mono text-primary focus:outline-none placeholder:text-muted"
disabled={isRunningCmd}
/>
{isRunningCmd && <Loader2 className="w-3 h-3 animate-spin text-muted" />}
<button
onClick={() => setTerminalOutput('')}
className="p-1 rounded-md text-muted hover:text-primary hover:bg-surface/60 transition-all"
title="Очистить"
disabled={isRunningCmd}
>
<XCircle className="w-3 h-3" />
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
function FileTreeNode({
node,
expanded,
loading,
selected,
onToggle,
onSelect,
depth,
}: {
node: FileNode;
expanded: Set<string>;
loading: Set<string>;
selected: string | null;
onToggle: (node: FileNode) => void | Promise<void>;
onSelect: (path: string) => void;
depth: number;
}) {
const isDir = node.type === 'dir';
const isExpanded = expanded.has(node.path);
const isSelected = selected === node.path;
const isLoading = loading.has(node.path);
return (
<>
<button
onClick={() => {
if (isDir) {
void onToggle(node);
} else {
onSelect(node.path);
}
}}
className={`w-full flex items-center gap-1 px-2 py-0.5 text-left hover:bg-surface/60 transition-colors ${
isSelected ? 'bg-accent/10 text-accent' : 'text-secondary'
}`}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
{isDir ? (
<>
{isExpanded ? (
<ChevronDown className="w-2.5 h-2.5 flex-shrink-0" />
) : (
<ChevronRight className="w-2.5 h-2.5 flex-shrink-0" />
)}
<Folder className="w-3 h-3 text-amber-400 flex-shrink-0" />
</>
) : (
<>
<span className="w-2.5" />
<File className="w-3 h-3 text-muted flex-shrink-0" />
</>
)}
<span className="truncate text-[11px]">{node.name}</span>
{isDir && isExpanded && isLoading && <Loader2 className="w-3 h-3 ml-auto animate-spin text-muted" />}
</button>
{isDir && isExpanded && node.children?.map((child) => (
<FileTreeNode
key={child.path || child.name}
node={child}
expanded={expanded}
loading={loading}
selected={selected}
onToggle={onToggle}
onSelect={onSelect}
depth={depth + 1}
/>
))}
</>
);
}
function detectLanguage(path: string | null): string {
if (!path) return 'plaintext';
const lower = path.toLowerCase();
if (lower.endsWith('.go')) return 'go';
if (lower.endsWith('.ts')) return 'typescript';
if (lower.endsWith('.tsx')) return 'typescript';
if (lower.endsWith('.js')) return 'javascript';
if (lower.endsWith('.jsx')) return 'javascript';
if (lower.endsWith('.py')) return 'python';
if (lower.endsWith('.sql')) return 'sql';
if (lower.endsWith('.json')) return 'json';
if (lower.endsWith('.yaml') || lower.endsWith('.yml')) return 'yaml';
if (lower.endsWith('.md')) return 'markdown';
if (lower.endsWith('.sh')) return 'shell';
return 'plaintext';
}

View File

@@ -23,6 +23,7 @@ import {
} from 'lucide-react';
import { filterMenuItems } from '@/lib/config/menu';
import { useAuth } from '@/lib/contexts/AuthContext';
import { ThemeToggle } from '@/components/ThemeToggle';
interface SidebarProps {
onClose?: () => void;
@@ -65,12 +66,12 @@ export function Sidebar({ onClose }: SidebarProps) {
return (
<motion.aside
initial={false}
animate={{ width: isMobile ? 240 : collapsed ? 56 : 200 }}
animate={{ width: isMobile ? 260 : collapsed ? 60 : 220 }}
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
className="h-full flex flex-col bg-base border-r border-border/50"
className="relative z-30 h-full flex flex-col bg-base border-r border-border/50"
>
{/* Header */}
<div className="h-12 px-3 flex items-center justify-between">
<div className="h-14 px-3 flex items-center justify-between">
<AnimatePresence mode="wait">
{(isMobile || !collapsed) && (
<motion.div
@@ -79,7 +80,7 @@ export function Sidebar({ onClose }: SidebarProps) {
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<span className="font-black italic text-primary tracking-tight text-xl">GooSeek</span>
<span className="font-black italic text-primary tracking-tight text-2xl">GooSeek</span>
</motion.div>
)}
</AnimatePresence>
@@ -106,7 +107,7 @@ export function Sidebar({ onClose }: SidebarProps) {
</div>
{/* Navigation */}
<nav className="flex-1 px-2 py-2 space-y-0.5 overflow-y-auto">
<nav className="flex-1 px-2 py-3 space-y-1 overflow-y-auto">
{navItems.map((item) => (
<NavLink
key={item.href}
@@ -120,9 +121,9 @@ export function Sidebar({ onClose }: SidebarProps) {
))}
{/* Tools Section */}
<div className="pt-4 pb-1">
<div className="pt-5 pb-1.5">
{(isMobile || !collapsed) && (
<span className="px-2 text-[10px] font-semibold text-muted uppercase tracking-wider">
<span className="px-3 text-xs font-semibold text-muted uppercase tracking-wider">
Инструменты
</span>
)}
@@ -144,6 +145,17 @@ export function Sidebar({ onClose }: SidebarProps) {
{/* Footer - Profile Block */}
<div className="p-2 border-t border-border/30">
{/* Theme toggle */}
<div className={`${isAdmin ? '' : ''} mb-2`}>
{collapsed && !isMobile ? (
<div className="flex justify-center">
<ThemeToggle variant="icon" />
</div>
) : (
<ThemeToggle variant="full" />
)}
</div>
{isAdmin && (
<NavLink
href="/admin"
@@ -157,21 +169,21 @@ export function Sidebar({ onClose }: SidebarProps) {
{/* Guest - Login button */}
{!isAuthenticated && (
<div className={`${isAdmin ? 'mt-1' : ''}`}>
<div className={`${isAdmin ? 'mt-1.5' : ''}`}>
{collapsed && !isMobile ? (
<button
onClick={() => showAuthModal('login')}
className="btn-gradient w-full h-9 flex items-center justify-center"
className="btn-gradient w-full h-10 flex items-center justify-center rounded-lg"
>
<LogIn className="w-4 h-4 btn-gradient-text" />
<LogIn className="w-[18px] h-[18px] btn-gradient-text" />
</button>
) : (
<button
onClick={() => { showAuthModal('login'); handleNavClick(); }}
className="btn-gradient w-full h-9 flex items-center justify-center gap-2"
className="btn-gradient w-full h-10 flex items-center justify-center gap-2.5 rounded-lg"
>
<LogIn className="w-3.5 h-3.5 btn-gradient-text" />
<span className="btn-gradient-text text-xs font-medium">Войти</span>
<LogIn className="w-[18px] h-[18px] btn-gradient-text" />
<span className="btn-gradient-text text-sm font-medium">Войти</span>
</button>
)}
</div>
@@ -197,7 +209,7 @@ export function Sidebar({ onClose }: SidebarProps) {
</div>
<div className="flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-surface/50">
<Wallet className="w-2.5 h-2.5 text-accent" />
<span className="text-[10px] font-medium text-primary">{user.balance ?? 0}</span>
<span className="text-2xs font-medium text-primary">{user.balance ?? 0}</span>
</div>
</Link>
) : (
@@ -216,9 +228,9 @@ export function Sidebar({ onClose }: SidebarProps) {
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
<span className="text-xs font-medium text-primary truncate">{user.name}</span>
<span className={`text-[9px] font-medium px-1 py-0.5 rounded ${
<div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-primary truncate">{user.name}</span>
<span className={`text-2xs font-medium px-1.5 py-0.5 rounded ${
user.tier === 'business'
? 'bg-amber-500/20 text-amber-600'
: user.tier === 'pro'
@@ -229,8 +241,8 @@ export function Sidebar({ onClose }: SidebarProps) {
</span>
</div>
<div className="flex items-center gap-1 mt-0.5">
<Wallet className="w-2.5 h-2.5 text-accent" />
<span className="text-[10px] font-medium text-secondary">{(user.balance ?? 0).toLocaleString('ru-RU')} </span>
<Wallet className="w-3 h-3 text-accent" />
<span className="text-xs font-medium text-secondary">{(user.balance ?? 0).toLocaleString('ru-RU')} </span>
</div>
</div>
<ChevronRightIcon className="w-3.5 h-3.5 text-muted group-hover:text-secondary transition-colors" />
@@ -258,17 +270,17 @@ function NavLink({ href, icon: Icon, label, collapsed, active, onClick }: NavLin
href={href}
onClick={onClick}
className={`
flex items-center gap-2 h-9 rounded-lg transition-all duration-150
${collapsed ? 'justify-center px-0' : 'px-2'}
flex items-center gap-3 h-10 rounded-lg transition-all duration-150
${collapsed ? 'justify-center px-0' : 'px-3'}
${active
? 'active-gradient text-primary border-l-gradient ml-0'
: 'text-secondary hover:text-primary hover:bg-surface/50'
}
`}
>
<Icon className={`w-4 h-4 flex-shrink-0 ${active ? 'text-gradient' : ''}`} />
<Icon className={`w-[18px] h-[18px] flex-shrink-0 ${active ? 'text-gradient' : ''}`} />
{!collapsed && (
<span className={`text-xs font-medium truncate ${active ? 'text-gradient' : ''}`}>{label}</span>
<span className={`text-sm font-medium truncate ${active ? 'text-gradient' : ''}`}>{label}</span>
)}
</Link>
);

View File

@@ -0,0 +1,37 @@
'use client';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from '@/lib/contexts/ThemeContext';
export function ThemeToggle({
variant = 'icon',
}: {
variant?: 'icon' | 'full';
}) {
const { theme, toggleTheme } = useTheme();
const isDim = theme === 'dim';
const label = isDim ? 'Тема: GitHub Dimmed' : 'Тема: GitHub Light';
if (variant === 'full') {
return (
<button onClick={toggleTheme} className="btn-secondary w-full h-10 flex items-center justify-center gap-2" aria-label={label}>
{isDim ? <Moon className="w-4 h-4" /> : <Sun className="w-4 h-4" />}
<span className="text-sm font-medium">{isDim ? 'Dimmed' : 'Light'}</span>
</button>
);
}
return (
<button
onClick={toggleTheme}
className="btn-icon"
aria-label={label}
title={label}
type="button"
>
{isDim ? <Moon className="w-[18px] h-[18px]" /> : <Sun className="w-[18px] h-[18px]" />}
</button>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
MapPin,
@@ -24,8 +24,11 @@ import {
CalendarDays,
Flag,
User,
Clock,
Layers,
} from 'lucide-react';
import type { RoutePoint, RouteDirection, GeoLocation } from '@/lib/types';
import { getRoute } from '@/lib/api';
interface MapglAPI {
Map: new (container: HTMLElement | string, options: Record<string, unknown>) => MapglMapInstance;
@@ -116,6 +119,30 @@ const pointTypeColors: Record<string, string> = {
origin: '#10B981',
};
const pointTypeLabels: Record<string, string> = {
airport: 'Аэропорт',
hotel: 'Отель',
restaurant: 'Ресторан',
attraction: 'Достопримечательность',
transport: 'Транспорт',
custom: 'Точка',
museum: 'Музей',
park: 'Парк',
theater: 'Театр',
shopping: 'Шопинг',
entertainment: 'Развлечения',
religious: 'Храм',
viewpoint: 'Смотровая',
event: 'Событие',
destination: 'Пункт назначения',
poi: 'Место',
food: 'Еда',
transfer: 'Пересадка',
origin: 'Старт',
};
const ITINERARY_TYPES = new Set(['destination', 'origin', 'transfer', 'airport']);
let mapglPromise: Promise<MapglAPI> | null = null;
function loadMapGL(): Promise<MapglAPI> {
@@ -128,20 +155,46 @@ function loadMapGL(): Promise<MapglAPI> {
return mapglPromise;
}
function createMarkerSVG(index: number, color: string): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">` +
`<circle cx="14" cy="14" r="13" fill="${color}" stroke="white" stroke-width="2"/>` +
`<text x="14" y="18" text-anchor="middle" fill="white" font-size="12" font-weight="bold">${index}</text>` +
function createNumberedPinMarkerSVG(index: number, color: string): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="26" height="34" viewBox="0 0 26 34">` +
`<defs>` +
`<filter id="is${index}" x="-15%" y="-8%" width="130%" height="130%">` +
`<feDropShadow dx="0" dy="1" stdDeviation="1" flood-color="#000" flood-opacity="0.2"/>` +
`</filter>` +
`</defs>` +
`<path d="M13 32 C13 32 24 20 24 12 C24 6.5 19.1 2 13 2 C6.9 2 2 6.5 2 12 C2 20 13 32 13 32Z" fill="${color}" filter="url(#is${index})" stroke="white" stroke-width="1.5"/>` +
`<circle cx="13" cy="12" r="7" fill="white" fill-opacity="0.95"/>` +
`<text x="13" y="15.5" text-anchor="middle" fill="${color}" font-size="10" font-weight="700" font-family="Inter,system-ui,sans-serif">${index}</text>` +
`</svg>`;
}
function createUserLocationSVG(): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">` +
`<circle cx="16" cy="16" r="14" fill="#3B82F6" fill-opacity="0.2" stroke="#3B82F6" stroke-width="2"/>` +
`<circle cx="16" cy="16" r="6" fill="#3B82F6" stroke="white" stroke-width="2"/>` +
return `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">` +
`<circle cx="14" cy="14" r="12" fill="#3B82F6" fill-opacity="0.15" stroke="#3B82F6" stroke-width="1.5"/>` +
`<circle cx="14" cy="14" r="5" fill="#3B82F6" stroke="white" stroke-width="1.5"/>` +
`</svg>`;
}
function getCategoryAbbr(type: string): string {
const map: Record<string, string> = {
hotel: 'H',
restaurant: 'R',
food: 'R',
attraction: 'A',
museum: 'M',
park: 'P',
theater: 'T',
shopping: 'S',
entertainment: 'E',
religious: 'C',
viewpoint: 'V',
event: 'Ev',
poi: 'P',
custom: '?',
};
return map[type] || type.slice(0, 1).toUpperCase();
}
export function TravelMap({
route,
routeDirection,
@@ -158,12 +211,16 @@ export function TravelMap({
const mapglRef = useRef<MapglAPI | null>(null);
const markersRef = useRef<MapglMarkerInstance[]>([]);
const userMarkerRef = useRef<MapglMarkerInstance | null>(null);
const polylineRef = useRef<MapglPolylineInstance | null>(null);
const polylinesRef = useRef<MapglPolylineInstance[]>([]);
const onMapClickRef = useRef(onMapClick);
const onPointClickRef = useRef(onPointClick);
const [selectedPoint, setSelectedPoint] = useState<RoutePoint | null>(null);
const [isMapReady, setIsMapReady] = useState(false);
const [detectedLocation, setDetectedLocation] = useState<GeoLocation | null>(null);
const [showLegend, setShowLegend] = useState(false);
const [routeType, setRouteType] = useState<'road' | 'straight' | 'none'>('none');
const [fallbackDirection, setFallbackDirection] = useState<RouteDirection | null>(null);
const fallbackRequestRef = useRef<string>('');
const initDoneRef = useRef(false);
onMapClickRef.current = onMapClick;
@@ -171,6 +228,24 @@ export function TravelMap({
const effectiveUserLocation = userLocation ?? detectedLocation;
const activeTypes = useMemo(() => {
const types = new Set<string>();
route.forEach((p) => types.add(p.type));
return types;
}, [route]);
const pointNumberById = useMemo(() => {
const map = new Map<string, number>();
let idx = 0;
route.forEach((p) => {
if (!p.id) return;
if (!p.lat || !p.lng || p.lat === 0 || p.lng === 0) return;
idx += 1;
map.set(p.id, idx);
});
return map;
}, [route]);
useEffect(() => {
if (!mapRef.current || initDoneRef.current) return;
initDoneRef.current = true;
@@ -190,6 +265,9 @@ export function TravelMap({
zoom,
key: TWOGIS_API_KEY,
lang: 'ru',
// Hide built-in MapGL controls (we render our own UI controls).
zoomControl: false,
geolocationControl: false,
});
map.on('click', (e: MapglClickEvent) => {
@@ -214,10 +292,8 @@ export function TravelMap({
userMarkerRef.current.destroy();
userMarkerRef.current = null;
}
if (polylineRef.current) {
polylineRef.current.destroy();
polylineRef.current = null;
}
polylinesRef.current.forEach((p) => p.destroy());
polylinesRef.current = [];
if (mapInstanceRef.current) {
mapInstanceRef.current.destroy();
mapInstanceRef.current = null;
@@ -244,9 +320,7 @@ export function TravelMap({
mapInstanceRef.current.setZoom(12);
}
},
() => {
// geolocation denied or unavailable
},
() => {},
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 300000 },
);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -264,6 +338,41 @@ export function TravelMap({
return () => observer.disconnect();
}, []);
const effectiveRouteDirection = routeDirection || fallbackDirection;
useEffect(() => {
if (routeDirection) {
setFallbackDirection(null);
return;
}
const validPoints = route.filter((p) => p.lat !== 0 && p.lng !== 0 && p.lat && p.lng);
if (validPoints.length < 2) {
setFallbackDirection(null);
return;
}
const key = validPoints.map((p) => `${p.lat.toFixed(5)},${p.lng.toFixed(5)}`).join('|');
if (fallbackRequestRef.current === key) return;
fallbackRequestRef.current = key;
const geoPoints: GeoLocation[] = validPoints.map((p) => ({
lat: p.lat,
lng: p.lng,
name: p.name,
}));
getRoute(geoPoints).then((rd) => {
if (fallbackRequestRef.current === key) {
setFallbackDirection(rd);
}
}).catch(() => {
if (fallbackRequestRef.current === key) {
setFallbackDirection(null);
}
});
}, [route, routeDirection]);
useEffect(() => {
if (!isMapReady || !mapInstanceRef.current || !mapglRef.current) return;
@@ -273,10 +382,8 @@ export function TravelMap({
markersRef.current.forEach((m) => m.destroy());
markersRef.current = [];
if (polylineRef.current) {
polylineRef.current.destroy();
polylineRef.current = null;
}
polylinesRef.current.forEach((p) => p.destroy());
polylinesRef.current = [];
if (userMarkerRef.current) {
userMarkerRef.current.destroy();
@@ -289,11 +396,11 @@ export function TravelMap({
coordinates: [effectiveUserLocation.lng, effectiveUserLocation.lat],
label: {
text: '',
offset: [0, -48],
offset: [0, -40],
image: {
url: `data:image/svg+xml,${encodeURIComponent(createUserLocationSVG())}`,
size: [32, 32],
anchor: [16, 16],
size: [28, 28],
anchor: [14, 14],
},
},
});
@@ -311,21 +418,27 @@ export function TravelMap({
return;
}
route.forEach((point, index) => {
let pointIndex = 0;
route.forEach((point) => {
if (!point.lat || !point.lng || point.lat === 0 || point.lng === 0) return;
pointIndex++;
const color = pointTypeColors[point.type] || pointTypeColors.custom || '#EC4899';
const isItinerary = ITINERARY_TYPES.has(point.type);
const svgUrl = `data:image/svg+xml,${encodeURIComponent(createNumberedPinMarkerSVG(pointIndex, color))}`;
const markerSize: [number, number] = [26, 34];
const markerAnchor: [number, number] = [13, 32];
try {
const marker = new mapgl.Marker(map, {
coordinates: [point.lng, point.lat],
label: {
text: String(index + 1),
offset: [0, -48],
text: '',
offset: [0, isItinerary ? -48 : -48],
image: {
url: `data:image/svg+xml,${encodeURIComponent(createMarkerSVG(index + 1, color))}`,
size: [28, 28],
anchor: [14, 14],
url: svgUrl,
size: markerSize,
anchor: markerAnchor,
},
},
});
@@ -341,35 +454,61 @@ export function TravelMap({
}
});
const rdCoords = routeDirection?.geometry?.coordinates;
const rdCoords = effectiveRouteDirection?.geometry?.coordinates;
if (rdCoords && Array.isArray(rdCoords) && rdCoords.length > 1) {
setRouteType('road');
const coords = rdCoords.map(
(c: number[]) => [c[0], c[1]] as [number, number]
);
try {
polylineRef.current = new mapgl.Polyline(map, {
const outlinePoly = new mapgl.Polyline(map, {
coordinates: coords,
color: '#6366F1',
color: '#ffffff',
width: 7,
});
polylinesRef.current.push(outlinePoly);
} catch {
// outline polyline failed
}
try {
const mainPoly = new mapgl.Polyline(map, {
coordinates: coords,
color: '#4F5BD5',
width: 4,
});
} catch (err) {
console.error('[TravelMap] road polyline failed:', err, 'coords sample:', coords.slice(0, 3));
polylinesRef.current.push(mainPoly);
} catch {
// main polyline failed
}
} else if (route.length > 1) {
setRouteType('straight');
const coords = route
.filter((p) => p.lat !== 0 && p.lng !== 0)
.map((p) => [p.lng, p.lat] as [number, number]);
if (coords.length > 1) {
try {
polylineRef.current = new mapgl.Polyline(map, {
const outlinePoly = new mapgl.Polyline(map, {
coordinates: coords,
color: '#6366F1',
color: '#ffffff',
width: 6,
});
polylinesRef.current.push(outlinePoly);
} catch {
// outline polyline failed
}
try {
const mainPoly = new mapgl.Polyline(map, {
coordinates: coords,
color: '#94A3B8',
width: 3,
});
} catch (err) {
console.error('[TravelMap] fallback polyline failed:', err);
polylinesRef.current.push(mainPoly);
} catch {
// main polyline failed
}
}
} else {
setRouteType('none');
}
const allPoints: { lat: number; lng: number }[] = route
@@ -398,13 +537,13 @@ export function TravelMap({
} else {
map.fitBounds(
{
southWest: [minLng - lngSpan * 0.1, minLat - latSpan * 0.1],
northEast: [maxLng + lngSpan * 0.1, maxLat + latSpan * 0.1],
southWest: [minLng - lngSpan * 0.12, minLat - latSpan * 0.12],
northEast: [maxLng + lngSpan * 0.12, maxLat + latSpan * 0.12],
},
);
}
}
}, [isMapReady, route, routeDirection, effectiveUserLocation]);
}, [isMapReady, route, effectiveRouteDirection, effectiveUserLocation]);
const handleZoomIn = useCallback(() => {
const map = mapInstanceRef.current;
@@ -469,8 +608,8 @@ export function TravelMap({
mapInstanceRef.current.setZoom(14);
} else {
mapInstanceRef.current.fitBounds({
southWest: [minLng - lngSpan * 0.1, minLat - latSpan * 0.1],
northEast: [maxLng + lngSpan * 0.1, maxLat + latSpan * 0.1],
southWest: [minLng - lngSpan * 0.12, minLat - latSpan * 0.12],
northEast: [maxLng + lngSpan * 0.12, maxLat + latSpan * 0.12],
});
}
}, [route, effectiveUserLocation]);
@@ -479,90 +618,187 @@ export function TravelMap({
? pointTypeIcons[selectedPoint.type] || MapPin
: MapPin;
const selectedPointIndex = useMemo(() => {
if (!selectedPoint) return 0;
if (!selectedPoint.id) return 0;
return pointNumberById.get(selectedPoint.id) || 0;
}, [selectedPoint, pointNumberById]);
return (
<div className={`relative rounded-xl overflow-hidden ${className}`}>
<div ref={mapRef} className="w-full h-full min-h-[300px]" />
{showControls && (
<div className="absolute top-4 right-4 flex flex-col gap-2 z-[1000]">
<div className="absolute top-3 right-3 flex flex-col gap-1.5 z-30">
<button
onClick={handleZoomIn}
className="w-10 h-10 bg-elevated/90 backdrop-blur-sm border border-border/50 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
className="w-8 h-8 bg-elevated/90 backdrop-blur-sm border border-border/40 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
title="Увеличить"
>
<ZoomIn className="w-5 h-5" />
<ZoomIn className="w-4 h-4" />
</button>
<button
onClick={handleZoomOut}
className="w-10 h-10 bg-elevated/90 backdrop-blur-sm border border-border/50 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
className="w-8 h-8 bg-elevated/90 backdrop-blur-sm border border-border/40 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
title="Уменьшить"
>
<ZoomOut className="w-5 h-5" />
<ZoomOut className="w-4 h-4" />
</button>
<button
onClick={handleLocate}
className="w-10 h-10 bg-elevated/90 backdrop-blur-sm border border-border/50 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
className="w-8 h-8 bg-elevated/90 backdrop-blur-sm border border-border/40 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
title="Моё местоположение"
>
<Locate className="w-5 h-5" />
<Locate className="w-4 h-4" />
</button>
{route.length > 1 && (
<button
onClick={handleFitRoute}
className="w-10 h-10 bg-elevated/90 backdrop-blur-sm border border-border/50 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
className="w-8 h-8 bg-elevated/90 backdrop-blur-sm border border-border/40 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
title="Показать весь маршрут"
>
<Navigation className="w-5 h-5" />
<Navigation className="w-4 h-4" />
</button>
)}
{activeTypes.size > 1 && (
<button
onClick={() => setShowLegend((v) => !v)}
className={`w-8 h-8 backdrop-blur-sm border rounded-lg flex items-center justify-center transition-all ${
showLegend
? 'bg-accent/20 border-accent/40 text-accent'
: 'bg-elevated/90 border-border/40 text-secondary hover:text-primary hover:bg-surface'
}`}
title="Легенда"
>
<Layers className="w-4 h-4" />
</button>
)}
</div>
)}
{route.length > 0 && (
<div className="absolute top-3 left-3 z-30 flex flex-col gap-1">
<div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-elevated/90 backdrop-blur-sm border border-border/40 rounded-lg">
<MapPin className="w-3 h-3 text-accent" />
<span className="text-[11px] font-medium text-primary">{route.length}</span>
<span className="text-[10px] text-muted">
{route.length === 1 ? 'точка' : route.length < 5 ? 'точки' : 'точек'}
</span>
</div>
{routeType === 'road' && (
<div className="flex items-center gap-1 px-2 py-1 bg-green-500/10 backdrop-blur-sm border border-green-500/30 rounded-lg">
<Navigation className="w-2.5 h-2.5 text-green-500" />
<span className="text-[9px] text-green-500 font-medium">По дорогам</span>
</div>
)}
{routeType === 'straight' && (
<div className="flex items-center gap-1 px-2 py-1 bg-yellow-500/10 backdrop-blur-sm border border-yellow-500/30 rounded-lg">
<Navigation className="w-2.5 h-2.5 text-yellow-500" />
<span className="text-[9px] text-yellow-500 font-medium">Прямые линии</span>
</div>
)}
</div>
)}
{/* Legend */}
<AnimatePresence>
{showLegend && activeTypes.size > 0 && (
<motion.div
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
className="absolute top-14 right-3 z-30 bg-elevated/95 backdrop-blur-sm border border-border/40 rounded-lg p-2 min-w-[120px]"
>
<div className="space-y-1">
{Array.from(activeTypes).map((type) => (
<div key={type} className="flex items-center gap-2 px-1 py-0.5">
<div
className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
style={{ backgroundColor: pointTypeColors[type] || '#94A3B8' }}
/>
<span className="text-[10px] text-secondary leading-none">
{pointTypeLabels[type] || type}
</span>
<span className="text-[9px] text-muted ml-auto">
{route.filter((p) => p.type === type).length}
</span>
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Selected point popup */}
<AnimatePresence>
{selectedPoint && (
<motion.div
initial={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className="absolute bottom-4 left-4 right-4 bg-elevated/95 backdrop-blur-sm border border-border/50 rounded-xl p-4 z-[1000]"
exit={{ opacity: 0, y: 16 }}
className="absolute bottom-3 left-3 right-3 bg-elevated/95 backdrop-blur-sm border border-border/40 rounded-xl overflow-hidden z-30"
>
<button
onClick={() => setSelectedPoint(null)}
className="absolute top-3 right-3 p-1 rounded-lg hover:bg-surface/50 text-muted hover:text-primary transition-colors"
>
<X className="w-4 h-4" />
</button>
<div className="flex items-start gap-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center"
style={{ backgroundColor: pointTypeColors[selectedPoint.type] + '20' }}
<div className="p-3">
<button
onClick={() => setSelectedPoint(null)}
className="absolute top-2.5 right-2.5 p-1 rounded-md hover:bg-surface/50 text-muted hover:text-primary transition-colors"
>
<PointIcon
className="w-5 h-5"
style={{ color: pointTypeColors[selectedPoint.type] }}
/>
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-primary truncate">
{selectedPoint.name}
</h4>
{selectedPoint.address && (
<p className="text-xs text-muted mt-0.5 truncate">
{selectedPoint.address}
</p>
)}
{selectedPoint.aiComment && (
<div className="flex items-start gap-2 mt-2 p-2 bg-surface/50 rounded-lg">
<Sparkles className="w-4 h-4 text-accent flex-shrink-0 mt-0.5" />
<p className="text-xs text-secondary">{selectedPoint.aiComment}</p>
<X className="w-3.5 h-3.5" />
</button>
<div className="flex items-start gap-2.5 pr-6">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ backgroundColor: pointTypeColors[selectedPoint.type] + '18' }}
>
<PointIcon
className="w-4 h-4"
style={{ color: pointTypeColors[selectedPoint.type] }}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{selectedPointIndex > 0 && (
<span
className="w-4.5 h-4.5 rounded text-[9px] font-bold flex items-center justify-center text-white px-1"
style={{ backgroundColor: pointTypeColors[selectedPoint.type] }}
>
{selectedPointIndex}
</span>
)}
<h4 className="text-[13px] font-medium text-primary truncate">
{selectedPoint.name}
</h4>
</div>
)}
{selectedPoint.duration && (
<p className="text-xs text-muted mt-2">
Рекомендуемое время: {selectedPoint.duration} мин
</p>
)}
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
<span
className="text-[9px] font-medium px-1.5 py-px rounded-full"
style={{
backgroundColor: pointTypeColors[selectedPoint.type] + '15',
color: pointTypeColors[selectedPoint.type],
}}
>
{pointTypeLabels[selectedPoint.type] || selectedPoint.type}
</span>
{selectedPoint.address && (
<span className="text-[10px] text-muted truncate max-w-[180px]">
{selectedPoint.address}
</span>
)}
</div>
{selectedPoint.aiComment && (
<div className="flex items-start gap-1.5 mt-2 p-2 bg-accent/5 border border-accent/10 rounded-lg">
<Sparkles className="w-3 h-3 text-accent flex-shrink-0 mt-0.5" />
<p className="text-[10px] text-secondary leading-relaxed">{selectedPoint.aiComment}</p>
</div>
)}
{selectedPoint.duration && selectedPoint.duration > 0 && (
<div className="flex items-center gap-1 mt-1.5">
<Clock className="w-3 h-3 text-muted" />
<span className="text-[10px] text-muted">~{selectedPoint.duration} мин</span>
</div>
)}
</div>
</div>
</div>
</motion.div>

View File

@@ -0,0 +1,649 @@
'use client';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Calendar,
Ticket,
Hotel,
Plane,
Bus,
DollarSign,
CloudSun,
Camera,
Search,
Filter,
Sparkles,
Check,
Loader2,
AlertCircle,
} from 'lucide-react';
import { TravelWidgetRenderer } from '@/components/TravelWidgets';
import type { TravelWidget } from '@/lib/hooks/useTravelChat';
import type {
EventCard,
POICard,
HotelCard,
TransportOption,
ItineraryDay,
} from '@/lib/types';
import type { LLMValidationResponse } from '@/lib/hooks/useEditableItinerary';
type TabId = 'plan' | 'places' | 'events' | 'hotels' | 'tickets' | 'transport' | 'budget' | 'context';
interface TabDef {
id: TabId;
label: string;
icon: typeof Calendar;
widgetTypes: string[];
emptyLabel: string;
}
const TABS: TabDef[] = [
{ id: 'plan', label: 'План', icon: Calendar, widgetTypes: ['travel_itinerary'], emptyLabel: 'Маршрут ещё не построен' },
{ id: 'places', label: 'Места', icon: Camera, widgetTypes: ['travel_poi'], emptyLabel: 'Достопримечательности не найдены' },
{ id: 'events', label: 'События', icon: Ticket, widgetTypes: ['travel_events'], emptyLabel: 'Мероприятия не найдены' },
{ id: 'hotels', label: 'Отели', icon: Hotel, widgetTypes: ['travel_hotels'], emptyLabel: 'Отели не найдены' },
{ id: 'tickets', label: 'Билеты', icon: Plane, widgetTypes: ['travel_transport'], emptyLabel: 'Билеты не найдены' },
{ id: 'transport', label: 'Транспорт', icon: Bus, widgetTypes: ['travel_transport'], emptyLabel: 'Транспорт не найден' },
{ id: 'budget', label: 'Бюджет', icon: DollarSign, widgetTypes: ['travel_budget'], emptyLabel: 'Бюджет не рассчитан' },
{ id: 'context', label: 'Инфо', icon: CloudSun, widgetTypes: ['travel_context'], emptyLabel: 'Информация недоступна' },
];
type LoadingPhase = 'idle' | 'planning' | 'collecting' | 'building' | 'routing';
interface TravelWidgetTabsProps {
widgets: TravelWidget[];
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;
availablePois?: POICard[];
availableEvents?: EventCard[];
onItineraryUpdate?: (days: ItineraryDay[]) => void;
onValidateItineraryWithLLM?: (days: ItineraryDay[]) => Promise<LLMValidationResponse | null>;
isLoading?: boolean;
loadingPhase?: LoadingPhase;
isResearching?: boolean;
routePointCount?: number;
hasRouteDirection?: boolean;
}
function normalizeText(value: string): string {
return value.trim().toLowerCase();
}
function includesAny(haystack: string, needles: string[]): boolean {
const h = normalizeText(haystack);
if (!h) return false;
return needles.some((n) => h.includes(n));
}
function getEventSearchText(e: EventCard): string {
return [
e.title,
e.description,
e.address,
e.source,
...(e.tags || []),
]
.filter(Boolean)
.join(' ');
}
function getPOISearchText(p: POICard): string {
return [p.name, p.description, p.address, p.category].filter(Boolean).join(' ');
}
function getHotelSearchText(h: HotelCard): string {
return [h.name, h.address, ...(h.amenities || [])].filter(Boolean).join(' ');
}
function getTransportSearchText(t: TransportOption): string {
return [
t.mode,
t.from,
t.to,
t.provider,
t.airline,
t.flightNum,
]
.filter(Boolean)
.join(' ');
}
function TabToolbar({
tab,
query,
setQuery,
onlySelected,
setOnlySelected,
minRating,
setMinRating,
filtersOpen,
setFiltersOpen,
isLoading,
onAskMore,
showAskMore,
}: {
tab: TabId;
query: string;
setQuery: (value: string) => void;
onlySelected: boolean;
setOnlySelected: (value: boolean) => void;
minRating: number;
setMinRating: (value: number) => void;
filtersOpen: boolean;
setFiltersOpen: (value: boolean) => void;
isLoading?: boolean;
onAskMore?: () => void;
showAskMore?: boolean;
}) {
const placeholder =
tab === 'plan'
? 'Поиск по маршруту...'
: tab === 'places'
? 'Поиск по местам...'
: tab === 'events'
? 'Поиск по событиям...'
: tab === 'hotels'
? 'Поиск по отелям...'
: tab === 'tickets'
? 'Поиск по билетам...'
: tab === 'transport'
? 'Поиск по транспорту...'
: tab === 'budget'
? 'Поиск по бюджету...'
: 'Поиск...';
const showRating = tab === 'places' || tab === 'hotels';
const showSelected = tab === 'places' || tab === 'events' || tab === 'hotels' || tab === 'tickets' || tab === 'transport';
const filterCount = (showSelected && onlySelected ? 1 : 0) + (showRating && minRating > 0 ? 1 : 0);
const shouldShowFilterPanel = filtersOpen || filterCount > 0;
return (
<div className="flex flex-col gap-2 mb-3">
<div className="flex items-center gap-2">
<div className="flex-1 relative">
<Search className="w-3.5 h-3.5 text-muted absolute left-2.5 top-1/2 -translate-y-1/2" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
className="w-full pl-8 pr-3 py-2 text-xs bg-surface/40 border border-border/30 rounded-lg text-primary placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-accent/30"
/>
</div>
{onAskMore && showAskMore && (
<button
onClick={onAskMore}
disabled={isLoading}
className={`w-9 h-9 flex items-center justify-center rounded-lg border transition-colors ${
isLoading
? 'bg-surface/30 text-muted border-border/20'
: 'bg-accent/15 text-accent border-accent/25 hover:bg-accent/20'
}`}
title="Попросить AI найти ещё варианты"
aria-label="Найти ещё"
>
<Sparkles className="w-4 h-4" />
</button>
)}
<button
onClick={() => setFiltersOpen(!filtersOpen)}
className={`w-9 h-9 flex items-center justify-center rounded-lg border transition-colors ${
shouldShowFilterPanel
? 'bg-surface/50 text-secondary border-border/40'
: 'bg-surface/30 text-muted border-border/25 hover:text-secondary'
}`}
title="Фильтры"
aria-label="Фильтры"
>
<div className="relative">
<Filter className="w-4 h-4" />
{filterCount > 0 && (
<span className="absolute -top-1.5 -right-1.5 min-w-[14px] h-[14px] px-1 rounded-full bg-accent text-3xs leading-[14px] text-black text-center">
{filterCount}
</span>
)}
</div>
</button>
</div>
{shouldShowFilterPanel && (
<div className="flex items-center gap-2 flex-wrap">
{showSelected && (
<button
onClick={() => setOnlySelected(!onlySelected)}
className={`flex items-center gap-1.5 px-2.5 py-1 text-ui-sm rounded-lg border transition-colors ${
onlySelected
? 'bg-accent/20 text-accent border-accent/30'
: 'bg-surface/30 text-secondary border-border/30 hover:text-primary'
}`}
title="Показывать только выбранные"
>
{onlySelected && <Check className="w-3.5 h-3.5" />}
Выбранные
</button>
)}
{showRating && (
<div className="flex items-center gap-2">
<span className="text-ui-sm text-muted">Рейтинг</span>
<select
value={minRating}
onChange={(e) => setMinRating(Number(e.target.value))}
className="px-2 py-1 text-ui-sm bg-surface/30 border border-border/30 rounded-lg text-secondary focus:outline-none"
aria-label="Минимальный рейтинг"
>
<option value={0}>Любой</option>
<option value={4}>4.0+</option>
<option value={4.5}>4.5+</option>
</select>
</div>
)}
{!showSelected && !showRating && (
<span className="text-ui-sm text-muted/80">Нет фильтров для этого этапа</span>
)}
</div>
)}
</div>
);
}
export function TravelWidgetTabs({
widgets,
onAddEventToMap,
onAddPOIToMap,
onSelectHotel,
onSelectTransport,
onClarifyingAnswer,
onAction,
selectedEventIds = new Set(),
selectedPOIIds = new Set(),
selectedHotelId,
selectedTransportId,
availablePois,
availableEvents,
onItineraryUpdate,
onValidateItineraryWithLLM,
isLoading,
loadingPhase = 'idle',
isResearching,
routePointCount = 0,
hasRouteDirection,
}: TravelWidgetTabsProps) {
const [activeTab, setActiveTab] = useState<TabId>('plan');
const [inlineNotice, setInlineNotice] = useState<string | null>(null);
const [queryByTab, setQueryByTab] = useState<Record<TabId, string>>({
plan: '',
places: '',
events: '',
hotels: '',
tickets: '',
transport: '',
budget: '',
context: '',
});
const [onlySelectedByTab, setOnlySelectedByTab] = useState<Record<TabId, boolean>>({
plan: false,
places: false,
events: false,
hotels: false,
tickets: false,
transport: false,
budget: false,
context: false,
});
const [minRatingByTab, setMinRatingByTab] = useState<Record<TabId, number>>({
plan: 0,
places: 0,
events: 0,
hotels: 0,
tickets: 0,
transport: 0,
budget: 0,
context: 0,
});
const [filtersOpenByTab, setFiltersOpenByTab] = useState<Record<TabId, boolean>>({
plan: false,
places: false,
events: false,
hotels: false,
tickets: false,
transport: false,
budget: false,
context: false,
});
const travelWidgets = useMemo(
() => widgets.filter((w) => w.type.startsWith('travel_')),
[widgets],
);
const widgetsByTab = useMemo(() => {
const map = new Map<TabId, TravelWidget[]>();
for (const tab of TABS) {
map.set(tab.id, []);
}
for (const w of travelWidgets) {
for (const tab of TABS) {
if (tab.widgetTypes.includes(w.type)) {
map.get(tab.id)!.push(w);
break;
}
}
}
return map;
}, [travelWidgets]);
const tabCounts = useMemo(() => {
const counts = new Map<TabId, number>();
for (const tab of TABS) {
const tabWidgets = widgetsByTab.get(tab.id) || [];
let count = 0;
for (const w of tabWidgets) {
const p = w.params;
if (p.days) count += (p.days as ItineraryDay[]).length;
else if (p.pois) count += (p.pois as POICard[]).length;
else if (p.events) count += (p.events as EventCard[]).length;
else if (p.hotels) count += (p.hotels as HotelCard[]).length;
else if (p.flights || p.ground) {
const flightsCount = ((p.flights as TransportOption[]) || []).length;
const groundCount = ((p.ground as TransportOption[]) || []).length;
if (tab.id === 'tickets') count += flightsCount;
else if (tab.id === 'transport') count += groundCount;
else count += flightsCount + groundCount;
}
else if (p.breakdown) count = 1;
else if (p.weather || p.safety) count = 1;
}
counts.set(tab.id, count);
}
return counts;
}, [widgetsByTab]);
const activeWidgets = widgetsByTab.get(activeTab) || [];
const activeTabDef = TABS.find((t) => t.id === activeTab)!;
const clarifyingWidgets = useMemo(
() => travelWidgets.filter((w) => w.type === 'travel_clarifying'),
[travelWidgets],
);
const clarifyingKey = useMemo(() => clarifyingWidgets.map((w) => w.id).join('|'), [clarifyingWidgets]);
useEffect(() => {
// If clarifying widget changes, show it again and clear old notice
setInlineNotice(null);
}, [clarifyingKey]);
useEffect(() => {
// Keep the "submitted" notice only until planning really starts.
if (!inlineNotice) return;
if (!isLoading) return;
if (loadingPhase !== 'planning') {
setInlineNotice(null);
}
}, [inlineNotice, isLoading, loadingPhase]);
const handleTabClick = useCallback((tabId: TabId) => {
setActiveTab(tabId);
}, []);
const query = queryByTab[activeTab] || '';
const queryTokens = useMemo(() => normalizeText(query).split(/\s+/).filter(Boolean), [query]);
const onlySelected = onlySelectedByTab[activeTab] || false;
const minRating = minRatingByTab[activeTab] || 0;
const filteredActiveWidgets = useMemo(() => {
const widgetsForTab = activeWidgets;
if (widgetsForTab.length === 0) return [];
const filtered: TravelWidget[] = [];
for (const w of widgetsForTab) {
if (activeTab === 'places' && w.type === 'travel_poi') {
const pois = (w.params.pois || []) as POICard[];
const list = pois
.filter((p) => (minRating > 0 ? (p.rating || 0) >= minRating : true))
.filter((p) => (onlySelected ? selectedPOIIds.has(p.id) : true))
.filter((p) => (queryTokens.length ? includesAny(getPOISearchText(p), queryTokens) : true));
if (list.length > 0) filtered.push({ ...w, params: { ...w.params, pois: list } });
continue;
}
if (activeTab === 'events' && w.type === 'travel_events') {
const events = (w.params.events || []) as EventCard[];
const list = events
.filter((e) => (onlySelected ? selectedEventIds.has(e.id) : true))
.filter((e) => (queryTokens.length ? includesAny(getEventSearchText(e), queryTokens) : true));
if (list.length > 0) filtered.push({ ...w, params: { ...w.params, events: list } });
continue;
}
if (activeTab === 'hotels' && w.type === 'travel_hotels') {
const hotels = (w.params.hotels || []) as HotelCard[];
const list = hotels
.filter((h) => (minRating > 0 ? (h.rating || 0) >= minRating : true))
.filter((h) => (onlySelected ? selectedHotelId === h.id : true))
.filter((h) => (queryTokens.length ? includesAny(getHotelSearchText(h), queryTokens) : true));
if (list.length > 0) filtered.push({ ...w, params: { ...w.params, hotels: list } });
continue;
}
if ((activeTab === 'tickets' || activeTab === 'transport') && w.type === 'travel_transport') {
const flights = (w.params.flights || []) as TransportOption[];
const ground = (w.params.ground || []) as TransportOption[];
const baseList = activeTab === 'tickets' ? flights : ground;
const list = baseList
.filter((t) => (onlySelected ? selectedTransportId === t.id : true))
.filter((t) => (queryTokens.length ? includesAny(getTransportSearchText(t), queryTokens) : true));
if (list.length > 0) {
filtered.push({
...w,
params: {
...w.params,
flights: activeTab === 'tickets' ? list : [],
ground: activeTab === 'transport' ? list : [],
},
});
}
continue;
}
// plan/budget/context: keep as-is (we still show empty state below if nothing meaningful)
filtered.push(w);
}
return filtered;
}, [
activeWidgets,
activeTab,
onlySelected,
minRating,
queryTokens,
selectedPOIIds,
selectedEventIds,
selectedHotelId,
selectedTransportId,
]);
const hasRenderableContent = useMemo(() => {
if (activeTab === 'plan') {
// treat itinerary as present only when it has days
for (const w of activeWidgets) {
if (w.type === 'travel_itinerary' && Array.isArray(w.params.days) && (w.params.days as ItineraryDay[]).length > 0) {
return true;
}
}
return false;
}
if (activeTab === 'budget') {
return filteredActiveWidgets.some((w) => Boolean(w.params.breakdown));
}
if (activeTab === 'context') {
return filteredActiveWidgets.some((w) => Boolean(w.params.weather || w.params.safety || w.params.tips));
}
return filteredActiveWidgets.length > 0;
}, [activeTab, activeWidgets, filteredActiveWidgets]);
const askMore = useCallback(() => {
if (!onAction) return;
const q = queryByTab[activeTab]?.trim();
const encoded = q ? `search:${activeTab}:${q}` : `search:${activeTab}`;
onAction(encoded);
}, [activeTab, onAction, queryByTab]);
return (
<div className="h-full min-h-0 rounded-xl border border-border/50 bg-base overflow-hidden flex flex-col shadow-sm">
{!inlineNotice && clarifyingWidgets.map((w) => (
<div key={w.id} className="p-3 border-b border-border/20">
<TravelWidgetRenderer
widget={w}
onClarifyingAnswer={onClarifyingAnswer}
onInlineNotice={(text) => setInlineNotice(text)}
/>
</div>
))}
<div className="px-3 py-2 border-b border-border/30 bg-surface">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-ui-sm text-muted">
{inlineNotice ? (
<>
<Check className="w-3.5 h-3.5 text-green-600" />
<span className="text-green-600">{inlineNotice}</span>
</>
) : (
<>
{isLoading && <Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />}
<span className="text-secondary">
{isLoading && loadingPhase === 'planning' && 'Анализ запроса'}
{isLoading && loadingPhase === 'collecting' && 'Сбор данных'}
{isLoading && loadingPhase === 'building' && 'План по дням'}
{isLoading && loadingPhase === 'routing' && 'Маршрут по дорогам'}
{!isLoading && isResearching && 'Подбор вариантов'}
{!isLoading && !isResearching && 'Готово к действиям'}
</span>
</>
)}
</div>
<div className="flex items-center gap-2 text-ui-sm text-muted">
<span>
Точек: <span className="text-secondary">{routePointCount}</span>
</span>
<span className="w-1 h-1 rounded-full bg-border/60" />
<span>
Маршрут:{' '}
<span className="text-secondary">
{hasRouteDirection ? 'дороги' : routePointCount >= 2 ? 'линии' : 'нет'}
</span>
</span>
</div>
</div>
</div>
<div className="h-11 shrink-0 flex items-center gap-0.5 px-2 pt-2 pb-0 overflow-x-auto scrollbar-hide bg-base">
{TABS.map((tab) => {
const count = tabCounts.get(tab.id) || 0;
const isActive = activeTab === tab.id;
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => handleTabClick(tab.id)}
className={`h-9 flex items-center gap-1.5 px-3 text-ui-sm font-medium rounded-t-lg transition-all whitespace-nowrap flex-shrink-0 border-b-2 ${
isActive
? 'bg-surface/60 text-primary border-accent'
: 'text-muted hover:text-secondary hover:bg-surface/30 border-transparent'
}`}
>
<Icon className="w-3.5 h-3.5" />
{tab.label}
<span
className={`min-w-[18px] h-[14px] px-1.5 text-3xs rounded-full inline-flex items-center justify-center ${
count > 0 ? '' : 'opacity-0'
} ${isActive ? 'bg-accent/20 text-accent' : 'bg-surface/50 text-muted'}`}
aria-hidden={count === 0}
>
{count > 0 ? count : '0'}
</span>
</button>
);
})}
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
<AnimatePresence mode="wait">
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
className="p-3"
>
<TabToolbar
tab={activeTab}
query={queryByTab[activeTab] || ''}
setQuery={(value) => setQueryByTab((prev) => ({ ...prev, [activeTab]: value }))}
onlySelected={onlySelectedByTab[activeTab] || false}
setOnlySelected={(value) => setOnlySelectedByTab((prev) => ({ ...prev, [activeTab]: value }))}
minRating={minRatingByTab[activeTab] || 0}
setMinRating={(value) => setMinRatingByTab((prev) => ({ ...prev, [activeTab]: value }))}
filtersOpen={filtersOpenByTab[activeTab] || false}
setFiltersOpen={(value) => setFiltersOpenByTab((prev) => ({ ...prev, [activeTab]: value }))}
isLoading={isLoading}
onAskMore={activeTab === 'budget' || activeTab === 'context' ? undefined : askMore}
showAskMore={Boolean((queryByTab[activeTab] || '').trim())}
/>
{isLoading && travelWidgets.length === 0 ? (
<div className="flex items-center justify-center gap-2 py-8 text-muted">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-xs">Собираю данные...</span>
</div>
) : !hasRenderableContent ? (
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted">
<AlertCircle className="w-5 h-5 opacity-40" />
<span className="text-xs">{activeTabDef.emptyLabel}</span>
{(activeTab === 'tickets' || activeTab === 'transport') && (
<span className="text-ui-sm text-muted/80 text-center max-w-[520px]">
Билеты и транспорт появляются после планирования (или нажмите «Найти ещё», чтобы попросить AI собрать варианты).
</span>
)}
{activeTab === 'plan' && (
<span className="text-ui-sm text-muted/80 text-center max-w-[520px]">
План строится в 4 шага: анализ запроса сбор мест/событий/отелей маршрут по дням дороги на карте.
</span>
)}
</div>
) : (
filteredActiveWidgets.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}
availablePois={availablePois}
availableEvents={availableEvents}
onItineraryUpdate={onItineraryUpdate}
onValidateItineraryWithLLM={onValidateItineraryWithLLM}
/>
))
)}
</motion.div>
</AnimatePresence>
</div>
</div>
);
}

View File

@@ -26,6 +26,14 @@ import {
Ticket,
HelpCircle,
CloudSun,
Cloud,
Sun,
CloudRain,
CloudLightning,
Snowflake,
CloudFog,
Wind,
Droplets,
Shield,
AlertTriangle,
Info,
@@ -35,7 +43,9 @@ import {
ShieldAlert,
ShieldCheck,
Phone,
Pencil,
} from 'lucide-react';
import { EditableItinerary } from '@/components/EditableItinerary';
import type {
EventCard,
POICard,
@@ -47,6 +57,8 @@ import type {
MapPoint,
RouteSegment,
WeatherAssessment,
DailyForecast,
WeatherIcon,
SafetyAssessment,
RestrictionItem,
TravelTip,
@@ -103,7 +115,7 @@ function EventCardComponent({ event, onAddToMap, isSelected }: EventCardComponen
{event.tags && event.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{event.tags.slice(0, 3).map((tag) => (
<span key={tag} className="px-2 py-0.5 text-[10px] bg-surface/60 text-muted rounded-full">
<span key={tag} className="px-2 py-0.5 text-2xs bg-surface/60 text-muted rounded-full">
{tag}
</span>
))}
@@ -172,27 +184,20 @@ const categoryIconsMap: Record<string, typeof MapPin> = {
function POICardComponent({ poi, onAddToMap, isSelected }: POICardComponentProps) {
const [imgError, setImgError] = useState(false);
const [expanded, setExpanded] = useState(false);
const Icon = categoryIconsMap[poi.category] || MapPin;
const hasPhoto = poi.photos && poi.photos.length > 0 && !imgError;
const scheduleEntries = useMemo(() => {
if (!poi.schedule) return [];
return Object.entries(poi.schedule);
}, [poi.schedule]);
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
initial={{ opacity: 0, scale: 0.97 }}
animate={{ opacity: 1, scale: 1 }}
className={`flex-shrink-0 w-[260px] rounded-xl border overflow-hidden transition-all ${
className={`flex-shrink-0 w-[180px] rounded-xl border overflow-hidden transition-all ${
isSelected
? 'border-accent/50 bg-accent/10'
: 'border-border/40 bg-elevated/40 hover:border-border'
}`}
>
{/* Photo */}
<div className="relative w-full h-[140px] bg-surface/60 overflow-hidden">
<div className="relative w-full h-[100px] bg-surface/60 overflow-hidden">
{hasPhoto ? (
<img
src={poi.photos![0]}
@@ -203,109 +208,52 @@ function POICardComponent({ poi, onAddToMap, isSelected }: POICardComponentProps
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Icon className="w-10 h-10 text-muted/30" />
<Icon className="w-8 h-8 text-muted/20" />
</div>
)}
{/* Category badge */}
<span className="absolute top-2 left-2 px-2 py-0.5 text-[10px] font-medium bg-black/60 text-white rounded-full backdrop-blur-sm">
<span className="absolute top-1.5 left-1.5 px-1.5 py-px text-3xs font-medium bg-black/60 text-white rounded-full backdrop-blur-sm">
{categoryLabels[poi.category] || poi.category}
</span>
{/* Add to map button */}
{onAddToMap && (
<button
onClick={(e) => { e.stopPropagation(); onAddToMap(poi); }}
className="absolute top-2 right-2 p-1.5 rounded-full bg-black/50 text-white hover:bg-accent/80 transition-colors backdrop-blur-sm"
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/50 text-white hover:bg-accent/80 transition-colors backdrop-blur-sm"
title="На карту"
>
{isSelected ? <Check className="w-3.5 h-3.5" /> : <Plus className="w-3.5 h-3.5" />}
{isSelected ? <Check className="w-3 h-3" /> : <Plus className="w-3 h-3" />}
</button>
)}
{/* Photo count */}
{poi.photos && poi.photos.length > 1 && (
<span className="absolute bottom-2 right-2 px-1.5 py-0.5 text-[10px] bg-black/60 text-white rounded-full backdrop-blur-sm">
{poi.photos.length} фото
</span>
)}
</div>
{/* Content */}
<div className="p-3">
<h4 className="text-sm font-medium text-primary leading-tight line-clamp-2">{poi.name}</h4>
<div className="p-2">
<h4 className="text-ui-sm font-medium text-primary leading-tight line-clamp-2">{poi.name}</h4>
{/* Rating + Reviews */}
<div className="flex items-center gap-2 mt-1.5">
<div className="flex items-center gap-1.5 mt-1">
{(poi.rating ?? 0) > 0 && (
<span className="flex items-center gap-1 text-xs font-medium text-amber-500">
<Star className="w-3 h-3 fill-current" />
<span className="flex items-center gap-0.5 text-2xs font-medium text-amber-500">
<Star className="w-2.5 h-2.5 fill-current" />
{poi.rating!.toFixed(1)}
</span>
)}
{(poi.reviewCount ?? 0) > 0 && (
<span className="text-[10px] text-muted">
{poi.reviewCount} отзывов
</span>
<span className="text-3xs text-muted">({poi.reviewCount})</span>
)}
{(poi.duration ?? 0) > 0 && (
<span className="flex items-center gap-0.5 text-[10px] text-muted ml-auto">
<Clock className="w-2.5 h-2.5" />
{poi.duration} мин
{(poi.price ?? 0) > 0 && (
<span className="text-2xs font-medium text-accent ml-auto">
{poi.price?.toLocaleString('ru-RU')}
</span>
)}
</div>
{/* Description */}
{poi.description && (
<p className="text-xs text-secondary mt-1.5 line-clamp-2">{poi.description}</p>
<p className="text-2xs text-secondary mt-1 line-clamp-2 leading-snug">{poi.description}</p>
)}
{/* Address */}
{poi.address && (
<div className="flex items-start gap-1 mt-1.5">
<MapPin className="w-3 h-3 text-muted flex-shrink-0 mt-0.5" />
<span className="text-[10px] text-muted line-clamp-1">{poi.address}</span>
</div>
)}
{/* Price */}
{(poi.price ?? 0) > 0 && (
<div className="mt-1.5">
<span className="text-xs font-medium text-accent">
{poi.price?.toLocaleString('ru-RU')} {poi.currency || '₽'}
</span>
</div>
)}
{/* Expandable schedule */}
{scheduleEntries.length > 0 && (
<div className="mt-2">
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 text-[10px] text-accent hover:text-accent/80 transition-colors"
>
<Clock className="w-2.5 h-2.5" />
{expanded ? 'Скрыть расписание' : 'Расписание'}
{expanded ? <ChevronUp className="w-2.5 h-2.5" /> : <ChevronDown className="w-2.5 h-2.5" />}
</button>
<AnimatePresence>
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 mt-1.5">
{scheduleEntries.map(([day, hours]) => (
<div key={day} className="flex justify-between text-[10px]">
<span className="text-muted font-medium">{day}</span>
<span className="text-secondary">{hours}</span>
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{(poi.duration ?? 0) > 0 && (
<span className="flex items-center gap-0.5 text-3xs text-muted mt-1">
<Clock className="w-2.5 h-2.5" />
~{poi.duration} мин
</span>
)}
</div>
</motion.div>
@@ -536,6 +484,50 @@ function TransportCardComponent({ option, onSelect, isSelected }: TransportCardC
);
}
// --- Timeline Step Marker ---
const stepIconMap: Record<string, typeof MapPin> = {
attraction: Camera,
museum: Camera,
park: MapPin,
restaurant: Utensils,
food: Utensils,
theater: Music,
entertainment: Music,
shopping: Tag,
religious: MapPin,
viewpoint: Camera,
hotel: Hotel,
transport: Navigation,
transfer: Navigation,
airport: Plane,
event: Ticket,
};
function TimelineStepMarker({ index, refType, isLast, hasSegment }: {
index: number;
refType: string;
isLast: boolean;
hasSegment: boolean;
}) {
const Icon = stepIconMap[refType] || MapPin;
const showLine = !isLast || hasSegment;
return (
<div className="flex flex-col items-center flex-shrink-0">
<div className="relative w-7 h-7 rounded-full bg-accent/15 border-2 border-accent/40 flex items-center justify-center group-hover:border-accent/60 transition-colors">
<Icon className="w-3 h-3 text-accent" />
<span className="absolute -top-1 -right-1 w-3.5 h-3.5 rounded-full bg-accent text-white text-[8px] font-bold flex items-center justify-center leading-none">
{index}
</span>
</div>
{showLine && (
<div className="w-px flex-1 min-h-[16px] bg-gradient-to-b from-accent/30 to-border/20 mt-1" />
)}
</div>
);
}
// --- Transport Segment Card ---
function TransportSegmentCard({ segment }: { segment: RouteSegment }) {
@@ -553,33 +545,31 @@ function TransportSegmentCard({ segment }: { segment: RouteSegment }) {
}
return (
<div className="mx-2 my-1 p-2 rounded-lg bg-accent/5 border border-accent/15">
<div className="flex items-center gap-2 mb-1.5">
<Navigation className="w-3 h-3 text-accent" />
<span className="text-[10px] text-muted uppercase tracking-wide">Как добраться</span>
{distKm && (
<span className="text-[10px] text-secondary">{distKm} км</span>
)}
{durationMin !== null && durationMin > 0 && (
<span className="text-[10px] text-secondary">~{durationMin} мин</span>
)}
</div>
<div className="flex gap-2">
{segment.transportOptions.map((opt) => {
const Icon = modeIcons[opt.mode] || Navigation;
return (
<div
key={opt.mode}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-surface/50 border border-border/30"
>
<Icon className="w-3 h-3 text-muted" />
<span className="text-[11px] text-secondary">{opt.label}</span>
<span className="text-[11px] font-medium text-primary">
~{opt.price.toLocaleString()} {opt.currency === 'RUB' ? '₽' : opt.currency}
<div className="ml-3 pl-5 border-l border-dashed border-accent/20">
<div className="flex items-center gap-3 py-1.5">
<div className="flex items-center gap-1.5 text-[10px] text-muted">
<Navigation className="w-2.5 h-2.5 text-accent/60" />
{distKm && <span>{distKm} км</span>}
{distKm && durationMin !== null && durationMin > 0 && <span className="text-border">·</span>}
{durationMin !== null && durationMin > 0 && <span>~{durationMin} мин</span>}
</div>
<div className="flex gap-1.5 flex-wrap">
{segment.transportOptions.map((opt) => {
const Icon = modeIcons[opt.mode] || Navigation;
return (
<span
key={opt.mode}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-accent/8 text-[10px] text-secondary"
>
<Icon className="w-2.5 h-2.5 text-accent/50" />
{opt.label}
<span className="font-medium text-primary">
~{opt.price.toLocaleString()} {opt.currency === 'RUB' ? '₽' : opt.currency}
</span>
</span>
</div>
);
})}
);
})}
</div>
</div>
</div>
);
@@ -591,10 +581,16 @@ interface ItineraryWidgetProps {
days: ItineraryDay[];
budget?: BudgetBreakdown;
segments?: RouteSegment[];
dailyForecast?: DailyForecast[];
pois?: POICard[];
events?: EventCard[];
onItineraryUpdate?: (days: ItineraryDay[]) => void;
onValidateWithLLM?: (days: ItineraryDay[]) => Promise<import('@/lib/hooks/useEditableItinerary').LLMValidationResponse | null>;
}
function ItineraryWidget({ days, segments }: ItineraryWidgetProps) {
function ItineraryWidget({ days, segments, dailyForecast, pois, events, onItineraryUpdate, onValidateWithLLM }: ItineraryWidgetProps) {
const [expandedDay, setExpandedDay] = useState<number>(0);
const [isEditMode, setIsEditMode] = useState(false);
const findSegment = useCallback((fromTitle: string, toTitle: string): RouteSegment | undefined => {
if (!segments || segments.length === 0) return undefined;
@@ -603,87 +599,142 @@ function ItineraryWidget({ days, segments }: ItineraryWidgetProps) {
);
}, [segments]);
const findWeather = useCallback((date: string): DailyForecast | undefined => {
if (!dailyForecast || dailyForecast.length === 0) return undefined;
return dailyForecast.find((d) => d.date === date);
}, [dailyForecast]);
let globalStep = 0;
if (isEditMode && pois && events) {
return (
<EditableItinerary
days={days}
pois={pois}
events={events}
dailyForecast={dailyForecast}
onApply={(editedDays) => {
onItineraryUpdate?.(editedDays);
setIsEditMode(false);
}}
onCancel={() => setIsEditMode(false)}
onValidateWithLLM={onValidateWithLLM}
/>
);
}
return (
<div className="space-y-2">
{days.map((day, idx) => (
<motion.div
key={day.date}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.05 }}
className="border border-border/40 rounded-xl overflow-hidden"
>
<button
onClick={() => setExpandedDay(expandedDay === idx ? -1 : idx)}
className="w-full flex items-center justify-between p-3 bg-elevated/40 hover:bg-elevated/60 transition-colors"
{days.map((day, idx) => {
const dayStartStep = globalStep;
globalStep += day.items.length;
const dayWeather = findWeather(day.date) || (dailyForecast && dailyForecast[idx]);
const WeatherDayIcon = dayWeather ? (weatherIconMap[dayWeather.icon] || CloudSun) : null;
const weatherColor = dayWeather ? (weatherIconColors[dayWeather.icon] || 'text-sky-400') : '';
return (
<motion.div
key={day.date}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.05 }}
className="border border-border/30 rounded-xl overflow-hidden bg-elevated/20"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center">
<span className="text-xs font-bold text-accent">Д{idx + 1}</span>
</div>
<div className="text-left">
<div className="text-sm font-medium text-primary">{day.date}</div>
<div className="text-xs text-muted">{day.items.length} активностей</div>
</div>
</div>
{expandedDay === idx ? (
<ChevronUp className="w-4 h-4 text-muted" />
) : (
<ChevronDown className="w-4 h-4 text-muted" />
)}
</button>
<AnimatePresence>
{expandedDay === idx && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="p-3 space-y-0">
{day.items.map((item, itemIdx) => {
const nextItem = itemIdx < day.items.length - 1 ? day.items[itemIdx + 1] : null;
const segment = nextItem ? findSegment(item.title, nextItem.title) : undefined;
return (
<div key={`${item.refId}-${itemIdx}`}>
<div className="flex items-start gap-3 p-2 rounded-lg bg-surface/30">
<div className="flex flex-col items-center">
<div className="w-2 h-2 rounded-full bg-accent" />
{(itemIdx < day.items.length - 1 || segment) && (
<div className="w-0.5 h-8 bg-border/40 mt-1" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
{item.startTime && (
<span className="text-xs text-accent font-mono">{item.startTime}</span>
)}
<span className="text-sm text-primary">{item.title}</span>
</div>
{item.note && (
<p className="text-xs text-muted mt-0.5">{item.note}</p>
)}
{(item.cost ?? 0) > 0 && (
<span className="text-xs text-secondary">
~{item.cost} {item.currency || '₽'}
</span>
)}
</div>
</div>
{segment && segment.transportOptions && segment.transportOptions.length > 0 && (
<TransportSegmentCard segment={segment} />
)}
</div>
);
})}
<button
onClick={() => setExpandedDay(expandedDay === idx ? -1 : idx)}
className="w-full flex items-center justify-between px-3.5 py-2.5 hover:bg-surface/30 transition-colors"
>
<div className="flex items-center gap-2.5">
<span className="w-6 h-6 rounded-md bg-accent text-white text-[10px] font-bold flex items-center justify-center leading-none">
{idx + 1}
</span>
<div className="text-left">
<span className="text-[13px] font-medium text-primary">{day.date}</span>
<span className="text-[11px] text-muted ml-2">{day.items.length} мест</span>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
))}
</div>
<div className="flex items-center gap-2">
{idx === 0 && (pois && pois.length > 0 || events && events.length > 0) && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); setIsEditMode(true); }}
className="w-7 h-7 inline-flex items-center justify-center text-muted hover:text-accent bg-surface/30 hover:bg-accent/10 rounded-lg transition-colors border border-border/30 hover:border-accent/30"
title="Редактировать маршрут"
aria-label="Редактировать маршрут"
>
<Pencil className="w-3 h-3" />
</button>
)}
{dayWeather && WeatherDayIcon && (
<div className="flex items-center gap-1.5" title={dayWeather.conditions}>
<WeatherDayIcon className={`w-3.5 h-3.5 ${weatherColor}`} />
<span className="text-[10px] text-secondary">
{formatTemp(dayWeather.tempMin)}..{formatTemp(dayWeather.tempMax)}
</span>
</div>
)}
<ChevronDown className={`w-4 h-4 text-muted transition-transform duration-200 ${expandedDay === idx ? 'rotate-180' : ''}`} />
</div>
</button>
<AnimatePresence>
{expandedDay === idx && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="px-3.5 pb-3 pt-1">
{day.items.map((item, itemIdx) => {
const nextItem = itemIdx < day.items.length - 1 ? day.items[itemIdx + 1] : null;
const segment = nextItem ? findSegment(item.title, nextItem.title) : undefined;
const stepNum = dayStartStep + itemIdx + 1;
const isLast = itemIdx === day.items.length - 1;
return (
<div key={`${item.refId}-${itemIdx}`} className="group">
<div className="flex items-start gap-2.5 py-1.5">
<TimelineStepMarker
index={stepNum}
refType={item.refType}
isLast={isLast && !segment}
hasSegment={!!segment}
/>
<div className="flex-1 min-w-0 pt-0.5">
<div className="flex items-center gap-2">
{item.startTime && (
<span className="text-[11px] text-accent font-mono bg-accent/8 px-1.5 py-px rounded">
{item.startTime}
</span>
)}
<span className="text-[13px] font-medium text-primary truncate">{item.title}</span>
</div>
{item.note && (
<p className="text-[11px] text-muted mt-0.5 line-clamp-2 leading-relaxed">{item.note}</p>
)}
{(item.cost ?? 0) > 0 && (
<span className="inline-flex items-center gap-0.5 text-[10px] text-accent/80 mt-0.5">
<DollarSign className="w-2.5 h-2.5" />
~{item.cost?.toLocaleString('ru-RU')} {item.currency || '₽'}
</span>
)}
</div>
</div>
{segment && segment.transportOptions && segment.transportOptions.length > 0 && (
<TransportSegmentCard segment={segment} />
)}
</div>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
})}
</div>
);
}
@@ -762,9 +813,10 @@ function BudgetWidget({ breakdown }: BudgetWidgetProps) {
interface ClarifyingQuestionsWidgetProps {
questions: ClarifyingQuestion[];
onAnswer: (field: string, value: string) => void;
onSubmittedNotice?: (text: string) => void;
}
function ClarifyingQuestionsWidget({ questions, onAnswer }: ClarifyingQuestionsWidgetProps) {
function ClarifyingQuestionsWidget({ questions, onAnswer, onSubmittedNotice }: ClarifyingQuestionsWidgetProps) {
const [answers, setAnswers] = useState<Record<string, string>>({});
const [submitted, setSubmitted] = useState(false);
@@ -779,18 +831,20 @@ function ClarifyingQuestionsWidget({ questions, onAnswer }: ClarifyingQuestionsW
setSubmitted(true);
const combined = filled.map(([field, value]) => `${field}: ${value}`).join('\n');
onAnswer('_batch', combined);
}, [answers, onAnswer]);
onSubmittedNotice?.('Детали отправлены, планирую маршрут…');
}, [answers, onAnswer, onSubmittedNotice]);
if (submitted) {
if (onSubmittedNotice) return null;
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="p-4 rounded-xl border border-green-500/30 bg-green-500/5"
className="px-3 py-2 rounded-lg border border-green-500/25 bg-green-500/5"
>
<div className="flex items-center gap-2">
<Check className="w-4 h-4 text-green-600" />
<span className="text-sm text-green-600">Детали отправлены, планирую маршрут...</span>
<Check className="w-3.5 h-3.5 text-green-600" />
<span className="text-[12px] leading-4 text-green-600">Детали отправлены, планирую маршрут</span>
</div>
</motion.div>
);
@@ -851,6 +905,119 @@ function ClarifyingQuestionsWidget({ questions, onAnswer }: ClarifyingQuestionsW
);
}
function TipsCollapsible({ tips, tipIcons }: { tips: TravelTip[]; tipIcons: Record<string, typeof Info> }) {
const [open, setOpen] = useState(false);
return (
<div>
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2 px-1 w-full text-left"
>
<Lightbulb className="w-3.5 h-3.5 text-accent" />
<span className="text-xs font-medium text-primary">Советы</span>
<span className="px-1.5 py-0.5 text-[10px] bg-accent/20 text-accent rounded-full">{tips.length}</span>
<span className="ml-auto">
{open ? <ChevronUp className="w-3.5 h-3.5 text-muted" /> : <ChevronDown className="w-3.5 h-3.5 text-muted" />}
</span>
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="space-y-2 mt-2">
{tips.map((tip, i) => {
const TipIcon = tipIcons[tip.category] || Info;
return (
<div key={i} className="flex items-start gap-2 p-3 rounded-lg bg-elevated/30 border border-border/20">
<TipIcon className="w-3.5 h-3.5 text-accent flex-shrink-0 mt-0.5" />
<p className="text-xs text-secondary">{tip.text}</p>
</div>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
// --- Weather Icon Mapping ---
const weatherIconMap: Record<WeatherIcon | string, typeof Sun> = {
sun: Sun,
cloud: Cloud,
'cloud-sun': CloudSun,
rain: CloudRain,
storm: CloudLightning,
snow: Snowflake,
fog: CloudFog,
wind: Wind,
};
const weatherIconColors: Record<WeatherIcon | string, string> = {
sun: 'text-amber-400',
cloud: 'text-slate-400',
'cloud-sun': 'text-sky-400',
rain: 'text-blue-400',
storm: 'text-purple-400',
snow: 'text-cyan-300',
fog: 'text-slate-300',
wind: 'text-teal-400',
};
function formatTemp(temp: number): string {
return `${temp > 0 ? '+' : ''}${Math.round(temp)}°`;
}
function formatDateShort(dateStr: string): string {
try {
const d = new Date(dateStr + 'T00:00:00');
const weekdays = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
const day = d.getDate();
const months = ['янв', 'фев', 'мар', 'апр', 'мая', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'];
return `${weekdays[d.getDay()]}, ${day} ${months[d.getMonth()]}`;
} catch {
return dateStr;
}
}
function DailyForecastRow({ day }: { day: DailyForecast }) {
const Icon = weatherIconMap[day.icon] || CloudSun;
const iconColor = weatherIconColors[day.icon] || 'text-sky-400';
return (
<div className="flex items-center gap-2 py-1.5 px-2 rounded-lg hover:bg-surface/30 transition-colors group">
<span className="text-[10px] text-muted w-[70px] flex-shrink-0 leading-tight">
{formatDateShort(day.date)}
</span>
<Icon className={`w-4 h-4 ${iconColor} flex-shrink-0`} />
<span className="text-[11px] font-medium text-primary w-[52px] text-right flex-shrink-0">
{formatTemp(day.tempMin)}..{formatTemp(day.tempMax)}
</span>
<span className="text-[10px] text-secondary truncate flex-1">
{day.conditions}
</span>
{day.rainChance && day.rainChance !== 'низкая' && (
<span className="flex items-center gap-0.5 flex-shrink-0">
<Droplets className="w-2.5 h-2.5 text-blue-400" />
<span className="text-[9px] text-blue-400">{day.rainChance}</span>
</span>
)}
{day.tip && (
<span className="hidden group-hover:block absolute right-0 top-full z-10 p-2 bg-elevated border border-border/40 rounded-lg text-[10px] text-secondary max-w-[200px] shadow-lg">
{day.tip}
</span>
)}
</div>
);
}
// --- Travel Context Widget ---
interface TravelContextWidgetProps {
@@ -862,6 +1029,8 @@ interface TravelContextWidgetProps {
}
function TravelContextWidget({ weather, safety, restrictions, tips, bestTimeInfo }: TravelContextWidgetProps) {
const [showAllDays, setShowAllDays] = useState(false);
const safetyColors: Record<string, { bg: string; text: string; icon: typeof ShieldCheck }> = {
safe: { bg: 'bg-green-500/10', text: 'text-green-400', icon: ShieldCheck },
caution: { bg: 'bg-yellow-500/10', text: 'text-yellow-400', icon: Shield },
@@ -893,29 +1062,68 @@ function TravelContextWidget({ weather, safety, restrictions, tips, bestTimeInfo
const safetyStyle = safetyColors[safety.level] || safetyColors.safe;
const SafetyIcon = safetyStyle.icon;
const dailyForecast = weather.dailyForecast || [];
const visibleDays = showAllDays ? dailyForecast : dailyForecast.slice(0, 4);
const hasMoreDays = dailyForecast.length > 4;
return (
<div className="space-y-3">
{/* Weather */}
<div className="p-4 rounded-xl border border-border/40 bg-elevated/40">
<div className="flex items-center gap-2 mb-3">
<CloudSun className="w-4 h-4 text-sky-400" />
<h4 className="text-sm font-medium text-primary">Погода</h4>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Thermometer className="w-3.5 h-3.5 text-orange-400" />
<span className="text-xs text-secondary">
{weather.tempMin > 0 ? '+' : ''}{weather.tempMin}° ... {weather.tempMax > 0 ? '+' : ''}{weather.tempMax}°C
<CloudSun className="w-4 h-4 text-sky-400" />
<h4 className="text-sm font-medium text-primary">Погода</h4>
</div>
<div className="flex items-center gap-2">
<span className="flex items-center gap-1 text-xs text-secondary">
<Thermometer className="w-3 h-3 text-orange-400" />
{formatTemp(weather.tempMin)}..{formatTemp(weather.tempMax)}
</span>
<span className="flex items-center gap-1 text-xs text-secondary">
<Umbrella className="w-3 h-3 text-blue-400" />
{weather.rainChance}
</span>
</div>
<div className="flex items-center gap-2">
<Umbrella className="w-3.5 h-3.5 text-blue-400" />
<span className="text-xs text-secondary">Осадки: {weather.rainChance}</span>
</div>
</div>
<p className="text-xs text-secondary mt-2">{weather.summary}</p>
<p className="text-xs text-secondary">{weather.summary}</p>
{weather.clothing && (
<p className="text-xs text-muted mt-1.5 italic">{weather.clothing}</p>
<p className="text-[11px] text-muted mt-1.5 italic">{weather.clothing}</p>
)}
{/* Daily forecast */}
{dailyForecast.length > 0 && (
<div className="mt-3 pt-3 border-t border-border/30">
<div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] text-muted uppercase tracking-wide">Прогноз по дням</span>
<span className="text-[10px] text-muted">{dailyForecast.length} дн.</span>
</div>
<div className="space-y-0">
{visibleDays.map((day) => (
<DailyForecastRow key={day.date} day={day} />
))}
</div>
{hasMoreDays && (
<button
onClick={() => setShowAllDays(!showAllDays)}
className="flex items-center gap-1 mt-1 px-2 py-1 text-[10px] text-accent hover:text-accent-hover transition-colors"
>
{showAllDays ? (
<>
<ChevronUp className="w-3 h-3" />
Свернуть
</>
) : (
<>
<ChevronDown className="w-3 h-3" />
Ещё {dailyForecast.length - 4} дн.
</>
)}
</button>
)}
</div>
)}
</div>
@@ -965,23 +1173,9 @@ function TravelContextWidget({ weather, safety, restrictions, tips, bestTimeInfo
</div>
)}
{/* Tips */}
{/* Tips (collapsed by default) */}
{tips.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 px-1">
<Lightbulb className="w-3.5 h-3.5 text-accent" />
<span className="text-xs font-medium text-primary">Советы</span>
</div>
{tips.map((tip, i) => {
const TipIcon = tipIcons[tip.category] || Info;
return (
<div key={i} className="flex items-start gap-2 p-3 rounded-lg bg-elevated/30 border border-border/20">
<TipIcon className="w-3.5 h-3.5 text-accent flex-shrink-0 mt-0.5" />
<p className="text-xs text-secondary">{tip.text}</p>
</div>
);
})}
</div>
<TipsCollapsible tips={tips} tipIcons={tipIcons} />
)}
{/* Best time info */}
@@ -1008,10 +1202,15 @@ interface TravelWidgetRendererProps {
onSelectTransport?: (option: TransportOption) => void;
onClarifyingAnswer?: (field: string, value: string) => void;
onAction?: (kind: string) => void;
onInlineNotice?: (text: string) => void;
selectedEventIds?: Set<string>;
selectedPOIIds?: Set<string>;
selectedHotelId?: string;
selectedTransportId?: string;
availablePois?: POICard[];
availableEvents?: EventCard[];
onItineraryUpdate?: (days: ItineraryDay[]) => void;
onValidateItineraryWithLLM?: (days: ItineraryDay[]) => Promise<import('@/lib/hooks/useEditableItinerary').LLMValidationResponse | null>;
}
export function TravelWidgetRenderer({
@@ -1022,10 +1221,15 @@ export function TravelWidgetRenderer({
onSelectTransport,
onClarifyingAnswer,
onAction,
onInlineNotice,
selectedEventIds = new Set(),
selectedPOIIds = new Set(),
selectedHotelId,
selectedTransportId,
availablePois,
availableEvents,
onItineraryUpdate,
onValidateItineraryWithLLM,
}: TravelWidgetRendererProps) {
switch (widget.type) {
case 'travel_context': {
@@ -1130,10 +1334,20 @@ export function TravelWidgetRenderer({
const days = (widget.params.days || []) as ItineraryDay[];
const budgetData = widget.params.budget as BudgetBreakdown | undefined;
const segmentsData = (widget.params.segments || []) as RouteSegment[];
const forecastData = (widget.params.dailyForecast || []) as DailyForecast[];
if (days.length === 0) return null;
return (
<WidgetSection title="Маршрут по дням" icon={<Calendar className="w-4 h-4" />} count={days.length}>
<ItineraryWidget days={days} budget={budgetData} segments={segmentsData} />
<ItineraryWidget
days={days}
budget={budgetData}
segments={segmentsData}
dailyForecast={forecastData}
pois={availablePois}
events={availableEvents}
onItineraryUpdate={onItineraryUpdate}
onValidateWithLLM={onValidateItineraryWithLLM}
/>
</WidgetSection>
);
}
@@ -1151,6 +1365,7 @@ export function TravelWidgetRenderer({
<ClarifyingQuestionsWidget
questions={questions}
onAnswer={onClarifyingAnswer || (() => {})}
onSubmittedNotice={onInlineNotice}
/>
);
}