feat(web): real inline-SVG icon set (replace emoji); txn detail loading skeleton

This commit is contained in:
Munkherdene 2026-08-22 22:06:13 +08:00
parent 06e74b1668
commit 66d82b8b68
6 changed files with 275 additions and 37 deletions

View file

@ -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
View 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>
);
}

View file

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

View file

@ -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 }}>

View file

@ -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" }}>

View file

@ -23,6 +23,7 @@ import {
} from "@seed-design/react";
import { Card, MercuryButton, HideAmountsToggle, NameEdit } 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";
@ -479,8 +480,21 @@ function CategoryList({
style={{ color: "inherit", textDecoration: "none" }}
>
<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>
<span style={{ display: "inline-flex", alignItems: "center", gap: 10, fontWeight: 700 }}>
<span
aria-hidden
style={{
width: 34,
height: 34,
borderRadius: 11,
display: "grid",
placeItems: "center",
color: categoryStyle(row.category).fg,
background: categoryStyle(row.category).tint,
}}
>
<Icon name={categoryStyle(row.category).icon} size={18} />
</span>
{categoryStyle(row.category).name}
</span>
<span style={{ fontWeight: 700, color: over ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-placeholder)" }}>