refactor: simplify auth and instrument pages
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 <Navigate replace to={vm.canonicalTo} />;
|
||||
if (vm.stockState.loading)
|
||||
return (
|
||||
<LoadingSkeleton label={t("stock.loading")}>
|
||||
<Stack>
|
||||
<Skeleton h={110} />
|
||||
|
||||
<Skeleton h={350} />
|
||||
|
||||
<Skeleton h={170} />
|
||||
</Stack>
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
|
||||
const unavailableMessage = isApiErrorCode(
|
||||
vm.code,
|
||||
ApiErrorCode.StockTickerAmbiguous,
|
||||
)
|
||||
? "stock.ambiguousTicker"
|
||||
: "stock.unavailableText";
|
||||
|
||||
if (!vm.stock)
|
||||
return (
|
||||
<Stack role="alert">
|
||||
<Title order={1}>{t("stock.unavailable")}</Title>
|
||||
|
||||
<Text>
|
||||
{t(
|
||||
isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected)
|
||||
|| isApiErrorCode(vm.code, ApiErrorCode.InvalidTbankToken)
|
||||
? "stock.connect"
|
||||
: unavailableMessage,
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Anchor component={Link} to="/stocks">
|
||||
{t("stock.backToSearch")}
|
||||
</Anchor>
|
||||
|
||||
<Anchor component={Link} to="/settings">
|
||||
{t("accounts.openSettings")}
|
||||
</Anchor>
|
||||
|
||||
<Button onClick={vm.stockState.retry} variant="default">
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
if (vm.stockState.loading || !vm.stock) return (
|
||||
<InstrumentPageState
|
||||
code={vm.code}
|
||||
kind="stock"
|
||||
loading={vm.stockState.loading}
|
||||
retry={vm.stockState.retry}
|
||||
/>
|
||||
);
|
||||
|
||||
return <StockPageView vm={vm} />;
|
||||
};
|
||||
|
||||
@@ -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<StockPageViewProps> = (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<StockPageViewProps> = (props) => {
|
||||
|
||||
return (
|
||||
<Stack className={classes.page} gap="lg">
|
||||
<Paper className={classes.section} component="header" withBorder>
|
||||
<Group align="start" justify="space-between">
|
||||
<div className={classes.identity}>
|
||||
<Avatar
|
||||
alt=""
|
||||
key={vm.logoSrc}
|
||||
radius="md"
|
||||
size={56}
|
||||
src={vm.logoSrc}
|
||||
>
|
||||
{stock.name[0]}
|
||||
</Avatar>
|
||||
|
||||
<div>
|
||||
<Title order={1} size="h2">
|
||||
{stock.name}
|
||||
</Title>
|
||||
|
||||
<Text c="dimmed" size="sm">
|
||||
{stock.ticker} ·{" "}
|
||||
{t(`stock.shareTypes.${stock.shareType}`, {
|
||||
defaultValue: t("stock.share"),
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
disabled={vm.refreshing}
|
||||
onClick={vm.refresh}
|
||||
variant="subtle"
|
||||
>
|
||||
{t("investment.refresh")}
|
||||
</Button>
|
||||
</Group>
|
||||
<InstrumentHeader
|
||||
logoSrc={vm.logoSrc}
|
||||
name={stock.name}
|
||||
onRefresh={vm.refresh}
|
||||
refreshing={vm.refreshing}
|
||||
subtitle={subtitle}
|
||||
>
|
||||
|
||||
{!!vm.company?.description && (
|
||||
<Text mt="md" size="sm" style={{ whiteSpace: "pre-line" }}>
|
||||
@@ -246,13 +225,18 @@ const StockPageView: FC<StockPageViewProps> = (props) => {
|
||||
</Stack>
|
||||
)}
|
||||
</StockSection>
|
||||
</Paper>
|
||||
</InstrumentHeader>
|
||||
|
||||
<StockChart vm={vm} />
|
||||
|
||||
<StockPosition vm={vm} />
|
||||
|
||||
<StockDividends vm={vm} />
|
||||
<StockDividends
|
||||
dividends={vm.dividends}
|
||||
pastDividends={vm.pastDividends}
|
||||
state={vm.dividendsState}
|
||||
upcoming={vm.upcoming}
|
||||
/>
|
||||
|
||||
<StockEvents vm={vm} />
|
||||
</Stack>
|
||||
|
||||
@@ -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<StockEventFilter, OperationCategory[]> = {
|
||||
all: [],
|
||||
dividends: ["income"],
|
||||
other: ["tax", "fee", "transfer", "other"],
|
||||
trades: ["trade"],
|
||||
};
|
||||
|
||||
type StockPageVm = ReturnType<typeof useStockPageVm>;
|
||||
|
||||
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:
|
||||
|
||||
+25
-20
@@ -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<StockEventsProps> = (props) => {
|
||||
const { vm } = props;
|
||||
const StockDividends: FC<StockDividendsProps> = (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 => (
|
||||
<div
|
||||
className={classes.dividend}
|
||||
key={JSON.stringify([
|
||||
@@ -104,7 +109,7 @@ const StockDividends: FC<StockEventsProps> = (props) => {
|
||||
else {
|
||||
upcomingContent = (
|
||||
<Text c="dimmed">
|
||||
{t("stock.noUpcoming", { to: formatDate(vm.dividends.to) })}
|
||||
{t("stock.noUpcoming", { to: formatDate(dividends.to) })}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -112,10 +117,10 @@ const StockDividends: FC<StockEventsProps> = (props) => {
|
||||
|
||||
let pastDividendsContent;
|
||||
|
||||
if (vm.pastDividends.length > 0) {
|
||||
if (pastDividends.length > 0) {
|
||||
pastDividendsContent = (
|
||||
<Stack gap="sm">
|
||||
{vm.pastDividends.map(event => (
|
||||
{pastDividends.map(event => (
|
||||
<Paper
|
||||
key={JSON.stringify([
|
||||
event.recordDate,
|
||||
@@ -166,11 +171,11 @@ const StockDividends: FC<StockEventsProps> = (props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<StockSection state={vm.dividendsState} title={t("stock.upcoming")}>
|
||||
{!!vm.dividends && (
|
||||
<InstrumentSection state={state} title={t("stock.upcoming")}>
|
||||
{!!dividends && (
|
||||
<Stack gap="md">
|
||||
{!!(
|
||||
vm.dividends.estimateUnavailable || vm.dividends.positionIsStale
|
||||
dividends.estimateUnavailable || dividends.positionIsStale
|
||||
) && (
|
||||
<Alert aria-live="polite" color="orange" role="status">
|
||||
{t("stock.estimateUnavailable")}
|
||||
@@ -183,10 +188,10 @@ const StockDividends: FC<StockEventsProps> = (props) => {
|
||||
{t("stock.dividendNote")}
|
||||
</Text>
|
||||
|
||||
{!!vm.dividends.positionAt && (
|
||||
{!!dividends.positionAt && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.snapshotAt", {
|
||||
date: formatSnapshotTime(vm.dividends.positionAt),
|
||||
date: formatSnapshotTime(dividends.positionAt),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
@@ -200,8 +205,8 @@ const StockDividends: FC<StockEventsProps> = (props) => {
|
||||
<Accordion.Panel>
|
||||
<Text c="dimmed" my="sm" size="xs">
|
||||
{t("stock.dividendWindow", {
|
||||
from: formatDate(vm.dividends.from),
|
||||
to: formatDate(vm.dividends.to),
|
||||
from: formatDate(dividends.from),
|
||||
to: formatDate(dividends.to),
|
||||
})}
|
||||
</Text>
|
||||
|
||||
@@ -211,7 +216,7 @@ const StockDividends: FC<StockEventsProps> = (props) => {
|
||||
</Accordion>
|
||||
</Stack>
|
||||
)}
|
||||
</StockSection>
|
||||
</InstrumentSection>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user