diff --git a/AGENTS.md b/AGENTS.md
index 43db83b..b2ecfe5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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 остаются локальными.
## Границы
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 8135959..d3d6d94 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -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.
diff --git a/docs/UI-PATTERN.md b/docs/UI-PATTERN.md
index 4d8737a..de0eacc 100644
--- a/docs/UI-PATTERN.md
+++ b/docs/UI-PATTERN.md
@@ -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.
diff --git a/src/app/routes/RouteErrorBoundary.test.tsx b/src/app/routes/RouteErrorBoundary.test.tsx
new file mode 100644
index 0000000..738b6a7
--- /dev/null
+++ b/src/app/routes/RouteErrorBoundary.test.tsx
@@ -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 (
+
+ {useLocation().pathname === "/healthy" ? (
+ "healthy route"
+ ) : (
+
+ )}
+
+ );
+}
+function RouteHarness() {
+ const navigate = useNavigate();
+ return (
+ <>
+ navigate("/healthy")}>
+ go healthy
+
+
+
+
+ >
+ );
+}
+function renderBoundary(path: string) {
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+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(),
+ );
+ });
+});
diff --git a/src/app/routes/RouteErrorBoundary.tsx b/src/app/routes/RouteErrorBoundary.tsx
new file mode 100644
index 0000000..fbe6f83
--- /dev/null
+++ b/src/app/routes/RouteErrorBoundary.tsx
@@ -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 {
+ 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 ;
+ }
+ return this.props.children;
+ }
+}
+
+function RouteErrorFallback({ authenticated }: { authenticated: boolean }) {
+ const { t } = useTranslation();
+ const to = authenticated ? "/accounts" : "/login";
+ return (
+
+ {t("routeError.title")}
+ {t("routeError.text")}
+
+ {t("routeError.action")}
+
+
+ );
+}
+
+export function RouteErrorBoundary({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const { pathname, search } = useLocation();
+ const { user } = useAuth();
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/app/ui/App.tsx b/src/app/ui/App.tsx
index afd1f5a..b0b9fb4 100644
--- a/src/app/ui/App.tsx
+++ b/src/app/ui/App.tsx
@@ -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() {
-
+
+
+
diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.tsx b/src/features/tbank-connection/ui/TbankConnectionSection.tsx
index 43edf59..5574fec 100644
--- a/src/features/tbank-connection/ui/TbankConnectionSection.tsx
+++ b/src/features/tbank-connection/ui/TbankConnectionSection.tsx
@@ -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 ;
+ if (viewModel.state === "error")
+ return ;
+ return ;
+}
+
+function TbankConnectionSectionLoaded({ status }: { status: Status }) {
const { t } = useTranslation();
- const { accessToken } = useAuth();
const queryClient = useQueryClient();
const [deleteOpened, setDeleteOpened] = useState(false);
const [requestError, setRequestError] = useState(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 {t("states.loadingText")} ;
- if (status.isError)
- return (
-
- status.refetch()}>
- {t("tbank.retry")}
-
-
- );
- const connected = status.data.connected;
+ const connected = status.connected;
return (
{t("tbank.title")}
{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")}
diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.view.test.tsx b/src/features/tbank-connection/ui/TbankConnectionSection.view.test.tsx
new file mode 100644
index 0000000..5622b2b
--- /dev/null
+++ b/src/features/tbank-connection/ui/TbankConnectionSection.view.test.tsx
@@ -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(
+
+
+ ,
+ );
+ expect(screen.getByText("Пожалуйста, подождите.")).toBeInTheDocument();
+ rerender(
+
+
+ ,
+ );
+ screen.getByRole("button", { name: "Повторить" }).click();
+ expect(retry).toHaveBeenCalledOnce();
+ });
+});
diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.view.tsx b/src/features/tbank-connection/ui/TbankConnectionSection.view.tsx
new file mode 100644
index 0000000..7eaf751
--- /dev/null
+++ b/src/features/tbank-connection/ui/TbankConnectionSection.view.tsx
@@ -0,0 +1,17 @@
+import { Alert, Button, Text } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+
+export function TbankConnectionLoading() {
+ const { t } = useTranslation();
+ return {t("states.loadingText")} ;
+}
+export function TbankConnectionError({ retry }: { retry: () => void }) {
+ const { t } = useTranslation();
+ return (
+
+
+ {t("tbank.retry")}
+
+
+ );
+}
diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.vm.ts b/src/features/tbank-connection/ui/TbankConnectionSection.vm.ts
new file mode 100644
index 0000000..cb81a59
--- /dev/null
+++ b/src/features/tbank-connection/ui/TbankConnectionSection.vm.ts
@@ -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 };
+}
diff --git a/src/pages/account/ui/AccountPage.tsx b/src/pages/account/ui/AccountPage.tsx
index 4448860..5706d87 100644
--- a/src/pages/account/ui/AccountPage.tsx
+++ b/src/pages/account/ui/AccountPage.tsx
@@ -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(accountsQueryKey)
- ?.find((item) => item.id === accountId);
-
- if (portfolio.isFetching) return ;
- if (portfolio.isError)
- return (
- portfolio.refetch()}
- />
- );
- if (!portfolio.data) return ;
- if (!portfolio.data.positions.length && !portfolio.data.cashBalances.length)
- return ;
- return ;
-}
-
-export function AccountPageLoading() {
- const { t } = useTranslation();
+ const viewModel = useAccountPageViewModel();
+ if (viewModel.state === "loading") return ;
+ if (viewModel.state === "error") return ;
+ if (viewModel.state === "empty")
+ return ;
return (
-
);
}
-export function AccountPageError({
- code,
- retry,
-}: {
- code?: string;
- retry: () => void;
-}) {
- const { t } = useTranslation();
- if (code === "NOT_FOUND")
- return (
-
- {t("portfolio.back")}
-
- }
- />
- );
- if (code === "TBANK_NOT_CONNECTED" || code === "INVALID_TBANK_TOKEN")
- return (
-
- {t("accounts.openSettings")}
-
- }
- />
- );
- if (code === "TBANK_UNAVAILABLE")
- return (
- {t("accounts.retry")}}
- />
- );
- return (
- {t("accounts.retry")}}
- />
- );
-}
-
-export function AccountPageEmpty({ account }: { account?: BrokerageAccount }) {
- const { t } = useTranslation();
- return (
-
-
-
-
- );
-}
-
-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 (
-
-
- {portfolio.isStale ? (
- {t("portfolio.staleWarning")}
- ) : null}
-
- {t("portfolio.updatedAt", {
- date: formatSnapshotTime(portfolio.lastSuccessfulAt),
- })}
-
-
- {portfolio.totalsByCurrency.map((total) => (
-
-
- {total.currency}
-
-
- {formatMoney(total)}
-
-
- ))}
-
- {stocks.length ? (
-
- ) : null}
- {bonds.length ? (
-
- ) : null}
- {otherPositions.length ? (
-
- ) : null}
-
- {portfolio.cashBalances.map((balance) => (
-
- {balance.currency}
- {formatMoney(balance)}
-
- ))}
-
-
- );
-}
-
-function PositionSection({
- title,
- positions,
-}: {
- title: string;
- positions: Portfolio["positions"];
-}) {
- const { t } = useTranslation();
- return (
-
- {positions.map((position) => (
-
-
-
- {position.displayName}
-
- {t("portfolio.quantity", { value: position.quantity })}
-
-
-
- {formatMoney(position.currentValue)}
-
- {t("portfolio.return", {
- value: formatMoney(position.absoluteReturn),
- })}
-
-
-
-
- ))}
-
- );
-}
-
-function AccountHeading({ account }: { account?: BrokerageAccount }) {
- const { t } = useTranslation();
- return (
-
- {account?.name ?? t("portfolio.title")}
- {account ? (
-
- {t(accountTypeKey(account.type))}
-
- ) : null}
-
- );
-}
-
-function Section({
- title,
- children,
-}: {
- title: string;
- children: React.ReactNode;
-}) {
- return (
-
- {title}
- {children}
-
- );
-}
+export {
+ AccountPageEmpty,
+ AccountPageError,
+ AccountPageLoaded,
+ AccountPageLoading,
+} from "./AccountPage.view";
diff --git a/src/pages/account/ui/AccountPage.view.tsx b/src/pages/account/ui/AccountPage.view.tsx
new file mode 100644
index 0000000..2f70f25
--- /dev/null
+++ b/src/pages/account/ui/AccountPage.view.tsx
@@ -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 (
+
+ );
+}
+export function AccountPageError({
+ code,
+ retry,
+}: {
+ code?: string;
+ retry: () => void;
+}) {
+ const { t } = useTranslation();
+ if (code === "NOT_FOUND")
+ return (
+
+ {t("portfolio.back")}
+
+ }
+ />
+ );
+ if (code === "TBANK_NOT_CONNECTED" || code === "INVALID_TBANK_TOKEN")
+ return (
+
+ {t("accounts.openSettings")}
+
+ }
+ />
+ );
+ if (code === "TBANK_UNAVAILABLE")
+ return (
+ {t("accounts.retry")}}
+ />
+ );
+ return (
+ {t("accounts.retry")}}
+ />
+ );
+}
+export function AccountPageEmpty({ account }: { account?: BrokerageAccount }) {
+ const { t } = useTranslation();
+ return (
+
+
+
+
+ );
+}
+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 (
+
+
+ {portfolio.isStale ? (
+ {t("portfolio.staleWarning")}
+ ) : null}
+
+ {t("portfolio.updatedAt", {
+ date: formatSnapshotTime(portfolio.lastSuccessfulAt),
+ })}
+
+
+ {portfolio.totalsByCurrency.map((total) => (
+
+
+ {total.currency}
+
+
+ {formatMoney(total)}
+
+
+ ))}
+
+ {stocks.length ? (
+
+ ) : null}
+ {bonds.length ? (
+
+ ) : null}
+ {other.length ? (
+
+ ) : null}
+
+ {portfolio.cashBalances.map((balance) => (
+
+ {balance.currency}
+ {formatMoney(balance)}
+
+ ))}
+
+
+ );
+}
+function PositionSection({
+ title,
+ positions,
+}: {
+ title: string;
+ positions: Portfolio["positions"];
+}) {
+ const { t } = useTranslation();
+ return (
+
+ {positions.map((position) => (
+
+
+
+ {position.displayName}
+
+ {t("portfolio.quantity", { value: position.quantity })}
+
+
+
+ {formatMoney(position.currentValue)}
+
+ {t("portfolio.return", {
+ value: formatMoney(position.absoluteReturn),
+ })}
+
+
+
+
+ ))}
+
+ );
+}
+function AccountHeading({ account }: { account?: BrokerageAccount }) {
+ const { t } = useTranslation();
+ return (
+
+ {account?.name ?? t("portfolio.title")}
+ {account ? (
+
+ {t(accountTypeKey(account.type))}
+
+ ) : null}
+
+ );
+}
+function Section({
+ title,
+ children,
+}: {
+ title: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {title}
+ {children}
+
+ );
+}
diff --git a/src/pages/account/ui/AccountPage.vm.ts b/src/pages/account/ui/AccountPage.vm.ts
new file mode 100644
index 0000000..2369182
--- /dev/null
+++ b/src/pages/account/ui/AccountPage.vm.ts
@@ -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(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 };
+}
diff --git a/src/pages/accounts/ui/AccountsPage.tsx b/src/pages/accounts/ui/AccountsPage.tsx
index 8c35000..2ca3de0 100644
--- a/src/pages/accounts/ui/AccountsPage.tsx
+++ b/src/pages/accounts/ui/AccountsPage.tsx
@@ -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 ;
- if (accounts.isError)
- return accounts.refetch()} />;
- if (!accounts.data?.length) return ;
- return (
-
- {t("accounts.title")}
-
-
- );
+ const viewModel = useAccountsPageViewModel();
+ if (viewModel.state === "loading") return ;
+ if (viewModel.state === "error") return ;
+ if (viewModel.state === "empty") return ;
+ return ;
}
-export function AccountsPageLoading() {
- const { t } = useTranslation();
- return (
-
- );
-}
-
-export function AccountsPageError({
- code,
- retry,
-}: {
- code?: string;
- retry?: () => void;
-}) {
- const { t } = useTranslation();
- const settingsAction = (
-
- {t("accounts.openSettings")}
-
- );
- if (code === "TBANK_NOT_CONNECTED")
- return (
-
- );
- if (code === "INVALID_TBANK_TOKEN")
- return (
-
- );
- if (code === "TBANK_UNAVAILABLE")
- return (
-
- {t("accounts.retry")}
- {settingsAction}
-
- }
- />
- );
- return (
- {t("accounts.retry")}}
- />
- );
-}
-
-export function AccountsPageEmpty() {
- const { t } = useTranslation();
- return (
-
- );
-}
+export {
+ AccountsPageEmpty,
+ AccountsPageError,
+ AccountsPageLoading,
+} from "./AccountsPage.view";
diff --git a/src/pages/accounts/ui/AccountsPage.view.tsx b/src/pages/accounts/ui/AccountsPage.view.tsx
new file mode 100644
index 0000000..fdefe56
--- /dev/null
+++ b/src/pages/accounts/ui/AccountsPage.view.tsx
@@ -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 (
+
+ );
+}
+
+export function AccountsPageError({
+ code,
+ retry,
+}: {
+ code?: string;
+ retry?: () => void;
+}) {
+ const { t } = useTranslation();
+ const settingsAction = (
+
+ {t("accounts.openSettings")}
+
+ );
+ if (code === "TBANK_NOT_CONNECTED")
+ return (
+
+ );
+ if (code === "INVALID_TBANK_TOKEN")
+ return (
+
+ );
+ if (code === "TBANK_UNAVAILABLE")
+ return (
+
+ {t("accounts.retry")}
+ {settingsAction}
+
+ }
+ />
+ );
+ return (
+ {t("accounts.retry")}}
+ />
+ );
+}
+
+export function AccountsPageEmpty() {
+ const { t } = useTranslation();
+ return (
+
+ );
+}
+
+export function AccountsPageLoaded({
+ accounts,
+}: {
+ accounts: BrokerageAccount[];
+}) {
+ const { t } = useTranslation();
+ return (
+
+ {t("accounts.title")}
+
+
+ );
+}
diff --git a/src/pages/accounts/ui/AccountsPage.vm.ts b/src/pages/accounts/ui/AccountsPage.vm.ts
new file mode 100644
index 0000000..86c5357
--- /dev/null
+++ b/src/pages/accounts/ui/AccountsPage.vm.ts
@@ -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 };
+}
diff --git a/src/pages/not-found/ui/NotFoundPage.tsx b/src/pages/not-found/ui/NotFoundPage.tsx
index d4d299a..285367c 100644
--- a/src/pages/not-found/ui/NotFoundPage.tsx
+++ b/src/pages/not-found/ui/NotFoundPage.tsx
@@ -8,22 +8,15 @@ export function NotFoundPage() {
return (
{t("notFound.title")}
-
+
+ {t("notFound.action")}
+
+ }
+ />
);
}
-
-function NotFoundPageEmpty() {
- const { t } = useTranslation();
- return (
-
- {t("notFound.action")}
-
- }
- />
- );
-}
diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts
index a976323..4971086 100644
--- a/src/shared/i18n/locales/ru.ts
+++ b/src/shared/i18n/locales/ru.ts
@@ -84,6 +84,11 @@ export const ru = {
retry: "Повторить",
},
notFound: { title: "Страница не найдена", action: "К счетам" },
+ routeError: {
+ title: "Не удалось открыть страницу",
+ text: "Перейдите в безопасный раздел и попробуйте снова.",
+ action: "Продолжить",
+ },
states: {
loadingTitle: "Загрузка",
loadingText: "Пожалуйста, подождите.",