refactor: little structure refactor

This commit is contained in:
2026-08-14 10:24:24 +03:00
parent da585b157a
commit 2cca503519
18 changed files with 622 additions and 388 deletions
+1
View File
@@ -19,6 +19,7 @@ Node 22 обязателен (`.nvmrc`). `npm run openapi:sync` - единств
- Перед созданием формы, страницы или UI-состояний прочитать [`docs/UI-PATTERN.md`](docs/UI-PATTERN.md).
- Перед изменением структуры слайса или импортов прочитать [`docs/STRUCTURE.md`](docs/STRUCTURE.md).
- Перед кодом, тестами или quality checks прочитать [`docs/CONVENTIONS.md`](docs/CONVENTIONS.md) и [`docs/TESTING.md`](docs/TESTING.md).
- Route-level runtime errors обрабатываются одним native route-keyed ErrorBoundary вокруг `AppRoutes`; expected API states остаются локальными.
## Границы
+2
View File
@@ -5,3 +5,5 @@
MSW запускается только Vitest setup в `src/test/`; handlers покрывают текущий API. Не добавлять service worker, browser mocking или mock development mode. Для transport проверять заголовки и retry через MSW, не через реальный backend.
Проверяйте наблюдаемое поведение, а не детали реализации. UI-тест строится как Arrange, Act, Assert; для интерактивных действий предпочитайте `userEvent`. Форма должна проверять валидацию и отсутствие запроса при невалидных данных; маршруты - редиректы и доступность экрана.
Colocated tests finance views фиксируют выбор loading/error/empty/loaded и доступные recovery actions. Route boundary tests используют test-only throwing route и проверяют безопасную ссылку и reset после navigation.
+2
View File
@@ -5,3 +5,5 @@
Во время submit все действия формы блокируются. Ошибки Zod отображаются у поля; ошибки сети сохраняют введённые значения и показываются отдельным `Alert`.
Запросные экраны выбирают состояние в порядке loading, error, empty, loaded. Состояния имеют отдельные компоненты, если повторяются или содержат самостоятельную разметку.
Для finance-экранов container вызывает query hooks, VM выбирает состояние и передает только presentation props в Loading/Error/Empty/Loaded view. Known API errors, retry и stale snapshot остаются local views, а непойманные route errors обрабатывает общий route-keyed ErrorBoundary.
@@ -0,0 +1,69 @@
import { MantineProvider } from "@mantine/core";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, useLocation, useNavigate } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RouteErrorBoundary } from "./RouteErrorBoundary";
let authenticated = false;
vi.mock("@/features/auth", () => ({
useAuth: () => ({ user: authenticated ? {} : null }),
}));
function ThrowingRoute(): never {
throw new Error("test route failure");
}
function HealthyRoute() {
return (
<div>
{useLocation().pathname === "/healthy" ? (
"healthy route"
) : (
<ThrowingRoute />
)}
</div>
);
}
function RouteHarness() {
const navigate = useNavigate();
return (
<>
<button type="button" onClick={() => navigate("/healthy")}>
go healthy
</button>
<RouteErrorBoundary>
<HealthyRoute />
</RouteErrorBoundary>
</>
);
}
function renderBoundary(path: string) {
return render(
<MantineProvider>
<MemoryRouter initialEntries={[path]}>
<RouteHarness />
</MemoryRouter>
</MantineProvider>,
);
}
describe("RouteErrorBoundary", () => {
afterEach(cleanup);
it("offers login after a public route error without error details", () => {
authenticated = false;
renderBoundary("/login");
expect(screen.getByRole("link", { name: "Продолжить" })).toHaveAttribute(
"href",
"/login",
);
expect(screen.queryByText("test route failure")).not.toBeInTheDocument();
});
it("offers accounts after a protected route error and resets on navigation", async () => {
authenticated = true;
renderBoundary("/accounts");
screen.getByRole("button", { name: "go healthy" }).click();
await waitFor(() =>
expect(screen.getByText("healthy route")).toBeInTheDocument(),
);
});
});
+61
View File
@@ -0,0 +1,61 @@
import { Button, Stack, Text, Title } from "@mantine/core";
import { Component } from "react";
import { useTranslation } from "react-i18next";
import { Link, useLocation } from "react-router";
import { useAuth } from "@/features/auth";
type Props = {
children: React.ReactNode;
routeKey: string;
authenticated: boolean;
};
type State = { hasError: boolean };
class Boundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true };
}
componentDidUpdate(previousProps: Props) {
if (previousProps.routeKey !== this.props.routeKey && this.state.hasError) {
this.setState({ hasError: false });
}
}
render() {
if (this.state.hasError) {
return <RouteErrorFallback authenticated={this.props.authenticated} />;
}
return this.props.children;
}
}
function RouteErrorFallback({ authenticated }: { authenticated: boolean }) {
const { t } = useTranslation();
const to = authenticated ? "/accounts" : "/login";
return (
<Stack>
<Title order={1}>{t("routeError.title")}</Title>
<Text>{t("routeError.text")}</Text>
<Button component={Link} to={to}>
{t("routeError.action")}
</Button>
</Stack>
);
}
export function RouteErrorBoundary({
children,
}: {
children: React.ReactNode;
}) {
const { pathname, search } = useLocation();
const { user } = useAuth();
return (
<Boundary authenticated={Boolean(user)} routeKey={`${pathname}${search}`}>
{children}
</Boundary>
);
}
+4 -1
View File
@@ -2,6 +2,7 @@ import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router";
import { AppRoutes } from "@/app/routes";
import { RouteErrorBoundary } from "@/app/routes/RouteErrorBoundary";
import { theme } from "@/app/theme";
import { AuthProvider } from "@/features/auth";
@@ -15,7 +16,9 @@ export function App() {
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthProvider>
<AppRoutes />
<RouteErrorBoundary>
<AppRoutes />
</RouteErrorBoundary>
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
@@ -6,14 +6,18 @@ import {
Stack,
Text,
} from "@mantine/core";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { brokerageAccountsQueryKey } from "@/entities/brokerage-account";
import { apiQueryClient } from "@/shared/api";
import type { paths } from "@/shared/api/generated/schema";
import { useAuth } from "../../auth";
import {
TbankConnectionError,
TbankConnectionLoading,
} from "./TbankConnectionSection.view";
import { useTbankConnectionViewModel } from "./TbankConnectionSection.vm";
type Status =
paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"];
@@ -25,8 +29,15 @@ function errorCode(error: unknown): string | undefined {
}
export function TbankConnectionSection() {
const viewModel = useTbankConnectionViewModel();
if (viewModel.state === "loading") return <TbankConnectionLoading />;
if (viewModel.state === "error")
return <TbankConnectionError retry={viewModel.retry} />;
return <TbankConnectionSectionLoaded status={viewModel.status} />;
}
function TbankConnectionSectionLoaded({ status }: { status: Status }) {
const { t } = useTranslation();
const { accessToken } = useAuth();
const queryClient = useQueryClient();
const [deleteOpened, setDeleteOpened] = useState(false);
const [requestError, setRequestError] = useState<string | null>(null);
@@ -36,11 +47,6 @@ export function TbankConnectionSection() {
reset,
formState: { errors },
} = useForm<{ token: string }>();
const status = useQuery({
queryKey: ["tbank-connection"],
enabled: Boolean(accessToken),
...apiQueryClient.queryOptions("get", "/api/v1/tbank-connection"),
});
const update = apiQueryClient.useMutation("put", "/api/v1/tbank-connection", {
onSuccess: (data) => {
queryClient.setQueryData(["tbank-connection"], data);
@@ -66,25 +72,16 @@ export function TbankConnectionSection() {
);
const pending = update.isPending || remove.isPending;
if (status.isPending) return <Text>{t("states.loadingText")}</Text>;
if (status.isError)
return (
<Alert color="red" title={t("tbank.statusError")}>
<Button variant="subtle" onClick={() => status.refetch()}>
{t("tbank.retry")}
</Button>
</Alert>
);
const connected = status.data.connected;
const connected = status.connected;
return (
<Stack mt="xl">
<Text fw={600}>{t("tbank.title")}</Text>
<Text size="sm">
{connected
? t("tbank.connected", {
date: new Date(
String(status.data.connectedAt),
).toLocaleDateString("ru-RU"),
date: new Date(String(status.connectedAt)).toLocaleDateString(
"ru-RU",
),
})
: t("tbank.disconnected")}
</Text>
@@ -0,0 +1,26 @@
import { MantineProvider } from "@mantine/core";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
TbankConnectionError,
TbankConnectionLoading,
} from "./TbankConnectionSection.view";
describe("TbankConnectionSection state views", () => {
it("renders loading and retries a status error", () => {
const retry = vi.fn();
const { rerender } = render(
<MantineProvider>
<TbankConnectionLoading />
</MantineProvider>,
);
expect(screen.getByText("Пожалуйста, подождите.")).toBeInTheDocument();
rerender(
<MantineProvider>
<TbankConnectionError retry={retry} />
</MantineProvider>,
);
screen.getByRole("button", { name: "Повторить" }).click();
expect(retry).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,17 @@
import { Alert, Button, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
export function TbankConnectionLoading() {
const { t } = useTranslation();
return <Text>{t("states.loadingText")}</Text>;
}
export function TbankConnectionError({ retry }: { retry: () => void }) {
const { t } = useTranslation();
return (
<Alert color="red" title={t("tbank.statusError")}>
<Button variant="subtle" onClick={retry}>
{t("tbank.retry")}
</Button>
</Alert>
);
}
@@ -0,0 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/features/auth";
import { apiQueryClient } from "@/shared/api";
export function useTbankConnectionViewModel() {
const { accessToken } = useAuth();
const status = useQuery({
queryKey: ["tbank-connection"],
enabled: Boolean(accessToken),
...apiQueryClient.queryOptions("get", "/api/v1/tbank-connection"),
});
if (status.isPending) return { state: "loading" as const };
if (status.isError)
return { state: "error" as const, retry: () => status.refetch() };
return { state: "loaded" as const, status: status.data };
}
+20 -252
View File
@@ -1,260 +1,28 @@
import {
Alert,
Button,
Card,
Group,
Paper,
SimpleGrid,
Stack,
Text,
Title,
} from "@mantine/core";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Link, useParams } from "react-router";
import {
accountTypeKey,
type BrokerageAccount,
} from "@/entities/brokerage-account";
import type { Portfolio } from "@/entities/portfolio";
import {
formatMoney,
formatSnapshotTime,
portfolioErrorCode,
usePortfolio,
} from "@/entities/portfolio";
import { useAuth } from "@/features/auth";
import { StateView } from "@/shared/ui";
import classes from "./AccountPage.module.css";
const accountsQueryKey = ["brokerage-accounts"] as const;
AccountPageEmpty,
AccountPageError,
AccountPageLoaded,
AccountPageLoading,
} from "./AccountPage.view";
import { useAccountPageViewModel } from "./AccountPage.vm";
export function AccountPage() {
const { accountId } = useParams();
const { accessToken } = useAuth();
const portfolio = usePortfolio(accountId, accessToken);
const account = useQueryClient()
.getQueryData<BrokerageAccount[]>(accountsQueryKey)
?.find((item) => item.id === accountId);
if (portfolio.isFetching) return <AccountPageLoading />;
if (portfolio.isError)
return (
<AccountPageError
code={portfolioErrorCode(portfolio.error)}
retry={() => portfolio.refetch()}
/>
);
if (!portfolio.data) return <AccountPageLoading />;
if (!portfolio.data.positions.length && !portfolio.data.cashBalances.length)
return <AccountPageEmpty account={account} />;
return <AccountPageLoaded account={account} portfolio={portfolio.data} />;
}
export function AccountPageLoading() {
const { t } = useTranslation();
const viewModel = useAccountPageViewModel();
if (viewModel.state === "loading") return <AccountPageLoading />;
if (viewModel.state === "error") return <AccountPageError {...viewModel} />;
if (viewModel.state === "empty")
return <AccountPageEmpty account={viewModel.account} />;
return (
<StateView
loading
title={t("states.loadingTitle")}
text={t("states.loadingText")}
<AccountPageLoaded
account={viewModel.account}
portfolio={viewModel.portfolio}
/>
);
}
export function AccountPageError({
code,
retry,
}: {
code?: string;
retry: () => void;
}) {
const { t } = useTranslation();
if (code === "NOT_FOUND")
return (
<StateView
error
title={t("portfolio.accountUnavailableTitle")}
text={t("portfolio.accountUnavailableText")}
action={
<Button component={Link} to="/accounts">
{t("portfolio.back")}
</Button>
}
/>
);
if (code === "TBANK_NOT_CONNECTED" || code === "INVALID_TBANK_TOKEN")
return (
<StateView
error
title={t("portfolio.connectionErrorTitle")}
text={t("portfolio.connectionErrorText")}
action={
<Button component={Link} to="/settings">
{t("accounts.openSettings")}
</Button>
}
/>
);
if (code === "TBANK_UNAVAILABLE")
return (
<StateView
error
title={t("accounts.unavailableTitle")}
text={t("accounts.unavailableText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
}
export function AccountPageEmpty({ account }: { account?: BrokerageAccount }) {
const { t } = useTranslation();
return (
<Stack>
<AccountHeading account={account} />
<StateView
title={t("portfolio.emptyTitle")}
text={t("portfolio.emptyText")}
/>
</Stack>
);
}
export function AccountPageLoaded({
account,
portfolio,
}: {
account?: BrokerageAccount;
portfolio: Portfolio;
}) {
const { t } = useTranslation();
const stocks = portfolio.positions.filter(
(position) => position.type === "STOCK",
);
const bonds = portfolio.positions.filter(
(position) => position.type === "BOND",
);
const otherPositions = portfolio.positions.filter(
(position) => position.type !== "STOCK" && position.type !== "BOND",
);
return (
<Stack className={classes.page} gap="lg">
<AccountHeading account={account} />
{portfolio.isStale ? (
<Alert color="blue">{t("portfolio.staleWarning")}</Alert>
) : null}
<Text c="dimmed" size="sm">
{t("portfolio.updatedAt", {
date: formatSnapshotTime(portfolio.lastSuccessfulAt),
})}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
{portfolio.totalsByCurrency.map((total) => (
<Paper className={classes.total} key={total.currency} withBorder>
<Text c="dimmed" size="xs">
{total.currency}
</Text>
<Text fw={800} size="xl">
{formatMoney(total)}
</Text>
</Paper>
))}
</SimpleGrid>
{stocks.length ? (
<PositionSection title={t("portfolio.stocks")} positions={stocks} />
) : null}
{bonds.length ? (
<PositionSection title={t("portfolio.bonds")} positions={bonds} />
) : null}
{otherPositions.length ? (
<PositionSection
title={t("portfolio.otherPositions")}
positions={otherPositions}
/>
) : null}
<Section title={t("portfolio.cashBalances")}>
{portfolio.cashBalances.map((balance) => (
<Paper className={classes.cash} key={balance.currency} withBorder>
<Text>{balance.currency}</Text>
<Text fw={700}>{formatMoney(balance)}</Text>
</Paper>
))}
</Section>
</Stack>
);
}
function PositionSection({
title,
positions,
}: {
title: string;
positions: Portfolio["positions"];
}) {
const { t } = useTranslation();
return (
<Section title={title}>
{positions.map((position) => (
<Card
className={classes.row}
key={`${position.figi}-${position.displayName}-${position.quantity}`}
withBorder
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={2}>
<Text fw={700}>{position.displayName}</Text>
<Text c="dimmed" size="sm">
{t("portfolio.quantity", { value: position.quantity })}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text fw={700}>{formatMoney(position.currentValue)}</Text>
<Text c="dimmed" size="sm">
{t("portfolio.return", {
value: formatMoney(position.absoluteReturn),
})}
</Text>
</Stack>
</Group>
</Card>
))}
</Section>
);
}
function AccountHeading({ account }: { account?: BrokerageAccount }) {
const { t } = useTranslation();
return (
<Stack gap={2}>
<Title order={1}>{account?.name ?? t("portfolio.title")}</Title>
{account ? (
<Text c="dimmed" size="sm">
{t(accountTypeKey(account.type))}
</Text>
) : null}
</Stack>
);
}
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<Stack gap="sm">
<Title order={2}>{title}</Title>
{children}
</Stack>
);
}
export {
AccountPageEmpty,
AccountPageError,
AccountPageLoaded,
AccountPageLoading,
} from "./AccountPage.view";
+217
View File
@@ -0,0 +1,217 @@
import {
Alert,
Button,
Card,
Group,
Paper,
SimpleGrid,
Stack,
Text,
Title,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { accountTypeKey } from "@/entities/brokerage-account";
import type { Portfolio } from "@/entities/portfolio";
import { formatMoney, formatSnapshotTime } from "@/entities/portfolio";
import { StateView } from "@/shared/ui";
import classes from "./AccountPage.module.css";
export function AccountPageLoading() {
const { t } = useTranslation();
return (
<StateView
loading
title={t("states.loadingTitle")}
text={t("states.loadingText")}
/>
);
}
export function AccountPageError({
code,
retry,
}: {
code?: string;
retry: () => void;
}) {
const { t } = useTranslation();
if (code === "NOT_FOUND")
return (
<StateView
error
title={t("portfolio.accountUnavailableTitle")}
text={t("portfolio.accountUnavailableText")}
action={
<Button component={Link} to="/accounts">
{t("portfolio.back")}
</Button>
}
/>
);
if (code === "TBANK_NOT_CONNECTED" || code === "INVALID_TBANK_TOKEN")
return (
<StateView
error
title={t("portfolio.connectionErrorTitle")}
text={t("portfolio.connectionErrorText")}
action={
<Button component={Link} to="/settings">
{t("accounts.openSettings")}
</Button>
}
/>
);
if (code === "TBANK_UNAVAILABLE")
return (
<StateView
error
title={t("accounts.unavailableTitle")}
text={t("accounts.unavailableText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
}
export function AccountPageEmpty({ account }: { account?: BrokerageAccount }) {
const { t } = useTranslation();
return (
<Stack>
<AccountHeading account={account} />
<StateView
title={t("portfolio.emptyTitle")}
text={t("portfolio.emptyText")}
/>
</Stack>
);
}
export function AccountPageLoaded({
account,
portfolio,
}: {
account?: BrokerageAccount;
portfolio: Portfolio;
}) {
const { t } = useTranslation();
const stocks = portfolio.positions.filter((p) => p.type === "STOCK");
const bonds = portfolio.positions.filter((p) => p.type === "BOND");
const other = portfolio.positions.filter(
(p) => p.type !== "STOCK" && p.type !== "BOND",
);
return (
<Stack className={classes.page} gap="lg">
<AccountHeading account={account} />
{portfolio.isStale ? (
<Alert color="blue">{t("portfolio.staleWarning")}</Alert>
) : null}
<Text c="dimmed" size="sm">
{t("portfolio.updatedAt", {
date: formatSnapshotTime(portfolio.lastSuccessfulAt),
})}
</Text>
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
{portfolio.totalsByCurrency.map((total) => (
<Paper className={classes.total} key={total.currency} withBorder>
<Text c="dimmed" size="xs">
{total.currency}
</Text>
<Text fw={800} size="xl">
{formatMoney(total)}
</Text>
</Paper>
))}
</SimpleGrid>
{stocks.length ? (
<PositionSection title={t("portfolio.stocks")} positions={stocks} />
) : null}
{bonds.length ? (
<PositionSection title={t("portfolio.bonds")} positions={bonds} />
) : null}
{other.length ? (
<PositionSection
title={t("portfolio.otherPositions")}
positions={other}
/>
) : null}
<Section title={t("portfolio.cashBalances")}>
{portfolio.cashBalances.map((balance) => (
<Paper className={classes.cash} key={balance.currency} withBorder>
<Text>{balance.currency}</Text>
<Text fw={700}>{formatMoney(balance)}</Text>
</Paper>
))}
</Section>
</Stack>
);
}
function PositionSection({
title,
positions,
}: {
title: string;
positions: Portfolio["positions"];
}) {
const { t } = useTranslation();
return (
<Section title={title}>
{positions.map((position) => (
<Card
className={classes.row}
key={`${position.figi}-${position.displayName}-${position.quantity}`}
withBorder
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={2}>
<Text fw={700}>{position.displayName}</Text>
<Text c="dimmed" size="sm">
{t("portfolio.quantity", { value: position.quantity })}
</Text>
</Stack>
<Stack gap={2} align="flex-end">
<Text fw={700}>{formatMoney(position.currentValue)}</Text>
<Text c="dimmed" size="sm">
{t("portfolio.return", {
value: formatMoney(position.absoluteReturn),
})}
</Text>
</Stack>
</Group>
</Card>
))}
</Section>
);
}
function AccountHeading({ account }: { account?: BrokerageAccount }) {
const { t } = useTranslation();
return (
<Stack gap={2}>
<Title order={1}>{account?.name ?? t("portfolio.title")}</Title>
{account ? (
<Text c="dimmed" size="sm">
{t(accountTypeKey(account.type))}
</Text>
) : null}
</Stack>
);
}
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<Stack gap="sm">
<Title order={2}>{title}</Title>
{children}
</Stack>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { useQueryClient } from "@tanstack/react-query";
import { useParams } from "react-router";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { portfolioErrorCode, usePortfolio } from "@/entities/portfolio";
import { useAuth } from "@/features/auth";
const accountsQueryKey = ["brokerage-accounts"] as const;
export function useAccountPageViewModel() {
const { accountId } = useParams();
const { accessToken } = useAuth();
const portfolio = usePortfolio(accountId, accessToken);
const account = useQueryClient()
.getQueryData<BrokerageAccount[]>(accountsQueryKey)
?.find((item) => item.id === accountId);
if (portfolio.isError)
return {
state: "error" as const,
code: portfolioErrorCode(portfolio.error),
retry: () => portfolio.refetch(),
};
if (portfolio.isFetching || !portfolio.data)
return { state: "loading" as const };
if (!portfolio.data.positions.length && !portfolio.data.cashBalances.length)
return { state: "empty" as const, account };
return { state: "loaded" as const, account, portfolio: portfolio.data };
}
+16 -98
View File
@@ -1,103 +1,21 @@
import { Button, Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import {
BrokerageAccountList,
errorCode,
useBrokerageAccounts,
} from "@/entities/brokerage-account";
import { useAuth } from "@/features/auth";
import { StateView } from "@/shared/ui";
AccountsPageEmpty,
AccountsPageError,
AccountsPageLoaded,
AccountsPageLoading,
} from "./AccountsPage.view";
import { useAccountsPageViewModel } from "./AccountsPage.vm";
export function AccountsPage() {
const { t } = useTranslation();
const { accessToken } = useAuth();
const accounts = useBrokerageAccounts(accessToken);
const code = errorCode(accounts.error);
if (accounts.isFetching) return <AccountsPageLoading />;
if (accounts.isError)
return <AccountsPageError code={code} retry={() => accounts.refetch()} />;
if (!accounts.data?.length) return <AccountsPageEmpty />;
return (
<Stack>
<Title order={1}>{t("accounts.title")}</Title>
<BrokerageAccountList accounts={accounts.data} />
</Stack>
);
const viewModel = useAccountsPageViewModel();
if (viewModel.state === "loading") return <AccountsPageLoading />;
if (viewModel.state === "error") return <AccountsPageError {...viewModel} />;
if (viewModel.state === "empty") return <AccountsPageEmpty />;
return <AccountsPageLoaded accounts={viewModel.accounts} />;
}
export function AccountsPageLoading() {
const { t } = useTranslation();
return (
<StateView
loading
title={t("states.loadingTitle")}
text={t("states.loadingText")}
/>
);
}
export function AccountsPageError({
code,
retry,
}: {
code?: string;
retry?: () => void;
}) {
const { t } = useTranslation();
const settingsAction = (
<Button component={Link} to="/settings" variant="light">
{t("accounts.openSettings")}
</Button>
);
if (code === "TBANK_NOT_CONNECTED")
return (
<StateView
error
title={t("accounts.notConnectedTitle")}
text={t("accounts.notConnectedText")}
action={settingsAction}
/>
);
if (code === "INVALID_TBANK_TOKEN")
return (
<StateView
error
title={t("accounts.invalidTokenTitle")}
text={t("accounts.invalidTokenText")}
action={settingsAction}
/>
);
if (code === "TBANK_UNAVAILABLE")
return (
<StateView
error
title={t("accounts.unavailableTitle")}
text={t("accounts.unavailableText")}
action={
<Stack align="flex-start">
<Button onClick={retry}>{t("accounts.retry")}</Button>
{settingsAction}
</Stack>
}
/>
);
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
}
export function AccountsPageEmpty() {
const { t } = useTranslation();
return (
<StateView
title={t("accounts.emptyTitle")}
text={t("accounts.emptyText")}
/>
);
}
export {
AccountsPageEmpty,
AccountsPageError,
AccountsPageLoading,
} from "./AccountsPage.view";
@@ -0,0 +1,96 @@
import { Button, Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import type { BrokerageAccount } from "@/entities/brokerage-account";
import { BrokerageAccountList } from "@/entities/brokerage-account";
import { StateView } from "@/shared/ui";
export function AccountsPageLoading() {
const { t } = useTranslation();
return (
<StateView
loading
title={t("states.loadingTitle")}
text={t("states.loadingText")}
/>
);
}
export function AccountsPageError({
code,
retry,
}: {
code?: string;
retry?: () => void;
}) {
const { t } = useTranslation();
const settingsAction = (
<Button component={Link} to="/settings" variant="light">
{t("accounts.openSettings")}
</Button>
);
if (code === "TBANK_NOT_CONNECTED")
return (
<StateView
error
title={t("accounts.notConnectedTitle")}
text={t("accounts.notConnectedText")}
action={settingsAction}
/>
);
if (code === "INVALID_TBANK_TOKEN")
return (
<StateView
error
title={t("accounts.invalidTokenTitle")}
text={t("accounts.invalidTokenText")}
action={settingsAction}
/>
);
if (code === "TBANK_UNAVAILABLE")
return (
<StateView
error
title={t("accounts.unavailableTitle")}
text={t("accounts.unavailableText")}
action={
<Stack align="flex-start">
<Button onClick={retry}>{t("accounts.retry")}</Button>
{settingsAction}
</Stack>
}
/>
);
return (
<StateView
error
title={t("states.errorTitle")}
text={t("states.errorText")}
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
/>
);
}
export function AccountsPageEmpty() {
const { t } = useTranslation();
return (
<StateView
title={t("accounts.emptyTitle")}
text={t("accounts.emptyText")}
/>
);
}
export function AccountsPageLoaded({
accounts,
}: {
accounts: BrokerageAccount[];
}) {
const { t } = useTranslation();
return (
<Stack>
<Title order={1}>{t("accounts.title")}</Title>
<BrokerageAccountList accounts={accounts} />
</Stack>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { errorCode, useBrokerageAccounts } from "@/entities/brokerage-account";
import { useAuth } from "@/features/auth";
export function useAccountsPageViewModel() {
const { accessToken } = useAuth();
const accounts = useBrokerageAccounts(accessToken);
if (accounts.isFetching) return { state: "loading" as const };
if (accounts.isError)
return {
state: "error" as const,
code: errorCode(accounts.error),
retry: () => accounts.refetch(),
};
if (!accounts.data?.length) return { state: "empty" as const };
return { state: "loaded" as const, accounts: accounts.data };
}
+9 -16
View File
@@ -8,22 +8,15 @@ export function NotFoundPage() {
return (
<Stack>
<Title order={1}>{t("notFound.title")}</Title>
<NotFoundPageEmpty />
<StateView
title={t("states.emptyTitle")}
text={t("states.emptyText")}
action={
<Button component={Link} to="/accounts">
{t("notFound.action")}
</Button>
}
/>
</Stack>
);
}
function NotFoundPageEmpty() {
const { t } = useTranslation();
return (
<StateView
title={t("states.emptyTitle")}
text={t("states.emptyText")}
action={
<Button component={Link} to="/accounts">
{t("notFound.action")}
</Button>
}
/>
);
}
+5
View File
@@ -84,6 +84,11 @@ export const ru = {
retry: "Повторить",
},
notFound: { title: "Страница не найдена", action: "К счетам" },
routeError: {
title: "Не удалось открыть страницу",
text: "Перейдите в безопасный раздел и попробуйте снова.",
action: "Продолжить",
},
states: {
loadingTitle: "Загрузка",
loadingText: "Пожалуйста, подождите.",