feat: travel service with 2GIS routing, POI, hotels + finance providers + UI overhaul

- Add travel-svc microservice (Amadeus, TravelPayouts, 2GIS, OpenRouteService)
- Add travel orchestrator with parallel collectors (events, POI, hotels, flights)
- Add 2GIS road routing with transport cost calculation (car/bus/taxi)
- Add TravelMap (2GIS MapGL) and TravelWidgets components
- Add useTravelChat hook for streaming travel agent responses
- Add finance heatmap providers refactor
- Add SearXNG settings, API proxy routes, Docker compose updates
- Update Dockerfiles, config, types, and all UI pages for consistency

Made-with: Cursor
This commit is contained in:
home
2026-03-01 21:58:32 +03:00
parent e6b9cfc60a
commit 08bd41e75c
71 changed files with 12364 additions and 945 deletions

View File

@@ -168,7 +168,7 @@ export function ChatInput({ onSend, onStop, isLoading, placeholder, autoFocus, v
<div className="relative w-full">
<motion.div
animate={{
borderColor: isFocused ? 'hsl(239 84% 67% / 0.4)' : 'hsl(240 4% 16% / 0.8)',
borderColor: isFocused ? 'hsl(224 64% 48% / 0.35)' : 'hsl(220 12% 86%)',
}}
transition={{ duration: 0.15 }}
className={`

View File

@@ -78,7 +78,7 @@ export function ChatMessage({ message }: ChatMessageProps) {
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-gradient hover:opacity-80 underline underline-offset-2 decoration-[hsl(239_84%_74%/0.3)] hover:decoration-[hsl(239_84%_74%/0.5)] transition-all"
className="text-gradient hover:opacity-80 underline underline-offset-2 decoration-[hsl(224_64%_48%/0.3)] hover:decoration-[hsl(224_64%_48%/0.5)] transition-all"
>
{children}
</a>

View File

@@ -16,7 +16,7 @@ export function Citation({ citation, compact }: CitationProps) {
href={citation.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center w-5 h-5 text-2xs font-medium bg-cream-300/10 hover:bg-cream-300/20 text-cream-300 border border-cream-400/20 rounded transition-colors"
className="inline-flex items-center justify-center w-5 h-5 text-2xs font-medium bg-accent/10 hover:bg-accent/18 text-accent border border-accent/25 rounded transition-colors"
>
{citation.index}
</a>
@@ -31,9 +31,9 @@ export function Citation({ citation, compact }: CitationProps) {
href={citation.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-2.5 py-1.5 bg-navy-800/40 hover:bg-navy-800/60 border border-navy-700/30 hover:border-cream-400/20 rounded-lg transition-all group"
className="inline-flex items-center gap-2 px-2.5 py-1.5 bg-elevated/80 hover:bg-elevated border border-border hover:border-accent/25 rounded-lg transition-all group"
>
<span className="w-4 h-4 rounded bg-cream-300/10 text-cream-300 flex items-center justify-center text-2xs font-medium">
<span className="w-4 h-4 rounded bg-accent/10 text-accent flex items-center justify-center text-2xs font-medium">
{citation.index}
</span>
{citation.favicon && (
@@ -46,27 +46,27 @@ export function Citation({ citation, compact }: CitationProps) {
}}
/>
)}
<span className="text-xs text-cream-400/80 group-hover:text-cream-200 max-w-[120px] truncate transition-colors">
<span className="text-xs text-secondary group-hover:text-primary max-w-[120px] truncate transition-colors">
{citation.domain}
</span>
<ExternalLink className="w-2.5 h-2.5 text-cream-500/50 group-hover:text-cream-400/70 transition-colors" />
<ExternalLink className="w-2.5 h-2.5 text-muted group-hover:text-secondary transition-colors" />
</a>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side="top"
className="max-w-[300px] p-4 bg-navy-800/95 backdrop-blur-xl border border-navy-700/50 rounded-xl shadow-xl z-50"
className="max-w-[300px] p-4 bg-elevated backdrop-blur-xl border border-border rounded-xl shadow-dropdown z-50"
sideOffset={8}
>
<p className="font-medium text-sm text-cream-100 line-clamp-2 mb-2">
<p className="font-medium text-sm text-primary line-clamp-2 mb-2">
{citation.title}
</p>
{citation.snippet && (
<p className="text-xs text-cream-400/70 line-clamp-3 mb-3">
<p className="text-xs text-secondary line-clamp-3 mb-3">
{citation.snippet}
</p>
)}
<div className="flex items-center gap-2 text-2xs text-cream-500/60">
<div className="flex items-center gap-2 text-2xs text-muted">
{citation.favicon && (
<img
src={citation.favicon}
@@ -79,7 +79,7 @@ export function Citation({ citation, compact }: CitationProps) {
)}
<span className="truncate">{citation.domain}</span>
</div>
<Tooltip.Arrow className="fill-navy-800" />
<Tooltip.Arrow className="fill-elevated" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
@@ -107,14 +107,14 @@ export function CitationList({ citations, maxVisible = 6 }: CitationListProps) {
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button className="text-xs text-cream-500/70 hover:text-cream-300 px-2.5 py-1.5 rounded-lg hover:bg-navy-800/30 transition-colors">
<button className="text-xs text-muted hover:text-secondary px-2.5 py-1.5 rounded-lg hover:bg-surface/60 transition-colors">
+{remaining} ещё
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side="top"
className="max-w-[320px] p-3 bg-navy-800/95 backdrop-blur-xl border border-navy-700/50 rounded-xl shadow-xl z-50"
className="max-w-[320px] p-3 bg-elevated backdrop-blur-xl border border-border rounded-xl shadow-dropdown z-50"
sideOffset={8}
>
<div className="space-y-2">
@@ -124,18 +124,18 @@ export function CitationList({ citations, maxVisible = 6 }: CitationListProps) {
href={citation.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 p-2 rounded-lg hover:bg-navy-700/50 transition-colors"
className="flex items-center gap-2 p-2 rounded-lg hover:bg-surface/60 transition-colors"
>
<span className="w-4 h-4 rounded bg-cream-300/10 text-cream-300 flex items-center justify-center text-2xs font-medium flex-shrink-0">
<span className="w-4 h-4 rounded bg-accent/10 text-accent flex items-center justify-center text-2xs font-medium flex-shrink-0">
{citation.index}
</span>
<span className="text-xs text-cream-200 truncate">
<span className="text-xs text-primary truncate">
{citation.title}
</span>
</a>
))}
</div>
<Tooltip.Arrow className="fill-navy-800" />
<Tooltip.Arrow className="fill-elevated" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>

View File

@@ -14,7 +14,7 @@ export function DiscoverCard({ item, variant = 'medium', onSummarize }: Discover
if (variant === 'large') {
return (
<article className="group relative overflow-hidden rounded-2xl bg-navy-900/40 border border-navy-700/20 hover:border-cream-400/15 transition-all duration-300">
<article className="group relative overflow-hidden rounded-2xl bg-elevated/80 border border-border hover:border-accent/25 transition-all duration-300 shadow-card">
{item.thumbnail && (
<div className="aspect-video overflow-hidden">
<img
@@ -34,23 +34,23 @@ export function DiscoverCard({ item, variant = 'medium', onSummarize }: Discover
alt=""
className="w-4 h-4 rounded"
/>
<span className="text-xs text-cream-500/60">{domain}</span>
<span className="text-xs text-muted">{domain}</span>
{item.sourcesCount && item.sourcesCount > 1 && (
<span className="text-xs text-cream-600/40">
<span className="text-xs text-faint">
{item.sourcesCount} источников
</span>
)}
</div>
<h2 className="text-xl font-semibold text-cream-100 mb-3 line-clamp-2 group-hover:text-cream-50 transition-colors">
<h2 className="text-xl font-semibold text-primary mb-3 line-clamp-2 group-hover:text-accent-hover transition-colors">
{item.title}
</h2>
<p className="text-cream-400/70 text-sm line-clamp-3 mb-5">{item.content}</p>
<p className="text-secondary text-sm line-clamp-3 mb-5">{item.content}</p>
<div className="flex items-center gap-4">
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-cream-400/80 hover:text-cream-200 transition-colors"
className="flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors"
>
<ExternalLink className="w-4 h-4" />
Читать
@@ -58,7 +58,7 @@ export function DiscoverCard({ item, variant = 'medium', onSummarize }: Discover
{onSummarize && (
<button
onClick={() => onSummarize(item.url)}
className="flex items-center gap-2 text-sm text-cream-300 hover:text-cream-100 transition-colors"
className="flex items-center gap-2 text-sm text-accent hover:text-accent-hover transition-colors"
>
<Sparkles className="w-4 h-4" />
AI Саммари
@@ -72,7 +72,7 @@ export function DiscoverCard({ item, variant = 'medium', onSummarize }: Discover
if (variant === 'small') {
return (
<article className="group flex items-start gap-3 p-3 rounded-xl hover:bg-navy-800/30 transition-colors">
<article className="group flex items-start gap-3 p-3 rounded-xl hover:bg-surface/60 transition-colors">
{item.thumbnail && (
<img
src={item.thumbnail}
@@ -90,13 +90,13 @@ export function DiscoverCard({ item, variant = 'medium', onSummarize }: Discover
alt=""
className="w-3 h-3 rounded"
/>
<span className="text-xs text-cream-600/50 truncate">{domain}</span>
<span className="text-xs text-faint truncate">{domain}</span>
</div>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-cream-200 group-hover:text-cream-100 line-clamp-2 transition-colors"
className="text-sm font-medium text-secondary group-hover:text-primary line-clamp-2 transition-colors"
>
{item.title}
</a>
@@ -106,7 +106,7 @@ export function DiscoverCard({ item, variant = 'medium', onSummarize }: Discover
}
return (
<article className="group p-4 bg-navy-900/30 border border-navy-700/20 rounded-xl hover:border-cream-400/15 hover:bg-navy-900/50 transition-all duration-200">
<article className="group p-4 bg-elevated/60 border border-border rounded-xl hover:border-accent/25 hover:bg-elevated/90 transition-all duration-200 shadow-card">
{item.thumbnail && (
<div className="aspect-video rounded-lg overflow-hidden mb-4 -mx-1 -mt-1">
<img
@@ -125,7 +125,7 @@ export function DiscoverCard({ item, variant = 'medium', onSummarize }: Discover
alt=""
className="w-4 h-4 rounded"
/>
<span className="text-xs text-cream-600/50">{domain}</span>
<span className="text-xs text-faint">{domain}</span>
</div>
<a
href={item.url}
@@ -133,15 +133,15 @@ export function DiscoverCard({ item, variant = 'medium', onSummarize }: Discover
rel="noopener noreferrer"
className="block"
>
<h3 className="font-medium text-cream-100 mb-2 line-clamp-2 group-hover:text-cream-50 transition-colors">
<h3 className="font-medium text-primary mb-2 line-clamp-2 group-hover:text-accent-hover transition-colors">
{item.title}
</h3>
</a>
<p className="text-sm text-cream-500/60 line-clamp-2">{item.content}</p>
<p className="text-sm text-muted line-clamp-2">{item.content}</p>
{onSummarize && (
<button
onClick={() => onSummarize(item.url)}
className="flex items-center gap-2 mt-3 text-xs text-cream-400 hover:text-cream-200 transition-colors"
className="flex items-center gap-2 mt-3 text-xs text-accent hover:text-accent-hover transition-colors"
>
<Sparkles className="w-3.5 h-3.5" />
Саммари

View File

@@ -220,7 +220,7 @@ export function Sidebar({ onClose }: SidebarProps) {
<span className="text-xs font-medium text-primary truncate">{user.name}</span>
<span className={`text-[9px] font-medium px-1 py-0.5 rounded ${
user.tier === 'business'
? 'bg-amber-500/20 text-amber-400'
? 'bg-amber-500/20 text-amber-600'
: user.tier === 'pro'
? 'bg-accent/20 text-accent'
: 'bg-surface text-muted'

View File

@@ -0,0 +1,582 @@
'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
MapPin,
Navigation,
Plane,
Hotel,
Utensils,
Camera,
Bus,
X,
ZoomIn,
ZoomOut,
Locate,
Sparkles,
TreePine,
Theater,
ShoppingBag,
Gamepad2,
Church,
Eye,
CalendarDays,
Flag,
User,
} from 'lucide-react';
import type { RoutePoint, RouteDirection, GeoLocation } from '@/lib/types';
interface MapglAPI {
Map: new (container: HTMLElement | string, options: Record<string, unknown>) => MapglMapInstance;
Marker: new (map: MapglMapInstance, options: Record<string, unknown>) => MapglMarkerInstance;
Polyline: new (map: MapglMapInstance, options: Record<string, unknown>) => MapglPolylineInstance;
}
interface MapglMapInstance {
destroy: () => void;
setCenter: (center: number[], options?: Record<string, unknown>) => void;
getCenter: () => number[];
setZoom: (zoom: number, options?: Record<string, unknown>) => void;
getZoom: () => number;
fitBounds: (bounds: { southWest: number[]; northEast: number[] }, options?: Record<string, unknown>) => void;
invalidateSize: () => void;
on: (type: string, listener: (e: MapglClickEvent) => void) => void;
off: (type: string, listener: (e: MapglClickEvent) => void) => void;
}
interface MapglClickEvent {
lngLat: number[];
}
interface MapglMarkerInstance {
destroy: () => void;
on: (type: string, listener: () => void) => void;
}
interface MapglPolylineInstance {
destroy: () => void;
}
interface TravelMapProps {
route: RoutePoint[];
routeDirection?: RouteDirection;
center?: GeoLocation;
zoom?: number;
onPointClick?: (point: RoutePoint) => void;
onMapClick?: (location: GeoLocation) => void;
className?: string;
showControls?: boolean;
userLocation?: GeoLocation | null;
}
const TWOGIS_API_KEY = process.env.NEXT_PUBLIC_TWOGIS_API_KEY || '';
const pointTypeIcons: Record<string, typeof MapPin> = {
airport: Plane,
hotel: Hotel,
restaurant: Utensils,
attraction: Camera,
transport: Bus,
custom: MapPin,
museum: Camera,
park: TreePine,
theater: Theater,
shopping: ShoppingBag,
entertainment: Gamepad2,
religious: Church,
viewpoint: Eye,
event: CalendarDays,
destination: Flag,
poi: MapPin,
food: Utensils,
transfer: Bus,
origin: User,
};
const pointTypeColors: Record<string, string> = {
airport: '#3B82F6',
hotel: '#8B5CF6',
restaurant: '#F59E0B',
attraction: '#10B981',
transport: '#6366F1',
custom: '#EC4899',
museum: '#14B8A6',
park: '#22C55E',
theater: '#A855F7',
shopping: '#F97316',
entertainment: '#EF4444',
religious: '#78716C',
viewpoint: '#06B6D4',
event: '#E11D48',
destination: '#3B82F6',
poi: '#10B981',
food: '#F59E0B',
transfer: '#94A3B8',
origin: '#10B981',
};
let mapglPromise: Promise<MapglAPI> | null = null;
function loadMapGL(): Promise<MapglAPI> {
if (mapglPromise) return mapglPromise;
mapglPromise = import('@2gis/mapgl').then((mod) =>
(mod.load as (url?: string) => Promise<unknown>)()
).then((api) => api as 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>` +
`</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"/>` +
`</svg>`;
}
export function TravelMap({
route,
routeDirection,
center,
zoom = 10,
onPointClick,
onMapClick,
className = '',
showControls = true,
userLocation,
}: TravelMapProps) {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<MapglMapInstance | null>(null);
const mapglRef = useRef<MapglAPI | null>(null);
const markersRef = useRef<MapglMarkerInstance[]>([]);
const userMarkerRef = useRef<MapglMarkerInstance | null>(null);
const polylineRef = useRef<MapglPolylineInstance | null>(null);
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 initDoneRef = useRef(false);
onMapClickRef.current = onMapClick;
onPointClickRef.current = onPointClick;
const effectiveUserLocation = userLocation ?? detectedLocation;
useEffect(() => {
if (!mapRef.current || initDoneRef.current) return;
initDoneRef.current = true;
let destroyed = false;
const initCenter = center || { lat: 55.7558, lng: 37.6173 };
loadMapGL().then((mapgl) => {
if (destroyed || !mapRef.current) return;
mapglRef.current = mapgl;
try {
const map = new mapgl.Map(mapRef.current, {
center: [initCenter.lng, initCenter.lat],
zoom,
key: TWOGIS_API_KEY,
lang: 'ru',
});
map.on('click', (e: MapglClickEvent) => {
onMapClickRef.current?.({
lat: e.lngLat[1],
lng: e.lngLat[0],
});
});
mapInstanceRef.current = map;
setIsMapReady(true);
} catch {
initDoneRef.current = false;
}
});
return () => {
destroyed = true;
markersRef.current.forEach((m) => m.destroy());
markersRef.current = [];
if (userMarkerRef.current) {
userMarkerRef.current.destroy();
userMarkerRef.current = null;
}
if (polylineRef.current) {
polylineRef.current.destroy();
polylineRef.current = null;
}
if (mapInstanceRef.current) {
mapInstanceRef.current.destroy();
mapInstanceRef.current = null;
}
initDoneRef.current = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (userLocation || !navigator.geolocation) return;
navigator.geolocation.getCurrentPosition(
(position) => {
const loc: GeoLocation = {
lat: position.coords.latitude,
lng: position.coords.longitude,
name: 'Моё местоположение',
};
setDetectedLocation(loc);
if (mapInstanceRef.current && route.length === 0) {
mapInstanceRef.current.setCenter([loc.lng, loc.lat]);
mapInstanceRef.current.setZoom(12);
}
},
() => {
// geolocation denied or unavailable
},
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 300000 },
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const container = mapRef.current;
if (!container) return;
const observer = new ResizeObserver(() => {
mapInstanceRef.current?.invalidateSize();
});
observer.observe(container);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!isMapReady || !mapInstanceRef.current || !mapglRef.current) return;
const map = mapInstanceRef.current;
const mapgl = mapglRef.current;
markersRef.current.forEach((m) => m.destroy());
markersRef.current = [];
if (polylineRef.current) {
polylineRef.current.destroy();
polylineRef.current = null;
}
if (userMarkerRef.current) {
userMarkerRef.current.destroy();
userMarkerRef.current = null;
}
if (effectiveUserLocation && effectiveUserLocation.lat !== 0 && effectiveUserLocation.lng !== 0) {
try {
const userMkr = new mapgl.Marker(map, {
coordinates: [effectiveUserLocation.lng, effectiveUserLocation.lat],
label: {
text: '',
offset: [0, -48],
image: {
url: `data:image/svg+xml,${encodeURIComponent(createUserLocationSVG())}`,
size: [32, 32],
anchor: [16, 16],
},
},
});
userMarkerRef.current = userMkr;
} catch {
// marker creation failed
}
}
if (route.length === 0) {
if (effectiveUserLocation && effectiveUserLocation.lat !== 0 && effectiveUserLocation.lng !== 0) {
map.setCenter([effectiveUserLocation.lng, effectiveUserLocation.lat]);
map.setZoom(12);
}
return;
}
route.forEach((point, index) => {
if (!point.lat || !point.lng || point.lat === 0 || point.lng === 0) return;
const color = pointTypeColors[point.type] || pointTypeColors.custom || '#EC4899';
try {
const marker = new mapgl.Marker(map, {
coordinates: [point.lng, point.lat],
label: {
text: String(index + 1),
offset: [0, -48],
image: {
url: `data:image/svg+xml,${encodeURIComponent(createMarkerSVG(index + 1, color))}`,
size: [28, 28],
anchor: [14, 14],
},
},
});
marker.on('click', () => {
setSelectedPoint(point);
onPointClickRef.current?.(point);
});
markersRef.current.push(marker);
} catch {
// marker creation failed
}
});
const rdCoords = routeDirection?.geometry?.coordinates;
if (rdCoords && Array.isArray(rdCoords) && rdCoords.length > 1) {
const coords = rdCoords.map(
(c: number[]) => [c[0], c[1]] as [number, number]
);
try {
polylineRef.current = new mapgl.Polyline(map, {
coordinates: coords,
color: '#6366F1',
width: 4,
});
} catch (err) {
console.error('[TravelMap] road polyline failed:', err, 'coords sample:', coords.slice(0, 3));
}
} else if (route.length > 1) {
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, {
coordinates: coords,
color: '#6366F1',
width: 3,
});
} catch (err) {
console.error('[TravelMap] fallback polyline failed:', err);
}
}
}
const allPoints: { lat: number; lng: number }[] = route
.filter((p) => p.lat !== 0 && p.lng !== 0)
.map((p) => ({ lat: p.lat, lng: p.lng }));
if (effectiveUserLocation && effectiveUserLocation.lat !== 0 && effectiveUserLocation.lng !== 0) {
allPoints.push({ lat: effectiveUserLocation.lat, lng: effectiveUserLocation.lng });
}
if (allPoints.length > 0) {
const lngs = allPoints.map((p) => p.lng);
const lats = allPoints.map((p) => p.lat);
const minLng = Math.min(...lngs);
const maxLng = Math.max(...lngs);
const minLat = Math.min(...lats);
const maxLat = Math.max(...lats);
const lngSpan = maxLng - minLng;
const latSpan = maxLat - minLat;
if (latSpan < 0.001 && lngSpan < 0.001) {
map.setCenter([allPoints[0].lng, allPoints[0].lat]);
map.setZoom(14);
} else {
map.fitBounds(
{
southWest: [minLng - lngSpan * 0.1, minLat - latSpan * 0.1],
northEast: [maxLng + lngSpan * 0.1, maxLat + latSpan * 0.1],
},
);
}
}
}, [isMapReady, route, routeDirection, effectiveUserLocation]);
const handleZoomIn = useCallback(() => {
const map = mapInstanceRef.current;
if (map) {
map.setZoom(map.getZoom() + 1);
}
}, []);
const handleZoomOut = useCallback(() => {
const map = mapInstanceRef.current;
if (map) {
map.setZoom(map.getZoom() - 1);
}
}, []);
const handleLocate = useCallback(() => {
if (!mapInstanceRef.current) return;
navigator.geolocation.getCurrentPosition(
(position) => {
const loc: GeoLocation = {
lat: position.coords.latitude,
lng: position.coords.longitude,
name: 'Моё местоположение',
};
setDetectedLocation(loc);
mapInstanceRef.current?.setCenter(
[position.coords.longitude, position.coords.latitude],
);
mapInstanceRef.current?.setZoom(14);
},
() => {},
);
}, []);
const handleFitRoute = useCallback(() => {
if (!mapInstanceRef.current || route.length === 0) return;
const allPoints: { lat: number; lng: number }[] = route
.filter((p) => p.lat !== 0 && p.lng !== 0)
.map((p) => ({ lat: p.lat, lng: p.lng }));
if (effectiveUserLocation && effectiveUserLocation.lat !== 0 && effectiveUserLocation.lng !== 0) {
allPoints.push({ lat: effectiveUserLocation.lat, lng: effectiveUserLocation.lng });
}
if (allPoints.length === 0) return;
const lngs = allPoints.map((p) => p.lng);
const lats = allPoints.map((p) => p.lat);
const minLng = Math.min(...lngs);
const maxLng = Math.max(...lngs);
const minLat = Math.min(...lats);
const maxLat = Math.max(...lats);
const lngSpan = maxLng - minLng;
const latSpan = maxLat - minLat;
if (latSpan < 0.001 && lngSpan < 0.001) {
mapInstanceRef.current.setCenter([allPoints[0].lng, allPoints[0].lat]);
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],
});
}
}, [route, effectiveUserLocation]);
const PointIcon = selectedPoint
? pointTypeIcons[selectedPoint.type] || MapPin
: MapPin;
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]">
<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"
title="Увеличить"
>
<ZoomIn className="w-5 h-5" />
</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"
title="Уменьшить"
>
<ZoomOut className="w-5 h-5" />
</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"
title="Моё местоположение"
>
<Locate className="w-5 h-5" />
</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"
title="Показать весь маршрут"
>
<Navigation className="w-5 h-5" />
</button>
)}
</div>
)}
<AnimatePresence>
{selectedPoint && (
<motion.div
initial={{ opacity: 0, y: 20 }}
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]"
>
<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' }}
>
<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>
</div>
)}
{selectedPoint.duration && (
<p className="text-xs text-muted mt-2">
Рекомендуемое время: {selectedPoint.duration} мин
</p>
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{!isMapReady && (
<div className="absolute inset-0 bg-surface/80 backdrop-blur-sm flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-muted">Загрузка карты 2GIS...</p>
</div>
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -34,7 +34,7 @@ export function AuthModal() {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-fade-in"
className="absolute inset-0 bg-primary/30 backdrop-blur-sm animate-fade-in"
onClick={hideAuthModal}
/>

View File

@@ -164,7 +164,7 @@ export function AccountTab() {
<h3 className="text-lg font-medium text-primary">{user.name}</h3>
<span className={`text-xs font-medium px-2 py-0.5 rounded ${
user.tier === 'business'
? 'bg-amber-500/20 text-amber-400'
? 'bg-amber-500/15 text-amber-600'
: user.tier === 'pro'
? 'bg-accent/20 text-accent'
: 'bg-surface text-muted'

View File

@@ -55,7 +55,7 @@ const plans: Plan[] = [
price: 4990,
priceMonthly: 4990,
icon: Building2,
color: 'text-amber-400',
color: 'text-amber-600',
features: ['Всё из Pro', 'Безлимитный AI', 'Приоритетная поддержка', 'Команды', 'SLA 99.9%'],
limits: {
apiRequests: '100,000/день',

View File

@@ -516,19 +516,19 @@ function ConnectorCard({
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${
isConnected ? 'bg-emerald-500/10' : 'bg-surface/60'
}`}>
<Icon className={`w-5 h-5 ${isConnected ? 'text-emerald-400' : 'text-secondary'}`} />
<Icon className={`w-5 h-5 ${isConnected ? 'text-emerald-600' : 'text-secondary'}`} />
</div>
<div className="flex-1 min-w-0 text-left">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-primary">{connector.name}</span>
{isConnected && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-400">
<span className="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-600">
Подключён
</span>
)}
{hasError && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400">
<span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-600">
Ошибка
</span>
)}
@@ -548,7 +548,7 @@ function ConnectorCard({
isConnected ? 'bg-emerald-500' : 'bg-surface/80 border border-border'
}`}>
<div className={`absolute top-1 w-4 h-4 rounded-full transition-all ${
isConnected ? 'right-1 bg-white' : 'left-1 bg-secondary'
isConnected ? 'right-1 bg-elevated' : 'left-1 bg-secondary'
}`} />
</div>
)}
@@ -569,7 +569,7 @@ function ConnectorCard({
<div key={field.key}>
<label className="block text-xs text-secondary mb-1.5">
{field.label}
{field.required && <span className="text-red-400 ml-0.5">*</span>}
{field.required && <span className="text-red-600 ml-0.5">*</span>}
</label>
<div className="relative">
{field.type === 'textarea' ? (
@@ -578,7 +578,7 @@ function ConnectorCard({
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.placeholder}
rows={3}
className="w-full px-3 py-2 bg-surface/60 border border-border/50 rounded-lg text-sm text-primary placeholder:text-muted focus:outline-none focus:border-indigo-500/50 resize-none"
className="w-full px-3 py-2 bg-surface/60 border border-border/50 rounded-lg text-sm text-primary placeholder:text-muted focus:outline-none focus:border-accent/50 resize-none"
/>
) : (
<input
@@ -586,7 +586,7 @@ function ConnectorCard({
value={formData[field.key] || ''}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.placeholder}
className="w-full px-3 py-2 bg-surface/60 border border-border/50 rounded-lg text-sm text-primary placeholder:text-muted focus:outline-none focus:border-indigo-500/50 pr-10"
className="w-full px-3 py-2 bg-surface/60 border border-border/50 rounded-lg text-sm text-primary placeholder:text-muted focus:outline-none focus:border-accent/50 pr-10"
/>
)}
{field.type === 'password' && (
@@ -612,8 +612,8 @@ function ConnectorCard({
{hasError && userConnector?.errorMessage && (
<div className="mt-3 p-2 rounded-lg bg-red-500/10 border border-red-500/20 flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-xs text-red-400">{userConnector.errorMessage}</p>
<AlertCircle className="w-4 h-4 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-red-600">{userConnector.errorMessage}</p>
</div>
)}
@@ -621,7 +621,7 @@ function ConnectorCard({
<button
type="submit"
disabled={saving}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-indigo-500 hover:bg-indigo-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-accent hover:bg-accent-hover text-accent-foreground text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
>
{saving ? (
<>
@@ -653,7 +653,7 @@ function ConnectorCard({
<button
type="button"
onClick={onDisconnect}
className="px-3 py-2 bg-red-500/10 border border-red-500/20 text-red-400 text-sm rounded-lg hover:bg-red-500/20 transition-colors"
className="px-3 py-2 bg-red-500/10 border border-red-500/20 text-red-600 text-sm rounded-lg hover:bg-red-500/20 transition-colors"
>
<X className="w-4 h-4" />
</button>
@@ -666,7 +666,7 @@ function ConnectorCard({
href="https://github.com/settings/tokens/new"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 mt-3 text-xs text-indigo-400 hover:text-indigo-300"
className="flex items-center gap-1 mt-3 text-xs text-accent hover:text-accent-hover"
>
Создать токен на GitHub
<ExternalLink className="w-3 h-3" />
@@ -678,7 +678,7 @@ function ConnectorCard({
href="https://t.me/BotFather"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 mt-3 text-xs text-indigo-400 hover:text-indigo-300"
className="flex items-center gap-1 mt-3 text-xs text-accent hover:text-accent-hover"
>
Открыть @BotFather
<ExternalLink className="w-3 h-3" />

View File

@@ -103,8 +103,8 @@ export function PreferencesTab() {
className="w-full flex items-center justify-between p-3 sm:p-4 bg-elevated/40 border border-border/40 rounded-xl hover:bg-elevated/60 hover:border-border transition-all"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-indigo-500/10 flex items-center justify-center">
<Plug className="w-5 h-5 text-indigo-400" />
<div className="w-10 h-10 rounded-xl bg-accent/10 flex items-center justify-center">
<Plug className="w-5 h-5 text-accent" />
</div>
<div className="text-left">
<p className="text-sm font-medium text-primary">Настроить коннекторы</p>