merge task/t10: transactions list + detail + edits

This commit is contained in:
Munkherdene 2026-08-22 21:01:05 +08:00
commit 3014385e9b
9 changed files with 758 additions and 0 deletions

View file

@ -0,0 +1,11 @@
import { TransactionDetail } from "@/features/accounting/TransactionDetail";
// Ports ios/Mercury/Features/Transactions/TransactionDetailView.swift.
export default async function TransactionDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <TransactionDetail id={id} />;
}

View file

@ -0,0 +1,7 @@
import { TransactionList } from "@/features/accounting/TransactionList";
// Ports ios/Mercury/Features/Transactions/TransactionsView.swift's ledger
// tab (narrowed to this task's scope — see TransactionList).
export default function AccountingPage() {
return <TransactionList />;
}

View file

@ -0,0 +1,97 @@
"use client";
import {
BottomSheetRoot,
BottomSheetBackdrop,
BottomSheetPositioner,
BottomSheetContent,
BottomSheetHeader,
BottomSheetTitle,
BottomSheetCloseButton,
BottomSheetBody,
Icon,
ListRoot,
ListItem,
ListContent,
ListTitle,
} from "@seed-design/react";
import type { Category } from "@/api/schemas";
import { accountingStrings as s } from "./strings";
export interface CategorizeSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
categories: Category[];
/** The transaction's current category (main or sub) — highlighted in the list. */
selected?: string;
onSelect: (category: Category) => void;
}
const closeSvg = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
);
const checkSvg = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12.5l4.5 4.5L19 7" />
</svg>
);
/**
* Category picker bottom sheet (ports `CategoryPickerSheet` from
* `ios/Mercury/Features/Planner/PlannerEditViews.swift`, opened from the
* transaction detail's Ангилал row): a titled list of all categories,
* tap-to-select, checkmark on the current pick.
*/
export function CategorizeSheet({ open, onOpenChange, categories, selected, onSelect }: CategorizeSheetProps) {
return (
<BottomSheetRoot open={open} onOpenChange={onOpenChange}>
<BottomSheetBackdrop />
<BottomSheetPositioner>
<BottomSheetContent>
<BottomSheetHeader style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<BottomSheetTitle>{s.categoryPicker.title}</BottomSheetTitle>
<BottomSheetCloseButton aria-label={s.categoryPicker.cancel}>
<Icon svg={closeSvg} size="16px" />
</BottomSheetCloseButton>
</BottomSheetHeader>
<BottomSheetBody style={{ maxHeight: "60vh", overflowY: "auto" }}>
<ListRoot>
{categories.map((cat) => {
const isSelected = cat.name === selected;
return (
<ListItem key={cat.name} style={{ padding: 0 }}>
<button
type="button"
onClick={() => onSelect(cat)}
style={{
display: "flex",
width: "100%",
alignItems: "center",
justifyContent: "space-between",
background: "none",
border: "none",
textAlign: "left",
cursor: "pointer",
padding: "12px 4px",
font: "inherit",
color: "inherit",
}}
>
<ListContent>
<ListTitle>{cat.name}</ListTitle>
</ListContent>
{isSelected && <Icon svg={checkSvg} size="18px" />}
</button>
</ListItem>
);
})}
</ListRoot>
</BottomSheetBody>
</BottomSheetContent>
</BottomSheetPositioner>
</BottomSheetRoot>
);
}

View file

@ -0,0 +1,321 @@
"use client";
import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import {
DialogRoot,
DialogBackdrop,
DialogPositioner,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogAction,
TextFieldRoot,
TextFieldTextarea,
} from "@seed-design/react";
import { useTransactions, useCategories } from "@/api/hooks/reads";
import { useCategorize, useRenameTxn, useSetNote } from "@/api/hooks/mutations";
import { Card, MercuryButton, NameEdit } from "@/ds";
import { MASKED, tugrikRaw } from "@/ds/money";
import { accountingStrings as s } from "./strings";
import { CategorizeSheet } from "./CategorizeSheet";
import { findTxnByRouteId } from "./txnRoute";
import { useHiddenAmounts } from "./useHiddenAmounts";
export interface TransactionDetailProps {
id: string;
}
const ROW_BORDER: CSSProperties = { borderBottom: "1px solid var(--seed-color-border-neutral, #e5e5e5)" };
function formatDateTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}.${pad(d.getMonth() + 1)}.${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function DetailRow({
label,
value,
chevron,
bold,
last,
onClick,
}: {
label: string;
value: string;
chevron?: boolean;
bold?: boolean;
last?: boolean;
onClick?: () => void;
}) {
const wrapperStyle: CSSProperties = last ? {} : ROW_BORDER;
const body = (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: "12px 16px" }}>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)", flexShrink: 0 }}>{label}</span>
<span
style={{
fontSize: bold ? 14 : 13,
fontWeight: bold ? 700 : 400,
textAlign: "right",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{value}
</span>
{chevron && (
<span aria-hidden style={{ color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
</span>
)}
</div>
);
if (!onClick) return <div style={wrapperStyle}>{body}</div>;
return (
<button
type="button"
onClick={onClick}
style={{ all: "unset", display: "block", width: "100%", boxSizing: "border-box", cursor: "pointer", ...wrapperStyle }}
>
{body}
</button>
);
}
/**
* Transaction detail (ports `TransactionDetailView.swift`, narrowed to this
* task's scope): a key-value table plus categorize / rename / note edits.
* Categorize and rename both "learn a rule" server-side keyed on the
* merchant's match key (applies to all of that merchant's past + future
* transactions) renaming shows a confirm dialog because of that broad
* effect; categorizing (like iOS) applies immediately.
*/
export function TransactionDetail({ id }: TransactionDetailProps) {
const router = useRouter();
const { data: transactions } = useTransactions();
const { data: categories = [] } = useCategories();
const categorize = useCategorize();
const renameTxn = useRenameTxn();
const setNoteMutation = useSetNote();
const hiddenAmounts = useHiddenAmounts();
const txn = useMemo(() => findTxnByRouteId(transactions ?? [], id), [transactions, id]);
// Local overrides so an edit reflects immediately, matching iOS's
// `assigned` / `displayTitle` @State — the server call runs in the
// background and the list refetch (via mutation `onSuccess` invalidation)
// reconciles afterwards.
const [assignedCategory, setAssignedCategory] = useState<string | null>(null);
const [displayTitle, setDisplayTitle] = useState<string | null>(null);
const [noteOverride, setNoteOverride] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [renaming, setRenaming] = useState(false);
const [pendingName, setPendingName] = useState<string | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [editingNote, setEditingNote] = useState(false);
const [noteDraft, setNoteDraft] = useState("");
useEffect(() => {
setAssignedCategory(null);
setDisplayTitle(null);
setNoteOverride(null);
setRenaming(false);
setEditingNote(false);
}, [id]);
if (!txn) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<BackButton onClick={() => router.push("/accounting")} />
<Card>
<p style={{ margin: 0, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>{s.detail.notFound}</p>
</Card>
</div>
);
}
const income = txn.direction === "income";
const category = assignedCategory ?? txn.category;
const title = displayTitle ?? txn.title;
const note = noteOverride ?? txn.note ?? "";
const canNote = txn.txnId != null && txn.txnId > 0;
const amountRaw = hiddenAmounts ? MASKED : tugrikRaw(txn.amount);
const signedAmount = hiddenAmounts ? MASKED : `${income ? "+" : ""}${tugrikRaw(txn.amount)}`;
async function confirmRename() {
if (!pendingName) return;
const name = pendingName;
setDisplayTitle(name);
setPendingName(null);
try {
await renameTxn.mutateAsync({ matchKey: txn!.matchKey ?? txn!.title, name });
} catch {
// Best-effort, matches iOS's `renameMerchant` — leave the optimistic
// title in place; the next successful list load reconciles it.
}
}
async function saveNote() {
const trimmed = noteDraft.trim();
setNoteOverride(trimmed);
setEditingNote(false);
if (txn!.txnId != null) {
try {
await setNoteMutation.mutateAsync({ id: txn!.txnId, note: trimmed });
} catch {
// Best-effort, matches iOS's `saveNote`.
}
}
}
if (renaming) {
return (
<div style={{ paddingTop: 24 }}>
<NameEdit
title={s.rename.title}
initial={title}
placeholder={s.rename.placeholder}
onCancel={() => setRenaming(false)}
onSave={(name) => {
setRenaming(false);
setPendingName(name);
setConfirmOpen(true);
}}
/>
</div>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
<BackButton onClick={() => router.push("/accounting")} />
<Card style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 4, minWidth: 0 }}>
<span style={{ fontSize: 16, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{title || category}
</span>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>{category}</span>
</div>
<span
style={{
fontSize: 18,
fontWeight: 700,
flexShrink: 0,
color: income ? "var(--mercury-success, #1e9e6b)" : "var(--mercury-critical, #e5484d)",
}}
>
{signedAmount}
</span>
</Card>
<Card style={{ padding: 0 }}>
<DetailRow label={s.detail.total} value={amountRaw} bold />
<DetailRow label={s.detail.date} value={formatDateTime(txn.date)} />
<DetailRow label={s.detail.category} value={category} chevron onClick={() => setPickerOpen(true)} />
<DetailRow label={s.detail.name} value={title} chevron onClick={() => setRenaming(true)} />
{txn.balanceAfter != null && (
<DetailRow label={s.detail.balance} value={hiddenAmounts ? MASKED : tugrikRaw(txn.balanceAfter)} />
)}
<DetailRow
label={s.detail.type}
value={income ? s.detail.income : s.detail.expense}
last={!canNote}
/>
{canNote &&
(editingNote ? (
<div style={{ padding: 12, display: "flex", flexDirection: "column", gap: 8 }}>
<TextFieldRoot value={noteDraft} onValueChange={setNoteDraft}>
<TextFieldTextarea
aria-label={s.detail.note}
placeholder={s.detail.noteAdd}
style={{ minHeight: 72 }}
/>
</TextFieldRoot>
<div style={{ display: "flex", gap: 8 }}>
<MercuryButton variant="secondary" style={{ flex: 1 }} onClick={() => setEditingNote(false)}>
{s.detail.noteCancel}
</MercuryButton>
<MercuryButton
variant="primary"
style={{ flex: 1 }}
loading={setNoteMutation.isPending}
onClick={saveNote}
>
{s.detail.noteSave}
</MercuryButton>
</div>
</div>
) : (
<DetailRow
label={s.detail.note}
value={note || s.detail.noteAdd}
chevron
last
onClick={() => {
setNoteDraft(note);
setEditingNote(true);
}}
/>
))}
</Card>
<CategorizeSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
categories={categories}
selected={category}
onSelect={(cat) => {
setAssignedCategory(cat.name);
setPickerOpen(false);
categorize.mutate({
matchKey: txn.matchKey ?? txn.title,
category: cat.name,
kind: txn.direction === "income" ? "income" : "expense",
});
}}
/>
<DialogRoot open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogBackdrop />
<DialogPositioner>
<DialogContent>
<DialogHeader>
<DialogTitle>{s.rename.confirmTitle}</DialogTitle>
<DialogDescription>{s.rename.confirmDescription}</DialogDescription>
</DialogHeader>
<DialogFooter style={{ display: "flex", gap: 8 }}>
<DialogAction style={{ flex: 1 }}>{s.rename.confirmCancel}</DialogAction>
<DialogAction style={{ flex: 1 }} onClick={confirmRename}>
{s.rename.confirmSave}
</DialogAction>
</DialogFooter>
</DialogContent>
</DialogPositioner>
</DialogRoot>
</div>
);
}
function BackButton({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
aria-label={s.detail.back}
style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer", alignSelf: "flex-start", padding: 0 }}
>
</button>
);
}

View file

@ -0,0 +1,52 @@
import { render, screen } from "@testing-library/react";
import { it, expect, vi, beforeEach } from "vitest";
import type { Txn } from "@/api/schemas";
import { setHidden } from "@/ds/money";
const txns: Txn[] = [
{
date: "2026-08-20T09:00:00Z",
amount: "15000",
direction: "expense",
category: "Хоол",
title: "Кофе шоп",
accountId: 1,
txnId: 101,
},
{
date: "2026-08-20T08:00:00Z",
amount: "2500000",
direction: "income",
category: "Цалин",
title: "ХХК цалин",
accountId: 1,
txnId: 102,
salary: true,
},
];
vi.mock("@/api/hooks/reads", () => ({
useTransactions: () => ({ data: txns, isLoading: false }),
}));
import { TransactionList } from "./TransactionList";
beforeEach(() => {
setHidden(false);
});
it("renders transaction titles and formatted amounts, hiding salary rows by default", () => {
render(<TransactionList />);
// Non-salary row: title + a signed, grouped amount.
expect(screen.getByText("Кофе шоп")).toBeInTheDocument();
expect(screen.getByText("15,000₮")).toBeInTheDocument();
// Salary row is filtered out of the row list by default...
expect(screen.queryByText("ХХК цалин")).not.toBeInTheDocument();
// ...but its amount still counts toward the income total (salary is
// included in totals per TransactionsModel.recompute, only excluded from
// the row list).
expect(screen.getByText("2,500,000₮")).toBeInTheDocument();
});

View file

@ -0,0 +1,174 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { Skeleton } from "@seed-design/react";
import { useTransactions } from "@/api/hooks/reads";
import type { Txn } from "@/api/schemas";
import { Card, HideAmountsToggle } from "@/ds";
import { MASKED, tugrikRaw } from "@/ds/money";
import { accountingStrings as s } from "./strings";
import { txnRouteId } from "./txnRoute";
import { useHiddenAmounts } from "./useHiddenAmounts";
/** Local calendar day (browser-local time), for grouping + the day header
* a dependency-free stand-in for iOS's Asia/Ulaanbaatar `Calendar`. */
function dayKeyAndLabel(iso: string): { key: string; label: string } {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return { key: iso.slice(0, 10), label: iso.slice(0, 10) };
const y = d.getFullYear();
const m = d.getMonth() + 1;
const day = d.getDate();
return { key: `${y}-${String(m).padStart(2, "0")}-${String(day).padStart(2, "0")}`, label: `${m}-р сарын ${day}` };
}
interface DayGroup {
key: string;
label: string;
items: Txn[];
}
/** Groups already-sorted (newest-first) rows into consecutive same-day buckets. */
function groupByDay(items: Txn[]): DayGroup[] {
const groups: DayGroup[] = [];
for (const txn of items) {
const { key, label } = dayKeyAndLabel(txn.date);
const last = groups[groups.length - 1];
if (last && last.key === key) {
last.items.push(txn);
} else {
groups.push({ key, label, items: [txn] });
}
}
return groups;
}
function amountOf(txn: Txn): number {
return parseFloat(txn.amount) || 0;
}
function SummaryRow({ label, value, hidden, tone }: { label: string; value: number; hidden: boolean; tone: "income" | "expense" }) {
return (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: 16 }}>{label}</span>
<span
style={{
fontSize: 16,
fontWeight: 700,
color: tone === "income" ? "var(--mercury-success, #1e9e6b)" : "var(--mercury-critical, #e5484d)",
}}
>
{hidden ? MASKED : tugrikRaw(value)}
</span>
</div>
);
}
function TxnRow({ txn, hidden }: { txn: Txn; hidden: boolean }) {
const income = txn.direction === "income";
const isTransfer = txn.transfer === true;
const sign = income ? "+" : "";
const amountText = hidden ? MASKED : `${sign}${tugrikRaw(txn.amount)}`;
const amountColor = isTransfer
? "var(--seed-color-fg-neutral-muted, #8b8b8b)"
: income
? "var(--mercury-success, #1e9e6b)"
: "var(--mercury-critical, #e5484d)";
return (
<Link
href={`/accounting/${txnRouteId(txn)}`}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
padding: "12px 0",
textDecoration: "none",
color: "inherit",
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: 4, minWidth: 0 }}>
<span style={{ fontSize: 15, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{txn.title || txn.category}
</span>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
{isTransfer ? `${txn.category} · ${s.list.transferTag}` : txn.category}
</span>
</div>
<span style={{ fontSize: 16, fontWeight: 700, color: amountColor, flexShrink: 0 }}>{amountText}</span>
</Link>
);
}
/**
* The Тооцоо list: income/expense totals for the loaded month, then the
* transaction rows grouped by day. Mirrors `TransactionsView.ledgerTab` /
* `TransactionsModel.recompute` (narrowed to this task's scope no month
* nav or category chips): salary deposits (`salary === true`) are hidden by
* default, and transfers are excluded from the totals and tagged in the row
* meta line instead of colored green/red.
*/
export function TransactionList() {
const { data, isLoading } = useTransactions();
const all = useMemo(() => data ?? [], [data]);
const hidden = useHiddenAmounts();
const totals = useMemo(() => {
let income = 0;
let expense = 0;
for (const txn of all) {
if (txn.transfer === true) continue;
if (txn.direction === "income") income += amountOf(txn);
else expense += amountOf(txn);
}
return { income, expense };
}, [all]);
const visible = useMemo(() => all.filter((txn) => txn.salary !== true), [all]);
const groups = useMemo(() => groupByDay(visible), [visible]);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0 }}>{s.list.title}</h1>
<HideAmountsToggle label={s.list.hideToggle} />
</div>
<Card style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<SummaryRow label={s.list.income} value={totals.income} hidden={hidden} tone="income" />
<SummaryRow label={s.list.expense} value={totals.expense} hidden={hidden} tone="expense" />
</Card>
{isLoading ? (
<Skeleton style={{ height: 320, width: "100%", borderRadius: "var(--seed-radius-r3)" }} />
) : groups.length === 0 ? (
<Card>
<p style={{ margin: 0, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>{s.list.empty}</p>
</Card>
) : (
<Card style={{ display: "flex", flexDirection: "column", gap: 20 }}>
{groups.map((group) => (
<div key={group.key}>
<div
style={{
fontSize: 14,
fontWeight: 700,
color: "var(--seed-color-fg-neutral-muted, #8b8b8b)",
marginBottom: 4,
}}
>
{group.label}
</div>
<div style={{ display: "flex", flexDirection: "column" }}>
{group.items.map((txn, i) => (
<TxnRow key={`${txnRouteId(txn)}-${i}`} txn={txn} hidden={hidden} />
))}
</div>
</div>
))}
</Card>
)}
</div>
);
}

View file

@ -0,0 +1,44 @@
// Accounting (Тооцоо) feature copy, ported verbatim from
// ios/Mercury/Features/Transactions/TransactionsView.swift,
// TransactionDetailView.swift, and
// ios/Mercury/Features/Categories/CategorizeReviewView.swift,
// ios/Mercury/Features/Planner/PlannerEditViews.swift (CategoryPickerSheet).
export const accountingStrings = {
list: {
title: "Гүйлгээ",
hideToggle: "Мөнгөн дүн нуух",
income: "Орлого",
expense: "Зарлага",
empty: "Гүйлгээ алга",
transferTag: "Шилжүүлэг",
},
detail: {
total: "Нийт",
date: "Огноо",
category: "Ангилал",
name: "Нэр",
balance: "Үлдэгдэл",
type: "Төрөл",
income: "Орлого",
expense: "Зарлага",
note: "Тэмдэглэл",
noteAdd: "Нэмэх",
noteSave: "Хадгалах",
noteCancel: "Болих",
notFound: "Гүйлгээ олдсонгүй",
back: "Буцах",
},
rename: {
title: "Нэр өөрчлөх",
placeholder: "Шинэ нэр",
confirmTitle: "Нэр өөрчлөх үү?",
confirmDescription: "Энэ худалдагчийн бүх өмнөх болон дараагийн гүйлгээнд шинэ нэрийг хэрэглэнэ.",
confirmCancel: "Болих",
confirmSave: "Хадгалах",
},
categoryPicker: {
title: "Гарчиг",
cancel: "Болих",
select: "Сонгох",
},
} as const;

View file

@ -0,0 +1,18 @@
import type { Txn } from "@/api/schemas";
/**
* Stable per-row identifier for linking a list row to `/accounting/[id]`.
* Settled transactions carry a numeric `txnId`; pending holds (no id yet,
* same as iOS's `TxnDTO.txnId == nil`) fall back to a composite of their
* match key + date so the row is still linkable, best-effort, without a
* dedicated "get one transaction" endpoint.
*/
export function txnRouteId(txn: Txn): string {
if (txn.txnId != null) return String(txn.txnId);
return `p_${encodeURIComponent(txn.matchKey ?? txn.title)}_${encodeURIComponent(txn.date)}`;
}
/** Finds the transaction in `txns` that a given `/accounting/[id]` id refers to. */
export function findTxnByRouteId(txns: Txn[], id: string): Txn | undefined {
return txns.find((t) => txnRouteId(t) === id);
}

View file

@ -0,0 +1,34 @@
"use client";
import { useEffect, useState } from "react";
import { HIDE_AMOUNTS_EVENT } from "@/ds";
import { isHidden } from "@/ds/money";
/**
* Tracks the global hide-amounts flag reactively. `tugrik()`/`isHidden()`
* read `localStorage` synchronously but don't cause a re-render on their
* own pages that show masked amounts need to listen for the
* `HideAmountsToggle`-dispatched event (and other tabs' storage writes) to
* update immediately when the switch flips.
*/
export function useHiddenAmounts(): boolean {
const [hidden, setHiddenState] = useState<boolean>(() => isHidden());
useEffect(() => {
function onToggle(e: Event) {
const detail = (e as CustomEvent<{ hidden: boolean }>).detail;
setHiddenState(detail ? detail.hidden : isHidden());
}
function onStorage() {
setHiddenState(isHidden());
}
window.addEventListener(HIDE_AMOUNTS_EVENT, onToggle);
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener(HIDE_AMOUNTS_EVENT, onToggle);
window.removeEventListener("storage", onStorage);
};
}, []);
return hidden;
}