diff --git a/src/app/routes/AppRoutes/AppRoutes.test.tsx b/src/app/routes/AppRoutes/AppRoutes.test.tsx index cbc1698..9a9ad22 100644 --- a/src/app/routes/AppRoutes/AppRoutes.test.tsx +++ b/src/app/routes/AppRoutes/AppRoutes.test.tsx @@ -3,7 +3,7 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen } from "@testing-library/react"; import { cssVariablesResolver, theme } from "@wealthledger/ui/theme"; import { http, HttpResponse } from "msw"; -import { MemoryRouter } from "react-router"; +import { type InitialEntry, MemoryRouter } from "react-router"; import { describe, expect, it } from "vitest"; import { AuthProvider } from "@/features/auth"; @@ -12,7 +12,7 @@ import { server } from "@/test/server"; import { AppRoutes } from "./AppRoutes"; -function renderRoutes(path: string) { +function renderRoutes(path: InitialEntry) { return render( @@ -57,10 +57,18 @@ describe("AppRoutes", () => { ).toBeInTheDocument(); }); - it("logs in through the public form and reaches the protected landing", async () => { + it.each([ + { endpoint: "login", path: "/accounts", title: "Вход" }, + { endpoint: "register", path: "/register", title: "Регистрация" }, + { + endpoint: "login", + path: { pathname: "/login", state: { returnTo: "//example.com" } }, + title: "Вход", + }, + ])("authenticates via $endpoint and reaches the protected landing from $path", async ({ endpoint, path, title }) => { let credentials: unknown; server.use( - http.post("*/api/v1/auth/login", async ({ request }) => { + http.post(`*/api/v1/auth/${endpoint}`, async ({ request }) => { credentials = await request.json(); return HttpResponse.json({ @@ -69,16 +77,23 @@ describe("AppRoutes", () => { }); }), ); - renderRoutes("/accounts"); + renderRoutes(path); - await screen.findByRole("heading", { level: 1, name: "Вход" }); + await screen.findByRole("heading", { level: 1, name: title }); fireEvent.change(screen.getByLabelText("Email"), { target: { value: " User@Example.com " }, }); fireEvent.change(screen.getByLabelText("Пароль"), { target: { value: "a secure password" }, }); - fireEvent.click(screen.getByRole("button", { name: "Вход" })); + + if (endpoint === "register") { + fireEvent.change(screen.getByLabelText("Подтвердите пароль"), { + target: { value: "a secure password" }, + }); + } + + fireEvent.click(screen.getByRole("button", { name: title })); expect( await screen.findByRole("heading", { diff --git a/src/entities/bank-card/ui/BankPeriodPicker/BankPeriodPicker.vm.ts b/src/entities/bank-card/ui/BankPeriodPicker/BankPeriodPicker.vm.ts index 245df2f..00b3b86 100644 --- a/src/entities/bank-card/ui/BankPeriodPicker/BankPeriodPicker.vm.ts +++ b/src/entities/bank-card/ui/BankPeriodPicker/BankPeriodPicker.vm.ts @@ -24,7 +24,7 @@ function useBankPeriodPickerVm(props: BankPeriodPickerProps) { onChange(next); }; - return { onChange, onPeriodChange, t, value }; + return { onPeriodChange, t, value }; } export { useBankPeriodPickerVm }; diff --git a/src/entities/bond/api/queries.ts b/src/entities/bond/api/queries.ts index e51100a..4755d6c 100644 --- a/src/entities/bond/api/queries.ts +++ b/src/entities/bond/api/queries.ts @@ -14,35 +14,28 @@ type BondPeriod = NonNullable< const BOND_STALE_TIME_MS = 30_000; -function bondQueryOptions(uid: string) { - return { - ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}", { - params: { path: { uid } }, - }), - staleTime: BOND_STALE_TIME_MS, - }; -} - function useBond(identifier: string) { const isUid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( identifier, ); - const byUid = useResourceQuery({ - ...bondQueryOptions(identifier), - enabled: Boolean(identifier) && isUid, - }); + // eslint-disable-next-line unicorn/prefer-minimal-ternary -- Hoisting params breaks the generated union of endpoint request shapes. + const init = isUid + ? { params: { path: { uid: identifier } } } + : { params: { path: { ticker: identifier } } }; - const byTicker = useResourceQuery({ - ...apiQueryClient.queryOptions("get", "/api/v1/bonds/by-ticker/{ticker}", { - params: { path: { ticker: identifier } }, - }), - enabled: Boolean(identifier) && !isUid, + const options = apiQueryClient.queryOptions( + "get", + isUid ? "/api/v1/bonds/{uid}" : "/api/v1/bonds/by-ticker/{ticker}", + init, + ); + + return useResourceQuery({ + ...options, + enabled: Boolean(identifier), staleTime: BOND_STALE_TIME_MS, }); - - return isUid ? byUid : byTicker; } function useBondCandles(uid: string, period: BondPeriod) { @@ -110,7 +103,6 @@ function useBondSearch(query: string) { export { type BondCoupons, type BondMetrics, - type BondPeriod, MIN_BOND_SEARCH_LENGTH, useBond, useBondCandles, diff --git a/src/entities/bond/index.ts b/src/entities/bond/index.ts index 365bab9..dc839e1 100644 --- a/src/entities/bond/index.ts +++ b/src/entities/bond/index.ts @@ -1,7 +1,6 @@ export { type BondCoupons, type BondMetrics, - type BondPeriod, MIN_BOND_SEARCH_LENGTH, useBond, useBondCandles, diff --git a/src/entities/stock/api/queries.ts b/src/entities/stock/api/queries.ts index 969158e..51e52e6 100644 --- a/src/entities/stock/api/queries.ts +++ b/src/entities/stock/api/queries.ts @@ -1,13 +1,13 @@ import { apiQueryClient, type paths, - unwrapResourceResult, - useAuthenticatedQueries, useResourceQuery, } from "@/shared/api"; const MIN_STOCK_SEARCH_LENGTH = 2; +type StockDividends = NonNullable; + type StockPeriod = NonNullable< NonNullable< paths["/api/v1/stocks/{uid}/candles"]["get"]["parameters"]["query"] @@ -16,35 +16,28 @@ type StockPeriod = NonNullable< const STOCK_STALE_TIME_MS = 30_000; -function stockQueryOptions(uid: string) { - return { - ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}", { - params: { path: { uid } }, - }), - staleTime: STOCK_STALE_TIME_MS, - }; -} - function useStock(identifier: string) { const isUid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( identifier, ); - const byUid = useResourceQuery({ - ...stockQueryOptions(identifier), - enabled: Boolean(identifier) && isUid, - }); + // eslint-disable-next-line unicorn/prefer-minimal-ternary -- Hoisting params breaks the generated union of endpoint request shapes. + const init = isUid + ? { params: { path: { uid: identifier } } } + : { params: { path: { ticker: identifier } } }; - const byTicker = useResourceQuery({ - ...apiQueryClient.queryOptions("get", "/api/v1/stocks/by-ticker/{ticker}", { - params: { path: { ticker: identifier } }, - }), - enabled: Boolean(identifier) && !isUid, + const options = apiQueryClient.queryOptions( + "get", + isUid ? "/api/v1/stocks/{uid}" : "/api/v1/stocks/by-ticker/{ticker}", + init, + ); + + return useResourceQuery({ + ...options, + enabled: Boolean(identifier), staleTime: STOCK_STALE_TIME_MS, }); - - return isUid ? byUid : byTicker; } function useStockCandles(uid: string, period: StockPeriod) { @@ -99,22 +92,6 @@ function useStockQuote(uid: string) { }); } -function useStocks(uids: string[]) { - const uniqueUids = [...new Set(uids)]; - - const queries = useAuthenticatedQueries( - uniqueUids.map(uniqueUid => stockQueryOptions(uniqueUid)), - ); - - return new Map( - queries.map((query, index) => [ - // eslint-disable-next-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key. - uniqueUids[index], - unwrapResourceResult(query).data, - ]), - ); -} - function useStockSearch(query: string) { return useResourceQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/stocks", { @@ -127,13 +104,12 @@ function useStockSearch(query: string) { export { MIN_STOCK_SEARCH_LENGTH, - type StockPeriod, + type StockDividends, useStock, useStockCandles, useStockCompany, useStockDividends, useStockPosition, useStockQuote, - useStocks, useStockSearch, }; diff --git a/src/entities/stock/index.ts b/src/entities/stock/index.ts index f3018b2..d449a38 100644 --- a/src/entities/stock/index.ts +++ b/src/entities/stock/index.ts @@ -1,12 +1,11 @@ export { MIN_STOCK_SEARCH_LENGTH, - type StockPeriod, + type StockDividends, useStock, useStockCandles, useStockCompany, useStockDividends, useStockPosition, useStockQuote, - useStocks, useStockSearch, } from "./api/queries"; diff --git a/src/features/auth/model/auth-context.ts b/src/features/auth/model/auth-context.ts index 083e5b2..1573a73 100644 --- a/src/features/auth/model/auth-context.ts +++ b/src/features/auth/model/auth-context.ts @@ -3,11 +3,9 @@ import { createContext, use } from "react"; import type { paths } from "@/shared/api"; type AuthContextValue = { - accessToken: string | undefined; login: (email: string, password: string) => Promise; logout: () => Promise; ready: boolean; - refresh: () => Promise; register: (email: string, password: string) => Promise; user: undefined | User; }; diff --git a/src/features/auth/model/auth.test.tsx b/src/features/auth/model/auth.test.tsx index 6d2fb65..571a0d0 100644 --- a/src/features/auth/model/auth.test.tsx +++ b/src/features/auth/model/auth.test.tsx @@ -27,6 +27,32 @@ function createAuthWrapper( ); } +async function expireSession() { + server.use(http.get("*/api/v1/brokerage-accounts", () => + HttpResponse.json({ code: "UNAUTHORIZED" }, { status: 401 }), + { once: true })); + + try { + await apiClient.GET("/api/v1/brokerage-accounts"); + } + catch (error) { + // A refresh that changes identity cancels the original user's request. + if (!(error instanceof DOMException && error.name === "AbortError")) throw error; + } +} + +async function requestAuthorization() { + let authorization: null | string = null; + server.use(http.get("*/api/v1/brokerage-accounts", ({ request }) => { + authorization = request.headers.get("Authorization"); + + return HttpResponse.json(resource([])); + }, { once: true })); + await apiClient.GET("/api/v1/brokerage-accounts"); + + return authorization; +} + describe("AuthProvider", () => { it("authorizes requests after restoring the initial session", async () => { let authorization: null | string @@ -77,7 +103,7 @@ describe("AuthProvider", () => { await waitFor(() => expect(result.current.ready).toBe(true)); expect(result.current.user).toBeUndefined(); - expect(result.current.accessToken).toBeUndefined(); + expect(await requestAuthorization()).toBeNull(); expect(queryClient.getQueryData(["cached-query"])).toBeUndefined(); expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); }); @@ -106,13 +132,13 @@ describe("AuthProvider", () => { const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => - expect(result.current.accessToken).toBe("restored-token"), + expect(result.current.ready).toBe(true), ); const response = await apiClient.GET("/api/v1/brokerage-accounts"); expect(response.response.status).toBe(401); await waitFor(() => expect(result.current.user).toBeUndefined()); - expect(result.current.accessToken).toBeUndefined(); + expect(await requestAuthorization()).toBeNull(); expect(queryClient.getQueryData(["cached-query"])).toBeUndefined(); expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); }); @@ -147,7 +173,7 @@ describe("AuthProvider", () => { queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]); const wrapper: FC = createAuthWrapper(queryClient); const { result } = renderHook(() => useAuth(), { wrapper }); - await waitFor(() => expect(result.current.accessToken).toBe("test-token")); + await waitFor(() => expect(result.current.ready).toBe(true)); expect(queryClient.getQueryData(bankCardsKey)).toEqual([ { id: "bank-card-1" }, @@ -155,7 +181,7 @@ describe("AuthProvider", () => { await act(() => result.current.logout()); expect(result.current.user).toBeUndefined(); - expect(result.current.accessToken).toBeUndefined(); + expect(await requestAuthorization()).toBeNull(); expect(queryClient.getQueryData(statusKey)).toBeUndefined(); expect(queryClient.getQueryData(portfolioKey)).toBeUndefined(); expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); @@ -265,7 +291,7 @@ describe("AuthProvider", () => { const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => expect(result.current.ready).toBe(true)); queryClient.setQueryData(bankCardsKey, [{ id: "a-card" }]); - await act(() => result.current.refresh()); + await act(() => expireSession()); expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "a-card" }]); let release!: (value: string) => void; @@ -283,7 +309,7 @@ describe("AuthProvider", () => { .catch(() => {}); identity = "b"; - await act(() => result.current.refresh()); + await act(() => expireSession()); release("a-data"); await pending; expect(result.current.user?.id).toBe("b"); @@ -322,7 +348,7 @@ describe("AuthProvider", () => { expect(result.current.cards.data?.[0].id).toBe("a-card"), ); identity = "b"; - await act(() => result.current.auth.refresh()); + await act(() => expireSession()); await waitFor(() => expect(result.current.cards.data?.[0].id).toBe("b-card"), ); diff --git a/src/features/auth/model/auth.vm.ts b/src/features/auth/model/auth.vm.ts index cb757bd..4a6906e 100644 --- a/src/features/auth/model/auth.vm.ts +++ b/src/features/auth/model/auth.vm.ts @@ -29,7 +29,6 @@ async function request( function useAuthProviderVm() { const queryClient = useQueryClient(); const [user, setUser] = useState(undefined); - const [accessToken, setAccessToken] = useState(undefined); const accessTokenRef = useRef(undefined); const [ready, setReady] = useState(false); @@ -69,7 +68,6 @@ function useAuthProviderVm() { userRef.current = authentication.user; setUser(authentication.user); - setAccessToken(authentication.accessToken); }, [queryClient], ); @@ -78,7 +76,6 @@ function useAuthProviderVm() { generationRef.current += 1; userRef.current = undefined; setUser(undefined); - setAccessToken(undefined); accessTokenRef.current = undefined; queryClient.clear(); }, [queryClient]); @@ -156,12 +153,10 @@ function useAuthProviderVm() { useEffect(() => () => configureAuthClient(), []); const authContextValue = { - accessToken, login: (email: string, password: string) => authenticate("/api/v1/auth/login", email, password), logout, ready, - refresh, register: (email: string, password: string) => authenticate("/api/v1/auth/register", email, password), user, diff --git a/src/features/auth/ui/LoginForm/LoginForm.tsx b/src/features/auth/ui/LoginForm/LoginForm.tsx index d3a6cea..d9696c2 100644 --- a/src/features/auth/ui/LoginForm/LoginForm.tsx +++ b/src/features/auth/ui/LoginForm/LoginForm.tsx @@ -1,12 +1,12 @@ import type { FC } from "react"; +import { useAuth } from "../../model/auth-context"; import { AuthForm } from "../AuthForm/AuthForm"; -import { useLoginFormVm } from "./LoginForm.vm"; const LoginForm: FC = () => { - const { onSubmit } = useLoginFormVm(); + const { login } = useAuth(); - return ; + return ; }; export { LoginForm }; diff --git a/src/features/auth/ui/LoginForm/LoginForm.vm.ts b/src/features/auth/ui/LoginForm/LoginForm.vm.ts deleted file mode 100644 index 0392611..0000000 --- a/src/features/auth/ui/LoginForm/LoginForm.vm.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { ComponentProps } from "react"; - -import { useLocation, useNavigate } from "react-router"; - -import { useAuth } from "../../model/auth-context"; -import { AuthForm } from "../AuthForm/AuthForm"; - -function useLoginFormVm() { - const { login } = useAuth(); - const navigate = useNavigate(); - const location = useLocation(); - - const returnTo - = (location.state as null - | { returnTo?: string })?.returnTo ?? "/accounts"; - - const onSubmit: ComponentProps["onSubmit"] = async ( - email, - password, - ) => { - await login(email, password); - await navigate(returnTo, { replace: true }); - }; - - return { onSubmit }; -} - -export { useLoginFormVm }; diff --git a/src/features/auth/ui/RegisterForm/RegisterForm.tsx b/src/features/auth/ui/RegisterForm/RegisterForm.tsx index 9832dd1..f293501 100644 --- a/src/features/auth/ui/RegisterForm/RegisterForm.tsx +++ b/src/features/auth/ui/RegisterForm/RegisterForm.tsx @@ -1,12 +1,12 @@ import type { FC } from "react"; +import { useAuth } from "../../model/auth-context"; import { AuthForm } from "../AuthForm/AuthForm"; -import { useRegisterFormVm } from "./RegisterForm.vm"; const RegisterForm: FC = () => { - const { onSubmit } = useRegisterFormVm(); + const { register } = useAuth(); - return ; + return ; }; export { RegisterForm }; diff --git a/src/features/auth/ui/RegisterForm/RegisterForm.vm.ts b/src/features/auth/ui/RegisterForm/RegisterForm.vm.ts deleted file mode 100644 index 35a3235..0000000 --- a/src/features/auth/ui/RegisterForm/RegisterForm.vm.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { ComponentProps } from "react"; - -import { useLocation, useNavigate } from "react-router"; - -import { useAuth } from "../../model/auth-context"; -import { AuthForm } from "../AuthForm/AuthForm"; - -function useRegisterFormVm() { - const { register } = useAuth(); - const navigate = useNavigate(); - const location = useLocation(); - - const returnTo - = (location.state as null - | { returnTo?: string })?.returnTo ?? "/accounts"; - - const onSubmit: ComponentProps["onSubmit"] = async ( - email, - password, - ) => { - await register(email, password); - await navigate(returnTo, { replace: true }); - }; - - return { onSubmit, register }; -} - -export { useRegisterFormVm }; diff --git a/src/features/instrument-details/index.ts b/src/features/instrument-details/index.ts index 556c6fa..f1cf034 100644 --- a/src/features/instrument-details/index.ts +++ b/src/features/instrument-details/index.ts @@ -1,5 +1,8 @@ export { section, type SectionState } from "./model/section"; +export { useInstrumentParameters } from "./model/useInstrumentParameters"; export { InstrumentChart } from "./ui/InstrumentChart/InstrumentChart.view"; export { InstrumentEvents } from "./ui/InstrumentEvents.view"; +export { InstrumentHeader } from "./ui/InstrumentHeader/InstrumentHeader.view"; +export { InstrumentPageState } from "./ui/InstrumentPageState.view"; export { InstrumentPosition } from "./ui/InstrumentPosition.view"; export { InstrumentSection } from "./ui/InstrumentSection.view"; diff --git a/src/features/instrument-details/model/useInstrumentParameters.ts b/src/features/instrument-details/model/useInstrumentParameters.ts new file mode 100644 index 0000000..e622739 --- /dev/null +++ b/src/features/instrument-details/model/useInstrumentParameters.ts @@ -0,0 +1,41 @@ +import { useSearchParams } from "react-router"; + +import type { OperationCategory } from "@/entities/broker-operation"; + +const instrumentPeriods = ["month", "halfYear", "year", "all"] as const; + +function useInstrumentParameters(incomeFilter: "coupons" | "dividends") { + const [parameters, setParameters] = useSearchParams(); + + const period = instrumentPeriods.find( + value => value === parameters.get("period"), + ) ?? "month"; + + const filters: { categories: OperationCategory[]; value: string }[] = [ + { categories: [], value: "all" }, + { categories: ["trade"], value: "trades" }, + { categories: ["income"], value: incomeFilter }, + { categories: ["tax", "fee", "transfer", "other"], value: "other" }, + ]; + + const { categories, value: filter } = filters.find( + item => item.value === parameters.get("events"), + ) ?? filters[0]; + + const setParameter = (key: string, value: string) => { + const next = new URLSearchParams(parameters); + next.set(key, value); + setParameters(next, { replace: true }); + }; + + return { + categories, + filter, + period, + search: parameters.size > 0 ? `?${parameters.toString()}` : "", + setFilter: (value: string) => setParameter("events", value), + setPeriod: (value: string) => setParameter("period", value), + }; +} + +export { instrumentPeriods, useInstrumentParameters }; diff --git a/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts b/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts index 885c383..8cbc580 100644 --- a/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts +++ b/src/features/instrument-details/ui/InstrumentChart/InstrumentChart.vm.ts @@ -4,6 +4,8 @@ import { formatDate, formatDecimal, formatMoney } from "@/shared/lib"; import type { InstrumentChartProps } from "../../model/types"; +import { instrumentPeriods } from "../../model/useInstrumentParameters"; + function useInstrumentChartVm(props: InstrumentChartProps) { const { vm } = props; const { t } = useTranslation(); @@ -34,7 +36,7 @@ function useInstrumentChartVm(props: InstrumentChartProps) { return { ...candle, price }; }); - const periodOptions = ["month", "halfYear", "year", "all"].map(value => ({ + const periodOptions = instrumentPeriods.map(value => ({ label: t(`stock.periods.${value}`), value, })); diff --git a/src/features/instrument-details/ui/InstrumentHeader/InstrumentHeader.module.css b/src/features/instrument-details/ui/InstrumentHeader/InstrumentHeader.module.css new file mode 100644 index 0000000..0ee88f3 --- /dev/null +++ b/src/features/instrument-details/ui/InstrumentHeader/InstrumentHeader.module.css @@ -0,0 +1,17 @@ +.header { + padding: var(--mantine-spacing-lg); + border-radius: var(--mantine-radius-lg); + min-width: 0; + overflow-wrap: anywhere; +} +.identity { + display: flex; + align-items: center; + gap: var(--mantine-spacing-md); + min-width: 0; +} +@media (max-width: 48em) { + .header { + padding: var(--mantine-spacing-md); + } +} diff --git a/src/features/instrument-details/ui/InstrumentHeader/InstrumentHeader.view.tsx b/src/features/instrument-details/ui/InstrumentHeader/InstrumentHeader.view.tsx new file mode 100644 index 0000000..8a260e1 --- /dev/null +++ b/src/features/instrument-details/ui/InstrumentHeader/InstrumentHeader.view.tsx @@ -0,0 +1,45 @@ +import type { FC, PropsWithChildren, ReactNode } from "react"; + +import { Avatar, Button, Group, Paper, Text, Title } from "@mantine/core"; +import { useTranslation } from "react-i18next"; + +import classes from "./InstrumentHeader.module.css"; + +type InstrumentHeaderProps = PropsWithChildren<{ + logoSrc: null | string; + name: string; + onRefresh: () => void; + refreshing: boolean; + subtitle: ReactNode; +}>; + +const InstrumentHeader: FC = (props) => { + const { children, logoSrc, name, onRefresh, refreshing, subtitle } = props; + const { t } = useTranslation(); + + return ( + + +
+ + {name[0]} + + +
+ {name} + + {subtitle} +
+
+ + +
+ + {children} +
+ ); +}; + +export { InstrumentHeader }; diff --git a/src/features/instrument-details/ui/InstrumentPageState.view.tsx b/src/features/instrument-details/ui/InstrumentPageState.view.tsx new file mode 100644 index 0000000..aa6fb61 --- /dev/null +++ b/src/features/instrument-details/ui/InstrumentPageState.view.tsx @@ -0,0 +1,66 @@ +import type { FC } from "react"; + +import { Anchor, Button, Skeleton, Stack, Text, Title } from "@mantine/core"; +import { LoadingSkeleton } from "@wealthledger/ui"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; + +import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; + +type InstrumentPageStateProps = { + code?: string; + kind: "bond" | "stock"; + loading: boolean; + retry: () => void; +}; + +const InstrumentPageState: FC = (props) => { + const { code, kind, loading, retry } = props; + const { t } = useTranslation(); + if (loading) return ( + + + + + + + + + + ); + + const ambiguousCode = kind === "stock" + ? ApiErrorCode.StockTickerAmbiguous + : ApiErrorCode.BondTickerAmbiguous; + + let message = t(`${kind}.unavailableText`); + if (isApiErrorCode(code, ambiguousCode)) + message = t(`${kind}.ambiguousTicker`); + if ( + isApiErrorCode(code, ApiErrorCode.TbankNotConnected) + || isApiErrorCode(code, ApiErrorCode.InvalidTbankToken) + ) + message = t(`${kind}.connect`); + + return ( + + {t(`${kind}.unavailable`)} + + {message} + + + {t(`${kind}.backToSearch`)} + + + + {t("accounts.openSettings")} + + + + + ); +}; + +export { InstrumentPageState }; diff --git a/src/pages/account/model/positions.fixtures.ts b/src/pages/account/model/positions.fixtures.ts new file mode 100644 index 0000000..6b02780 --- /dev/null +++ b/src/pages/account/model/positions.fixtures.ts @@ -0,0 +1,73 @@ +import type { Portfolio } from "@/entities/portfolio"; + +const portfolio: Portfolio + = { + cashBalances: [{ currency: "USD", value: "1" }], + isStale: true, + lastSuccessfulAt: "2026-08-13T10:00:00Z", + positions: [ + { + absoluteReturn: { currency: "RUB", value: "10" }, + averagePurchasePrice: null, + currentValue: { currency: "RUB", value: "100" }, + displayName: "Акция", + figi: "FIGI-STOCK", + isin: null, + logoName: "akciya.png", + quantity: "2", + ticker: null, + type: "STOCK", + }, + { + absoluteReturn: { currency: "RUB", value: "-5" }, + averagePurchasePrice: { currency: "RUB", value: "45" }, + currentValue: { currency: "RUB", value: "50" }, + displayName: "Облигация", + figi: "FIGI-BOND", + isin: "RU000A0JX0J2", + logoName: null, + quantity: "1", + ticker: null, + type: "BOND", + }, + { + absoluteReturn: null, + averagePurchasePrice: null, + currentValue: null, + displayName: "Фонд", + figi: "FIGI-FUND", + isin: null, + logoName: "fond.png", + quantity: "3", + ticker: null, + type: "FUND", + }, + ], + structure: { currencies: [], unvaluedCategories: [] }, + totalsByCurrency: [{ currency: "RUB", value: "150" }], + }; + +const position: Portfolio["positions"][number] = { + ...portfolio.positions[0], + absoluteReturn: { currency: "RUB", value: "25" }, + account: { id: "a", name: "Основной" }, + averagePurchasePrice: { currency: "RUB", value: "280" }, + currentValue: { currency: "RUB", value: "375" }, + displayName: "Сбербанк", + figi: "FIGI-SBER", + isin: "RU0009029540", + logoName: null, + quantity: "1.25", + ticker: "SBER", +}; + +const second = { + ...position, + absoluteReturn: null, + account: { id: "b", name: "ИИС" }, + averagePurchasePrice: null, + currentValue: null, + quantity: "-2", +}; + +export { portfolio, position, second }; diff --git a/src/pages/account/model/positions.test.ts b/src/pages/account/model/positions.test.ts new file mode 100644 index 0000000..d39d659 --- /dev/null +++ b/src/pages/account/model/positions.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "vitest"; + +import type { Portfolio } from "@/entities/portfolio"; + +import { toAccountPagePositionSections } from "./positions"; +import { portfolio, position, second } from "./positions.fixtures"; + +function sections(positions: Portfolio["positions"]) { + return toAccountPagePositionSections({ + ...portfolio, + positions, + }); +} + +describe("account position sections", () => { + it("excludes currency instruments only from display, preserving order and share denominators", () => { + const otherTypes = [ + "OPTION", + "OTHER", + "FUTURE", + "STRUCTURED_NOTE", + "DIGITAL_FINANCIAL_ASSET", + "FUND", + ] as const; + + const snapshot: Portfolio = { + ...portfolio, + cashBalances: [{ currency: "RUB", value: "1000" }], + positions: [ + ...portfolio.positions, + { + ...portfolio.positions[0], + currentValue: { currency: "RUB", value: "50" }, + figi: "FIGI-CURRENCY", + type: "CURRENCY", + }, + ...otherTypes.map( + + type => + ({ + ...portfolio.positions[2], + currentValue: + type === "OPTION" ? { currency: "RUB", value: "50" } : null, + figi: `FIGI-${type}-NEXT`, + type, + }), + ), + ], + structure: { currencies: [], unvaluedCategories: [] }, + totalsByCurrency: [{ currency: "RUB", value: "1250" }], + }; + + const original = structuredClone(snapshot); + + const sections = toAccountPagePositionSections(snapshot); + + expect(sections.map(section => section.kind)).toEqual([ + "stocks", + "bonds", + "other", + ]); + expect(sections[2].groups.map(group => group.positions[0].figi)).toEqual([ + "FIGI-FUND", + ...otherTypes.map(type => `FIGI-${type}-NEXT`), + ]); + expect(sections[0].groups[0].positions[0].shareText).toBe("40 %"); + expect(sections[1].groups[0].positions[0].shareText).toBe("20 %"); + expect(sections[2].groups[1].positions[0].shareText).toBe("20 %"); + expect(snapshot).toEqual(original); + }); + + it("prepares grouped position sections with derived display data", () => { + expect( + toAccountPagePositionSections(portfolio).map(({ groups, kind }) => ({ + kind, + positions: groups.flatMap(group => group.positions), + })), + ).toMatchObject([ + { + kind: "stocks", + positions: [ + { + displayName: "Акция", + logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png", + returnText: "+10 ₽", + returnTone: "positive", + shareText: "66,7 %", + }, + ], + + }, + { + kind: "bonds", + positions: [ + { + displayName: "Облигация", + logoSrc: null, + returnText: "-5 ₽", + returnTone: "negative", + shareText: "33,3 %", + }, + ], + }, + { + kind: "other", + positions: [ + { + displayName: "Фонд", + logoSrc: null, + returnText: null, + returnTone: undefined, + shareText: null, + }, + ], + }, + ]); + }); + + it("uses ISIN only when FIGI is absent, never merging by name or ticker", () => { + expect( + + sections([ + { + ...position, + figi: null, + }, + { ...second, figi: null }, + ])[0].groups, + ).toHaveLength(1); + expect( + sections([position, { ...second, figi: "OTHER-FIGI" }])[0].groups, + ).toHaveLength(2); + expect( + + sections([ + { + ...position, + figi: null, + isin: null, + }, + { + ...second, + figi: null, + isin: null, + }, + ])[0].groups, + ).toHaveLength(2); + }); + + it("preserves one-account cards and does not merge repeated rows on the same account", () => { + expect( + sections([position, { ...second, account: position.account }])[0].groups, + ).toHaveLength(2); + expect( + sections([ + { ...position, account: undefined }, + { ...second, account: undefined }, + ])[0].groups, + ).toHaveLength(2); + }); + + it("keeps currencies and purchase prices attached to their source positions", () => { + const source = [ + position, + { + ...second, + averagePurchasePrice: { currency: "USD", value: "0.99" }, + currentValue: { currency: "USD", value: "1.01" }, + }, + ]; + + const before = structuredClone(source); + const [group] = sections(source)[0].groups; + expect( + group.positions.map(item => [ + item.quantity, + item.currentValue, + item.averagePurchasePrice, + ]), + ).toEqual( + source.map(item => [ + item.quantity, + item.currentValue, + item.averagePurchasePrice, + ]), + ); + expect(source).toEqual(before); + }); +}); diff --git a/src/pages/account/model/positions.ts b/src/pages/account/model/positions.ts index 347e215..340f36a 100644 --- a/src/pages/account/model/positions.ts +++ b/src/pages/account/model/positions.ts @@ -69,8 +69,6 @@ function positionIdentity(position: AccountPagePosition) { function toAccountPagePositionSections( portfolio: Portfolio, - instrumentDetails: Map = new Map(), ): AccountPagePositionSection[] { const denominators = new Map(); @@ -87,11 +85,10 @@ function toAccountPagePositionSections( "stocks", portfolio, denominators, - instrumentDetails, ); - const bonds = toSection("bonds", portfolio, denominators, instrumentDetails); - const other = toSection("other", portfolio, denominators, instrumentDetails); + const bonds = toSection("bonds", portfolio, denominators); + const other = toSection("other", portfolio, denominators); return [stocks, bonds, other].filter(section => section.groups.length > 0); } @@ -100,7 +97,6 @@ function toPosition( position: Portfolio["positions"][number], denominators: Map, kind: PositionSectionKind, - instrumentSlug?: string, ): AccountPagePosition { const isExpanded = kind !== "other"; @@ -119,7 +115,7 @@ function toPosition( ...position, instrumentTo: (kind === "stocks" || kind === "bonds") && position.instrumentUid - ? `/${kind}/${encodeURIComponent(instrumentSlug ?? position.instrumentUid)}` + ? `/${kind}/${encodeURIComponent(position.instrumentUid)}` : undefined, logoSrc: isExpanded ? instrumentLogoUrl(position.logoName) @@ -141,8 +137,6 @@ function toSection( kind: PositionSectionKind, portfolio: Portfolio, denominators: Map, - instrumentDetails: Map, ): AccountPagePositionSection { return { groups: groupPositions( @@ -161,7 +155,6 @@ function toSection( position, denominators, kind, - instrumentDetails.get(position.instrumentUid ?? "")?.slug, ), ), ), diff --git a/src/pages/account/ui/AccountPage/AccountPage.module.css b/src/pages/account/ui/AccountPage/AccountPage.module.css index c097c31..9c0e130 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.module.css +++ b/src/pages/account/ui/AccountPage/AccountPage.module.css @@ -1,5 +1,4 @@ -.tile, -.cash { +.tile { border-color: var(--mantine-color-gray-3); background: var(--mantine-color-gray-0); } @@ -52,15 +51,10 @@ margin-block: 5px; } -.tile, -.cash { +.tile { padding: var(--mantine-spacing-md); } -.cash { - display: flex; - justify-content: space-between; -} .positive { color: var(--wl-color-positive); diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index ca410bb..52742a7 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -212,10 +212,10 @@ describe("AccountPage states", () => { for (const link of links) expect(link).toHaveAttribute( "href", - `/${collection}/${type === "STOCK" ? slug : uid}`, + `/${collection}/${uid}`, ); }); - expect(requests).toHaveBeenCalledTimes(type === "STOCK" ? 1 : 0); + expect(requests).not.toHaveBeenCalled(); expect( screen.queryByText("Название из карточки инструмента"), ).not.toBeInTheDocument(); @@ -234,43 +234,6 @@ describe("AccountPage states", () => { }, ); - it.each(["unavailable", "noncanonical"])( - "keeps exact UID links and the portfolio visible when ticker is %s", - async (scenario) => { - const uid = "c190ff1f-1447-4227-b543-316332699ca5"; - const requests = vi.fn(); - server.use( - http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => - HttpResponse.json( - resource({ - ...portfolio, - positions: [ - { - ...portfolio.positions[0], - instrumentUid: uid, - ticker: "SBERP", - }, - ], - }), - ), - ), - http.get("*/api/v1/stocks/:uid", () => { - requests(); - - return scenario === "unavailable" - ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) - : HttpResponse.json(resource({ instrumentUid: uid, slug: uid })); - }), - ); - renderAccountRoute(createTestQueryClient()); - await waitFor(() => expect(requests).toHaveBeenCalledOnce()); - const links = await screen.findAllByRole("link", { name: "Вторая" }); - expect(links).toHaveLength(2); - for (const link of links) - expect(link).toHaveAttribute("href", `/stocks/${uid}`); - }, - ); - it("keeps the account and positions visible for an older HTTP response without structure", async () => { server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx index c5ca484..091d099 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx @@ -6,63 +6,14 @@ import { http, HttpResponse } from "msw"; import { MemoryRouter, Route, Routes } from "react-router"; import { describe, expect, it, vi } from "vitest"; -import type { Portfolio } from "@/entities/portfolio"; - import { configureAuthClient } from "@/shared/api"; import { createQueryWrapper } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; -import { toAccountPagePositionSections } from "../../model/positions"; +import { portfolio } from "../../model/positions.fixtures"; import { useAccountPageViewModel } from "./AccountPage.vm"; -const portfolio: Portfolio - = { - cashBalances: [{ currency: "USD", value: "1" }], - isStale: true, - lastSuccessfulAt: "2026-08-13T10:00:00Z", - positions: [ - { - absoluteReturn: { currency: "RUB", value: "10" }, - averagePurchasePrice: null, - currentValue: { currency: "RUB", value: "100" }, - displayName: "Акция", - figi: "FIGI-STOCK", - isin: null, - logoName: "akciya.png", - quantity: "2", - ticker: null, - type: "STOCK", - }, - { - absoluteReturn: { currency: "RUB", value: "-5" }, - averagePurchasePrice: { currency: "RUB", value: "45" }, - currentValue: { currency: "RUB", value: "50" }, - displayName: "Облигация", - figi: "FIGI-BOND", - isin: "RU000A0JX0J2", - logoName: null, - quantity: "1", - ticker: null, - type: "BOND", - }, - { - absoluteReturn: null, - averagePurchasePrice: null, - currentValue: null, - displayName: "Фонд", - figi: "FIGI-FUND", - isin: null, - logoName: "fond.png", - quantity: "3", - ticker: null, - type: "FUND", - }, - ], - structure: { currencies: [], unvaluedCategories: [] }, - totalsByCurrency: [{ currency: "RUB", value: "150" }], - }; - function configureTestAuth() { configureAuthClient({ accessToken: () => "test-token", @@ -152,109 +103,6 @@ function mockLoadedAccountPage() { } describe("useAccountPageViewModel", () => { - it("excludes currency instruments only from display, preserving order and share denominators", () => { - const otherTypes = [ - "OPTION", - "OTHER", - "FUTURE", - "STRUCTURED_NOTE", - "DIGITAL_FINANCIAL_ASSET", - "FUND", - ] as const; - - const snapshot: Portfolio = { - ...portfolio, - cashBalances: [{ currency: "RUB", value: "1000" }], - positions: [ - ...portfolio.positions, - { - ...portfolio.positions[0], - currentValue: { currency: "RUB", value: "50" }, - figi: "FIGI-CURRENCY", - type: "CURRENCY", - }, - ...otherTypes.map( - - type => - ({ - ...portfolio.positions[2], - currentValue: - type === "OPTION" ? { currency: "RUB", value: "50" } : null, - figi: `FIGI-${type}-NEXT`, - type, - }), - ), - ], - structure: { currencies: [], unvaluedCategories: [] }, - totalsByCurrency: [{ currency: "RUB", value: "1250" }], - }; - - const original = structuredClone(snapshot); - - const sections = toAccountPagePositionSections(snapshot); - - expect(sections.map(section => section.kind)).toEqual([ - "stocks", - "bonds", - "other", - ]); - expect(sections[2].groups.map(group => group.positions[0].figi)).toEqual([ - "FIGI-FUND", - ...otherTypes.map(type => `FIGI-${type}-NEXT`), - ]); - expect(sections[0].groups[0].positions[0].shareText).toBe("40 %"); - expect(sections[1].groups[0].positions[0].shareText).toBe("20 %"); - expect(sections[2].groups[1].positions[0].shareText).toBe("20 %"); - expect(snapshot).toEqual(original); - }); - - it("prepares grouped position sections with derived display data", () => { - expect( - toAccountPagePositionSections(portfolio).map(({ groups, kind }) => ({ - kind, - positions: groups.flatMap(group => group.positions), - })), - ).toMatchObject([ - { - kind: "stocks", - positions: [ - { - displayName: "Акция", - logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png", - returnText: "+10 ₽", - returnTone: "positive", - shareText: "66,7 %", - }, - ], - - }, - { - kind: "bonds", - positions: [ - { - displayName: "Облигация", - logoSrc: null, - returnText: "-5 ₽", - returnTone: "negative", - shareText: "33,3 %", - }, - ], - }, - { - kind: "other", - positions: [ - { - displayName: "Фонд", - logoSrc: null, - returnText: null, - returnTone: undefined, - shareText: null, - }, - ], - }, - ]); - }); - it("prepares recent operations and events state for presentation views", async () => { configureTestAuth(); mockLoadedAccountPage(); diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts index 6f35f44..69fb200 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts @@ -14,7 +14,6 @@ import { } from "@/entities/brokerage-event"; import { type IncomeSummary, useIncomeSummary } from "@/entities/income"; import { usePortfolio } from "@/entities/portfolio"; -import { useStocks } from "@/entities/stock"; import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; @@ -168,14 +167,6 @@ function useAccountPageViewModel(): AccountPageViewModel { const { accountId } = useParams(); const portfolio = usePortfolio(accountId); - const stocks = useStocks( - portfolio.data?.positions.flatMap(position => - position.type === "STOCK" && position.instrumentUid - ? [position.instrumentUid] - : [], - ) ?? [], - ); - const operations = useBrokerageOperations(accountId, { limit: 5, }); @@ -236,10 +227,7 @@ function useAccountPageViewModel(): AccountPageViewModel { accountId, incomeSummary, portfolio: portfolio.data, - positionSections: toAccountPagePositionSections( - portfolio.data, - stocks, - ), + positionSections: toAccountPagePositionSections(portfolio.data), recentEvents, recentOperations, state: "loaded" as const, diff --git a/src/pages/account/ui/AccountPage/position-groups.test.tsx b/src/pages/account/ui/AccountPage/position-groups.test.tsx index 4875400..db760bb 100644 --- a/src/pages/account/ui/AccountPage/position-groups.test.tsx +++ b/src/pages/account/ui/AccountPage/position-groups.test.tsx @@ -3,50 +3,16 @@ import { render, screen, within } from "@testing-library/react"; import { MemoryRouter } from "react-router"; import { describe, expect, it } from "vitest"; -import type { Portfolio } from "@/entities/portfolio"; - import { toAccountPagePositionSections } from "../../model/positions"; +import { portfolio, position, second } from "../../model/positions.fixtures"; import { PositionSection } from "./components/PositionSection.view"; -const position: Portfolio["positions"][number] - = { - absoluteReturn: { currency: "RUB", value: "25" }, - account: { id: "a", name: "Основной" }, - averagePurchasePrice: { currency: "RUB", value: "280" }, - currentValue: { currency: "RUB", value: "375" }, - displayName: "Сбербанк", - figi: "FIGI-SBER", - isin: "RU0009029540", - logoName: null, - quantity: "1.25", - ticker: "SBER", - type: "STOCK", - }; - -const second - = { - ...position, - absoluteReturn: null, - account: { id: "b", name: "ИИС" }, - averagePurchasePrice: null, - currentValue: null, - quantity: "-2", - }; - -function sections(positions: Portfolio["positions"]) { - return toAccountPagePositionSections({ - cashBalances: [], - isStale: false, - lastSuccessfulAt: "2026-09-09T12:00:00Z", - positions, - structure: { currencies: [], unvaluedCategories: [] }, - totalsByCurrency: [], - }); -} - describe("Positions grouped by security", () => { it("shows one security with exact per-account quantities and valuations", () => { - const section = sections([position, second])[0]; + const section = toAccountPagePositionSections({ + ...portfolio, positions: [position, second], + })[0]; + render( @@ -76,75 +42,4 @@ describe("Positions grouped by security", () => { expect(within(rows[1]).getByText("Количество: -2")).toBeInTheDocument(); expect(rows[1]).not.toHaveTextContent("₽"); }); - - it("uses ISIN only when FIGI is absent, never merging by name or ticker", () => { - expect( - - sections([ - { - ...position, - figi: null, - }, - { ...second, figi: null }, - ])[0].groups, - ).toHaveLength(1); - expect( - sections([position, { ...second, figi: "OTHER-FIGI" }])[0].groups, - ).toHaveLength(2); - expect( - - sections([ - { - ...position, - figi: null, - isin: null, - }, - { - ...second, - figi: null, - isin: null, - }, - ])[0].groups, - ).toHaveLength(2); - }); - - it("preserves one-account cards and does not merge repeated rows on the same account", () => { - expect( - sections([position, { ...second, account: position.account }])[0].groups, - ).toHaveLength(2); - expect( - sections([ - { ...position, account: undefined }, - { ...second, account: undefined }, - ])[0].groups, - ).toHaveLength(2); - }); - - it("keeps currencies and purchase prices attached to their source positions", () => { - const source = [ - position, - { - ...second, - averagePurchasePrice: { currency: "USD", value: "0.99" }, - currentValue: { currency: "USD", value: "1.01" }, - }, - ]; - - const before = structuredClone(source); - const [group] = sections(source)[0].groups; - expect( - group.positions.map(item => [ - item.quantity, - item.currentValue, - item.averagePurchasePrice, - ]), - ).toEqual( - source.map(item => [ - item.quantity, - item.currentValue, - item.averagePurchasePrice, - ]), - ); - expect(source).toEqual(before); - }); }); diff --git a/src/pages/bond/ui/BondPage/BondPage.module.css b/src/pages/bond/ui/BondPage/BondPage.module.css index 51c9498..85bdea6 100644 --- a/src/pages/bond/ui/BondPage/BondPage.module.css +++ b/src/pages/bond/ui/BondPage/BondPage.module.css @@ -3,17 +3,6 @@ margin: 0 auto; min-width: 0; } -.section { - padding: var(--mantine-spacing-lg); - border-radius: var(--mantine-radius-lg); - min-width: 0; -} -.identity { - display: flex; - align-items: center; - gap: var(--mantine-spacing-md); - min-width: 0; -} .price { font-size: clamp(2rem, 4vw, 3.2rem); font-weight: 700; @@ -38,9 +27,6 @@ border-radius: var(--mantine-radius-md); } @media (max-width: 48em) { - .section { - padding: var(--mantine-spacing-md); - } .companyMetrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } diff --git a/src/pages/bond/ui/BondPage/BondPage.tsx b/src/pages/bond/ui/BondPage/BondPage.tsx index b7225d3..a870ea3 100644 --- a/src/pages/bond/ui/BondPage/BondPage.tsx +++ b/src/pages/bond/ui/BondPage/BondPage.tsx @@ -1,66 +1,23 @@ import type { FC } from "react"; -import { Anchor, Button, Skeleton, Stack, Text, Title } from "@mantine/core"; -import { LoadingSkeleton } from "@wealthledger/ui"; -import { useTranslation } from "react-i18next"; -import { Link, Navigate } from "react-router"; +import { Navigate } from "react-router"; -import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; +import { InstrumentPageState } from "@/features/instrument-details"; import { BondPageView } from "./BondPage.view"; import { useBondPageVm } from "./BondPage.vm"; const BondPage: FC = () => { const vm = useBondPageVm(); - const { t } = useTranslation(); if (vm.canonicalTo) return ; - if (vm.bondState.loading) - return ( - - - - - - - - - - ); - - const unavailableMessage = isApiErrorCode( - vm.code, - ApiErrorCode.BondTickerAmbiguous, - ) - ? "bond.ambiguousTicker" - : "bond.unavailableText"; - - if (!vm.bond) - return ( - - {t("bond.unavailable")} - - - {t( - isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected) - || isApiErrorCode(vm.code, ApiErrorCode.InvalidTbankToken) - ? "bond.connect" - : unavailableMessage, - )} - - - - {t("bond.backToSearch")} - - - - {t("accounts.openSettings")} - - - - - ); + if (vm.bondState.loading || !vm.bond) return ( + + ); return ; }; diff --git a/src/pages/bond/ui/BondPage/BondPage.view.tsx b/src/pages/bond/ui/BondPage/BondPage.view.tsx index f3cbbbd..3aaa379 100644 --- a/src/pages/bond/ui/BondPage/BondPage.view.tsx +++ b/src/pages/bond/ui/BondPage/BondPage.view.tsx @@ -2,11 +2,8 @@ import type { FC } from "react"; import { Alert, - Avatar, Badge, - Button, Group, - Paper, Skeleton, Stack, Text, @@ -18,6 +15,7 @@ import { useTranslation } from "react-i18next"; import { InstrumentChart, InstrumentEvents, + InstrumentHeader, InstrumentPosition, } from "@/features/instrument-details"; import { @@ -42,6 +40,8 @@ const BondPageView: FC = (props) => { const bond = vm.bond; if (!bond) return; + const subtitle = `${bond.ticker ?? ""} · ${bond.isin ?? ""}`; + const maturity = bond.maturityDate ? formatDate(bond.maturityDate) : t("bond.unknown"); @@ -144,32 +144,13 @@ const BondPageView: FC = (props) => { return ( - - -
- - {bond.name[0]} - - -
- - {bond.name} - - - - {bond.ticker} · {bond.isin} - -
-
- - -
+ {!!bond.floatingCoupon && {t("bond.floatingCoupon")}} @@ -224,7 +205,7 @@ const BondPageView: FC = (props) => { -
+ diff --git a/src/pages/bond/ui/BondPage/BondPage.vm.ts b/src/pages/bond/ui/BondPage/BondPage.vm.ts index 46271c9..7279927 100644 --- a/src/pages/bond/ui/BondPage/BondPage.vm.ts +++ b/src/pages/bond/ui/BondPage/BondPage.vm.ts @@ -1,7 +1,6 @@ -import { useParams, useSearchParams } from "react-router"; +import { useParams } from "react-router"; import { - type BondPeriod, useBond, useBondCandles, useBondCoupons, @@ -9,37 +8,21 @@ import { useBondPosition, useBondQuote, } from "@/entities/bond"; -import { type OperationCategory, useBrokerageOperationHistory } from "@/entities/broker-operation"; -import { section } from "@/features/instrument-details"; +import { useBrokerageOperationHistory } from "@/entities/broker-operation"; +import { section, useInstrumentParameters } from "@/features/instrument-details"; import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; import { instrumentLogoUrl } from "@/shared/lib"; -const periods: BondPeriod[] = ["month", "halfYear", "year", "all"]; -const filters = ["all", "trades", "coupons", "other"] as const; - -type BondEventFilter = (typeof filters)[number]; - -const categories: Record = { - all: [], - coupons: ["income"], - other: ["tax", "fee", "transfer", "other"], - trades: ["trade"], -}; - type BondPageVm = ReturnType; function useBondPageVm() { const { bondId = "" } = useParams(); const bond = useBond(bondId); const uid = bond.data?.instrumentUid ?? ""; - const [parameters, setParameters] = useSearchParams(); - const period - = periods.find(value => value === parameters.get("period")) ?? "month"; - - const filter - = filters.find(value => value === parameters.get("events")) ?? "all"; + const { categories, filter, period, search, setFilter, setPeriod } + = useInstrumentParameters("coupons"); const quote = useBondQuote(uid); const metrics = useBondMetrics(uid); @@ -48,8 +31,7 @@ function useBondPageVm() { const coupons = useBondCoupons(uid); const history = useBrokerageOperationHistory(uid ? "all" : undefined, { - // eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. - categories: categories[filter], + categories, instrumentUid: uid, limit: 20, }); @@ -60,9 +42,7 @@ function useBondPageVm() { { accountId: "all", candlePeriod: period, - categories: - // eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. - categories[filter], + categories, instrumentUid: uid, limit: 20, screen: "bond", @@ -70,14 +50,6 @@ function useBondPageVm() { [bond, quote, candles, position, metrics, coupons, operations], ); - const setParameter = (key: string, value: string) => { - const next = new URLSearchParams(parameters); - next.set(key, value); - setParameters(next, { replace: true }); - }; - - const search = parameters.size > 0 ? `?${parameters.toString()}` : ""; - return { bond: bond.data, bondState: section(bond, uid ? sync.retry : () => bond.refetch()), @@ -132,8 +104,8 @@ function useBondPageVm() { refresh: sync.refresh, refreshError: sync.transportError || sync.sourceError, refreshing: sync.refreshing, - setFilter: (value: string) => setParameter("events", value), - setPeriod: (value: string) => setParameter("period", value), + setFilter, + setPeriod, upcoming: coupons.data?.coupons .filter(event => event.timing === "upcoming") diff --git a/src/pages/stock/ui/StockPage/StockPage.module.css b/src/pages/stock/ui/StockPage/StockPage.module.css index 0fffd90..31068a1 100644 --- a/src/pages/stock/ui/StockPage/StockPage.module.css +++ b/src/pages/stock/ui/StockPage/StockPage.module.css @@ -3,17 +3,6 @@ margin: 0 auto; min-width: 0; } -.section { - padding: var(--mantine-spacing-lg); - border-radius: var(--mantine-radius-lg); - min-width: 0; -} -.identity { - display: flex; - align-items: center; - gap: var(--mantine-spacing-md); - min-width: 0; -} .price { font-size: clamp(2rem, 4vw, 3.2rem); font-weight: 700; @@ -38,20 +27,12 @@ grid-template-columns: repeat(auto-fit, minmax(135px, 1fr)); gap: var(--mantine-spacing-lg); } -.embeddedSection { - margin-top: var(--mantine-spacing-lg); - padding-top: var(--mantine-spacing-lg); - border-top: 1px solid var(--wl-color-border); -} .companyMetrics > div { padding: var(--mantine-spacing-sm); background: var(--wl-color-subtle); border-radius: var(--mantine-radius-md); } @media (max-width: 48em) { - .section { - padding: var(--mantine-spacing-md); - } .companyMetrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } diff --git a/src/pages/stock/ui/StockPage/StockPage.tsx b/src/pages/stock/ui/StockPage/StockPage.tsx index 7301a24..a9780a7 100644 --- a/src/pages/stock/ui/StockPage/StockPage.tsx +++ b/src/pages/stock/ui/StockPage/StockPage.tsx @@ -1,66 +1,23 @@ import type { FC } from "react"; -import { Anchor, Button, Skeleton, Stack, Text, Title } from "@mantine/core"; -import { LoadingSkeleton } from "@wealthledger/ui"; -import { useTranslation } from "react-i18next"; -import { Link, Navigate } from "react-router"; +import { Navigate } from "react-router"; -import { ApiErrorCode, isApiErrorCode } from "@/shared/api"; +import { InstrumentPageState } from "@/features/instrument-details"; import { StockPageView } from "./StockPage.view"; import { useStockPageVm } from "./StockPage.vm"; const StockPage: FC = () => { const vm = useStockPageVm(); - const { t } = useTranslation(); if (vm.canonicalTo) return ; - if (vm.stockState.loading) - return ( - - - - - - - - - - ); - - const unavailableMessage = isApiErrorCode( - vm.code, - ApiErrorCode.StockTickerAmbiguous, - ) - ? "stock.ambiguousTicker" - : "stock.unavailableText"; - - if (!vm.stock) - return ( - - {t("stock.unavailable")} - - - {t( - isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected) - || isApiErrorCode(vm.code, ApiErrorCode.InvalidTbankToken) - ? "stock.connect" - : unavailableMessage, - )} - - - - {t("stock.backToSearch")} - - - - {t("accounts.openSettings")} - - - - - ); + if (vm.stockState.loading || !vm.stock) return ( + + ); return ; }; diff --git a/src/pages/stock/ui/StockPage/StockPage.view.tsx b/src/pages/stock/ui/StockPage/StockPage.view.tsx index 71fa99d..728e569 100644 --- a/src/pages/stock/ui/StockPage/StockPage.view.tsx +++ b/src/pages/stock/ui/StockPage/StockPage.view.tsx @@ -2,20 +2,18 @@ import type { FC } from "react"; import { Alert, - Avatar, Badge, Button, Group, - Paper, Skeleton, Stack, Text, - Title, } from "@mantine/core"; import { LoadingSkeleton } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; import { + InstrumentHeader, InstrumentChart as StockChart, InstrumentEvents as StockEvents, InstrumentPosition as StockPosition, @@ -30,7 +28,7 @@ import { import type { StockPageVm } from "./StockPage.vm"; -import { StockDividends } from "./components/StockEvents.view"; +import { StockDividends } from "./components/StockDividends.view"; import classes from "./StockPage.module.css"; type StockPageViewProps = { vm: StockPageVm }; @@ -41,6 +39,15 @@ const StockPageView: FC = (props) => { const stock = vm.stock; if (!stock) return; + const subtitle = ( + <> + {stock.ticker} ·{" "} + {t(`stock.shareTypes.${stock.shareType}`, { + defaultValue: t("stock.share"), + })} + + ); + const appearanceClass = vm.quote?.change?.value.startsWith("-") ? classes.negative : classes.positive; @@ -152,41 +159,13 @@ const StockPageView: FC = (props) => { return ( - - -
- - {stock.name[0]} - - -
- - {stock.name} - - - - {stock.ticker} ·{" "} - {t(`stock.shareTypes.${stock.shareType}`, { - defaultValue: t("stock.share"), - })} - -
-
- - -
+ {!!vm.company?.description && ( @@ -246,13 +225,18 @@ const StockPageView: FC = (props) => {
)} - + - +
diff --git a/src/pages/stock/ui/StockPage/StockPage.vm.ts b/src/pages/stock/ui/StockPage/StockPage.vm.ts index 2e5d095..9e416ee 100644 --- a/src/pages/stock/ui/StockPage/StockPage.vm.ts +++ b/src/pages/stock/ui/StockPage/StockPage.vm.ts @@ -1,8 +1,7 @@ -import { useParams, useSearchParams } from "react-router"; +import { useParams } from "react-router"; -import { type OperationCategory, useBrokerageOperationHistory } from "@/entities/broker-operation"; +import { useBrokerageOperationHistory } from "@/entities/broker-operation"; import { - type StockPeriod, useStock, useStockCandles, useStockCompany, @@ -12,36 +11,21 @@ import { } from "@/entities/stock"; import { section, + useInstrumentParameters, } from "@/features/instrument-details"; import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; import { instrumentLogoUrl } from "@/shared/lib"; -const periods: StockPeriod[] = ["month", "halfYear", "year", "all"]; -const filters = ["all", "trades", "dividends", "other"] as const; - -type StockEventFilter = (typeof filters)[number]; - -const categories: Record = { - all: [], - dividends: ["income"], - other: ["tax", "fee", "transfer", "other"], - trades: ["trade"], -}; - type StockPageVm = ReturnType; function useStockPageVm() { const { stockId = "" } = useParams(); const stock = useStock(stockId); const uid = stock.data?.instrumentUid ?? ""; - const [parameters, setParameters] = useSearchParams(); - const period - = periods.find(value => value === parameters.get("period")) ?? "month"; - - const filter - = filters.find(value => value === parameters.get("events")) ?? "all"; + const { categories, filter, period, search, setFilter, setPeriod } + = useInstrumentParameters("dividends"); const quote = useStockQuote(uid); const candles = useStockCandles(uid, period); @@ -50,8 +34,7 @@ function useStockPageVm() { const dividends = useStockDividends(uid); const history = useBrokerageOperationHistory(uid ? "all" : undefined, { - // eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. - categories: categories[filter], + categories, instrumentUid: uid, limit: 20, }); @@ -62,9 +45,7 @@ function useStockPageVm() { { accountId: "all", candlePeriod: period, - categories: - // eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. - categories[filter], + categories, instrumentUid: uid, limit: 20, screen: "stock", @@ -72,15 +53,6 @@ function useStockPageVm() { [stock, quote, candles, position, company, dividends, operations], ); - const setParameter = (key: string, value: string) => { - const next = new URLSearchParams(parameters); - next.set(key, value); - setParameters(next, { replace: true }); - }; - - const companyData = company.data; - const search = parameters.size > 0 ? `?${parameters.toString()}` : ""; - return { candles: candles.data, candlesState: section(candles, sync.retry), @@ -89,7 +61,7 @@ function useStockPageVm() { ? `/stocks/${encodeURIComponent(stock.data.slug)}${search}` : null, code: apiErrorCode(stock.error), - company: companyData, + company: company.data, companyState: section(company, sync.retry), dividends: dividends.data, dividendsState: section(dividends, sync.retry), @@ -133,8 +105,8 @@ function useStockPageVm() { refresh: sync.refresh, refreshError: sync.transportError || sync.sourceError, refreshing: sync.refreshing, - setFilter: (value: string) => setParameter("events", value), - setPeriod: (value: string) => setParameter("period", value), + setFilter, + setPeriod, stock: stock.data, stockState: section(stock, uid ? sync.retry : () => stock.refetch()), upcoming: diff --git a/src/pages/stock/ui/StockPage/components/StockEvents.view.tsx b/src/pages/stock/ui/StockPage/components/StockDividends.view.tsx similarity index 80% rename from src/pages/stock/ui/StockPage/components/StockEvents.view.tsx rename to src/pages/stock/ui/StockPage/components/StockDividends.view.tsx index 780c6d1..ac54370 100644 --- a/src/pages/stock/ui/StockPage/components/StockEvents.view.tsx +++ b/src/pages/stock/ui/StockPage/components/StockDividends.view.tsx @@ -11,25 +11,30 @@ import { } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { InstrumentSection as StockSection } from "@/features/instrument-details"; -import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib"; +import type { StockDividends as Dividends } from "@/entities/stock"; -import type { StockPageVm } from "../StockPage.vm"; +import { InstrumentSection, type SectionState } from "@/features/instrument-details"; +import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib"; import classes from "../StockPage.module.css"; -type StockEventsProps = { vm: StockPageVm }; +type StockDividendsProps = { + dividends?: Dividends; + pastDividends: Dividends["dividends"]; + state: SectionState; + upcoming: Dividends["dividends"]; +}; -const StockDividends: FC = (props) => { - const { vm } = props; +const StockDividends: FC = (props) => { + const { dividends, pastDividends, state, upcoming } = props; const { t } = useTranslation(); let upcomingContent; - if (vm.dividends) { - if (vm.upcoming.length > 0) { + if (dividends) { + if (upcoming.length > 0) { upcomingContent = ( - vm.upcoming.map(event => ( + upcoming.map(event => (
= (props) => { else { upcomingContent = ( - {t("stock.noUpcoming", { to: formatDate(vm.dividends.to) })} + {t("stock.noUpcoming", { to: formatDate(dividends.to) })} ); } @@ -112,10 +117,10 @@ const StockDividends: FC = (props) => { let pastDividendsContent; - if (vm.pastDividends.length > 0) { + if (pastDividends.length > 0) { pastDividendsContent = ( - {vm.pastDividends.map(event => ( + {pastDividends.map(event => ( = (props) => { } return ( - - {!!vm.dividends && ( + + {!!dividends && ( {!!( - vm.dividends.estimateUnavailable || vm.dividends.positionIsStale + dividends.estimateUnavailable || dividends.positionIsStale ) && ( {t("stock.estimateUnavailable")} @@ -183,10 +188,10 @@ const StockDividends: FC = (props) => { {t("stock.dividendNote")} - {!!vm.dividends.positionAt && ( + {!!dividends.positionAt && ( {t("stock.snapshotAt", { - date: formatSnapshotTime(vm.dividends.positionAt), + date: formatSnapshotTime(dividends.positionAt), })} )} @@ -200,8 +205,8 @@ const StockDividends: FC = (props) => { {t("stock.dividendWindow", { - from: formatDate(vm.dividends.from), - to: formatDate(vm.dividends.to), + from: formatDate(dividends.from), + to: formatDate(dividends.to), })} @@ -211,7 +216,7 @@ const StockDividends: FC = (props) => { )} - + ); }; diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index 3851c8b..49b5425 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -14,6 +14,5 @@ export { ApiErrorCode, apiErrorCode, isApiErrorCode } from "./problem-details"; export { invalidateResourceAccess, type InvestmentResourceMeta, - unwrapResourceResult, useResourceQuery, } from "./resource-query"; diff --git a/src/shared/api/resource-query.ts b/src/shared/api/resource-query.ts index 02be78c..653b6f6 100644 --- a/src/shared/api/resource-query.ts +++ b/src/shared/api/resource-query.ts @@ -188,6 +188,5 @@ function useResourceQuery( export { invalidateResourceAccess, type InvestmentResourceMeta, - unwrapResourceResult, useResourceQuery, };