Major changes: - Add Go backend (backend/) with microservices architecture - Enhanced master-agents-svc: reranker, content-classifier, stealth-crawler, proxy-manager, media-search, fastClassifier, language detection - New web-svc widgets: KnowledgeCard, ProductCard, ProfileCard, VideoCard, UnifiedCard, CardGallery, InlineImageGallery, SourcesPanel, RelatedQuestions - Improved discover-svc with discover-db integration - Docker deployment improvements (Caddyfile, vendor.sh, BUILD.md) - Library-svc: project_id schema migration - Remove deprecated finance-svc and travel-svc - Localization improvements across services Made-with: Cursor
266 lines
10 KiB
TypeScript
266 lines
10 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect, useCallback } from 'react';
|
||
import { motion } from 'framer-motion';
|
||
import { TrendingUp, TrendingDown, RefreshCw, Loader2, ArrowUpRight, ArrowDownRight, Activity, BarChart3 } from 'lucide-react';
|
||
import * as Tabs from '@radix-ui/react-tabs';
|
||
import { fetchMarkets, fetchHeatmap, fetchTopMovers } from '@/lib/api';
|
||
import type { FinanceMarket, HeatmapData, TopMovers, FinanceStock } from '@/lib/types';
|
||
|
||
const defaultMarkets: FinanceMarket[] = [
|
||
{ id: 'moex', name: 'MOEX', region: 'ru' },
|
||
{ id: 'crypto', name: 'Крипто', region: 'global' },
|
||
{ id: 'forex', name: 'Валюты', region: 'global' },
|
||
];
|
||
|
||
const timeRanges = [
|
||
{ id: '1d', label: '1Д' },
|
||
{ id: '1w', label: '1Н' },
|
||
{ id: '1m', label: '1М' },
|
||
{ id: '3m', label: '3М' },
|
||
{ id: '1y', label: '1Г' },
|
||
];
|
||
|
||
function formatPrice(price: number, market: string): string {
|
||
if (market === 'crypto' && price > 1000) {
|
||
return price.toLocaleString('ru-RU', { maximumFractionDigits: 0 });
|
||
}
|
||
return price.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
}
|
||
|
||
function formatChange(change: number): string {
|
||
const prefix = change >= 0 ? '+' : '';
|
||
return `${prefix}${change.toFixed(2)}%`;
|
||
}
|
||
|
||
function StockRow({ stock, market, delay }: { stock: FinanceStock; market: string; delay: number }) {
|
||
const isPositive = stock.change >= 0;
|
||
|
||
return (
|
||
<motion.div
|
||
initial={{ opacity: 0, x: -8 }}
|
||
animate={{ opacity: 1, x: 0 }}
|
||
transition={{ delay }}
|
||
className="flex items-center gap-3 sm:gap-4 p-2 sm:p-3 rounded-xl hover:bg-surface/40 transition-colors group"
|
||
>
|
||
<div className="w-9 h-9 sm:w-10 sm:h-10 rounded-xl bg-surface/60 flex items-center justify-center flex-shrink-0">
|
||
<span className="text-[10px] sm:text-xs font-semibold text-secondary">{stock.symbol.slice(0, 4)}</span>
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-sm font-medium text-primary truncate group-hover:text-accent transition-colors">
|
||
{stock.name}
|
||
</p>
|
||
{stock.sector && (
|
||
<p className="text-xs text-muted truncate">{stock.sector}</p>
|
||
)}
|
||
</div>
|
||
<div className="text-right flex-shrink-0">
|
||
<p className="text-sm font-mono text-primary">{formatPrice(stock.price, market)}</p>
|
||
<div className={`flex items-center justify-end gap-1 text-xs font-mono ${isPositive ? 'text-success' : 'text-error'}`}>
|
||
{isPositive ? (
|
||
<TrendingUp className="w-3 h-3" />
|
||
) : (
|
||
<TrendingDown className="w-3 h-3" />
|
||
)}
|
||
{formatChange(stock.change)}
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
function MoversSection({ title, stocks, market, icon: Icon, color }: {
|
||
title: string;
|
||
stocks: FinanceStock[];
|
||
market: string;
|
||
icon: React.ElementType;
|
||
color: string;
|
||
}) {
|
||
if (!stocks || stocks.length === 0) return null;
|
||
|
||
return (
|
||
<div className="mb-6 sm:mb-8">
|
||
<div className="flex items-center gap-3 mb-3 sm:mb-4">
|
||
<div className={`w-8 h-8 sm:w-9 sm:h-9 rounded-xl ${color} flex items-center justify-center`}>
|
||
<Icon className="w-4 h-4" />
|
||
</div>
|
||
<h3 className="text-sm font-medium text-primary">{title}</h3>
|
||
</div>
|
||
<div className="space-y-1 bg-elevated/40 border border-border/40 rounded-xl p-2">
|
||
{stocks.slice(0, 5).map((stock, i) => (
|
||
<StockRow key={stock.symbol} stock={stock} market={market} delay={i * 0.05} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function FinancePage() {
|
||
const [markets, setMarkets] = useState<FinanceMarket[]>(defaultMarkets);
|
||
const [currentMarket, setCurrentMarket] = useState('moex');
|
||
const [heatmapData, setHeatmapData] = useState<HeatmapData | null>(null);
|
||
const [moversData, setMoversData] = useState<TopMovers | null>(null);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [timeRange, setTimeRange] = useState('1d');
|
||
|
||
const loadMarkets = useCallback(async () => {
|
||
try {
|
||
const data = await fetchMarkets();
|
||
if (data && data.length > 0) {
|
||
setMarkets(data);
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load markets:', err);
|
||
}
|
||
}, []);
|
||
|
||
const loadData = useCallback(async () => {
|
||
setIsLoading(true);
|
||
try {
|
||
const [heatmap, movers] = await Promise.all([
|
||
fetchHeatmap(currentMarket, timeRange).catch(() => null),
|
||
fetchTopMovers(currentMarket, 10).catch(() => null),
|
||
]);
|
||
setHeatmapData(heatmap);
|
||
setMoversData(movers);
|
||
} catch (err) {
|
||
console.error('Failed to load finance data:', err);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, [currentMarket, timeRange]);
|
||
|
||
useEffect(() => {
|
||
loadMarkets();
|
||
}, [loadMarkets]);
|
||
|
||
useEffect(() => {
|
||
loadData();
|
||
}, [loadData]);
|
||
|
||
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="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 sm:mb-8">
|
||
<div>
|
||
<h1 className="text-xl sm:text-2xl font-semibold text-primary mb-1 sm:mb-2">Финансы</h1>
|
||
<p className="text-sm text-secondary">Котировки и аналитика рынков</p>
|
||
</div>
|
||
<button
|
||
onClick={loadData}
|
||
disabled={isLoading}
|
||
className="flex items-center gap-2 px-4 py-2.5 text-sm text-secondary hover:text-primary bg-surface/40 border border-border/50 hover:border-accent/30 rounded-xl transition-all w-fit"
|
||
>
|
||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||
<span className="hidden sm:inline">Обновить</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Market Tabs */}
|
||
<Tabs.Root value={currentMarket} onValueChange={setCurrentMarket}>
|
||
<Tabs.List className="flex gap-2 mb-4 sm:mb-6 overflow-x-auto pb-1 -mx-4 px-4 sm:mx-0 sm:px-0">
|
||
{markets.map((m) => (
|
||
<Tabs.Trigger
|
||
key={m.id}
|
||
value={m.id}
|
||
className="px-4 py-2.5 text-sm rounded-xl border transition-all whitespace-nowrap data-[state=active]:bg-accent/10 data-[state=active]:border-accent/30 data-[state=active]:text-primary bg-surface/30 border-border/30 text-muted hover:text-secondary hover:border-border"
|
||
>
|
||
{m.name}
|
||
</Tabs.Trigger>
|
||
))}
|
||
</Tabs.List>
|
||
</Tabs.Root>
|
||
|
||
{/* Time Range */}
|
||
<div className="flex gap-1 p-1.5 bg-elevated/40 border border-border/30 rounded-xl w-fit mb-6 sm:mb-8 overflow-x-auto">
|
||
{timeRanges.map((range) => (
|
||
<button
|
||
key={range.id}
|
||
onClick={() => setTimeRange(range.id)}
|
||
className={`px-3 py-1.5 text-xs rounded-lg transition-all whitespace-nowrap ${
|
||
timeRange === range.id
|
||
? 'bg-accent/15 text-accent border border-accent/30'
|
||
: 'text-muted hover:text-secondary'
|
||
}`}
|
||
>
|
||
{range.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Content */}
|
||
{isLoading ? (
|
||
<div className="flex flex-col items-center justify-center py-16 sm:py-20">
|
||
<Loader2 className="w-8 h-8 animate-spin text-accent/60" />
|
||
<p className="text-sm text-muted mt-4">Загрузка данных...</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{moversData && (
|
||
<div className="grid gap-4 sm:gap-6">
|
||
<MoversSection
|
||
title="Лидеры роста"
|
||
stocks={moversData.gainers}
|
||
market={currentMarket}
|
||
icon={ArrowUpRight}
|
||
color="bg-success/10 text-success"
|
||
/>
|
||
<MoversSection
|
||
title="Лидеры падения"
|
||
stocks={moversData.losers}
|
||
market={currentMarket}
|
||
icon={ArrowDownRight}
|
||
color="bg-error/10 text-error"
|
||
/>
|
||
<MoversSection
|
||
title="Самые активные"
|
||
stocks={moversData.mostActive}
|
||
market={currentMarket}
|
||
icon={Activity}
|
||
color="bg-accent/10 text-accent"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{heatmapData && heatmapData.sectors && heatmapData.sectors.length > 0 && (
|
||
<div className="mt-6 sm:mt-8">
|
||
<div className="flex items-center gap-3 mb-3 sm:mb-4">
|
||
<div className="w-8 h-8 sm:w-9 sm:h-9 rounded-xl bg-surface/60 flex items-center justify-center">
|
||
<BarChart3 className="w-4 h-4 text-secondary" />
|
||
</div>
|
||
<h3 className="text-sm font-medium text-primary">По секторам</h3>
|
||
</div>
|
||
<div className="space-y-3 sm:space-y-4">
|
||
{heatmapData.sectors.map((sector) => (
|
||
<div key={sector.name} className="bg-elevated/40 border border-border/40 rounded-xl p-3 sm:p-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<span className="text-sm font-medium text-primary">{sector.name}</span>
|
||
<span className={`text-xs font-mono ${sector.change >= 0 ? 'text-success' : 'text-error'}`}>
|
||
{formatChange(sector.change)}
|
||
</span>
|
||
</div>
|
||
<div className="space-y-1">
|
||
{sector.tickers.slice(0, 3).map((stock, i) => (
|
||
<StockRow key={stock.symbol} stock={stock} market={currentMarket} delay={i * 0.02} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{!moversData && !heatmapData && (
|
||
<div className="text-center py-16 sm:py-20">
|
||
<BarChart3 className="w-12 h-12 mx-auto mb-4 text-muted" />
|
||
<p className="text-secondary">Данные недоступны для выбранного рынка</p>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|