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

@@ -100,7 +100,7 @@ function PostModal({ post, onClose, onSave }: PostModalProps) {
<label className="block text-sm text-secondary mb-1">Статус</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
onChange={(e) => setStatus(e.target.value as 'draft' | 'published' | 'archived')}
className="w-full px-3 py-2 bg-base border border-border/50 rounded-lg text-primary focus:outline-none focus:border-primary"
>
<option value="draft">Черновик</option>

View File

@@ -5,6 +5,7 @@ import { usePathname } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
import { Menu, X } from 'lucide-react';
import { Sidebar } from '@/components/Sidebar';
import { ThemeToggle } from '@/components/ThemeToggle';
export default function MainLayout({ children }: { children: React.ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
@@ -41,30 +42,39 @@ export default function MainLayout({ children }: { children: React.ReactNode })
<Menu className="w-5 h-5" />
</button>
<span className="font-black italic text-primary tracking-tight text-2xl">GooSeek</span>
<div className="w-10" />
<div className="w-10 flex items-center justify-end">
<ThemeToggle variant="icon" />
</div>
</div>
)}
{/* Desktop Sidebar */}
{!isMobile && <Sidebar />}
{!isMobile && (
<div className="relative z-30">
<Sidebar />
</div>
)}
{/* Mobile Sidebar Overlay */}
<AnimatePresence>
<AnimatePresence mode="wait">
{isMobile && sidebarOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={() => setSidebarOpen(false)}
className="fixed inset-0 z-40 bg-base/80 backdrop-blur-sm"
style={{ pointerEvents: 'auto' }}
/>
<motion.div
initial={{ x: -240 }}
initial={{ x: -260 }}
animate={{ x: 0 }}
exit={{ x: -240 }}
exit={{ x: -260 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="fixed left-0 top-0 bottom-0 z-50 w-[240px]"
className="fixed left-0 top-0 bottom-0 z-50 w-[260px]"
style={{ pointerEvents: 'auto' }}
>
<Sidebar onClose={() => setSidebarOpen(false)} />
</motion.div>

View File

@@ -0,0 +1,263 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
import {
ArrowLeft,
BookOpen,
CheckCircle2,
Clock,
Loader2,
Play,
Target,
Users,
} from 'lucide-react';
import { enrollInCourse, fetchLearningCourse } from '@/lib/api';
import type { CourseModule, LearningCourse } from '@/lib/types';
const difficultyLabels: Record<string, string> = {
beginner: 'Начинающий',
intermediate: 'Средний',
advanced: 'Продвинутый',
expert: 'Эксперт',
};
export default function LearningCourseLandingPage() {
const params = useParams<{ slug: string }>();
const router = useRouter();
const slug = params?.slug || '';
const [course, setCourse] = useState<LearningCourse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isEnrolling, setIsEnrolling] = useState(false);
const [error, setError] = useState<string | null>(null);
const modules = useMemo<CourseModule[]>(() => {
if (!course?.baseOutline?.modules || !Array.isArray(course.baseOutline.modules)) {
return [];
}
return course.baseOutline.modules;
}, [course]);
const loadCourse = useCallback(async () => {
if (!slug) return;
setIsLoading(true);
setError(null);
try {
const data = await fetchLearningCourse(slug);
if (!data) {
setError('Курс не найден');
setCourse(null);
return;
}
setCourse(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Не удалось загрузить курс');
} finally {
setIsLoading(false);
}
}, [slug]);
useEffect(() => {
void loadCourse();
}, [loadCourse]);
const handleEnroll = useCallback(async () => {
if (!course) return;
setIsEnrolling(true);
setError(null);
try {
await enrollInCourse(course.id);
router.push('/learning');
} catch (err) {
setError(err instanceof Error ? err.message : 'Не удалось записаться на курс');
} finally {
setIsEnrolling(false);
}
}, [course, router]);
if (isLoading) {
return (
<div className="h-full flex items-center justify-center">
<div className="flex items-center gap-2 text-muted text-sm">
<Loader2 className="w-4 h-4 animate-spin" />
Загрузка курса...
</div>
</div>
);
}
if (!course) {
return (
<div className="h-full flex items-center justify-center px-4">
<div className="max-w-md w-full p-4 rounded-2xl border border-border/40 bg-surface/40 text-center">
<p className="text-sm text-primary font-medium">Курс недоступен</p>
{error && <p className="text-xs text-muted mt-1">{error}</p>}
<button
onClick={() => router.push('/learning')}
className="mt-4 inline-flex items-center gap-1.5 px-3 py-2 text-xs btn-gradient rounded-lg"
>
<ArrowLeft className="w-3.5 h-3.5 btn-gradient-text" />
<span className="btn-gradient-text">Назад к обучению</span>
</button>
</div>
</div>
);
}
return (
<div className="h-full overflow-y-auto bg-gradient-main">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6 space-y-5">
<button
onClick={() => router.push('/learning')}
className="inline-flex items-center gap-2 text-xs text-secondary hover:text-primary transition-colors"
>
<ArrowLeft className="w-3.5 h-3.5" />
Назад к обучению
</button>
<motion.section
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="p-5 rounded-2xl border border-border/40 bg-surface/40"
>
<div className="flex flex-wrap items-center gap-2">
<span className="text-[10px] px-2 py-0.5 rounded border border-border/40 text-muted">
{difficultyLabels[course.difficulty] || course.difficulty}
</span>
<span className="text-[10px] px-2 py-0.5 rounded border border-border/40 text-muted">
{course.category}
</span>
<span className="inline-flex items-center gap-1 text-[10px] text-faint">
<Clock className="w-3 h-3" />
{course.durationHours}ч
</span>
<span className="inline-flex items-center gap-1 text-[10px] text-faint">
<Users className="w-3 h-3" />
{course.enrolledCount} записей
</span>
</div>
<h1 className="mt-3 text-2xl sm:text-3xl font-bold text-primary">
{course.landing?.hero_title || course.title}
</h1>
<p className="mt-2 text-sm text-secondary max-w-3xl">
{course.landing?.hero_subtitle || course.shortDescription}
</p>
<div className="mt-4 flex flex-wrap items-center gap-2">
<button
onClick={handleEnroll}
disabled={isEnrolling}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs btn-gradient rounded-lg disabled:opacity-70"
>
{isEnrolling ? (
<Loader2 className="w-3.5 h-3.5 animate-spin btn-gradient-text" />
) : (
<Play className="w-3.5 h-3.5 btn-gradient-text" />
)}
<span className="btn-gradient-text">
{isEnrolling ? 'Записываем...' : 'Записаться и начать'}
</span>
</button>
{course.landing?.salary_range && (
<span className="text-xs px-2.5 py-1 rounded-lg bg-success/10 text-success border border-success/25">
Доход после курса: {course.landing.salary_range}
</span>
)}
</div>
</motion.section>
{error && (
<div className="p-3 rounded-xl border border-error/30 bg-error/10 text-sm text-error">
{error}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<section className="lg:col-span-2 p-4 rounded-2xl border border-border/40 bg-surface/40">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-accent" />
<h2 className="text-sm font-medium text-primary">Программа курса</h2>
</div>
<div className="mt-3 space-y-2">
{modules.length > 0 ? (
modules.map((moduleItem, index) => (
<div key={`${moduleItem.title}-${index}`} className="p-3 rounded-xl border border-border/40 bg-elevated/30">
<p className="text-xs text-muted">Модуль {index + 1}</p>
<p className="text-sm font-medium text-primary mt-0.5">{moduleItem.title}</p>
<p className="text-xs text-secondary mt-1">{moduleItem.description}</p>
{moduleItem.practice_focus && (
<div className="mt-2 text-[11px] text-faint inline-flex items-center gap-1.5">
<Target className="w-3 h-3" />
Практика: {moduleItem.practice_focus}
</div>
)}
</div>
))
) : (
<div className="p-3 rounded-xl border border-border/40 bg-elevated/20 text-xs text-muted">
Программа появится после публикации модулей.
</div>
)}
</div>
</section>
<section className="p-4 rounded-2xl border border-border/40 bg-surface/40 space-y-3">
<div>
<h3 className="text-sm font-medium text-primary">Для кого курс</h3>
<p className="text-xs text-secondary mt-1">
{course.landing?.target_audience || 'Разработчики и специалисты, которым нужен практический рост.'}
</p>
</div>
<div>
<h3 className="text-sm font-medium text-primary">Что получите</h3>
<ul className="mt-2 space-y-1">
{(course.landing?.outcomes || []).slice(0, 6).map((item, index) => (
<li key={index} className="text-xs text-secondary flex gap-2">
<CheckCircle2 className="w-3.5 h-3.5 text-success mt-0.5" />
<span>{item}</span>
</li>
))}
</ul>
</div>
<div>
<h3 className="text-sm font-medium text-primary">Порог входа</h3>
<p className="text-xs text-secondary mt-1">
{course.landing?.prerequisites || 'Базовые знания программирования и готовность к практике.'}
</p>
</div>
</section>
</div>
{(course.landing?.benefits || []).length > 0 && (
<section className="p-4 rounded-2xl border border-border/40 bg-surface/40">
<h2 className="text-sm font-medium text-primary">Преимущества</h2>
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-2">
{(course.landing?.benefits || []).map((benefit, index) => (
<div key={index} className="p-3 rounded-xl border border-border/40 bg-elevated/30 text-xs text-secondary">
{benefit}
</div>
))}
</div>
</section>
)}
{(course.landing?.faq || []).length > 0 && (
<section className="p-4 rounded-2xl border border-border/40 bg-surface/40">
<h2 className="text-sm font-medium text-primary">FAQ</h2>
<div className="mt-3 space-y-2">
{(course.landing?.faq || []).map((item, index) => (
<div key={index} className="p-3 rounded-xl border border-border/40 bg-elevated/30">
<p className="text-xs font-medium text-primary">{item.question}</p>
<p className="text-xs text-secondary mt-1">{item.answer}</p>
</div>
))}
</div>
</section>
)}
</div>
</div>
);
}

View File

@@ -1,201 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, Loader2, Target, BookOpen, Brain, Sparkles, Code2, CheckCircle2 } from 'lucide-react';
import { createLesson } from '@/lib/api';
const difficulties = [
{ value: 'beginner', label: 'Начинающий', icon: Target },
{ value: 'intermediate', label: 'Средний', icon: BookOpen },
{ value: 'advanced', label: 'Продвинутый', icon: Brain },
{ value: 'expert', label: 'Эксперт', icon: Sparkles },
];
const modes = [
{ value: 'explain', label: 'Объяснение' },
{ value: 'guided', label: 'С наставником' },
{ value: 'interactive', label: 'Интерактив' },
{ value: 'practice', label: 'Практика' },
{ value: 'quiz', label: 'Тест' },
];
export default function NewLessonPage() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState({
topic: '',
difficulty: 'beginner',
mode: 'explain',
includeCode: true,
includeQuiz: true,
});
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.topic.trim() || isSubmitting) return;
setIsSubmitting(true);
setError(null);
try {
await createLesson({
topic: formData.topic.trim(),
difficulty: formData.difficulty,
mode: formData.mode,
includeCode: formData.includeCode,
includeQuiz: formData.includeQuiz,
locale: 'ru',
});
router.push('/learning');
} catch (err) {
console.error('Failed to create lesson:', err);
setError('Не удалось создать урок. Попробуйте позже.');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="h-full overflow-y-auto">
<div className="max-w-lg mx-auto px-4 sm:px-6 py-6 sm:py-8">
{/* Header */}
<div className="flex items-center gap-4 mb-6 sm:mb-8">
<Link
href="/learning"
className="w-10 h-10 flex items-center justify-center rounded-xl text-secondary hover:text-primary hover:bg-surface/50 transition-all"
>
<ArrowLeft className="w-5 h-5" />
</Link>
<div>
<h1 className="text-xl sm:text-2xl font-semibold text-primary">Новый урок</h1>
<p className="text-sm text-secondary mt-0.5 hidden sm:block">Создайте интерактивный урок с AI</p>
</div>
</div>
{error && (
<div className="mb-6 p-4 bg-error/10 border border-error/30 rounded-xl text-sm text-error">
{error}
</div>
)}
{/* Form */}
<form onSubmit={handleCreate} className="space-y-5">
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Тема урока <span className="text-error">*</span>
</label>
<input
value={formData.topic}
onChange={(e) => setFormData((f) => ({ ...f, topic: e.target.value }))}
placeholder="Например: Основы Python"
className="w-full px-4 py-3 text-sm bg-elevated/60 border border-border rounded-xl text-primary placeholder:text-muted focus:outline-none input-gradient transition-colors"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-3">
Уровень сложности
</label>
<div className="grid grid-cols-2 gap-2">
{difficulties.map((d) => (
<button
key={d.value}
type="button"
onClick={() => setFormData((f) => ({ ...f, difficulty: d.value }))}
className={`flex items-center gap-3 p-3 rounded-xl border transition-all ${
formData.difficulty === d.value
? 'active-gradient text-primary'
: 'bg-elevated/40 border-border/50 text-muted hover:border-border hover:text-secondary'
}`}
>
<d.icon className={`w-4 h-4 ${formData.difficulty === d.value ? 'text-gradient' : ''}`} />
<span className="text-sm">{d.label}</span>
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-2">
Режим обучения
</label>
<select
value={formData.mode}
onChange={(e) => setFormData((f) => ({ ...f, mode: e.target.value }))}
className="w-full px-4 py-3 text-sm bg-elevated/60 border border-border rounded-xl text-primary focus:outline-none input-gradient transition-colors"
>
{modes.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-secondary mb-3">
Дополнительные материалы
</label>
<div className="flex flex-col sm:flex-row gap-3">
<label
className={`flex-1 flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${
formData.includeCode
? 'active-gradient text-primary'
: 'bg-elevated/40 border-border/50 text-muted hover:border-border'
}`}
>
<input
type="checkbox"
checked={formData.includeCode}
onChange={(e) => setFormData((f) => ({ ...f, includeCode: e.target.checked }))}
className="sr-only"
/>
<Code2 className={`w-5 h-5 ${formData.includeCode ? 'text-gradient' : ''}`} />
<span className="text-sm">Примеры кода</span>
</label>
<label
className={`flex-1 flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${
formData.includeQuiz
? 'active-gradient text-primary'
: 'bg-elevated/40 border-border/50 text-muted hover:border-border'
}`}
>
<input
type="checkbox"
checked={formData.includeQuiz}
onChange={(e) => setFormData((f) => ({ ...f, includeQuiz: e.target.checked }))}
className="sr-only"
/>
<CheckCircle2 className={`w-5 h-5 ${formData.includeQuiz ? 'text-gradient' : ''}`} />
<span className="text-sm">Тесты</span>
</label>
</div>
</div>
{/* Actions */}
<div className="flex flex-col-reverse sm:flex-row gap-3 pt-4">
<Link
href="/learning"
className="flex-1 px-4 py-3 text-sm text-center text-secondary hover:text-primary bg-surface/40 border border-border/50 rounded-xl transition-all"
>
Отмена
</Link>
<button
type="submit"
disabled={!formData.topic.trim() || isSubmitting}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 text-sm btn-gradient disabled:opacity-50"
>
{isSubmitting && <Loader2 className="w-4 h-4 animate-spin btn-gradient-text" />}
<span className="btn-gradient-text">Создать урок</span>
</button>
</div>
</form>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,298 +1,283 @@
'use client';
import { useState, useCallback } from 'react';
import { motion } from 'framer-motion';
import {
HeartPulse,
Search,
Pill,
Stethoscope,
FileText,
AlertTriangle,
Clock,
Loader2,
Sparkles,
Activity,
Thermometer,
Brain,
Eye,
Bone,
Wind,
Droplet,
Shield,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { AlertTriangle, ArrowUp, HeartPulse, Loader2, MapPin, Plus, X } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import { useMedicineChat, type MedicineChatMessage, type MedicineWidget } from '@/lib/hooks/useMedicineChat';
import { MedicineWidgetTabs } from '@/components/MedicineWidgetTabs';
interface Symptom {
id: string;
name: string;
icon: React.ElementType;
category: string;
}
interface Article {
id: string;
title: string;
category: string;
readTime: string;
icon: string;
}
const commonSymptoms: Symptom[] = [
{ id: '1', name: 'Головная боль', icon: Brain, category: 'Неврология' },
{ id: '2', name: 'Температура', icon: Thermometer, category: 'Общие' },
{ id: '3', name: 'Боль в горле', icon: Wind, category: 'ЛОР' },
{ id: '4', name: 'Боль в спине', icon: Bone, category: 'Ортопедия' },
{ id: '5', name: 'Проблемы со зрением', icon: Eye, category: 'Офтальмология' },
{ id: '6', name: 'Давление', icon: Activity, category: 'Кардиология' },
{ id: '7', name: 'Аллергия', icon: Droplet, category: 'Аллергология' },
{ id: '8', name: 'Усталость', icon: HeartPulse, category: 'Общие' },
const quickPrompts = [
{ icon: '🤒', text: 'Температура и слабость', query: 'Температура 38.2, слабость, ломота в теле второй день' },
{ icon: '🫀', text: 'Боль в груди', query: 'Периодическая давящая боль в груди и одышка при нагрузке' },
{ icon: '🧠', text: 'Головная боль', query: 'Сильные головные боли вечером, иногда тошнота, светобоязнь' },
{ icon: '🤧', text: 'Кашель/горло', query: 'Кашель, боль в горле, насморк, температура 37.5' },
];
const healthArticles: Article[] = [
{
id: '1',
title: 'Как укрепить иммунитет зимой',
category: 'Профилактика',
readTime: '5 мин',
icon: '🛡️',
},
{
id: '2',
title: 'Правильное питание для сердца',
category: 'Кардиология',
readTime: '7 мин',
icon: '❤️',
},
{
id: '3',
title: 'Упражнения для здоровой спины',
category: 'Ортопедия',
readTime: '6 мин',
icon: '🏃',
},
{
id: '4',
title: 'Как справиться со стрессом',
category: 'Психология',
readTime: '8 мин',
icon: '🧘',
},
];
const quickServices = [
{ icon: Stethoscope, label: 'Найти врача', color: 'bg-blue-500/10 text-blue-600' },
{ icon: Pill, label: 'Справочник лекарств', color: 'bg-green-500/10 text-green-600' },
{ icon: FileText, label: 'Анализы', color: 'bg-purple-500/10 text-purple-600' },
{ icon: Sparkles, label: 'AI Консультант', color: 'active-gradient text-gradient' },
];
function SymptomButton({ symptom, onClick }: { symptom: Symptom; onClick: () => void }) {
function AssistantMessage({ message }: { message: MedicineChatMessage }) {
return (
<button
onClick={onClick}
className="flex items-center gap-2 px-3 py-2 bg-surface/50 border border-border/40 rounded-lg hover:border-border hover:bg-surface/70 transition-all text-left"
>
<symptom.icon className="w-4 h-4 text-muted flex-shrink-0" />
<span className="text-sm text-secondary truncate">{symptom.name}</span>
</button>
);
}
function ArticleCard({ article, delay }: { article: Article; delay: number }) {
return (
<motion.div
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay }}
className="flex items-center gap-3 p-3 bg-elevated/40 border border-border/40 rounded-xl hover:border-border transition-all cursor-pointer group"
>
<div className="w-10 h-10 rounded-xl bg-surface/60 flex items-center justify-center text-xl flex-shrink-0">
{article.icon}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-primary group-hover:text-gradient transition-colors truncate">
{article.title}
</h3>
<div className="flex items-center gap-2 text-xs text-muted">
<span>{article.category}</span>
<span></span>
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{article.readTime}
</span>
<div className="max-w-full w-full">
{message.content && (
<div className="prose prose-sm prose-invert max-w-none text-ui">
<ReactMarkdown>{message.content}</ReactMarkdown>
</div>
</div>
</motion.div>
)}
{message.isStreaming && (
<div className="flex items-center gap-2 mt-2">
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
<span className="text-xs text-muted">Анализ симптомов и подбор специалистов...</span>
</div>
)}
</div>
);
}
export default function MedicinePage() {
const [searchQuery, setSearchQuery] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [aiResponse, setAiResponse] = useState<string | null>(null);
const [inputValue, setInputValue] = useState('');
const [city, setCity] = useState('Москва');
const [showPanel, setShowPanel] = useState(true);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const handleAIConsult = useCallback(async () => {
if (!searchQuery.trim()) return;
setIsLoading(true);
setAiResponse(null);
await new Promise((resolve) => setTimeout(resolve, 2000));
setAiResponse(
'На основе описанных симптомов рекомендую обратиться к терапевту для первичного осмотра. ' +
'Это может быть связано с несколькими причинами, которые требуют диагностики. ' +
'До визита к врачу: пейте больше жидкости, отдыхайте, избегайте переохлаждения.'
);
setIsLoading(false);
}, [searchQuery]);
const { messages, isLoading, sendMessage, stopGeneration, clearChat } = useMedicineChat();
const hasMessages = messages.length > 0;
const handleSymptomClick = (symptomName: string) => {
setSearchQuery(symptomName);
const allWidgets = useMemo((): MedicineWidget[] => {
const dedup = new Map<string, MedicineWidget>();
for (const msg of messages) {
if (msg.role !== 'assistant') continue;
for (const w of msg.widgets) {
if (w.type.startsWith('medicine_')) dedup.set(w.type, w);
}
}
return Array.from(dedup.values());
}, [messages]);
const handleSend = useCallback(() => {
if (!inputValue.trim() || isLoading) return;
sendMessage(inputValue, city);
setInputValue('');
if (textareaRef.current) textareaRef.current.style.height = 'auto';
}, [city, inputValue, isLoading, sendMessage]);
const handleInput = () => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`;
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return (
<div className="h-full overflow-y-auto">
<div className="max-w-2xl mx-auto px-4 sm:px-6 py-6 sm:py-8">
{/* Header */}
<div className="mb-6 sm:mb-8">
<h1 className="text-xl sm:text-2xl font-semibold text-primary mb-1 sm:mb-2">Медицина</h1>
<p className="text-sm text-secondary">AI-помощник по здоровью и медицине</p>
</div>
{/* Disclaimer */}
<div className="flex items-start gap-3 p-4 bg-amber-500/5 border border-amber-500/20 rounded-xl mb-6">
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-600 mb-1">Важно</p>
<p className="text-xs text-muted">
Информация носит справочный характер и не заменяет консультацию врача.
При серьёзных симптомах обратитесь к специалисту.
</p>
</div>
</div>
{/* AI Search */}
<div className="bg-elevated/40 border border-border/40 rounded-xl p-4 mb-6">
<div className="relative mb-4">
<Sparkles className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gradient" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAIConsult()}
placeholder="Опишите симптомы или задайте вопрос о здоровье..."
className="w-full h-12 pl-12 pr-4 bg-surface/50 border border-border/50 rounded-xl text-sm text-primary placeholder:text-muted focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20 transition-all"
/>
</div>
<button
onClick={handleAIConsult}
disabled={isLoading || !searchQuery.trim()}
className="w-full h-11 flex items-center justify-center gap-2 active-gradient text-gradient font-medium text-sm rounded-xl disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
<div className="relative z-0 flex flex-col h-full bg-gradient-main">
<AnimatePresence mode="wait">
{!hasMessages ? (
<motion.div
key="welcome"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, y: -12 }}
className="flex-1 overflow-y-auto"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
AI анализирует...
</>
) : (
<>
<Search className="w-4 h-4" />
Получить консультацию AI
</>
)}
</button>
<div className="flex flex-col items-center justify-center min-h-full px-4 sm:px-6 py-8">
<div className="w-full max-w-2xl">
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className="text-center mb-8">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl active-gradient flex items-center justify-center">
<HeartPulse className="w-8 h-8" />
</div>
<h1 className="text-2xl sm:text-3xl font-bold text-primary tracking-tight">
Медицинский <span className="text-gradient">консультант</span>
</h1>
<p className="text-sm text-secondary mt-2 max-w-xl mx-auto">
Опишите симптомы я помогу с диф-оценкой, подберу профильного врача, найду варианты записи и время приёма.
</p>
</motion.div>
{/* AI Response */}
{aiResponse && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="mt-4 p-4 bg-surface/50 border border-border/40 rounded-xl"
>
<div className="flex items-center gap-2 mb-2">
<Shield className="w-4 h-4 text-gradient" />
<span className="text-xs font-medium text-gradient">Рекомендация AI</span>
<div className="mb-4 p-3 bg-amber-500/5 border border-amber-500/20 rounded-xl">
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
<p className="text-xs text-muted">
Информация носит справочный характер. Таблетки и дозировки не назначаются. При сильной боли, одышке, потере сознания 103/112.
</p>
</div>
</div>
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }}>
<div className="bg-elevated/60 backdrop-blur-xl border border-border/50 rounded-2xl overflow-hidden">
<div className="px-4 pt-4">
<label className="text-ui-sm text-muted flex items-center gap-1.5 mb-2">
<MapPin className="w-3.5 h-3.5" />
Город для записи
</label>
<input
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="Москва"
className="w-full h-10 px-3 bg-surface/40 border border-border/40 rounded-lg text-sm text-primary"
/>
</div>
<div className="flex items-end gap-3 p-4">
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onInput={handleInput}
onKeyDown={handleKeyDown}
placeholder="Опишите симптомы и жалобы..."
className="flex-1 bg-transparent text-base text-primary resize-none focus:outline-none min-h-[28px] max-h-[120px] placeholder:text-muted leading-relaxed"
rows={1}
/>
<button
onClick={handleSend}
disabled={!inputValue.trim()}
className={`w-10 h-10 flex items-center justify-center rounded-xl transition-all ${
inputValue.trim() ? 'btn-gradient text-accent' : 'bg-surface/50 text-muted border border-border/50'
}`}
aria-label="Отправить"
>
<ArrowUp className="w-5 h-5" />
</button>
</div>
</div>
</motion.div>
<div className="mt-6 grid grid-cols-2 gap-2">
{quickPrompts.map((prompt, i) => (
<motion.button
key={prompt.text}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 + i * 0.05 }}
onClick={() => sendMessage(prompt.query, city)}
className="flex items-center gap-2 px-4 py-3 bg-elevated/40 border border-border/40 rounded-xl hover:border-border hover:bg-elevated/60 transition-all text-left"
>
<span className="text-xl">{prompt.icon}</span>
<span className="text-sm text-secondary">{prompt.text}</span>
</motion.button>
))}
</div>
</div>
<p className="text-sm text-secondary leading-relaxed">{aiResponse}</p>
</motion.div>
)}
</div>
{/* Quick Services */}
<div className="grid grid-cols-4 gap-2 sm:gap-3 mb-6 sm:mb-8">
{quickServices.map((service) => (
<button
key={service.label}
className={`flex flex-col items-center gap-2 p-3 sm:p-4 rounded-xl border border-border/40 hover:border-border transition-all ${service.color}`}
>
<service.icon className="w-5 h-5 sm:w-6 sm:h-6" />
<span className="text-[10px] sm:text-xs font-medium text-center leading-tight">
{service.label}
</span>
</button>
))}
</div>
{/* Common Symptoms */}
<div className="mb-6 sm:mb-8">
<div className="flex items-center gap-3 mb-4">
<div className="w-9 h-9 rounded-xl bg-red-500/10 flex items-center justify-center">
<HeartPulse className="w-4 h-4 text-red-600" />
</div>
<h2 className="text-sm font-medium text-primary">Частые симптомы</h2>
</div>
</motion.div>
) : (
<motion.div key="chat" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex flex-col h-full">
<div className="flex-1 flex overflow-hidden">
<div className="flex flex-col w-full lg:w-[48%] min-w-0">
<div className="flex-1 overflow-y-auto px-3 sm:px-4 py-4">
<div className="space-y-4">
{messages.map((message, i) => (
<motion.div
key={message.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.02 }}
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
{message.role === 'user' ? (
<div className="max-w-[85%] px-3 py-2 bg-accent/20 border border-accent/30 rounded-xl rounded-br-md">
<p className="text-ui text-primary whitespace-pre-wrap">{message.content}</p>
</div>
) : (
<AssistantMessage message={message} />
)}
</motion.div>
))}
<div ref={messagesEndRef} />
</div>
</div>
<div className="grid grid-cols-2 gap-2">
{commonSymptoms.map((symptom) => (
<SymptomButton
key={symptom.id}
symptom={symptom}
onClick={() => handleSymptomClick(symptom.name)}
/>
))}
</div>
</div>
{(allWidgets.length > 0 || isLoading) && showPanel && (
<div className="lg:hidden border-t border-border/30 bg-base/80 backdrop-blur-sm h-[46vh] min-h-[260px] max-h-[560px]">
<MedicineWidgetTabs widgets={allWidgets} isLoading={isLoading} />
</div>
)}
{/* Health Articles */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-9 h-9 rounded-xl bg-green-500/10 flex items-center justify-center">
<FileText className="w-4 h-4 text-green-600" />
<div className="px-3 sm:px-4 pb-3 pt-2 bg-base/95">
<div className="flex items-center gap-1.5 mb-2 flex-wrap">
<button
onClick={() => setShowPanel((v) => !v)}
className={`flex items-center gap-1.5 px-2.5 py-1 text-ui-sm rounded-lg transition-colors ${
showPanel ? 'bg-accent/20 text-accent' : 'bg-surface/50 text-secondary'
}`}
>
Панель врача
</button>
<button
onClick={() => {
clearChat();
setInputValue('');
}}
className="flex items-center gap-1.5 px-2.5 py-1 text-ui-sm bg-surface/50 text-secondary rounded-lg hover:text-primary transition-colors"
>
<Plus className="w-3 h-3" />
Новый
</button>
</div>
<div className="bg-elevated/60 backdrop-blur-xl border border-border/50 rounded-xl overflow-hidden">
<div className="flex items-end gap-2 p-3">
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleInput}
placeholder="Уточните симптомы или попросите запись к врачу..."
className="flex-1 bg-transparent text-sm text-primary resize-none focus:outline-none min-h-[24px] max-h-[100px] placeholder:text-muted leading-relaxed"
rows={1}
disabled={isLoading}
/>
{isLoading ? (
<button
onClick={stopGeneration}
className="w-9 h-9 flex items-center justify-center rounded-lg bg-error/10 text-error border border-error/30"
aria-label="Остановить"
>
<X className="w-4 h-4" />
</button>
) : (
<button
onClick={handleSend}
disabled={!inputValue.trim()}
className={`w-9 h-9 flex items-center justify-center rounded-lg transition-all ${
inputValue.trim() ? 'btn-gradient text-accent' : 'bg-surface/50 text-muted border border-border/50'
}`}
aria-label="Отправить"
>
<ArrowUp className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
</div>
<div className="hidden lg:block w-[52%] border-l border-border/30">
<div className="h-full p-3">
{allWidgets.length > 0 || isLoading ? (
<MedicineWidgetTabs widgets={allWidgets} isLoading={isLoading} />
) : (
<div className="h-full rounded-xl border border-border/40 bg-surface/20 flex flex-col items-center justify-center gap-3 p-4">
<HeartPulse className="w-8 h-8 text-muted/30" />
<p className="text-sm text-muted text-center">Панель диагностики появится<br />после первого ответа</p>
</div>
)}
</div>
</div>
</div>
<h2 className="text-sm font-medium text-primary">Полезные статьи</h2>
</div>
<div className="space-y-2">
{healthArticles.map((article, i) => (
<ArticleCard key={article.id} article={article} delay={i * 0.05} />
))}
</div>
</div>
{/* Emergency Info */}
<div className="bg-red-500/5 border border-red-500/20 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-4 h-4 text-red-600" />
<span className="text-sm font-medium text-red-600">Экстренная помощь</span>
</div>
<p className="text-sm text-muted mb-3">
При угрозе жизни немедленно вызывайте скорую помощь
</p>
<div className="flex gap-3">
<a
href="tel:103"
className="flex-1 h-10 flex items-center justify-center gap-2 bg-red-500/10 border border-red-500/30 text-red-600 font-medium text-sm rounded-lg hover:bg-red-500/20 transition-colors"
>
📞 103
</a>
<a
href="tel:112"
className="flex-1 h-10 flex items-center justify-center gap-2 bg-red-500/10 border border-red-500/30 text-red-600 font-medium text-sm rounded-lg hover:bg-red-500/20 transition-colors"
>
📞 112
</a>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -240,7 +240,7 @@ export default function SpacesPage() {
{member.avatar ? (
<img src={member.avatar} alt="" className="w-full h-full rounded-full object-cover" />
) : (
<span className="text-[10px] font-medium text-secondary">
<span className="text-2xs font-medium text-secondary">
{(member.name || member.email || '?').charAt(0).toUpperCase()}
</span>
)}
@@ -248,7 +248,7 @@ export default function SpacesPage() {
))}
{(space.memberCount || 0) > 4 && (
<div className="w-7 h-7 rounded-full bg-surface border-2 border-base flex items-center justify-center">
<span className="text-[10px] font-medium text-muted">
<span className="text-2xs font-medium text-muted">
+{(space.memberCount || 0) - 4}
</span>
</div>

View File

@@ -6,19 +6,22 @@ import {
Plane,
MapPin,
Calendar,
Sparkles,
Loader2,
X,
ArrowUp,
Bookmark,
Map as MapIcon,
Plus,
ChevronUp,
Columns2,
PanelBottom,
} from 'lucide-react';
import { useTravelChat, type TravelChatMessage } from '@/lib/hooks/useTravelChat';
import { TravelWidgetRenderer } from '@/components/TravelWidgets';
import { useTravelChat, type TravelChatMessage, type TravelWidget } from '@/lib/hooks/useTravelChat';
import { TravelWidgetTabs } from '@/components/TravelWidgetTabs';
import { TravelMap } from '@/components/TravelMap';
import { fetchTrips, createTrip, deleteTrip } from '@/lib/api';
import type { Trip, RoutePoint, GeoLocation, EventCard, POICard, HotelCard, TransportOption } from '@/lib/types';
import type { Trip, RoutePoint, GeoLocation, EventCard, POICard, HotelCard, TransportOption, ItineraryDay } from '@/lib/types';
import type { LLMValidationResponse } from '@/lib/hooks/useEditableItinerary';
import ReactMarkdown from 'react-markdown';
const quickPrompts = [
@@ -80,86 +83,28 @@ function TripCard({ trip, onClick, onDelete }: TripCardProps) {
);
}
interface AssistantMessageProps {
message: TravelChatMessage;
onAddEventToMap: (event: EventCard) => void;
onAddPOIToMap: (poi: POICard) => void;
onSelectHotel: (hotel: HotelCard) => void;
onSelectTransport: (option: TransportOption) => void;
onClarifyingAnswer: (field: string, value: string) => void;
onAction: (kind: string) => void;
selectedEventIds: Set<string>;
selectedPOIIds: Set<string>;
selectedHotelId?: string;
selectedTransportId?: string;
}
function AssistantMessage({
message,
onAddEventToMap,
onAddPOIToMap,
onSelectHotel,
onSelectTransport,
onClarifyingAnswer,
onAction,
selectedEventIds,
selectedPOIIds,
selectedHotelId,
selectedTransportId,
}: AssistantMessageProps) {
const travelWidgets = useMemo(
() => message.widgets.filter((w) => w.type.startsWith('travel_')),
[message.widgets]
);
function AssistantMessage({ message }: { message: TravelChatMessage }) {
return (
<div className="max-w-full w-full">
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-lg active-gradient flex items-center justify-center flex-shrink-0">
<Sparkles className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
{message.content && (
<div className="prose prose-sm prose-invert max-w-none">
<ReactMarkdown>{message.content}</ReactMarkdown>
</div>
)}
{message.isStreaming && !message.content && (
<div className="flex items-center gap-2 mt-2">
<Loader2 className="w-4 h-4 animate-spin text-accent" />
<span className="text-xs text-muted">Планирую маршрут...</span>
</div>
)}
</div>
<div className="min-w-0">
{message.content && (
<div className="prose prose-sm prose-invert max-w-none text-ui">
<ReactMarkdown>{message.content}</ReactMarkdown>
</div>
)}
{message.isStreaming && !message.content && (
<div className="flex items-center gap-2 mt-1">
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
<span className="text-xs text-muted">Планирую маршрут...</span>
</div>
)}
{message.isStreaming && message.content && (
<div className="flex items-center gap-2 mt-2">
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
<span className="text-xs text-muted">Собираю данные...</span>
</div>
)}
</div>
{travelWidgets.length > 0 && (
<div className="mt-4 ml-11 space-y-3">
{travelWidgets.map((widget) => (
<TravelWidgetRenderer
key={widget.id}
widget={widget}
onAddEventToMap={onAddEventToMap}
onAddPOIToMap={onAddPOIToMap}
onSelectHotel={onSelectHotel}
onSelectTransport={onSelectTransport}
onClarifyingAnswer={onClarifyingAnswer}
onAction={onAction}
selectedEventIds={selectedEventIds}
selectedPOIIds={selectedPOIIds}
selectedHotelId={selectedHotelId}
selectedTransportId={selectedTransportId}
/>
))}
</div>
)}
{message.isStreaming && message.content && (
<div className="flex items-center gap-2 mt-3 ml-11">
<Loader2 className="w-4 h-4 animate-spin text-accent" />
<span className="text-xs text-muted">Собираю данные...</span>
</div>
)}
</div>
);
}
@@ -175,10 +120,6 @@ export default function TravelPage() {
budget: 0,
});
const [showOptions, setShowOptions] = useState(false);
const [selectedEventIds, setSelectedEventIds] = useState<Set<string>>(new Set());
const [selectedPOIIds, setSelectedPOIIds] = useState<Set<string>>(new Set());
const [selectedHotelId, setSelectedHotelId] = useState<string>();
const [selectedTransportId, setSelectedTransportId] = useState<string>();
const [userLocation, setUserLocation] = useState<GeoLocation | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -202,24 +143,67 @@ export default function TravelPage() {
messages,
isLoading,
isResearching,
loadingPhase,
currentRoute,
routeDirection,
routeSegments,
pois: availablePois,
events: availableEvents,
selectedEventIds,
selectedPOIIds,
selectedHotelId,
selectedTransportId,
sendMessage,
stopGeneration,
clearChat,
addRoutePoint,
answerClarifying,
handleAction,
addEventToRoute,
addPOIToRoute,
selectHotelOnRoute,
toggleEventSelection,
togglePOISelection,
toggleHotelSelection,
toggleTransportSelection,
} = useTravelChat({
onRouteUpdate: (route) => {
if (route.length > 0) setShowMap(true);
},
});
const handleItineraryUpdate = useCallback((days: ItineraryDay[]) => {
sendMessage(
`_clarify:Пользователь отредактировал маршрут. Вот обновлённый маршрут:\n${JSON.stringify(days, null, 2)}\n\ожалуйста, пересчитай бюджет и обнови маршрут.`,
);
}, [sendMessage]);
const handleValidateItineraryWithLLM = useCallback(
async (days: ItineraryDay[]): Promise<LLMValidationResponse | null> => {
try {
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
const headers: HeadersInit = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const response = await fetch(`${API_BASE}/api/v1/travel/validate-itinerary`, {
method: 'POST',
headers,
body: JSON.stringify({
days,
pois: availablePois,
events: availableEvents,
}),
});
if (!response.ok) {
return null;
}
return await response.json() as LLMValidationResponse;
} catch {
return null;
}
},
[availablePois, availableEvents],
);
useEffect(() => {
loadTrips();
}, []);
@@ -264,42 +248,20 @@ export default function TravelPage() {
}, [sendMessage, planOptions, userLocation]);
const handleAddEventToMap = useCallback((event: EventCard) => {
setSelectedEventIds((prev) => {
const next = new Set(prev);
if (next.has(event.id)) {
next.delete(event.id);
} else {
next.add(event.id);
addEventToRoute(event);
}
return next;
});
}, [addEventToRoute]);
toggleEventSelection(event);
}, [toggleEventSelection]);
const handleAddPOIToMap = useCallback((poi: POICard) => {
setSelectedPOIIds((prev) => {
const next = new Set(prev);
if (next.has(poi.id)) {
next.delete(poi.id);
} else {
next.add(poi.id);
addPOIToRoute(poi);
}
return next;
});
}, [addPOIToRoute]);
togglePOISelection(poi);
}, [togglePOISelection]);
const handleSelectHotel = useCallback((hotel: HotelCard) => {
setSelectedHotelId((prev) => {
const newId = prev === hotel.id ? undefined : hotel.id;
if (newId) selectHotelOnRoute(hotel);
return newId;
});
}, [selectHotelOnRoute]);
toggleHotelSelection(hotel);
}, [toggleHotelSelection]);
const handleSelectTransport = useCallback((option: TransportOption) => {
setSelectedTransportId((prev) => (prev === option.id ? undefined : option.id));
}, []);
toggleTransportSelection(option);
}, [toggleTransportSelection]);
const handleMapClick = useCallback((location: GeoLocation) => {
const point: RoutePoint = {
@@ -367,8 +329,35 @@ export default function TravelPage() {
const hasMessages = messages.length > 0;
const allWidgets = useMemo((): TravelWidget[] => {
const widgetMap = new Map<string, TravelWidget>();
for (const msg of messages) {
if (msg.role !== 'assistant') continue;
for (const w of msg.widgets) {
if (w.type.startsWith('travel_')) {
widgetMap.set(w.type, w);
}
}
}
return Array.from(widgetMap.values());
}, [messages]);
const hasTravelWidgets = allWidgets.length > 0;
const [showWidgetPanel, setShowWidgetPanel] = useState(true);
const [isDesktop, setIsDesktop] = useState(false);
const [rightPanelMode, setRightPanelMode] = useState<'map' | 'split' | 'panel'>('map');
useEffect(() => {
if (typeof window === 'undefined') return;
const mq = window.matchMedia('(min-width: 1024px)');
const apply = () => setIsDesktop(mq.matches);
apply();
mq.addEventListener?.('change', apply);
return () => mq.removeEventListener?.('change', apply);
}, []);
return (
<div className="flex flex-col h-full bg-gradient-main">
<div className="relative z-0 flex flex-col h-full bg-gradient-main">
<AnimatePresence mode="wait">
{!hasMessages ? (
<motion.div
@@ -411,7 +400,7 @@ export default function TravelPage() {
onKeyDown={handleKeyDown}
onInput={handleInput}
placeholder="Опишите своё идеальное путешествие..."
className="flex-1 bg-transparent text-[15px] text-primary resize-none focus:outline-none min-h-[28px] max-h-[120px] placeholder:text-muted leading-relaxed"
className="flex-1 bg-transparent text-base text-primary resize-none focus:outline-none min-h-[28px] max-h-[120px] placeholder:text-muted leading-relaxed"
rows={1}
autoFocus
/>
@@ -563,44 +552,23 @@ export default function TravelPage() {
className="flex flex-col h-full"
>
<div className="flex-1 flex overflow-hidden">
<div className={`flex flex-col min-w-0 ${showMap ? 'w-full lg:w-1/2' : 'w-full'}`}>
{isResearching && (
<div className="px-4 py-2 bg-accent/5 border-b border-accent/20">
<div className="flex items-center gap-2 max-w-3xl mx-auto">
<Loader2 className="w-4 h-4 animate-spin text-accent" />
<span className="text-xs text-accent">Исследую маршруты, мероприятия и отели...</span>
</div>
</div>
)}
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-6">
<div className="max-w-3xl mx-auto space-y-6">
<div className={`flex flex-col min-w-0 ${showMap ? 'w-full lg:w-[45%]' : 'w-full'}`}>
<div className="flex-1 overflow-y-auto px-3 sm:px-4 py-4">
<div className="space-y-4">
{messages.map((message, i) => (
<motion.div
key={message.id}
initial={{ opacity: 0, y: 12 }}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.02 }}
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
{message.role === 'user' ? (
<div className="max-w-[85%] px-4 py-3 bg-accent/20 border border-accent/30 rounded-2xl rounded-br-md">
<p className="text-sm text-primary whitespace-pre-wrap">{message.content}</p>
<div className="max-w-[85%] px-3 py-2 bg-accent/20 border border-accent/30 rounded-xl rounded-br-md">
<p className="text-ui text-primary whitespace-pre-wrap">{message.content}</p>
</div>
) : (
<AssistantMessage
message={message}
onAddEventToMap={handleAddEventToMap}
onAddPOIToMap={handleAddPOIToMap}
onSelectHotel={handleSelectHotel}
onSelectTransport={handleSelectTransport}
onClarifyingAnswer={answerClarifying}
onAction={handleAction}
selectedEventIds={selectedEventIds}
selectedPOIIds={selectedPOIIds}
selectedHotelId={selectedHotelId}
selectedTransportId={selectedTransportId}
/>
<AssistantMessage message={message} />
)}
</motion.div>
))}
@@ -608,92 +576,139 @@ export default function TravelPage() {
</div>
</div>
<div className="sticky bottom-0 px-4 sm:px-6 pb-4 pt-4 bg-gradient-to-t from-base via-base/95 to-transparent">
<div className="max-w-3xl mx-auto">
<div className="flex items-center gap-2 mb-3">
{currentRoute.length > 0 && (
<button
onClick={handleSaveTrip}
disabled={saveStatus === 'saving'}
className={`flex items-center gap-2 px-3 py-1.5 text-xs rounded-lg transition-colors ${
saveStatus === 'saved'
? 'bg-green-500/20 text-green-600'
: saveStatus === 'error'
? 'bg-error/20 text-error'
: 'bg-accent/20 text-accent hover:bg-accent/30'
}`}
>
{saveStatus === 'saving' ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Bookmark className="w-3.5 h-3.5" />
)}
{saveStatus === 'saved' ? 'Сохранено!' : saveStatus === 'error' ? 'Ошибка' : 'Сохранить поездку'}
</button>
)}
{/* Mobile: keep widgets under chat */}
{hasTravelWidgets && showWidgetPanel && (
<div className="lg:hidden border-t border-border/30 bg-base/80 backdrop-blur-sm">
<div className="h-[45vh] min-h-[260px] max-h-[520px]">
<TravelWidgetTabs
widgets={allWidgets}
onAddEventToMap={handleAddEventToMap}
onAddPOIToMap={handleAddPOIToMap}
onSelectHotel={handleSelectHotel}
onSelectTransport={handleSelectTransport}
onClarifyingAnswer={answerClarifying}
onAction={handleAction}
selectedEventIds={selectedEventIds}
selectedPOIIds={selectedPOIIds}
selectedHotelId={selectedHotelId}
selectedTransportId={selectedTransportId}
availablePois={availablePois}
availableEvents={availableEvents}
onItineraryUpdate={handleItineraryUpdate}
onValidateItineraryWithLLM={handleValidateItineraryWithLLM}
isLoading={isLoading}
loadingPhase={loadingPhase}
isResearching={isResearching}
routePointCount={currentRoute.length}
hasRouteDirection={Boolean(routeDirection)}
/>
</div>
</div>
)}
<div className="px-3 sm:px-4 pb-3 pt-2 bg-base/95">
<div className="flex items-center gap-1.5 mb-2 flex-wrap">
{currentRoute.length > 0 && (
<button
onClick={() => setShowMap(!showMap)}
className={`flex items-center gap-2 px-3 py-1.5 text-xs rounded-lg transition-colors ${
showMap ? 'bg-accent/20 text-accent' : 'bg-surface/50 text-secondary'
onClick={handleSaveTrip}
disabled={saveStatus === 'saving'}
className={`flex items-center gap-1.5 px-2.5 py-1 text-ui-sm rounded-lg transition-colors ${
saveStatus === 'saved'
? 'bg-green-500/20 text-green-600'
: saveStatus === 'error'
? 'bg-error/20 text-error'
: 'bg-accent/20 text-accent hover:bg-accent/30'
}`}
>
<MapIcon className="w-3.5 h-3.5" />
{showMap ? 'Скрыть карту' : 'Показать карту'}
{currentRoute.length > 0 && (
<span className="ml-1 px-1.5 py-0.5 bg-accent/30 rounded text-[10px]">
{currentRoute.length}
</span>
{saveStatus === 'saving' ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Bookmark className="w-3 h-3" />
)}
{saveStatus === 'saved' ? 'Сохранено' : saveStatus === 'error' ? 'Ошибка' : 'Сохранить'}
</button>
)}
<button
onClick={() => setShowMap(!showMap)}
className={`flex items-center gap-1.5 px-2.5 py-1 text-ui-sm rounded-lg transition-colors ${
showMap ? 'bg-accent/20 text-accent' : 'bg-surface/50 text-secondary'
}`}
>
<MapIcon className="w-3 h-3" />
Карта
{currentRoute.length > 0 && (
<span className="px-1 py-0.5 bg-accent/30 rounded text-3xs">
{currentRoute.length}
</span>
)}
</button>
{hasTravelWidgets && (
<button
onClick={() => {
clearChat();
setInputValue('');
setSaveStatus('idle');
if (isDesktop) {
setRightPanelMode((prev) => (prev === 'map' ? 'split' : prev === 'split' ? 'panel' : 'map'));
} else {
setShowWidgetPanel(!showWidgetPanel);
}
}}
className="flex items-center gap-2 px-3 py-1.5 text-xs bg-surface/50 text-secondary rounded-lg hover:text-primary transition-colors"
className={`flex items-center gap-1.5 px-2.5 py-1 text-ui-sm rounded-lg transition-colors ${
(isDesktop ? rightPanelMode !== 'map' : showWidgetPanel)
? 'bg-accent/20 text-accent'
: 'bg-surface/50 text-secondary'
}`}
>
<Plus className="w-3.5 h-3.5" />
Новый план
<Calendar className="w-3 h-3" />
Панель
</button>
</div>
)}
<button
onClick={() => {
clearChat();
setInputValue('');
setSaveStatus('idle');
}}
className="flex items-center gap-1.5 px-2.5 py-1 text-ui-sm bg-surface/50 text-secondary rounded-lg hover:text-primary transition-colors"
>
<Plus className="w-3 h-3" />
Новый
</button>
</div>
<div className="bg-elevated/60 backdrop-blur-xl border border-border/50 rounded-2xl overflow-hidden">
<div className="flex items-end gap-3 p-4">
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleInput}
placeholder="Уточните детали или задайте вопрос..."
className="flex-1 bg-transparent text-[15px] text-primary resize-none focus:outline-none min-h-[28px] max-h-[120px] placeholder:text-muted leading-relaxed"
rows={1}
disabled={isLoading}
/>
{isLoading ? (
<button
onClick={stopGeneration}
className="w-10 h-10 flex items-center justify-center rounded-xl bg-error/10 text-error border border-error/30"
aria-label="Остановить"
>
<X className="w-5 h-5" />
</button>
) : (
<button
onClick={handleSend}
disabled={!inputValue.trim()}
className={`w-10 h-10 flex items-center justify-center rounded-xl transition-all ${
inputValue.trim()
? 'btn-gradient text-accent'
: 'bg-surface/50 text-muted border border-border/50'
}`}
aria-label="Отправить"
>
<ArrowUp className="w-5 h-5" />
</button>
)}
</div>
<div className="bg-elevated/60 backdrop-blur-xl border border-border/50 rounded-xl overflow-hidden">
<div className="flex items-end gap-2 p-3">
<textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleInput}
placeholder="Уточните детали или задайте вопрос..."
className="flex-1 bg-transparent text-sm text-primary resize-none focus:outline-none min-h-[24px] max-h-[100px] placeholder:text-muted leading-relaxed"
rows={1}
disabled={isLoading}
/>
{isLoading ? (
<button
onClick={stopGeneration}
className="w-9 h-9 flex items-center justify-center rounded-lg bg-error/10 text-error border border-error/30"
aria-label="Остановить"
>
<X className="w-4 h-4" />
</button>
) : (
<button
onClick={handleSend}
disabled={!inputValue.trim()}
className={`w-9 h-9 flex items-center justify-center rounded-lg transition-all ${
inputValue.trim()
? 'btn-gradient text-accent'
: 'bg-surface/50 text-muted border border-border/50'
}`}
aria-label="Отправить"
>
<ArrowUp className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
@@ -701,16 +716,109 @@ export default function TravelPage() {
<div
className={`hidden lg:block border-l border-border/30 transition-all duration-300 ${
showMap ? 'w-1/2 opacity-100' : 'w-0 opacity-0 overflow-hidden'
showMap ? 'w-[55%] opacity-100' : 'w-0 opacity-0 overflow-hidden'
}`}
>
<TravelMap
route={currentRoute}
routeDirection={routeDirection ?? undefined}
onMapClick={handleMapClick}
className="h-full"
userLocation={userLocation}
/>
<div className="relative h-full">
<TravelMap
route={currentRoute}
routeDirection={routeDirection ?? undefined}
onMapClick={handleMapClick}
className="h-full"
userLocation={userLocation}
/>
{/* Desktop: widgets drawer over the map (3 modes) */}
{hasTravelWidgets && (
<>
<div className="absolute top-3 right-3 z-20 flex items-center gap-1 bg-base border border-border/50 rounded-xl p-1 shadow-sm">
<button
type="button"
onClick={() => setRightPanelMode('map')}
className={`px-2.5 py-1.5 text-ui-sm rounded-lg transition-colors ${
rightPanelMode === 'map' ? 'bg-accent/20 text-accent' : 'text-secondary hover:text-primary hover:bg-surface/30'
}`}
title="Карта"
aria-label="Карта"
>
<MapIcon className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => setRightPanelMode('split')}
className={`px-2.5 py-1.5 text-ui-sm rounded-lg transition-colors ${
rightPanelMode === 'split' ? 'bg-accent/20 text-accent' : 'text-secondary hover:text-primary hover:bg-surface/30'
}`}
title="Карта + панель"
aria-label="Карта + панель"
>
<Columns2 className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => setRightPanelMode('panel')}
className={`px-2.5 py-1.5 text-ui-sm rounded-lg transition-colors ${
rightPanelMode === 'panel' ? 'bg-accent/20 text-accent' : 'text-secondary hover:text-primary hover:bg-surface/30'
}`}
title="Панель"
aria-label="Панель"
>
<PanelBottom className="w-3.5 h-3.5" />
</button>
</div>
{rightPanelMode !== 'map' && (
<div
className={`absolute left-3 right-3 bottom-3 z-10 transition-all duration-200 ${
rightPanelMode === 'panel' ? 'top-3' : ''
}`}
>
<div
className={`h-full flex flex-col ${
rightPanelMode === 'split' ? 'h-[42%] min-h-[260px] max-h-[520px]' : ''
}`}
>
<button
type="button"
onClick={() => setRightPanelMode((m) => (m === 'split' ? 'panel' : 'split'))}
className="mb-2 w-full flex items-center justify-between px-3 py-2 text-xs rounded-xl bg-base border border-border/50 hover:border-accent/30 transition-colors shadow-sm"
aria-label="Изменить размер панели"
title="Изменить размер панели"
>
<span className="text-secondary">Панель этапов</span>
<ChevronUp className={`w-4 h-4 text-muted transition-transform ${rightPanelMode === 'panel' ? '' : 'rotate-180'}`} />
</button>
<div className="flex-1 min-h-0">
<TravelWidgetTabs
widgets={allWidgets}
onAddEventToMap={handleAddEventToMap}
onAddPOIToMap={handleAddPOIToMap}
onSelectHotel={handleSelectHotel}
onSelectTransport={handleSelectTransport}
onClarifyingAnswer={answerClarifying}
onAction={handleAction}
selectedEventIds={selectedEventIds}
selectedPOIIds={selectedPOIIds}
selectedHotelId={selectedHotelId}
selectedTransportId={selectedTransportId}
availablePois={availablePois}
availableEvents={availableEvents}
onItineraryUpdate={handleItineraryUpdate}
onValidateItineraryWithLLM={handleValidateItineraryWithLLM}
isLoading={isLoading}
loadingPhase={loadingPhase}
isResearching={isResearching}
routePointCount={currentRoute.length}
hasRouteDirection={Boolean(routeDirection)}
/>
</div>
</div>
</div>
)}
</>
)}
</div>
</div>
</div>
@@ -720,7 +828,7 @@ export default function TravelPage() {
route={currentRoute}
routeDirection={routeDirection ?? undefined}
onMapClick={handleMapClick}
className="h-[300px]"
className="h-[250px]"
userLocation={userLocation}
/>
</div>

View File

@@ -6,42 +6,36 @@
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
:root {
/* GooSeek Light Asphalt/Graphite Theme */
/* Base backgrounds — light grays with warm undertone */
--bg-base: 220 14% 96%;
--bg-elevated: 220 13% 100%;
--bg-surface: 220 13% 93%;
--bg-overlay: 220 12% 89%;
--bg-muted: 220 10% 85%;
/* Text colors — graphite/charcoal for readability */
--text-primary: 220 15% 16%;
--text-secondary: 220 10% 38%;
--text-muted: 220 8% 54%;
--text-faint: 220 6% 68%;
/* Accent colors — slate-blue, professional */
--accent: 224 64% 48%;
--accent-hover: 224 64% 42%;
--accent-muted: 224 45% 58%;
--accent-subtle: 224 30% 90%;
/* Secondary accent — teal for variety */
--accent-secondary: 180 50% 38%;
--accent-secondary-muted: 180 35% 52%;
/* Semantic colors */
--success: 152 60% 38%;
--success-muted: 152 40% 48%;
--warning: 38 85% 48%;
--warning-muted: 38 60% 55%;
--error: 0 65% 50%;
--error-muted: 0 50% 58%;
/* Border colors */
--border: 220 12% 86%;
--border-hover: 220 12% 78%;
--border-focus: 224 64% 48%;
/* GitHub Light (approx. 1:1 tokens) */
--bg-base: 210 29% 97%; /* #f6f8fa */
--bg-elevated: 0 0% 100%; /* #ffffff */
--bg-surface: 210 29% 97%; /* #f6f8fa */
--bg-overlay: 0 0% 100%; /* #ffffff */
--bg-muted: 210 22% 93%; /* ~#eef1f4 */
--text-primary: 215 19% 17%; /* #24292f */
--text-secondary: 215 14% 33%; /* #57606a */
--text-muted: 215 10% 45%; /* #6e7781 */
--text-faint: 214 10% 60%; /* #8c959f */
--accent: 213 92% 44%; /* #0969da */
--accent-hover: 213 95% 36%; /* ~#0550ae */
--accent-muted: 212 92% 64%; /* ~#54aeff */
--accent-subtle: 199 100% 92%;/* ~#ddf4ff */
--accent-secondary: 210 69% 38%;
--accent-secondary-muted: 210 60% 52%;
--success: 140 64% 30%; /* #1a7f37 */
--success-muted: 140 48% 38%;
--warning: 36 100% 30%; /* ~#9a6700 */
--warning-muted: 36 85% 38%;
--error: 355 74% 47%; /* #cf222e */
--error-muted: 355 60% 55%;
--border: 210 14% 84%; /* #d0d7de */
--border-hover: 213 10% 72%; /* #afb8c1 */
--border-focus: 213 92% 44%;
/* Legacy mappings for compatibility */
--background: var(--bg-base);
@@ -75,16 +69,60 @@ html {
color-scheme: light;
}
html.theme-dim {
color-scheme: dark;
/* GitHub Dark Dimmed (approx. 1:1 tokens) */
--bg-base: 210 13% 15%; /* #22272e */
--bg-elevated: 210 12% 20%; /* #2d333b */
--bg-surface: 210 12% 20%; /* #2d333b */
--bg-overlay: 210 10% 25%; /* #373e47 */
--bg-muted: 210 15% 13%; /* #1c2128 */
--text-primary: 210 17% 73%; /* #adbac7 */
--text-secondary: 211 13% 49%; /* #768390 */
--text-muted: 212 11% 41%; /* ~#636e7b */
--text-faint: 213 11% 33%; /* ~#545d68 */
--accent: 212 89% 64%; /* #539bf5 */
--accent-hover: 212 74% 58%; /* ~#4184e4 */
--accent-muted: 212 82% 70%;
--accent-subtle: 214 53% 20%; /* ~#143d79 */
--accent-secondary: 199 80% 55%;
--accent-secondary-muted: 199 70% 62%;
--success: 135 52% 48%; /* ~#57ab5a */
--success-muted: 135 42% 56%;
--warning: 39 75% 56%; /* ~#c69026 */
--warning-muted: 39 65% 62%;
--error: 0 75% 58%; /* ~#e5534b */
--error-muted: 0 65% 64%;
--border: 212 12% 30%; /* #444c56 */
--border-hover: 213 11% 36%; /* #545d68 */
--border-focus: 212 89% 64%;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background: hsl(var(--bg-base));
color: hsl(var(--text-primary));
font-size: 0.9375rem; /* match Tailwind text-base override (15px) */
line-height: 1.5rem;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
letter-spacing: -0.01em;
}
button,
input,
textarea,
select {
font: inherit;
}
/* ========================
Gradient backgrounds
======================== */
@@ -92,35 +130,35 @@ body {
.bg-gradient-main {
background: linear-gradient(
160deg,
hsl(220 16% 96%) 0%,
hsl(220 14% 94%) 30%,
hsl(225 14% 92%) 60%,
hsl(220 12% 95%) 100%
hsl(var(--bg-base)) 0%,
hsl(var(--bg-surface)) 30%,
hsl(var(--bg-muted)) 60%,
hsl(var(--bg-base)) 100%
);
}
.bg-gradient-elevated {
background: linear-gradient(
145deg,
hsl(0 0% 100%) 0%,
hsl(220 14% 97%) 100%
hsl(var(--bg-elevated)) 0%,
hsl(var(--bg-base)) 100%
);
}
.bg-gradient-card {
background: linear-gradient(
145deg,
hsl(0 0% 100% / 0.9) 0%,
hsl(220 14% 96% / 0.7) 100%
hsl(var(--bg-elevated) / 0.92) 0%,
hsl(var(--bg-base) / 0.72) 100%
);
}
.bg-gradient-accent {
background: linear-gradient(
135deg,
hsl(224 64% 48%) 0%,
hsl(240 55% 52%) 50%,
hsl(224 64% 48%) 100%
hsl(var(--accent)) 0%,
hsl(var(--accent-hover)) 50%,
hsl(var(--accent)) 100%
);
}
@@ -131,9 +169,9 @@ body {
.text-gradient {
background: linear-gradient(
135deg,
hsl(224 64% 42%) 0%,
hsl(240 55% 48%) 50%,
hsl(180 50% 38%) 100%
hsl(var(--accent-hover)) 0%,
hsl(var(--accent)) 55%,
hsl(var(--accent-secondary)) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
@@ -152,7 +190,7 @@ body {
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: hsl(224 64% 42%);
color: hsl(var(--accent-hover));
transition: all 0.15s ease;
z-index: 1;
}
@@ -165,9 +203,9 @@ body {
padding: 1.5px;
background: linear-gradient(
135deg,
hsl(224 64% 48%) 0%,
hsl(240 55% 52%) 50%,
hsl(180 50% 42%) 100%
hsl(var(--accent)) 0%,
hsl(var(--accent)) 50%,
hsl(var(--accent-secondary)) 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
@@ -180,9 +218,9 @@ body {
.btn-gradient:hover {
background: linear-gradient(
135deg,
hsl(224 64% 48% / 0.08) 0%,
hsl(240 55% 52% / 0.06) 50%,
hsl(180 50% 42% / 0.05) 100%
hsl(var(--accent) / 0.08) 0%,
hsl(var(--accent) / 0.06) 55%,
hsl(var(--accent-secondary) / 0.05) 100%
);
}
@@ -198,7 +236,7 @@ body {
padding: 0.75rem 1.25rem;
font-size: 0.875rem;
font-weight: 500;
color: hsl(224 64% 42%);
color: hsl(var(--accent-hover));
transition: all 0.15s ease;
z-index: 1;
}
@@ -211,9 +249,9 @@ body {
padding: 2px;
background: linear-gradient(
135deg,
hsl(224 64% 48%) 0%,
hsl(240 55% 52%) 50%,
hsl(180 50% 42%) 100%
hsl(var(--accent)) 0%,
hsl(var(--accent)) 50%,
hsl(var(--accent-secondary)) 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
@@ -226,18 +264,18 @@ body {
.btn-gradient-lg:hover {
background: linear-gradient(
135deg,
hsl(224 64% 48% / 0.08) 0%,
hsl(240 55% 52% / 0.06) 50%,
hsl(180 50% 42% / 0.05) 100%
hsl(var(--accent) / 0.08) 0%,
hsl(var(--accent) / 0.06) 55%,
hsl(var(--accent-secondary) / 0.05) 100%
);
}
.btn-gradient-text {
background: linear-gradient(
135deg,
hsl(224 64% 42%) 0%,
hsl(240 55% 48%) 50%,
hsl(180 50% 38%) 100%
hsl(var(--accent-hover)) 0%,
hsl(var(--accent)) 55%,
hsl(var(--accent-secondary)) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
@@ -252,9 +290,9 @@ body {
position: relative;
background: linear-gradient(
135deg,
hsl(224 64% 48% / 0.08) 0%,
hsl(240 55% 52% / 0.06) 50%,
hsl(180 50% 42% / 0.04) 100%
hsl(var(--accent) / 0.08) 0%,
hsl(var(--accent) / 0.06) 55%,
hsl(var(--accent-secondary) / 0.04) 100%
);
}
@@ -266,9 +304,9 @@ body {
padding: 1px;
background: linear-gradient(
135deg,
hsl(224 64% 48% / 0.3) 0%,
hsl(240 55% 52% / 0.2) 50%,
hsl(180 50% 42% / 0.15) 100%
hsl(var(--accent) / 0.3) 0%,
hsl(var(--accent) / 0.2) 55%,
hsl(var(--accent-secondary) / 0.15) 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
@@ -290,9 +328,9 @@ body {
padding: 1px;
background: linear-gradient(
135deg,
hsl(224 64% 48% / 0.2) 0%,
hsl(240 55% 52% / 0.15) 50%,
hsl(180 50% 42% / 0.1) 100%
hsl(var(--accent) / 0.2) 0%,
hsl(var(--accent) / 0.15) 55%,
hsl(var(--accent-secondary) / 0.1) 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
@@ -312,9 +350,9 @@ body {
justify-content: center;
background: linear-gradient(
135deg,
hsl(224 64% 48% / 0.1) 0%,
hsl(240 55% 52% / 0.07) 50%,
hsl(180 50% 42% / 0.05) 100%
hsl(var(--accent) / 0.1) 0%,
hsl(var(--accent) / 0.07) 55%,
hsl(var(--accent-secondary) / 0.05) 100%
);
position: relative;
}
@@ -327,9 +365,9 @@ body {
padding: 1px;
background: linear-gradient(
135deg,
hsl(224 64% 48% / 0.2) 0%,
hsl(240 55% 52% / 0.15) 50%,
hsl(180 50% 42% / 0.1) 100%
hsl(var(--accent) / 0.2) 0%,
hsl(var(--accent) / 0.15) 55%,
hsl(var(--accent-secondary) / 0.1) 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
@@ -346,8 +384,8 @@ body {
.input-gradient:focus {
outline: none;
border-color: transparent;
box-shadow: 0 0 0 1px hsl(224 64% 48% / 0.35),
0 0 0 3px hsl(224 64% 48% / 0.08);
box-shadow: 0 0 0 1px hsl(var(--accent) / 0.35),
0 0 0 3px hsl(var(--accent) / 0.08);
}
/* ========================
@@ -355,36 +393,36 @@ body {
======================== */
.loader-gradient {
color: hsl(224 64% 48%);
filter: drop-shadow(0 0 6px hsl(224 64% 48% / 0.2));
color: hsl(var(--accent));
filter: drop-shadow(0 0 6px hsl(var(--accent) / 0.2));
}
.progress-gradient {
background: linear-gradient(
90deg,
hsl(224 64% 48%) 0%,
hsl(240 55% 52%) 50%,
hsl(180 50% 42%) 100%
hsl(var(--accent)) 0%,
hsl(var(--accent-hover)) 55%,
hsl(var(--accent-secondary)) 100%
);
}
.stat-gradient {
background: linear-gradient(
135deg,
hsl(224 64% 48% / 0.06) 0%,
hsl(240 55% 52% / 0.03) 100%
hsl(var(--accent) / 0.06) 0%,
hsl(var(--accent) / 0.03) 100%
);
border: 1px solid;
border-image: linear-gradient(
135deg,
hsl(224 64% 48% / 0.18) 0%,
hsl(180 50% 42% / 0.1) 100%
hsl(var(--accent) / 0.18) 0%,
hsl(var(--accent-secondary) / 0.1) 100%
) 1;
}
.glow-gradient {
box-shadow: 0 4px 16px hsl(224 64% 48% / 0.1),
0 1px 4px hsl(220 14% 50% / 0.08);
box-shadow: 0 4px 16px hsl(var(--accent) / 0.1),
0 1px 4px hsl(var(--text-muted) / 0.08);
}
/* ========================
@@ -405,9 +443,9 @@ body {
height: 60%;
background: linear-gradient(
180deg,
hsl(224 64% 48%) 0%,
hsl(240 55% 52%) 50%,
hsl(180 50% 42%) 100%
hsl(var(--accent)) 0%,
hsl(var(--accent)) 55%,
hsl(var(--accent-secondary)) 100%
);
border-radius: 1px;
}
@@ -426,12 +464,12 @@ body {
}
::-webkit-scrollbar-thumb {
background: hsl(220 10% 78%);
background: hsl(var(--border));
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(220 10% 66%);
background: hsl(var(--border-hover));
}
.scrollbar-hide {
@@ -445,7 +483,7 @@ body {
.poi-carousel {
scrollbar-width: thin;
scrollbar-color: hsl(220 10% 78% / 0.4) transparent;
scrollbar-color: hsl(var(--border) / 0.5) transparent;
}
.poi-carousel::-webkit-scrollbar {
@@ -457,17 +495,17 @@ body {
}
.poi-carousel::-webkit-scrollbar-thumb {
background: hsl(220 10% 78% / 0.4);
background: hsl(var(--border) / 0.5);
border-radius: 2px;
}
.poi-carousel::-webkit-scrollbar-thumb:hover {
background: hsl(220 10% 78% / 0.7);
background: hsl(var(--border-hover) / 0.7);
}
::selection {
background: hsl(224 64% 48% / 0.2);
color: hsl(220 15% 16%);
background: hsl(var(--accent) / 0.2);
color: hsl(var(--text-primary));
}
.font-mono {
@@ -483,20 +521,20 @@ body {
@layer components {
.glass-card {
@apply bg-elevated/80 backdrop-blur-xl border border-border rounded-2xl;
box-shadow: 0 1px 3px hsl(220 14% 50% / 0.06),
0 4px 12px hsl(220 14% 50% / 0.04);
box-shadow: 0 1px 3px hsl(var(--text-muted) / 0.1),
0 4px 12px hsl(var(--text-muted) / 0.06);
}
.surface-card {
@apply bg-elevated/70 backdrop-blur-sm border border-border/60 rounded-xl;
@apply transition-all duration-200;
box-shadow: 0 1px 2px hsl(220 14% 50% / 0.05);
box-shadow: 0 1px 2px hsl(var(--text-muted) / 0.1);
}
.surface-card:hover {
@apply border-border-hover bg-elevated/90;
box-shadow: 0 2px 8px hsl(220 14% 50% / 0.08),
0 1px 3px hsl(220 14% 50% / 0.05);
box-shadow: 0 2px 8px hsl(var(--text-muted) / 0.14),
0 1px 3px hsl(var(--text-muted) / 0.1);
}
.input-cursor {
@@ -521,16 +559,16 @@ body {
@apply transition-all duration-150;
background: linear-gradient(
135deg,
hsl(224 64% 48%) 0%,
hsl(232 58% 52%) 100%
hsl(var(--accent)) 0%,
hsl(var(--accent-hover)) 100%
);
}
.btn-primary-solid:hover {
background: linear-gradient(
135deg,
hsl(224 64% 42%) 0%,
hsl(232 58% 46%) 100%
hsl(var(--accent-hover)) 0%,
hsl(var(--accent)) 100%
);
}
@@ -572,12 +610,12 @@ body {
@apply bg-elevated/70 backdrop-blur-sm;
@apply border border-border/60 rounded-2xl;
@apply transition-all duration-200;
box-shadow: 0 1px 3px hsl(220 14% 50% / 0.05);
box-shadow: 0 1px 3px hsl(var(--text-muted) / 0.08);
}
.card:hover {
@apply border-border bg-elevated/90;
box-shadow: 0 2px 8px hsl(220 14% 50% / 0.08);
box-shadow: 0 2px 8px hsl(var(--text-muted) / 0.12);
}
.card-interactive {
@@ -586,8 +624,8 @@ body {
.card-interactive:hover {
@apply border-accent/25 shadow-lg;
box-shadow: 0 4px 16px hsl(224 64% 48% / 0.08),
0 1px 4px hsl(220 14% 50% / 0.05);
box-shadow: 0 4px 16px hsl(var(--accent) / 0.08),
0 1px 4px hsl(var(--text-muted) / 0.1);
}
.section-header {
@@ -626,17 +664,17 @@ body {
}
.glow-accent {
box-shadow: 0 2px 12px hsl(224 64% 48% / 0.1),
0 1px 4px hsl(224 64% 48% / 0.06);
box-shadow: 0 2px 12px hsl(var(--accent) / 0.1),
0 1px 4px hsl(var(--accent) / 0.06);
}
.glow-accent-strong {
box-shadow: 0 4px 20px hsl(224 64% 48% / 0.15),
0 2px 8px hsl(224 64% 48% / 0.1);
box-shadow: 0 4px 20px hsl(var(--accent) / 0.15),
0 2px 8px hsl(var(--accent) / 0.1);
}
.glow-subtle {
box-shadow: 0 2px 12px hsl(220 14% 50% / 0.08);
box-shadow: 0 2px 12px hsl(var(--text-muted) / 0.12);
}
.focus-ring {
@@ -693,10 +731,10 @@ body {
@keyframes glow-pulse {
0%, 100% {
box-shadow: 0 2px 12px hsl(224 64% 48% / 0.08);
box-shadow: 0 2px 12px hsl(var(--accent) / 0.08);
}
50% {
box-shadow: 0 4px 20px hsl(224 64% 48% / 0.14);
box-shadow: 0 4px 20px hsl(var(--accent) / 0.14);
}
}
@@ -727,9 +765,9 @@ body {
.animate-shimmer {
background: linear-gradient(
90deg,
hsl(220 14% 93%) 0%,
hsl(220 14% 88%) 50%,
hsl(220 14% 93%) 100%
hsl(var(--bg-surface)) 0%,
hsl(var(--bg-muted)) 50%,
hsl(var(--bg-surface)) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s linear infinite;
@@ -808,10 +846,10 @@ body {
@keyframes thinking-pulse {
0%, 100% {
background: hsl(224 64% 48% / 0.04);
background: hsl(var(--accent) / 0.04);
}
50% {
background: hsl(224 64% 48% / 0.09);
background: hsl(var(--accent) / 0.09);
}
}
@@ -840,7 +878,7 @@ body {
.file-card-hover:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px -5px hsl(220 14% 50% / 0.15);
box-shadow: 0 8px 25px -5px hsl(var(--text-muted) / 0.2);
}
.collapsible-content {
@@ -856,11 +894,11 @@ body {
.progress-shimmer {
background: linear-gradient(
90deg,
hsl(224 64% 48%) 0%,
hsl(240 55% 56%) 25%,
hsl(180 50% 42%) 50%,
hsl(240 55% 56%) 75%,
hsl(224 64% 48%) 100%
hsl(var(--accent)) 0%,
hsl(var(--accent-hover)) 25%,
hsl(var(--accent-secondary)) 50%,
hsl(var(--accent-hover)) 75%,
hsl(var(--accent)) 100%
);
background-size: 200% 100%;
animation: progress-shimmer 2s linear infinite;
@@ -886,6 +924,6 @@ body {
.artifact-glow:hover {
box-shadow:
0 4px 16px hsl(152 60% 38% / 0.1),
0 2px 8px hsl(220 14% 50% / 0.08);
0 4px 16px hsl(var(--success) / 0.14),
0 2px 8px hsl(var(--text-muted) / 0.12);
}

View File

@@ -7,13 +7,30 @@ export const metadata: Metadata = {
description: 'AI-поиск нового поколения',
};
const themeInitScript = `
(() => {
try {
const KEY = 'gooseek_theme';
const saved = localStorage.getItem(KEY);
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = (saved === 'light' || saved === 'dim') ? saved : (prefersDark ? 'dim' : 'light');
const root = document.documentElement;
if (theme === 'dim') root.classList.add('theme-dim');
else root.classList.remove('theme-dim');
} catch {}
})();
`;
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ru">
<html lang="ru" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
</head>
<body className="antialiased">
<Providers>{children}</Providers>
</body>

View File

@@ -3,15 +3,18 @@
import { ReactNode } from 'react';
import { LanguageProvider } from '@/lib/contexts/LanguageContext';
import { AuthProvider } from '@/lib/contexts/AuthContext';
import { ThemeProvider } from '@/lib/contexts/ThemeContext';
import { AuthModal } from '@/components/auth';
export function Providers({ children }: { children: ReactNode }) {
return (
<AuthProvider>
<LanguageProvider>
{children}
<AuthModal />
</LanguageProvider>
</AuthProvider>
<ThemeProvider>
<AuthProvider>
<LanguageProvider>
{children}
<AuthModal />
</LanguageProvider>
</AuthProvider>
</ThemeProvider>
);
}

View File

@@ -0,0 +1,40 @@
import type { MetadataRoute } from 'next';
const ALL_APP_ROUTES = [
'/discover',
'/spaces',
'/history',
'/travel',
'/medicine',
'/finance',
'/learning',
];
function getDisallowedRoutes(): string[] {
const raw = process.env.NEXT_PUBLIC_ENABLED_ROUTES || '';
if (!raw.trim()) return [];
const enabled = new Set(
raw
.split(',')
.map((r) => r.trim())
.filter((r) => r.startsWith('/')),
);
return ALL_APP_ROUTES.filter((r) => !enabled.has(r));
}
export default function robots(): MetadataRoute.Robots {
const disallowed = getDisallowedRoutes();
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/admin/', ...disallowed.map((r) => r + '/')],
},
],
sitemap: 'https://gooseek.ru/sitemap.xml',
};
}

View File

@@ -0,0 +1,40 @@
import type { MetadataRoute } from 'next';
const BASE_URL = 'https://gooseek.ru';
const ALL_APP_ROUTES = [
'/discover',
'/spaces',
'/history',
'/travel',
'/medicine',
'/finance',
'/learning',
];
const ALWAYS_IN_SITEMAP = ['/', '/login', '/register'];
function getEnabledAppRoutes(): string[] {
const raw = process.env.NEXT_PUBLIC_ENABLED_ROUTES || '';
if (!raw.trim()) return ALL_APP_ROUTES;
const enabled = new Set(
raw
.split(',')
.map((r) => r.trim())
.filter((r) => r.startsWith('/')),
);
return ALL_APP_ROUTES.filter((r) => enabled.has(r));
}
export default function sitemap(): MetadataRoute.Sitemap {
const routes = [...ALWAYS_IN_SITEMAP, ...getEnabledAppRoutes()];
return routes.map((route) => ({
url: `${BASE_URL}${route}`,
lastModified: new Date(),
changeFrequency: route === '/' ? 'daily' : 'weekly',
priority: route === '/' ? 1.0 : 0.7,
}));
}

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}
/>
);
}

View File

@@ -30,9 +30,13 @@ import type {
RoutePoint,
TravelSuggestion,
TravelPlanRequest,
LearningCourse,
LearningUserProfile,
LearningEnrollment,
LearningTask,
} from './types';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || process.env.API_URL || '';
function getAuthHeaders(): HeadersInit {
const headers: HeadersInit = {
@@ -47,6 +51,17 @@ function getAuthHeaders(): HeadersInit {
return headers;
}
function getAuthHeadersWithoutContentType(): HeadersInit {
const headers: HeadersInit = {};
if (typeof window !== 'undefined') {
const token = localStorage.getItem('token');
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
return headers;
}
export async function* streamChat(request: ChatRequest): AsyncGenerator<StreamEvent> {
const response = await fetch(`${API_BASE}/api/chat`, {
method: 'POST',
@@ -497,6 +512,197 @@ export async function deleteLesson(id: string): Promise<void> {
}
}
// --- New Learning Cabinet API ---
export async function fetchLearningCourses(params?: {
category?: string;
difficulty?: string;
search?: string;
limit?: number;
offset?: number;
}): Promise<{ courses: LearningCourse[]; total: number }> {
const searchParams = new URLSearchParams();
if (params?.category) searchParams.set('category', params.category);
if (params?.difficulty) searchParams.set('difficulty', params.difficulty);
if (params?.search) searchParams.set('search', params.search);
searchParams.set('limit', String(params?.limit ?? 20));
searchParams.set('offset', String(params?.offset ?? 0));
const response = await fetch(`${API_BASE}/api/v1/learning/courses?${searchParams}`, {
headers: getAuthHeaders(),
});
if (!response.ok) throw new Error(`Courses fetch failed: ${response.status}`);
return response.json();
}
export async function fetchLearningCourse(slug: string): Promise<LearningCourse | null> {
const response = await fetch(`${API_BASE}/api/v1/learning/courses/${slug}`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
if (response.status === 404) return null;
throw new Error(`Course fetch failed: ${response.status}`);
}
return response.json();
}
export async function fetchLearningProfile(): Promise<{ profile: LearningUserProfile | null; exists: boolean }> {
const response = await fetch(`${API_BASE}/api/v1/learning/me/profile`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
if (response.status === 401) return { profile: null, exists: false };
throw new Error(`Profile fetch failed: ${response.status}`);
}
return response.json();
}
export async function saveLearningProfile(data: {
displayName?: string;
profile?: Record<string, unknown>;
onboardingCompleted?: boolean;
}): Promise<{ success: boolean }> {
const response = await fetch(`${API_BASE}/api/v1/learning/me/profile`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`Profile save failed: ${response.status}`);
return response.json();
}
export async function submitLearningOnboarding(data: {
displayName?: string;
answers: Record<string, string>;
}): Promise<{ success: boolean; profile: Record<string, unknown> }> {
const response = await fetch(`${API_BASE}/api/v1/learning/me/onboarding`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`Onboarding submit failed: ${response.status}`);
return response.json();
}
export async function submitResume(fileId: string, extractedText: string): Promise<{ success: boolean; profile: Record<string, unknown> }> {
const response = await fetch(`${API_BASE}/api/v1/learning/me/resume`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ fileId, extractedText }),
});
if (!response.ok) throw new Error(`Resume submit failed: ${response.status}`);
return response.json();
}
export interface UploadedUserFile {
id: string;
filename: string;
fileType: string;
fileSize: number;
status?: string;
}
export interface UploadedFileContent {
id: string;
filename: string;
extractedText: string;
}
export async function uploadUserFile(file: File): Promise<UploadedUserFile> {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE}/api/v1/files/upload`, {
method: 'POST',
headers: getAuthHeadersWithoutContentType(),
body: formData,
});
if (!response.ok) {
throw new Error(`File upload failed: ${response.status}`);
}
return response.json();
}
export async function fetchUploadedFileContent(fileId: string): Promise<UploadedFileContent> {
const response = await fetch(`${API_BASE}/api/v1/files/${fileId}/content`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error(`File content fetch failed: ${response.status}`);
}
return response.json();
}
export async function analyzeUploadedFile(fileId: string): Promise<{ extractedText?: string }> {
const response = await fetch(`${API_BASE}/api/v1/files/${fileId}/analyze`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({}),
});
if (!response.ok) {
throw new Error(`File analyze failed: ${response.status}`);
}
return response.json();
}
export async function createSandboxSession(payload?: { taskId?: string; image?: string }): Promise<{ id: string; status: string }> {
const response = await fetch(`${API_BASE}/api/v1/sandbox/sessions`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
taskId: payload?.taskId,
image: payload?.image,
}),
});
if (!response.ok) {
throw new Error(`Sandbox create failed: ${response.status}`);
}
return response.json();
}
export async function enrollInCourse(courseId: string): Promise<LearningEnrollment> {
const response = await fetch(`${API_BASE}/api/v1/learning/enroll`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ courseId }),
});
if (!response.ok) {
if (response.status === 409) throw new Error('Already enrolled');
throw new Error(`Enroll failed: ${response.status}`);
}
return response.json();
}
export async function fetchEnrollments(): Promise<{ enrollments: LearningEnrollment[] }> {
const response = await fetch(`${API_BASE}/api/v1/learning/enrollments`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
if (response.status === 401) return { enrollments: [] };
throw new Error(`Enrollments fetch failed: ${response.status}`);
}
return response.json();
}
export async function fetchEnrollment(id: string): Promise<{ enrollment: LearningEnrollment; tasks: LearningTask[] } | null> {
const response = await fetch(`${API_BASE}/api/v1/learning/enrollments/${id}`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
if (response.status === 404) return null;
throw new Error(`Enrollment fetch failed: ${response.status}`);
}
return response.json();
}
export async function fetchAdminDashboard(): Promise<DashboardStats> {
const response = await fetch(`${API_BASE}/api/v1/admin/dashboard`, {
headers: getAuthHeaders(),
@@ -1091,6 +1297,7 @@ export async function* streamTravelAgent(
userLocation?: { lat: number; lng: number; name?: string };
},
chatId?: string,
signal?: AbortSignal,
): AsyncGenerator<StreamEvent> {
const chatHistory = history.flatMap(([user, assistant]) => [
['human', user],
@@ -1132,6 +1339,7 @@ export async function* streamTravelAgent(
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(request),
signal,
});
if (!response.ok) {
@@ -1146,32 +1354,102 @@ export async function* streamTravelAgent(
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
try {
while (true) {
if (signal?.aborted) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
const { done, value } = await reader.read();
if (done) break;
for (const line of lines) {
if (line.trim()) {
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim()) {
try {
const event = JSON.parse(line) as StreamEvent;
yield event;
} catch {
// skip malformed NDJSON lines
}
}
}
}
if (buffer.trim() && !signal?.aborted) {
try {
const event = JSON.parse(buffer) as StreamEvent;
yield event;
} catch {
// skip malformed final buffer
}
}
} finally {
reader.releaseLock();
}
}
export async function* streamMedicineConsult(
symptoms: string,
city?: string,
history?: [string, string][],
signal?: AbortSignal
): AsyncGenerator<StreamEvent> {
const response = await fetch(`${API_BASE}/api/v1/medicine/consult`, {
method: 'POST',
headers: getAuthHeaders(),
signal,
body: JSON.stringify({
symptoms,
city,
history,
}),
});
if (!response.ok) {
throw new Error(`Medicine consult request failed: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('No response body');
}
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
if (signal?.aborted) break;
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line) as StreamEvent;
yield event;
} catch {
console.warn('Failed to parse travel agent event:', line);
// skip malformed NDJSON lines
}
}
}
}
if (buffer.trim()) {
try {
const event = JSON.parse(buffer) as StreamEvent;
yield event;
} catch {
console.warn('Failed to parse final travel agent buffer:', buffer);
if (buffer.trim() && !signal?.aborted) {
try {
const event = JSON.parse(buffer) as StreamEvent;
yield event;
} catch {
// skip malformed final buffer
}
}
} finally {
reader.releaseLock();
}
}

View File

@@ -1,13 +1,14 @@
/**
* Конфигурация видимости пунктов меню
*
* Управляется через переменные окружения:
* - NEXT_PUBLIC_DISABLED_ROUTES — список отключённых маршрутов через запятую
*
* Пример: NEXT_PUBLIC_DISABLED_ROUTES=/travel,/medicine,/finance,/learning
*
* NEXT_PUBLIC_ENABLED_ROUTES — список включённых маршрутов через запятую.
* Если задан — показываются ТОЛЬКО перечисленные маршруты (+ корень "/" всегда виден).
* Если пуст или не задан — показываются все маршруты.
*
* Пример: NEXT_PUBLIC_ENABLED_ROUTES=/travel,/medicine,/discover
*/
type RouteId =
type RouteId =
| '/'
| '/discover'
| '/spaces'
@@ -18,39 +19,45 @@ type RouteId =
| '/learning'
| '/settings';
const parseDisabledRoutes = (): Set<string> => {
const envValue = process.env.NEXT_PUBLIC_DISABLED_ROUTES || '';
const ALWAYS_VISIBLE: ReadonlySet<string> = new Set(['/', '/settings']);
const parseEnabledRoutes = (): Set<string> | null => {
const envValue = process.env.NEXT_PUBLIC_ENABLED_ROUTES || '';
if (!envValue.trim()) {
return new Set();
return null;
}
const routes = envValue
.split(',')
.map(route => route.trim())
.filter(route => route.startsWith('/'));
.map((route) => route.trim())
.filter((route) => route.startsWith('/'));
return new Set(routes);
};
const disabledRoutes = parseDisabledRoutes();
const enabledRoutes = parseEnabledRoutes();
/**
* Проверяет, включён ли маршрут для отображения
* Проверяет, включён ли маршрут для отображения.
* Если NEXT_PUBLIC_ENABLED_ROUTES задан — показываем только то, что в списке + ALWAYS_VISIBLE.
* Если не задан — показываем всё.
*/
export function isRouteEnabled(route: string): boolean {
return !disabledRoutes.has(route);
if (ALWAYS_VISIBLE.has(route)) return true;
if (enabledRoutes === null) return true;
return enabledRoutes.has(route);
}
/**
* Фильтрует массив пунктов меню по включённым маршрутам
*/
export function filterMenuItems<T extends { href: string }>(items: T[]): T[] {
return items.filter(item => isRouteEnabled(item.href));
return items.filter((item) => isRouteEnabled(item.href));
}
/**
* Получает список отключённых маршрутов
* Получает список включённых маршрутов (или null если все включены)
*/
export function getDisabledRoutes(): string[] {
return Array.from(disabledRoutes);
export function getEnabledRoutes(): string[] | null {
return enabledRoutes ? Array.from(enabledRoutes) : null;
}

View File

@@ -0,0 +1,94 @@
'use client';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
export type ThemeMode = 'light' | 'dim';
const THEME_STORAGE_KEY = 'gooseek_theme';
function readStoredTheme(): ThemeMode | null {
try {
const v = localStorage.getItem(THEME_STORAGE_KEY);
if (v === 'light' || v === 'dim') return v;
return null;
} catch {
return null;
}
}
function prefersDark(): boolean {
if (typeof window === 'undefined') return false;
return !!window.matchMedia?.('(prefers-color-scheme: dark)')?.matches;
}
function applyTheme(theme: ThemeMode): void {
if (typeof document === 'undefined') return;
const root = document.documentElement;
if (theme === 'dim') root.classList.add('theme-dim');
else root.classList.remove('theme-dim');
try {
localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
// ignore storage failures (private mode, quota, etc.)
}
}
function resolveInitialTheme(): ThemeMode {
if (typeof document !== 'undefined' && document.documentElement.classList.contains('theme-dim')) {
return 'dim';
}
const stored = typeof window !== 'undefined' ? readStoredTheme() : null;
if (stored) return stored;
return prefersDark() ? 'dim' : 'light';
}
type ThemeContextValue = {
theme: ThemeMode;
setTheme: (theme: ThemeMode) => void;
toggleTheme: () => void;
};
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<ThemeMode>('light');
useEffect(() => {
const initial = resolveInitialTheme();
setThemeState(initial);
applyTheme(initial);
}, []);
const setTheme = useCallback((next: ThemeMode) => {
setThemeState(next);
applyTheme(next);
}, []);
const toggleTheme = useCallback(() => {
setTheme(theme === 'light' ? 'dim' : 'light');
}, [theme, setTheme]);
useEffect(() => {
const onStorage = (e: StorageEvent) => {
if (e.key !== THEME_STORAGE_KEY) return;
const v = e.newValue;
if (v !== 'light' && v !== 'dim') return;
setThemeState(v);
applyTheme(v);
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
const value = useMemo(() => ({ theme, setTheme, toggleTheme }), [theme, setTheme, toggleTheme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error('useTheme must be used within ThemeProvider');
}
return ctx;
}

View File

@@ -0,0 +1,375 @@
'use client';
import { useState, useCallback, useMemo, useRef } from 'react';
import type { ItineraryDay, ItineraryItem, POICard, EventCard, DailyForecast } from '../types';
export interface ValidationResult {
valid: boolean;
reason?: string;
severity: 'block' | 'warn';
}
export interface EditableDay {
date: string;
items: ItineraryItem[];
}
export type CustomItemType = 'food' | 'walk' | 'rest' | 'shopping' | 'custom';
interface UseEditableItineraryParams {
initialDays: ItineraryDay[];
poisMap: Map<string, POICard>;
eventsMap: Map<string, EventCard>;
dailyForecast?: DailyForecast[];
onValidateWithLLM?: (days: EditableDay[]) => Promise<LLMValidationResponse | null>;
}
export interface LLMValidationWarning {
dayIdx: number;
itemIdx?: number;
message: string;
}
export interface LLMValidationResponse {
valid: boolean;
warnings: LLMValidationWarning[];
suggestions: LLMValidationWarning[];
}
const WEEKDAY_MAP: Record<string, number> = {
'Пн': 1, 'Вт': 2, 'Ср': 3, 'Чт': 4, 'Пт': 5, 'Сб': 6, 'Вс': 0,
'пн': 1, 'вт': 2, 'ср': 3, 'чт': 4, 'пт': 5, 'сб': 6, 'вс': 0,
};
function getWeekdayName(date: Date): string {
const names = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
return names[date.getDay()];
}
function parseTimeToMinutes(time: string): number {
const parts = time.split(':');
if (parts.length !== 2) return -1;
const h = parseInt(parts[0], 10);
const m = parseInt(parts[1], 10);
if (isNaN(h) || isNaN(m)) return -1;
return h * 60 + m;
}
function isScheduleClosed(scheduleValue: string): boolean {
const lower = scheduleValue.toLowerCase();
return lower === 'выходной' || lower === 'закрыто' || lower === 'closed' || lower === '-';
}
export function validateItemPlacement(
item: ItineraryItem,
targetDate: string,
targetDayItems: ItineraryItem[],
poisMap: Map<string, POICard>,
eventsMap: Map<string, EventCard>,
insertIndex?: number,
): ValidationResult {
const targetDateObj = new Date(targetDate + 'T00:00:00');
if (isNaN(targetDateObj.getTime())) {
return { valid: true, severity: 'warn' };
}
if (item.refType === 'poi' && item.refId) {
const poi = poisMap.get(item.refId);
if (poi?.schedule && Object.keys(poi.schedule).length > 0) {
const weekday = getWeekdayName(targetDateObj);
const scheduleEntry = poi.schedule[weekday];
if (scheduleEntry && isScheduleClosed(scheduleEntry)) {
return {
valid: false,
reason: `${item.title} закрыт в ${weekday} (${targetDate})`,
severity: 'block',
};
}
}
}
if (item.refType === 'event' && item.refId) {
const event = eventsMap.get(item.refId);
if (event) {
if (event.dateStart) {
const eventStart = new Date(event.dateStart + 'T00:00:00');
const eventEnd = event.dateEnd
? new Date(event.dateEnd + 'T00:00:00')
: eventStart;
if (targetDateObj < eventStart || targetDateObj > eventEnd) {
const range = event.dateEnd
? `${event.dateStart}${event.dateEnd}`
: event.dateStart;
return {
valid: false,
reason: `${item.title} проходит ${range}, нельзя поставить на ${targetDate}`,
severity: 'block',
};
}
}
}
}
const isDuplicate = targetDayItems.some(
(existing, idx) =>
existing.refId === item.refId &&
existing.refId !== '' &&
idx !== insertIndex,
);
if (isDuplicate) {
return {
valid: false,
reason: `${item.title} уже есть в этом дне`,
severity: 'block',
};
}
if (item.refType !== 'food' && item.refType !== 'walk' && item.refType !== 'rest' &&
item.refType !== 'shopping' && item.refType !== 'custom') {
if ((!item.lat || item.lat === 0) && (!item.lng || item.lng === 0)) {
return {
valid: false,
reason: `${item.title} не имеет координат — не будет отображён на карте`,
severity: 'warn',
};
}
}
if (item.startTime && item.endTime) {
const itemStart = parseTimeToMinutes(item.startTime);
const itemEnd = parseTimeToMinutes(item.endTime);
if (itemStart >= 0 && itemEnd >= 0) {
for (let i = 0; i < targetDayItems.length; i++) {
if (i === insertIndex) continue;
const other = targetDayItems[i];
if (!other.startTime || !other.endTime) continue;
const otherStart = parseTimeToMinutes(other.startTime);
const otherEnd = parseTimeToMinutes(other.endTime);
if (otherStart < 0 || otherEnd < 0) continue;
if (itemStart < otherEnd && itemEnd > otherStart) {
return {
valid: false,
reason: `Конфликт времени: ${item.title} (${item.startTime}-${item.endTime}) пересекается с ${other.title} (${other.startTime}-${other.endTime})`,
severity: 'block',
};
}
}
}
}
return { valid: true, severity: 'warn' };
}
export function useEditableItinerary({
initialDays,
poisMap,
eventsMap,
onValidateWithLLM,
}: UseEditableItineraryParams) {
const [editableDays, setEditableDays] = useState<EditableDay[]>([]);
const [isEditing, setIsEditing] = useState(false);
const [llmWarnings, setLlmWarnings] = useState<LLMValidationWarning[]>([]);
const [llmSuggestions, setLlmSuggestions] = useState<LLMValidationWarning[]>([]);
const [isValidating, setIsValidating] = useState(false);
const originalDaysRef = useRef<ItineraryDay[]>([]);
const hasChanges = useMemo(() => {
if (!isEditing) return false;
return JSON.stringify(editableDays) !== JSON.stringify(originalDaysRef.current);
}, [editableDays, isEditing]);
const startEditing = useCallback(() => {
const copy: EditableDay[] = initialDays.map((d) => ({
date: d.date,
items: d.items.map((item) => ({ ...item })),
}));
originalDaysRef.current = initialDays;
setEditableDays(copy);
setIsEditing(true);
setLlmWarnings([]);
setLlmSuggestions([]);
}, [initialDays]);
const stopEditing = useCallback(() => {
setIsEditing(false);
setEditableDays([]);
setLlmWarnings([]);
setLlmSuggestions([]);
}, []);
const resetChanges = useCallback(() => {
const copy: EditableDay[] = originalDaysRef.current.map((d) => ({
date: d.date,
items: d.items.map((item) => ({ ...item })),
}));
setEditableDays(copy);
setLlmWarnings([]);
setLlmSuggestions([]);
}, []);
const moveItem = useCallback(
(
fromDayIdx: number,
fromItemIdx: number,
toDayIdx: number,
toItemIdx: number,
): ValidationResult => {
const days = editableDays;
if (
fromDayIdx < 0 || fromDayIdx >= days.length ||
toDayIdx < 0 || toDayIdx >= days.length
) {
return { valid: false, reason: 'Неверный индекс дня', severity: 'block' };
}
const item = days[fromDayIdx].items[fromItemIdx];
if (!item) {
return { valid: false, reason: 'Элемент не найден', severity: 'block' };
}
const targetItems = fromDayIdx === toDayIdx
? days[toDayIdx].items.filter((_, i) => i !== fromItemIdx)
: [...days[toDayIdx].items];
const validation = validateItemPlacement(
item,
days[toDayIdx].date,
targetItems,
poisMap,
eventsMap,
);
if (!validation.valid) {
return validation;
}
setEditableDays((prev) => {
const next = prev.map((d) => ({
date: d.date,
items: [...d.items],
}));
const [movedItem] = next[fromDayIdx].items.splice(fromItemIdx, 1);
const adjustedToIdx =
fromDayIdx === toDayIdx && toItemIdx > fromItemIdx
? toItemIdx - 1
: toItemIdx;
next[toDayIdx].items.splice(adjustedToIdx, 0, movedItem);
return next;
});
return { valid: true, severity: 'warn' };
},
[editableDays, poisMap, eventsMap],
);
const addItem = useCallback(
(dayIdx: number, item: ItineraryItem): ValidationResult => {
if (dayIdx < 0 || dayIdx >= editableDays.length) {
return { valid: false, reason: 'Неверный индекс дня', severity: 'block' };
}
const validation = validateItemPlacement(
item,
editableDays[dayIdx].date,
editableDays[dayIdx].items,
poisMap,
eventsMap,
);
if (!validation.valid) {
return validation;
}
setEditableDays((prev) => {
const next = prev.map((d) => ({
date: d.date,
items: [...d.items],
}));
next[dayIdx].items.push(item);
return next;
});
return { valid: true, severity: 'warn' };
},
[editableDays, poisMap, eventsMap],
);
const removeItem = useCallback((dayIdx: number, itemIdx: number) => {
setEditableDays((prev) => {
const next = prev.map((d) => ({
date: d.date,
items: [...d.items],
}));
if (dayIdx >= 0 && dayIdx < next.length) {
next[dayIdx].items.splice(itemIdx, 1);
}
return next;
});
}, []);
const addCustomItem = useCallback(
(
dayIdx: number,
title: string,
refType: CustomItemType,
durationMin: number,
): ValidationResult => {
const newItem: ItineraryItem = {
refType,
refId: `custom-${Date.now()}`,
title,
lat: 0,
lng: 0,
note: `~${durationMin} мин`,
};
return addItem(dayIdx, newItem);
},
[addItem],
);
const applyChanges = useCallback(async (): Promise<LLMValidationResponse | null> => {
if (!onValidateWithLLM) return null;
setIsValidating(true);
setLlmWarnings([]);
setLlmSuggestions([]);
try {
const result = await onValidateWithLLM(editableDays);
if (result) {
setLlmWarnings(result.warnings);
setLlmSuggestions(result.suggestions);
}
return result;
} finally {
setIsValidating(false);
}
}, [editableDays, onValidateWithLLM]);
return {
editableDays,
isEditing,
hasChanges,
isValidating,
llmWarnings,
llmSuggestions,
startEditing,
stopEditing,
resetChanges,
moveItem,
addItem,
removeItem,
addCustomItem,
applyChanges,
validatePlacement: (item: ItineraryItem, dayIdx: number) =>
validateItemPlacement(
item,
editableDays[dayIdx]?.date ?? '',
editableDays[dayIdx]?.items ?? [],
poisMap,
eventsMap,
),
};
}

View File

@@ -0,0 +1,282 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import type { StreamEvent, LearningPhase } from '../types';
import { generateId } from '../api';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
export interface LearningChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
isStreaming?: boolean;
widgets?: Array<{ type: string; data: Record<string, unknown> }>;
createdAt: Date;
}
interface UseLearningChatOptions {
enrollmentId?: string;
courseTitle?: string;
profileContext?: string;
planContext?: string;
}
export function useLearningChat(options: UseLearningChatOptions = {}) {
const [messages, setMessages] = useState<LearningChatMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [loadingPhase, setLoadingPhase] = useState<LearningPhase>('idle');
const abortControllerRef = useRef<AbortController | null>(null);
const sendMessage = useCallback(async (content: string) => {
if (!content.trim() || isLoading) return;
const userMessage: LearningChatMessage = {
id: generateId(),
role: 'user',
content: content.trim(),
createdAt: new Date(),
};
const assistantMessage: LearningChatMessage = {
id: generateId(),
role: 'assistant',
content: '',
isStreaming: true,
widgets: [],
createdAt: new Date(),
};
setMessages((prev) => [...prev, userMessage, assistantMessage]);
setIsLoading(true);
setLoadingPhase('taskDesign');
const history: [string, string][] = messages
.filter((m) => !m.isStreaming)
.reduce((acc, m, i, arr) => {
if (m.role === 'user' && arr[i + 1]?.role === 'assistant') {
acc.push([m.content, arr[i + 1].content]);
}
return acc;
}, [] as [string, string][]);
const systemInstructions = [
options.courseTitle ? `Текущий курс: ${options.courseTitle}` : '',
options.enrollmentId ? `ID записи: ${options.enrollmentId}` : '',
options.planContext ? `План обучения: ${options.planContext}` : '',
].filter(Boolean).join('\n');
const abortController = new AbortController();
abortControllerRef.current = abortController;
try {
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
const headers: HeadersInit = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const response = await fetch(`${API_BASE}/api/chat`, {
method: 'POST',
headers,
signal: abortController.signal,
body: JSON.stringify({
message: {
messageId: assistantMessage.id,
chatId: userMessage.id,
content: content.trim(),
},
optimizationMode: 'balanced',
history,
locale: 'ru',
answerMode: 'learning',
learningMode: true,
systemInstructions,
chatModel: { providerId: '', key: '' },
}),
});
if (!response.ok) throw new Error(`Chat failed: ${response.status}`);
const reader = response.body?.getReader();
if (!reader) throw new Error('No response body');
const decoder = new TextDecoder();
let buffer = '';
let fullContent = '';
const collectedWidgets: Array<{ type: string; data: Record<string, unknown> }> = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line) as StreamEvent;
processLearningEvent(event, assistantMessage.id, {
setMessages,
setLoadingPhase,
fullContent: { get: () => fullContent, set: (v: string) => { fullContent = v; } },
collectedWidgets,
});
} catch {
// skip unparseable lines
}
}
}
if (buffer.trim()) {
try {
const event = JSON.parse(buffer) as StreamEvent;
processLearningEvent(event, assistantMessage.id, {
setMessages,
setLoadingPhase,
fullContent: { get: () => fullContent, set: (v: string) => { fullContent = v; } },
collectedWidgets,
});
} catch {
// skip
}
}
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessage.id
? { ...m, isStreaming: false, widgets: collectedWidgets }
: m
)
);
} catch (error) {
if ((error as Error).name === 'AbortError') {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessage.id ? { ...m, isStreaming: false } : m
)
);
} else {
const errMsg = error instanceof Error ? error.message : 'Ошибка';
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessage.id
? { ...m, content: `Ошибка: ${errMsg}`, isStreaming: false }
: m
)
);
}
} finally {
setIsLoading(false);
setLoadingPhase('idle');
abortControllerRef.current = null;
}
}, [isLoading, messages, options]);
const stopGeneration = useCallback(() => {
abortControllerRef.current?.abort();
setIsLoading(false);
setLoadingPhase('idle');
setMessages((prev) =>
prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m))
);
}, []);
const clearChat = useCallback(() => {
setMessages([]);
setLoadingPhase('idle');
}, []);
return {
messages,
isLoading,
loadingPhase,
sendMessage,
stopGeneration,
clearChat,
};
}
interface ProcessContext {
setMessages: React.Dispatch<React.SetStateAction<LearningChatMessage[]>>;
setLoadingPhase: React.Dispatch<React.SetStateAction<LearningPhase>>;
fullContent: { get: () => string; set: (v: string) => void };
collectedWidgets: Array<{ type: string; data: Record<string, unknown> }>;
}
function processLearningEvent(
event: StreamEvent,
assistantId: string,
ctx: ProcessContext
): void {
switch (event.type) {
case 'textChunk': {
if (event.chunk && typeof event.chunk === 'string') {
const newContent = ctx.fullContent.get() + event.chunk;
ctx.fullContent.set(newContent);
ctx.setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: newContent } : m
)
);
}
break;
}
case 'block': {
if (event.block && typeof event.block === 'object') {
const block = event.block as { type: string; data: unknown };
if (block.type === 'text' && typeof block.data === 'string') {
ctx.fullContent.set(block.data);
ctx.setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: block.data as string } : m
)
);
} else if (block.type === 'widget' && block.data) {
const widget = block.data as { type: string; params: Record<string, unknown> };
ctx.collectedWidgets.push({ type: widget.type, data: widget.params || {} });
if (widget.type === 'learning_task') {
ctx.setLoadingPhase('taskDesign');
} else if (widget.type === 'learning_plan') {
ctx.setLoadingPhase('planBuilding');
} else if (widget.type === 'learning_quiz') {
ctx.setLoadingPhase('onboarding');
} else if (widget.type === 'learning_evaluation') {
ctx.setLoadingPhase('verifying');
} else if (widget.type === 'learning_sandbox_status') {
ctx.setLoadingPhase('verifying');
} else if (widget.type === 'learning_progress') {
ctx.setLoadingPhase('idle');
}
}
}
break;
}
case 'updateBlock': {
if (event.patch && Array.isArray(event.patch)) {
for (const p of event.patch) {
if (p.op === 'replace' && p.path === '/data' && typeof p.value === 'string') {
ctx.fullContent.set(p.value);
ctx.setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: p.value as string } : m
)
);
}
}
}
break;
}
case 'researchComplete':
ctx.setLoadingPhase('idle');
break;
case 'messageEnd':
ctx.setLoadingPhase('idle');
break;
}
}

View File

@@ -0,0 +1,140 @@
'use client';
import { useCallback, useRef, useState } from 'react';
import { streamMedicineConsult } from '../api';
export interface MedicineWidget {
id: string;
type: string;
params: Record<string, unknown>;
}
export interface MedicineChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
isStreaming?: boolean;
widgets: MedicineWidget[];
createdAt: Date;
}
function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
}
export function useMedicineChat() {
const [messages, setMessages] = useState<MedicineChatMessage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const historyRef = useRef<[string, string][]>([]);
const sendMessage = useCallback(
async (content: string, city?: string) => {
if (!content.trim() || isLoading) return;
const userMessage: MedicineChatMessage = {
id: generateId(),
role: 'user',
content: content.trim(),
widgets: [],
createdAt: new Date(),
};
const assistantMessage: MedicineChatMessage = {
id: generateId(),
role: 'assistant',
content: '',
isStreaming: true,
widgets: [],
createdAt: new Date(),
};
setMessages((prev) => [...prev, userMessage, assistantMessage]);
setIsLoading(true);
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
try {
let fullContent = '';
const widgets: MedicineWidget[] = [];
const stream = streamMedicineConsult(
content.trim(),
city?.trim() || undefined,
historyRef.current,
controller.signal
);
for await (const event of stream) {
const chunkText = event.chunk || (event.data as Record<string, unknown>)?.chunk;
if (event.type === 'textChunk' && typeof chunkText === 'string') {
fullContent += chunkText;
setMessages((prev) =>
prev.map((m) => (m.id === assistantMessage.id ? { ...m, content: fullContent } : m))
);
}
if (event.type === 'block' && event.block?.type === 'widget') {
const data = event.block.data as { widgetType?: string; params?: Record<string, unknown> };
if (!data.widgetType) continue;
widgets.push({
id: event.block.id,
type: data.widgetType,
params: data.params || {},
});
setMessages((prev) =>
prev.map((m) => (m.id === assistantMessage.id ? { ...m, widgets: [...widgets] } : m))
);
}
if (event.type === 'messageEnd') {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessage.id
? { ...m, isStreaming: false, content: fullContent, widgets: [...widgets] }
: m
)
);
}
}
if (fullContent.trim()) {
historyRef.current.push([userMessage.content, fullContent]);
historyRef.current = historyRef.current.slice(-8);
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Ошибка консультации';
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessage.id ? { ...m, content: `Ошибка: ${message}`, isStreaming: false } : m
)
);
} finally {
setIsLoading(false);
abortControllerRef.current = null;
}
},
[isLoading]
);
const stopGeneration = useCallback(() => {
abortControllerRef.current?.abort();
setIsLoading(false);
setMessages((prev) => prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)));
}, []);
const clearChat = useCallback(() => {
setMessages([]);
historyRef.current = [];
}, []);
return {
messages,
isLoading,
sendMessage,
stopGeneration,
clearChat,
};
}

View File

@@ -2,7 +2,6 @@
import { useState, useCallback, useRef } from 'react';
import type {
TravelMessage,
RoutePoint,
TravelSuggestion,
TravelPreferences,
@@ -59,6 +58,13 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
const [routeDirection, setRouteDirection] = useState<RouteDirection | null>(null);
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([]);
const [isResearching, setIsResearching] = useState(false);
const [loadingPhase, setLoadingPhase] = useState<'idle' | 'planning' | 'collecting' | 'building' | 'routing'>('idle');
const [selectedEventIds, setSelectedEventIds] = useState<Set<string>>(new Set());
const [selectedPOIIds, setSelectedPOIIds] = useState<Set<string>>(new Set());
const [selectedHotelId, setSelectedHotelId] = useState<string | undefined>();
const [selectedTransportId, setSelectedTransportId] = useState<string | undefined>();
const abortControllerRef = useRef<AbortController | null>(null);
const chatIdRef = useRef<string>(generateId());
const lastUserQueryRef = useRef<string>('');
@@ -108,9 +114,14 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
createdAt: new Date(),
};
abortControllerRef.current?.abort();
const controller = new AbortController();
abortControllerRef.current = controller;
setMessages((prev) => [...prev, userMessage, assistantMessage]);
setIsLoading(true);
setIsResearching(true);
setLoadingPhase('planning');
setClarifyingQuestions([]);
const history: [string, string][] = messages
@@ -127,7 +138,7 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
const effectiveOptions = planOptions || (isClarify ? lastPlanOptionsRef.current : undefined);
try {
const stream = streamTravelAgent(messageContent, history, effectiveOptions, chatIdRef.current);
const stream = streamTravelAgent(messageContent, history, effectiveOptions, chatIdRef.current, controller.signal);
let fullContent = '';
const collectedWidgets: TravelWidget[] = [];
@@ -152,9 +163,18 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
break;
}
case 'research':
case 'research': {
setIsResearching(true);
const substep = (block.data as Record<string, unknown>)?.substep as string | undefined;
if (substep?.includes('collect') || substep?.includes('search')) {
setLoadingPhase('collecting');
} else if (substep?.includes('itinerary') || substep?.includes('build')) {
setLoadingPhase('building');
} else if (substep?.includes('route') || substep?.includes('routing')) {
setLoadingPhase('routing');
}
break;
}
case 'widget': {
const widgetData = block.data as { widgetType: string; params: Record<string, unknown> };
@@ -200,18 +220,30 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Ошибка при планировании';
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessage.id
? { ...m, content: `Ошибка: ${errorMessage}`, isStreaming: false }
: m
)
);
options.onError?.(error instanceof Error ? error : new Error(errorMessage));
if (error instanceof DOMException && error.name === 'AbortError') {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessage.id
? { ...m, content: m.content || 'Генерация остановлена', isStreaming: false }
: m
)
);
} else {
const errorMessage = error instanceof Error ? error.message : 'Ошибка при планировании';
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessage.id
? { ...m, content: `Ошибка: ${errorMessage}`, isStreaming: false }
: m
)
);
options.onError?.(error instanceof Error ? error : new Error(errorMessage));
}
} finally {
setIsLoading(false);
setIsResearching(false);
setLoadingPhase('idle');
abortControllerRef.current = null;
}
}, [isLoading, messages, options]);
@@ -225,13 +257,7 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
options.onMapPointsUpdate?.(points);
if (params.routeDirection) {
const rd = params.routeDirection as RouteDirection;
console.log('[useTravelChat] routeDirection received:', {
coordsCount: rd?.geometry?.coordinates?.length ?? 0,
distance: rd?.distance,
stepsCount: rd?.steps?.length ?? 0,
});
setRouteDirection(rd);
setRouteDirection(params.routeDirection as RouteDirection);
}
if (params.segments) {
@@ -349,6 +375,12 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
setBudget(null);
setClarifyingQuestions([]);
setSuggestions([]);
setRouteDirection(null);
setRouteSegments([]);
setSelectedEventIds(new Set());
setSelectedPOIIds(new Set());
setSelectedHotelId(undefined);
setSelectedTransportId(undefined);
chatIdRef.current = generateId();
lastUserQueryRef.current = '';
lastPlanOptionsRef.current = {};
@@ -380,7 +412,34 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
sendMessage(`_clarify:${combinedMessage}`);
}, [sendMessage]);
function buildSearchPrompt(scope: string, query: string | undefined): string {
const q = (query || '').trim();
const base = q ? `Запрос пользователя: "${q}".` : 'Запрос пользователя: найти больше вариантов.';
switch (scope) {
case 'places':
return `${base}\n\nНайди ещё места/достопримечательности (POI) по текущему направлению поездки. Верни только релевантные варианты с координатами, рейтингом, адресом и кратким описанием.`;
case 'events':
return `${base}\n\nНайди ещё мероприятия и события по датам поездки. Верни релевантные варианты с датами, адресом, ссылкой и координатами если возможно.`;
case 'hotels':
return `${base}\n\nНайди ещё варианты отелей по датам поездки. Верни несколько категорий (дешевле/средний/комфорт) с ценой, рейтингом, адресом и координатами.`;
case 'tickets':
return `${base}\n\одбери билеты (перелёты/поезда) из города вылета к месту назначения и обратно по датам поездки. Верни варианты с ценой, временем, длительностью, пересадками и ссылкой.`;
case 'transport':
return `${base}\n\одбери транспорт на месте (трансферы/общественный/такси/аренда) для перемещений по маршруту. Верни варианты с примерной ценой/длительностью и рекомендациями.`;
default:
return `${base}\n\nНайди ещё варианты мест, отелей и мероприятий.`;
}
}
const handleAction = useCallback((actionKind: string) => {
if (actionKind.startsWith('search:')) {
// format: search:<scope> or search:<scope>:<query>
const parts = actionKind.split(':');
const scope = parts[1] || 'all';
const query = parts.slice(2).join(':') || undefined;
sendMessage(buildSearchPrompt(scope, query));
return;
}
switch (actionKind) {
case 'save':
break;
@@ -448,10 +507,49 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
}
}, [options]);
const toggleEventSelection = useCallback((event: EventCard) => {
setSelectedEventIds((prev) => {
const next = new Set(prev);
if (next.has(event.id)) {
next.delete(event.id);
} else {
next.add(event.id);
addEventToRoute(event);
}
return next;
});
}, [addEventToRoute]);
const togglePOISelection = useCallback((poi: POICard) => {
setSelectedPOIIds((prev) => {
const next = new Set(prev);
if (next.has(poi.id)) {
next.delete(poi.id);
} else {
next.add(poi.id);
addPOIToRoute(poi);
}
return next;
});
}, [addPOIToRoute]);
const toggleHotelSelection = useCallback((hotel: HotelCard) => {
setSelectedHotelId((prev) => {
const newId = prev === hotel.id ? undefined : hotel.id;
if (newId) selectHotelOnRoute(hotel);
return newId;
});
}, [selectHotelOnRoute]);
const toggleTransportSelection = useCallback((option: TransportOption) => {
setSelectedTransportId((prev) => (prev === option.id ? undefined : option.id));
}, []);
return {
messages,
isLoading,
isResearching,
loadingPhase,
currentRoute,
mapPoints,
events,
@@ -464,6 +562,10 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
suggestions,
routeDirection,
routeSegments,
selectedEventIds,
selectedPOIIds,
selectedHotelId,
selectedTransportId,
sendMessage,
stopGeneration,
clearChat,
@@ -474,5 +576,9 @@ export function useTravelChat(options: UseTravelChatOptions = {}) {
addEventToRoute,
addPOIToRoute,
selectHotelOnRoute,
toggleEventSelection,
togglePOISelection,
toggleHotelSelection,
toggleTransportSelection,
};
}

View File

@@ -44,7 +44,18 @@ export type WidgetType =
| 'travel_budget'
| 'travel_clarifying'
| 'travel_actions'
| 'travel_context';
| 'travel_context'
| 'learning_profile_card'
| 'learning_plan'
| 'learning_task'
| 'learning_quiz'
| 'learning_evaluation'
| 'learning_sandbox_status'
| 'learning_progress'
| 'medicine_assessment'
| 'medicine_doctors'
| 'medicine_appointments'
| 'medicine_reference';
export interface Chat {
id: string;
@@ -249,6 +260,7 @@ export interface FinanceMarket {
region: string;
}
// Legacy lesson types (kept for backward compat)
export interface Lesson {
id: string;
title: string;
@@ -301,6 +313,118 @@ export interface PracticeExercise {
language: string;
}
// --- New Learning Cabinet types ---
export interface LearningCourse {
id: string;
slug: string;
title: string;
shortDescription: string;
category: string;
tags: string[];
difficulty: string;
durationHours: number;
baseOutline: CourseOutline;
landing: CourseLanding;
coverImage?: string;
status: string;
enrolledCount: number;
createdAt: string;
updatedAt: string;
}
export interface CourseOutline {
modules?: CourseModule[];
total_hours?: number;
difficulty_adjusted?: string;
personalization_notes?: string;
}
export interface CourseModule {
index: number;
title: string;
description: string;
skills: string[];
estimated_hours: number;
practice_focus: string;
}
export interface CourseLanding {
hero_title?: string;
hero_subtitle?: string;
benefits?: string[];
target_audience?: string;
outcomes?: string[];
salary_range?: string;
prerequisites?: string;
faq?: Array<{ question: string; answer: string }>;
}
export interface LearningUserProfile {
userId: string;
displayName: string;
profile: Record<string, unknown>;
resumeFileId?: string;
resumeExtractedText?: string;
onboardingCompleted: boolean;
createdAt: string;
updatedAt: string;
}
export interface LearningEnrollment {
id: string;
userId: string;
courseId: string;
status: string;
plan: CourseOutline;
progress: EnrollmentProgress;
createdAt: string;
updatedAt: string;
course?: LearningCourse;
}
export interface EnrollmentProgress {
completed_modules: number[];
current_module: number;
score: number;
}
export interface LearningTask {
id: string;
enrollmentId: string;
moduleIndex: number;
title: string;
taskType: string;
instructionsMd: string;
rubric: Record<string, unknown>;
sandboxTemplate: Record<string, unknown>;
verificationCmd?: string;
status: string;
createdAt: string;
updatedAt: string;
}
export interface LearningSubmission {
id: string;
taskId: string;
sandboxSessionId?: string;
result: Record<string, unknown>;
score: number;
maxScore: number;
feedbackMd?: string;
createdAt: string;
}
export type LearningPhase =
| 'idle'
| 'onboarding'
| 'resumeParsing'
| 'profiling'
| 'planBuilding'
| 'taskDesign'
| 'sandboxPreparing'
| 'verifying';
export type UserRole = 'user' | 'admin';
export type UserTier = 'free' | 'pro' | 'business';
@@ -371,6 +495,58 @@ export interface AdminPostUpdateRequest {
status?: string;
}
// --- Connectors (Settings) ---
export type ConnectorCategory = 'search' | 'data' | 'finance' | 'notifications';
export type ConnectorId =
| 'web_search'
| 'duckduckgo'
| 'rss'
| 'coincap'
| 'exchangerate'
| 'openmeteo'
| 'github'
| 'email'
| 'telegram'
| 'push'
| 'websocket';
export type ConnectorAction = 'source' | 'notify';
export type ConnectorFieldType = 'text' | 'password' | 'textarea' | 'number' | 'url';
export interface ConnectorField {
key: string;
label: string;
type: ConnectorFieldType;
placeholder?: string;
required?: boolean;
helpText?: string;
}
export interface ConnectorConfig {
id: ConnectorId;
name: string;
description: string;
icon: string;
category: ConnectorCategory;
requiresAuth: boolean;
fields: ConnectorField[];
actions: ConnectorAction[];
}
export type UserConnectorStatus = 'connected' | 'disconnected' | 'error';
export interface UserConnector {
id: ConnectorId;
enabled: boolean;
config: Record<string, string>;
status: UserConnectorStatus;
connectedAt?: string;
errorMessage?: string;
}
export interface PlatformSettings {
id: string;
siteName: string;
@@ -624,6 +800,19 @@ export interface EventCard {
source?: string;
}
export type WeatherIcon = 'sun' | 'cloud' | 'cloud-sun' | 'rain' | 'storm' | 'snow' | 'fog' | 'wind';
export interface DailyForecast {
date: string;
tempMin: number;
tempMax: number;
conditions: string;
icon: WeatherIcon;
rainChance: string;
wind?: string;
tip?: string;
}
export interface WeatherAssessment {
summary: string;
tempMin: number;
@@ -631,6 +820,7 @@ export interface WeatherAssessment {
conditions: string;
clothing: string;
rainChance: string;
dailyForecast?: DailyForecast[];
}
export interface SafetyAssessment {

View File

@@ -0,0 +1,49 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const ALWAYS_ALLOWED = new Set(['/', '/settings', '/login', '/register', '/forgot-password', '/reset-password']);
const SYSTEM_PREFIXES = ['/_next', '/api', '/favicon', '/robots', '/sitemap'];
function parseEnabledRoutes(): Set<string> | null {
const raw = process.env.NEXT_PUBLIC_ENABLED_ROUTES || '';
if (!raw.trim()) return null;
return new Set(
raw
.split(',')
.map((r) => r.trim())
.filter((r) => r.startsWith('/')),
);
}
const enabled = parseEnabledRoutes();
function isAllowed(pathname: string): boolean {
if (ALWAYS_ALLOWED.has(pathname)) return true;
if (SYSTEM_PREFIXES.some((p) => pathname.startsWith(p))) return true;
if (enabled === null) return true;
for (const route of Array.from(enabled)) {
if (pathname === route || pathname.startsWith(route + '/')) return true;
}
if (pathname.startsWith('/admin')) return true;
return false;
}
export function middleware(request: NextRequest): NextResponse | undefined {
const { pathname } = request.nextUrl;
if (!isAllowed(pathname)) {
const url = request.nextUrl.clone();
url.pathname = '/';
return NextResponse.redirect(url, 302);
}
return undefined;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};