chore: codify React component style
This commit is contained in:
@@ -28,6 +28,7 @@ Gate non-mutating и проверяет runtime, OpenAPI snapshot, hygiene, stri
|
|||||||
## Enforceable boundaries
|
## Enforceable boundaries
|
||||||
|
|
||||||
- Code style и i18n: [`docs/CONVENTIONS.md`](docs/CONVENTIONS.md), исполняются Biome и TypeScript.
|
- Code style и i18n: [`docs/CONVENTIONS.md`](docs/CONVENTIONS.md), исполняются Biome и TypeScript.
|
||||||
|
- React component declaration, props и export style: [`docs/CONVENTIONS.md`](docs/CONVENTIONS.md), часть исполняется Biome, часть является mandatory review convention.
|
||||||
- FSD и public API: [`docs/STRUCTURE.md`](docs/STRUCTURE.md), исполняются dependency-cruiser и Knip; направление `app → pages → features → entities → shared`.
|
- FSD и public API: [`docs/STRUCTURE.md`](docs/STRUCTURE.md), исполняются dependency-cruiser и Knip; направление `app → pages → features → entities → shared`.
|
||||||
- HTTP и types: [`docs/API-LAYER.md`](docs/API-LAYER.md); только typed client и generated OpenAPI `paths[...]`, без ручных transport DTO и прямых T-Bank calls.
|
- HTTP и types: [`docs/API-LAYER.md`](docs/API-LAYER.md); только typed client и generated OpenAPI `paths[...]`, без ручных transport DTO и прямых T-Bank calls.
|
||||||
- UI states и VM/view boundary: [`docs/UI-PATTERN.md`](docs/UI-PATTERN.md); container вызывает page VM hook, VM владеет data/state/derived presentation data, `*.view.tsx` получает props и рендерит. View files дробить по semantic responsibility, когда section/state можно назвать отдельно.
|
- UI states и VM/view boundary: [`docs/UI-PATTERN.md`](docs/UI-PATTERN.md); container вызывает page VM hook, VM владеет data/state/derived presentation data, `*.view.tsx` получает props и рендерит. View files дробить по semantic responsibility, когда section/state можно назвать отдельно.
|
||||||
|
|||||||
+25
-1
@@ -15,7 +15,31 @@
|
|||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"rules": {
|
"rules": {
|
||||||
"preset": "recommended"
|
"preset": "recommended",
|
||||||
|
"nursery": {
|
||||||
|
"useReactFunctionComponentDefinition": {
|
||||||
|
"level": "error",
|
||||||
|
"options": {
|
||||||
|
"namedComponents": "arrowFunction"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"noDefaultExport": "error",
|
||||||
|
"useExportsLast": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"includes": ["vite.config.ts", "vitest.config.ts"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"style": {
|
||||||
|
"noDefaultExport": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,24 @@
|
|||||||
- Зависимости используют exact versions; не добавлять semver ranges.
|
- Зависимости используют exact versions; не добавлять semver ranges.
|
||||||
- Полный локальный gate: `npm run verify`. Pre-commit запускает staged formatting, OpenAPI check и FSD dependency checks; commit message должен следовать Conventional Commits.
|
- Полный локальный gate: `npm run verify`. Pre-commit запускает staged formatting, OpenAPI check и FSD dependency checks; commit message должен следовать Conventional Commits.
|
||||||
|
|
||||||
|
## React component style
|
||||||
|
|
||||||
|
Biome-enforced guardrails:
|
||||||
|
|
||||||
|
- Named React components объявлять как const-valued components, а не function declarations.
|
||||||
|
- Не использовать default exports.
|
||||||
|
- Export statements держать после declarations тела module.
|
||||||
|
|
||||||
|
Mandatory review conventions:
|
||||||
|
|
||||||
|
- React components типизировать через `FC`: `const Component: FC<Props> = (props) => { ... }`; если props нет — `const Component: FC = () => { ... }`.
|
||||||
|
- Props описывать named type рядом с component либо в `Component.types.ts`, если type переиспользуется или file становится крупным.
|
||||||
|
- Не использовать inline props typing в component parameters.
|
||||||
|
- Для components только с `children` использовать `FC<PropsWithChildren>` без отдельного props alias. Если кроме `children` есть другие props, использовать named props type на основе `PropsWithChildren<{ ... }>`.
|
||||||
|
- Не destructure props в component parameters; принимать `props` и destructure внутри component body.
|
||||||
|
- Обычный module export surface сводить к одному нижнему named export block, когда это выражается читаемо без потери public API.
|
||||||
|
- Чистые barrel files должны использовать прямой re-export без промежуточного import: `export { Component } from "./Component";`.
|
||||||
|
|
||||||
## File roles
|
## File roles
|
||||||
|
|
||||||
- `Component.tsx` - контейнер или публичный компонент; для data-driven pages вызывает page VM hook и выбирает top-level state view.
|
- `Component.tsx` - контейнер или публичный компонент; для data-driven pages вызывает page VM hook и выбирает top-level state view.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { readFile, writeFile } from "node:fs/promises";
|
|||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
export const INITIAL_BUNDLE_BUDGET_BYTES = 500 * 1024;
|
const INITIAL_BUNDLE_BUDGET_BYTES = 500 * 1024;
|
||||||
|
|
||||||
function manifestEntries(manifest) {
|
function manifestEntries(manifest) {
|
||||||
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
|
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
|
||||||
@@ -21,7 +21,7 @@ function assetFile(entry, entryName) {
|
|||||||
return entry.file;
|
return entry.file;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInitialAssetFiles(manifest) {
|
function getInitialAssetFiles(manifest) {
|
||||||
const entries = manifestEntries(manifest);
|
const entries = manifestEntries(manifest);
|
||||||
const entryCandidates = entries.filter(
|
const entryCandidates = entries.filter(
|
||||||
([, entry]) => entry?.isEntry === true,
|
([, entry]) => entry?.isEntry === true,
|
||||||
@@ -46,7 +46,7 @@ export function getInitialAssetFiles(manifest) {
|
|||||||
return files;
|
return files;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function measureAssets(files, getSize) {
|
function measureAssets(files, getSize) {
|
||||||
return files.reduce((total, file) => {
|
return files.reduce((total, file) => {
|
||||||
const size = getSize(file);
|
const size = getSize(file);
|
||||||
if (!Number.isSafeInteger(size) || size < 0) {
|
if (!Number.isSafeInteger(size) || size < 0) {
|
||||||
@@ -56,7 +56,7 @@ export function measureAssets(files, getSize) {
|
|||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertBundleBudget(initialBytes) {
|
function assertBundleBudget(initialBytes) {
|
||||||
if (initialBytes > INITIAL_BUNDLE_BUDGET_BYTES) {
|
if (initialBytes > INITIAL_BUNDLE_BUDGET_BYTES) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Initial JavaScript payload is ${initialBytes} bytes; budget is ${INITIAL_BUNDLE_BUDGET_BYTES} bytes`,
|
`Initial JavaScript payload is ${initialBytes} bytes; budget is ${INITIAL_BUNDLE_BUDGET_BYTES} bytes`,
|
||||||
@@ -64,7 +64,7 @@ export function assertBundleBudget(initialBytes) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLazyRouteAssetSizes(manifest, getSize, baseline) {
|
function getLazyRouteAssetSizes(manifest, getSize, baseline) {
|
||||||
const routeEntries = manifestEntries(manifest).filter(
|
const routeEntries = manifestEntries(manifest).filter(
|
||||||
([, entry]) =>
|
([, entry]) =>
|
||||||
entry?.isDynamicEntry === true && typeof entry.src === "string",
|
entry?.isDynamicEntry === true && typeof entry.src === "string",
|
||||||
@@ -142,3 +142,11 @@ if (
|
|||||||
}
|
}
|
||||||
await run(mode);
|
await run(mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
assertBundleBudget,
|
||||||
|
getInitialAssetFiles,
|
||||||
|
getLazyRouteAssetSizes,
|
||||||
|
INITIAL_BUNDLE_BUDGET_BYTES,
|
||||||
|
measureAssets,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Loader } from "@mantine/core";
|
import { Loader } from "@mantine/core";
|
||||||
|
import type { FC, PropsWithChildren } from "react";
|
||||||
import { lazy, Suspense } from "react";
|
import { lazy, Suspense } from "react";
|
||||||
import { Navigate, Route, Routes, useLocation } from "react-router";
|
import { Navigate, Route, Routes, useLocation } from "react-router";
|
||||||
import { useAuth } from "@/features/auth";
|
import { useAuth } from "@/features/auth";
|
||||||
@@ -40,11 +41,14 @@ const ProtectedLayout = lazy(async () => {
|
|||||||
return { default: ProtectedLayout };
|
return { default: ProtectedLayout };
|
||||||
});
|
});
|
||||||
|
|
||||||
function LazyPage({ children }: { children: React.ReactNode }) {
|
const LazyPage: FC<PropsWithChildren> = (props) => {
|
||||||
return <Suspense fallback={<Loader />}>{children}</Suspense>;
|
const { children } = props;
|
||||||
}
|
|
||||||
|
|
||||||
function Protected({ children }: { children: React.ReactNode }) {
|
return <Suspense fallback={<Loader />}>{children}</Suspense>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Protected: FC<PropsWithChildren> = (props) => {
|
||||||
|
const { children } = props;
|
||||||
const { user, ready } = useAuth();
|
const { user, ready } = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
if (!ready) return null;
|
if (!ready) return null;
|
||||||
@@ -57,15 +61,16 @@ function Protected({ children }: { children: React.ReactNode }) {
|
|||||||
replace
|
replace
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function Public({ children }: { children: React.ReactNode }) {
|
const Public: FC<PropsWithChildren> = (props) => {
|
||||||
|
const { children } = props;
|
||||||
const { user, ready } = useAuth();
|
const { user, ready } = useAuth();
|
||||||
if (!ready) return null;
|
if (!ready) return null;
|
||||||
return user ? <Navigate to="/accounts" replace /> : children;
|
return user ? <Navigate to="/accounts" replace /> : children;
|
||||||
}
|
};
|
||||||
|
|
||||||
export function AppRoutes() {
|
const AppRoutes: FC = () => {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/accounts" replace />} />
|
<Route path="/" element={<Navigate to="/accounts" replace />} />
|
||||||
@@ -149,4 +154,6 @@ export function AppRoutes() {
|
|||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { AppRoutes };
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
import { AppBreadcrumbs } from "@/app/ui";
|
import { AppBreadcrumbs } from "@/app/ui";
|
||||||
import { AppShell } from "@/shared/ui";
|
import { AppShell } from "@/shared/ui";
|
||||||
|
|
||||||
export function ProtectedLayout() {
|
const ProtectedLayout: FC = () => {
|
||||||
return <AppShell contentAboveOutlet={<AppBreadcrumbs />} />;
|
return <AppShell contentAboveOutlet={<AppBreadcrumbs />} />;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { ProtectedLayout };
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import type { FC } from "react";
|
||||||
import { MemoryRouter, useLocation, useNavigate } from "react-router";
|
import { MemoryRouter, useLocation, useNavigate } from "react-router";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { RouteErrorBoundary } from "./RouteErrorBoundary";
|
import { RouteErrorBoundary } from "./RouteErrorBoundary";
|
||||||
@@ -9,10 +10,11 @@ vi.mock("@/features/auth", () => ({
|
|||||||
useAuth: () => ({ user: authenticated ? {} : null }),
|
useAuth: () => ({ user: authenticated ? {} : null }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function ThrowingRoute(): never {
|
const ThrowingRoute: FC = () => {
|
||||||
throw new Error("test route failure");
|
throw new Error("test route failure");
|
||||||
}
|
};
|
||||||
function HealthyRoute() {
|
|
||||||
|
const HealthyRoute: FC = () => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{useLocation().pathname === "/healthy" ? (
|
{useLocation().pathname === "/healthy" ? (
|
||||||
@@ -22,9 +24,11 @@ function HealthyRoute() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
function RouteHarness() {
|
|
||||||
|
const RouteHarness: FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button type="button" onClick={() => navigate("/healthy")}>
|
<button type="button" onClick={() => navigate("/healthy")}>
|
||||||
@@ -35,7 +39,8 @@ function RouteHarness() {
|
|||||||
</RouteErrorBoundary>
|
</RouteErrorBoundary>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function renderBoundary(path: string) {
|
function renderBoundary(path: string) {
|
||||||
return render(
|
return render(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
|
|||||||
@@ -1,24 +1,29 @@
|
|||||||
import { Button, Stack, Text, Title } from "@mantine/core";
|
import { Button, Stack, Text, Title } from "@mantine/core";
|
||||||
|
import type { FC, PropsWithChildren } from "react";
|
||||||
import { Component } from "react";
|
import { Component } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useLocation } from "react-router";
|
import { Link, useLocation } from "react-router";
|
||||||
import { useAuth } from "@/features/auth";
|
import { useAuth } from "@/features/auth";
|
||||||
|
|
||||||
type Props = {
|
type BoundaryProps = PropsWithChildren<{
|
||||||
children: React.ReactNode;
|
|
||||||
routeKey: string;
|
routeKey: string;
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type RouteErrorFallbackProps = {
|
||||||
|
authenticated: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type State = { hasError: boolean };
|
type State = { hasError: boolean };
|
||||||
|
|
||||||
class Boundary extends Component<Props, State> {
|
class Boundary extends Component<BoundaryProps, State> {
|
||||||
state: State = { hasError: false };
|
state: State = { hasError: false };
|
||||||
|
|
||||||
static getDerivedStateFromError(): State {
|
static getDerivedStateFromError(): State {
|
||||||
return { hasError: true };
|
return { hasError: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(previousProps: Props) {
|
componentDidUpdate(previousProps: BoundaryProps) {
|
||||||
if (previousProps.routeKey !== this.props.routeKey && this.state.hasError) {
|
if (previousProps.routeKey !== this.props.routeKey && this.state.hasError) {
|
||||||
this.setState({ hasError: false });
|
this.setState({ hasError: false });
|
||||||
}
|
}
|
||||||
@@ -32,9 +37,11 @@ class Boundary extends Component<Props, State> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function RouteErrorFallback({ authenticated }: { authenticated: boolean }) {
|
const RouteErrorFallback: FC<RouteErrorFallbackProps> = (props) => {
|
||||||
|
const { authenticated } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const to = authenticated ? "/accounts" : "/login";
|
const to = authenticated ? "/accounts" : "/login";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>{t("routeError.title")}</Title>
|
<Title order={1}>{t("routeError.title")}</Title>
|
||||||
@@ -44,18 +51,18 @@ function RouteErrorFallback({ authenticated }: { authenticated: boolean }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export function RouteErrorBoundary({
|
const RouteErrorBoundary: FC<PropsWithChildren> = (props) => {
|
||||||
children,
|
const { children } = props;
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const { pathname, search } = useLocation();
|
const { pathname, search } = useLocation();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Boundary authenticated={Boolean(user)} routeKey={`${pathname}${search}`}>
|
<Boundary authenticated={Boolean(user)} routeKey={`${pathname}${search}`}>
|
||||||
{children}
|
{children}
|
||||||
</Boundary>
|
</Boundary>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { RouteErrorBoundary };
|
||||||
|
|||||||
+3
-1
@@ -1,6 +1,6 @@
|
|||||||
import { createTheme } from "@mantine/core";
|
import { createTheme } from "@mantine/core";
|
||||||
|
|
||||||
export const theme = createTheme({
|
const theme = createTheme({
|
||||||
primaryColor: "blue",
|
primaryColor: "blue",
|
||||||
defaultRadius: "md",
|
defaultRadius: "md",
|
||||||
fontFamily:
|
fontFamily:
|
||||||
@@ -20,3 +20,5 @@ export const theme = createTheme({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export { theme };
|
||||||
|
|||||||
+5
-2
@@ -1,5 +1,6 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { FC } from "react";
|
||||||
import { BrowserRouter } from "react-router";
|
import { BrowserRouter } from "react-router";
|
||||||
import { AppRoutes, RouteErrorBoundary } from "@/app/routes";
|
import { AppRoutes, RouteErrorBoundary } from "@/app/routes";
|
||||||
import { theme } from "@/app/theme";
|
import { theme } from "@/app/theme";
|
||||||
@@ -9,7 +10,7 @@ const queryClient = new QueryClient({
|
|||||||
defaultOptions: { queries: { refetchOnWindowFocus: false } },
|
defaultOptions: { queries: { refetchOnWindowFocus: false } },
|
||||||
});
|
});
|
||||||
|
|
||||||
export function App() {
|
const App: FC = () => {
|
||||||
return (
|
return (
|
||||||
<MantineProvider theme={theme} forceColorScheme="light">
|
<MantineProvider theme={theme} forceColorScheme="light">
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -23,4 +24,6 @@ export function App() {
|
|||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { App };
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useMatch } from "react-router";
|
import { Link, useMatch } from "react-router";
|
||||||
import { useBrokerageAccounts } from "@/entities/brokerage-account";
|
import { useBrokerageAccounts } from "@/entities/brokerage-account";
|
||||||
|
|
||||||
type Crumb = { label: string; to?: string };
|
type Crumb = { label: string; to?: string };
|
||||||
|
|
||||||
function BreadcrumbTrail({ items }: { items: Crumb[] }) {
|
type BreadcrumbTrailProps = {
|
||||||
|
items: Crumb[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type AccountBreadcrumbsProps = {
|
||||||
|
accountId: string;
|
||||||
|
events: boolean;
|
||||||
|
operations: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BreadcrumbTrail: FC<BreadcrumbTrailProps> = (props) => {
|
||||||
|
const { items } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
aria-label={t("breadcrumbs.label")}
|
aria-label={t("breadcrumbs.label")}
|
||||||
@@ -33,23 +46,17 @@ function BreadcrumbTrail({ items }: { items: Crumb[] }) {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function AccountBreadcrumbs({
|
const AccountBreadcrumbs: FC<AccountBreadcrumbsProps> = (props) => {
|
||||||
accountId,
|
const { accountId, events, operations } = props;
|
||||||
events,
|
|
||||||
operations,
|
|
||||||
}: {
|
|
||||||
accountId: string;
|
|
||||||
events: boolean;
|
|
||||||
operations: boolean;
|
|
||||||
}) {
|
|
||||||
const { data: accounts } = useBrokerageAccounts();
|
const { data: accounts } = useBrokerageAccounts();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const accountName =
|
const accountName =
|
||||||
accounts?.find((account) => account.id === accountId)?.name ??
|
accounts?.find((account) => account.id === accountId)?.name ??
|
||||||
t("breadcrumbs.accountFallback");
|
t("breadcrumbs.accountFallback");
|
||||||
const accountPath = `/accounts/${accountId}`;
|
const accountPath = `/accounts/${accountId}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BreadcrumbTrail
|
<BreadcrumbTrail
|
||||||
items={
|
items={
|
||||||
@@ -72,9 +79,9 @@ function AccountBreadcrumbs({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export function AppBreadcrumbs() {
|
const AppBreadcrumbs: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const accountOperations = useMatch({
|
const accountOperations = useMatch({
|
||||||
path: "/accounts/:accountId/operations",
|
path: "/accounts/:accountId/operations",
|
||||||
@@ -117,4 +124,6 @@ export function AppBreadcrumbs() {
|
|||||||
if (settings)
|
if (settings)
|
||||||
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.settings") }]} />;
|
return <BreadcrumbTrail items={[{ label: t("breadcrumbs.settings") }]} />;
|
||||||
return null;
|
return null;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { AppBreadcrumbs };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { renderHook, waitFor } from "@testing-library/react";
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
import type { ReactNode } from "react";
|
import type { FC, PropsWithChildren } from "react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
import {
|
import {
|
||||||
@@ -25,11 +25,15 @@ describe("useBrokerageOperations", () => {
|
|||||||
}),
|
}),
|
||||||
).toEqual({ categories: ["income", "tax"], cursor: "next" });
|
).toEqual({ categories: ["income", "tax"], cursor: "next" });
|
||||||
const client = new QueryClient();
|
const client = new QueryClient();
|
||||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
const wrapper: FC<PropsWithChildren> = (props) => {
|
||||||
|
const { children } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const { result } = renderHook(
|
const { result } = renderHook(
|
||||||
() => useBrokerageOperations("account-1", { limit: 5 }),
|
() => useBrokerageOperations("account-1", { limit: 5 }),
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import type { paths } from "@/shared/api/generated/schema";
|
|||||||
type OperationsEndpoint =
|
type OperationsEndpoint =
|
||||||
paths["/api/v1/brokerage-accounts/{accountId}/operations"]["get"];
|
paths["/api/v1/brokerage-accounts/{accountId}/operations"]["get"];
|
||||||
|
|
||||||
export type BrokerageOperation =
|
type BrokerageOperation =
|
||||||
OperationsEndpoint["responses"][200]["content"]["application/json"]["items"][number];
|
OperationsEndpoint["responses"][200]["content"]["application/json"]["items"][number];
|
||||||
type BrokerageOperationsQuery = NonNullable<
|
type BrokerageOperationsQuery = NonNullable<
|
||||||
OperationsEndpoint["parameters"]["query"]
|
OperationsEndpoint["parameters"]["query"]
|
||||||
>;
|
>;
|
||||||
export type OperationCategory = BrokerageOperation["category"];
|
type OperationCategory = BrokerageOperation["category"];
|
||||||
|
|
||||||
export const operationCategories = [
|
const operationCategories = [
|
||||||
"trade",
|
"trade",
|
||||||
"income",
|
"income",
|
||||||
"tax",
|
"tax",
|
||||||
@@ -20,9 +20,7 @@ export const operationCategories = [
|
|||||||
"other",
|
"other",
|
||||||
] as const satisfies readonly OperationCategory[];
|
] as const satisfies readonly OperationCategory[];
|
||||||
|
|
||||||
export function brokerageOperationsQueryParams(
|
function brokerageOperationsQueryParams(query: BrokerageOperationsQuery) {
|
||||||
query: BrokerageOperationsQuery,
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
...(query.from ? { from: query.from } : {}),
|
...(query.from ? { from: query.from } : {}),
|
||||||
...(query.to ? { to: query.to } : {}),
|
...(query.to ? { to: query.to } : {}),
|
||||||
@@ -34,7 +32,7 @@ export function brokerageOperationsQueryParams(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBrokerageOperations(
|
function useBrokerageOperations(
|
||||||
accountId: string | undefined,
|
accountId: string | undefined,
|
||||||
query: BrokerageOperationsQuery = {},
|
query: BrokerageOperationsQuery = {},
|
||||||
) {
|
) {
|
||||||
@@ -50,3 +48,11 @@ export function useBrokerageOperations(
|
|||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
type BrokerageOperation,
|
||||||
|
brokerageOperationsQueryParams,
|
||||||
|
type OperationCategory,
|
||||||
|
operationCategories,
|
||||||
|
useBrokerageOperations,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
export type {
|
|
||||||
BrokerageOperation,
|
|
||||||
OperationCategory,
|
|
||||||
} from "./api/useBrokerageOperations";
|
|
||||||
export {
|
export {
|
||||||
|
type BrokerageOperation,
|
||||||
|
type OperationCategory,
|
||||||
operationCategories,
|
operationCategories,
|
||||||
useBrokerageOperations,
|
useBrokerageOperations,
|
||||||
} from "./api/useBrokerageOperations";
|
} from "./api/useBrokerageOperations";
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { MantineProvider } from "@mantine/core";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { renderHook, waitFor } from "@testing-library/react";
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import type { ReactNode } from "react";
|
import type { FC, PropsWithChildren } from "react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
@@ -10,11 +10,17 @@ import { useBrokerageAccounts } from "./useBrokerageAccounts";
|
|||||||
|
|
||||||
function createWrapper() {
|
function createWrapper() {
|
||||||
const client = new QueryClient();
|
const client = new QueryClient();
|
||||||
return ({ children }: { children: ReactNode }) => (
|
const TestWrapper: FC<PropsWithChildren> = (props) => {
|
||||||
|
const { children } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return TestWrapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("useBrokerageAccounts", () => {
|
describe("useBrokerageAccounts", () => {
|
||||||
|
|||||||
@@ -4,16 +4,21 @@ import type { paths } from "@/shared/api/generated/schema";
|
|||||||
const brokerageAccountsQueryOptions = () =>
|
const brokerageAccountsQueryOptions = () =>
|
||||||
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts");
|
apiQueryClient.queryOptions("get", "/api/v1/brokerage-accounts");
|
||||||
|
|
||||||
export const brokerageAccountsQueryKey =
|
const brokerageAccountsQueryKey = brokerageAccountsQueryOptions().queryKey;
|
||||||
brokerageAccountsQueryOptions().queryKey;
|
|
||||||
|
|
||||||
export type BrokerageAccount =
|
type BrokerageAccount =
|
||||||
paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"][number];
|
paths["/api/v1/brokerage-accounts"]["get"]["responses"][200]["content"]["application/json"][number];
|
||||||
|
|
||||||
export function useBrokerageAccounts() {
|
function useBrokerageAccounts() {
|
||||||
return useAuthenticatedQuery({
|
return useAuthenticatedQuery({
|
||||||
...brokerageAccountsQueryOptions(),
|
...brokerageAccountsQueryOptions(),
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
type BrokerageAccount,
|
||||||
|
brokerageAccountsQueryKey,
|
||||||
|
useBrokerageAccounts,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export type { BrokerageAccount } from "./api/useBrokerageAccounts";
|
|
||||||
export {
|
export {
|
||||||
|
type BrokerageAccount,
|
||||||
brokerageAccountsQueryKey,
|
brokerageAccountsQueryKey,
|
||||||
useBrokerageAccounts,
|
useBrokerageAccounts,
|
||||||
} from "./api/useBrokerageAccounts";
|
} from "./api/useBrokerageAccounts";
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import type { BrokerageAccount } from "../api/useBrokerageAccounts";
|
import type { BrokerageAccount } from "../api/useBrokerageAccounts";
|
||||||
|
|
||||||
export function accountTypeKey(type: BrokerageAccount["type"]) {
|
function accountTypeKey(type: BrokerageAccount["type"]) {
|
||||||
return `accounts.types.${type}` as const;
|
return `accounts.types.${type}` as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function errorCode(error: unknown): string | undefined {
|
function errorCode(error: unknown): string | undefined {
|
||||||
return typeof error === "object" && error !== null && "code" in error
|
return typeof error === "object" && error !== null && "code" in error
|
||||||
? String(error.code)
|
? String(error.code)
|
||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { accountTypeKey, errorCode };
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Card, SimpleGrid, Stack, Text } from "@mantine/core";
|
import { Card, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||||
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import type { BrokerageAccount } from "../api/useBrokerageAccounts";
|
import type { BrokerageAccount } from "../api/useBrokerageAccounts";
|
||||||
@@ -7,8 +8,10 @@ import classes from "./BrokerageAccountList.module.css";
|
|||||||
|
|
||||||
type BrokerageAccountListProps = { accounts: BrokerageAccount[] };
|
type BrokerageAccountListProps = { accounts: BrokerageAccount[] };
|
||||||
|
|
||||||
export function BrokerageAccountList({ accounts }: BrokerageAccountListProps) {
|
const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => {
|
||||||
|
const { accounts } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
{accounts.map((account) => (
|
{accounts.map((account) => (
|
||||||
@@ -43,4 +46,6 @@ export function BrokerageAccountList({ accounts }: BrokerageAccountListProps) {
|
|||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { BrokerageAccountList };
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { MantineProvider } from "@mantine/core";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { renderHook, waitFor } from "@testing-library/react";
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import type { ReactNode } from "react";
|
import type { FC, PropsWithChildren } from "react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
@@ -10,11 +10,17 @@ import { useBrokerageEvents } from "./useBrokerageEvents";
|
|||||||
|
|
||||||
function createWrapper() {
|
function createWrapper() {
|
||||||
const client = new QueryClient();
|
const client = new QueryClient();
|
||||||
return ({ children }: { children: ReactNode }) => (
|
const TestWrapper: FC<PropsWithChildren> = (props) => {
|
||||||
|
const { children } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return TestWrapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("useBrokerageEvents", () => {
|
describe("useBrokerageEvents", () => {
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import type { paths } from "@/shared/api/generated/schema";
|
|||||||
|
|
||||||
type EventsEndpoint =
|
type EventsEndpoint =
|
||||||
paths["/api/v1/brokerage-accounts/{accountId}/events"]["get"];
|
paths["/api/v1/brokerage-accounts/{accountId}/events"]["get"];
|
||||||
export type BrokerageEvent =
|
type BrokerageEvent =
|
||||||
EventsEndpoint["responses"][200]["content"]["application/json"]["events"][number];
|
EventsEndpoint["responses"][200]["content"]["application/json"]["events"][number];
|
||||||
|
|
||||||
export function useBrokerageEvents(accountId: string | undefined) {
|
function useBrokerageEvents(accountId: string | undefined) {
|
||||||
return useAuthenticatedQuery({
|
return useAuthenticatedQuery({
|
||||||
...apiQueryClient.queryOptions(
|
...apiQueryClient.queryOptions(
|
||||||
"get",
|
"get",
|
||||||
@@ -18,3 +18,5 @@ export function useBrokerageEvents(accountId: string | undefined) {
|
|||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { type BrokerageEvent, useBrokerageEvents };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { renderHook, waitFor } from "@testing-library/react";
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
import type { ReactNode } from "react";
|
import type { FC, PropsWithChildren } from "react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { configureAuthClient } from "@/shared/api";
|
import { configureAuthClient } from "@/shared/api";
|
||||||
@@ -17,11 +17,15 @@ describe("usePortfolio", () => {
|
|||||||
clear: vi.fn(),
|
clear: vi.fn(),
|
||||||
});
|
});
|
||||||
const client = new QueryClient();
|
const client = new QueryClient();
|
||||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
const wrapper: FC<PropsWithChildren> = (props) => {
|
||||||
|
const { children } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const { result } = renderHook(() => usePortfolio("account-1"), {
|
const { result } = renderHook(() => usePortfolio("account-1"), {
|
||||||
wrapper,
|
wrapper,
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ const portfolioQueryPolicy = {
|
|||||||
retry: false,
|
retry: false,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type Portfolio =
|
type Portfolio =
|
||||||
paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"];
|
paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"];
|
||||||
|
|
||||||
export function usePortfolio(accountId: string | undefined) {
|
function usePortfolio(accountId: string | undefined) {
|
||||||
return useAuthenticatedQuery({
|
return useAuthenticatedQuery({
|
||||||
...apiQueryClient.queryOptions(
|
...apiQueryClient.queryOptions(
|
||||||
"get",
|
"get",
|
||||||
@@ -20,3 +20,5 @@ export function usePortfolio(accountId: string | undefined) {
|
|||||||
...portfolioQueryPolicy,
|
...portfolioQueryPolicy,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { type Portfolio, usePortfolio };
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
export type { Portfolio } from "./api/usePortfolio";
|
export { type Portfolio, usePortfolio } from "./api/usePortfolio";
|
||||||
export { usePortfolio } from "./api/usePortfolio";
|
|
||||||
export {
|
export {
|
||||||
formatMoney,
|
formatMoney,
|
||||||
formatShare,
|
formatShare,
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ function formatDecimal(value: string): string {
|
|||||||
return `${sign}${grouped}${fraction ? `,${fraction}` : ""}`;
|
return `${sign}${grouped}${fraction ? `,${fraction}` : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatMoney(money: Money | null): string {
|
function formatMoney(money: Money | null): string {
|
||||||
if (!money) return "—";
|
if (!money) return "—";
|
||||||
return `${formatDecimal(money.value)} ${money.currency === "RUB" ? "₽" : money.currency}`;
|
return `${formatDecimal(money.value)} ${money.currency === "RUB" ? "₽" : money.currency}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatShare(
|
function formatShare(
|
||||||
numerator: Money | null,
|
numerator: Money | null,
|
||||||
denominator: Money | null,
|
denominator: Money | null,
|
||||||
): string | null {
|
): string | null {
|
||||||
@@ -32,7 +32,7 @@ export function formatShare(
|
|||||||
return `${rounded} %`;
|
return `${rounded} %`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatSnapshotTime(value: string): string {
|
function formatSnapshotTime(value: string): string {
|
||||||
return new Intl.DateTimeFormat("ru-RU", {
|
return new Intl.DateTimeFormat("ru-RU", {
|
||||||
dateStyle: "medium",
|
dateStyle: "medium",
|
||||||
timeStyle: "short",
|
timeStyle: "short",
|
||||||
@@ -40,7 +40,7 @@ export function formatSnapshotTime(value: string): string {
|
|||||||
}).format(new Date(value));
|
}).format(new Date(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function portfolioErrorCode(error: unknown): string | undefined {
|
function portfolioErrorCode(error: unknown): string | undefined {
|
||||||
return typeof error === "object" && error !== null && "code" in error
|
return typeof error === "object" && error !== null && "code" in error
|
||||||
? String(error.code)
|
? String(error.code)
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -48,11 +48,17 @@ export function portfolioErrorCode(error: unknown): string | undefined {
|
|||||||
|
|
||||||
const logoSize = 160;
|
const logoSize = 160;
|
||||||
|
|
||||||
export function instrumentLogoUrl(
|
function instrumentLogoUrl(logoName: string | null | undefined): string | null {
|
||||||
logoName: string | null | undefined,
|
|
||||||
): string | null {
|
|
||||||
const stem = logoName?.trim().replace(/\.png$/i, "");
|
const stem = logoName?.trim().replace(/\.png$/i, "");
|
||||||
return stem
|
return stem
|
||||||
? `https://invest-brands.cdn-tinkoff.ru/${stem}x${logoSize}.png`
|
? `https://invest-brands.cdn-tinkoff.ru/${stem}x${logoSize}.png`
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
formatMoney,
|
||||||
|
formatShare,
|
||||||
|
formatSnapshotTime,
|
||||||
|
instrumentLogoUrl,
|
||||||
|
portfolioErrorCode,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { renderHook, waitFor } from "@testing-library/react";
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import type { ReactNode } from "react";
|
import type { FC, PropsWithChildren } from "react";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { apiClient } from "@/shared/api";
|
import { apiClient } from "@/shared/api";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
@@ -22,11 +22,15 @@ describe("AuthProvider", () => {
|
|||||||
return HttpResponse.json([]);
|
return HttpResponse.json([]);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
const wrapper: FC<PropsWithChildren> = (props) => {
|
||||||
|
const { children } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
<AuthProvider>{children}</AuthProvider>
|
<AuthProvider>{children}</AuthProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.ready).toBe(true));
|
await waitFor(() => expect(result.current.ready).toBe(true));
|
||||||
@@ -39,11 +43,15 @@ describe("AuthProvider", () => {
|
|||||||
server.use(http.post("*/api/v1/auth/refresh", () => HttpResponse.error()));
|
server.use(http.post("*/api/v1/auth/refresh", () => HttpResponse.error()));
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
queryClient.setQueryData(["cached-query"], { value: "stale" });
|
queryClient.setQueryData(["cached-query"], { value: "stale" });
|
||||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
const wrapper: FC<PropsWithChildren> = (props) => {
|
||||||
|
const { children } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthProvider>{children}</AuthProvider>
|
<AuthProvider>{children}</AuthProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.ready).toBe(true));
|
await waitFor(() => expect(result.current.ready).toBe(true));
|
||||||
@@ -71,11 +79,15 @@ describe("AuthProvider", () => {
|
|||||||
);
|
);
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
queryClient.setQueryData(["cached-query"], { value: "stale" });
|
queryClient.setQueryData(["cached-query"], { value: "stale" });
|
||||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
const wrapper: FC<PropsWithChildren> = (props) => {
|
||||||
|
const { children } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthProvider>{children}</AuthProvider>
|
<AuthProvider>{children}</AuthProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
const { result } = renderHook(() => useAuth(), { wrapper });
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { FC, PropsWithChildren } from "react";
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -25,7 +26,6 @@ type AuthContextValue = {
|
|||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
refresh: () => Promise<boolean>;
|
refresh: () => Promise<boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
async function request(
|
async function request(
|
||||||
@@ -40,7 +40,8 @@ async function request(
|
|||||||
return data as Authentication;
|
return data as Authentication;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
const AuthProvider: FC<PropsWithChildren> = (props) => {
|
||||||
|
const { children } = props;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||||
@@ -138,10 +139,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
{children}
|
{children}
|
||||||
</AuthContext>
|
</AuthContext>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export function useAuth() {
|
function useAuth() {
|
||||||
const context = useContext(AuthContext);
|
const context = useContext(AuthContext);
|
||||||
if (!context) throw new Error("AuthProvider is required");
|
if (!context) throw new Error("AuthProvider is required");
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { AuthProvider, useAuth };
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||||
|
import type { FC } from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FormProvider, type Resolver, useForm } from "react-hook-form";
|
import { FormProvider, type Resolver, useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -50,14 +51,13 @@ const registerSchema = loginSchema.refine(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
type AuthFormValues = z.input<typeof loginSchema>;
|
type AuthFormValues = z.input<typeof loginSchema>;
|
||||||
|
type AuthFormProps = {
|
||||||
export function AuthForm({
|
|
||||||
register,
|
|
||||||
onSubmit,
|
|
||||||
}: {
|
|
||||||
register?: true;
|
register?: true;
|
||||||
onSubmit: (email: string, password: string) => Promise<void>;
|
onSubmit: (email: string, password: string) => Promise<void>;
|
||||||
}) {
|
};
|
||||||
|
|
||||||
|
const AuthForm: FC<AuthFormProps> = (props) => {
|
||||||
|
const { register, onSubmit } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const schema = register ? registerSchema : loginSchema;
|
const schema = register ? registerSchema : loginSchema;
|
||||||
const methods = useForm<AuthFormValues>({
|
const methods = useForm<AuthFormValues>({
|
||||||
@@ -114,4 +114,6 @@ export function AuthForm({
|
|||||||
</form>
|
</form>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { AuthForm };
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router";
|
import { useLocation, useNavigate } from "react-router";
|
||||||
import { useAuth } from "../model/auth";
|
import { useAuth } from "../model/auth";
|
||||||
import { AuthForm } from "./AuthForm";
|
import { AuthForm } from "./AuthForm";
|
||||||
|
|
||||||
export function LoginForm() {
|
const LoginForm: FC = () => {
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const returnTo =
|
const returnTo =
|
||||||
(location.state as { returnTo?: string } | null)?.returnTo ?? "/accounts";
|
(location.state as { returnTo?: string } | null)?.returnTo ?? "/accounts";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthForm
|
<AuthForm
|
||||||
onSubmit={async (email, password) => {
|
onSubmit={async (email, password) => {
|
||||||
@@ -16,4 +18,6 @@ export function LoginForm() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { LoginForm };
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router";
|
import { useLocation, useNavigate } from "react-router";
|
||||||
import { useAuth } from "../model/auth";
|
import { useAuth } from "../model/auth";
|
||||||
import { AuthForm } from "./AuthForm";
|
import { AuthForm } from "./AuthForm";
|
||||||
|
|
||||||
export function RegisterForm() {
|
const RegisterForm: FC = () => {
|
||||||
const { register } = useAuth();
|
const { register } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const returnTo =
|
const returnTo =
|
||||||
(location.state as { returnTo?: string } | null)?.returnTo ?? "/accounts";
|
(location.state as { returnTo?: string } | null)?.returnTo ?? "/accounts";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthForm
|
<AuthForm
|
||||||
register
|
register
|
||||||
@@ -17,4 +19,6 @@ export function RegisterForm() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { RegisterForm };
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { FC } from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -24,6 +25,9 @@ import {
|
|||||||
|
|
||||||
type Status =
|
type Status =
|
||||||
paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"];
|
paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"];
|
||||||
|
type TbankConnectionSectionLoadedProps = {
|
||||||
|
status: Status;
|
||||||
|
};
|
||||||
|
|
||||||
function errorCode(error: unknown): string | undefined {
|
function errorCode(error: unknown): string | undefined {
|
||||||
return typeof error === "object" && error !== null && "code" in error
|
return typeof error === "object" && error !== null && "code" in error
|
||||||
@@ -31,15 +35,18 @@ function errorCode(error: unknown): string | undefined {
|
|||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TbankConnectionSection() {
|
const TbankConnectionSection: FC = () => {
|
||||||
const viewModel = useTbankConnectionViewModel();
|
const viewModel = useTbankConnectionViewModel();
|
||||||
if (viewModel.state === "loading") return <TbankConnectionLoading />;
|
if (viewModel.state === "loading") return <TbankConnectionLoading />;
|
||||||
if (viewModel.state === "error")
|
if (viewModel.state === "error")
|
||||||
return <TbankConnectionError retry={viewModel.retry} />;
|
return <TbankConnectionError retry={viewModel.retry} />;
|
||||||
return <TbankConnectionSectionLoaded status={viewModel.status} />;
|
return <TbankConnectionSectionLoaded status={viewModel.status} />;
|
||||||
}
|
};
|
||||||
|
|
||||||
function TbankConnectionSectionLoaded({ status }: { status: Status }) {
|
const TbankConnectionSectionLoaded: FC<TbankConnectionSectionLoadedProps> = (
|
||||||
|
props,
|
||||||
|
) => {
|
||||||
|
const { status } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [deleteOpened, setDeleteOpened] = useState(false);
|
const [deleteOpened, setDeleteOpened] = useState(false);
|
||||||
@@ -151,4 +158,6 @@ function TbankConnectionSectionLoaded({ status }: { status: Status }) {
|
|||||||
</Modal>
|
</Modal>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { TbankConnectionSection };
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import { Alert, Button, Text } from "@mantine/core";
|
import { Alert, Button, Text } from "@mantine/core";
|
||||||
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export function TbankConnectionLoading() {
|
type TbankConnectionErrorProps = {
|
||||||
|
retry: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TbankConnectionLoading: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return <Text>{t("states.loadingText")}</Text>;
|
return <Text>{t("states.loadingText")}</Text>;
|
||||||
}
|
};
|
||||||
export function TbankConnectionError({ retry }: { retry: () => void }) {
|
|
||||||
|
const TbankConnectionError: FC<TbankConnectionErrorProps> = (props) => {
|
||||||
|
const { retry } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert color="red" title={t("tbank.statusError")}>
|
<Alert color="red" title={t("tbank.statusError")}>
|
||||||
<Button variant="subtle" onClick={retry}>
|
<Button variant="subtle" onClick={retry}>
|
||||||
@@ -14,4 +22,6 @@ export function TbankConnectionError({ retry }: { retry: () => void }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { TbankConnectionError, TbankConnectionLoading };
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import { apiQueryClient } from "@/shared/api";
|
|||||||
const tbankConnectionQueryOptions = () =>
|
const tbankConnectionQueryOptions = () =>
|
||||||
apiQueryClient.queryOptions("get", "/api/v1/tbank-connection");
|
apiQueryClient.queryOptions("get", "/api/v1/tbank-connection");
|
||||||
|
|
||||||
export const tbankConnectionQueryKey = tbankConnectionQueryOptions().queryKey;
|
const tbankConnectionQueryKey = tbankConnectionQueryOptions().queryKey;
|
||||||
|
|
||||||
export function useTbankConnectionViewModel() {
|
function useTbankConnectionViewModel() {
|
||||||
const { accessToken } = useAuth();
|
const { accessToken } = useAuth();
|
||||||
const status = useQuery({
|
const status = useQuery({
|
||||||
...tbankConnectionQueryOptions(),
|
...tbankConnectionQueryOptions(),
|
||||||
@@ -18,3 +18,5 @@ export function useTbankConnectionViewModel() {
|
|||||||
return { state: "error" as const, retry: () => status.refetch() };
|
return { state: "error" as const, retry: () => status.refetch() };
|
||||||
return { state: "loaded" as const, status: status.data };
|
return { state: "loaded" as const, status: status.data };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { tbankConnectionQueryKey, useTbankConnectionViewModel };
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Button, Card, Group, Stack, Text, Title } from "@mantine/core";
|
import { Button, Card, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useParams } from "react-router";
|
import { useParams } from "react-router";
|
||||||
import {
|
import {
|
||||||
@@ -8,7 +9,19 @@ import {
|
|||||||
import { formatMoney } from "@/entities/portfolio";
|
import { formatMoney } from "@/entities/portfolio";
|
||||||
import { StateView } from "@/shared/ui";
|
import { StateView } from "@/shared/ui";
|
||||||
|
|
||||||
export function AccountEventsPage() {
|
type AccountEventsPageErrorProps = {
|
||||||
|
retry: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AccountEventsPageLoadedProps = {
|
||||||
|
events: BrokerageEvent[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type AccountEventRowProps = {
|
||||||
|
event: BrokerageEvent;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AccountEventsPage: FC = () => {
|
||||||
const { accountId } = useParams();
|
const { accountId } = useParams();
|
||||||
const events = useBrokerageEvents(accountId);
|
const events = useBrokerageEvents(accountId);
|
||||||
if (events.isLoading) return <AccountEventsPageLoading />;
|
if (events.isLoading) return <AccountEventsPageLoading />;
|
||||||
@@ -17,10 +30,11 @@ export function AccountEventsPage() {
|
|||||||
if (!events.data) return <AccountEventsPageLoading />;
|
if (!events.data) return <AccountEventsPageLoading />;
|
||||||
if (!events.data.events.length) return <AccountEventsPageEmpty />;
|
if (!events.data.events.length) return <AccountEventsPageEmpty />;
|
||||||
return <AccountEventsPageLoaded events={events.data.events} />;
|
return <AccountEventsPageLoaded events={events.data.events} />;
|
||||||
}
|
};
|
||||||
|
|
||||||
function AccountEventsPageLoading() {
|
const AccountEventsPageLoading: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StateView
|
<StateView
|
||||||
loading
|
loading
|
||||||
@@ -28,10 +42,12 @@ function AccountEventsPageLoading() {
|
|||||||
text={t("states.loadingText")}
|
text={t("states.loadingText")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function AccountEventsPageError({ retry }: { retry: () => void }) {
|
const AccountEventsPageError: FC<AccountEventsPageErrorProps> = (props) => {
|
||||||
|
const { retry } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StateView
|
<StateView
|
||||||
error
|
error
|
||||||
@@ -40,10 +56,11 @@ function AccountEventsPageError({ retry }: { retry: () => void }) {
|
|||||||
action={<Button onClick={retry}>{t("events.retry")}</Button>}
|
action={<Button onClick={retry}>{t("events.retry")}</Button>}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function AccountEventsPageEmpty() {
|
const AccountEventsPageEmpty: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack maw={960} gap="lg">
|
<Stack maw={960} gap="lg">
|
||||||
<Title order={1}>{t("events.title")}</Title>
|
<Title order={1}>{t("events.title")}</Title>
|
||||||
@@ -51,10 +68,12 @@ function AccountEventsPageEmpty() {
|
|||||||
<StateView title={t("events.emptyTitle")} text={t("events.emptyText")} />
|
<StateView title={t("events.emptyTitle")} text={t("events.emptyText")} />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function AccountEventsPageLoaded({ events }: { events: BrokerageEvent[] }) {
|
const AccountEventsPageLoaded: FC<AccountEventsPageLoadedProps> = (props) => {
|
||||||
|
const { events } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack maw={960} gap="lg">
|
<Stack maw={960} gap="lg">
|
||||||
<Title order={1}>{t("events.title")}</Title>
|
<Title order={1}>{t("events.title")}</Title>
|
||||||
@@ -67,10 +86,12 @@ function AccountEventsPageLoaded({ events }: { events: BrokerageEvent[] }) {
|
|||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function AccountEventRow({ event }: { event: BrokerageEvent }) {
|
const AccountEventRow: FC<AccountEventRowProps> = (props) => {
|
||||||
|
const { event } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card withBorder>
|
<Card withBorder>
|
||||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||||
@@ -94,7 +115,7 @@ function AccountEventRow({ event }: { event: BrokerageEvent }) {
|
|||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function formatCalendarDate(date: string) {
|
function formatCalendarDate(date: string) {
|
||||||
return new Intl.DateTimeFormat("ru-RU", {
|
return new Intl.DateTimeFormat("ru-RU", {
|
||||||
@@ -102,3 +123,5 @@ function formatCalendarDate(date: string) {
|
|||||||
timeZone: "UTC",
|
timeZone: "UTC",
|
||||||
}).format(new Date(`${date}T00:00:00Z`));
|
}).format(new Date(`${date}T00:00:00Z`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { AccountEventsPage };
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import type { FC } from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useParams } from "react-router";
|
import { useParams } from "react-router";
|
||||||
@@ -28,7 +29,11 @@ function dateEnd(value: string) {
|
|||||||
return value ? `${value}T23:59:59.999Z` : undefined;
|
return value ? `${value}T23:59:59.999Z` : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountOperationsPage() {
|
type OperationRowProps = {
|
||||||
|
operation: BrokerageOperation;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AccountOperationsPage: FC = () => {
|
||||||
const { accountId } = useParams();
|
const { accountId } = useParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [from, setFrom] = useState("");
|
const [from, setFrom] = useState("");
|
||||||
@@ -147,10 +152,12 @@ export function AccountOperationsPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export function OperationRow({ operation }: { operation: BrokerageOperation }) {
|
const OperationRow: FC<OperationRowProps> = (props) => {
|
||||||
|
const { operation } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card withBorder>
|
<Card withBorder>
|
||||||
<Group justify="space-between" align="flex-start">
|
<Group justify="space-between" align="flex-start">
|
||||||
@@ -176,4 +183,6 @@ export function OperationRow({ operation }: { operation: BrokerageOperation }) {
|
|||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { AccountOperationsPage, OperationRow };
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
import {
|
import {
|
||||||
AccountPageEmpty,
|
AccountPageEmpty,
|
||||||
AccountPageError,
|
AccountPageError,
|
||||||
@@ -6,7 +7,7 @@ import {
|
|||||||
} from "./AccountPage.view";
|
} from "./AccountPage.view";
|
||||||
import { useAccountPageViewModel } from "./AccountPage.vm";
|
import { useAccountPageViewModel } from "./AccountPage.vm";
|
||||||
|
|
||||||
export function AccountPage() {
|
const AccountPage: FC = () => {
|
||||||
const viewModel = useAccountPageViewModel();
|
const viewModel = useAccountPageViewModel();
|
||||||
if (viewModel.state === "loading") return <AccountPageLoading />;
|
if (viewModel.state === "loading") return <AccountPageLoading />;
|
||||||
if (viewModel.state === "error") return <AccountPageError {...viewModel} />;
|
if (viewModel.state === "error") return <AccountPageError {...viewModel} />;
|
||||||
@@ -33,11 +34,12 @@ export function AccountPage() {
|
|||||||
recentEvents={viewModel.recentEvents}
|
recentEvents={viewModel.recentEvents}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
AccountPage,
|
||||||
AccountPageEmpty,
|
AccountPageEmpty,
|
||||||
AccountPageError,
|
AccountPageError,
|
||||||
AccountPageLoaded,
|
AccountPageLoaded,
|
||||||
AccountPageLoading,
|
AccountPageLoading,
|
||||||
} from "./AccountPage.view";
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Alert, Button, Stack, Text } from "@mantine/core";
|
import { Alert, Button, Stack, Text } from "@mantine/core";
|
||||||
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
||||||
@@ -22,8 +23,34 @@ import {
|
|||||||
Section,
|
Section,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
|
|
||||||
export function AccountPageLoading() {
|
type AccountPageErrorProps = {
|
||||||
|
code?: string;
|
||||||
|
retry: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AccountPageEmptyProps = {
|
||||||
|
account?: BrokerageAccount;
|
||||||
|
refreshing?: boolean;
|
||||||
|
refreshError?: boolean;
|
||||||
|
retry?: () => void;
|
||||||
|
recentOperations: AccountPageRecentOperations;
|
||||||
|
recentEvents: AccountPageRecentEvents;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AccountPageLoadedProps = {
|
||||||
|
account?: BrokerageAccount;
|
||||||
|
portfolio: Portfolio;
|
||||||
|
positionSections: AccountPagePositionSection[];
|
||||||
|
refreshing?: boolean;
|
||||||
|
refreshError?: boolean;
|
||||||
|
retry?: () => void;
|
||||||
|
recentOperations: AccountPageRecentOperations;
|
||||||
|
recentEvents: AccountPageRecentEvents;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AccountPageLoading: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StateView
|
<StateView
|
||||||
loading
|
loading
|
||||||
@@ -31,15 +58,10 @@ export function AccountPageLoading() {
|
|||||||
text={t("states.loadingText")}
|
text={t("states.loadingText")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export function AccountPageError({
|
const AccountPageError: FC<AccountPageErrorProps> = (props) => {
|
||||||
code,
|
const { code, retry } = props;
|
||||||
retry,
|
|
||||||
}: {
|
|
||||||
code?: string;
|
|
||||||
retry: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (code === "NOT_FOUND")
|
if (code === "NOT_FOUND")
|
||||||
return (
|
return (
|
||||||
@@ -84,24 +106,19 @@ export function AccountPageError({
|
|||||||
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
|
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export function AccountPageEmpty({
|
const AccountPageEmpty: FC<AccountPageEmptyProps> = (props) => {
|
||||||
|
const {
|
||||||
account,
|
account,
|
||||||
refreshing = false,
|
refreshing = false,
|
||||||
refreshError = false,
|
refreshError = false,
|
||||||
retry,
|
retry,
|
||||||
recentOperations,
|
recentOperations,
|
||||||
recentEvents,
|
recentEvents,
|
||||||
}: {
|
} = props;
|
||||||
account?: BrokerageAccount;
|
|
||||||
refreshing?: boolean;
|
|
||||||
refreshError?: boolean;
|
|
||||||
retry?: () => void;
|
|
||||||
recentOperations: AccountPageRecentOperations;
|
|
||||||
recentEvents: AccountPageRecentEvents;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<AccountHeading account={account} />
|
<AccountHeading account={account} />
|
||||||
@@ -118,9 +135,10 @@ export function AccountPageEmpty({
|
|||||||
<RecentEvents events={recentEvents} />
|
<RecentEvents events={recentEvents} />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export function AccountPageLoaded({
|
const AccountPageLoaded: FC<AccountPageLoadedProps> = (props) => {
|
||||||
|
const {
|
||||||
account,
|
account,
|
||||||
portfolio,
|
portfolio,
|
||||||
positionSections,
|
positionSections,
|
||||||
@@ -129,17 +147,9 @@ export function AccountPageLoaded({
|
|||||||
retry,
|
retry,
|
||||||
recentOperations,
|
recentOperations,
|
||||||
recentEvents,
|
recentEvents,
|
||||||
}: {
|
} = props;
|
||||||
account?: BrokerageAccount;
|
|
||||||
portfolio: Portfolio;
|
|
||||||
positionSections: AccountPagePositionSection[];
|
|
||||||
refreshing?: boolean;
|
|
||||||
refreshError?: boolean;
|
|
||||||
retry?: () => void;
|
|
||||||
recentOperations: AccountPageRecentOperations;
|
|
||||||
recentEvents: AccountPageRecentEvents;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack className={classes.page} gap="lg">
|
<Stack className={classes.page} gap="lg">
|
||||||
<AccountHeading account={account} />
|
<AccountHeading account={account} />
|
||||||
@@ -171,10 +181,17 @@ export function AccountPageLoaded({
|
|||||||
<RecentEvents events={recentEvents} />
|
<RecentEvents events={recentEvents} />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) {
|
function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) {
|
||||||
if (kind === "stocks") return "portfolio.stocks";
|
if (kind === "stocks") return "portfolio.stocks";
|
||||||
if (kind === "bonds") return "portfolio.bonds";
|
if (kind === "bonds") return "portfolio.bonds";
|
||||||
return "portfolio.otherPositions";
|
return "portfolio.otherPositions";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AccountPageEmpty,
|
||||||
|
AccountPageError,
|
||||||
|
AccountPageLoaded,
|
||||||
|
AccountPageLoading,
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { MantineProvider } from "@mantine/core";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { renderHook, waitFor } from "@testing-library/react";
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import type { ReactNode } from "react";
|
import type { FC, PropsWithChildren } from "react";
|
||||||
import { MemoryRouter, Route, Routes } from "react-router";
|
import { MemoryRouter, Route, Routes } from "react-router";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { Portfolio } from "@/entities/portfolio";
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
@@ -60,7 +60,9 @@ function configureTestAuth() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function wrapper({ children }: { children: ReactNode }) {
|
const wrapper: FC<PropsWithChildren> = (props) => {
|
||||||
|
const { children } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={new QueryClient()}>
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
@@ -72,7 +74,7 @@ function wrapper({ children }: { children: ReactNode }) {
|
|||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function mockLoadedAccountPage() {
|
function mockLoadedAccountPage() {
|
||||||
server.use(
|
server.use(
|
||||||
|
|||||||
@@ -22,20 +22,20 @@ import {
|
|||||||
type PositionSectionKind = "stocks" | "bonds" | "other";
|
type PositionSectionKind = "stocks" | "bonds" | "other";
|
||||||
type AccountPageReturnTone = "positive" | "negative";
|
type AccountPageReturnTone = "positive" | "negative";
|
||||||
|
|
||||||
export type AccountPagePosition = Portfolio["positions"][number] & {
|
type AccountPagePosition = Portfolio["positions"][number] & {
|
||||||
shareText: string | null;
|
shareText: string | null;
|
||||||
returnText: string | null;
|
returnText: string | null;
|
||||||
returnTone?: AccountPageReturnTone;
|
returnTone?: AccountPageReturnTone;
|
||||||
logoSrc: string | null;
|
logoSrc: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AccountPagePositionSection = {
|
type AccountPagePositionSection = {
|
||||||
kind: PositionSectionKind;
|
kind: PositionSectionKind;
|
||||||
wide: boolean;
|
wide: boolean;
|
||||||
positions: AccountPagePosition[];
|
positions: AccountPagePosition[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AccountPageRecentOperations =
|
type AccountPageRecentOperations =
|
||||||
| {
|
| {
|
||||||
state: "loading" | "error" | "empty";
|
state: "loading" | "error" | "empty";
|
||||||
to: string;
|
to: string;
|
||||||
@@ -46,7 +46,7 @@ export type AccountPageRecentOperations =
|
|||||||
items: BrokerageOperation[];
|
items: BrokerageOperation[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AccountPageRecentEvents =
|
type AccountPageRecentEvents =
|
||||||
| {
|
| {
|
||||||
state: "loading" | "error" | "empty";
|
state: "loading" | "error" | "empty";
|
||||||
to: string;
|
to: string;
|
||||||
@@ -77,13 +77,13 @@ type AccountPageEmptyViewModel = AccountPageContent & {
|
|||||||
state: "empty";
|
state: "empty";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AccountPageViewModel =
|
type AccountPageViewModel =
|
||||||
| { state: "loading" }
|
| { state: "loading" }
|
||||||
| { state: "error"; code?: string; retry: () => void }
|
| { state: "error"; code?: string; retry: () => void }
|
||||||
| AccountPageEmptyViewModel
|
| AccountPageEmptyViewModel
|
||||||
| AccountPageLoadedViewModel;
|
| AccountPageLoadedViewModel;
|
||||||
|
|
||||||
export function useAccountPageViewModel(): AccountPageViewModel {
|
function useAccountPageViewModel(): AccountPageViewModel {
|
||||||
const { accountId } = useParams();
|
const { accountId } = useParams();
|
||||||
const portfolio = usePortfolio(accountId);
|
const portfolio = usePortfolio(accountId);
|
||||||
const operations = useBrokerageOperations(accountId, {
|
const operations = useBrokerageOperations(accountId, {
|
||||||
@@ -153,7 +153,7 @@ function toRecentEvents(
|
|||||||
return { state: "loaded", to, items };
|
return { state: "loaded", to, items };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toAccountPagePositionSections(
|
function toAccountPagePositionSections(
|
||||||
portfolio: Portfolio,
|
portfolio: Portfolio,
|
||||||
): AccountPagePositionSection[] {
|
): AccountPagePositionSection[] {
|
||||||
const denominators = new Map<string, number>();
|
const denominators = new Map<string, number>();
|
||||||
@@ -221,3 +221,12 @@ function toPosition(
|
|||||||
logoSrc: wide ? instrumentLogoUrl(position.logoName) : null,
|
logoSrc: wide ? instrumentLogoUrl(position.logoName) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
type AccountPagePosition,
|
||||||
|
type AccountPagePositionSection,
|
||||||
|
type AccountPageRecentEvents,
|
||||||
|
type AccountPageRecentOperations,
|
||||||
|
toAccountPagePositionSections,
|
||||||
|
useAccountPageViewModel,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { Stack, Text, Title } from "@mantine/core";
|
import { Stack, Text, Title } from "@mantine/core";
|
||||||
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
||||||
import { accountTypeKey } from "@/entities/brokerage-account";
|
import { accountTypeKey } from "@/entities/brokerage-account";
|
||||||
|
|
||||||
export function AccountHeading({ account }: { account?: BrokerageAccount }) {
|
type AccountHeadingProps = {
|
||||||
|
account?: BrokerageAccount;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AccountHeading: FC<AccountHeadingProps> = (props) => {
|
||||||
|
const { account } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
<Title order={1}>{account?.name ?? t("portfolio.title")}</Title>
|
<Title order={1}>{account?.name ?? t("portfolio.title")}</Title>
|
||||||
@@ -15,4 +22,6 @@ export function AccountHeading({ account }: { account?: BrokerageAccount }) {
|
|||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { AccountHeading };
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { Paper, Text } from "@mantine/core";
|
import { Paper, Text } from "@mantine/core";
|
||||||
|
import type { FC } from "react";
|
||||||
import type { Portfolio } from "@/entities/portfolio";
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
import { formatMoney } from "@/entities/portfolio";
|
import { formatMoney } from "@/entities/portfolio";
|
||||||
import classes from "../AccountPage.module.css";
|
import classes from "../AccountPage.module.css";
|
||||||
|
|
||||||
export function CashBalances({
|
type CashBalancesProps = {
|
||||||
balances,
|
|
||||||
}: {
|
|
||||||
balances: Portfolio["cashBalances"];
|
balances: Portfolio["cashBalances"];
|
||||||
}) {
|
};
|
||||||
|
|
||||||
|
const CashBalances: FC<CashBalancesProps> = (props) => {
|
||||||
|
const { balances } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{balances.map((balance) => (
|
{balances.map((balance) => (
|
||||||
@@ -18,4 +21,6 @@ export function CashBalances({
|
|||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { CashBalances };
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
|
import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core";
|
||||||
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export function PortfolioRefreshState({
|
type PortfolioRefreshStateProps = {
|
||||||
refreshing,
|
|
||||||
refreshError,
|
|
||||||
retry,
|
|
||||||
}: {
|
|
||||||
refreshing: boolean;
|
refreshing: boolean;
|
||||||
refreshError: boolean;
|
refreshError: boolean;
|
||||||
retry?: () => void;
|
retry?: () => void;
|
||||||
}) {
|
};
|
||||||
|
|
||||||
|
const PortfolioRefreshState: FC<PortfolioRefreshStateProps> = (props) => {
|
||||||
|
const { refreshing, refreshError, retry } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (!refreshing && !refreshError) return null;
|
if (!refreshing && !refreshError) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{refreshing ? (
|
{refreshing ? (
|
||||||
@@ -34,4 +35,6 @@ export function PortfolioRefreshState({
|
|||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { PortfolioRefreshState };
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { Paper, SimpleGrid, Text } from "@mantine/core";
|
import { Paper, SimpleGrid, Text } from "@mantine/core";
|
||||||
|
import type { FC } from "react";
|
||||||
import type { Portfolio } from "@/entities/portfolio";
|
import type { Portfolio } from "@/entities/portfolio";
|
||||||
import { formatMoney } from "@/entities/portfolio";
|
import { formatMoney } from "@/entities/portfolio";
|
||||||
import classes from "../AccountPage.module.css";
|
import classes from "../AccountPage.module.css";
|
||||||
|
|
||||||
export function PortfolioTotals({
|
type PortfolioTotalsProps = {
|
||||||
totals,
|
|
||||||
}: {
|
|
||||||
totals: Portfolio["totalsByCurrency"];
|
totals: Portfolio["totalsByCurrency"];
|
||||||
}) {
|
};
|
||||||
|
|
||||||
|
const PortfolioTotals: FC<PortfolioTotalsProps> = (props) => {
|
||||||
|
const { totals } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 3 }}>
|
||||||
{totals.map((total) => (
|
{totals.map((total) => (
|
||||||
@@ -22,4 +25,6 @@ export function PortfolioTotals({
|
|||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { PortfolioTotals };
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { Card, Group, Stack, Text } from "@mantine/core";
|
import { Card, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { formatMoney } from "@/entities/portfolio";
|
import { formatMoney } from "@/entities/portfolio";
|
||||||
import classes from "../AccountPage.module.css";
|
import classes from "../AccountPage.module.css";
|
||||||
import type { AccountPagePosition } from "../AccountPage.vm";
|
import type { AccountPagePosition } from "../AccountPage.vm";
|
||||||
import { PositionLogo } from "./PositionLogo.view";
|
import { PositionLogo } from "./PositionLogo.view";
|
||||||
|
|
||||||
export function PositionCard({
|
type PositionCardProps = {
|
||||||
position,
|
|
||||||
wide = false,
|
|
||||||
}: {
|
|
||||||
position: AccountPagePosition;
|
position: AccountPagePosition;
|
||||||
wide?: boolean;
|
wide?: boolean;
|
||||||
}) {
|
};
|
||||||
|
|
||||||
|
const PositionCard: FC<PositionCardProps> = (props) => {
|
||||||
|
const { position, wide = false } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const returnClass = position.returnTone
|
const returnClass = position.returnTone
|
||||||
? classes[position.returnTone]
|
? classes[position.returnTone]
|
||||||
@@ -78,4 +79,6 @@ export function PositionCard({
|
|||||||
) : null}
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { PositionCard };
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { Avatar } from "@mantine/core";
|
import { Avatar } from "@mantine/core";
|
||||||
|
import type { FC } from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export function PositionLogo({
|
type PositionLogoProps = {
|
||||||
src,
|
|
||||||
label,
|
|
||||||
}: {
|
|
||||||
src: string | null;
|
src: string | null;
|
||||||
label: string;
|
label: string;
|
||||||
}) {
|
};
|
||||||
|
|
||||||
|
const PositionLogo: FC<PositionLogoProps> = (props) => {
|
||||||
|
const { src, label } = props;
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
if (src && !failed)
|
if (src && !failed)
|
||||||
return (
|
return (
|
||||||
<Avatar
|
<Avatar
|
||||||
@@ -24,4 +26,6 @@ export function PositionLogo({
|
|||||||
{(label.trim()[0] ?? "?").toUpperCase()}
|
{(label.trim()[0] ?? "?").toUpperCase()}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { PositionLogo };
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { SimpleGrid } from "@mantine/core";
|
import { SimpleGrid } from "@mantine/core";
|
||||||
|
import type { FC } from "react";
|
||||||
import type { AccountPagePositionSection } from "../AccountPage.vm";
|
import type { AccountPagePositionSection } from "../AccountPage.vm";
|
||||||
import { PositionCard } from "./PositionCard.view";
|
import { PositionCard } from "./PositionCard.view";
|
||||||
import { Section } from "./Section.view";
|
import { Section } from "./Section.view";
|
||||||
|
|
||||||
export function PositionSection({
|
type PositionSectionProps = {
|
||||||
title,
|
|
||||||
section,
|
|
||||||
}: {
|
|
||||||
title: string;
|
title: string;
|
||||||
section: AccountPagePositionSection;
|
section: AccountPagePositionSection;
|
||||||
}) {
|
};
|
||||||
|
|
||||||
|
const PositionSection: FC<PositionSectionProps> = (props) => {
|
||||||
|
const { title, section } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title={title}>
|
<Section title={title}>
|
||||||
<SimpleGrid cols={section.wide ? 1 : { base: 1, sm: 2, md: 3 }}>
|
<SimpleGrid cols={section.wide ? 1 : { base: 1, sm: 2, md: 3 }}>
|
||||||
@@ -23,4 +25,6 @@ export function PositionSection({
|
|||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { PositionSection };
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Alert, Button, Paper, Text } from "@mantine/core";
|
import { Alert, Button, Paper, Text } from "@mantine/core";
|
||||||
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
import type { BrokerageEvent } from "@/entities/brokerage-event";
|
||||||
@@ -7,8 +8,18 @@ import classes from "../AccountPage.module.css";
|
|||||||
import type { AccountPageRecentEvents } from "../AccountPage.vm";
|
import type { AccountPageRecentEvents } from "../AccountPage.vm";
|
||||||
import { Section } from "./Section.view";
|
import { Section } from "./Section.view";
|
||||||
|
|
||||||
export function RecentEvents({ events }: { events: AccountPageRecentEvents }) {
|
type RecentEventsProps = {
|
||||||
|
events: AccountPageRecentEvents;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RecentEventProps = {
|
||||||
|
event: BrokerageEvent;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RecentEvents: FC<RecentEventsProps> = (props) => {
|
||||||
|
const { events } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title={t("events.recentTitle")}>
|
<Section title={t("events.recentTitle")}>
|
||||||
{events.state === "loading" ? (
|
{events.state === "loading" ? (
|
||||||
@@ -33,10 +44,12 @@ export function RecentEvents({ events }: { events: AccountPageRecentEvents }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function RecentEvent({ event }: { event: BrokerageEvent }) {
|
const RecentEvent: FC<RecentEventProps> = (props) => {
|
||||||
|
const { event } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper className={classes.cash} withBorder>
|
<Paper className={classes.cash} withBorder>
|
||||||
<Text>{`${t(`events.type.${event.type}`)} · ${event.displayName}`}</Text>
|
<Text>{`${t(`events.type.${event.type}`)} · ${event.displayName}`}</Text>
|
||||||
@@ -47,4 +60,6 @@ function RecentEvent({ event }: { event: BrokerageEvent }) {
|
|||||||
</Text>
|
</Text>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { RecentEvents };
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Alert, Button, Paper, Text } from "@mantine/core";
|
import { Alert, Button, Paper, Text } from "@mantine/core";
|
||||||
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { formatMoney } from "@/entities/portfolio";
|
import { formatMoney } from "@/entities/portfolio";
|
||||||
@@ -6,12 +7,14 @@ import classes from "../AccountPage.module.css";
|
|||||||
import type { AccountPageRecentOperations } from "../AccountPage.vm";
|
import type { AccountPageRecentOperations } from "../AccountPage.vm";
|
||||||
import { Section } from "./Section.view";
|
import { Section } from "./Section.view";
|
||||||
|
|
||||||
export function RecentOperations({
|
type RecentOperationsProps = {
|
||||||
operations,
|
|
||||||
}: {
|
|
||||||
operations: AccountPageRecentOperations;
|
operations: AccountPageRecentOperations;
|
||||||
}) {
|
};
|
||||||
|
|
||||||
|
const RecentOperations: FC<RecentOperationsProps> = (props) => {
|
||||||
|
const { operations } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title={t("ledger.recentTitle")}>
|
<Section title={t("ledger.recentTitle")}>
|
||||||
{operations.state === "loading" ? (
|
{operations.state === "loading" ? (
|
||||||
@@ -40,4 +43,6 @@ export function RecentOperations({
|
|||||||
</Button>
|
</Button>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { RecentOperations };
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { Stack, Title } from "@mantine/core";
|
import { Stack, Title } from "@mantine/core";
|
||||||
|
import type { FC, PropsWithChildren } from "react";
|
||||||
|
|
||||||
export function Section({
|
type SectionProps = PropsWithChildren<{
|
||||||
title,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
title: string;
|
||||||
children: React.ReactNode;
|
}>;
|
||||||
}) {
|
|
||||||
|
const Section: FC<SectionProps> = (props) => {
|
||||||
|
const { title, children } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Title order={2}>{title}</Title>
|
<Title order={2}>{title}</Title>
|
||||||
{children}
|
{children}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { Section };
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
import {
|
import {
|
||||||
AccountsPageEmpty,
|
AccountsPageEmpty,
|
||||||
AccountsPageError,
|
AccountsPageError,
|
||||||
@@ -6,7 +7,7 @@ import {
|
|||||||
} from "./AccountsPage.view";
|
} from "./AccountsPage.view";
|
||||||
import { useAccountsPageViewModel } from "./AccountsPage.vm";
|
import { useAccountsPageViewModel } from "./AccountsPage.vm";
|
||||||
|
|
||||||
export function AccountsPage() {
|
const AccountsPage: FC = () => {
|
||||||
const viewModel = useAccountsPageViewModel();
|
const viewModel = useAccountsPageViewModel();
|
||||||
if (viewModel.state === "loading") return <AccountsPageLoading />;
|
if (viewModel.state === "loading") return <AccountsPageLoading />;
|
||||||
if (viewModel.state === "error") return <AccountsPageError {...viewModel} />;
|
if (viewModel.state === "error") return <AccountsPageError {...viewModel} />;
|
||||||
@@ -26,10 +27,11 @@ export function AccountsPage() {
|
|||||||
retry={viewModel.retry}
|
retry={viewModel.retry}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
AccountsPage,
|
||||||
AccountsPageEmpty,
|
AccountsPageEmpty,
|
||||||
AccountsPageError,
|
AccountsPageError,
|
||||||
AccountsPageLoading,
|
AccountsPageLoading,
|
||||||
} from "./AccountsPage.view";
|
};
|
||||||
|
|||||||
@@ -7,14 +7,40 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
import type { BrokerageAccount } from "@/entities/brokerage-account";
|
||||||
import { BrokerageAccountList } from "@/entities/brokerage-account";
|
import { BrokerageAccountList } from "@/entities/brokerage-account";
|
||||||
import { StateView } from "@/shared/ui";
|
import { StateView } from "@/shared/ui";
|
||||||
|
|
||||||
export function AccountsPageLoading() {
|
type AccountsPageErrorProps = {
|
||||||
|
code?: string;
|
||||||
|
retry?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AccountsPageEmptyProps = {
|
||||||
|
refreshing?: boolean;
|
||||||
|
refreshError?: boolean;
|
||||||
|
retry?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AccountsPageLoadedProps = {
|
||||||
|
accounts: BrokerageAccount[];
|
||||||
|
refreshing?: boolean;
|
||||||
|
refreshError?: boolean;
|
||||||
|
retry?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AccountsRefreshStateProps = {
|
||||||
|
refreshing: boolean;
|
||||||
|
refreshError: boolean;
|
||||||
|
retry?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AccountsPageLoading: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StateView
|
<StateView
|
||||||
loading
|
loading
|
||||||
@@ -22,15 +48,10 @@ export function AccountsPageLoading() {
|
|||||||
text={t("states.loadingText")}
|
text={t("states.loadingText")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export function AccountsPageError({
|
const AccountsPageError: FC<AccountsPageErrorProps> = (props) => {
|
||||||
code,
|
const { code, retry } = props;
|
||||||
retry,
|
|
||||||
}: {
|
|
||||||
code?: string;
|
|
||||||
retry?: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const settingsAction = (
|
const settingsAction = (
|
||||||
<Button component={Link} to="/settings" variant="light">
|
<Button component={Link} to="/settings" variant="light">
|
||||||
@@ -77,18 +98,12 @@ export function AccountsPageError({
|
|||||||
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
|
action={<Button onClick={retry}>{t("accounts.retry")}</Button>}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export function AccountsPageEmpty({
|
const AccountsPageEmpty: FC<AccountsPageEmptyProps> = (props) => {
|
||||||
refreshing = false,
|
const { refreshing = false, refreshError = false, retry } = props;
|
||||||
refreshError = false,
|
|
||||||
retry,
|
|
||||||
}: {
|
|
||||||
refreshing?: boolean;
|
|
||||||
refreshError?: boolean;
|
|
||||||
retry?: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<AccountsRefreshState
|
<AccountsRefreshState
|
||||||
@@ -102,20 +117,12 @@ export function AccountsPageEmpty({
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export function AccountsPageLoaded({
|
const AccountsPageLoaded: FC<AccountsPageLoadedProps> = (props) => {
|
||||||
accounts,
|
const { accounts, refreshing = false, refreshError = false, retry } = props;
|
||||||
refreshing = false,
|
|
||||||
refreshError = false,
|
|
||||||
retry,
|
|
||||||
}: {
|
|
||||||
accounts: BrokerageAccount[];
|
|
||||||
refreshing?: boolean;
|
|
||||||
refreshError?: boolean;
|
|
||||||
retry?: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>{t("accounts.title")}</Title>
|
<Title order={1}>{t("accounts.title")}</Title>
|
||||||
@@ -127,19 +134,13 @@ export function AccountsPageLoaded({
|
|||||||
<BrokerageAccountList accounts={accounts} />
|
<BrokerageAccountList accounts={accounts} />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
function AccountsRefreshState({
|
const AccountsRefreshState: FC<AccountsRefreshStateProps> = (props) => {
|
||||||
refreshing,
|
const { refreshing, refreshError, retry } = props;
|
||||||
refreshError,
|
|
||||||
retry,
|
|
||||||
}: {
|
|
||||||
refreshing: boolean;
|
|
||||||
refreshError: boolean;
|
|
||||||
retry?: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (!refreshing && !refreshError) return null;
|
if (!refreshing && !refreshError) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{refreshing ? (
|
{refreshing ? (
|
||||||
@@ -162,4 +163,11 @@ function AccountsRefreshState({
|
|||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
AccountsPageEmpty,
|
||||||
|
AccountsPageError,
|
||||||
|
AccountsPageLoaded,
|
||||||
|
AccountsPageLoading,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { errorCode, useBrokerageAccounts } from "@/entities/brokerage-account";
|
import { errorCode, useBrokerageAccounts } from "@/entities/brokerage-account";
|
||||||
|
|
||||||
export function useAccountsPageViewModel() {
|
function useAccountsPageViewModel() {
|
||||||
const accounts = useBrokerageAccounts();
|
const accounts = useBrokerageAccounts();
|
||||||
if (!accounts.data) {
|
if (!accounts.data) {
|
||||||
if (accounts.isFetching) return { state: "loading" as const };
|
if (accounts.isFetching) return { state: "loading" as const };
|
||||||
@@ -26,3 +26,5 @@ export function useAccountsPageViewModel() {
|
|||||||
retry: () => accounts.refetch(),
|
retry: () => accounts.refetch(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { useAccountsPageViewModel };
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Paper, Stack, Title } from "@mantine/core";
|
import { Paper, Stack, Title } from "@mantine/core";
|
||||||
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { LoginForm } from "@/features/auth";
|
import { LoginForm } from "@/features/auth";
|
||||||
|
|
||||||
export function LoginPage() {
|
const LoginPage: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="auth-page">
|
<main className="auth-page">
|
||||||
<Paper className="auth-card" p="xl" radius="lg" withBorder>
|
<Paper className="auth-card" p="xl" radius="lg" withBorder>
|
||||||
@@ -14,4 +16,6 @@ export function LoginPage() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { LoginPage };
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Stack, Title } from "@mantine/core";
|
import { Stack, Title } from "@mantine/core";
|
||||||
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { Button, StateView } from "@/shared/ui";
|
import { Button, StateView } from "@/shared/ui";
|
||||||
|
|
||||||
export function NotFoundPage() {
|
const NotFoundPage: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>{t("notFound.title")}</Title>
|
<Title order={1}>{t("notFound.title")}</Title>
|
||||||
@@ -19,4 +21,6 @@ export function NotFoundPage() {
|
|||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { NotFoundPage };
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Paper, Stack, Title } from "@mantine/core";
|
import { Paper, Stack, Title } from "@mantine/core";
|
||||||
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { RegisterForm } from "@/features/auth";
|
import { RegisterForm } from "@/features/auth";
|
||||||
|
|
||||||
export function RegisterPage() {
|
const RegisterPage: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="auth-page">
|
<main className="auth-page">
|
||||||
<Paper className="auth-card" p="xl" radius="lg" withBorder>
|
<Paper className="auth-card" p="xl" radius="lg" withBorder>
|
||||||
@@ -14,4 +16,6 @@ export function RegisterPage() {
|
|||||||
</Paper>
|
</Paper>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { RegisterPage };
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { Button, Stack, Title } from "@mantine/core";
|
import { Button, Stack, Title } from "@mantine/core";
|
||||||
|
import type { FC } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { useAuth } from "@/features/auth";
|
import { useAuth } from "@/features/auth";
|
||||||
import { TbankConnectionSection } from "@/features/tbank-connection";
|
import { TbankConnectionSection } from "@/features/tbank-connection";
|
||||||
|
|
||||||
export function SettingsPage() {
|
const SettingsPage: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>{t("settings.title")}</Title>
|
<Title order={1}>{t("settings.title")}</Title>
|
||||||
@@ -22,4 +24,6 @@ export function SettingsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { SettingsPage };
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { MantineProvider } from "@mantine/core";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { renderHook, waitFor } from "@testing-library/react";
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import type { ReactNode } from "react";
|
import type { FC, PropsWithChildren } from "react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { server } from "@/test/server";
|
import { server } from "@/test/server";
|
||||||
import { useAuthenticatedQuery } from "./authenticated-query";
|
import { useAuthenticatedQuery } from "./authenticated-query";
|
||||||
@@ -10,11 +10,17 @@ import { apiQueryClient, configureAuthClient } from "./client";
|
|||||||
|
|
||||||
function createWrapper() {
|
function createWrapper() {
|
||||||
const client = new QueryClient();
|
const client = new QueryClient();
|
||||||
return ({ children }: { children: ReactNode }) => (
|
const TestWrapper: FC<PropsWithChildren> = (props) => {
|
||||||
|
const { children } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
<QueryClientProvider client={client}>{children}</QueryClientProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return TestWrapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("useAuthenticatedQuery", () => {
|
describe("useAuthenticatedQuery", () => {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
subscribeAuthenticatedSession,
|
subscribeAuthenticatedSession,
|
||||||
} from "./client";
|
} from "./client";
|
||||||
|
|
||||||
export function useAuthenticatedQuery<
|
function useAuthenticatedQuery<
|
||||||
TQueryFnData = unknown,
|
TQueryFnData = unknown,
|
||||||
TError = Error,
|
TError = Error,
|
||||||
TData = TQueryFnData,
|
TData = TQueryFnData,
|
||||||
@@ -32,3 +32,5 @@ export function useAuthenticatedQuery<
|
|||||||
(typeof enabled === "function" ? enabled(query) : (enabled ?? true)),
|
(typeof enabled === "function" ? enabled(query) : (enabled ?? true)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { useAuthenticatedQuery };
|
||||||
|
|||||||
@@ -21,16 +21,16 @@ function setAuthenticatedSession(next: boolean) {
|
|||||||
for (const listener of authSessionListeners) listener();
|
for (const listener of authSessionListeners) listener();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function configureAuthClient(handlers: AuthHandlers | null) {
|
function configureAuthClient(handlers: AuthHandlers | null) {
|
||||||
auth = handlers;
|
auth = handlers;
|
||||||
setAuthenticatedSession(Boolean(auth?.accessToken()));
|
setAuthenticatedSession(Boolean(auth?.accessToken()));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAuthenticatedSessionSnapshot() {
|
function getAuthenticatedSessionSnapshot() {
|
||||||
return hasAuthenticatedSession;
|
return hasAuthenticatedSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function subscribeAuthenticatedSession(listener: () => void) {
|
function subscribeAuthenticatedSession(listener: () => void) {
|
||||||
authSessionListeners.add(listener);
|
authSessionListeners.add(listener);
|
||||||
return () => authSessionListeners.delete(listener);
|
return () => authSessionListeners.delete(listener);
|
||||||
}
|
}
|
||||||
@@ -71,10 +71,18 @@ async function authenticatedFetch(request: Request): Promise<Response> {
|
|||||||
: response;
|
: response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiClient = createClient<paths>({
|
const apiClient = createClient<paths>({
|
||||||
baseUrl: apiBaseUrl,
|
baseUrl: apiBaseUrl,
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
fetch: authenticatedFetch,
|
fetch: authenticatedFetch,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const apiQueryClient = createQueryClient(apiClient);
|
const apiQueryClient = createQueryClient(apiClient);
|
||||||
|
|
||||||
|
export {
|
||||||
|
apiClient,
|
||||||
|
apiQueryClient,
|
||||||
|
configureAuthClient,
|
||||||
|
getAuthenticatedSessionSnapshot,
|
||||||
|
subscribeAuthenticatedSession,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const ru = {
|
const ru = {
|
||||||
translation: {
|
translation: {
|
||||||
appName: "WealthLedger",
|
appName: "WealthLedger",
|
||||||
navigation: { accounts: "Счета", settings: "Настройки" },
|
navigation: { accounts: "Счета", settings: "Настройки" },
|
||||||
@@ -167,3 +167,5 @@ export const ru = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export { ru };
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import type { ParseKeys } from "i18next";
|
|||||||
|
|
||||||
import { i18n } from "./i18n";
|
import { i18n } from "./i18n";
|
||||||
|
|
||||||
export function validationMessage(
|
function validationMessage(
|
||||||
key: ParseKeys,
|
key: ParseKeys,
|
||||||
): (_value: unknown) => { message: string } {
|
): (_value: unknown) => { message: string } {
|
||||||
return () => ({ message: i18n.t(key) });
|
return () => ({ message: i18n.t(key) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { validationMessage };
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { AppShell as MantineAppShell, NavLink, Text } from "@mantine/core";
|
import { AppShell as MantineAppShell, NavLink, Text } from "@mantine/core";
|
||||||
import type { ReactNode } from "react";
|
import type { FC, ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Outlet, NavLink as RouterNavLink, useLocation } from "react-router";
|
import { Outlet, NavLink as RouterNavLink, useLocation } from "react-router";
|
||||||
|
|
||||||
export function AppShell({
|
type AppShellProps = {
|
||||||
contentAboveOutlet,
|
|
||||||
}: {
|
|
||||||
contentAboveOutlet?: ReactNode;
|
contentAboveOutlet?: ReactNode;
|
||||||
}) {
|
};
|
||||||
|
|
||||||
|
const AppShell: FC<AppShellProps> = (props) => {
|
||||||
|
const { contentAboveOutlet } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const items = [
|
const items = [
|
||||||
@@ -52,4 +53,6 @@ export function AppShell({
|
|||||||
</nav>
|
</nav>
|
||||||
</MantineAppShell>
|
</MantineAppShell>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export { AppShell };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Button, Loader, Paper, Stack, Text, Title } from "@mantine/core";
|
import { Button, Loader, Paper, Stack, Text, Title } from "@mantine/core";
|
||||||
import type { ReactNode } from "react";
|
import type { FC, ReactNode } from "react";
|
||||||
|
|
||||||
type StateViewProps = {
|
type StateViewProps = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -9,13 +9,8 @@ type StateViewProps = {
|
|||||||
error?: boolean;
|
error?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function StateView({
|
const StateView: FC<StateViewProps> = (props) => {
|
||||||
title,
|
const { title, text, action, loading = false, error = false } = props;
|
||||||
text,
|
|
||||||
action,
|
|
||||||
loading = false,
|
|
||||||
error = false,
|
|
||||||
}: StateViewProps) {
|
|
||||||
const role = loading ? "status" : error ? "alert" : undefined;
|
const role = loading ? "status" : error ? "alert" : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -28,6 +23,6 @@ export function StateView({
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export { Button };
|
export { Button, StateView };
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const authentication = {
|
|||||||
user: { id: "user-1", email: "user@example.com" },
|
user: { id: "user-1", email: "user@example.com" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handlers = [
|
const handlers = [
|
||||||
http.post("*/api/v1/auth/register", () => HttpResponse.json(authentication)),
|
http.post("*/api/v1/auth/register", () => HttpResponse.json(authentication)),
|
||||||
http.post("*/api/v1/auth/login", () => HttpResponse.json(authentication)),
|
http.post("*/api/v1/auth/login", () => HttpResponse.json(authentication)),
|
||||||
http.post("*/api/v1/auth/refresh", () =>
|
http.post("*/api/v1/auth/refresh", () =>
|
||||||
@@ -53,3 +53,5 @@ export const handlers = [
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export { handlers };
|
||||||
|
|||||||
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
import { setupServer } from "msw/node";
|
import { setupServer } from "msw/node";
|
||||||
import { handlers } from "./handlers";
|
import { handlers } from "./handlers";
|
||||||
|
|
||||||
export const server = setupServer(...handlers);
|
const server = setupServer(...handlers);
|
||||||
|
|
||||||
|
export { server };
|
||||||
|
|||||||
Reference in New Issue
Block a user