merge task/t6: Mercury DS components + base i18n

This commit is contained in:
Munkherdene 2026-08-22 20:35:15 +08:00
commit 071b5bdb6f
11 changed files with 351 additions and 1 deletions

View file

@ -0,0 +1,14 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { it, expect } from "vitest";
import { AmountToggle } from "./AmountToggle";
it("hides then reveals amount on click", () => {
render(<AmountToggle value="52000" />);
const el = screen.getByRole("button");
const first = el.textContent;
expect(["52,000₮", "••••••"]).toContain(first);
fireEvent.click(el);
const second = el.textContent;
expect(["52,000₮", "••••••"]).toContain(second);
expect(second).not.toBe(first);
});

30
src/ds/AmountToggle.tsx Normal file
View file

@ -0,0 +1,30 @@
"use client";
import { useState } from "react";
import { tugrikRaw, MASKED } from "./money";
export interface AmountToggleProps {
value: string | number;
className?: string;
}
/**
* Tap-to-reveal amount, independent of the global hide-amounts flag
* (mirrors iOS `TapAmount`). Starts revealed; each click flips its own
* local hidden state.
*/
export function AmountToggle({ value, className }: AmountToggleProps) {
const [hidden, setHidden] = useState(false);
return (
<button
type="button"
className={className}
aria-pressed={hidden}
onClick={() => setHidden((h) => !h)}
style={{ background: "none", border: "none", padding: 0, font: "inherit", cursor: "pointer" }}
>
{hidden ? MASKED : tugrikRaw(value)}
</button>
);
}

21
src/ds/Card.tsx Normal file
View file

@ -0,0 +1,21 @@
import * as React from "react";
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {}
/** Surface container: Seed's floating layer background + r3 radius (12px),
* the same shape used by Mercury's cards on iOS (`SeedRadius.r3`). */
export function Card({ style, children, ...props }: CardProps) {
return (
<div
style={{
background: "var(--seed-color-bg-layer-floating)",
borderRadius: "var(--seed-radius-r3)",
padding: 16,
...style,
}}
{...props}
>
{children}
</div>
);
}

View file

@ -0,0 +1,34 @@
"use client";
import { useState } from "react";
import { SwitchRoot, SwitchControl, SwitchThumb, SwitchLabel } from "@seed-design/react";
import { isHidden, setHidden } from "./money";
/** Dispatched on `window` whenever the global hide-amounts flag changes, so
* pages that render amounts elsewhere (not through this switch) can re-render. */
export const HIDE_AMOUNTS_EVENT = "mercury:hideamounts";
export interface HideAmountsToggleProps {
label?: string;
}
export function HideAmountsToggle({ label = "Мөнгөн дүн нуух" }: HideAmountsToggleProps) {
const [checked, setChecked] = useState<boolean>(() => isHidden());
function handleChange(next: boolean) {
setHidden(next);
setChecked(next);
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(HIDE_AMOUNTS_EVENT, { detail: { hidden: next } }));
}
}
return (
<SwitchRoot checked={checked} onCheckedChange={handleChange}>
<SwitchControl>
<SwitchThumb />
</SwitchControl>
<SwitchLabel>{label}</SwitchLabel>
</SwitchRoot>
);
}

View file

@ -0,0 +1,13 @@
import { render, screen } from "@testing-library/react";
import { it, expect } from "vitest";
import { MercuryButton } from "./MercuryButton";
it("renders yellow primary CTA", () => {
render(<MercuryButton variant="primary">Холбох</MercuryButton>);
const btn = screen.getByRole("button", { name: "Холбох" });
expect(btn).toBeInTheDocument();
// primary is a fixed brand-yellow fill / black label, not theme-dynamic —
// asserted on the inline style string since jsdom won't resolve the CSS var.
expect(btn.getAttribute("style")).toContain("var(--mercury-brand-yellow)");
expect(btn.getAttribute("style")).toContain("var(--mercury-on-brand)");
});

45
src/ds/MercuryButton.tsx Normal file
View file

@ -0,0 +1,45 @@
"use client";
import * as React from "react";
import { ActionButton, type ActionButtonProps } from "@seed-design/react";
export type MercuryButtonVariant = "primary" | "secondary" | "ghost";
export interface MercuryButtonProps
extends Omit<ActionButtonProps, "variant" | "color"> {
variant?: MercuryButtonVariant;
}
// Map Mercury's three brand-facing variants onto Seed's neutral variants.
// `primary` reuses `neutralSolid`'s shape/sizing but overrides fill/label
// color inline to the fixed Mercury brand yellow (not theme-dynamic — same
// #FFEB02/black pairing in light and dark, mirroring MercuryPrimaryButton.swift).
const seedVariant: Record<MercuryButtonVariant, ActionButtonProps["variant"]> = {
primary: "neutralSolid",
secondary: "neutralOutline",
ghost: "ghost",
};
export function MercuryButton({
variant = "primary",
style,
...props
}: MercuryButtonProps) {
const primaryStyle: React.CSSProperties | undefined =
variant === "primary"
? {
background: "var(--mercury-brand-yellow)",
color: "var(--mercury-on-brand)",
borderColor: "transparent",
}
: undefined;
return (
<ActionButton
variant={seedVariant[variant]}
size="large"
style={{ ...primaryStyle, ...style }}
{...props}
/>
);
}

64
src/ds/NameEdit.tsx Normal file
View file

@ -0,0 +1,64 @@
"use client";
import { useState } from "react";
import { TextFieldRoot, TextFieldInput } from "@seed-design/react";
import { MercuryButton } from "./MercuryButton";
import { t } from "@/i18n/common";
export interface NameEditProps {
initial: string;
title: string;
placeholder?: string;
onSave: (name: string) => void | Promise<void>;
onCancel: () => void;
}
/**
* Ghost-input edit form for renaming things (mirrors iOS `NameEditView`):
* a centered title, a large text field pre-filled with the current value,
* and save/cancel actions. Used instead of a native prompt/alert per
* Mercury's UI conventions.
*/
export function NameEdit({ initial, title, placeholder = "Нэр", onSave, onCancel }: NameEditProps) {
const [name, setName] = useState(initial);
const [saving, setSaving] = useState(false);
const trimmed = name.trim();
const canSave = trimmed.length > 0 && !saving;
async function handleSave() {
if (!canSave) return;
setSaving(true);
try {
await onSave(trimmed);
} finally {
setSaving(false);
}
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
<h2 style={{ textAlign: "center", margin: 0 }}>{title}</h2>
<TextFieldRoot value={name} onValueChange={setName}>
<TextFieldInput
placeholder={placeholder}
autoFocus
style={{ textAlign: "center", fontSize: 24 }}
/>
</TextFieldRoot>
<div style={{ display: "flex", gap: 12 }}>
<MercuryButton variant="secondary" onClick={onCancel} style={{ flex: 1 }}>
{t.common.cancel}
</MercuryButton>
<MercuryButton
variant="primary"
onClick={handleSave}
disabled={!canSave}
loading={saving}
style={{ flex: 1 }}
>
{t.common.save}
</MercuryButton>
</div>
</div>
);
}

92
src/ds/TabBar.tsx Normal file
View file

@ -0,0 +1,92 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { Icon } from "@seed-design/react";
import { t } from "@/i18n/common";
export type TabKey = "home" | "accounting" | "planner" | "assets" | "profile";
const HREF: Record<TabKey, string> = {
home: "/home",
accounting: "/accounting",
planner: "/planner",
assets: "/assets",
profile: "/profile",
};
// `@seed-design/react`'s `Icon` takes a raw `svg` node rather than a named
// icon catalog (there's no bundled icon-name set in the installed
// @seed-design/react/css versions — verified under node_modules), so these
// are small hand-drawn stand-ins mirroring the iOS tab glyphs
// (houseFill / horizline3VerticalFill / checkmarkCalendarFill / cardFill /
// personFill from HomeTabBar.swift). Swap for the real Seed icon set once
// it's available in this app.
// Seed's `<Icon svg={...} />` uses a Radix `Slot` internally, which clones
// its size/color props onto a *single* element — so each entry here must be
// one root `<svg>` node, not a fragment of bare paths.
function tabSvg(children: React.ReactNode): React.ReactNode {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
}
const TAB_ICON: Record<TabKey, React.ReactNode> = {
home: tabSvg(<path d="M3 10.5 12 3l9 7.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z" />),
accounting: tabSvg(
<>
<rect x="3" y="5" width="18" height="3" rx="1" />
<rect x="3" y="10.5" width="18" height="3" rx="1" />
<rect x="3" y="16" width="18" height="3" rx="1" />
</>,
),
planner: tabSvg(
<>
<rect x="3" y="4" width="18" height="17" rx="2" />
<path d="M3 9h18" />
<path d="m8 14 2.5 2.5L16 11" />
</>,
),
assets: tabSvg(<rect x="2.5" y="5" width="19" height="14" rx="2" />),
profile: tabSvg(
<>
<circle cx="12" cy="8" r="4" />
<path d="M4 20c1.5-4 5-6 8-6s6.5 2 8 6" />
</>,
),
};
const TAB_ORDER: TabKey[] = ["home", "accounting", "planner", "assets", "profile"];
export interface TabBarProps {
active: TabKey;
}
/** Bottom rounded nav bar on mobile (< md), left sidebar on desktop (>= md).
* Ports `HomeTabBar.swift`: active item uses primary text color, the rest
* use the placeholder/muted color. */
export function TabBar({ active }: TabBarProps) {
return (
<nav
className="fixed inset-x-0 bottom-0 z-40 flex items-stretch justify-around rounded-t-2xl px-4 pt-2 pb-2 shadow-[0_-2px_5px_rgba(0,0,0,0.1)] md:static md:h-screen md:w-56 md:flex-col md:items-stretch md:justify-start md:gap-1 md:rounded-none md:px-3 md:py-6 md:shadow-none"
style={{ background: "var(--seed-color-bg-layer-default)" }}
>
{TAB_ORDER.map((key) => {
const isActive = key === active;
return (
<Link
key={key}
href={HREF[key]}
className="flex flex-1 flex-col items-center gap-1 p-1 md:flex-none md:flex-row md:justify-start md:gap-3 md:rounded-xl md:px-3 md:py-2"
style={{ color: isActive ? "var(--seed-color-fg-neutral)" : "var(--seed-color-fg-placeholder)" }}
>
<Icon svg={TAB_ICON[key]} size="24px" />
<span className="text-xs md:text-sm">{t.tabs[key]}</span>
</Link>
);
})}
</nav>
);
}

17
src/ds/index.ts Normal file
View file

@ -0,0 +1,17 @@
export { MercuryButton } from "./MercuryButton";
export type { MercuryButtonProps, MercuryButtonVariant } from "./MercuryButton";
export { AmountToggle } from "./AmountToggle";
export type { AmountToggleProps } from "./AmountToggle";
export { HideAmountsToggle, HIDE_AMOUNTS_EVENT } from "./HideAmountsToggle";
export type { HideAmountsToggleProps } from "./HideAmountsToggle";
export { TabBar } from "./TabBar";
export type { TabBarProps, TabKey } from "./TabBar";
export { NameEdit } from "./NameEdit";
export type { NameEditProps } from "./NameEdit";
export { Card } from "./Card";
export type { CardProps } from "./Card";

15
src/i18n/common.ts Normal file
View file

@ -0,0 +1,15 @@
export const t = {
tabs: {
home: "Нүүр",
accounting: "Тооцоо",
planner: "Төсөв",
assets: "Хөрөнгө",
profile: "Миний",
},
common: {
save: "Хадгалах",
cancel: "Болих",
connect: "Холбох",
delete: "Устгах",
},
} as const;

View file

@ -2,5 +2,10 @@ import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
test: { environment: "jsdom", setupFiles: ["./vitest.setup.ts"], globals: true }, test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
globals: true,
server: { deps: { inline: [/@seed-design/] } },
},
}); });