feat(web): localize categories to Mongolian + icon/color chips; fix transaction date sort; add deploy Dockerfile
This commit is contained in:
parent
6913c1e5ee
commit
06e74b1668
8 changed files with 148 additions and 11 deletions
10
.dockerignore
Normal file
10
.dockerignore
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.env*
|
||||||
|
!.env.local.example
|
||||||
|
npm-debug.log*
|
||||||
|
.DS_Store
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
e2e
|
||||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Mercury web — production image. Build with bun, run the Next standalone
|
||||||
|
# server bundle under Node. FMS_API_URL is injected at runtime (server-side
|
||||||
|
# proxy target), never baked into the build.
|
||||||
|
FROM oven/bun:1 AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json bun.lock ./
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
COPY . .
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
# Next standalone output: minimal server + traced node_modules, plus static assets.
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {};
|
const nextConfig: NextConfig = {
|
||||||
|
// Emit a self-contained server bundle (.next/standalone) for a lean
|
||||||
|
// production Docker image (`node server.js`).
|
||||||
|
output: "standalone",
|
||||||
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
|
||||||
70
src/ds/categoryStyle.ts
Normal file
70
src/ds/categoryStyle.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
export interface CategoryStyle {
|
||||||
|
name: string; // Mongolian display name
|
||||||
|
emoji: string;
|
||||||
|
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 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"),
|
||||||
|
// Transport
|
||||||
|
transport: s("Тээвэр", "🚗", "#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"),
|
||||||
|
// Debt & loans
|
||||||
|
"debt & loans": s("Зээл & өр", "🛡️", "#F6E3E3", "#A83232"),
|
||||||
|
debt: s("Өр", "🛡️", "#F6E3E3", "#A83232"),
|
||||||
|
loan: s("Зээл", "💳", "#E7E3F6", "#5B44B0"),
|
||||||
|
// Shopping
|
||||||
|
shopping: s("Дэлгүүр", "🛍️", "#F5E7F0", "#9A2E77"),
|
||||||
|
electronics: s("Цахилгаан бараа", "🖥️", "#E1E5EA", "#42505F"),
|
||||||
|
// Entertainment
|
||||||
|
entertainment: s("Зугаа цэнгэл", "🎮", "#E7E3F6", "#5B44B0"),
|
||||||
|
anime: s("Аниме", "🎮", "#E7E3F6", "#5B44B0"),
|
||||||
|
spotify: s("Spotify", "🎵", "#E3F0D8", "#3F7A1E"),
|
||||||
|
// Health & care
|
||||||
|
fitness: s("Фитнес", "🏋️", "#DDEAF6", "#215C9A"),
|
||||||
|
haircut: s("Үс засалт", "✂️", "#F5E7F0", "#9A2E77"),
|
||||||
|
// Money movement / income
|
||||||
|
transfers: s("Шилжүүлэг", "🔁", "#E1E5EA", "#42505F"),
|
||||||
|
salary: s("Цалин", "💰", "#E3F0D8", "#3F7A1E"),
|
||||||
|
// Synthetic budget line for the loan repayment.
|
||||||
|
"зээлийн төлбөр": s("Зээлийн төлбөр", "🛡️", "#F6E3E3", "#A83232"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const INCOME_FALLBACK = s("Орлого", "💰", "#E3F0D8", "#3F7A1E");
|
||||||
|
const OTHER_FALLBACK = s("Бусад", "🧾", "#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. */
|
||||||
|
export function categoryStyle(category: string | null | undefined, income = false): CategoryStyle {
|
||||||
|
const key = (category ?? "").trim().toLowerCase();
|
||||||
|
const hit = TABLE[key];
|
||||||
|
if (hit) return hit;
|
||||||
|
if (income) return INCOME_FALLBACK;
|
||||||
|
return key ? { ...OTHER_FALLBACK, name: category as string } : OTHER_FALLBACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Just the Mongolian display name. */
|
||||||
|
export function categoryName(category: string | null | undefined, income = false): string {
|
||||||
|
return categoryStyle(category, income).name;
|
||||||
|
}
|
||||||
|
|
@ -15,3 +15,4 @@ export type { NameEditProps } from "./NameEdit";
|
||||||
|
|
||||||
export { Card } from "./Card";
|
export { Card } from "./Card";
|
||||||
export type { CardProps } from "./Card";
|
export type { CardProps } from "./Card";
|
||||||
|
export * from "./categoryStyle";
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Skeleton } from "@seed-design/react";
|
||||||
import { useTransactions } from "@/api/hooks/reads";
|
import { useTransactions } from "@/api/hooks/reads";
|
||||||
import type { Txn } from "@/api/schemas";
|
import type { Txn } from "@/api/schemas";
|
||||||
import { Card, HideAmountsToggle } from "@/ds";
|
import { Card, HideAmountsToggle } from "@/ds";
|
||||||
|
import { categoryStyle } from "@/ds/categoryStyle";
|
||||||
import { MASKED, tugrikRaw } from "@/ds/money";
|
import { MASKED, tugrikRaw } from "@/ds/money";
|
||||||
import { accountingStrings as s } from "./strings";
|
import { accountingStrings as s } from "./strings";
|
||||||
import { txnRouteId } from "./txnRoute";
|
import { txnRouteId } from "./txnRoute";
|
||||||
|
|
@ -74,6 +75,7 @@ function TxnRow({ txn, hidden }: { txn: Txn; hidden: boolean }) {
|
||||||
: income
|
: income
|
||||||
? "var(--mercury-success, #1e9e6b)"
|
? "var(--mercury-success, #1e9e6b)"
|
||||||
: "var(--mercury-critical, #e5484d)";
|
: "var(--mercury-critical, #e5484d)";
|
||||||
|
const cat = categoryStyle(txn.category, income);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -83,18 +85,35 @@ function TxnRow({ txn, hidden }: { txn: Txn; hidden: boolean }) {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
gap: 12,
|
gap: 12,
|
||||||
padding: "12px 0",
|
padding: "10px 0",
|
||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
color: "inherit",
|
color: "inherit",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 4, minWidth: 0 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
|
||||||
<span style={{ fontSize: 15, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
<span
|
||||||
{txn.title || txn.category}
|
aria-hidden
|
||||||
</span>
|
style={{
|
||||||
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
|
width: 40,
|
||||||
{isTransfer ? `${txn.category} · ${s.list.transferTag}` : txn.category}
|
height: 40,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: 13,
|
||||||
|
display: "grid",
|
||||||
|
placeItems: "center",
|
||||||
|
fontSize: 19,
|
||||||
|
background: isTransfer ? "var(--seed-color-bg-neutral-subtle, #eef0f2)" : cat.tint,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isTransfer ? "🔁" : cat.emoji}
|
||||||
</span>
|
</span>
|
||||||
|
<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, color: cat.fg }}>
|
||||||
|
{isTransfer ? `${cat.name} · ${s.list.transferTag}` : cat.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontSize: 16, fontWeight: 700, color: amountColor, flexShrink: 0 }}>{amountText}</span>
|
<span style={{ fontSize: 16, fontWeight: 700, color: amountColor, flexShrink: 0 }}>{amountText}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -125,7 +144,14 @@ export function TransactionList() {
|
||||||
return { income, expense };
|
return { income, expense };
|
||||||
}, [all]);
|
}, [all]);
|
||||||
|
|
||||||
const visible = useMemo(() => all.filter((txn) => txn.salary !== true), [all]);
|
const visible = useMemo(
|
||||||
|
() =>
|
||||||
|
all
|
||||||
|
.filter((txn) => txn.salary !== true)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()),
|
||||||
|
[all],
|
||||||
|
);
|
||||||
const groups = useMemo(() => groupByDay(visible), [visible]);
|
const groups = useMemo(() => groupByDay(visible), [visible]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { NetWorth } from "../../api/schemas/networth";
|
||||||
import type { Analyze } from "../../api/schemas/analyze";
|
import type { Analyze } from "../../api/schemas/analyze";
|
||||||
import type { Budget } from "../../api/schemas/budget";
|
import type { Budget } from "../../api/schemas/budget";
|
||||||
import { tugrik, tugrikRaw } from "../../ds/money";
|
import { tugrik, tugrikRaw } from "../../ds/money";
|
||||||
|
import { categoryName } from "../../ds/categoryStyle";
|
||||||
import { sample } from "./sample";
|
import { sample } from "./sample";
|
||||||
|
|
||||||
/** Parse a backend decimal string ("900000") into a number. Mirrors
|
/** Parse a backend decimal string ("900000") into a number. Mirrors
|
||||||
|
|
@ -100,7 +101,7 @@ export function buildHome({ netWorth, month, today, budget }: BuildHomeInput): H
|
||||||
.sort((a, b) => dec(b.limit) - dec(a.limit))
|
.sort((a, b) => dec(b.limit) - dec(a.limit))
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
name: row.category,
|
name: categoryName(row.category),
|
||||||
spent: dec(row.spent),
|
spent: dec(row.spent),
|
||||||
range: tugrikRaw(dec(row.limit)),
|
range: tugrikRaw(dec(row.limit)),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
Skeleton,
|
Skeleton,
|
||||||
} from "@seed-design/react";
|
} from "@seed-design/react";
|
||||||
import { Card, MercuryButton, HideAmountsToggle, NameEdit } from "@/ds";
|
import { Card, MercuryButton, HideAmountsToggle, NameEdit } from "@/ds";
|
||||||
|
import { categoryStyle } from "@/ds/categoryStyle";
|
||||||
import { tugrik, tugrikShort } from "@/ds/money";
|
import { tugrik, tugrikShort } from "@/ds/money";
|
||||||
import { useBudget, useNetWorth } from "@/api/hooks/reads";
|
import { useBudget, useNetWorth } from "@/api/hooks/reads";
|
||||||
import { usePutBudget, useSavingsGoalMutations } from "@/api/hooks/mutations";
|
import { usePutBudget, useSavingsGoalMutations } from "@/api/hooks/mutations";
|
||||||
|
|
@ -478,7 +479,10 @@ function CategoryList({
|
||||||
style={{ color: "inherit", textDecoration: "none" }}
|
style={{ color: "inherit", textDecoration: "none" }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span style={{ fontWeight: 700 }}>{row.category}</span>
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 8, fontWeight: 700 }}>
|
||||||
|
<span aria-hidden>{categoryStyle(row.category).emoji}</span>
|
||||||
|
{categoryStyle(row.category).name}
|
||||||
|
</span>
|
||||||
<span style={{ fontWeight: 700, color: over ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-placeholder)" }}>
|
<span style={{ fontWeight: 700, color: over ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-placeholder)" }}>
|
||||||
{tugrik(spent)}
|
{tugrik(spent)}
|
||||||
{limit > 0 ? ` / ${tugrik(limit)}` : " / —"}
|
{limit > 0 ? ` / ${tugrik(limit)}` : " / —"}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue