feat: frontend API layer, Layout, SearchBar, HomePage
- Add typed API response types and fetch client (responses.ts + client.ts) - Add useSearch hook with TanStack Query - Add Layout with header and search bar - Add SearchBar with debounced search and dropdown - Implement HomePage, StockPage, BondPage with data loading
This commit is contained in:
parent
49437817a5
commit
4b753266b3
103
apps/frontend/src/api/client.ts
Normal file
103
apps/frontend/src/api/client.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import type {
|
||||
ApiEnvelope,
|
||||
ApiResponseMeta,
|
||||
ShareResponse,
|
||||
StockMarketData,
|
||||
DividendItem,
|
||||
ShareHistoryItem,
|
||||
BondResponse,
|
||||
BondMarketData,
|
||||
BondHistoryItem,
|
||||
CandleItem,
|
||||
SearchResultItem,
|
||||
HealthResponse,
|
||||
} from './responses';
|
||||
|
||||
const BASE = '';
|
||||
|
||||
async function request<T>(path: string, params?: Record<string, string>): Promise<{ data: T; meta: ApiResponseMeta }> {
|
||||
const url = new URL(`${BASE}${path}`, window.location.origin);
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined) url.searchParams.set(k, v);
|
||||
}
|
||||
}
|
||||
const res = await fetch(url.toString());
|
||||
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
|
||||
const json: ApiEnvelope<{ data: T; meta: ApiResponseMeta }> = await res.json();
|
||||
return json.data;
|
||||
}
|
||||
|
||||
export function getHealth(): Promise<{ data: HealthResponse; meta: ApiResponseMeta }> {
|
||||
return request<HealthResponse>('/api/v1/health');
|
||||
}
|
||||
|
||||
export function searchSecurities(
|
||||
q: string,
|
||||
type: 'all' | 'share' | 'bond' = 'all',
|
||||
limit = 20,
|
||||
): Promise<{ data: SearchResultItem[]; meta: ApiResponseMeta }> {
|
||||
return request<SearchResultItem[]>('/api/v1/securities/search', { q, type, limit: String(limit) });
|
||||
}
|
||||
|
||||
export function getShare(secid: string): Promise<{ data: ShareResponse; meta: ApiResponseMeta }> {
|
||||
return request<ShareResponse>(`/api/v1/securities/shares/${encodeURIComponent(secid)}`);
|
||||
}
|
||||
|
||||
export function getShareMarketData(secid: string): Promise<{ data: StockMarketData; meta: ApiResponseMeta }> {
|
||||
return request<StockMarketData>(`/api/v1/securities/shares/${encodeURIComponent(secid)}/marketdata`);
|
||||
}
|
||||
|
||||
export function getShareDividends(secid: string): Promise<{ data: DividendItem[]; meta: ApiResponseMeta }> {
|
||||
return request<DividendItem[]>(`/api/v1/securities/shares/${encodeURIComponent(secid)}/dividends`);
|
||||
}
|
||||
|
||||
export function getShareHistory(
|
||||
secid: string,
|
||||
from: string,
|
||||
till: string,
|
||||
): Promise<{ data: ShareHistoryItem[]; meta: ApiResponseMeta }> {
|
||||
return request<ShareHistoryItem[]>(`/api/v1/securities/shares/${encodeURIComponent(secid)}/history`, { from, till });
|
||||
}
|
||||
|
||||
export function getBond(secid: string): Promise<{ data: BondResponse; meta: ApiResponseMeta }> {
|
||||
return request<BondResponse>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}`);
|
||||
}
|
||||
|
||||
export function getBondMarketData(secid: string): Promise<{ data: BondMarketData; meta: ApiResponseMeta }> {
|
||||
return request<BondMarketData>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}/marketdata`);
|
||||
}
|
||||
|
||||
export function getBondHistory(
|
||||
secid: string,
|
||||
from: string,
|
||||
till: string,
|
||||
): Promise<{ data: BondHistoryItem[]; meta: ApiResponseMeta }> {
|
||||
return request<BondHistoryItem[]>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}/history`, { from, till });
|
||||
}
|
||||
|
||||
export function getShareCandles(
|
||||
secid: string,
|
||||
interval: '1h' | '24h',
|
||||
from: string,
|
||||
till: string,
|
||||
): Promise<{ data: CandleItem[]; meta: ApiResponseMeta }> {
|
||||
return request<CandleItem[]>(`/api/v1/securities/shares/${encodeURIComponent(secid)}/candles`, {
|
||||
interval,
|
||||
from,
|
||||
till,
|
||||
});
|
||||
}
|
||||
|
||||
export function getBondCandles(
|
||||
secid: string,
|
||||
interval: '1h' | '24h',
|
||||
from: string,
|
||||
till: string,
|
||||
): Promise<{ data: CandleItem[]; meta: ApiResponseMeta }> {
|
||||
return request<CandleItem[]>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}/candles`, {
|
||||
interval,
|
||||
from,
|
||||
till,
|
||||
});
|
||||
}
|
||||
124
apps/frontend/src/api/responses.ts
Normal file
124
apps/frontend/src/api/responses.ts
Normal file
@ -0,0 +1,124 @@
|
||||
export interface ApiResponseMeta {
|
||||
cachedAt: string | null;
|
||||
fromCache: boolean;
|
||||
}
|
||||
|
||||
export interface ApiEnvelope<T> {
|
||||
data: T;
|
||||
meta: ApiResponseMeta;
|
||||
}
|
||||
|
||||
export interface StockMarketData {
|
||||
price: number;
|
||||
change: number;
|
||||
changePercent: number;
|
||||
open: number;
|
||||
high: number | null;
|
||||
low: number | null;
|
||||
volume: number;
|
||||
value: number;
|
||||
issueCapitalization: number | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ShareResponse {
|
||||
secid: string;
|
||||
isin: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
latName: string | null;
|
||||
listLevel: number;
|
||||
issueSize: number;
|
||||
faceValue: number;
|
||||
faceUnit: string;
|
||||
type: string;
|
||||
marketData: StockMarketData;
|
||||
}
|
||||
|
||||
export interface DividendItem {
|
||||
registryCloseDate: string;
|
||||
value: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface ShareHistoryItem {
|
||||
date: string;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface BondMarketData {
|
||||
price: number;
|
||||
yieldToMaturity: number | null;
|
||||
duration: number | null;
|
||||
accruedInt: number;
|
||||
couponValue: number;
|
||||
couponPercent: number | null;
|
||||
nextCouponDate: string | null;
|
||||
open: number;
|
||||
high: number | null;
|
||||
low: number | null;
|
||||
volume: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BondResponse {
|
||||
secid: string;
|
||||
isin: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
latName: string | null;
|
||||
listLevel: number;
|
||||
issueSize: number;
|
||||
faceValue: number;
|
||||
faceUnit: string;
|
||||
matDate: string;
|
||||
couponValue: number;
|
||||
couponPercent: number | null;
|
||||
couponPeriod: number;
|
||||
nextCoupon: string | null;
|
||||
accruedInt: number;
|
||||
bondType: string;
|
||||
bondSubType: string;
|
||||
offerDate: string | null;
|
||||
buybackDate: string | null;
|
||||
marketData: BondMarketData;
|
||||
}
|
||||
|
||||
export interface BondHistoryItem {
|
||||
date: string;
|
||||
closePrice: number;
|
||||
yieldClose: number | null;
|
||||
duration: number | null;
|
||||
}
|
||||
|
||||
export interface CandleItem {
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
value: number;
|
||||
begin: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
export interface SearchResultItem {
|
||||
secid: string;
|
||||
isin: string;
|
||||
shortName: string;
|
||||
type: 'share' | 'bond';
|
||||
listLevel: number;
|
||||
currency: string | null;
|
||||
price: number | null;
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
status: string;
|
||||
timestamp: string;
|
||||
uptime: number;
|
||||
}
|
||||
@ -1,3 +1,27 @@
|
||||
import { Outlet, Link } from 'react-router-dom';
|
||||
import { SearchBar } from './SearchBar';
|
||||
|
||||
export function Layout() {
|
||||
return <div />;
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<header
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
padding: '12px 24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 24,
|
||||
}}
|
||||
>
|
||||
<Link to="/" style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)', textDecoration: 'none' }}>
|
||||
MoexVibe
|
||||
</Link>
|
||||
<SearchBar />
|
||||
</header>
|
||||
<main style={{ flex: 1, padding: 24, maxWidth: 1200, width: '100%', margin: '0 auto' }}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
101
apps/frontend/src/components/SearchBar.tsx
Normal file
101
apps/frontend/src/components/SearchBar.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSearch } from '../hooks/useSearch';
|
||||
|
||||
export function SearchBar() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebounced(query), 300);
|
||||
return () => clearTimeout(id);
|
||||
}, [query]);
|
||||
|
||||
const { data: results, isLoading } = useSearch(debounced);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, []);
|
||||
|
||||
const showResults = open && debounced.length >= 2;
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', width: 400, maxWidth: '100%' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск акций и облигаций..."
|
||||
value={query}
|
||||
onChange={e => { setQuery(e.target.value); setOpen(true); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
{showResults && (
|
||||
<ul
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: '#fff',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: 6,
|
||||
marginTop: 4,
|
||||
padding: 0,
|
||||
listStyle: 'none',
|
||||
zIndex: 100,
|
||||
maxHeight: 360,
|
||||
overflowY: 'auto',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
{isLoading && <li style={{ padding: 12, color: '#888' }}>Загрузка...</li>}
|
||||
{!isLoading && results && results.length === 0 && (
|
||||
<li style={{ padding: 12, color: '#888' }}>Ничего не найдено</li>
|
||||
)}
|
||||
{!isLoading &&
|
||||
results?.map(item => (
|
||||
<li
|
||||
key={item.secid}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
navigate(item.type === 'share' ? `/stocks/${item.secid}` : `/bonds/${item.secid}`);
|
||||
}}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
cursor: 'pointer',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = '#f5f5f5')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = '')}
|
||||
>
|
||||
<span>
|
||||
<strong>{item.shortName}</strong>
|
||||
<span style={{ marginLeft: 8, color: '#888', fontSize: 12 }}>{item.secid}</span>
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: item.type === 'share' ? '#1976d2' : '#2e7d32' }}>
|
||||
{item.type === 'share' ? 'Акция' : 'Облигация'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
apps/frontend/src/hooks/useSearch.ts
Normal file
15
apps/frontend/src/hooks/useSearch.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { searchSecurities } from '../api/client';
|
||||
import type { SearchResultItem } from '../api/responses';
|
||||
|
||||
export function useSearch(query: string) {
|
||||
return useQuery<SearchResultItem[]>({
|
||||
queryKey: ['securities', 'search', query],
|
||||
queryFn: async () => {
|
||||
const res = await searchSecurities(query);
|
||||
return res.data;
|
||||
},
|
||||
enabled: query.length >= 2,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
@ -1,3 +1,41 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getBond } from '../api/client';
|
||||
|
||||
export function BondPage() {
|
||||
return <div />;
|
||||
const { secid } = useParams<{ secid: string }>();
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['bonds', secid],
|
||||
queryFn: () => getBond(secid!),
|
||||
enabled: !!secid,
|
||||
});
|
||||
|
||||
if (isLoading) return <div>Загрузка...</div>;
|
||||
if (error || !data) return <div>Ошибка загрузки данных</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{data.data.shortName}</h1>
|
||||
<p style={{ color: 'var(--color-text-secondary)', marginBottom: 16 }}>
|
||||
{data.data.secid} · {data.data.isin}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
boxShadow: 'var(--shadow)',
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 28, fontWeight: 700 }}>
|
||||
{data.data.marketData.price.toFixed(2)} ₽
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)', marginTop: 4 }}>
|
||||
{data.data.marketData.yieldToMaturity !== null
|
||||
? `Доходность: ${data.data.marketData.yieldToMaturity.toFixed(2)}%`
|
||||
: 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,3 +1,18 @@
|
||||
export function HomePage() {
|
||||
return <div />;
|
||||
return (
|
||||
<div style={{ textAlign: 'center', paddingTop: 120 }}>
|
||||
<h1 style={{ fontSize: 32, fontWeight: 700, marginBottom: 12 }}>
|
||||
MoexVibe
|
||||
</h1>
|
||||
<p style={{ color: 'var(--color-text-secondary)', fontSize: 16, marginBottom: 32 }}>
|
||||
Анализ акций и облигаций Московской биржи
|
||||
</p>
|
||||
<p style={{ color: '#888', fontSize: 13 }}>
|
||||
Введите название или тикер в строку поиска выше
|
||||
</p>
|
||||
<p style={{ color: '#aaa', fontSize: 12, marginTop: 8 }}>
|
||||
Данные задерживаются на 15 минут · Бесплатный API MOEX ISS
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,3 +1,47 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getShare } from '../api/client';
|
||||
|
||||
export function StockPage() {
|
||||
return <div />;
|
||||
const { secid } = useParams<{ secid: string }>();
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['shares', secid],
|
||||
queryFn: () => getShare(secid!),
|
||||
enabled: !!secid,
|
||||
});
|
||||
|
||||
if (isLoading) return <div>Загрузка...</div>;
|
||||
if (error || !data) return <div>Ошибка загрузки данных</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{data.data.shortName}</h1>
|
||||
<p style={{ color: 'var(--color-text-secondary)', marginBottom: 16 }}>
|
||||
{data.data.secid} · {data.data.isin}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
boxShadow: 'var(--shadow)',
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 28, fontWeight: 700 }}>
|
||||
{data.data.marketData.price.toFixed(2)} ₽
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: data.data.marketData.change >= 0 ? 'var(--color-positive)' : 'var(--color-negative)',
|
||||
}}
|
||||
>
|
||||
{data.data.marketData.change >= 0 ? '+' : ''}
|
||||
{data.data.marketData.change.toFixed(2)} /{' '}
|
||||
{data.data.marketData.changePercent >= 0 ? '+' : ''}
|
||||
{data.data.marketData.changePercent.toFixed(2)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user