From 4b753266b321116c9056d9d1303a55455d681eee Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 13 Jun 2026 19:16:10 +0300 Subject: [PATCH] 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 --- apps/frontend/src/api/client.ts | 103 +++++++++++++++++ apps/frontend/src/api/responses.ts | 124 +++++++++++++++++++++ apps/frontend/src/components/Layout.tsx | 26 ++++- apps/frontend/src/components/SearchBar.tsx | 101 +++++++++++++++++ apps/frontend/src/hooks/useSearch.ts | 15 +++ apps/frontend/src/pages/BondPage.tsx | 40 ++++++- apps/frontend/src/pages/HomePage.tsx | 17 ++- apps/frontend/src/pages/StockPage.tsx | 46 +++++++- 8 files changed, 468 insertions(+), 4 deletions(-) create mode 100644 apps/frontend/src/api/client.ts create mode 100644 apps/frontend/src/api/responses.ts create mode 100644 apps/frontend/src/components/SearchBar.tsx create mode 100644 apps/frontend/src/hooks/useSearch.ts diff --git a/apps/frontend/src/api/client.ts b/apps/frontend/src/api/client.ts new file mode 100644 index 0000000..42c4a18 --- /dev/null +++ b/apps/frontend/src/api/client.ts @@ -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(path: string, params?: Record): 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('/api/v1/health'); +} + +export function searchSecurities( + q: string, + type: 'all' | 'share' | 'bond' = 'all', + limit = 20, +): Promise<{ data: SearchResultItem[]; meta: ApiResponseMeta }> { + return request('/api/v1/securities/search', { q, type, limit: String(limit) }); +} + +export function getShare(secid: string): Promise<{ data: ShareResponse; meta: ApiResponseMeta }> { + return request(`/api/v1/securities/shares/${encodeURIComponent(secid)}`); +} + +export function getShareMarketData(secid: string): Promise<{ data: StockMarketData; meta: ApiResponseMeta }> { + return request(`/api/v1/securities/shares/${encodeURIComponent(secid)}/marketdata`); +} + +export function getShareDividends(secid: string): Promise<{ data: DividendItem[]; meta: ApiResponseMeta }> { + return request(`/api/v1/securities/shares/${encodeURIComponent(secid)}/dividends`); +} + +export function getShareHistory( + secid: string, + from: string, + till: string, +): Promise<{ data: ShareHistoryItem[]; meta: ApiResponseMeta }> { + return request(`/api/v1/securities/shares/${encodeURIComponent(secid)}/history`, { from, till }); +} + +export function getBond(secid: string): Promise<{ data: BondResponse; meta: ApiResponseMeta }> { + return request(`/api/v1/securities/bonds/${encodeURIComponent(secid)}`); +} + +export function getBondMarketData(secid: string): Promise<{ data: BondMarketData; meta: ApiResponseMeta }> { + return request(`/api/v1/securities/bonds/${encodeURIComponent(secid)}/marketdata`); +} + +export function getBondHistory( + secid: string, + from: string, + till: string, +): Promise<{ data: BondHistoryItem[]; meta: ApiResponseMeta }> { + return request(`/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(`/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(`/api/v1/securities/bonds/${encodeURIComponent(secid)}/candles`, { + interval, + from, + till, + }); +} diff --git a/apps/frontend/src/api/responses.ts b/apps/frontend/src/api/responses.ts new file mode 100644 index 0000000..88e5024 --- /dev/null +++ b/apps/frontend/src/api/responses.ts @@ -0,0 +1,124 @@ +export interface ApiResponseMeta { + cachedAt: string | null; + fromCache: boolean; +} + +export interface ApiEnvelope { + 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; +} diff --git a/apps/frontend/src/components/Layout.tsx b/apps/frontend/src/components/Layout.tsx index 834242a..f007e0e 100644 --- a/apps/frontend/src/components/Layout.tsx +++ b/apps/frontend/src/components/Layout.tsx @@ -1,3 +1,27 @@ +import { Outlet, Link } from 'react-router-dom'; +import { SearchBar } from './SearchBar'; + export function Layout() { - return
; + return ( +
+
+ + MoexVibe + + +
+
+ +
+
+ ); } diff --git a/apps/frontend/src/components/SearchBar.tsx b/apps/frontend/src/components/SearchBar.tsx new file mode 100644 index 0000000..b97c2ef --- /dev/null +++ b/apps/frontend/src/components/SearchBar.tsx @@ -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(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 ( +
+ { setQuery(e.target.value); setOpen(true); }} + onFocus={() => setOpen(true)} + style={{ + width: '100%', + padding: '8px 12px', + border: '1px solid #ccc', + borderRadius: 6, + fontSize: 14, + }} + /> + {showResults && ( +
    + {isLoading &&
  • Загрузка...
  • } + {!isLoading && results && results.length === 0 && ( +
  • Ничего не найдено
  • + )} + {!isLoading && + results?.map(item => ( +
  • { + 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 = '')} + > + + {item.shortName} + {item.secid} + + + {item.type === 'share' ? 'Акция' : 'Облигация'} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/frontend/src/hooks/useSearch.ts b/apps/frontend/src/hooks/useSearch.ts new file mode 100644 index 0000000..982a413 --- /dev/null +++ b/apps/frontend/src/hooks/useSearch.ts @@ -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({ + queryKey: ['securities', 'search', query], + queryFn: async () => { + const res = await searchSecurities(query); + return res.data; + }, + enabled: query.length >= 2, + staleTime: 60_000, + }); +} diff --git a/apps/frontend/src/pages/BondPage.tsx b/apps/frontend/src/pages/BondPage.tsx index 57bc3e4..29b5294 100644 --- a/apps/frontend/src/pages/BondPage.tsx +++ b/apps/frontend/src/pages/BondPage.tsx @@ -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
; + const { secid } = useParams<{ secid: string }>(); + const { data, isLoading, error } = useQuery({ + queryKey: ['bonds', secid], + queryFn: () => getBond(secid!), + enabled: !!secid, + }); + + if (isLoading) return
Загрузка...
; + if (error || !data) return
Ошибка загрузки данных
; + + return ( +
+

{data.data.shortName}

+

+ {data.data.secid} · {data.data.isin} +

+
+
+ {data.data.marketData.price.toFixed(2)} ₽ +
+
+ {data.data.marketData.yieldToMaturity !== null + ? `Доходность: ${data.data.marketData.yieldToMaturity.toFixed(2)}%` + : 'N/A'} +
+
+
+ ); } diff --git a/apps/frontend/src/pages/HomePage.tsx b/apps/frontend/src/pages/HomePage.tsx index 80f85d3..aa46098 100644 --- a/apps/frontend/src/pages/HomePage.tsx +++ b/apps/frontend/src/pages/HomePage.tsx @@ -1,3 +1,18 @@ export function HomePage() { - return
; + return ( +
+

+ MoexVibe +

+

+ Анализ акций и облигаций Московской биржи +

+

+ Введите название или тикер в строку поиска выше +

+

+ Данные задерживаются на 15 минут · Бесплатный API MOEX ISS +

+
+ ); } diff --git a/apps/frontend/src/pages/StockPage.tsx b/apps/frontend/src/pages/StockPage.tsx index c61d182..b9a6ce2 100644 --- a/apps/frontend/src/pages/StockPage.tsx +++ b/apps/frontend/src/pages/StockPage.tsx @@ -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
; + const { secid } = useParams<{ secid: string }>(); + const { data, isLoading, error } = useQuery({ + queryKey: ['shares', secid], + queryFn: () => getShare(secid!), + enabled: !!secid, + }); + + if (isLoading) return
Загрузка...
; + if (error || !data) return
Ошибка загрузки данных
; + + return ( +
+

{data.data.shortName}

+

+ {data.data.secid} · {data.data.isin} +

+
+
+ {data.data.marketData.price.toFixed(2)} ₽ +
+
= 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)}% +
+
+
+ ); }