Files
frontend/src/pages/stock/ui/StockPage/StockPage.view.tsx
T
ksv741 b404c1802a
Frontend verify / commits (pull_request) Successful in 24s
Frontend verify / security (pull_request) Failing after 9s
Frontend verify / verify (pull_request) Skipped
refactor: simplify auth and instrument pages
2026-09-29 05:45:08 +03:00

259 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { FC } from "react";
import {
Alert,
Badge,
Button,
Group,
Skeleton,
Stack,
Text,
} from "@mantine/core";
import { LoadingSkeleton } from "@wealthledger/ui";
import { useTranslation } from "react-i18next";
import {
InstrumentHeader,
InstrumentChart as StockChart,
InstrumentEvents as StockEvents,
InstrumentPosition as StockPosition,
InstrumentSection as StockSection,
} from "@/features/instrument-details";
import {
formatDate,
formatDecimal,
formatMoney,
formatSnapshotTime,
} from "@/shared/lib";
import type { StockPageVm } from "./StockPage.vm";
import { StockDividends } from "./components/StockDividends.view";
import classes from "./StockPage.module.css";
type StockPageViewProps = { vm: StockPageVm };
const StockPageView: FC<StockPageViewProps> = (props) => {
const { vm } = props;
const { t } = useTranslation();
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;
let quoteContent;
if (vm.quoteState.loading) {
quoteContent = (
<LoadingSkeleton label={t("stock.loadingPrice")}>
<Skeleton h={55} mt="lg" w="50%" />
</LoadingSkeleton>
);
}
else {
quoteContent = (
<div aria-live="polite">
<Text className={classes.price} mt="lg">
{formatMoney(vm.quote?.price)}
</Text>
{!!vm.quote?.change && (
<Text className={appearanceClass} fw={600}>
{vm.quote?.change?.value.startsWith("-") ? "" : "+"}
{formatMoney(vm.quote.change)}
{vm.quote.changePercent === null
? ""
: ` (${formatDecimal(vm.quote.changePercent)} %)`}
</Text>
)}
{vm.quote?.previousCloseDate
? (
<Text c="dimmed" size="xs">
{t("stock.changeBase", {
date: formatDate(vm.quote.previousCloseDate),
})}
</Text>
)
: (
<Text c="dimmed" size="xs">
{t("stock.noComparison")}
</Text>
)}
<Group gap="sm" mt="sm">
<Badge color="gray" variant="light">
{t(`stock.trading.${vm.quote?.tradingStatus}`, {
defaultValue: t("stock.trading.unknown"),
})}
</Badge>
{!!vm.quote?.priceAt && (
<Text c="dimmed" size="xs">
{t("stock.priceAt", {
date: formatSnapshotTime(vm.quote.priceAt),
})}
</Text>
)}
</Group>
{!!vm.quote?.unavailable.length && (
<Text c="dimmed" mt="xs" size="xs">
{t("stock.partialQuote")}
</Text>
)}
{!!vm.quoteState.refreshing && (
<Text c="dimmed" role="status" size="xs">
{t("accounts.refreshing")}
</Text>
)}
</div>
);
}
let companyMetricsContent;
if (vm.company) {
if (vm.company.metrics.length > 0) {
companyMetricsContent = (
<div className={classes.companyMetrics}>
{vm.company.metrics.map(metric => (
<div key={metric.key}>
<Text c="dimmed" size="xs">
{t(`stock.metrics.${metric.key}`)}
{metric.period ? ` · ${metric.period}` : ""}
</Text>
<Text fw={600} size="lg">
{new Intl.NumberFormat("ru-RU", {
maximumFractionDigits: 2,
notation:
metric.unit === "money" ? "compact" : "standard",
}).format(metric.value)}{" "}
{metricUnit(metric, t("stock.currencyUnknown"))}
</Text>
</div>
))}
</div>
);
}
else {
companyMetricsContent = <Text c="dimmed">{t("stock.noFundamentals")}</Text>;
}
}
return (
<Stack className={classes.page} gap="lg">
<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" }}>
{vm.company.description}
</Text>
)}
{!!(vm.refreshError || vm.stockState.error || vm.quoteState.error) && (
<Alert aria-live="polite" color="orange" mt="md" role="status">
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
</Alert>
)}
{quoteContent}
<StockSection
embedded
state={vm.companyState}
title={t("stock.company")}
>
<Group gap="xl" mb="md">
{!!stock.sector && (
<Text size="sm">
{t("stock.sector")}:{" "}
{t(`stock.sectors.${stock.sector}`, {
defaultValue: stock.sector,
})}
</Text>
)}
{stock.lot !== null && <Text size="sm">{t("stock.lot", { value: stock.lot })}</Text>}
</Group>
{!!vm.company && (
<Stack gap="lg">
{vm.company.unavailable.length > 0 && (
<Alert aria-live="polite" color="orange" role="status">
<Group justify="space-between">
<Text size="sm">{t("stock.partialCompany")}</Text>
<Button
onClick={vm.companyState.retry}
size="xs"
variant="default"
>
{t("accounts.retry")}
</Button>
</Group>
</Alert>
)}
{companyMetricsContent}
<Text c="dimmed" size="xs">
{t("stock.periodNote")}
</Text>
</Stack>
)}
</StockSection>
</InstrumentHeader>
<StockChart vm={vm} />
<StockPosition vm={vm} />
<StockDividends
dividends={vm.dividends}
pastDividends={vm.pastDividends}
state={vm.dividendsState}
upcoming={vm.upcoming}
/>
<StockEvents vm={vm} />
</Stack>
);
};
function metricUnit(
metric: NonNullable<StockPageVm["company"]>["metrics"][number],
unknownCurrency: string,
) {
if (metric.unit === "money")
return metric.currency === "RUB"
? "₽"
: (metric.currency ?? unknownCurrency);
return metric.unit === "percent" ? "%" : "×";
}
export { StockPageView };