Compare commits

..

No commits in common. "0010b7b5fa9cd8628e1ddf0995d517a725f05cf4" and "06e74b16688b4846f70c8d0119949253db856024" have entirely different histories.

30 changed files with 579 additions and 1220 deletions

View file

@ -1,15 +1,45 @@
"use client"; "use client";
import { useRouter } from "next/navigation";
import { AuthForm } from "@/features/auth/AuthForm"; import { AuthForm } from "@/features/auth/AuthForm";
import { AuthHeader } from "@/features/auth/AuthHeader"; import { authStrings } from "@/features/auth/strings";
// Ports ios/Mercury/Features/Auth/ForgotPasswordView.swift: back chevron, // Ports ios/Mercury/Features/Auth/ForgotPasswordView.swift: back chevron,
// MERCURY wordmark, explanatory subtitle (rendered by AuthForm), the email // MERCURY wordmark, explanatory subtitle (rendered by AuthForm), the email
// field, and the "Сэргээх холбоос авах" CTA. // field, and the "Сэргээх холбоос авах" CTA.
export default function ForgotPasswordPage() { export default function ForgotPasswordPage() {
const router = useRouter();
return ( return (
<> <>
<AuthHeader /> <button
type="button"
onClick={() => router.back()}
aria-label="Буцах"
style={{
background: "none",
border: "none",
alignSelf: "flex-start",
marginTop: 24,
fontSize: 20,
cursor: "pointer",
}}
>
</button>
<div style={{ height: 72 }} />
<h1
style={{
textAlign: "center",
fontWeight: 700,
letterSpacing: 1,
fontSize: 24,
}}
>
{authStrings.wordmark}
</h1>
<div style={{ flex: 1, minHeight: 40 }} /> <div style={{ flex: 1, minHeight: 40 }} />

View file

@ -2,7 +2,7 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { AuthForm } from "@/features/auth/AuthForm"; import { AuthForm } from "@/features/auth/AuthForm";
import { AuthHeader } from "@/features/auth/AuthHeader"; import { authStrings } from "@/features/auth/strings";
// Ports ios/Mercury/Features/Auth/LoginView.swift: back chevron, MERCURY // Ports ios/Mercury/Features/Auth/LoginView.swift: back chevron, MERCURY
// wordmark, then the login form (email + password + forgot link + CTAs). // wordmark, then the login form (email + password + forgot link + CTAs).
@ -11,7 +11,34 @@ export default function LoginPage() {
return ( return (
<> <>
<AuthHeader /> <button
type="button"
onClick={() => router.back()}
aria-label="Буцах"
style={{
background: "none",
border: "none",
alignSelf: "flex-start",
marginTop: 24,
fontSize: 20,
cursor: "pointer",
}}
>
</button>
<div style={{ height: 72 }} />
<h1
style={{
textAlign: "center",
fontWeight: 700,
letterSpacing: 1,
fontSize: 24,
}}
>
{authStrings.wordmark}
</h1>
<div style={{ flex: 1, minHeight: 40 }} /> <div style={{ flex: 1, minHeight: 40 }} />

View file

@ -2,7 +2,7 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { AuthForm } from "@/features/auth/AuthForm"; import { AuthForm } from "@/features/auth/AuthForm";
import { AuthHeader } from "@/features/auth/AuthHeader"; import { authStrings } from "@/features/auth/strings";
// Ports ios/Mercury/Features/Auth/RegisterView.swift: back chevron, MERCURY // Ports ios/Mercury/Features/Auth/RegisterView.swift: back chevron, MERCURY
// wordmark, then the registration form (email + password + confirm + CTAs). // wordmark, then the registration form (email + password + confirm + CTAs).
@ -11,7 +11,34 @@ export default function RegisterPage() {
return ( return (
<> <>
<AuthHeader /> <button
type="button"
onClick={() => router.back()}
aria-label="Буцах"
style={{
background: "none",
border: "none",
alignSelf: "flex-start",
marginTop: 24,
fontSize: 20,
cursor: "pointer",
}}
>
</button>
<div style={{ height: 72 }} />
<h1
style={{
textAlign: "center",
fontWeight: 700,
letterSpacing: 1,
fontSize: 24,
}}
>
{authStrings.wordmark}
</h1>
<div style={{ flex: 1, minHeight: 40 }} /> <div style={{ flex: 1, minHeight: 40 }} />

View file

@ -1,48 +0,0 @@
import { Icon, type IconName } from "./icons";
/** A designed empty / low-data state: a soft icon medallion, a title, and a
* one-line hint. Replaces the bare "Гүйлгээ алга" text so a quiet screen still
* feels intentional. */
export function EmptyState({
icon,
title,
hint,
compact = false,
}: {
icon: IconName;
title: string;
hint?: string;
compact?: boolean;
}) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
textAlign: "center",
gap: 10,
padding: compact ? "20px 16px" : "36px 20px",
}}
>
<span
aria-hidden
style={{
width: 52,
height: 52,
borderRadius: 18,
display: "grid",
placeItems: "center",
color: "var(--seed-color-fg-neutral-muted, #8b8b8b)",
background: "var(--seed-color-bg-neutral-subtle, #eef0f2)",
}}
>
<Icon name={icon} size={26} />
</span>
<span style={{ fontSize: 15, fontWeight: 700, color: "var(--seed-color-fg-neutral)" }}>{title}</span>
{hint ? (
<span style={{ fontSize: 13, maxWidth: 280, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>{hint}</span>
) : null}
</div>
);
}

View file

@ -1,35 +0,0 @@
import { Icon, type IconName } from "./icons";
/** The rounded, tinted icon square used on every list row (transactions,
* accounts, assets, planner categories, profile menu). One source of truth for
* the chip so all rows share the exact same shape/size. */
export function IconChip({
icon,
tint,
fg,
size = 40,
}: {
icon: IconName;
tint: string;
fg: string;
size?: number;
}) {
const radius = Math.round(size * 0.32);
return (
<span
aria-hidden
style={{
width: size,
height: size,
flexShrink: 0,
borderRadius: radius,
display: "grid",
placeItems: "center",
color: fg,
background: tint,
}}
>
<Icon name={icon} size={Math.round(size * 0.5)} />
</span>
);
}

View file

@ -1,44 +0,0 @@
import type { ReactNode } from "react";
import { Icon } from "./icons";
/** A section title with an optional trailing action (e.g. a `+` add button).
* Used to head every list block so spacing + type are consistent. */
export function SectionHeader({
title,
action,
onAction,
actionLabel,
}: {
title: string;
action?: ReactNode;
onAction?: () => void;
actionLabel?: string;
}) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
<h2 style={{ margin: 0, fontSize: 17, fontWeight: 700, color: "var(--seed-color-fg-neutral)" }}>{title}</h2>
{onAction ? (
<button
type="button"
onClick={onAction}
aria-label={actionLabel}
style={{
all: "unset",
cursor: "pointer",
width: 32,
height: 32,
borderRadius: 10,
display: "grid",
placeItems: "center",
color: "var(--seed-color-fg-neutral)",
background: "var(--seed-color-bg-neutral-subtle, #eef0f2)",
}}
>
<Icon name="plus" size={18} />
</button>
) : (
action ?? null
)}
</div>
);
}

View file

@ -1,59 +1,58 @@
import type { IconName } from "./icons";
// Mongolian display name + icon + color for a backend category. Categories are // Mongolian display name + icon + color for a backend category. Categories are
// stored under English names; this is the single place that maps each to how it // stored under English names; this is the single place that maps each to how it
// reads and looks in the UI. Ported from ios/Mercury/Features/Transactions/ // reads and looks in the UI. Ported from ios/Mercury/Features/Transactions/
// CategoryStyle.swift. // CategoryStyle.swift — the web has no Seed multicolor-icon catalog, so each
// icon is an emoji + a soft color tint for the category chip.
export interface CategoryStyle { export interface CategoryStyle {
name: string; // Mongolian display name name: string; // Mongolian display name
icon: IconName; emoji: string;
tint: string; // chip background (soft) tint: string; // chip background (soft)
fg: string; // chip foreground / accent fg: string; // chip foreground / accent
} }
const s = (name: string, icon: IconName, tint: string, fg: string): CategoryStyle => ({ name, icon, tint, fg }); const s = (name: string, emoji: string, tint: string, fg: string): CategoryStyle => ({ name, emoji, tint, fg });
const TABLE: Record<string, CategoryStyle> = { const TABLE: Record<string, CategoryStyle> = {
// Food & drink // Food & drink
"food & drink": s("Хоол хүнс", "utensils", "#FDE8D7", "#B4530A"), "food & drink": s("Хоол хүнс", "🍴", "#FDE8D7", "#B4530A"),
groceries: s("Хүнсний бараа", "cart", "#E3F0D8", "#3F7A1E"), groceries: s("Хүнсний бараа", "🛒", "#E3F0D8", "#3F7A1E"),
dining: s("Хоолны газар", "utensils", "#FDE8D7", "#B4530A"), dining: s("Хоолны газар", "🍱", "#FDE8D7", "#B4530A"),
dinner: s("Оройн хоол", "utensils", "#FDE8D7", "#B4530A"), dinner: s("Оройн хоол", "🍽️", "#FDE8D7", "#B4530A"),
coffee: s("Кофе", "coffee", "#EEE3D7", "#7A5230"), coffee: s("Кофе", "", "#EEE3D7", "#7A5230"),
drink: s("Ундаа", "coffee", "#DDEAF6", "#215C9A"), drink: s("Ундаа", "🥤", "#DDEAF6", "#215C9A"),
// Transport // Transport
transport: s("Тээвэр", "car", "#DDE6F6", "#2A4B9A"), transport: s("Тээвэр", "🚗", "#DDE6F6", "#2A4B9A"),
// Bills & services // Bills & services
"bills & services": s("Төлбөр & үйлчилгээ", "card", "#E7E3F6", "#5B44B0"), "bills & services": s("Төлбөр & үйлчилгээ", "💳", "#E7E3F6", "#5B44B0"),
fees: s("Шимтгэл", "receipt", "#EDEBE7", "#6B6257"), fees: s("Шимтгэл", "🧾", "#EDEBE7", "#6B6257"),
insurance: s("Даатгал", "shield", "#DFEFEA", "#1E7A63"), insurance: s("Даатгал", "🛡️", "#DFEFEA", "#1E7A63"),
phone: s("Утас", "phone", "#E1E5EA", "#42505F"), phone: s("Утас", "📱", "#E1E5EA", "#42505F"),
subscriptions: s("Сабскрипшн", "sparkles", "#F5E7F0", "#9A2E77"), subscriptions: s("Сабскрипшн", "", "#F5E7F0", "#9A2E77"),
"үйлчилгээ": s("Үйлчилгээ", "wrench", "#EDEBE7", "#6B6257"), "үйлчилгээ": s("Үйлчилгээ", "🔧", "#EDEBE7", "#6B6257"),
// Debt & loans // Debt & loans
"debt & loans": s("Зээл & өр", "shield", "#F6E3E3", "#A83232"), "debt & loans": s("Зээл & өр", "🛡️", "#F6E3E3", "#A83232"),
debt: s("Өр", "shield", "#F6E3E3", "#A83232"), debt: s("Өр", "🛡️", "#F6E3E3", "#A83232"),
loan: s("Зээл", "card", "#E7E3F6", "#5B44B0"), loan: s("Зээл", "💳", "#E7E3F6", "#5B44B0"),
// Shopping // Shopping
shopping: s("Дэлгүүр", "bag", "#F5E7F0", "#9A2E77"), shopping: s("Дэлгүүр", "🛍️", "#F5E7F0", "#9A2E77"),
electronics: s("Цахилгаан бараа", "monitor", "#E1E5EA", "#42505F"), electronics: s("Цахилгаан бараа", "🖥️", "#E1E5EA", "#42505F"),
// Entertainment // Entertainment
entertainment: s("Зугаа цэнгэл", "gamepad", "#E7E3F6", "#5B44B0"), entertainment: s("Зугаа цэнгэл", "🎮", "#E7E3F6", "#5B44B0"),
anime: s("Аниме", "gamepad", "#E7E3F6", "#5B44B0"), anime: s("Аниме", "🎮", "#E7E3F6", "#5B44B0"),
spotify: s("Spotify", "sparkles", "#E3F0D8", "#3F7A1E"), spotify: s("Spotify", "🎵", "#E3F0D8", "#3F7A1E"),
// Health & care // Health & care
fitness: s("Фитнес", "dumbbell", "#DDEAF6", "#215C9A"), fitness: s("Фитнес", "🏋️", "#DDEAF6", "#215C9A"),
haircut: s("Үс засалт", "scissors", "#F5E7F0", "#9A2E77"), haircut: s("Үс засалт", "✂️", "#F5E7F0", "#9A2E77"),
// Money movement / income // Money movement / income
transfers: s("Шилжүүлэг", "repeat", "#E1E5EA", "#42505F"), transfers: s("Шилжүүлэг", "🔁", "#E1E5EA", "#42505F"),
salary: s("Цалин", "wallet", "#E3F0D8", "#3F7A1E"), salary: s("Цалин", "💰", "#E3F0D8", "#3F7A1E"),
// Synthetic budget line for the loan repayment. // Synthetic budget line for the loan repayment.
"зээлийн төлбөр": s("Зээлийн төлбөр", "shield", "#F6E3E3", "#A83232"), "зээлийн төлбөр": s("Зээлийн төлбөр", "🛡️", "#F6E3E3", "#A83232"),
}; };
const INCOME_FALLBACK = s("Орлого", "wallet", "#E3F0D8", "#3F7A1E"); const INCOME_FALLBACK = s("Орлого", "💰", "#E3F0D8", "#3F7A1E");
const OTHER_FALLBACK = s("Бусад", "receipt", "#EDEBE7", "#6B6257"); const OTHER_FALLBACK = s("Бусад", "🧾", "#EDEBE7", "#6B6257");
/** Look up the style for a backend category name. `income` decides the fallback /** Look up the style for a backend category name. `income` decides the fallback
* when the category is empty or unknown. Mirrors CategoryStyle.of in iOS. */ * when the category is empty or unknown. Mirrors CategoryStyle.of in iOS. */

View file

@ -1,211 +0,0 @@
import type { SVGProps } from "react";
// Clean line-icon set (stroke, currentColor) — a dependency-free stand-in for
// the Seed multicolor catalog, which has no web build. 24x24, 1.75 stroke,
// round caps/joins. Color via `color` / currentColor; size via `size`.
export type IconName =
| "utensils"
| "cart"
| "coffee"
| "car"
| "card"
| "shield"
| "phone"
| "sparkles"
| "bag"
| "monitor"
| "gamepad"
| "dumbbell"
| "scissors"
| "repeat"
| "wallet"
| "receipt"
| "wrench"
| "home"
| "list"
| "calendar-check"
| "layers"
| "user"
| "plus"
| "chevron-right"
| "chevron-left"
| "eye"
| "eye-off"
| "arrow-up-right"
| "arrow-down-left"
| "trending"
| "hand-coins"
| "bank";
const PATHS: Record<IconName, React.ReactNode> = {
utensils: (
<>
<path d="M4 3v6a2 2 0 0 0 2 2h0a2 2 0 0 0 2-2V3M6 11v10" />
<path d="M17 3c-1.5 0-3 1.5-3 4.5S15.5 12 17 12v9" />
</>
),
cart: (
<>
<circle cx="9" cy="20" r="1.4" />
<circle cx="18" cy="20" r="1.4" />
<path d="M2 3h2.2l2 12.4a1.5 1.5 0 0 0 1.5 1.2h9.1a1.5 1.5 0 0 0 1.5-1.2L20.5 7H5.2" />
</>
),
coffee: (
<>
<path d="M4 8h13v5a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5V8Z" />
<path d="M17 9h2.5a2.5 2.5 0 0 1 0 5H17" />
<path d="M8 2c-.6.8-.6 1.7 0 2.5M12 2c-.6.8-.6 1.7 0 2.5" />
</>
),
car: (
<>
<path d="M5 12l1.5-4.5A2 2 0 0 1 8.4 6h7.2a2 2 0 0 1 1.9 1.5L19 12" />
<path d="M3 12h18v4a1 1 0 0 1-1 1h-1.5M5.5 17H4a1 1 0 0 1-1-1v-4" />
<path d="M6.5 17v1.5M17.5 17v1.5" />
<circle cx="7" cy="14.5" r=".6" /><circle cx="17" cy="14.5" r=".6" />
</>
),
card: (
<>
<rect x="2.5" y="5" width="19" height="14" rx="2.5" />
<path d="M2.5 9.5h19" />
</>
),
shield: <path d="M12 3l7 2.5v5.5c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V5.5L12 3Z" />,
phone: (
<>
<rect x="6.5" y="2.5" width="11" height="19" rx="2.5" />
<path d="M11 18.5h2" />
</>
),
sparkles: (
<>
<path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3Z" />
<path d="M18 14l.8 2.2L21 17l-2.2.8L18 20l-.8-2.2L15 17l2.2-.8L18 14Z" />
</>
),
bag: (
<>
<path d="M5.5 8h13l-.9 11a2 2 0 0 1-2 1.8H8.4a2 2 0 0 1-2-1.8L5.5 8Z" />
<path d="M9 8V6.5a3 3 0 0 1 6 0V8" />
</>
),
monitor: (
<>
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M9 20h6M12 16v4" />
</>
),
gamepad: (
<>
<path d="M7 9h10a4 4 0 0 1 4 4v.5a3.5 3.5 0 0 1-6.3 2.1L14 15h-4l-.7.6A3.5 3.5 0 0 1 3 13.5V13a4 4 0 0 1 4-4Z" />
<path d="M7.5 12v2M6.5 13h2M15.5 12.5h.01M17.5 14.5h.01" />
</>
),
dumbbell: (
<>
<path d="M3 9v6M6 7v10M18 7v10M21 9v6M6 12h12" />
</>
),
scissors: (
<>
<circle cx="6" cy="6" r="2.2" /><circle cx="6" cy="18" r="2.2" />
<path d="M8 7.5 20 18M8 16.5 20 6" />
</>
),
repeat: (
<>
<path d="M3 12V9a3 3 0 0 1 3-3h11M14 3l3 3-3 3" />
<path d="M21 12v3a3 3 0 0 1-3 3H7M10 21l-3-3 3-3" />
</>
),
wallet: (
<>
<path d="M3.5 7.5A2 2 0 0 1 5.5 5.5h11a1.5 1.5 0 0 1 0 3H4" />
<rect x="3" y="7.5" width="18" height="11.5" rx="2.5" />
<circle cx="16.5" cy="13.5" r="1.1" />
</>
),
receipt: (
<>
<path d="M6 3h12v18l-2-1.3-2 1.3-2-1.3-2 1.3-2-1.3L6 21V3Z" />
<path d="M9 8h6M9 12h6" />
</>
),
wrench: <path d="M14.5 6.5a3.5 3.5 0 0 0 4.4 4.4l-8.9 8.9a2 2 0 0 1-2.8-2.8l8.9-8.9a3.5 3.5 0 0 0-1.6-1.6Z" />,
home: (
<>
<path d="M4 11 12 4l8 7" />
<path d="M6 10v9a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-9" />
</>
),
list: <path d="M4 7h16M4 12h16M4 17h16" />,
"calendar-check": (
<>
<rect x="3.5" y="5" width="17" height="16" rx="2.5" />
<path d="M3.5 9.5h17M8 3v4M16 3v4M8.5 15l2.2 2.2L15.5 12" />
</>
),
layers: (
<>
<path d="M12 3 3 8l9 5 9-5-9-5Z" />
<path d="M3 13l9 5 9-5M3 18l9 5 9-5" opacity=".5" />
</>
),
user: (
<>
<circle cx="12" cy="8" r="3.5" />
<path d="M5 20a7 7 0 0 1 14 0" />
</>
),
plus: <path d="M12 5v14M5 12h14" />,
"chevron-right": <path d="m9 5 7 7-7 7" />,
"chevron-left": <path d="m15 5-7 7 7 7" />,
eye: (
<>
<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z" />
<circle cx="12" cy="12" r="2.6" />
</>
),
"eye-off": (
<>
<path d="M4 4l16 16" />
<path d="M9.5 9.6A2.6 2.6 0 0 0 12 14.5a2.6 2.6 0 0 0 2.4-1.6M6.5 6.8C4 8.4 2.5 12 2.5 12s3.5 6.5 9.5 6.5c1.5 0 2.8-.4 4-1M16.5 15.2C19 13.6 21.5 12 21.5 12s-3-5.5-8-6.4" />
</>
),
"arrow-up-right": <path d="M7 17 17 7M9 7h8v8" />,
"arrow-down-left": <path d="M17 7 7 17M15 17H7V9" />,
trending: <path d="m3 16 5-5 4 4 8-8M15 7h6v6" />,
"hand-coins": (
<>
<circle cx="8" cy="6" r="3" />
<path d="M13 9h4.5a2 2 0 0 1 0 4H14M3 20l3-3h6a2 2 0 0 0 2-2" />
</>
),
bank: (
<>
<path d="M3 9.5 12 4l9 5.5M4.5 9.5V18M19.5 9.5V18M8 10v6M12 10v6M16 10v6M3 20h18" />
</>
),
};
export function Icon({ name, size = 22, ...rest }: { name: IconName; size?: number } & Omit<SVGProps<SVGSVGElement>, "name">) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
{...rest}
>
{PATHS[name]}
</svg>
);
}

View file

@ -16,7 +16,3 @@ export type { NameEditProps } from "./NameEdit";
export { Card } from "./Card"; export { Card } from "./Card";
export type { CardProps } from "./Card"; export type { CardProps } from "./Card";
export * from "./categoryStyle"; export * from "./categoryStyle";
export * from "./icons";
export * from "./IconChip";
export * from "./SectionHeader";
export * from "./EmptyState";

View file

@ -13,7 +13,6 @@ import {
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogAction, DialogAction,
Skeleton,
TextFieldRoot, TextFieldRoot,
TextFieldTextarea, TextFieldTextarea,
} from "@seed-design/react"; } from "@seed-design/react";
@ -101,7 +100,7 @@ function DetailRow({
*/ */
export function TransactionDetail({ id }: TransactionDetailProps) { export function TransactionDetail({ id }: TransactionDetailProps) {
const router = useRouter(); const router = useRouter();
const { data: transactions, isLoading } = useTransactions(); const { data: transactions } = useTransactions();
const { data: categories = [] } = useCategories(); const { data: categories = [] } = useCategories();
const categorize = useCategorize(); const categorize = useCategorize();
const renameTxn = useRenameTxn(); const renameTxn = useRenameTxn();
@ -133,15 +132,6 @@ export function TransactionDetail({ id }: TransactionDetailProps) {
setEditingNote(false); setEditingNote(false);
}, [id]); }, [id]);
if (isLoading && !txn) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<BackButton onClick={() => router.push("/accounting")} />
<Skeleton style={{ height: 300, width: "100%", borderRadius: "var(--seed-radius-r3)" }} />
</div>
);
}
if (!txn) { if (!txn) {
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}> <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>

View file

@ -7,7 +7,6 @@ import { useTransactions } from "@/api/hooks/reads";
import type { Txn } from "@/api/schemas"; import type { Txn } from "@/api/schemas";
import { Card, HideAmountsToggle } from "@/ds"; import { Card, HideAmountsToggle } from "@/ds";
import { categoryStyle } from "@/ds/categoryStyle"; import { categoryStyle } from "@/ds/categoryStyle";
import { Icon } from "@/ds/icons";
import { MASKED, tugrikRaw } from "@/ds/money"; import { MASKED, tugrikRaw } from "@/ds/money";
import { accountingStrings as s } from "./strings"; import { accountingStrings as s } from "./strings";
import { txnRouteId } from "./txnRoute"; import { txnRouteId } from "./txnRoute";
@ -101,11 +100,11 @@ function TxnRow({ txn, hidden }: { txn: Txn; hidden: boolean }) {
borderRadius: 13, borderRadius: 13,
display: "grid", display: "grid",
placeItems: "center", placeItems: "center",
color: isTransfer ? "var(--seed-color-fg-neutral-muted, #6b7280)" : cat.fg, fontSize: 19,
background: isTransfer ? "var(--seed-color-bg-neutral-subtle, #eef0f2)" : cat.tint, background: isTransfer ? "var(--seed-color-bg-neutral-subtle, #eef0f2)" : cat.tint,
}} }}
> >
<Icon name={isTransfer ? "repeat" : cat.icon} size={20} /> {isTransfer ? "🔁" : cat.emoji}
</span> </span>
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}> <div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
<span style={{ fontSize: 15, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> <span style={{ fontSize: 15, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>

View file

@ -2,12 +2,9 @@
import * as React from "react"; import * as React from "react";
import { useNetWorth, useTransactions } from "@/api/hooks/reads"; import { useNetWorth, useTransactions } from "@/api/hooks/reads";
import { Card, IconChip, EmptyState } from "@/ds"; import { Card } from "@/ds";
import { categoryStyle } from "@/ds/categoryStyle";
import { tugrikRaw } from "@/ds/money"; import { tugrikRaw } from "@/ds/money";
import type { Txn } from "@/api/schemas";
import { assetsStrings as s } from "./strings"; import { assetsStrings as s } from "./strings";
import { DetailHeader } from "./DetailHeader";
const mutedStyle: React.CSSProperties = { color: "var(--seed-color-fg-neutral-subtle)" }; const mutedStyle: React.CSSProperties = { color: "var(--seed-color-fg-neutral-subtle)" };
@ -29,7 +26,10 @@ export function AccountDetail({ accountId }: AccountDetailProps) {
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}> <div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<DetailHeader title={account?.bank ?? s.accountDetail.bank} /> <header style={{ display: "flex", alignItems: "center", gap: 12 }}>
<BackLink />
<h1 style={{ margin: 0, fontSize: 18, fontWeight: 700, flex: 1 }}>{account?.bank ?? s.accountDetail.bank}</h1>
</header>
<Card style={{ textAlign: "center", padding: "24px 20px" }}> <Card style={{ textAlign: "center", padding: "24px 20px" }}>
<div style={{ fontSize: 12, ...mutedStyle }}>{account?.accountNumber}</div> <div style={{ fontSize: 12, ...mutedStyle }}>{account?.accountNumber}</div>
@ -44,22 +44,37 @@ export function AccountDetail({ accountId }: AccountDetailProps) {
<DetailRow label={s.accountDetail.currency} value={account?.currency ?? "—"} /> <DetailRow label={s.accountDetail.currency} value={account?.currency ?? "—"} />
</Card> </Card>
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}> <section>
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 700 }}>{s.accountDetail.recentTransactions}</h3> <h3 style={{ margin: "0 0 10px", fontSize: 14, fontWeight: 700 }}>{s.accountDetail.recentTransactions}</h3>
{transactions.isLoading ? ( {transactions.isLoading ? (
<p style={{ fontSize: 13, ...mutedStyle }}></p> <p style={{ fontSize: 13, ...mutedStyle }}></p>
) : (transactions.data ?? []).length === 0 ? ( ) : (transactions.data ?? []).length === 0 ? (
<Card style={{ padding: 0 }}> <p style={{ fontSize: 13, ...mutedStyle }}>{s.accountDetail.noTransactions}</p>
<EmptyState icon="receipt" title={s.accountDetail.noTransactions} compact />
</Card>
) : ( ) : (
<Card style={{ padding: 0 }}> <Card style={{ padding: 0 }}>
{(transactions.data ?? []).map((txn, i) => ( {(transactions.data ?? []).map((txn, i) => {
const income = txn.direction === "income";
return (
<React.Fragment key={i}> <React.Fragment key={i}>
{i > 0 && <Divider />} {i > 0 && <Divider />}
<TxnRow txn={txn} /> <div style={{ display: "flex", justifyContent: "space-between", padding: "14px 16px" }}>
<div>
<div style={{ fontWeight: 600 }}>{txn.title || txn.category}</div>
<div style={{ fontSize: 12, ...mutedStyle }}>{txn.date.slice(0, 10)}</div>
</div>
<div
style={{
fontWeight: 700,
color: income ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-critical)",
}}
>
{income ? "+" : ""}
{tugrikRaw(txn.amount)}
</div>
</div>
</React.Fragment> </React.Fragment>
))} );
})}
</Card> </Card>
)} )}
</section> </section>
@ -67,32 +82,11 @@ export function AccountDetail({ accountId }: AccountDetailProps) {
); );
} }
function TxnRow({ txn }: { txn: Txn }) { function BackLink() {
const income = txn.direction === "income";
const cat = categoryStyle(txn.category, income);
return ( return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: "14px 16px" }}> <a href="/assets" style={{ textDecoration: "none", color: "inherit", fontWeight: 600 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}> {s.common.back}
<IconChip icon={cat.icon} tint={cat.tint} fg={cat.fg} /> </a>
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
<span style={{ fontSize: 15, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{txn.title || cat.name}
</span>
<span style={{ fontSize: 12, ...mutedStyle }}>{txn.date.slice(0, 10)}</span>
</div>
</div>
<span
style={{
fontSize: 15,
fontWeight: 700,
flexShrink: 0,
color: income ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-critical)",
}}
>
{income ? "+" : ""}
{tugrikRaw(txn.amount)}
</span>
</div>
); );
} }

View file

@ -17,7 +17,7 @@ import {
} from "@seed-design/react"; } from "@seed-design/react";
import { useNetWorth, useManualAssets, useLending } from "@/api/hooks/reads"; import { useNetWorth, useManualAssets, useLending } from "@/api/hooks/reads";
import { useManualAssetMutations, useLendingMutations } from "@/api/hooks/mutations"; import { useManualAssetMutations, useLendingMutations } from "@/api/hooks/mutations";
import { Card, HideAmountsToggle, MercuryButton, IconChip, SectionHeader, EmptyState, Icon, type IconName } from "@/ds"; import { Card, HideAmountsToggle, MercuryButton } from "@/ds";
import { tugrik, tugrikRaw } from "@/ds/money"; import { tugrik, tugrikRaw } from "@/ds/money";
import type { Account, ManualAsset, Lending } from "@/api/schemas"; import type { Account, ManualAsset, Lending } from "@/api/schemas";
import { assetsStrings as s } from "./strings"; import { assetsStrings as s } from "./strings";
@ -32,31 +32,6 @@ const rowStyle: React.CSSProperties = {
gap: 12, gap: 12,
padding: "14px 16px", padding: "14px 16px",
}; };
const titleStyle: React.CSSProperties = {
fontSize: 15,
fontWeight: 700,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
};
const subtitleStyle: React.CSSProperties = { fontSize: 12, ...mutedStyle };
const amountStyle: React.CSSProperties = { fontSize: 15, fontWeight: 700, flexShrink: 0 };
// Bank/lending/asset chip tints — soft tones matching the categoryStyle
// palette used elsewhere in Mercury (transactions, planner).
const BANK_TINT = { tint: "#DDEAF6", fg: "#215C9A" };
const LENDING_TINT = { tint: "#E3F0D8", fg: "#3F7A1E" };
function assetChip(category: string): { icon: IconName; tint: string; fg: string } {
switch (category) {
case "car":
return { icon: "car", tint: "#DDE6F6", fg: "#2A4B9A" };
case "electronics":
return { icon: "monitor", tint: "#E1E5EA", fg: "#42505F" };
default:
return { icon: "layers", tint: "#EDEBE7", fg: "#6B6257" };
}
}
export function AssetsView() { export function AssetsView() {
// Re-renders when the global hide-amounts flag flips (tugrik()/tugrikRaw() // Re-renders when the global hide-amounts flag flips (tugrik()/tugrikRaw()
@ -90,13 +65,11 @@ export function AssetsView() {
<NetWorthCard netWorth={netWorth.data} loading={netWorth.isLoading} /> <NetWorthCard netWorth={netWorth.data} loading={netWorth.isLoading} />
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}> <section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<SectionHeader title={s.accounts.title} /> <SectionTitle>{s.accounts.title}</SectionTitle>
{netWorth.isLoading ? ( {netWorth.isLoading ? (
<SkeletonRows count={2} /> <SkeletonRows count={2} />
) : accounts.length === 0 ? ( ) : accounts.length === 0 ? (
<Card style={{ padding: 0 }}> <EmptyText>{s.accounts.empty}</EmptyText>
<EmptyState icon="bank" title={s.accounts.empty} hint={s.accounts.emptyHint} />
</Card>
) : ( ) : (
<Card style={{ padding: 0 }}> <Card style={{ padding: 0 }}>
{accounts.map((account, i) => ( {accounts.map((account, i) => (
@ -106,14 +79,11 @@ export function AssetsView() {
href={`/assets/account/${account.accountId}`} href={`/assets/account/${account.accountId}`}
style={{ ...rowStyle, color: "inherit", textDecoration: "none" }} style={{ ...rowStyle, color: "inherit", textDecoration: "none" }}
> >
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}> <div>
<IconChip icon="bank" {...BANK_TINT} /> <div style={{ fontWeight: 600 }}>{account.bank}</div>
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}> <div style={{ fontSize: 12, ...mutedStyle }}>{account.accountNumber}</div>
<span style={titleStyle}>{account.bank}</span>
<span style={subtitleStyle}>{account.accountNumber}</span>
</div> </div>
</div> <div style={{ fontWeight: 700 }}>{tugrik(account.balance)}</div>
<span style={amountStyle}>{tugrik(account.balance)}</span>
</Link> </Link>
</React.Fragment> </React.Fragment>
))} ))}
@ -122,13 +92,11 @@ export function AssetsView() {
</section> </section>
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}> <section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<SectionHeader title={s.manualAssets.title} onAction={() => setAddAssetOpen(true)} actionLabel={s.manualAssets.add} /> <SectionHeaderRow title={s.manualAssets.title} onAdd={() => setAddAssetOpen(true)} addLabel={s.manualAssets.add} />
{manualAssets.isLoading ? ( {manualAssets.isLoading ? (
<SkeletonRows count={3} /> <SkeletonRows count={3} />
) : assets.length === 0 ? ( ) : assets.length === 0 ? (
<Card style={{ padding: 0 }}> <EmptyBlock title={s.manualAssets.emptyTitle} subtitle={s.manualAssets.emptySubtitle} />
<EmptyState icon="layers" title={s.manualAssets.emptyTitle} hint={s.manualAssets.emptySubtitle} />
</Card>
) : ( ) : (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}> <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{assets.map((asset) => ( {assets.map((asset) => (
@ -145,13 +113,11 @@ export function AssetsView() {
</section> </section>
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}> <section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<SectionHeader title={s.lending.title} onAction={() => setAddLoanOpen(true)} actionLabel={s.lending.add} /> <SectionHeaderRow title={s.lending.title} onAdd={() => setAddLoanOpen(true)} addLabel={s.lending.add} />
{lending.isLoading ? ( {lending.isLoading ? (
<SkeletonRows count={2} /> <SkeletonRows count={2} />
) : loans.length === 0 ? ( ) : loans.length === 0 ? (
<Card style={{ padding: 0 }}> <EmptyText>{s.lending.empty}</EmptyText>
<EmptyState icon="hand-coins" title={s.lending.empty} hint={s.lending.emptyHint} />
</Card>
) : ( ) : (
<Card style={{ padding: 0 }}> <Card style={{ padding: 0 }}>
{loans.map((loan, i) => ( {loans.map((loan, i) => (
@ -235,21 +201,21 @@ export function AssetsView() {
function NetWorthCard({ netWorth, loading }: { netWorth?: { total: string; assets: string; liabilities: string }; loading: boolean }) { function NetWorthCard({ netWorth, loading }: { netWorth?: { total: string; assets: string; liabilities: string }; loading: boolean }) {
return ( return (
<Card style={{ padding: "26px 22px", background: "var(--mercury-balance-card)" }}> <Card style={{ padding: "24px 20px", background: "var(--mercury-balance-card)" }}>
{loading ? ( {loading ? (
<Skeleton height="40px" /> <Skeleton height="40px" />
) : ( ) : (
<> <>
<div style={{ fontSize: 13, fontWeight: 600, letterSpacing: 0.2, opacity: 0.75 }}>{s.netWorth.total}</div> <div style={{ fontSize: 13, opacity: 0.75 }}>{s.netWorth.total}</div>
<div style={{ fontSize: 34, fontWeight: 800, marginTop: 8, letterSpacing: -0.5 }}>{tugrik(netWorth?.total ?? "0")}</div> <div style={{ fontSize: 32, fontWeight: 700, marginTop: 6 }}>{tugrik(netWorth?.total ?? "0")}</div>
<div style={{ display: "flex", gap: 28, marginTop: 20 }}> <div style={{ display: "flex", gap: 20, marginTop: 16 }}>
<div> <div>
<div style={{ fontSize: 12, opacity: 0.75 }}>{s.netWorth.assets}</div> <div style={{ fontSize: 12, opacity: 0.75 }}>{s.netWorth.assets}</div>
<div style={{ fontSize: 15, fontWeight: 700, marginTop: 2 }}>{tugrik(netWorth?.assets ?? "0")}</div> <div style={{ fontWeight: 600 }}>{tugrik(netWorth?.assets ?? "0")}</div>
</div> </div>
<div> <div>
<div style={{ fontSize: 12, opacity: 0.75 }}>{s.netWorth.liabilities}</div> <div style={{ fontSize: 12, opacity: 0.75 }}>{s.netWorth.liabilities}</div>
<div style={{ fontSize: 15, fontWeight: 700, marginTop: 2 }}>{tugrik(netWorth?.liabilities ?? "0")}</div> <div style={{ fontWeight: 600 }}>{tugrik(netWorth?.liabilities ?? "0")}</div>
</div> </div>
</div> </div>
</> </>
@ -260,13 +226,48 @@ function NetWorthCard({ netWorth, loading }: { netWorth?: { total: string; asset
// --- Small shared bits ------------------------------------------------------- // --- Small shared bits -------------------------------------------------------
function SectionTitle({ children }: { children: React.ReactNode }) {
return <h2 style={{ margin: 0, fontSize: 14, fontWeight: 700 }}>{children}</h2>;
}
function SectionHeaderRow({ title, onAdd, addLabel }: { title: string; onAdd: () => void; addLabel: string }) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<SectionTitle>{title}</SectionTitle>
<button
type="button"
onClick={onAdd}
aria-label={addLabel}
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 22, lineHeight: 1, padding: 4 }}
>
+
</button>
</div>
);
}
function EmptyText({ children }: { children: React.ReactNode }) {
return (
<p style={{ ...mutedStyle, fontSize: 13, padding: "12px 4px", margin: 0 }}>{children}</p>
);
}
function EmptyBlock({ title, subtitle }: { title: string; subtitle: string }) {
return (
<div style={{ textAlign: "center", padding: "40px 20px", display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ fontWeight: 600 }}>{title}</div>
<div style={{ fontSize: 13, ...mutedStyle }}>{subtitle}</div>
</div>
);
}
function SkeletonRows({ count }: { count: number }) { function SkeletonRows({ count }: { count: number }) {
return ( return (
<Card style={{ display: "flex", flexDirection: "column", gap: 12 }}> <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{Array.from({ length: count }).map((_, i) => ( {Array.from({ length: count }).map((_, i) => (
<Skeleton key={i} height="48px" style={{ borderRadius: "var(--seed-radius-r2, 8px)" }} /> <Skeleton key={i} height="64px" />
))} ))}
</Card> </div>
); );
} }
@ -291,30 +292,25 @@ function ManualAssetRow({
const positive = change >= 0; const positive = change >= 0;
const categoryLabel = s.manualAssets.categories[asset.category] ?? asset.category; const categoryLabel = s.manualAssets.categories[asset.category] ?? asset.category;
const conditionLabel = s.manualAssets.conditions[asset.condition] ?? asset.condition; const conditionLabel = s.manualAssets.conditions[asset.condition] ?? asset.condition;
const chip = assetChip(asset.category);
return ( return (
<Card style={{ padding: 0 }}> <Card style={{ padding: 0 }}>
<div style={rowStyle}> <div style={rowStyle}>
<Link <Link href={`/assets/manual/${encodeURIComponent(asset.name)}`} style={{ color: "inherit", textDecoration: "none", flex: 1 }}>
href={`/assets/manual/${encodeURIComponent(asset.name)}`} <div style={{ fontWeight: 700 }}>{asset.name}</div>
style={{ display: "flex", alignItems: "center", gap: 12, color: "inherit", textDecoration: "none", flex: 1, minWidth: 0 }} <div style={{ fontSize: 12, ...mutedStyle }}>
>
<IconChip icon={chip.icon} tint={chip.tint} fg={chip.fg} />
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
<span style={titleStyle}>{asset.name}</span>
<span style={subtitleStyle}>
{categoryLabel} · {conditionLabel} {categoryLabel} · {conditionLabel}
</span>
</div> </div>
</Link> </Link>
<div style={{ textAlign: "right", flexShrink: 0 }}> <div style={{ textAlign: "right" }}>
<div style={amountStyle}>{tugrikRaw(asset.value)}</div> <div style={{ fontWeight: 700 }}>{tugrikRaw(asset.value)}</div>
<div style={{ fontSize: 11, ...mutedStyle }}>
{s.manualAssets.acquiredPrefix}: {tugrikRaw(asset.acquiredValue)}
</div>
<div <div
style={{ style={{
fontSize: 12, fontSize: 12,
fontWeight: 600, fontWeight: 600,
marginTop: 2,
color: positive ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-critical)", color: positive ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-critical)",
}} }}
> >
@ -323,40 +319,32 @@ function ManualAssetRow({
</div> </div>
</div> </div>
</div> </div>
<div style={{ display: "flex", gap: 8, padding: "0 16px 14px" }}> <div style={{ display: "flex", gap: 8, padding: "0 16px 12px" }}>
<button <button
type="button" type="button"
onClick={onRevalue} onClick={onRevalue}
disabled={revaluing} disabled={revaluing}
style={{ style={{
flex: 1, flex: 1,
display: "flex", border: "1px solid var(--seed-color-border-neutral, #e5e5e5)",
alignItems: "center", borderRadius: 8,
justifyContent: "center", background: "none",
gap: 6, padding: "6px 10px",
border: "none",
borderRadius: 10,
background: "var(--seed-color-bg-neutral-subtle, #eef0f2)",
padding: "8px 10px",
fontSize: 12, fontSize: 12,
fontWeight: 600,
color: "var(--seed-color-fg-neutral)",
cursor: revaluing ? "default" : "pointer", cursor: revaluing ? "default" : "pointer",
}} }}
> >
<Icon name="trending" size={14} />
{revaluing ? "…" : s.manualAssets.revalue} {revaluing ? "…" : s.manualAssets.revalue}
</button> </button>
<button <button
type="button" type="button"
onClick={onDelete} onClick={onDelete}
style={{ style={{
border: "none", border: "1px solid var(--seed-color-border-neutral, #e5e5e5)",
borderRadius: 10, borderRadius: 8,
background: "var(--seed-color-bg-neutral-subtle, #eef0f2)", background: "none",
padding: "8px 14px", padding: "6px 10px",
fontSize: 12, fontSize: 12,
fontWeight: 600,
color: "var(--seed-color-fg-critical)", color: "var(--seed-color-fg-critical)",
cursor: "pointer", cursor: "pointer",
}} }}
@ -385,36 +373,21 @@ function statusLabel(loan: Lending): string {
function LoanRow({ loan, onDelete }: { loan: Lending; onDelete: () => void }) { function LoanRow({ loan, onDelete }: { loan: Lending; onDelete: () => void }) {
return ( return (
<div style={rowStyle}> <div style={rowStyle}>
<Link <Link href={`/assets/lending/${loan.id}`} style={{ color: "inherit", textDecoration: "none", flex: 1 }}>
href={`/assets/lending/${loan.id}`} <div style={{ fontWeight: 600 }}>{loan.person}</div>
style={{ display: "flex", alignItems: "center", gap: 12, color: "inherit", textDecoration: "none", flex: 1, minWidth: 0 }} <div style={{ fontSize: 12, color: loan.overdue ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-neutral-subtle)" }}>
>
<IconChip icon="hand-coins" {...LENDING_TINT} />
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
<span style={titleStyle}>{loan.person}</span>
<span style={{ fontSize: 12, color: loan.overdue ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-neutral-subtle)" }}>
{statusLabel(loan)} {statusLabel(loan)}
</span>
</div> </div>
</Link> </Link>
<div style={{ textAlign: "right", flexShrink: 0 }}> <div style={{ textAlign: "right" }}>
<div style={amountStyle}>{tugrikRaw(loan.remaining)}</div> <div style={{ fontWeight: 700 }}>{tugrikRaw(loan.remaining)}</div>
<div style={subtitleStyle}>/ {tugrikRaw(loan.principal)}</div> <div style={{ fontSize: 12, ...mutedStyle }}>/ {tugrikRaw(loan.principal)}</div>
</div> </div>
<button <button
type="button" type="button"
onClick={onDelete} onClick={onDelete}
aria-label={s.lending.delete} aria-label={s.lending.delete}
style={{ style={{ background: "none", border: "none", color: "var(--seed-color-fg-neutral-subtle)", cursor: "pointer", fontSize: 16 }}
background: "none",
border: "none",
color: "var(--seed-color-fg-neutral-subtle)",
cursor: "pointer",
fontSize: 18,
lineHeight: 1,
padding: 4,
flexShrink: 0,
}}
> >
× ×
</button> </button>

View file

@ -1,47 +0,0 @@
"use client";
import Link from "next/link";
import { Icon } from "@/ds";
import { assetsStrings as s } from "./strings";
/**
* Shared header for the account / manual-asset / lending detail pages: a
* round back button + title, matching the row/typography language used
* across the rest of the Assets feature.
*/
export function DetailHeader({ title }: { title: string }) {
return (
<header style={{ display: "flex", alignItems: "center", gap: 12 }}>
<Link
href="/assets"
aria-label={s.common.back}
style={{
display: "grid",
placeItems: "center",
width: 36,
height: 36,
flexShrink: 0,
borderRadius: 12,
background: "var(--seed-color-bg-neutral-subtle, #eef0f2)",
color: "var(--seed-color-fg-neutral)",
}}
>
<Icon name="chevron-left" size={20} />
</Link>
<h1
style={{
margin: 0,
fontSize: 18,
fontWeight: 700,
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{title}
</h1>
</header>
);
}

View file

@ -18,11 +18,10 @@ import {
import { useLending } from "@/api/hooks/reads"; import { useLending } from "@/api/hooks/reads";
import { useLendingMutations } from "@/api/hooks/mutations"; import { useLendingMutations } from "@/api/hooks/mutations";
import type { Lending } from "@/api/schemas"; import type { Lending } from "@/api/schemas";
import { Card, MercuryButton, IconChip, EmptyState } from "@/ds"; import { Card, MercuryButton } from "@/ds";
import { tugrikRaw } from "@/ds/money"; import { tugrikRaw } from "@/ds/money";
import { assetsStrings as s } from "./strings"; import { assetsStrings as s } from "./strings";
import { ConfirmDialog } from "./ConfirmDialog"; import { ConfirmDialog } from "./ConfirmDialog";
import { DetailHeader } from "./DetailHeader";
type LendingRepayment = Lending["repayments"][number]; type LendingRepayment = Lending["repayments"][number];
@ -71,7 +70,7 @@ export function LendingDetail({ id }: LendingDetailProps) {
if (!loan) { if (!loan) {
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}> <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<DetailHeader title={s.lending.title} /> <BackLink />
<p style={mutedStyle}>{s.lending.empty}</p> <p style={mutedStyle}>{s.lending.empty}</p>
</div> </div>
); );
@ -85,12 +84,12 @@ export function LendingDetail({ id }: LendingDetailProps) {
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}> <div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<DetailHeader title={loan.person} /> <header style={{ display: "flex", alignItems: "center", gap: 12 }}>
<BackLink />
<h1 style={{ margin: 0, fontSize: 18, fontWeight: 700, flex: 1 }}>{loan.person}</h1>
</header>
<Card style={{ textAlign: "center", padding: "24px 20px" }}> <Card style={{ textAlign: "center", padding: "24px 20px" }}>
<div style={{ display: "flex", justifyContent: "center", marginBottom: 12 }}>
<IconChip icon="hand-coins" tint="#E3F0D8" fg="#3F7A1E" size={48} />
</div>
<div style={{ fontSize: 32, fontWeight: 700 }}>{tugrikRaw(loan.remaining)}</div> <div style={{ fontSize: 32, fontWeight: 700 }}>{tugrikRaw(loan.remaining)}</div>
<div style={{ marginTop: 6, ...mutedStyle }}> <div style={{ marginTop: 6, ...mutedStyle }}>
{s.lending.total} <strong>{tugrikRaw(loan.principal)}</strong> {s.lending.total} <strong>{tugrikRaw(loan.principal)}</strong>
@ -117,28 +116,25 @@ export function LendingDetail({ id }: LendingDetailProps) {
<strong style={{ color: "var(--seed-color-fg-positive)" }}>{tugrikRaw(loan.repaid)}</strong> <strong style={{ color: "var(--seed-color-fg-positive)" }}>{tugrikRaw(loan.repaid)}</strong>
</div> </div>
{loan.repayments.length === 0 ? ( {loan.repayments.length === 0 ? (
<EmptyState icon="receipt" title={s.lending.repayments.empty} compact /> <p style={{ fontSize: 12, ...mutedStyle, margin: 0 }}>{s.lending.repayments.empty}</p>
) : ( ) : (
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}> <div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
{loan.repayments.map((r, i) => ( {loan.repayments.map((r, i) => (
<React.Fragment key={r.id}> <React.Fragment key={r.id}>
{i > 0 && <div style={{ height: 1, background: "var(--seed-color-border-neutral, #e5e5e5)" }} />} {i > 0 && <div style={{ height: 1, background: "var(--seed-color-border-neutral, #e5e5e5)" }} />}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: "10px 0" }}> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 0" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}> <div>
<IconChip icon="receipt" tint="#E1E5EA" fg="#42505F" size={36} /> <div style={{ fontWeight: 600 }}>{tugrikRaw(r.amount)}</div>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 700 }}>{tugrikRaw(r.amount)}</div>
<div style={{ fontSize: 12, ...mutedStyle }}> <div style={{ fontSize: 12, ...mutedStyle }}>
{r.paidOn} {r.paidOn}
{r.note ? ` · ${r.note}` : ""} {r.note ? ` · ${r.note}` : ""}
</div> </div>
</div> </div>
</div>
<button <button
type="button" type="button"
onClick={() => setDeletingRepayment(r)} onClick={() => setDeletingRepayment(r)}
aria-label={s.lending.repayments.deleteTitle} aria-label={s.lending.repayments.deleteTitle}
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 18, flexShrink: 0, color: "var(--seed-color-fg-neutral-subtle)" }} style={{ background: "none", border: "none", cursor: "pointer", fontSize: 16, color: "var(--seed-color-fg-neutral-subtle)" }}
> >
× ×
</button> </button>
@ -194,6 +190,14 @@ export function LendingDetail({ id }: LendingDetailProps) {
); );
} }
function BackLink() {
return (
<a href="/assets" style={{ textDecoration: "none", color: "inherit", fontWeight: 600 }}>
{s.common.back}
</a>
);
}
function AddRepaymentSheet({ function AddRepaymentSheet({
onClose, onClose,
onSave, onSave,

View file

@ -9,23 +9,11 @@ import { apiGet } from "@/api/client";
import { useManualAssets } from "@/api/hooks/reads"; import { useManualAssets } from "@/api/hooks/reads";
import { useManualAssetMutations } from "@/api/hooks/mutations"; import { useManualAssetMutations } from "@/api/hooks/mutations";
import { AssetPointSchema, AssetListingSchema, type ManualAsset, type RevalueResult } from "@/api/schemas"; import { AssetPointSchema, AssetListingSchema, type ManualAsset, type RevalueResult } from "@/api/schemas";
import { Card, MercuryButton, IconChip, type IconName } from "@/ds"; import { Card, MercuryButton } from "@/ds";
import { tugrikRaw, tugrikShortRaw } from "@/ds/money"; import { tugrikRaw, tugrikShortRaw } from "@/ds/money";
import { assetsStrings as s } from "./strings"; import { assetsStrings as s } from "./strings";
import { ValueHistoryChart } from "./ValueHistoryChart"; import { ValueHistoryChart } from "./ValueHistoryChart";
import { ConfirmDialog } from "./ConfirmDialog"; import { ConfirmDialog } from "./ConfirmDialog";
import { DetailHeader } from "./DetailHeader";
function assetChip(category: string): { icon: IconName; tint: string; fg: string } {
switch (category) {
case "car":
return { icon: "car", tint: "#DDE6F6", fg: "#2A4B9A" };
case "electronics":
return { icon: "monitor", tint: "#E1E5EA", fg: "#42505F" };
default:
return { icon: "layers", tint: "#EDEBE7", fg: "#6B6257" };
}
}
// --- Local reads (no hook exists yet for asset history/listings; these mirror // --- Local reads (no hook exists yet for asset history/listings; these mirror
// the shape of reads.ts's other hooks and hit the same endpoints iOS uses: // the shape of reads.ts's other hooks and hit the same endpoints iOS uses:
@ -95,7 +83,7 @@ export function ManualAssetDetail({ name }: ManualAssetDetailProps) {
if (!asset) { if (!asset) {
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}> <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<DetailHeader title={name} /> <BackLink />
<p style={mutedStyle}>{name}</p> <p style={mutedStyle}>{name}</p>
</div> </div>
); );
@ -121,16 +109,14 @@ export function ManualAssetDetail({ name }: ManualAssetDetailProps) {
const categoryLabel = s.manualAssets.categories[asset.category] ?? asset.category; const categoryLabel = s.manualAssets.categories[asset.category] ?? asset.category;
const conditionLabel = s.manualAssets.conditions[asset.condition] ?? asset.condition; const conditionLabel = s.manualAssets.conditions[asset.condition] ?? asset.condition;
const chip = assetChip(asset.category);
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}> <div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<DetailHeader title={asset.name} /> <header style={{ display: "flex", alignItems: "center", gap: 12 }}>
<BackLink />
<h1 style={{ margin: 0, fontSize: 18, fontWeight: 700, flex: 1 }}>{asset.name}</h1>
</header>
<Card style={{ textAlign: "center", padding: "24px 20px" }}> <Card style={{ textAlign: "center", padding: "24px 20px" }}>
<div style={{ display: "flex", justifyContent: "center", marginBottom: 12 }}>
<IconChip icon={chip.icon} tint={chip.tint} fg={chip.fg} size={48} />
</div>
<div style={{ fontSize: 12, ...mutedStyle }}>{categoryLabel} · {conditionLabel}</div> <div style={{ fontSize: 12, ...mutedStyle }}>{categoryLabel} · {conditionLabel}</div>
<div style={{ fontSize: 32, fontWeight: 700, marginTop: 8 }}>{tugrikRaw(value)}</div> <div style={{ fontSize: 32, fontWeight: 700, marginTop: 8 }}>{tugrikRaw(value)}</div>
<div <div
@ -254,6 +240,14 @@ export function ManualAssetDetail({ name }: ManualAssetDetailProps) {
); );
} }
function BackLink() {
return (
<a href="/assets" style={{ textDecoration: "none", color: "inherit", fontWeight: 600 }}>
{s.common.back}
</a>
);
}
function DetailRow({ label, value, color }: { label: string; value: string; color?: string }) { function DetailRow({ label, value, color }: { label: string; value: string; color?: string }) {
return ( return (
<div style={{ display: "flex", justifyContent: "space-between", padding: "14px 0" }}> <div style={{ display: "flex", justifyContent: "space-between", padding: "14px 0" }}>

View file

@ -14,7 +14,6 @@ export const assetsStrings = {
accounts: { accounts: {
title: "Миний дансууд", title: "Миний дансууд",
empty: "Холбосон данс алга", empty: "Холбосон данс алга",
emptyHint: "Банкны дансаа холбомогц энд харагдана",
}, },
manualAssets: { manualAssets: {
title: "Хөрөнгийн жагсаалт", title: "Хөрөнгийн жагсаалт",
@ -66,7 +65,6 @@ export const assetsStrings = {
lending: { lending: {
title: "Найзуудад өгсөн зээл", title: "Найзуудад өгсөн зээл",
empty: "Зээл бүртгэгдээгүй байна", empty: "Зээл бүртгэгдээгүй байна",
emptyHint: "Найздаа зээл өгсөн бол + дарж бүртгээрэй",
add: "Шинэ зээл", add: "Шинэ зээл",
total: "нийт", total: "нийт",
statusPaid: "Төлсөн", statusPaid: "Төлсөн",

View file

@ -137,8 +137,8 @@ export function AuthForm({
if (step === "twoFactor") { if (step === "twoFactor") {
return ( return (
<form onSubmit={handleCodeSubmit} className="flex flex-col gap-5"> <form onSubmit={handleCodeSubmit} className="flex flex-col gap-3">
<p className="text-center text-sm" style={{ color: "var(--mercury-subtle, #6b7280)", margin: 0 }}> <p className="text-center text-sm" style={{ color: "var(--mercury-subtle, #6b7280)" }}>
{authStrings.twoFactor.subtitle} {authStrings.twoFactor.subtitle}
</p> </p>
@ -162,14 +162,13 @@ export function AuthForm({
} }
return ( return (
<form onSubmit={handleFormSubmit} className="flex flex-col gap-5"> <form onSubmit={handleFormSubmit} className="flex flex-col gap-3">
{mode === "forgot" && ( {mode === "forgot" && (
<p className="text-center text-sm" style={{ color: "var(--mercury-subtle, #6b7280)", margin: 0 }}> <p className="text-center text-sm" style={{ color: "var(--mercury-subtle, #6b7280)" }}>
{authStrings.forgot.subtitle} {authStrings.forgot.subtitle}
</p> </p>
)} )}
<div className="flex flex-col gap-3">
<TextFieldRoot value={email} onValueChange={setEmail} name="email"> <TextFieldRoot value={email} onValueChange={setEmail} name="email">
<TextFieldInput <TextFieldInput
type="email" type="email"
@ -210,21 +209,19 @@ export function AuthForm({
type="button" type="button"
onClick={onNavigateForgot} onClick={onNavigateForgot}
className="self-end text-sm" className="self-end text-sm"
style={{ color: "var(--mercury-subtle, #6b7280)", background: "none", border: "none", padding: 0 }} style={{ color: "var(--mercury-subtle, #6b7280)", background: "none", border: "none" }}
> >
{authStrings.login.forgot} {authStrings.login.forgot}
</button> </button>
)} )}
</div>
<FormError message={errorMessage} /> <FormError message={errorMessage} />
{infoMessage && ( {infoMessage && (
<p className="text-sm" style={{ color: "var(--mercury-subtle, #6b7280)", margin: 0 }}> <p className="text-sm" style={{ color: "var(--mercury-subtle, #6b7280)" }}>
{infoMessage} {infoMessage}
</p> </p>
)} )}
<div className="flex flex-col gap-3" style={{ marginTop: 4 }}>
<MercuryButton type="submit" variant="primary" loading={isSubmitting}> <MercuryButton type="submit" variant="primary" loading={isSubmitting}>
{mode === "login" {mode === "login"
? authStrings.login.submit ? authStrings.login.submit
@ -243,7 +240,6 @@ export function AuthForm({
{authStrings.register.loginLink} {authStrings.register.loginLink}
</MercuryButton> </MercuryButton>
)} )}
</div>
</form> </form>
); );
} }
@ -251,7 +247,7 @@ export function AuthForm({
function FormError({ message }: { message?: string }) { function FormError({ message }: { message?: string }) {
if (!message) return null; if (!message) return null;
return ( return (
<p role="alert" className="text-sm" style={{ color: "var(--seed-color-fg-critical)", margin: 0 }}> <p role="alert" className="text-sm" style={{ color: "var(--seed-color-fg-critical)" }}>
{message} {message}
</p> </p>
); );

View file

@ -1,71 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { Icon } from "@/ds";
import { authStrings } from "./strings";
/**
* Shared header for the three auth screens (login / register / forgot): a
* back chevron pinned top-left, then a MERCURY wordmark lockup a tinted
* monogram badge stacked above the bold, letter-spaced wordmark centered
* below it. Purely presentational; navigation still goes through
* `router.back()`, unchanged from the markup this replaces in each page.
*/
export function AuthHeader() {
const router = useRouter();
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center" }}>
<button
type="button"
onClick={() => router.back()}
aria-label="Буцах"
style={{
alignSelf: "flex-start",
marginTop: 24,
display: "grid",
placeItems: "center",
width: 36,
height: 36,
borderRadius: 12,
background: "none",
border: "none",
cursor: "pointer",
color: "var(--seed-color-fg-neutral)",
}}
>
<Icon name="chevron-left" size={22} />
</button>
<div style={{ marginTop: 48, display: "flex", flexDirection: "column", alignItems: "center", gap: 14 }}>
<span
aria-hidden
style={{
width: 52,
height: 52,
borderRadius: 16,
display: "grid",
placeItems: "center",
fontSize: 22,
fontWeight: 800,
color: "var(--mercury-on-brand)",
background: "var(--mercury-brand-yellow)",
}}
>
M
</span>
<h1
style={{
textAlign: "center",
fontWeight: 800,
letterSpacing: 3,
fontSize: 24,
margin: 0,
}}
>
{authStrings.wordmark}
</h1>
</div>
</div>
);
}

View file

@ -3,10 +3,10 @@
import * as React from "react"; import * as React from "react";
import Link from "next/link"; import Link from "next/link";
import { Skeleton, ProgressCircleRoot, ProgressCircleTrack, ProgressCircleRange } from "@seed-design/react"; import { Skeleton, ProgressCircleRoot, ProgressCircleTrack, ProgressCircleRange } from "@seed-design/react";
import { Card, AmountToggle, HideAmountsToggle, IconChip, SectionHeader, EmptyState, categoryStyle } from "../../ds"; import { Card, AmountToggle, HideAmountsToggle } from "../../ds";
import { tugrikShortRaw } from "../../ds/money"; import { tugrikShortRaw } from "../../ds/money";
import { useNetWorth, useAnalyzeMonth, useAnalyzeToday, useBudget } from "../../api/hooks/reads"; import { useNetWorth, useAnalyzeMonth, useAnalyzeToday, useBudget } from "../../api/hooks/reads";
import { buildHome, type HomeData, type LedgerRow as LedgerRowData } from "./buildHome"; import { buildHome, type HomeData } from "./buildHome";
import { sample } from "./sample"; import { sample } from "./sample";
import { homeStrings as s } from "./strings"; import { homeStrings as s } from "./strings";
@ -42,56 +42,6 @@ function TugrikCircle({ bg, fg }: { bg: string; fg: string }) {
); );
} }
/** One Орлого/Зарлага row: an IconChip (income the wallet/green "income"
* style; expense the payee's category style, falling back to a neutral
* receipt when unknown) + the payee name + a trailing colored amount.
* Mirrors TransactionList's TxnRow pattern. */
function LedgerEntryRow({ row, income }: { row: LedgerRowData; income: boolean }) {
const cat = categoryStyle(income ? undefined : row.date, income);
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: "8px 0" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
<IconChip icon={cat.icon} tint={cat.tint} fg={cat.fg} />
<span
style={{
fontSize: 15,
fontWeight: 700,
color: "var(--seed-color-fg-neutral)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{row.date}
</span>
</div>
<span
style={{
fontSize: 15,
fontWeight: 700,
flexShrink: 0,
color: income ? "var(--mercury-success, #1e9e6b)" : "var(--mercury-critical, #e5484d)",
}}
>
{income ? "+" : ""}
{row.title}
</span>
</div>
);
}
/** Skeleton placeholder for a ledger row, shaped like `LedgerEntryRow` so the
* layout doesn't jump once real data (or the sample fallback) arrives
* avoids flashing the sample payee/amount as if it were a real loaded row. */
function LedgerRowSkeleton() {
return (
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "8px 0" }}>
<Skeleton height="40px" width="40px" style={{ borderRadius: 13, flexShrink: 0 }} />
<Skeleton height="1em" width="55%" />
</div>
);
}
/** The Нүүр (home) dashboard: header, safe-to-spend hero, daily-limit + /** The Нүүр (home) dashboard: header, safe-to-spend hero, daily-limit +
* top budgets, this-month spend, and recent income/expense. Figures come * top budgets, this-month spend, and recent income/expense. Figures come
* from `buildHome` (real where the backend has them, `sample` otherwise). * from `buildHome` (real where the backend has them, `sample` otherwise).
@ -116,14 +66,6 @@ export function DashboardView() {
const fraction = budgetDenominator > 0 ? Math.min(1, data.monthlyExpense / budgetDenominator) : 0; const fraction = budgetDenominator > 0 ? Math.min(1, data.monthlyExpense / budgetDenominator) : 0;
const dailyFraction = data.dailyLimitTotal > 0 ? Math.min(1, data.dailyLimitUsed / data.dailyLimitTotal) : 0; const dailyFraction = data.dailyLimitTotal > 0 ? Math.min(1, data.dailyLimitUsed / data.dailyLimitTotal) : 0;
// Genuinely sparse month: real backend data loaded (`built` succeeded, so
// this isn't the full pre-connection sample), but no discretionary spend
// and no real income/expense payees this month — show a designed empty
// state instead of the sample ledger placeholders standing in as if real.
const noRealPayees =
(monthQ.data?.incomePayees?.length ?? 0) === 0 && (monthQ.data?.expensePayees?.length ?? 0) === 0;
const sparseMonth = built !== null && data.monthlyExpense === 0 && noRealPayees;
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}> <div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
<header style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}> <header style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
@ -142,21 +84,21 @@ export function DashboardView() {
color: "var(--mercury-on-brand)", color: "var(--mercury-on-brand)",
background: "var(--mercury-balance-card)", background: "var(--mercury-balance-card)",
borderRadius: "var(--seed-radius-r5, 20px)", borderRadius: "var(--seed-radius-r5, 20px)",
padding: "18px 20px", padding: "14px 20px",
}} }}
> >
<div style={{ display: "flex", alignItems: "center", gap: 14 }}> <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<TugrikCircle bg="var(--mercury-balance-circle)" fg="var(--mercury-on-brand)" /> <TugrikCircle bg="var(--mercury-balance-circle)" fg="var(--mercury-on-brand)" />
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}> <div style={{ display: "flex", flexDirection: "column" }}>
<span style={{ fontSize: 12, opacity: 0.85 }}>{data.overspent ? s.overspent : s.safeToSpend}</span> <span style={{ fontSize: 12 }}>{data.overspent ? s.overspent : s.safeToSpend}</span>
<span style={{ fontSize: 27, fontWeight: 700, lineHeight: 1.15 }}> <span style={{ fontSize: 26, fontWeight: 700 }}>
<Amount value={data.safeToSpend} loading={loading} width="140px" /> <Amount value={data.safeToSpend} loading={loading} width="140px" />
</span> </span>
</div> </div>
</div> </div>
<div <div
style={{ style={{
marginTop: 14, marginTop: 12,
height: 6, height: 6,
borderRadius: 999, borderRadius: 999,
background: "rgba(0,0,0,0.13)", background: "rgba(0,0,0,0.13)",
@ -172,11 +114,11 @@ export function DashboardView() {
}} }}
/> />
</div> </div>
<div style={{ marginTop: 10, display: "flex", justifyContent: "space-between", alignItems: "baseline", fontSize: 12 }}> <div style={{ marginTop: 8, display: "flex", justifyContent: "space-between", fontSize: 12 }}>
<span style={{ opacity: 0.85 }}>{s.spent}</span> <span>{s.spent}</span>
<span style={{ fontWeight: 700, fontSize: 13 }}> <span style={{ fontWeight: 700, fontSize: 13 }}>
<Amount value={data.monthlyExpense} loading={loading} /> <Amount value={data.monthlyExpense} loading={loading} />
<span style={{ fontWeight: 400, fontSize: 11, opacity: 0.85 }}> / {tugrikShortRaw(budgetDenominator)}</span> <span style={{ fontWeight: 400, fontSize: 11 }}> / {tugrikShortRaw(budgetDenominator)}</span>
</span> </span>
</div> </div>
</Link> </Link>
@ -190,30 +132,30 @@ export function DashboardView() {
color: "#fff", color: "#fff",
background: "var(--mercury-limit-card)", background: "var(--mercury-limit-card)",
borderRadius: "var(--seed-radius-r5, 20px)", borderRadius: "var(--seed-radius-r5, 20px)",
padding: "18px 20px", padding: "14px 20px",
}} }}
> >
<div style={{ display: "flex", alignItems: "center", gap: 14 }}> <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<ProgressCircleRoot value={dailyFraction * 100} size="40" tone="staticWhite"> <ProgressCircleRoot value={dailyFraction * 100} size="40" tone="staticWhite">
<ProgressCircleTrack /> <ProgressCircleTrack />
<ProgressCircleRange /> <ProgressCircleRange />
</ProgressCircleRoot> </ProgressCircleRoot>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}> <div style={{ display: "flex", flexDirection: "column" }}>
<span style={{ fontSize: 12, opacity: 0.85 }}>{s.dailyLimit}</span> <span style={{ fontSize: 12 }}>{s.dailyLimit}</span>
<span style={{ fontSize: 22, fontWeight: 700, lineHeight: 1.15 }}> <span style={{ fontSize: 22, fontWeight: 700 }}>
<Amount value={data.dailyLimitUsed} loading={loading} width="60px" /> <Amount value={data.dailyLimitUsed} loading={loading} width="60px" />
{" / "} {" / "}
<Amount value={data.dailyLimitTotal} loading={loading} width="60px" /> <Amount value={data.dailyLimitTotal} loading={loading} width="60px" />
</span> </span>
</div> </div>
</div> </div>
<div style={{ marginTop: 6, fontSize: 11, opacity: 0.85 }}> <div style={{ marginTop: 4, fontSize: 11, opacity: 0.85 }}>
{s.todaySpent}: <Amount value={data.todayExpense} loading={loading} width="50px" /> {s.todaySpent}: <Amount value={data.todayExpense} loading={loading} width="50px" />
</div> </div>
<hr style={{ margin: "14px 0", border: 0, borderTop: "1px solid rgba(0,0,0,0.13)" }} /> <hr style={{ margin: "12px 0", border: 0, borderTop: "1px solid rgba(0,0,0,0.13)" }} />
{data.budgets.map((b) => ( {data.budgets.map((b) => (
<div key={b.name} style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", padding: "6px 0" }}> <div key={b.name} style={{ display: "flex", justifyContent: "space-between", padding: "4px 0" }}>
<span style={{ fontSize: 12, opacity: 0.85 }}>{b.name}</span> <span style={{ fontSize: 12 }}>{b.name}</span>
<span style={{ fontSize: 14, fontWeight: 700 }}> <span style={{ fontSize: 14, fontWeight: 700 }}>
<span style={{ color: "var(--mercury-warning-chip)" }}> <span style={{ color: "var(--mercury-warning-chip)" }}>
<Amount value={b.spent} loading={loading} width="50px" /> <Amount value={b.spent} loading={loading} width="50px" />
@ -258,37 +200,24 @@ export function DashboardView() {
{/* Recent income / expense ledgers. */} {/* Recent income / expense ledgers. */}
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}> <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{loading ? (
<>
<Card style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<SectionHeader title={s.income} />
<LedgerRowSkeleton />
</Card>
<Card style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<SectionHeader title={s.expense} />
<LedgerRowSkeleton />
</Card>
</>
) : sparseMonth ? (
<Card> <Card>
<EmptyState icon="receipt" title={s.noSpendTitle} hint={s.noSpendHint} compact /> <span style={{ fontWeight: 700, fontSize: 14, color: "var(--seed-color-fg-placeholder)" }}>{s.income}</span>
</Card>
) : (
<>
<Card style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<SectionHeader title={s.income} />
{data.income.map((row, i) => ( {data.income.map((row, i) => (
<LedgerEntryRow key={i} row={row} income /> <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>
<Card style={{ display: "flex", flexDirection: "column", gap: 8 }}> <Card>
<SectionHeader title={s.expense} /> <span style={{ fontWeight: 700, fontSize: 14, color: "var(--seed-color-fg-placeholder)" }}>{s.expense}</span>
{data.expense.map((row, i) => ( {data.expense.map((row, i) => (
<LedgerEntryRow key={i} row={row} income={false} /> <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>
</>
)}
</div> </div>
</div> </div>
</div> </div>

View file

@ -80,7 +80,7 @@ describe("buildHome", () => {
}, },
})!; })!;
expect(h.budgets).toHaveLength(3); expect(h.budgets).toHaveLength(3);
expect(h.budgets.map((b) => b.name)).toEqual(["Хоол", "Кофе", "Тээвэр"]); expect(h.budgets.map((b) => b.name)).toEqual(["Хоол", "Coffee", "Тээвэр"]);
expect(h.budgets.every((b) => b.range.includes("₮"))).toBe(true); expect(h.budgets.every((b) => b.range.includes("₮"))).toBe(true);
}); });

View file

@ -18,8 +18,8 @@ export const sample: HomeData = {
], ],
monthlyExpense, monthlyExpense,
availableBudget, availableBudget,
income: [{ title: "1,345,634₮", date: "Цалин" }], income: [{ title: "1,345,634₮", date: "04.01" }],
expense: [{ title: "52,000₮", date: "Худалдан авалт" }], expense: [{ title: "худалдан авалт", date: "04.01" }],
safeToSpend: Math.max(0, availableBudget - monthlyExpense), safeToSpend: Math.max(0, availableBudget - monthlyExpense),
overspent: monthlyExpense > availableBudget, overspent: monthlyExpense > availableBudget,
}; };

View file

@ -13,6 +13,4 @@ export const homeStrings = {
income: "Орлого", income: "Орлого",
expense: "Зарлага", expense: "Зарлага",
todaySpent: "Өнөөдөр зарцуулсан", todaySpent: "Өнөөдөр зарцуулсан",
noSpendTitle: "Энэ сар хараахан зарлага алга",
noSpendHint: "Гүйлгээ хийгдмэгц энд харагдана.",
} as const; } as const;

View file

@ -21,9 +21,8 @@ import {
ContentDialogFooter, ContentDialogFooter,
Skeleton, Skeleton,
} from "@seed-design/react"; } from "@seed-design/react";
import { Card, MercuryButton, HideAmountsToggle, NameEdit, IconChip, SectionHeader, EmptyState } from "@/ds"; import { Card, MercuryButton, HideAmountsToggle, NameEdit } from "@/ds";
import { categoryStyle } from "@/ds/categoryStyle"; import { categoryStyle } from "@/ds/categoryStyle";
import { Icon } from "@/ds/icons";
import { tugrik, tugrikShort } from "@/ds/money"; import { tugrik, tugrikShort } from "@/ds/money";
import { useBudget, useNetWorth } from "@/api/hooks/reads"; import { useBudget, useNetWorth } from "@/api/hooks/reads";
import { usePutBudget, useSavingsGoalMutations } from "@/api/hooks/mutations"; import { usePutBudget, useSavingsGoalMutations } from "@/api/hooks/mutations";
@ -35,12 +34,6 @@ type CategoryLimit = Budget["categories"][number];
type HorizonReport = Budget["report"]["day"]; type HorizonReport = Budget["report"]["day"];
type LimitRow = HorizonReport["rows"][number]; type LimitRow = HorizonReport["rows"][number];
// Savings goals aren't a transaction category, so they get their own fixed
// chip color rather than borrowing `categoryStyle` (which maps backend
// category names → Mongolian display + color).
const SAVINGS_TINT = "#DDEAF6";
const SAVINGS_FG = "#215C9A";
function dec(v: string | undefined | null): number { function dec(v: string | undefined | null): number {
return parseFloat(v ?? "0") || 0; return parseFloat(v ?? "0") || 0;
} }
@ -49,15 +42,6 @@ function onlyDigits(v: string): string {
return v.replace(/[^0-9]/g, ""); return v.replace(/[^0-9]/g, "");
} }
/** Splits a "Title hint" string (the shape every empty-state copy in
* strings.ts uses) into its two halves for `EmptyState`, without inventing
* new copy. Falls back to the whole string as the title if there's no dash. */
function splitEmptyCopy(text: string): { title: string; hint?: string } {
const idx = text.indexOf(" — ");
if (idx === -1) return { title: text };
return { title: text.slice(0, idx), hint: text.slice(idx + 3) };
}
const HORIZON_LABEL: Record<Horizon, string> = { const HORIZON_LABEL: Record<Horizon, string> = {
day: s.horizon.day, day: s.horizon.day,
week: s.horizon.week, week: s.horizon.week,
@ -260,18 +244,10 @@ function PlannedIncomeCard({
className="flex w-full flex-col items-start gap-3 text-left" className="flex w-full flex-col items-start gap-3 text-left"
style={{ background: "none", border: "none", padding: 0, cursor: "pointer" }} style={{ background: "none", border: "none", padding: 0, cursor: "pointer" }}
> >
<div className="flex w-full items-center justify-between"> <span style={{ fontWeight: 700, fontSize: 14 }}>{s.plannedIncome.label}</span>
<span style={{ fontWeight: 700, fontSize: 13, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}> <span style={{ fontWeight: 700, fontSize: 26 }}>+{tugrikShort(plannedIncome)}</span>
{s.plannedIncome.label}
</span>
<Icon name="chevron-right" size={14} style={{ color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }} />
</div>
<span style={{ fontWeight: 700, fontSize: 28 }}>+{tugrikShort(plannedIncome)}</span>
{showBreakdown && ( {showBreakdown && (
<div <div className="flex w-full flex-col gap-1">
className="flex w-full flex-col gap-2"
style={{ borderTop: "1px solid var(--seed-color-border-default, #eee)", paddingTop: 10 }}
>
{loanObligations > 0 && ( {loanObligations > 0 && (
<BreakdownRow label={s.plannedIncome.loanObligations} value={`${tugrikShort(loanObligations)}`} /> <BreakdownRow label={s.plannedIncome.loanObligations} value={`${tugrikShort(loanObligations)}`} />
)} )}
@ -316,7 +292,6 @@ function OverallCard({
const [editing, setEditing] = React.useState(false); const [editing, setEditing] = React.useState(false);
const [draft, setDraft] = React.useState(""); const [draft, setDraft] = React.useState("");
const percent = limit > 0 ? Math.min(100, (spent / limit) * 100) : 0; const percent = limit > 0 ? Math.min(100, (spent / limit) * 100) : 0;
const limitSet = limit > 0;
return ( return (
<div <div
@ -359,23 +334,19 @@ function OverallCard({
setEditing(true); setEditing(true);
}} }}
className="flex w-full items-center gap-4 text-left" className="flex w-full items-center gap-4 text-left"
style={{ background: "none", border: "none", padding: 0, cursor: "pointer", color: "#fff", justifyContent: "space-between" }} style={{ background: "none", border: "none", padding: 0, cursor: "pointer", color: "#fff" }}
> >
<div className="flex items-center gap-4" style={{ minWidth: 0 }}>
<ProgressCircleRoot value={percent} maxValue={100} style={{ width: 49, height: 49, flexShrink: 0 }}> <ProgressCircleRoot value={percent} maxValue={100} style={{ width: 49, height: 49, flexShrink: 0 }}>
<ProgressCircleTrack style={{ opacity: 0.3 }} /> <ProgressCircleTrack style={{ opacity: 0.3 }} />
<ProgressCircleRange /> <ProgressCircleRange />
</ProgressCircleRoot> </ProgressCircleRoot>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span style={{ fontSize: 12, opacity: 0.85 }}>{OVERALL_LABEL[horizon]}</span> <span style={{ fontSize: 12 }}>{OVERALL_LABEL[horizon]}</span>
<span style={{ fontSize: 26, fontWeight: 700 }}> <span style={{ fontSize: 26, fontWeight: 700 }}>
{tugrikShort(spent)} {tugrikShort(spent)}
{limitSet ? ` / ${tugrikShort(limit)}` : ""} {limit > 0 ? ` / ${tugrikShort(limit)}` : " / —"}
</span> </span>
{!limitSet && <span style={{ fontSize: 12, opacity: 0.75 }}>{s.overall.unsetHint}</span>}
</div> </div>
</div>
<Icon name="chevron-right" size={14} style={{ opacity: 0.8, flexShrink: 0 }} />
</button> </button>
)} )}
</div> </div>
@ -405,8 +376,6 @@ function CategoryList({
const [newName, setNewName] = React.useState(""); const [newName, setNewName] = React.useState("");
const [confirmRemove, setConfirmRemove] = React.useState<string | null>(null); const [confirmRemove, setConfirmRemove] = React.useState<string | null>(null);
const empty = React.useMemo(() => splitEmptyCopy(s.categories.empty), []);
function openEditor(name: string) { function openEditor(name: string) {
const limit = categoryLimitFor(name); const limit = categoryLimitFor(name);
setDrafts({ day: dec(limit.day) > 0 ? limit.day : "", week: dec(limit.week) > 0 ? limit.week : "", month: dec(limit.month) > 0 ? limit.month : "" }); setDrafts({ day: dec(limit.day) > 0 ? limit.day : "", week: dec(limit.week) > 0 ? limit.week : "", month: dec(limit.month) > 0 ? limit.month : "" });
@ -415,14 +384,12 @@ function CategoryList({
return ( return (
<Card> <Card>
<SectionHeader <div className="flex items-center justify-between">
title={s.categories.title} <h2 style={{ fontWeight: 700, fontSize: 14, color: "var(--seed-color-fg-placeholder)" }}>{s.categories.title}</h2>
onAction={() => { <MercuryButton variant="ghost" onClick={() => { setNewName(""); setAdding(true); }}>
setNewName(""); {s.categories.add}
setAdding(true); </MercuryButton>
}} </div>
actionLabel={s.categories.add}
/>
{adding && ( {adding && (
<div className="mt-3 flex flex-col gap-2"> <div className="mt-3 flex flex-col gap-2">
@ -457,7 +424,9 @@ function CategoryList({
)} )}
{!loading && rows.length === 0 && ( {!loading && rows.length === 0 && (
<EmptyState icon="layers" title={empty.title} hint={empty.hint} compact /> <p className="mt-3" style={{ color: "var(--seed-color-fg-placeholder)" }}>
{s.categories.empty}
</p>
)} )}
{!loading && rows.length > 0 && ( {!loading && rows.length > 0 && (
@ -467,7 +436,6 @@ function CategoryList({
const limit = dec(row.limit); const limit = dec(row.limit);
const over = limit > 0 && spent > limit; const over = limit > 0 && spent > limit;
const percent = limit > 0 ? Math.min(100, (spent / limit) * 100) : 0; const percent = limit > 0 ? Math.min(100, (spent / limit) * 100) : 0;
const style = categoryStyle(row.category);
if (editingName === row.category) { if (editingName === row.category) {
return ( return (
@ -503,59 +471,34 @@ function CategoryList({
} }
return ( return (
<li key={row.category} className="flex items-center gap-3"> <li key={row.category} className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Link <Link
href={`/planner/${encodeURIComponent(row.category)}`} href={`/planner/${encodeURIComponent(row.category)}`}
className="flex flex-1 items-center gap-3" className="flex flex-1 flex-col gap-2"
style={{ color: "inherit", textDecoration: "none", minWidth: 0 }} style={{ color: "inherit", textDecoration: "none" }}
> >
<IconChip icon={style.icon} tint={style.tint} fg={style.fg} size={36} /> <div className="flex items-center justify-between">
<div className="flex flex-1 flex-col gap-2" style={{ minWidth: 0 }}> <span style={{ display: "inline-flex", alignItems: "center", gap: 8, fontWeight: 700 }}>
<div className="flex items-center justify-between gap-2"> <span aria-hidden>{categoryStyle(row.category).emoji}</span>
<span {categoryStyle(row.category).name}
style={{
fontWeight: 700,
fontSize: 14,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{style.name}
</span> </span>
<span <span style={{ fontWeight: 700, color: over ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-placeholder)" }}>
style={{
fontWeight: 700,
fontSize: 13,
flexShrink: 0,
color: over ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-placeholder)",
}}
>
{tugrik(spent)} {tugrik(spent)}
{limit > 0 ? ` / ${tugrik(limit)}` : " / —"} {limit > 0 ? ` / ${tugrik(limit)}` : " / —"}
</span> </span>
</div> </div>
<ProgressBar percent={percent} tone={over ? "critical" : "brand"} /> <ProgressBar percent={percent} tone={over ? "critical" : "brand"} />
</div>
</Link> </Link>
<button <button
type="button" type="button"
onClick={() => openEditor(row.category)} onClick={() => openEditor(row.category)}
aria-label={s.categories.editTitle(row.category, HORIZON_LABEL[horizon])} aria-label={s.categories.editTitle(row.category, HORIZON_LABEL[horizon])}
style={{ style={{ background: "none", border: "none", cursor: "pointer", color: "var(--seed-color-fg-placeholder)" }}
all: "unset",
cursor: "pointer",
width: 28,
height: 28,
borderRadius: 8,
flexShrink: 0,
display: "grid",
placeItems: "center",
color: "var(--seed-color-fg-neutral-muted, #8b8b8b)",
}}
> >
</button> </button>
</div>
</li> </li>
); );
})} })}
@ -630,13 +573,20 @@ function SavingsSection({
const [editing, setEditing] = React.useState<{ mode: "add" | "edit"; goal?: SavingsGoal } | null>(null); const [editing, setEditing] = React.useState<{ mode: "add" | "edit"; goal?: SavingsGoal } | null>(null);
const [confirmDelete, setConfirmDelete] = React.useState<string | null>(null); const [confirmDelete, setConfirmDelete] = React.useState<string | null>(null);
const empty = React.useMemo(() => splitEmptyCopy(s.savings.empty), []);
return ( return (
<Card> <Card>
<SectionHeader title={s.savings.title} onAction={() => setEditing({ mode: "add" })} actionLabel={s.savings.add} /> <div className="flex items-center justify-between">
<h2 style={{ fontWeight: 700, fontSize: 14, color: "var(--seed-color-fg-placeholder)" }}>{s.savings.title}</h2>
<MercuryButton variant="ghost" onClick={() => setEditing({ mode: "add" })}>
{s.savings.add}
</MercuryButton>
</div>
{goals.length === 0 && !editing && <EmptyState icon="hand-coins" title={empty.title} hint={empty.hint} compact />} {goals.length === 0 && !editing && (
<p className="mt-3" style={{ color: "var(--seed-color-fg-placeholder)" }}>
{s.savings.empty}
</p>
)}
{goals.length > 0 && ( {goals.length > 0 && (
<ul className="mt-4 flex flex-col gap-4"> <ul className="mt-4 flex flex-col gap-4">
@ -652,36 +602,17 @@ function SavingsSection({
<button <button
type="button" type="button"
onClick={() => setEditing({ mode: "edit", goal })} onClick={() => setEditing({ mode: "edit", goal })}
className="flex w-full items-center gap-3 text-left" className="flex w-full flex-col gap-2 text-left"
style={{ background: "none", border: "none", padding: 0, cursor: "pointer" }} style={{ background: "none", border: "none", padding: 0, cursor: "pointer" }}
> >
<IconChip icon="hand-coins" tint={SAVINGS_TINT} fg={SAVINGS_FG} size={36} /> <div className="flex items-center justify-between">
<div className="flex flex-1 flex-col gap-2" style={{ minWidth: 0 }}> <span style={{ fontWeight: 700 }}>{goal.name}</span>
<div className="flex items-center justify-between gap-2"> <span style={{ fontWeight: 700, color: done ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-placeholder)" }}>
<span
style={{
fontWeight: 700,
fontSize: 14,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{goal.name}
</span>
<span
style={{
fontWeight: 700,
fontSize: 13,
flexShrink: 0,
color: done ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-placeholder)",
}}
>
{tugrik(saved)} {tugrik(saved)}
{target > 0 ? ` / ${tugrik(target)}` : " / —"} {target > 0 ? ` / ${tugrik(target)}` : " / —"}
</span> </span>
</div> </div>
<ProgressBar percent={percent} tone="brand" /> <ProgressBar percent={percent} tone={done ? "brand" : "brand"} />
<div className="flex items-center justify-between" style={{ fontSize: 12, color: "var(--seed-color-fg-placeholder)" }}> <div className="flex items-center justify-between" style={{ fontSize: 12, color: "var(--seed-color-fg-placeholder)" }}>
<span>{goal.accountId === 0 ? s.savings.linkAccount : goal.accountName || s.savings.linkAccount}</span> <span>{goal.accountId === 0 ? s.savings.linkAccount : goal.accountName || s.savings.linkAccount}</span>
{monthly > 0 && <span>{s.savings.perMonth} {tugrik(monthly)}</span>} {monthly > 0 && <span>{s.savings.perMonth} {tugrik(monthly)}</span>}
@ -691,7 +622,6 @@ function SavingsSection({
{s.savings.remaining} {tugrik(remaining)} {s.savings.remaining} {tugrik(remaining)}
</span> </span>
)} )}
</div>
</button> </button>
</li> </li>
); );

View file

@ -24,7 +24,6 @@ export const plannerStrings = {
month: "Энэ сарын лимит", month: "Энэ сарын лимит",
}, },
editTitle: (horizonLabel: string) => `${horizonLabel} — нийт лимит`, editTitle: (horizonLabel: string) => `${horizonLabel} — нийт лимит`,
unsetHint: "Лимит тохируулаагүй",
}, },
categories: { categories: {
title: "Ангиллын лимит", title: "Ангиллын лимит",

View file

@ -15,8 +15,6 @@ import {
Skeleton, Skeleton,
} from "@seed-design/react"; } from "@seed-design/react";
import { MercuryButton } from "@/ds/MercuryButton"; import { MercuryButton } from "@/ds/MercuryButton";
import { EmptyState, Icon, IconChip } from "@/ds";
import { categoryStyle } from "@/ds/categoryStyle";
import { useCategories } from "@/api/hooks/reads"; import { useCategories } from "@/api/hooks/reads";
import { useCategoryMutations } from "@/api/hooks/mutations"; import { useCategoryMutations } from "@/api/hooks/mutations";
import type { Category } from "@/api/schemas"; import type { Category } from "@/api/schemas";
@ -107,9 +105,9 @@ export function CategoriesManager({ onBack }: CategoriesManagerProps) {
type="button" type="button"
onClick={onBack} onClick={onBack}
aria-label={profileStrings.categories.back} aria-label={profileStrings.categories.back}
style={{ display: "grid", placeItems: "center", background: "none", border: "none", cursor: "pointer", color: "var(--seed-color-fg-neutral)" }} style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
> >
<Icon name="chevron-left" size={22} />
</button> </button>
)} )}
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0, flex: 1 }}>{profileStrings.categories.title}</h1> <h1 style={{ fontSize: 16, fontWeight: 600, margin: 0, flex: 1 }}>{profileStrings.categories.title}</h1>
@ -126,7 +124,7 @@ export function CategoriesManager({ onBack }: CategoriesManagerProps) {
)} )}
{!isLoading && groups.length === 0 && ( {!isLoading && groups.length === 0 && (
<EmptyState icon="list" title={profileStrings.categories.empty} /> <p style={{ fontSize: 14, color: "var(--seed-color-fg-muted, #6b7280)" }}>{profileStrings.categories.empty}</p>
)} )}
{groups.map((group) => ( {groups.map((group) => (
@ -134,7 +132,6 @@ export function CategoriesManager({ onBack }: CategoriesManagerProps) {
<CategoryRow <CategoryRow
icon={group.icon} icon={group.icon}
name={group.name} name={group.name}
kind="expense"
heading heading
onEdit={() => setSheet({ mode: "edit", category: { name: group.name, kind: "expense", depth: 0, icon: group.icon } })} onEdit={() => setSheet({ mode: "edit", category: { name: group.name, kind: "expense", depth: 0, icon: group.icon } })}
onDelete={() => setDeleteTarget(group.name)} onDelete={() => setDeleteTarget(group.name)}
@ -207,23 +204,20 @@ export function CategoriesManager({ onBack }: CategoriesManagerProps) {
function CategoryRow({ function CategoryRow({
icon, icon,
name, name,
kind,
heading, heading,
onEdit, onEdit,
onDelete, onDelete,
}: { }: {
icon?: string | null; icon?: string | null;
name: string; name: string;
kind?: string;
heading?: boolean; heading?: boolean;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
}) { }) {
const style = categoryStyle(name, kind === "income");
return ( return (
<div style={{ display: "flex", alignItems: "center", gap: 12 }}> <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<IconChip icon={style.icon} tint={style.tint} fg={style.fg} size={heading ? 40 : 32} /> <span aria-hidden style={{ fontSize: heading ? 20 : 16 }}>{icon || "🏷️"}</span>
<span style={{ fontSize: heading ? 16 : 14, fontWeight: heading ? 700 : 500, flex: 1 }}>{style.name}</span> <span style={{ fontSize: heading ? 16 : 14, fontWeight: heading ? 600 : 400, flex: 1 }}>{name}</span>
<button <button
type="button" type="button"
onClick={onEdit} onClick={onEdit}
@ -253,26 +247,24 @@ function CategoryChip({
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
}) { }) {
const style = categoryStyle(category.name, category.kind === "income");
return ( return (
<div <div
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: 6, gap: 6,
padding: "6px 10px 6px 6px", padding: "6px 10px",
borderRadius: 999, borderRadius: 999,
background: style.tint, background: "var(--seed-color-bg-layer-default)",
color: style.fg,
}} }}
> >
<IconChip icon={style.icon} tint="transparent" fg={style.fg} size={22} /> <span aria-hidden>{category.icon || "🏷️"}</span>
<span style={{ fontSize: 13, fontWeight: 600 }}>{style.name}</span> <span style={{ fontSize: 13 }}>{category.name}</span>
<button <button
type="button" type="button"
onClick={onEdit} onClick={onEdit}
aria-label={`${profileStrings.categories.editAction}: ${category.name}`} aria-label={`${profileStrings.categories.editAction}: ${category.name}`}
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 12, color: "inherit", opacity: 0.7 }} style={{ background: "none", border: "none", cursor: "pointer", fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)" }}
> >
</button> </button>
@ -323,9 +315,9 @@ function CategoryEditor({
type="button" type="button"
onClick={onCancel} onClick={onCancel}
aria-label={profileStrings.categories.cancel} aria-label={profileStrings.categories.cancel}
style={{ display: "grid", placeItems: "center", background: "none", border: "none", cursor: "pointer", color: "var(--seed-color-fg-neutral)" }} style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
> >
<Icon name="chevron-left" size={22} />
</button> </button>
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}> <h1 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>
{editing ? profileStrings.categories.edit : profileStrings.categories.add} {editing ? profileStrings.categories.edit : profileStrings.categories.add}

View file

@ -2,7 +2,6 @@
import { useConnections } from "@/api/hooks/reads"; import { useConnections } from "@/api/hooks/reads";
import { Skeleton } from "@seed-design/react"; import { Skeleton } from "@seed-design/react";
import { EmptyState, IconChip, SectionHeader } from "@/ds";
import { profileStrings } from "./strings"; import { profileStrings } from "./strings";
/** /**
@ -12,28 +11,31 @@ import { profileStrings } from "./strings";
*/ */
export function ConnectedBanks() { export function ConnectedBanks() {
const { data: connections, isLoading } = useConnections(); const { data: connections, isLoading } = useConnections();
const empty = !isLoading && (!connections || connections.length === 0);
return ( return (
<section aria-labelledby="connected-banks-heading"> <section aria-labelledby="connected-banks-heading">
<div id="connected-banks-heading"> <h2
<SectionHeader title={profileStrings.banks.title} /> id="connected-banks-heading"
</div> style={{ fontSize: 12, color: "var(--seed-color-fg-subtle, #6b7280)", margin: "0 0 12px" }}
>
{profileStrings.banks.title}
</h2>
{isLoading && ( {isLoading && (
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 12 }}> <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<Skeleton height="44px" /> <Skeleton height="44px" />
<Skeleton height="44px" /> <Skeleton height="44px" />
</div> </div>
)} )}
{empty && ( {!isLoading && (!connections || connections.length === 0) && (
<EmptyState icon="bank" title={profileStrings.banks.empty} hint={profileStrings.banks.manageNote} compact /> <p style={{ fontSize: 14, color: "var(--seed-color-fg-muted, #6b7280)" }}>
{profileStrings.banks.empty}
</p>
)} )}
{!isLoading && connections && connections.length > 0 && ( {!isLoading && connections && connections.length > 0 && (
<> <ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 4 }}>
<ul style={{ listStyle: "none", margin: "8px 0 0", padding: 0, display: "flex", flexDirection: "column" }}>
{connections.map((c) => ( {connections.map((c) => (
<li <li
key={c.bank} key={c.bank}
@ -41,26 +43,14 @@ export function ConnectedBanks() {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
gap: 12,
padding: "10px 0", padding: "10px 0",
}} }}
> >
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}> <div style={{ display: "flex", flexDirection: "column" }}>
<IconChip icon="bank" tint="var(--seed-color-bg-neutral-subtle, #eef0f2)" fg="var(--seed-color-fg-neutral)" /> <span style={{ fontSize: 14, fontWeight: 600 }}>{c.bank}</span>
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}> <span style={{ fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)" }}>
<span {c.username}
style={{
fontSize: 15,
fontWeight: 700,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{c.bank}
</span> </span>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)" }}>{c.username}</span>
</div>
</div> </div>
{c.courierManaged && ( {c.courierManaged && (
<span <span
@ -68,7 +58,6 @@ export function ConnectedBanks() {
fontSize: 11, fontSize: 11,
padding: "4px 8px", padding: "4px 8px",
borderRadius: 999, borderRadius: 999,
flexShrink: 0,
background: "var(--seed-color-bg-layer-default)", background: "var(--seed-color-bg-layer-default)",
color: "var(--seed-color-fg-muted, #6b7280)", color: "var(--seed-color-fg-muted, #6b7280)",
}} }}
@ -79,12 +68,11 @@ export function ConnectedBanks() {
</li> </li>
))} ))}
</ul> </ul>
)}
<p style={{ fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)", marginTop: 8 }}> <p style={{ fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)", marginTop: 8 }}>
{profileStrings.banks.manageNote} {profileStrings.banks.manageNote}
</p> </p>
</>
)}
</section> </section>
); );
} }

View file

@ -3,8 +3,7 @@
import * as React from "react"; import * as React from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Card, HideAmountsToggle, Icon, IconChip, MercuryButton } from "@/ds"; import { Card, HideAmountsToggle, MercuryButton } from "@/ds";
import type { IconName } from "@/ds";
import { useMe, useSettings } from "@/api/hooks/reads"; import { useMe, useSettings } from "@/api/hooks/reads";
import { profileStrings } from "./strings"; import { profileStrings } from "./strings";
import { SettingsForm } from "./SettingsForm"; import { SettingsForm } from "./SettingsForm";
@ -25,7 +24,6 @@ export function ProfileView() {
const emailLocal = me?.email ? me.email.split("@")[0] : ""; const emailLocal = me?.email ? me.email.split("@")[0] : "";
const displayName = settings?.holderName || emailLocal || me?.email || ""; const displayName = settings?.holderName || emailLocal || me?.email || "";
const initial = (displayName || me?.email || "?").trim().charAt(0).toUpperCase() || "?";
async function handleLogout() { async function handleLogout() {
setLoggingOut(true); setLoggingOut(true);
@ -47,57 +45,23 @@ export function ProfileView() {
<HideAmountsToggle label={profileStrings.display.hideAmounts} /> <HideAmountsToggle label={profileStrings.display.hideAmounts} />
</div> </div>
<Card style={{ display: "flex", alignItems: "center", gap: 14 }}> <Card>
<span <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
aria-hidden <span style={{ fontSize: 14, fontWeight: 600 }}>{displayName}</span>
style={{
width: 56,
height: 56,
flexShrink: 0,
borderRadius: 28,
display: "grid",
placeItems: "center",
fontSize: 22,
fontWeight: 800,
color: "var(--mercury-on-brand)",
background: "var(--mercury-brand-yellow)",
}}
>
{initial}
</span>
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
<span
style={{
fontSize: 16,
fontWeight: 700,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{displayName}
</span>
{me?.email && ( {me?.email && (
<span <span style={{ fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)" }}>
style={{ {profileStrings.account.handlePrefix}
fontSize: 13, {emailLocal}
color: "var(--seed-color-fg-muted, #6b7280)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{me.email}
</span> </span>
)} )}
</div> </div>
</Card> </Card>
<Card style={{ display: "flex", flexDirection: "column" }}> <Card>
<nav style={{ display: "flex", flexDirection: "column" }}> <nav style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<MenuRow icon="wrench" label={profileStrings.menu.settings} onClick={() => setShowSettings(true)} /> <MenuRow label={profileStrings.menu.settings} onClick={() => setShowSettings(true)} />
<MenuLink icon="list" label={profileStrings.menu.categories} href="/profile/categories" /> <MenuLink label={profileStrings.menu.categories} href="/profile/categories" />
<MenuLink icon="sparkles" label={profileStrings.menu.subscriptions} href="/profile/subscriptions" /> <MenuLink label={profileStrings.menu.subscriptions} href="/profile/subscriptions" />
</nav> </nav>
</Card> </Card>
@ -112,46 +76,49 @@ export function ProfileView() {
); );
} }
const menuRowStyle: React.CSSProperties = { function MenuRow({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
gap: 12, padding: "12px 0",
width: "100%",
padding: "10px 0",
background: "none", background: "none",
border: "none", border: "none",
cursor: "pointer", cursor: "pointer",
font: "inherit", font: "inherit",
color: "inherit", color: "inherit",
textAlign: "left", textAlign: "left",
textDecoration: "none", }}
}; >
<span style={{ fontSize: 14 }}>{label}</span>
function MenuRowContent({ icon, label }: { icon: IconName; label: string }) { <span aria-hidden style={{ color: "var(--seed-color-fg-placeholder, #9ca3af)" }}>
return (
<> </span>
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
<IconChip icon={icon} tint="var(--seed-color-bg-neutral-subtle, #eef0f2)" fg="var(--seed-color-fg-neutral)" />
<span style={{ fontSize: 15, fontWeight: 600 }}>{label}</span>
</div>
<Icon name="chevron-right" size={18} style={{ color: "var(--seed-color-fg-placeholder, #9ca3af)" }} />
</>
);
}
function MenuRow({ icon, label, onClick }: { icon: IconName; label: string; onClick: () => void }) {
return (
<button type="button" onClick={onClick} style={menuRowStyle}>
<MenuRowContent icon={icon} label={label} />
</button> </button>
); );
} }
function MenuLink({ icon, label, href }: { icon: IconName; label: string; href: string }) { function MenuLink({ label, href }: { label: string; href: string }) {
return ( return (
<Link href={href} style={menuRowStyle}> <Link
<MenuRowContent icon={icon} label={label} /> href={href}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 0",
color: "inherit",
textDecoration: "none",
}}
>
<span style={{ fontSize: 14 }}>{label}</span>
<span aria-hidden style={{ color: "var(--seed-color-fg-placeholder, #9ca3af)" }}>
</span>
</Link> </Link>
); );
} }

View file

@ -12,7 +12,6 @@ import {
Skeleton, Skeleton,
} from "@seed-design/react"; } from "@seed-design/react";
import { MercuryButton } from "@/ds/MercuryButton"; import { MercuryButton } from "@/ds/MercuryButton";
import { Icon } from "@/ds";
import { useSettings } from "@/api/hooks/reads"; import { useSettings } from "@/api/hooks/reads";
import { useSaveSettings } from "@/api/hooks/mutations"; import { useSaveSettings } from "@/api/hooks/mutations";
import type { Settings } from "@/api/schemas"; import type { Settings } from "@/api/schemas";
@ -127,9 +126,9 @@ export function SettingsForm({ onBack }: SettingsFormProps) {
type="button" type="button"
onClick={onBack} onClick={onBack}
aria-label={profileStrings.settings.back} aria-label={profileStrings.settings.back}
style={{ display: "grid", placeItems: "center", background: "none", border: "none", cursor: "pointer", color: "var(--seed-color-fg-neutral)" }} style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
> >
<Icon name="chevron-left" size={22} />
</button> </button>
)} )}
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>{profileStrings.settings.title}</h1> <h1 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>{profileStrings.settings.title}</h1>

View file

@ -3,7 +3,6 @@
import * as React from "react"; import * as React from "react";
import { TextFieldRoot, TextFieldInput, Skeleton } from "@seed-design/react"; import { TextFieldRoot, TextFieldInput, Skeleton } from "@seed-design/react";
import { MercuryButton } from "@/ds/MercuryButton"; import { MercuryButton } from "@/ds/MercuryButton";
import { EmptyState, Icon, IconChip } from "@/ds";
import { tugrik } from "@/ds/money"; import { tugrik } from "@/ds/money";
import { useSubscriptions } from "@/api/hooks/reads"; import { useSubscriptions } from "@/api/hooks/reads";
import { useSubscriptionMutations } from "@/api/hooks/mutations"; import { useSubscriptionMutations } from "@/api/hooks/mutations";
@ -59,9 +58,9 @@ export function SubscriptionsView({ onBack }: SubscriptionsViewProps) {
type="button" type="button"
onClick={onBack} onClick={onBack}
aria-label={profileStrings.subscriptions.back} aria-label={profileStrings.subscriptions.back}
style={{ display: "grid", placeItems: "center", background: "none", border: "none", cursor: "pointer", color: "var(--seed-color-fg-neutral)" }} style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
> >
<Icon name="chevron-left" size={22} />
</button> </button>
)} )}
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0, flex: 1 }}>{profileStrings.subscriptions.title}</h1> <h1 style={{ fontSize: 16, fontWeight: 600, margin: 0, flex: 1 }}>{profileStrings.subscriptions.title}</h1>
@ -78,7 +77,7 @@ export function SubscriptionsView({ onBack }: SubscriptionsViewProps) {
)} )}
{!isLoading && all.length === 0 && ( {!isLoading && all.length === 0 && (
<EmptyState icon="sparkles" title={profileStrings.subscriptions.empty} /> <p style={{ fontSize: 14, color: "var(--seed-color-fg-muted, #6b7280)" }}>{profileStrings.subscriptions.empty}</p>
)} )}
{!isLoading && detected.length > 0 && ( {!isLoading && detected.length > 0 && (
@ -137,27 +136,14 @@ function SubscriptionRow({
destructive?: boolean; destructive?: boolean;
}) { }) {
return ( return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: "8px 0" }}> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "8px 0" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}> <div style={{ display: "flex", flexDirection: "column" }}>
<IconChip icon="sparkles" tint="var(--seed-color-bg-neutral-subtle, #eef0f2)" fg="var(--seed-color-fg-neutral)" size={36} /> <span style={{ fontSize: 14, fontWeight: 600 }}>{sub.label}</span>
<div style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
<span
style={{
fontSize: 14,
fontWeight: 700,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{sub.label}
</span>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)" }}> <span style={{ fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)" }}>
{tugrik(sub.monthly)} / {sub.cadence} {tugrik(sub.monthly)} / {sub.cadence}
{sub.nextDue ? ` · ${sub.nextDue}` : ""} {sub.nextDue ? ` · ${sub.nextDue}` : ""}
</span> </span>
</div> </div>
</div>
<button <button
type="button" type="button"
onClick={onAction} onClick={onAction}
@ -218,9 +204,9 @@ function ManualSubscriptionForm({
type="button" type="button"
onClick={onCancel} onClick={onCancel}
aria-label={profileStrings.subscriptions.cancel} aria-label={profileStrings.subscriptions.cancel}
style={{ display: "grid", placeItems: "center", background: "none", border: "none", cursor: "pointer", color: "var(--seed-color-fg-neutral)" }} style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
> >
<Icon name="chevron-left" size={22} />
</button> </button>
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>{profileStrings.subscriptions.addManual}</h1> <h1 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>{profileStrings.subscriptions.addManual}</h1>
</div> </div>