mercury-web/src/features/home/DashboardView.tsx
Munkherdene 6913c1e5ee feat(web): desktop layout (wider container + 2-col dashboard) + redirect-loop fix + mn string
- Shell content widens to max-w-6xl on desktop (was phone-strip max-w-2xl)
- Dashboard reflows into a 2-col grid on desktop instead of stretching
- On 401, clear session cookie via logout before redirect (prevents /login<->/home loop when cookie outlives server session)
- Profile 'Subscriptions' -> 'Захиалга'
2026-08-22 21:33:14 +08:00

225 lines
9.1 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.

"use client";
import * as React from "react";
import Link from "next/link";
import { Skeleton, ProgressCircleRoot, ProgressCircleTrack, ProgressCircleRange } from "@seed-design/react";
import { Card, AmountToggle, HideAmountsToggle } from "../../ds";
import { tugrikShortRaw } from "../../ds/money";
import { useNetWorth, useAnalyzeMonth, useAnalyzeToday, useBudget } from "../../api/hooks/reads";
import { buildHome, type HomeData } from "./buildHome";
import { sample } from "./sample";
import { homeStrings as s } from "./strings";
/** A number that's redacted with a Seed skeleton block while loading, and a
* tap-to-reveal `AmountToggle` once real data has arrived. Mirrors iOS's
* `.skeleton(loading)` view modifier, which redacts the finished layout in
* place rather than swapping in a separate spinner. */
function Amount({ value, loading, width = "72px" }: { value: number; loading: boolean; width?: string }) {
if (loading) return <Skeleton height="1em" width={width} style={{ display: "inline-block" }} />;
return <AmountToggle value={value} />;
}
/** The circular "₮" badge used on every card (togrogCircle in DashboardView.swift). */
function TugrikCircle({ bg, fg }: { bg: string; fg: string }) {
return (
<div
style={{
width: 49,
height: 49,
borderRadius: "50%",
background: bg,
color: fg,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 700,
fontSize: 20,
flexShrink: 0,
}}
>
</div>
);
}
/** The Нүүр (home) dashboard: header, safe-to-spend hero, daily-limit +
* top budgets, this-month spend, and recent income/expense. Figures come
* from `buildHome` (real where the backend has them, `sample` otherwise).
* Ported from ios/Mercury/Features/Home/DashboardView.swift. */
export function DashboardView() {
const netWorthQ = useNetWorth();
const monthQ = useAnalyzeMonth();
const todayQ = useAnalyzeToday();
const budgetQ = useBudget();
const loading = netWorthQ.isLoading || monthQ.isLoading || todayQ.isLoading || budgetQ.isLoading;
const built = buildHome({
netWorth: netWorthQ.data,
month: monthQ.data,
today: todayQ.data,
budget: budgetQ.data,
});
const data: HomeData = built ?? sample;
const budgetDenominator = data.availableBudget;
const fraction = budgetDenominator > 0 ? Math.min(1, data.monthlyExpense / budgetDenominator) : 0;
const dailyFraction = data.dailyLimitTotal > 0 ? Math.min(1, data.dailyLimitUsed / data.dailyLimitTotal) : 0;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
<header style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span style={{ fontWeight: 600, fontSize: 18, color: "var(--seed-color-fg-neutral)" }}>{s.wordmark}</span>
<HideAmountsToggle label={s.hideAmounts} />
</header>
<div className="grid grid-cols-1 gap-[18px] md:grid-cols-2 md:items-start md:gap-5">
{/* Safe-to-spend hero (green): how much is left to spend this month
after committed obligations and what's already been spent. */}
<Link
href="/accounting"
style={{
display: "block",
textDecoration: "none",
color: "var(--mercury-on-brand)",
background: "var(--mercury-balance-card)",
borderRadius: "var(--seed-radius-r5, 20px)",
padding: "14px 20px",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<TugrikCircle bg="var(--mercury-balance-circle)" fg="var(--mercury-on-brand)" />
<div style={{ display: "flex", flexDirection: "column" }}>
<span style={{ fontSize: 12 }}>{data.overspent ? s.overspent : s.safeToSpend}</span>
<span style={{ fontSize: 26, fontWeight: 700 }}>
<Amount value={data.safeToSpend} loading={loading} width="140px" />
</span>
</div>
</div>
<div
style={{
marginTop: 12,
height: 6,
borderRadius: 999,
background: "rgba(0,0,0,0.13)",
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${Math.max(2, fraction * 100)}%`,
borderRadius: 999,
background: data.overspent ? "var(--seed-color-bg-critical, #d92d20)" : "var(--mercury-on-brand)",
}}
/>
</div>
<div style={{ marginTop: 8, display: "flex", justifyContent: "space-between", fontSize: 12 }}>
<span>{s.spent}</span>
<span style={{ fontWeight: 700, fontSize: 13 }}>
<Amount value={data.monthlyExpense} loading={loading} />
<span style={{ fontWeight: 400, fontSize: 11 }}> / {tugrikShortRaw(budgetDenominator)}</span>
</span>
</div>
</Link>
{/* Daily limit + top budgets (blue). */}
<Link
href="/planner"
style={{
display: "block",
textDecoration: "none",
color: "#fff",
background: "var(--mercury-limit-card)",
borderRadius: "var(--seed-radius-r5, 20px)",
padding: "14px 20px",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<ProgressCircleRoot value={dailyFraction * 100} size="40" tone="staticWhite">
<ProgressCircleTrack />
<ProgressCircleRange />
</ProgressCircleRoot>
<div style={{ display: "flex", flexDirection: "column" }}>
<span style={{ fontSize: 12 }}>{s.dailyLimit}</span>
<span style={{ fontSize: 22, fontWeight: 700 }}>
<Amount value={data.dailyLimitUsed} loading={loading} width="60px" />
{" / "}
<Amount value={data.dailyLimitTotal} loading={loading} width="60px" />
</span>
</div>
</div>
<div style={{ marginTop: 4, fontSize: 11, opacity: 0.85 }}>
{s.todaySpent}: <Amount value={data.todayExpense} loading={loading} width="50px" />
</div>
<hr style={{ margin: "12px 0", border: 0, borderTop: "1px solid rgba(0,0,0,0.13)" }} />
{data.budgets.map((b) => (
<div key={b.name} style={{ display: "flex", justifyContent: "space-between", padding: "4px 0" }}>
<span style={{ fontSize: 12 }}>{b.name}</span>
<span style={{ fontSize: 14, fontWeight: 700 }}>
<span style={{ color: "var(--mercury-warning-chip)" }}>
<Amount value={b.spent} loading={loading} width="50px" />
</span>
<span style={{ fontWeight: 700, fontSize: 11 }}> / {b.range}</span>
</span>
</div>
))}
</Link>
{/* This month's spend (white). */}
<Card style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={{ fontWeight: 700, fontSize: 14, color: "var(--seed-color-fg-placeholder)" }}>{s.thisMonth}</span>
<div style={{ display: "flex", alignItems: "baseline", gap: 4 }}>
{loading ? (
<Skeleton height="1.6em" width="120px" />
) : (
<span style={{ fontSize: 26, fontWeight: 700, color: "var(--seed-color-fg-neutral)" }}>
{tugrikShortRaw(data.monthlyExpense)}
</span>
)}
<span style={{ fontSize: 16, fontWeight: 600, color: "var(--seed-color-fg-placeholder)" }}>
{s.expenseLabel}
</span>
</div>
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<Link
href="/accounting"
style={{
fontSize: 12,
color: "var(--mercury-on-brand)",
background: "var(--mercury-warning-chip)",
borderRadius: 999,
padding: "8px 12px",
textDecoration: "none",
}}
>
{s.viewAll}
</Link>
</div>
</Card>
{/* Recent income / expense ledgers. */}
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<Card>
<span style={{ fontWeight: 700, fontSize: 14, color: "var(--seed-color-fg-placeholder)" }}>{s.income}</span>
{data.income.map((row, i) => (
<div key={i} style={{ display: "flex", justifyContent: "space-between", padding: "6px 0" }}>
<span style={{ color: "var(--seed-color-fg-neutral)" }}>{row.title}</span>
<span style={{ color: "var(--seed-color-fg-placeholder)" }}>{row.date}</span>
</div>
))}
</Card>
<Card>
<span style={{ fontWeight: 700, fontSize: 14, color: "var(--seed-color-fg-placeholder)" }}>{s.expense}</span>
{data.expense.map((row, i) => (
<div key={i} style={{ display: "flex", justifyContent: "space-between", padding: "6px 0" }}>
<span style={{ color: "var(--seed-color-fg-neutral)" }}>{row.title}</span>
<span style={{ color: "var(--seed-color-fg-placeholder)" }}>{row.date}</span>
</div>
))}
</Card>
</div>
</div>
</div>
);
}