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";
|
||||
|
||||
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;
|
||||
|
|
|
|||
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 type { CardProps } from "./Card";
|
||||
export * from "./categoryStyle";
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Skeleton } from "@seed-design/react";
|
|||
import { useTransactions } from "@/api/hooks/reads";
|
||||
import type { Txn } from "@/api/schemas";
|
||||
import { Card, HideAmountsToggle } from "@/ds";
|
||||
import { categoryStyle } from "@/ds/categoryStyle";
|
||||
import { MASKED, tugrikRaw } from "@/ds/money";
|
||||
import { accountingStrings as s } from "./strings";
|
||||
import { txnRouteId } from "./txnRoute";
|
||||
|
|
@ -74,6 +75,7 @@ function TxnRow({ txn, hidden }: { txn: Txn; hidden: boolean }) {
|
|||
: income
|
||||
? "var(--mercury-success, #1e9e6b)"
|
||||
: "var(--mercury-critical, #e5484d)";
|
||||
const cat = categoryStyle(txn.category, income);
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
|
@ -83,18 +85,35 @@ function TxnRow({ txn, hidden }: { txn: Txn; hidden: boolean }) {
|
|||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
padding: "12px 0",
|
||||
padding: "10px 0",
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{txn.title || txn.category}
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
|
||||
{isTransfer ? `${txn.category} · ${s.list.transferTag}` : txn.category}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 40,
|
||||
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>
|
||||
<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>
|
||||
<span style={{ fontSize: 16, fontWeight: 700, color: amountColor, flexShrink: 0 }}>{amountText}</span>
|
||||
</Link>
|
||||
|
|
@ -125,7 +144,14 @@ export function TransactionList() {
|
|||
return { income, expense };
|
||||
}, [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]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { NetWorth } from "../../api/schemas/networth";
|
|||
import type { Analyze } from "../../api/schemas/analyze";
|
||||
import type { Budget } from "../../api/schemas/budget";
|
||||
import { tugrik, tugrikRaw } from "../../ds/money";
|
||||
import { categoryName } from "../../ds/categoryStyle";
|
||||
import { sample } from "./sample";
|
||||
|
||||
/** 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))
|
||||
.slice(0, 3)
|
||||
.map((row) => ({
|
||||
name: row.category,
|
||||
name: categoryName(row.category),
|
||||
spent: dec(row.spent),
|
||||
range: tugrikRaw(dec(row.limit)),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
Skeleton,
|
||||
} from "@seed-design/react";
|
||||
import { Card, MercuryButton, HideAmountsToggle, NameEdit } from "@/ds";
|
||||
import { categoryStyle } from "@/ds/categoryStyle";
|
||||
import { tugrik, tugrikShort } from "@/ds/money";
|
||||
import { useBudget, useNetWorth } from "@/api/hooks/reads";
|
||||
import { usePutBudget, useSavingsGoalMutations } from "@/api/hooks/mutations";
|
||||
|
|
@ -478,7 +479,10 @@ function CategoryList({
|
|||
style={{ color: "inherit", textDecoration: "none" }}
|
||||
>
|
||||
<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)" }}>
|
||||
{tugrik(spent)}
|
||||
{limit > 0 ? ` / ${tugrik(limit)}` : " / —"}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue