From 2cca503519888762067c770c188ed2d977979cbb Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Fri, 14 Aug 2026 10:24:24 +0300 Subject: [PATCH] refactor: little structure refactor --- AGENTS.md | 1 + docs/TESTING.md | 2 + docs/UI-PATTERN.md | 2 + src/app/routes/RouteErrorBoundary.test.tsx | 69 +++++ src/app/routes/RouteErrorBoundary.tsx | 61 ++++ src/app/ui/App.tsx | 5 +- .../ui/TbankConnectionSection.tsx | 39 ++- .../ui/TbankConnectionSection.view.test.tsx | 26 ++ .../ui/TbankConnectionSection.view.tsx | 17 ++ .../ui/TbankConnectionSection.vm.ts | 16 ++ src/pages/account/ui/AccountPage.tsx | 272 ++---------------- src/pages/account/ui/AccountPage.view.tsx | 217 ++++++++++++++ src/pages/account/ui/AccountPage.vm.ts | 27 ++ src/pages/accounts/ui/AccountsPage.tsx | 114 ++------ src/pages/accounts/ui/AccountsPage.view.tsx | 96 +++++++ src/pages/accounts/ui/AccountsPage.vm.ts | 16 ++ src/pages/not-found/ui/NotFoundPage.tsx | 25 +- src/shared/i18n/locales/ru.ts | 5 + 18 files changed, 622 insertions(+), 388 deletions(-) create mode 100644 src/app/routes/RouteErrorBoundary.test.tsx create mode 100644 src/app/routes/RouteErrorBoundary.tsx create mode 100644 src/features/tbank-connection/ui/TbankConnectionSection.view.test.tsx create mode 100644 src/features/tbank-connection/ui/TbankConnectionSection.view.tsx create mode 100644 src/features/tbank-connection/ui/TbankConnectionSection.vm.ts create mode 100644 src/pages/account/ui/AccountPage.view.tsx create mode 100644 src/pages/account/ui/AccountPage.vm.ts create mode 100644 src/pages/accounts/ui/AccountsPage.view.tsx create mode 100644 src/pages/accounts/ui/AccountsPage.vm.ts 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 ( + <> + + + + + + ); +} +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")} + + + ); +} + +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 ( - - - - ); - 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 ( + + + + ); +} 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 = ( - - ); - if (code === "TBANK_NOT_CONNECTED") - return ( - - ); - if (code === "INVALID_TBANK_TOKEN") - return ( - - ); - if (code === "TBANK_UNAVAILABLE") - return ( - - - {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 = ( + + ); + if (code === "TBANK_NOT_CONNECTED") + return ( + + ); + if (code === "INVALID_TBANK_TOKEN") + return ( + + ); + if (code === "TBANK_UNAVAILABLE") + return ( + + + {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: "Пожалуйста, подождите.",