feat: add stock pages and ticker navigation
Add stock search and detail pages with charts, dividends and shared event rows. Use verified ticker links for portfolio names and logos, numeric dates and refreshed account cards. Update the generated API contract.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { StocksPage } from "./ui/StocksPage";
|
||||
@@ -0,0 +1,36 @@
|
||||
.card {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) minmax(150px, 210px);
|
||||
gap: var(--mantine-spacing-md);
|
||||
align-items: start;
|
||||
padding: var(--mantine-spacing-lg);
|
||||
border-radius: var(--mantine-radius-lg);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.card:hover {
|
||||
border-color: var(--mantine-color-blue-4);
|
||||
background: var(--mantine-color-blue-0);
|
||||
}
|
||||
.card:focus-visible {
|
||||
outline: 2px solid var(--mantine-color-blue-6);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.identity {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.quote {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@media (max-width: 40em) {
|
||||
.card {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
padding: var(--mantine-spacing-md);
|
||||
}
|
||||
.quote {
|
||||
grid-column: 2;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
import { StocksPage } from "./StocksPage";
|
||||
|
||||
describe("stock search", () => {
|
||||
it("waits for a meaningful query and uses the server-verified ticker link", async () => {
|
||||
const query = vi.fn();
|
||||
server.use(
|
||||
http.get("*/api/v1/stocks", ({ request }) => {
|
||||
query(new URL(request.url).searchParams.get("query"));
|
||||
return HttpResponse.json([
|
||||
{
|
||||
name: "Сбербанк",
|
||||
ticker: "SBER",
|
||||
instrumentUid: "unique-stock",
|
||||
slug: "SBER",
|
||||
logoName: "sber.png",
|
||||
classCode: "TQBR",
|
||||
isin: "RU0009029540",
|
||||
realExchange: "REAL_EXCHANGE_MOEX",
|
||||
shareType: "SHARE_TYPE_COMMON",
|
||||
apiTradeAvailable: true,
|
||||
price: { currency: "RUB", value: "306.12" },
|
||||
priceAt: "2026-09-10T10:00:00Z",
|
||||
},
|
||||
]);
|
||||
}),
|
||||
);
|
||||
configureAuthClient({
|
||||
accessToken: () => "synthetic",
|
||||
refresh: async () => false,
|
||||
clear: vi.fn(),
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MantineProvider>
|
||||
<MemoryRouter>
|
||||
<StocksPage />
|
||||
</MemoryRouter>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
fireEvent.change(screen.getByLabelText("Поиск акций"), {
|
||||
target: { value: "SBER" },
|
||||
});
|
||||
expect(
|
||||
await screen.findByRole("link", { name: /Сбербанк/ }),
|
||||
).toHaveAttribute("href", "/stocks/SBER");
|
||||
expect(screen.getByText("306,12 ₽")).toBeVisible();
|
||||
expect(screen.getByText("Режим: TQBR")).toBeVisible();
|
||||
expect(screen.getByText("ISIN: RU0009029540")).toBeVisible();
|
||||
expect(screen.getByText("Мосбиржа")).toBeVisible();
|
||||
await waitFor(() => expect(query).toHaveBeenCalledWith("SBER"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { FC } from "react";
|
||||
import { StocksPageView } from "./StocksPage.view";
|
||||
import { useStocksPageVm } from "./StocksPage.vm";
|
||||
|
||||
const StocksPage: FC = () => <StocksPageView vm={useStocksPageVm()} />;
|
||||
|
||||
export { StocksPage };
|
||||
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
import { formatMoney, formatSnapshotTime } from "@/shared/lib";
|
||||
import { LoadingSkeleton } from "@/shared/ui";
|
||||
import classes from "./StocksPage.module.css";
|
||||
import type { StocksPageVm } from "./StocksPage.vm";
|
||||
|
||||
type StocksPageViewProps = { vm: StocksPageVm };
|
||||
const StocksPageView: FC<StocksPageViewProps> = (props) => {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Stack maw={860} mx="auto" gap="lg">
|
||||
<div>
|
||||
<Title order={1}>{t("stock.searchTitle")}</Title>
|
||||
<Text c="dimmed" mt="xs">
|
||||
{t("stock.searchSubtitle")}
|
||||
</Text>
|
||||
</div>
|
||||
<TextInput
|
||||
label={t("stock.searchLabel")}
|
||||
placeholder={t("stock.searchPlaceholder")}
|
||||
size="md"
|
||||
maxLength={80}
|
||||
value={vm.query}
|
||||
onChange={(event) => vm.setQuery(event.currentTarget.value)}
|
||||
/>
|
||||
{vm.query.trim().length < 2 ? (
|
||||
<Text c="dimmed">{t("stock.searchHint")}</Text>
|
||||
) : null}
|
||||
{vm.error ? (
|
||||
<Alert role="alert" color="orange">
|
||||
<Stack gap="xs">
|
||||
<Text>
|
||||
{t(
|
||||
vm.code === "TBANK_NOT_CONNECTED" ||
|
||||
vm.code === "INVALID_TBANK_TOKEN"
|
||||
? "stock.connect"
|
||||
: "stock.sectionError",
|
||||
)}
|
||||
</Text>
|
||||
<Anchor component={Link} to="/settings">
|
||||
{t("accounts.openSettings")}
|
||||
</Anchor>
|
||||
<Button variant="default" onClick={vm.retry}>
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : null}
|
||||
{vm.loading ? (
|
||||
<LoadingSkeleton label={t("stock.searching")}>
|
||||
<Stack>
|
||||
<Skeleton h={78} />
|
||||
<Skeleton h={78} />
|
||||
<Skeleton h={78} />
|
||||
</Stack>
|
||||
</LoadingSkeleton>
|
||||
) : vm.ready ? (
|
||||
vm.items.length ? (
|
||||
<Stack gap="sm">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("stock.searchVariants")}
|
||||
</Text>
|
||||
{vm.items.map((stock) => (
|
||||
<Paper
|
||||
component={Link}
|
||||
to={stock.to}
|
||||
className={classes.card}
|
||||
withBorder
|
||||
key={stock.instrumentUid}
|
||||
>
|
||||
<Avatar src={stock.logoSrc} alt="" radius="md" size={48}>
|
||||
{stock.name[0]}
|
||||
</Avatar>
|
||||
<div className={classes.identity}>
|
||||
<Text fw={600} size="lg">
|
||||
{stock.name}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{stock.ticker} ·{" "}
|
||||
{t(`stock.shareTypes.${stock.shareType}`, {
|
||||
defaultValue: t("stock.share"),
|
||||
})}
|
||||
</Text>
|
||||
<Group gap="xs" mt="sm">
|
||||
<Badge variant="light" color="gray">
|
||||
{t(`stock.exchanges.${stock.realExchange}`, {
|
||||
defaultValue: t("stock.exchangeUnknown"),
|
||||
})}
|
||||
</Badge>
|
||||
{stock.classCode ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("stock.classCode", { code: stock.classCode })}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
{stock.isin ? (
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t("stock.isin", { value: stock.isin })}
|
||||
</Text>
|
||||
) : null}
|
||||
{stock.apiTradeAvailable === false ? (
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t("stock.apiUnavailable")}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={classes.quote}>
|
||||
<Text fw={650} size="xl">
|
||||
{stock.price ? formatMoney(stock.price) : "—"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{stock.price && stock.priceAt
|
||||
? t("stock.priceAt", {
|
||||
date: formatSnapshotTime(stock.priceAt),
|
||||
})
|
||||
: t("stock.priceUnavailable")}
|
||||
</Text>
|
||||
{stock.apiTradeAvailable === true ? (
|
||||
<Text size="xs" c="green.8" mt="sm">
|
||||
{t("stock.apiAvailable")}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text c="dimmed">{t("stock.noSearchResults")}</Text>
|
||||
)
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { StocksPageView };
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useStockSearch } from "@/entities/stock";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
import { instrumentLogoUrl } from "@/shared/lib";
|
||||
|
||||
function useStocksPageVm() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const query = params.get("q") ?? "";
|
||||
const [debounced] = useDebouncedValue(query.trim(), 300);
|
||||
const result = useStockSearch(debounced);
|
||||
const waiting = query.trim() !== debounced;
|
||||
return {
|
||||
query,
|
||||
setQuery: (q: string) => setParams(q ? { q } : {}, { replace: true }),
|
||||
loading: query.trim().length >= 2 && (waiting || result.isLoading),
|
||||
error: result.isError,
|
||||
code: apiErrorCode(result.error),
|
||||
retry: () => {
|
||||
void result.refetch();
|
||||
},
|
||||
ready: debounced.length >= 2 && !waiting && Boolean(result.data),
|
||||
items:
|
||||
result.data?.map((stock) => ({
|
||||
...stock,
|
||||
to: `/stocks/${encodeURIComponent(stock.slug ?? stock.instrumentUid)}`,
|
||||
logoSrc: instrumentLogoUrl(stock.logoName),
|
||||
})) ?? [],
|
||||
};
|
||||
}
|
||||
type StocksPageVm = ReturnType<typeof useStocksPageVm>;
|
||||
|
||||
export { type StocksPageVm, useStocksPageVm };
|
||||
Reference in New Issue
Block a user