- 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' -> 'Захиалга'
225 lines
9.1 KiB
TypeScript
225 lines
9.1 KiB
TypeScript
"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>
|
||
);
|
||
}
|