diff --git a/src/ds/AmountToggle.test.tsx b/src/ds/AmountToggle.test.tsx new file mode 100644 index 0000000..bbf426b --- /dev/null +++ b/src/ds/AmountToggle.test.tsx @@ -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(); + 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); +}); diff --git a/src/ds/AmountToggle.tsx b/src/ds/AmountToggle.tsx new file mode 100644 index 0000000..25eb277 --- /dev/null +++ b/src/ds/AmountToggle.tsx @@ -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 ( + + ); +} diff --git a/src/ds/Card.tsx b/src/ds/Card.tsx new file mode 100644 index 0000000..bdbdda9 --- /dev/null +++ b/src/ds/Card.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; + +export interface CardProps extends React.HTMLAttributes {} + +/** 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 ( +
+ {children} +
+ ); +} diff --git a/src/ds/HideAmountsToggle.tsx b/src/ds/HideAmountsToggle.tsx new file mode 100644 index 0000000..e076181 --- /dev/null +++ b/src/ds/HideAmountsToggle.tsx @@ -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(() => isHidden()); + + function handleChange(next: boolean) { + setHidden(next); + setChecked(next); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent(HIDE_AMOUNTS_EVENT, { detail: { hidden: next } })); + } + } + + return ( + + + + + {label} + + ); +} diff --git a/src/ds/MercuryButton.test.tsx b/src/ds/MercuryButton.test.tsx new file mode 100644 index 0000000..7b9f840 --- /dev/null +++ b/src/ds/MercuryButton.test.tsx @@ -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(Холбох); + 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)"); +}); diff --git a/src/ds/MercuryButton.tsx b/src/ds/MercuryButton.tsx new file mode 100644 index 0000000..f0f51eb --- /dev/null +++ b/src/ds/MercuryButton.tsx @@ -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 { + 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 = { + 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 ( + + ); +} diff --git a/src/ds/NameEdit.tsx b/src/ds/NameEdit.tsx new file mode 100644 index 0000000..3d7e833 --- /dev/null +++ b/src/ds/NameEdit.tsx @@ -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; + 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 ( +
+

{title}

+ + + +
+ + {t.common.cancel} + + + {t.common.save} + +
+
+ ); +} diff --git a/src/ds/TabBar.tsx b/src/ds/TabBar.tsx new file mode 100644 index 0000000..a021a09 --- /dev/null +++ b/src/ds/TabBar.tsx @@ -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 = { + 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 `` uses a Radix `Slot` internally, which clones +// its size/color props onto a *single* element — so each entry here must be +// one root `` node, not a fragment of bare paths. +function tabSvg(children: React.ReactNode): React.ReactNode { + return ( + + {children} + + ); +} + +const TAB_ICON: Record = { + home: tabSvg(), + accounting: tabSvg( + <> + + + + , + ), + planner: tabSvg( + <> + + + + , + ), + assets: tabSvg(), + profile: tabSvg( + <> + + + , + ), +}; + +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 ( + {t.tabs[key]} + + ); + })} + + ); +} diff --git a/src/ds/index.ts b/src/ds/index.ts new file mode 100644 index 0000000..d27da6a --- /dev/null +++ b/src/ds/index.ts @@ -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"; diff --git a/src/i18n/common.ts b/src/i18n/common.ts new file mode 100644 index 0000000..cdabe4f --- /dev/null +++ b/src/i18n/common.ts @@ -0,0 +1,15 @@ +export const t = { + tabs: { + home: "Нүүр", + accounting: "Тооцоо", + planner: "Төсөв", + assets: "Хөрөнгө", + profile: "Миний", + }, + common: { + save: "Хадгалах", + cancel: "Болих", + connect: "Холбох", + delete: "Устгах", + }, +} as const; diff --git a/vitest.config.ts b/vitest.config.ts index 643bb3b..9828ce6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,5 +2,10 @@ import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; export default defineConfig({ 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/] } }, + }, });