Compare commits
10 commits
06e74b1668
...
0010b7b5fa
| Author | SHA1 | Date | |
|---|---|---|---|
| 0010b7b5fa | |||
| 11c30b6690 | |||
| edef3272cd | |||
| 15f2879f8b | |||
| 059b7e13d5 | |||
| 1bb43e58c2 | |||
| 404ab94e1c | |||
| 43108a92bb | |||
| e0c07be232 | |||
| 66d82b8b68 |
30 changed files with 1220 additions and 579 deletions
|
|
@ -1,45 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AuthForm } from "@/features/auth/AuthForm";
|
||||
import { authStrings } from "@/features/auth/strings";
|
||||
import { AuthHeader } from "@/features/auth/AuthHeader";
|
||||
|
||||
// Ports ios/Mercury/Features/Auth/ForgotPasswordView.swift: back chevron,
|
||||
// MERCURY wordmark, explanatory subtitle (rendered by AuthForm), the email
|
||||
// field, and the "Сэргээх холбоос авах" CTA.
|
||||
export default function ForgotPasswordPage() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<AuthHeader />
|
||||
|
||||
<div style={{ flex: 1, minHeight: 40 }} />
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AuthForm } from "@/features/auth/AuthForm";
|
||||
import { authStrings } from "@/features/auth/strings";
|
||||
import { AuthHeader } from "@/features/auth/AuthHeader";
|
||||
|
||||
// Ports ios/Mercury/Features/Auth/LoginView.swift: back chevron, MERCURY
|
||||
// wordmark, then the login form (email + password + forgot link + CTAs).
|
||||
|
|
@ -11,34 +11,7 @@ export default function LoginPage() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<AuthHeader />
|
||||
|
||||
<div style={{ flex: 1, minHeight: 40 }} />
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AuthForm } from "@/features/auth/AuthForm";
|
||||
import { authStrings } from "@/features/auth/strings";
|
||||
import { AuthHeader } from "@/features/auth/AuthHeader";
|
||||
|
||||
// Ports ios/Mercury/Features/Auth/RegisterView.swift: back chevron, MERCURY
|
||||
// wordmark, then the registration form (email + password + confirm + CTAs).
|
||||
|
|
@ -11,34 +11,7 @@ export default function RegisterPage() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
<AuthHeader />
|
||||
|
||||
<div style={{ flex: 1, minHeight: 40 }} />
|
||||
|
||||
|
|
|
|||
48
src/ds/EmptyState.tsx
Normal file
48
src/ds/EmptyState.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
35
src/ds/IconChip.tsx
Normal file
35
src/ds/IconChip.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
44
src/ds/SectionHeader.tsx
Normal file
44
src/ds/SectionHeader.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,58 +1,59 @@
|
|||
import type { IconName } from "./icons";
|
||||
|
||||
// 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
|
||||
// reads and looks in the UI. Ported from ios/Mercury/Features/Transactions/
|
||||
// CategoryStyle.swift — the web has no Seed multicolor-icon catalog, so each
|
||||
// icon is an emoji + a soft color tint for the category chip.
|
||||
// CategoryStyle.swift.
|
||||
|
||||
export interface CategoryStyle {
|
||||
name: string; // Mongolian display name
|
||||
emoji: string;
|
||||
icon: IconName;
|
||||
tint: string; // chip background (soft)
|
||||
fg: string; // chip foreground / accent
|
||||
}
|
||||
|
||||
const s = (name: string, emoji: string, tint: string, fg: string): CategoryStyle => ({ name, emoji, tint, fg });
|
||||
const s = (name: string, icon: IconName, tint: string, fg: string): CategoryStyle => ({ name, icon, tint, fg });
|
||||
|
||||
const TABLE: Record<string, CategoryStyle> = {
|
||||
// Food & drink
|
||||
"food & drink": s("Хоол хүнс", "🍴", "#FDE8D7", "#B4530A"),
|
||||
groceries: s("Хүнсний бараа", "🛒", "#E3F0D8", "#3F7A1E"),
|
||||
dining: s("Хоолны газар", "🍱", "#FDE8D7", "#B4530A"),
|
||||
dinner: s("Оройн хоол", "🍽️", "#FDE8D7", "#B4530A"),
|
||||
coffee: s("Кофе", "☕", "#EEE3D7", "#7A5230"),
|
||||
drink: s("Ундаа", "🥤", "#DDEAF6", "#215C9A"),
|
||||
"food & drink": s("Хоол хүнс", "utensils", "#FDE8D7", "#B4530A"),
|
||||
groceries: s("Хүнсний бараа", "cart", "#E3F0D8", "#3F7A1E"),
|
||||
dining: s("Хоолны газар", "utensils", "#FDE8D7", "#B4530A"),
|
||||
dinner: s("Оройн хоол", "utensils", "#FDE8D7", "#B4530A"),
|
||||
coffee: s("Кофе", "coffee", "#EEE3D7", "#7A5230"),
|
||||
drink: s("Ундаа", "coffee", "#DDEAF6", "#215C9A"),
|
||||
// Transport
|
||||
transport: s("Тээвэр", "🚗", "#DDE6F6", "#2A4B9A"),
|
||||
transport: s("Тээвэр", "car", "#DDE6F6", "#2A4B9A"),
|
||||
// Bills & services
|
||||
"bills & services": s("Төлбөр & үйлчилгээ", "💳", "#E7E3F6", "#5B44B0"),
|
||||
fees: s("Шимтгэл", "🧾", "#EDEBE7", "#6B6257"),
|
||||
insurance: s("Даатгал", "🛡️", "#DFEFEA", "#1E7A63"),
|
||||
phone: s("Утас", "📱", "#E1E5EA", "#42505F"),
|
||||
subscriptions: s("Сабскрипшн", "✨", "#F5E7F0", "#9A2E77"),
|
||||
"үйлчилгээ": s("Үйлчилгээ", "🔧", "#EDEBE7", "#6B6257"),
|
||||
"bills & services": s("Төлбөр & үйлчилгээ", "card", "#E7E3F6", "#5B44B0"),
|
||||
fees: s("Шимтгэл", "receipt", "#EDEBE7", "#6B6257"),
|
||||
insurance: s("Даатгал", "shield", "#DFEFEA", "#1E7A63"),
|
||||
phone: s("Утас", "phone", "#E1E5EA", "#42505F"),
|
||||
subscriptions: s("Сабскрипшн", "sparkles", "#F5E7F0", "#9A2E77"),
|
||||
"үйлчилгээ": s("Үйлчилгээ", "wrench", "#EDEBE7", "#6B6257"),
|
||||
// Debt & loans
|
||||
"debt & loans": s("Зээл & өр", "🛡️", "#F6E3E3", "#A83232"),
|
||||
debt: s("Өр", "🛡️", "#F6E3E3", "#A83232"),
|
||||
loan: s("Зээл", "💳", "#E7E3F6", "#5B44B0"),
|
||||
"debt & loans": s("Зээл & өр", "shield", "#F6E3E3", "#A83232"),
|
||||
debt: s("Өр", "shield", "#F6E3E3", "#A83232"),
|
||||
loan: s("Зээл", "card", "#E7E3F6", "#5B44B0"),
|
||||
// Shopping
|
||||
shopping: s("Дэлгүүр", "🛍️", "#F5E7F0", "#9A2E77"),
|
||||
electronics: s("Цахилгаан бараа", "🖥️", "#E1E5EA", "#42505F"),
|
||||
shopping: s("Дэлгүүр", "bag", "#F5E7F0", "#9A2E77"),
|
||||
electronics: s("Цахилгаан бараа", "monitor", "#E1E5EA", "#42505F"),
|
||||
// Entertainment
|
||||
entertainment: s("Зугаа цэнгэл", "🎮", "#E7E3F6", "#5B44B0"),
|
||||
anime: s("Аниме", "🎮", "#E7E3F6", "#5B44B0"),
|
||||
spotify: s("Spotify", "🎵", "#E3F0D8", "#3F7A1E"),
|
||||
entertainment: s("Зугаа цэнгэл", "gamepad", "#E7E3F6", "#5B44B0"),
|
||||
anime: s("Аниме", "gamepad", "#E7E3F6", "#5B44B0"),
|
||||
spotify: s("Spotify", "sparkles", "#E3F0D8", "#3F7A1E"),
|
||||
// Health & care
|
||||
fitness: s("Фитнес", "🏋️", "#DDEAF6", "#215C9A"),
|
||||
haircut: s("Үс засалт", "✂️", "#F5E7F0", "#9A2E77"),
|
||||
fitness: s("Фитнес", "dumbbell", "#DDEAF6", "#215C9A"),
|
||||
haircut: s("Үс засалт", "scissors", "#F5E7F0", "#9A2E77"),
|
||||
// Money movement / income
|
||||
transfers: s("Шилжүүлэг", "🔁", "#E1E5EA", "#42505F"),
|
||||
salary: s("Цалин", "💰", "#E3F0D8", "#3F7A1E"),
|
||||
transfers: s("Шилжүүлэг", "repeat", "#E1E5EA", "#42505F"),
|
||||
salary: s("Цалин", "wallet", "#E3F0D8", "#3F7A1E"),
|
||||
// Synthetic budget line for the loan repayment.
|
||||
"зээлийн төлбөр": s("Зээлийн төлбөр", "🛡️", "#F6E3E3", "#A83232"),
|
||||
"зээлийн төлбөр": s("Зээлийн төлбөр", "shield", "#F6E3E3", "#A83232"),
|
||||
};
|
||||
|
||||
const INCOME_FALLBACK = s("Орлого", "💰", "#E3F0D8", "#3F7A1E");
|
||||
const OTHER_FALLBACK = s("Бусад", "🧾", "#EDEBE7", "#6B6257");
|
||||
const INCOME_FALLBACK = s("Орлого", "wallet", "#E3F0D8", "#3F7A1E");
|
||||
const OTHER_FALLBACK = s("Бусад", "receipt", "#EDEBE7", "#6B6257");
|
||||
|
||||
/** 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. */
|
||||
|
|
|
|||
211
src/ds/icons.tsx
Normal file
211
src/ds/icons.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,3 +16,7 @@ export type { NameEditProps } from "./NameEdit";
|
|||
export { Card } from "./Card";
|
||||
export type { CardProps } from "./Card";
|
||||
export * from "./categoryStyle";
|
||||
export * from "./icons";
|
||||
export * from "./IconChip";
|
||||
export * from "./SectionHeader";
|
||||
export * from "./EmptyState";
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogAction,
|
||||
Skeleton,
|
||||
TextFieldRoot,
|
||||
TextFieldTextarea,
|
||||
} from "@seed-design/react";
|
||||
|
|
@ -100,7 +101,7 @@ function DetailRow({
|
|||
*/
|
||||
export function TransactionDetail({ id }: TransactionDetailProps) {
|
||||
const router = useRouter();
|
||||
const { data: transactions } = useTransactions();
|
||||
const { data: transactions, isLoading } = useTransactions();
|
||||
const { data: categories = [] } = useCategories();
|
||||
const categorize = useCategorize();
|
||||
const renameTxn = useRenameTxn();
|
||||
|
|
@ -132,6 +133,15 @@ export function TransactionDetail({ id }: TransactionDetailProps) {
|
|||
setEditingNote(false);
|
||||
}, [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) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useTransactions } from "@/api/hooks/reads";
|
|||
import type { Txn } from "@/api/schemas";
|
||||
import { Card, HideAmountsToggle } from "@/ds";
|
||||
import { categoryStyle } from "@/ds/categoryStyle";
|
||||
import { Icon } from "@/ds/icons";
|
||||
import { MASKED, tugrikRaw } from "@/ds/money";
|
||||
import { accountingStrings as s } from "./strings";
|
||||
import { txnRouteId } from "./txnRoute";
|
||||
|
|
@ -100,11 +101,11 @@ function TxnRow({ txn, hidden }: { txn: Txn; hidden: boolean }) {
|
|||
borderRadius: 13,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
fontSize: 19,
|
||||
color: isTransfer ? "var(--seed-color-fg-neutral-muted, #6b7280)" : cat.fg,
|
||||
background: isTransfer ? "var(--seed-color-bg-neutral-subtle, #eef0f2)" : cat.tint,
|
||||
}}
|
||||
>
|
||||
{isTransfer ? "🔁" : cat.emoji}
|
||||
<Icon name={isTransfer ? "repeat" : cat.icon} size={20} />
|
||||
</span>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@
|
|||
|
||||
import * as React from "react";
|
||||
import { useNetWorth, useTransactions } from "@/api/hooks/reads";
|
||||
import { Card } from "@/ds";
|
||||
import { Card, IconChip, EmptyState } from "@/ds";
|
||||
import { categoryStyle } from "@/ds/categoryStyle";
|
||||
import { tugrikRaw } from "@/ds/money";
|
||||
import type { Txn } from "@/api/schemas";
|
||||
import { assetsStrings as s } from "./strings";
|
||||
import { DetailHeader } from "./DetailHeader";
|
||||
|
||||
const mutedStyle: React.CSSProperties = { color: "var(--seed-color-fg-neutral-subtle)" };
|
||||
|
||||
|
|
@ -26,10 +29,7 @@ export function AccountDetail({ accountId }: AccountDetailProps) {
|
|||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
<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>
|
||||
<DetailHeader title={account?.bank ?? s.accountDetail.bank} />
|
||||
|
||||
<Card style={{ textAlign: "center", padding: "24px 20px" }}>
|
||||
<div style={{ fontSize: 12, ...mutedStyle }}>{account?.accountNumber}</div>
|
||||
|
|
@ -44,37 +44,22 @@ export function AccountDetail({ accountId }: AccountDetailProps) {
|
|||
<DetailRow label={s.accountDetail.currency} value={account?.currency ?? "—"} />
|
||||
</Card>
|
||||
|
||||
<section>
|
||||
<h3 style={{ margin: "0 0 10px", fontSize: 14, fontWeight: 700 }}>{s.accountDetail.recentTransactions}</h3>
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 700 }}>{s.accountDetail.recentTransactions}</h3>
|
||||
{transactions.isLoading ? (
|
||||
<p style={{ fontSize: 13, ...mutedStyle }}>…</p>
|
||||
) : (transactions.data ?? []).length === 0 ? (
|
||||
<p style={{ fontSize: 13, ...mutedStyle }}>{s.accountDetail.noTransactions}</p>
|
||||
<Card style={{ padding: 0 }}>
|
||||
<EmptyState icon="receipt" title={s.accountDetail.noTransactions} compact />
|
||||
</Card>
|
||||
) : (
|
||||
<Card style={{ padding: 0 }}>
|
||||
{(transactions.data ?? []).map((txn, i) => {
|
||||
const income = txn.direction === "income";
|
||||
return (
|
||||
{(transactions.data ?? []).map((txn, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <Divider />}
|
||||
<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>
|
||||
<TxnRow txn={txn} />
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
|
@ -82,11 +67,32 @@ export function AccountDetail({ accountId }: AccountDetailProps) {
|
|||
);
|
||||
}
|
||||
|
||||
function BackLink() {
|
||||
function TxnRow({ txn }: { txn: Txn }) {
|
||||
const income = txn.direction === "income";
|
||||
const cat = categoryStyle(txn.category, income);
|
||||
return (
|
||||
<a href="/assets" style={{ textDecoration: "none", color: "inherit", fontWeight: 600 }}>
|
||||
← {s.common.back}
|
||||
</a>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: "14px 16px" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
|
||||
<IconChip icon={cat.icon} tint={cat.tint} fg={cat.fg} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
} from "@seed-design/react";
|
||||
import { useNetWorth, useManualAssets, useLending } from "@/api/hooks/reads";
|
||||
import { useManualAssetMutations, useLendingMutations } from "@/api/hooks/mutations";
|
||||
import { Card, HideAmountsToggle, MercuryButton } from "@/ds";
|
||||
import { Card, HideAmountsToggle, MercuryButton, IconChip, SectionHeader, EmptyState, Icon, type IconName } from "@/ds";
|
||||
import { tugrik, tugrikRaw } from "@/ds/money";
|
||||
import type { Account, ManualAsset, Lending } from "@/api/schemas";
|
||||
import { assetsStrings as s } from "./strings";
|
||||
|
|
@ -32,6 +32,31 @@ const rowStyle: React.CSSProperties = {
|
|||
gap: 12,
|
||||
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() {
|
||||
// Re-renders when the global hide-amounts flag flips (tugrik()/tugrikRaw()
|
||||
|
|
@ -65,11 +90,13 @@ export function AssetsView() {
|
|||
<NetWorthCard netWorth={netWorth.data} loading={netWorth.isLoading} />
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<SectionTitle>{s.accounts.title}</SectionTitle>
|
||||
<SectionHeader title={s.accounts.title} />
|
||||
{netWorth.isLoading ? (
|
||||
<SkeletonRows count={2} />
|
||||
) : accounts.length === 0 ? (
|
||||
<EmptyText>{s.accounts.empty}</EmptyText>
|
||||
<Card style={{ padding: 0 }}>
|
||||
<EmptyState icon="bank" title={s.accounts.empty} hint={s.accounts.emptyHint} />
|
||||
</Card>
|
||||
) : (
|
||||
<Card style={{ padding: 0 }}>
|
||||
{accounts.map((account, i) => (
|
||||
|
|
@ -79,11 +106,14 @@ export function AssetsView() {
|
|||
href={`/assets/account/${account.accountId}`}
|
||||
style={{ ...rowStyle, color: "inherit", textDecoration: "none" }}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{account.bank}</div>
|
||||
<div style={{ fontSize: 12, ...mutedStyle }}>{account.accountNumber}</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
|
||||
<IconChip icon="bank" {...BANK_TINT} />
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
|
||||
<span style={titleStyle}>{account.bank}</span>
|
||||
<span style={subtitleStyle}>{account.accountNumber}</span>
|
||||
</div>
|
||||
<div style={{ fontWeight: 700 }}>{tugrik(account.balance)}</div>
|
||||
</div>
|
||||
<span style={amountStyle}>{tugrik(account.balance)}</span>
|
||||
</Link>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
|
@ -92,11 +122,13 @@ export function AssetsView() {
|
|||
</section>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<SectionHeaderRow title={s.manualAssets.title} onAdd={() => setAddAssetOpen(true)} addLabel={s.manualAssets.add} />
|
||||
<SectionHeader title={s.manualAssets.title} onAction={() => setAddAssetOpen(true)} actionLabel={s.manualAssets.add} />
|
||||
{manualAssets.isLoading ? (
|
||||
<SkeletonRows count={3} />
|
||||
) : assets.length === 0 ? (
|
||||
<EmptyBlock title={s.manualAssets.emptyTitle} subtitle={s.manualAssets.emptySubtitle} />
|
||||
<Card style={{ padding: 0 }}>
|
||||
<EmptyState icon="layers" title={s.manualAssets.emptyTitle} hint={s.manualAssets.emptySubtitle} />
|
||||
</Card>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{assets.map((asset) => (
|
||||
|
|
@ -113,11 +145,13 @@ export function AssetsView() {
|
|||
</section>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<SectionHeaderRow title={s.lending.title} onAdd={() => setAddLoanOpen(true)} addLabel={s.lending.add} />
|
||||
<SectionHeader title={s.lending.title} onAction={() => setAddLoanOpen(true)} actionLabel={s.lending.add} />
|
||||
{lending.isLoading ? (
|
||||
<SkeletonRows count={2} />
|
||||
) : loans.length === 0 ? (
|
||||
<EmptyText>{s.lending.empty}</EmptyText>
|
||||
<Card style={{ padding: 0 }}>
|
||||
<EmptyState icon="hand-coins" title={s.lending.empty} hint={s.lending.emptyHint} />
|
||||
</Card>
|
||||
) : (
|
||||
<Card style={{ padding: 0 }}>
|
||||
{loans.map((loan, i) => (
|
||||
|
|
@ -201,21 +235,21 @@ export function AssetsView() {
|
|||
|
||||
function NetWorthCard({ netWorth, loading }: { netWorth?: { total: string; assets: string; liabilities: string }; loading: boolean }) {
|
||||
return (
|
||||
<Card style={{ padding: "24px 20px", background: "var(--mercury-balance-card)" }}>
|
||||
<Card style={{ padding: "26px 22px", background: "var(--mercury-balance-card)" }}>
|
||||
{loading ? (
|
||||
<Skeleton height="40px" />
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontSize: 13, opacity: 0.75 }}>{s.netWorth.total}</div>
|
||||
<div style={{ fontSize: 32, fontWeight: 700, marginTop: 6 }}>{tugrik(netWorth?.total ?? "0")}</div>
|
||||
<div style={{ display: "flex", gap: 20, marginTop: 16 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, letterSpacing: 0.2, 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={{ display: "flex", gap: 28, marginTop: 20 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, opacity: 0.75 }}>{s.netWorth.assets}</div>
|
||||
<div style={{ fontWeight: 600 }}>{tugrik(netWorth?.assets ?? "0")}</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, marginTop: 2 }}>{tugrik(netWorth?.assets ?? "0")}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, opacity: 0.75 }}>{s.netWorth.liabilities}</div>
|
||||
<div style={{ fontWeight: 600 }}>{tugrik(netWorth?.liabilities ?? "0")}</div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, marginTop: 2 }}>{tugrik(netWorth?.liabilities ?? "0")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -226,48 +260,13 @@ function NetWorthCard({ netWorth, loading }: { netWorth?: { total: string; asset
|
|||
|
||||
// --- 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 }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<Card style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<Skeleton key={i} height="64px" />
|
||||
<Skeleton key={i} height="48px" style={{ borderRadius: "var(--seed-radius-r2, 8px)" }} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -292,25 +291,30 @@ function ManualAssetRow({
|
|||
const positive = change >= 0;
|
||||
const categoryLabel = s.manualAssets.categories[asset.category] ?? asset.category;
|
||||
const conditionLabel = s.manualAssets.conditions[asset.condition] ?? asset.condition;
|
||||
const chip = assetChip(asset.category);
|
||||
|
||||
return (
|
||||
<Card style={{ padding: 0 }}>
|
||||
<div style={rowStyle}>
|
||||
<Link href={`/assets/manual/${encodeURIComponent(asset.name)}`} style={{ color: "inherit", textDecoration: "none", flex: 1 }}>
|
||||
<div style={{ fontWeight: 700 }}>{asset.name}</div>
|
||||
<div style={{ fontSize: 12, ...mutedStyle }}>
|
||||
<Link
|
||||
href={`/assets/manual/${encodeURIComponent(asset.name)}`}
|
||||
style={{ display: "flex", alignItems: "center", gap: 12, color: "inherit", textDecoration: "none", flex: 1, minWidth: 0 }}
|
||||
>
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<div style={{ fontWeight: 700 }}>{tugrikRaw(asset.value)}</div>
|
||||
<div style={{ fontSize: 11, ...mutedStyle }}>
|
||||
{s.manualAssets.acquiredPrefix}: {tugrikRaw(asset.acquiredValue)}
|
||||
</div>
|
||||
<div style={{ textAlign: "right", flexShrink: 0 }}>
|
||||
<div style={amountStyle}>{tugrikRaw(asset.value)}</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
marginTop: 2,
|
||||
color: positive ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-critical)",
|
||||
}}
|
||||
>
|
||||
|
|
@ -319,32 +323,40 @@ function ManualAssetRow({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, padding: "0 16px 12px" }}>
|
||||
<div style={{ display: "flex", gap: 8, padding: "0 16px 14px" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRevalue}
|
||||
disabled={revaluing}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: "1px solid var(--seed-color-border-neutral, #e5e5e5)",
|
||||
borderRadius: 8,
|
||||
background: "none",
|
||||
padding: "6px 10px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
border: "none",
|
||||
borderRadius: 10,
|
||||
background: "var(--seed-color-bg-neutral-subtle, #eef0f2)",
|
||||
padding: "8px 10px",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--seed-color-fg-neutral)",
|
||||
cursor: revaluing ? "default" : "pointer",
|
||||
}}
|
||||
>
|
||||
<Icon name="trending" size={14} />
|
||||
{revaluing ? "…" : s.manualAssets.revalue}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
style={{
|
||||
border: "1px solid var(--seed-color-border-neutral, #e5e5e5)",
|
||||
borderRadius: 8,
|
||||
background: "none",
|
||||
padding: "6px 10px",
|
||||
border: "none",
|
||||
borderRadius: 10,
|
||||
background: "var(--seed-color-bg-neutral-subtle, #eef0f2)",
|
||||
padding: "8px 14px",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--seed-color-fg-critical)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
|
|
@ -373,21 +385,36 @@ function statusLabel(loan: Lending): string {
|
|||
function LoanRow({ loan, onDelete }: { loan: Lending; onDelete: () => void }) {
|
||||
return (
|
||||
<div style={rowStyle}>
|
||||
<Link href={`/assets/lending/${loan.id}`} style={{ color: "inherit", textDecoration: "none", flex: 1 }}>
|
||||
<div style={{ fontWeight: 600 }}>{loan.person}</div>
|
||||
<div style={{ fontSize: 12, color: loan.overdue ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-neutral-subtle)" }}>
|
||||
<Link
|
||||
href={`/assets/lending/${loan.id}`}
|
||||
style={{ display: "flex", alignItems: "center", gap: 12, color: "inherit", textDecoration: "none", flex: 1, minWidth: 0 }}
|
||||
>
|
||||
<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)}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<div style={{ fontWeight: 700 }}>{tugrikRaw(loan.remaining)}</div>
|
||||
<div style={{ fontSize: 12, ...mutedStyle }}>/ {tugrikRaw(loan.principal)}</div>
|
||||
<div style={{ textAlign: "right", flexShrink: 0 }}>
|
||||
<div style={amountStyle}>{tugrikRaw(loan.remaining)}</div>
|
||||
<div style={subtitleStyle}>/ {tugrikRaw(loan.principal)}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
aria-label={s.lending.delete}
|
||||
style={{ background: "none", border: "none", color: "var(--seed-color-fg-neutral-subtle)", cursor: "pointer", fontSize: 16 }}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--seed-color-fg-neutral-subtle)",
|
||||
cursor: "pointer",
|
||||
fontSize: 18,
|
||||
lineHeight: 1,
|
||||
padding: 4,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
|
|
|||
47
src/features/assets/DetailHeader.tsx
Normal file
47
src/features/assets/DetailHeader.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,10 +18,11 @@ import {
|
|||
import { useLending } from "@/api/hooks/reads";
|
||||
import { useLendingMutations } from "@/api/hooks/mutations";
|
||||
import type { Lending } from "@/api/schemas";
|
||||
import { Card, MercuryButton } from "@/ds";
|
||||
import { Card, MercuryButton, IconChip, EmptyState } from "@/ds";
|
||||
import { tugrikRaw } from "@/ds/money";
|
||||
import { assetsStrings as s } from "./strings";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
import { DetailHeader } from "./DetailHeader";
|
||||
|
||||
type LendingRepayment = Lending["repayments"][number];
|
||||
|
||||
|
|
@ -70,7 +71,7 @@ export function LendingDetail({ id }: LendingDetailProps) {
|
|||
if (!loan) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<BackLink />
|
||||
<DetailHeader title={s.lending.title} />
|
||||
<p style={mutedStyle}>{s.lending.empty}</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -84,12 +85,12 @@ export function LendingDetail({ id }: LendingDetailProps) {
|
|||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
<header style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<BackLink />
|
||||
<h1 style={{ margin: 0, fontSize: 18, fontWeight: 700, flex: 1 }}>{loan.person}</h1>
|
||||
</header>
|
||||
<DetailHeader title={loan.person} />
|
||||
|
||||
<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={{ marginTop: 6, ...mutedStyle }}>
|
||||
{s.lending.total} <strong>{tugrikRaw(loan.principal)}</strong>
|
||||
|
|
@ -116,25 +117,28 @@ export function LendingDetail({ id }: LendingDetailProps) {
|
|||
<strong style={{ color: "var(--seed-color-fg-positive)" }}>{tugrikRaw(loan.repaid)}</strong>
|
||||
</div>
|
||||
{loan.repayments.length === 0 ? (
|
||||
<p style={{ fontSize: 12, ...mutedStyle, margin: 0 }}>{s.lending.repayments.empty}</p>
|
||||
<EmptyState icon="receipt" title={s.lending.repayments.empty} compact />
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
|
||||
{loan.repayments.map((r, i) => (
|
||||
<React.Fragment key={r.id}>
|
||||
{i > 0 && <div style={{ height: 1, background: "var(--seed-color-border-neutral, #e5e5e5)" }} />}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 0" }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{tugrikRaw(r.amount)}</div>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: "10px 0" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
|
||||
<IconChip icon="receipt" tint="#E1E5EA" fg="#42505F" size={36} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700 }}>{tugrikRaw(r.amount)}</div>
|
||||
<div style={{ fontSize: 12, ...mutedStyle }}>
|
||||
{r.paidOn}
|
||||
{r.note ? ` · ${r.note}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeletingRepayment(r)}
|
||||
aria-label={s.lending.repayments.deleteTitle}
|
||||
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 16, color: "var(--seed-color-fg-neutral-subtle)" }}
|
||||
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 18, flexShrink: 0, color: "var(--seed-color-fg-neutral-subtle)" }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
|
@ -190,14 +194,6 @@ export function LendingDetail({ id }: LendingDetailProps) {
|
|||
);
|
||||
}
|
||||
|
||||
function BackLink() {
|
||||
return (
|
||||
<a href="/assets" style={{ textDecoration: "none", color: "inherit", fontWeight: 600 }}>
|
||||
← {s.common.back}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function AddRepaymentSheet({
|
||||
onClose,
|
||||
onSave,
|
||||
|
|
|
|||
|
|
@ -9,11 +9,23 @@ import { apiGet } from "@/api/client";
|
|||
import { useManualAssets } from "@/api/hooks/reads";
|
||||
import { useManualAssetMutations } from "@/api/hooks/mutations";
|
||||
import { AssetPointSchema, AssetListingSchema, type ManualAsset, type RevalueResult } from "@/api/schemas";
|
||||
import { Card, MercuryButton } from "@/ds";
|
||||
import { Card, MercuryButton, IconChip, type IconName } from "@/ds";
|
||||
import { tugrikRaw, tugrikShortRaw } from "@/ds/money";
|
||||
import { assetsStrings as s } from "./strings";
|
||||
import { ValueHistoryChart } from "./ValueHistoryChart";
|
||||
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
|
||||
// the shape of reads.ts's other hooks and hit the same endpoints iOS uses:
|
||||
|
|
@ -83,7 +95,7 @@ export function ManualAssetDetail({ name }: ManualAssetDetailProps) {
|
|||
if (!asset) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<BackLink />
|
||||
<DetailHeader title={name} />
|
||||
<p style={mutedStyle}>{name}</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -109,14 +121,16 @@ export function ManualAssetDetail({ name }: ManualAssetDetailProps) {
|
|||
const categoryLabel = s.manualAssets.categories[asset.category] ?? asset.category;
|
||||
const conditionLabel = s.manualAssets.conditions[asset.condition] ?? asset.condition;
|
||||
|
||||
const chip = assetChip(asset.category);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
<header style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<BackLink />
|
||||
<h1 style={{ margin: 0, fontSize: 18, fontWeight: 700, flex: 1 }}>{asset.name}</h1>
|
||||
</header>
|
||||
<DetailHeader title={asset.name} />
|
||||
|
||||
<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: 32, fontWeight: 700, marginTop: 8 }}>{tugrikRaw(value)}</div>
|
||||
<div
|
||||
|
|
@ -240,14 +254,6 @@ 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 }) {
|
||||
return (
|
||||
<div style={{ display: "flex", justifyContent: "space-between", padding: "14px 0" }}>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export const assetsStrings = {
|
|||
accounts: {
|
||||
title: "Миний дансууд",
|
||||
empty: "Холбосон данс алга",
|
||||
emptyHint: "Банкны дансаа холбомогц энд харагдана",
|
||||
},
|
||||
manualAssets: {
|
||||
title: "Хөрөнгийн жагсаалт",
|
||||
|
|
@ -65,6 +66,7 @@ export const assetsStrings = {
|
|||
lending: {
|
||||
title: "Найзуудад өгсөн зээл",
|
||||
empty: "Зээл бүртгэгдээгүй байна",
|
||||
emptyHint: "Найздаа зээл өгсөн бол + дарж бүртгээрэй",
|
||||
add: "Шинэ зээл",
|
||||
total: "нийт",
|
||||
statusPaid: "Төлсөн",
|
||||
|
|
|
|||
|
|
@ -137,8 +137,8 @@ export function AuthForm({
|
|||
|
||||
if (step === "twoFactor") {
|
||||
return (
|
||||
<form onSubmit={handleCodeSubmit} className="flex flex-col gap-3">
|
||||
<p className="text-center text-sm" style={{ color: "var(--mercury-subtle, #6b7280)" }}>
|
||||
<form onSubmit={handleCodeSubmit} className="flex flex-col gap-5">
|
||||
<p className="text-center text-sm" style={{ color: "var(--mercury-subtle, #6b7280)", margin: 0 }}>
|
||||
{authStrings.twoFactor.subtitle}
|
||||
</p>
|
||||
|
||||
|
|
@ -162,13 +162,14 @@ export function AuthForm({
|
|||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleFormSubmit} className="flex flex-col gap-3">
|
||||
<form onSubmit={handleFormSubmit} className="flex flex-col gap-5">
|
||||
{mode === "forgot" && (
|
||||
<p className="text-center text-sm" style={{ color: "var(--mercury-subtle, #6b7280)" }}>
|
||||
<p className="text-center text-sm" style={{ color: "var(--mercury-subtle, #6b7280)", margin: 0 }}>
|
||||
{authStrings.forgot.subtitle}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<TextFieldRoot value={email} onValueChange={setEmail} name="email">
|
||||
<TextFieldInput
|
||||
type="email"
|
||||
|
|
@ -209,19 +210,21 @@ export function AuthForm({
|
|||
type="button"
|
||||
onClick={onNavigateForgot}
|
||||
className="self-end text-sm"
|
||||
style={{ color: "var(--mercury-subtle, #6b7280)", background: "none", border: "none" }}
|
||||
style={{ color: "var(--mercury-subtle, #6b7280)", background: "none", border: "none", padding: 0 }}
|
||||
>
|
||||
{authStrings.login.forgot}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormError message={errorMessage} />
|
||||
{infoMessage && (
|
||||
<p className="text-sm" style={{ color: "var(--mercury-subtle, #6b7280)" }}>
|
||||
<p className="text-sm" style={{ color: "var(--mercury-subtle, #6b7280)", margin: 0 }}>
|
||||
{infoMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3" style={{ marginTop: 4 }}>
|
||||
<MercuryButton type="submit" variant="primary" loading={isSubmitting}>
|
||||
{mode === "login"
|
||||
? authStrings.login.submit
|
||||
|
|
@ -240,6 +243,7 @@ export function AuthForm({
|
|||
{authStrings.register.loginLink}
|
||||
</MercuryButton>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -247,7 +251,7 @@ export function AuthForm({
|
|||
function FormError({ message }: { message?: string }) {
|
||||
if (!message) return null;
|
||||
return (
|
||||
<p role="alert" className="text-sm" style={{ color: "var(--seed-color-fg-critical)" }}>
|
||||
<p role="alert" className="text-sm" style={{ color: "var(--seed-color-fg-critical)", margin: 0 }}>
|
||||
{message}
|
||||
</p>
|
||||
);
|
||||
|
|
|
|||
71
src/features/auth/AuthHeader.tsx
Normal file
71
src/features/auth/AuthHeader.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,10 +3,10 @@
|
|||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { Skeleton, ProgressCircleRoot, ProgressCircleTrack, ProgressCircleRange } from "@seed-design/react";
|
||||
import { Card, AmountToggle, HideAmountsToggle } from "../../ds";
|
||||
import { Card, AmountToggle, HideAmountsToggle, IconChip, SectionHeader, EmptyState, categoryStyle } from "../../ds";
|
||||
import { tugrikShortRaw } from "../../ds/money";
|
||||
import { useNetWorth, useAnalyzeMonth, useAnalyzeToday, useBudget } from "../../api/hooks/reads";
|
||||
import { buildHome, type HomeData } from "./buildHome";
|
||||
import { buildHome, type HomeData, type LedgerRow as LedgerRowData } from "./buildHome";
|
||||
import { sample } from "./sample";
|
||||
import { homeStrings as s } from "./strings";
|
||||
|
||||
|
|
@ -42,6 +42,56 @@ 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 +
|
||||
* top budgets, this-month spend, and recent income/expense. Figures come
|
||||
* from `buildHome` (real where the backend has them, `sample` otherwise).
|
||||
|
|
@ -66,6 +116,14 @@ export function DashboardView() {
|
|||
const fraction = budgetDenominator > 0 ? Math.min(1, data.monthlyExpense / budgetDenominator) : 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 (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
<header style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
|
|
@ -84,21 +142,21 @@ export function DashboardView() {
|
|||
color: "var(--mercury-on-brand)",
|
||||
background: "var(--mercury-balance-card)",
|
||||
borderRadius: "var(--seed-radius-r5, 20px)",
|
||||
padding: "14px 20px",
|
||||
padding: "18px 20px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<TugrikCircle bg="var(--mercury-balance-circle)" fg="var(--mercury-on-brand)" />
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
<span style={{ fontSize: 12 }}>{data.overspent ? s.overspent : s.safeToSpend}</span>
|
||||
<span style={{ fontSize: 26, fontWeight: 700 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<span style={{ fontSize: 12, opacity: 0.85 }}>{data.overspent ? s.overspent : s.safeToSpend}</span>
|
||||
<span style={{ fontSize: 27, fontWeight: 700, lineHeight: 1.15 }}>
|
||||
<Amount value={data.safeToSpend} loading={loading} width="140px" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
marginTop: 14,
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
background: "rgba(0,0,0,0.13)",
|
||||
|
|
@ -114,11 +172,11 @@ export function DashboardView() {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, display: "flex", justifyContent: "space-between", fontSize: 12 }}>
|
||||
<span>{s.spent}</span>
|
||||
<div style={{ marginTop: 10, display: "flex", justifyContent: "space-between", alignItems: "baseline", fontSize: 12 }}>
|
||||
<span style={{ opacity: 0.85 }}>{s.spent}</span>
|
||||
<span style={{ fontWeight: 700, fontSize: 13 }}>
|
||||
<Amount value={data.monthlyExpense} loading={loading} />
|
||||
<span style={{ fontWeight: 400, fontSize: 11 }}> / {tugrikShortRaw(budgetDenominator)}</span>
|
||||
<span style={{ fontWeight: 400, fontSize: 11, opacity: 0.85 }}> / {tugrikShortRaw(budgetDenominator)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
|
@ -132,30 +190,30 @@ export function DashboardView() {
|
|||
color: "#fff",
|
||||
background: "var(--mercury-limit-card)",
|
||||
borderRadius: "var(--seed-radius-r5, 20px)",
|
||||
padding: "14px 20px",
|
||||
padding: "18px 20px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<ProgressCircleRoot value={dailyFraction * 100} size="40" tone="staticWhite">
|
||||
<ProgressCircleTrack />
|
||||
<ProgressCircleRange />
|
||||
</ProgressCircleRoot>
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
<span style={{ fontSize: 12 }}>{s.dailyLimit}</span>
|
||||
<span style={{ fontSize: 22, fontWeight: 700 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<span style={{ fontSize: 12, opacity: 0.85 }}>{s.dailyLimit}</span>
|
||||
<span style={{ fontSize: 22, fontWeight: 700, lineHeight: 1.15 }}>
|
||||
<Amount value={data.dailyLimitUsed} loading={loading} width="60px" />
|
||||
{" / "}
|
||||
<Amount value={data.dailyLimitTotal} loading={loading} width="60px" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 4, fontSize: 11, opacity: 0.85 }}>
|
||||
<div style={{ marginTop: 6, fontSize: 11, opacity: 0.85 }}>
|
||||
{s.todaySpent}: <Amount value={data.todayExpense} loading={loading} width="50px" />
|
||||
</div>
|
||||
<hr style={{ margin: "12px 0", border: 0, borderTop: "1px solid rgba(0,0,0,0.13)" }} />
|
||||
<hr style={{ margin: "14px 0", border: 0, borderTop: "1px solid rgba(0,0,0,0.13)" }} />
|
||||
{data.budgets.map((b) => (
|
||||
<div key={b.name} style={{ display: "flex", justifyContent: "space-between", padding: "4px 0" }}>
|
||||
<span style={{ fontSize: 12 }}>{b.name}</span>
|
||||
<div key={b.name} style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", padding: "6px 0" }}>
|
||||
<span style={{ fontSize: 12, opacity: 0.85 }}>{b.name}</span>
|
||||
<span style={{ fontSize: 14, fontWeight: 700 }}>
|
||||
<span style={{ color: "var(--mercury-warning-chip)" }}>
|
||||
<Amount value={b.spent} loading={loading} width="50px" />
|
||||
|
|
@ -200,24 +258,37 @@ export function DashboardView() {
|
|||
|
||||
{/* Recent income / expense ledgers. */}
|
||||
<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>
|
||||
<span style={{ fontWeight: 700, fontSize: 14, color: "var(--seed-color-fg-placeholder)" }}>{s.income}</span>
|
||||
<EmptyState icon="receipt" title={s.noSpendTitle} hint={s.noSpendHint} compact />
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Card style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<SectionHeader title={s.income} />
|
||||
{data.income.map((row, i) => (
|
||||
<div key={i} style={{ display: "flex", justifyContent: "space-between", padding: "6px 0" }}>
|
||||
<span style={{ color: "var(--seed-color-fg-neutral)" }}>{row.title}</span>
|
||||
<span style={{ color: "var(--seed-color-fg-placeholder)" }}>{row.date}</span>
|
||||
</div>
|
||||
<LedgerEntryRow key={i} row={row} income />
|
||||
))}
|
||||
</Card>
|
||||
<Card>
|
||||
<span style={{ fontWeight: 700, fontSize: 14, color: "var(--seed-color-fg-placeholder)" }}>{s.expense}</span>
|
||||
<Card style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<SectionHeader title={s.expense} />
|
||||
{data.expense.map((row, i) => (
|
||||
<div key={i} style={{ display: "flex", justifyContent: "space-between", padding: "6px 0" }}>
|
||||
<span style={{ color: "var(--seed-color-fg-neutral)" }}>{row.title}</span>
|
||||
<span style={{ color: "var(--seed-color-fg-placeholder)" }}>{row.date}</span>
|
||||
</div>
|
||||
<LedgerEntryRow key={i} row={row} income={false} />
|
||||
))}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ describe("buildHome", () => {
|
|||
},
|
||||
})!;
|
||||
expect(h.budgets).toHaveLength(3);
|
||||
expect(h.budgets.map((b) => b.name)).toEqual(["Хоол", "Coffee", "Тээвэр"]);
|
||||
expect(h.budgets.map((b) => b.name)).toEqual(["Хоол", "Кофе", "Тээвэр"]);
|
||||
expect(h.budgets.every((b) => b.range.includes("₮"))).toBe(true);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ export const sample: HomeData = {
|
|||
],
|
||||
monthlyExpense,
|
||||
availableBudget,
|
||||
income: [{ title: "1,345,634₮", date: "04.01" }],
|
||||
expense: [{ title: "худалдан авалт", date: "04.01" }],
|
||||
income: [{ title: "1,345,634₮", date: "Цалин" }],
|
||||
expense: [{ title: "52,000₮", date: "Худалдан авалт" }],
|
||||
safeToSpend: Math.max(0, availableBudget - monthlyExpense),
|
||||
overspent: monthlyExpense > availableBudget,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,4 +13,6 @@ export const homeStrings = {
|
|||
income: "Орлого",
|
||||
expense: "Зарлага",
|
||||
todaySpent: "Өнөөдөр зарцуулсан",
|
||||
noSpendTitle: "Энэ сар хараахан зарлага алга",
|
||||
noSpendHint: "Гүйлгээ хийгдмэгц энд харагдана.",
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -21,8 +21,9 @@ import {
|
|||
ContentDialogFooter,
|
||||
Skeleton,
|
||||
} from "@seed-design/react";
|
||||
import { Card, MercuryButton, HideAmountsToggle, NameEdit } from "@/ds";
|
||||
import { Card, MercuryButton, HideAmountsToggle, NameEdit, IconChip, SectionHeader, EmptyState } from "@/ds";
|
||||
import { categoryStyle } from "@/ds/categoryStyle";
|
||||
import { Icon } from "@/ds/icons";
|
||||
import { tugrik, tugrikShort } from "@/ds/money";
|
||||
import { useBudget, useNetWorth } from "@/api/hooks/reads";
|
||||
import { usePutBudget, useSavingsGoalMutations } from "@/api/hooks/mutations";
|
||||
|
|
@ -34,6 +35,12 @@ type CategoryLimit = Budget["categories"][number];
|
|||
type HorizonReport = Budget["report"]["day"];
|
||||
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 {
|
||||
return parseFloat(v ?? "0") || 0;
|
||||
}
|
||||
|
|
@ -42,6 +49,15 @@ function onlyDigits(v: string): string {
|
|||
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> = {
|
||||
day: s.horizon.day,
|
||||
week: s.horizon.week,
|
||||
|
|
@ -244,10 +260,18 @@ function PlannedIncomeCard({
|
|||
className="flex w-full flex-col items-start gap-3 text-left"
|
||||
style={{ background: "none", border: "none", padding: 0, cursor: "pointer" }}
|
||||
>
|
||||
<span style={{ fontWeight: 700, fontSize: 14 }}>{s.plannedIncome.label}</span>
|
||||
<span style={{ fontWeight: 700, fontSize: 26 }}>+{tugrikShort(plannedIncome)}</span>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span style={{ fontWeight: 700, fontSize: 13, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
|
||||
{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 && (
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<div
|
||||
className="flex w-full flex-col gap-2"
|
||||
style={{ borderTop: "1px solid var(--seed-color-border-default, #eee)", paddingTop: 10 }}
|
||||
>
|
||||
{loanObligations > 0 && (
|
||||
<BreakdownRow label={s.plannedIncome.loanObligations} value={`−${tugrikShort(loanObligations)}`} />
|
||||
)}
|
||||
|
|
@ -292,6 +316,7 @@ function OverallCard({
|
|||
const [editing, setEditing] = React.useState(false);
|
||||
const [draft, setDraft] = React.useState("");
|
||||
const percent = limit > 0 ? Math.min(100, (spent / limit) * 100) : 0;
|
||||
const limitSet = limit > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -334,19 +359,23 @@ function OverallCard({
|
|||
setEditing(true);
|
||||
}}
|
||||
className="flex w-full items-center gap-4 text-left"
|
||||
style={{ background: "none", border: "none", padding: 0, cursor: "pointer", color: "#fff" }}
|
||||
style={{ background: "none", border: "none", padding: 0, cursor: "pointer", color: "#fff", justifyContent: "space-between" }}
|
||||
>
|
||||
<div className="flex items-center gap-4" style={{ minWidth: 0 }}>
|
||||
<ProgressCircleRoot value={percent} maxValue={100} style={{ width: 49, height: 49, flexShrink: 0 }}>
|
||||
<ProgressCircleTrack style={{ opacity: 0.3 }} />
|
||||
<ProgressCircleRange />
|
||||
</ProgressCircleRoot>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span style={{ fontSize: 12 }}>{OVERALL_LABEL[horizon]}</span>
|
||||
<span style={{ fontSize: 12, opacity: 0.85 }}>{OVERALL_LABEL[horizon]}</span>
|
||||
<span style={{ fontSize: 26, fontWeight: 700 }}>
|
||||
{tugrikShort(spent)}
|
||||
{limit > 0 ? ` / ${tugrikShort(limit)}` : " / —"}
|
||||
{limitSet ? ` / ${tugrikShort(limit)}` : ""}
|
||||
</span>
|
||||
{!limitSet && <span style={{ fontSize: 12, opacity: 0.75 }}>{s.overall.unsetHint}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<Icon name="chevron-right" size={14} style={{ opacity: 0.8, flexShrink: 0 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -376,6 +405,8 @@ function CategoryList({
|
|||
const [newName, setNewName] = React.useState("");
|
||||
const [confirmRemove, setConfirmRemove] = React.useState<string | null>(null);
|
||||
|
||||
const empty = React.useMemo(() => splitEmptyCopy(s.categories.empty), []);
|
||||
|
||||
function openEditor(name: string) {
|
||||
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 : "" });
|
||||
|
|
@ -384,12 +415,14 @@ function CategoryList({
|
|||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 style={{ fontWeight: 700, fontSize: 14, color: "var(--seed-color-fg-placeholder)" }}>{s.categories.title}</h2>
|
||||
<MercuryButton variant="ghost" onClick={() => { setNewName(""); setAdding(true); }}>
|
||||
{s.categories.add}
|
||||
</MercuryButton>
|
||||
</div>
|
||||
<SectionHeader
|
||||
title={s.categories.title}
|
||||
onAction={() => {
|
||||
setNewName("");
|
||||
setAdding(true);
|
||||
}}
|
||||
actionLabel={s.categories.add}
|
||||
/>
|
||||
|
||||
{adding && (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
|
|
@ -424,9 +457,7 @@ function CategoryList({
|
|||
)}
|
||||
|
||||
{!loading && rows.length === 0 && (
|
||||
<p className="mt-3" style={{ color: "var(--seed-color-fg-placeholder)" }}>
|
||||
{s.categories.empty}
|
||||
</p>
|
||||
<EmptyState icon="layers" title={empty.title} hint={empty.hint} compact />
|
||||
)}
|
||||
|
||||
{!loading && rows.length > 0 && (
|
||||
|
|
@ -436,6 +467,7 @@ function CategoryList({
|
|||
const limit = dec(row.limit);
|
||||
const over = limit > 0 && spent > limit;
|
||||
const percent = limit > 0 ? Math.min(100, (spent / limit) * 100) : 0;
|
||||
const style = categoryStyle(row.category);
|
||||
|
||||
if (editingName === row.category) {
|
||||
return (
|
||||
|
|
@ -471,34 +503,59 @@ function CategoryList({
|
|||
}
|
||||
|
||||
return (
|
||||
<li key={row.category} className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<li key={row.category} className="flex items-center gap-3">
|
||||
<Link
|
||||
href={`/planner/${encodeURIComponent(row.category)}`}
|
||||
className="flex flex-1 flex-col gap-2"
|
||||
style={{ color: "inherit", textDecoration: "none" }}
|
||||
className="flex flex-1 items-center gap-3"
|
||||
style={{ color: "inherit", textDecoration: "none", minWidth: 0 }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 8, fontWeight: 700 }}>
|
||||
<span aria-hidden>{categoryStyle(row.category).emoji}</span>
|
||||
{categoryStyle(row.category).name}
|
||||
<IconChip icon={style.icon} tint={style.tint} fg={style.fg} size={36} />
|
||||
<div className="flex flex-1 flex-col gap-2" style={{ minWidth: 0 }}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
fontSize: 14,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{style.name}
|
||||
</span>
|
||||
<span style={{ fontWeight: 700, color: over ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-placeholder)" }}>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
fontSize: 13,
|
||||
flexShrink: 0,
|
||||
color: over ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-placeholder)",
|
||||
}}
|
||||
>
|
||||
{tugrik(spent)}
|
||||
{limit > 0 ? ` / ${tugrik(limit)}` : " / —"}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar percent={percent} tone={over ? "critical" : "brand"} />
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEditor(row.category)}
|
||||
aria-label={s.categories.editTitle(row.category, HORIZON_LABEL[horizon])}
|
||||
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--seed-color-fg-placeholder)" }}
|
||||
style={{
|
||||
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>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
|
@ -573,20 +630,13 @@ function SavingsSection({
|
|||
const [editing, setEditing] = React.useState<{ mode: "add" | "edit"; goal?: SavingsGoal } | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = React.useState<string | null>(null);
|
||||
|
||||
const empty = React.useMemo(() => splitEmptyCopy(s.savings.empty), []);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<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>
|
||||
<SectionHeader title={s.savings.title} onAction={() => setEditing({ mode: "add" })} actionLabel={s.savings.add} />
|
||||
|
||||
{goals.length === 0 && !editing && (
|
||||
<p className="mt-3" style={{ color: "var(--seed-color-fg-placeholder)" }}>
|
||||
{s.savings.empty}
|
||||
</p>
|
||||
)}
|
||||
{goals.length === 0 && !editing && <EmptyState icon="hand-coins" title={empty.title} hint={empty.hint} compact />}
|
||||
|
||||
{goals.length > 0 && (
|
||||
<ul className="mt-4 flex flex-col gap-4">
|
||||
|
|
@ -602,17 +652,36 @@ function SavingsSection({
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing({ mode: "edit", goal })}
|
||||
className="flex w-full flex-col gap-2 text-left"
|
||||
className="flex w-full items-center gap-3 text-left"
|
||||
style={{ background: "none", border: "none", padding: 0, cursor: "pointer" }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span style={{ fontWeight: 700 }}>{goal.name}</span>
|
||||
<span style={{ fontWeight: 700, color: done ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-placeholder)" }}>
|
||||
<IconChip icon="hand-coins" tint={SAVINGS_TINT} fg={SAVINGS_FG} size={36} />
|
||||
<div className="flex flex-1 flex-col gap-2" style={{ minWidth: 0 }}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<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)}
|
||||
{target > 0 ? ` / ${tugrik(target)}` : " / —"}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar percent={percent} tone={done ? "brand" : "brand"} />
|
||||
<ProgressBar percent={percent} tone="brand" />
|
||||
<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>
|
||||
{monthly > 0 && <span>{s.savings.perMonth} {tugrik(monthly)}</span>}
|
||||
|
|
@ -622,6 +691,7 @@ function SavingsSection({
|
|||
{s.savings.remaining} {tugrik(remaining)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export const plannerStrings = {
|
|||
month: "Энэ сарын лимит",
|
||||
},
|
||||
editTitle: (horizonLabel: string) => `${horizonLabel} — нийт лимит`,
|
||||
unsetHint: "Лимит тохируулаагүй",
|
||||
},
|
||||
categories: {
|
||||
title: "Ангиллын лимит",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import {
|
|||
Skeleton,
|
||||
} from "@seed-design/react";
|
||||
import { MercuryButton } from "@/ds/MercuryButton";
|
||||
import { EmptyState, Icon, IconChip } from "@/ds";
|
||||
import { categoryStyle } from "@/ds/categoryStyle";
|
||||
import { useCategories } from "@/api/hooks/reads";
|
||||
import { useCategoryMutations } from "@/api/hooks/mutations";
|
||||
import type { Category } from "@/api/schemas";
|
||||
|
|
@ -105,9 +107,9 @@ export function CategoriesManager({ onBack }: CategoriesManagerProps) {
|
|||
type="button"
|
||||
onClick={onBack}
|
||||
aria-label={profileStrings.categories.back}
|
||||
style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
|
||||
style={{ display: "grid", placeItems: "center", background: "none", border: "none", cursor: "pointer", color: "var(--seed-color-fg-neutral)" }}
|
||||
>
|
||||
←
|
||||
<Icon name="chevron-left" size={22} />
|
||||
</button>
|
||||
)}
|
||||
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0, flex: 1 }}>{profileStrings.categories.title}</h1>
|
||||
|
|
@ -124,7 +126,7 @@ export function CategoriesManager({ onBack }: CategoriesManagerProps) {
|
|||
)}
|
||||
|
||||
{!isLoading && groups.length === 0 && (
|
||||
<p style={{ fontSize: 14, color: "var(--seed-color-fg-muted, #6b7280)" }}>{profileStrings.categories.empty}</p>
|
||||
<EmptyState icon="list" title={profileStrings.categories.empty} />
|
||||
)}
|
||||
|
||||
{groups.map((group) => (
|
||||
|
|
@ -132,6 +134,7 @@ export function CategoriesManager({ onBack }: CategoriesManagerProps) {
|
|||
<CategoryRow
|
||||
icon={group.icon}
|
||||
name={group.name}
|
||||
kind="expense"
|
||||
heading
|
||||
onEdit={() => setSheet({ mode: "edit", category: { name: group.name, kind: "expense", depth: 0, icon: group.icon } })}
|
||||
onDelete={() => setDeleteTarget(group.name)}
|
||||
|
|
@ -204,20 +207,23 @@ export function CategoriesManager({ onBack }: CategoriesManagerProps) {
|
|||
function CategoryRow({
|
||||
icon,
|
||||
name,
|
||||
kind,
|
||||
heading,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
icon?: string | null;
|
||||
name: string;
|
||||
kind?: string;
|
||||
heading?: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const style = categoryStyle(name, kind === "income");
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span aria-hidden style={{ fontSize: heading ? 20 : 16 }}>{icon || "🏷️"}</span>
|
||||
<span style={{ fontSize: heading ? 16 : 14, fontWeight: heading ? 600 : 400, flex: 1 }}>{name}</span>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<IconChip icon={style.icon} tint={style.tint} fg={style.fg} size={heading ? 40 : 32} />
|
||||
<span style={{ fontSize: heading ? 16 : 14, fontWeight: heading ? 700 : 500, flex: 1 }}>{style.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
|
|
@ -247,24 +253,26 @@ function CategoryChip({
|
|||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const style = categoryStyle(category.name, category.kind === "income");
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "6px 10px",
|
||||
padding: "6px 10px 6px 6px",
|
||||
borderRadius: 999,
|
||||
background: "var(--seed-color-bg-layer-default)",
|
||||
background: style.tint,
|
||||
color: style.fg,
|
||||
}}
|
||||
>
|
||||
<span aria-hidden>{category.icon || "🏷️"}</span>
|
||||
<span style={{ fontSize: 13 }}>{category.name}</span>
|
||||
<IconChip icon={style.icon} tint="transparent" fg={style.fg} size={22} />
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>{style.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
aria-label={`${profileStrings.categories.editAction}: ${category.name}`}
|
||||
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)" }}
|
||||
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 12, color: "inherit", opacity: 0.7 }}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
|
|
@ -315,9 +323,9 @@ function CategoryEditor({
|
|||
type="button"
|
||||
onClick={onCancel}
|
||||
aria-label={profileStrings.categories.cancel}
|
||||
style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
|
||||
style={{ display: "grid", placeItems: "center", background: "none", border: "none", cursor: "pointer", color: "var(--seed-color-fg-neutral)" }}
|
||||
>
|
||||
←
|
||||
<Icon name="chevron-left" size={22} />
|
||||
</button>
|
||||
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>
|
||||
{editing ? profileStrings.categories.edit : profileStrings.categories.add}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useConnections } from "@/api/hooks/reads";
|
||||
import { Skeleton } from "@seed-design/react";
|
||||
import { EmptyState, IconChip, SectionHeader } from "@/ds";
|
||||
import { profileStrings } from "./strings";
|
||||
|
||||
/**
|
||||
|
|
@ -11,31 +12,28 @@ import { profileStrings } from "./strings";
|
|||
*/
|
||||
export function ConnectedBanks() {
|
||||
const { data: connections, isLoading } = useConnections();
|
||||
const empty = !isLoading && (!connections || connections.length === 0);
|
||||
|
||||
return (
|
||||
<section aria-labelledby="connected-banks-heading">
|
||||
<h2
|
||||
id="connected-banks-heading"
|
||||
style={{ fontSize: 12, color: "var(--seed-color-fg-subtle, #6b7280)", margin: "0 0 12px" }}
|
||||
>
|
||||
{profileStrings.banks.title}
|
||||
</h2>
|
||||
<div id="connected-banks-heading">
|
||||
<SectionHeader title={profileStrings.banks.title} />
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 12 }}>
|
||||
<Skeleton height="44px" />
|
||||
<Skeleton height="44px" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && (!connections || connections.length === 0) && (
|
||||
<p style={{ fontSize: 14, color: "var(--seed-color-fg-muted, #6b7280)" }}>
|
||||
{profileStrings.banks.empty}
|
||||
</p>
|
||||
{empty && (
|
||||
<EmptyState icon="bank" title={profileStrings.banks.empty} hint={profileStrings.banks.manageNote} compact />
|
||||
)}
|
||||
|
||||
{!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) => (
|
||||
<li
|
||||
key={c.bank}
|
||||
|
|
@ -43,14 +41,26 @@ export function ConnectedBanks() {
|
|||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
padding: "10px 0",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{c.bank}</span>
|
||||
<span style={{ fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)" }}>
|
||||
{c.username}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
|
||||
<IconChip icon="bank" tint="var(--seed-color-bg-neutral-subtle, #eef0f2)" fg="var(--seed-color-fg-neutral)" />
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{c.bank}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)" }}>{c.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
{c.courierManaged && (
|
||||
<span
|
||||
|
|
@ -58,6 +68,7 @@ export function ConnectedBanks() {
|
|||
fontSize: 11,
|
||||
padding: "4px 8px",
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
background: "var(--seed-color-bg-layer-default)",
|
||||
color: "var(--seed-color-fg-muted, #6b7280)",
|
||||
}}
|
||||
|
|
@ -68,11 +79,12 @@ export function ConnectedBanks() {
|
|||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<p style={{ fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)", marginTop: 8 }}>
|
||||
{profileStrings.banks.manageNote}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, HideAmountsToggle, MercuryButton } from "@/ds";
|
||||
import { Card, HideAmountsToggle, Icon, IconChip, MercuryButton } from "@/ds";
|
||||
import type { IconName } from "@/ds";
|
||||
import { useMe, useSettings } from "@/api/hooks/reads";
|
||||
import { profileStrings } from "./strings";
|
||||
import { SettingsForm } from "./SettingsForm";
|
||||
|
|
@ -24,6 +25,7 @@ export function ProfileView() {
|
|||
|
||||
const emailLocal = me?.email ? me.email.split("@")[0] : "";
|
||||
const displayName = settings?.holderName || emailLocal || me?.email || "";
|
||||
const initial = (displayName || me?.email || "?").trim().charAt(0).toUpperCase() || "?";
|
||||
|
||||
async function handleLogout() {
|
||||
setLoggingOut(true);
|
||||
|
|
@ -45,23 +47,57 @@ export function ProfileView() {
|
|||
<HideAmountsToggle label={profileStrings.display.hideAmounts} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{displayName}</span>
|
||||
<Card style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<span
|
||||
aria-hidden
|
||||
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 && (
|
||||
<span style={{ fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)" }}>
|
||||
{profileStrings.account.handlePrefix}
|
||||
{emailLocal}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--seed-color-fg-muted, #6b7280)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{me.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<nav style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<MenuRow label={profileStrings.menu.settings} onClick={() => setShowSettings(true)} />
|
||||
<MenuLink label={profileStrings.menu.categories} href="/profile/categories" />
|
||||
<MenuLink label={profileStrings.menu.subscriptions} href="/profile/subscriptions" />
|
||||
<Card style={{ display: "flex", flexDirection: "column" }}>
|
||||
<nav style={{ display: "flex", flexDirection: "column" }}>
|
||||
<MenuRow icon="wrench" label={profileStrings.menu.settings} onClick={() => setShowSettings(true)} />
|
||||
<MenuLink icon="list" label={profileStrings.menu.categories} href="/profile/categories" />
|
||||
<MenuLink icon="sparkles" label={profileStrings.menu.subscriptions} href="/profile/subscriptions" />
|
||||
</nav>
|
||||
</Card>
|
||||
|
||||
|
|
@ -76,49 +112,46 @@ export function ProfileView() {
|
|||
);
|
||||
}
|
||||
|
||||
function MenuRow({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
const menuRowStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "12px 0",
|
||||
gap: 12,
|
||||
width: "100%",
|
||||
padding: "10px 0",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
font: "inherit",
|
||||
color: "inherit",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 14 }}>{label}</span>
|
||||
<span aria-hidden style={{ color: "var(--seed-color-fg-placeholder, #9ca3af)" }}>
|
||||
›
|
||||
</span>
|
||||
textDecoration: "none",
|
||||
};
|
||||
|
||||
function MenuRowContent({ icon, label }: { icon: IconName; label: string }) {
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuLink({ label, href }: { label: string; href: string }) {
|
||||
function MenuLink({ icon, label, href }: { icon: IconName; label: string; href: string }) {
|
||||
return (
|
||||
<Link
|
||||
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 href={href} style={menuRowStyle}>
|
||||
<MenuRowContent icon={icon} label={label} />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
Skeleton,
|
||||
} from "@seed-design/react";
|
||||
import { MercuryButton } from "@/ds/MercuryButton";
|
||||
import { Icon } from "@/ds";
|
||||
import { useSettings } from "@/api/hooks/reads";
|
||||
import { useSaveSettings } from "@/api/hooks/mutations";
|
||||
import type { Settings } from "@/api/schemas";
|
||||
|
|
@ -126,9 +127,9 @@ export function SettingsForm({ onBack }: SettingsFormProps) {
|
|||
type="button"
|
||||
onClick={onBack}
|
||||
aria-label={profileStrings.settings.back}
|
||||
style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
|
||||
style={{ display: "grid", placeItems: "center", background: "none", border: "none", cursor: "pointer", color: "var(--seed-color-fg-neutral)" }}
|
||||
>
|
||||
←
|
||||
<Icon name="chevron-left" size={22} />
|
||||
</button>
|
||||
)}
|
||||
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>{profileStrings.settings.title}</h1>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import * as React from "react";
|
||||
import { TextFieldRoot, TextFieldInput, Skeleton } from "@seed-design/react";
|
||||
import { MercuryButton } from "@/ds/MercuryButton";
|
||||
import { EmptyState, Icon, IconChip } from "@/ds";
|
||||
import { tugrik } from "@/ds/money";
|
||||
import { useSubscriptions } from "@/api/hooks/reads";
|
||||
import { useSubscriptionMutations } from "@/api/hooks/mutations";
|
||||
|
|
@ -58,9 +59,9 @@ export function SubscriptionsView({ onBack }: SubscriptionsViewProps) {
|
|||
type="button"
|
||||
onClick={onBack}
|
||||
aria-label={profileStrings.subscriptions.back}
|
||||
style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
|
||||
style={{ display: "grid", placeItems: "center", background: "none", border: "none", cursor: "pointer", color: "var(--seed-color-fg-neutral)" }}
|
||||
>
|
||||
←
|
||||
<Icon name="chevron-left" size={22} />
|
||||
</button>
|
||||
)}
|
||||
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0, flex: 1 }}>{profileStrings.subscriptions.title}</h1>
|
||||
|
|
@ -77,7 +78,7 @@ export function SubscriptionsView({ onBack }: SubscriptionsViewProps) {
|
|||
)}
|
||||
|
||||
{!isLoading && all.length === 0 && (
|
||||
<p style={{ fontSize: 14, color: "var(--seed-color-fg-muted, #6b7280)" }}>{profileStrings.subscriptions.empty}</p>
|
||||
<EmptyState icon="sparkles" title={profileStrings.subscriptions.empty} />
|
||||
)}
|
||||
|
||||
{!isLoading && detected.length > 0 && (
|
||||
|
|
@ -136,14 +137,27 @@ function SubscriptionRow({
|
|||
destructive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "8px 0" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{sub.label}</span>
|
||||
<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="sparkles" tint="var(--seed-color-bg-neutral-subtle, #eef0f2)" fg="var(--seed-color-fg-neutral)" size={36} />
|
||||
<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)" }}>
|
||||
{tugrik(sub.monthly)} / {sub.cadence}
|
||||
{sub.nextDue ? ` · ${sub.nextDue}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAction}
|
||||
|
|
@ -204,9 +218,9 @@ function ManualSubscriptionForm({
|
|||
type="button"
|
||||
onClick={onCancel}
|
||||
aria-label={profileStrings.subscriptions.cancel}
|
||||
style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
|
||||
style={{ display: "grid", placeItems: "center", background: "none", border: "none", cursor: "pointer", color: "var(--seed-color-fg-neutral)" }}
|
||||
>
|
||||
←
|
||||
<Icon name="chevron-left" size={22} />
|
||||
</button>
|
||||
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>{profileStrings.subscriptions.addManual}</h1>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue