Files
gooseek/backend/webui/src/app/(main)/finance/page.tsx
home 06fe57c765 feat: Go backend, enhanced search, new widgets, Docker deploy
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
2026-02-27 04:15:32 +03:00

266 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}