diff --git a/frontend/src/components/crypto/CryptoAllocationCard.tsx b/frontend/src/components/crypto/CryptoAllocationCard.tsx index 74e0d90..2a276f0 100644 --- a/frontend/src/components/crypto/CryptoAllocationCard.tsx +++ b/frontend/src/components/crypto/CryptoAllocationCard.tsx @@ -1,12 +1,13 @@ import { Bitcoin } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; -import { formatCurrency } from "@/lib/format"; +import { formatCurrency, maskAmount } from "@/lib/format"; import { useTranslation } from "@/lib/i18n"; import type { CryptoHolding } from "@/types"; interface CryptoAllocationCardProps { holdings: CryptoHolding[]; isLoading: boolean; + hidden: boolean; } // Fixed categorical order, same 8-slot ramp the rest of the app already @@ -49,7 +50,7 @@ function buildSlices(holdings: CryptoHolding[]): Slice[] { return [...top, { key: "other", name: "Other", symbol: null, thumbUrl: null, amount: otherTotal, color: OTHER_COLOR }]; } -export function CryptoAllocationCard({ holdings, isLoading }: CryptoAllocationCardProps) { +export function CryptoAllocationCard({ holdings, isLoading, hidden }: CryptoAllocationCardProps) { const { t } = useTranslation(); const slices = buildSlices(holdings); const total = slices.reduce((sum, slice) => sum + slice.amount, 0); @@ -105,7 +106,7 @@ export function CryptoAllocationCard({ holdings, isLoading }: CryptoAllocationCa {percent.toFixed(0)}% - {formatCurrency(slice.amount)} + {maskAmount(formatCurrency(slice.amount), hidden)} ); diff --git a/frontend/src/components/crypto/CryptoHistoryChart.tsx b/frontend/src/components/crypto/CryptoHistoryChart.tsx index 86c498e..abab181 100644 --- a/frontend/src/components/crypto/CryptoHistoryChart.tsx +++ b/frontend/src/components/crypto/CryptoHistoryChart.tsx @@ -1,9 +1,8 @@ -import { useState } from "react"; import { Area, AreaChart, ResponsiveContainer, Tooltip, YAxis } from "recharts"; import { ArrowDown, ArrowUp, Eye, EyeOff } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { CryptoRangeSelector } from "@/components/crypto/CryptoRangeSelector"; -import { formatCurrency, formatSignedCurrency, getIntlLocale } from "@/lib/format"; +import { formatCurrency, formatSignedCurrency, getIntlLocale, maskAmount } from "@/lib/format"; import { useTranslation } from "@/lib/i18n"; import type { CryptoHistoryResponse, CryptoRange } from "@/types"; @@ -12,29 +11,46 @@ interface CryptoHistoryChartProps { isLoading: boolean; range: CryptoRange; onRangeChange: (range: CryptoRange) => void; + hidden: boolean; + onToggleHidden: () => void; } function formatAxisDate(iso: string): string { return new Intl.DateTimeFormat(getIntlLocale(), { month: "short", day: "numeric" }).format(new Date(`${iso}T00:00:00`)); } -function ChartTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: { date: string; value: number } }> }) { +function ChartTooltip({ + active, + payload, + hidden, +}: { + active?: boolean; + payload?: Array<{ payload: { date: string; value: number } }>; + hidden: boolean; +}) { if (!active || !payload?.length) return null; const point = payload[0].payload; return (

{formatAxisDate(point.date)}

-

{formatCurrency(point.value)}

+

{maskAmount(formatCurrency(point.value), hidden)}

); } /** The Crypto tab's headline chart — same recipe as NetWorthChart, plus an - * eye toggle to mask the big number (screen-over-someone's-shoulder - * privacy, doesn't hide anything else on the page). */ -export function CryptoHistoryChart({ history, isLoading, range, onRangeChange }: CryptoHistoryChartProps) { + * eye toggle. `hidden` is owned by CryptoPage and passed to every other + * money-displaying section of the tab too — this is just where the toggle + * button itself lives. */ +export function CryptoHistoryChart({ + history, + isLoading, + range, + onRangeChange, + hidden, + onToggleHidden, +}: CryptoHistoryChartProps) { const { t } = useTranslation(); - const [hidden, setHidden] = useState(false); const isPositive = history ? Number(history.change_amount) >= 0 : true; const trendColor = isPositive ? "var(--success)" : "var(--danger)"; const chartData = history?.series.map((point) => ({ date: point.date, value: Number(point.value) })) ?? []; @@ -45,20 +61,20 @@ export function CryptoHistoryChart({ history, isLoading, range, onRangeChange }:
{t("nav.crypto")}

- {isLoading ? "…" : hidden ? "••••••" : formatCurrency(history?.current ?? 0)} + {isLoading ? "…" : maskAmount(formatCurrency(history?.current ?? 0), hidden)}

- {history && !hidden && ( + {history && (

{isPositive ? : } - {formatSignedCurrency(history.change_amount)} + {maskAmount(formatSignedCurrency(history.change_amount), hidden)} {history.change_percent !== null && ` (${isPositive ? "+" : ""}${history.change_percent.toFixed(1)}%)`} {t("netWorth.periodSuffix")}

@@ -82,7 +98,7 @@ export function CryptoHistoryChart({ history, isLoading, range, onRangeChange }: - } cursor={{ stroke: "var(--gridline)", strokeWidth: 1 }} /> +
- {holding.current_price !== null ? formatCurrency(holding.current_price) : t("crypto.pendingPrice")} + {holding.current_price !== null + ? maskAmount(formatCurrency(holding.current_price), hidden) + : t("crypto.pendingPrice")} @@ -88,21 +91,20 @@ export function CryptoHoldingsTable({ items, onTrade, onViewHistory, onDelete }: - {holding.value !== null ? formatCurrency(holding.value) : t("crypto.pendingPrice")} + {holding.value !== null ? maskAmount(formatCurrency(holding.value), hidden) : t("crypto.pendingPrice")} - {Number(holding.quantity)} {holding.symbol} + {maskAmount(`${Number(holding.quantity)} ${holding.symbol}`, hidden)} - {holding.avg_buy_price !== null ? formatCurrency(holding.avg_buy_price) : "—"} + {holding.avg_buy_price !== null ? maskAmount(formatCurrency(holding.avg_buy_price), hidden) : "—"} {holding.profit_loss !== null && holding.profit_loss_percent !== null ? ( <> - {sign} - {formatCurrency(holding.profit_loss)} + {maskAmount(`${sign}${formatCurrency(holding.profit_loss)}`, hidden)} {sign} diff --git a/frontend/src/components/crypto/CryptoStatsRow.tsx b/frontend/src/components/crypto/CryptoStatsRow.tsx index 1ec3599..9d78908 100644 --- a/frontend/src/components/crypto/CryptoStatsRow.tsx +++ b/frontend/src/components/crypto/CryptoStatsRow.tsx @@ -1,12 +1,13 @@ import type { ReactNode } from "react"; import { Card, CardContent } from "@/components/ui/Card"; -import { formatCurrency } from "@/lib/format"; +import { formatCurrency, maskAmount } from "@/lib/format"; import { useTranslation } from "@/lib/i18n"; import type { CryptoHolding } from "@/types"; interface CryptoStatsRowProps { holdings: CryptoHolding[]; isLoading: boolean; + hidden: boolean; } interface Totals { @@ -79,7 +80,7 @@ function PerformerTile({ label, holding }: { label: string; holding: CryptoHoldi ); } -export function CryptoStatsRow({ holdings, isLoading }: CryptoStatsRowProps) { +export function CryptoStatsRow({ holdings, isLoading, hidden }: CryptoStatsRowProps) { const { t } = useTranslation(); if (isLoading) { @@ -94,8 +95,7 @@ export function CryptoStatsRow({ holdings, isLoading }: CryptoStatsRowProps) {

- {totals.totalProfitLoss >= 0 ? "+" : ""} - {formatCurrency(totals.totalProfitLoss)} + {maskAmount(`${totals.totalProfitLoss >= 0 ? "+" : ""}${formatCurrency(totals.totalProfitLoss)}`, hidden)}

{totals.totalProfitLossPercent !== null && (

@@ -106,7 +106,9 @@ export function CryptoStatsRow({ holdings, isLoading }: CryptoStatsRowProps) { -

{formatCurrency(totals.totalCostBasis)}

+

+ {maskAmount(formatCurrency(totals.totalCostBasis), hidden)} +

{t("crypto.stats.costBasisHint")}

diff --git a/frontend/src/components/crypto/CryptoTransactionHistoryModal.tsx b/frontend/src/components/crypto/CryptoTransactionHistoryModal.tsx index 40c8e97..2304b49 100644 --- a/frontend/src/components/crypto/CryptoTransactionHistoryModal.tsx +++ b/frontend/src/components/crypto/CryptoTransactionHistoryModal.tsx @@ -1,7 +1,7 @@ import { Trash2 } from "lucide-react"; import { Dialog } from "@/components/ui/Dialog"; import { useCryptoTransactions, useDeleteCryptoTransaction } from "@/hooks/useCrypto"; -import { formatCurrency, formatTransactionDate } from "@/lib/format"; +import { formatCurrency, formatTransactionDate, maskAmount } from "@/lib/format"; import { useTranslation } from "@/lib/i18n"; import type { CryptoHolding } from "@/types"; @@ -9,9 +9,10 @@ interface CryptoTransactionHistoryModalProps { open: boolean; onClose: () => void; holding: CryptoHolding | null; + hidden: boolean; } -export function CryptoTransactionHistoryModal({ open, onClose, holding }: CryptoTransactionHistoryModalProps) { +export function CryptoTransactionHistoryModal({ open, onClose, holding, hidden }: CryptoTransactionHistoryModalProps) { const { t } = useTranslation(); const { data: transactions, isLoading } = useCryptoTransactions(holding ? holding.asset_id : null); const deleteTransaction = useDeleteCryptoTransaction(); @@ -45,7 +46,7 @@ export function CryptoTransactionHistoryModal({ open, onClose, holding }: Crypto - {Number(tx.quantity)} {holding.symbol} · {formatCurrency(tx.price_per_unit)} + {maskAmount(`${Number(tx.quantity)} ${holding.symbol} · ${formatCurrency(tx.price_per_unit)}`, hidden)} {formatTransactionDate(tx.date, true)} diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 7e7f0c0..666e95b 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -23,6 +23,15 @@ export function formatSignedCurrency(amount: number | string, currency: string = return `${sign}${formatCurrency(value, currency)}`; } +/** "Hide balance" masking — wraps an already-formatted amount (currency, + * quantity, whatever reveals a dollar figure) rather than reformatting it, + * so call sites don't need a separate hidden-vs-shown branch for every + * number. Percentages are deliberately never masked by callers — a % move + * doesn't reveal how much money is actually involved. */ +export function maskAmount(formatted: string, hidden: boolean): string { + return hidden ? "••••" : formatted; +} + const MONTH_LABELS_EN = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const; const MONTH_LABELS_RU = ["Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"] as const; diff --git a/frontend/src/pages/CryptoPage.tsx b/frontend/src/pages/CryptoPage.tsx index c5d934b..98bce12 100644 --- a/frontend/src/pages/CryptoPage.tsx +++ b/frontend/src/pages/CryptoPage.tsx @@ -21,6 +21,11 @@ function formatSyncedAt(iso: string): string { export function CryptoPage() { const { t } = useTranslation(); const [range, setRange] = useState("30d"); + // Single "hide balance" switch for the whole tab — owned here so every + // money-displaying section (chart, stats, allocation, table, trade + // history) masks together instead of the toggle only affecting whichever + // component happened to own it. + const [hidden, setHidden] = useState(false); const { data, isLoading } = useCryptoHoldings(); const { data: history, isLoading: isHistoryLoading } = useCryptoHistory(range); const refresh = useRefreshCryptoPrices(); @@ -40,11 +45,18 @@ export function CryptoPage() { return (
- +
);