feat(web): profile settings, categories, subscriptions, connected banks (read-only)

This commit is contained in:
Munkherdene 2026-08-22 20:57:19 +08:00
parent 2555e42f21
commit 24642be543
10 changed files with 1197 additions and 0 deletions

View file

@ -0,0 +1,9 @@
"use client";
import { useRouter } from "next/navigation";
import { CategoriesManager } from "@/features/profile/CategoriesManager";
export default function ProfileCategoriesPage() {
const router = useRouter();
return <CategoriesManager onBack={() => router.push("/profile")} />;
}

View file

@ -0,0 +1,5 @@
import { ProfileView } from "@/features/profile/ProfileView";
export default function ProfilePage() {
return <ProfileView />;
}

View file

@ -0,0 +1,9 @@
"use client";
import { useRouter } from "next/navigation";
import { SubscriptionsView } from "@/features/profile/SubscriptionsView";
export default function ProfileSubscriptionsPage() {
const router = useRouter();
return <SubscriptionsView onBack={() => router.push("/profile")} />;
}

View file

@ -0,0 +1,388 @@
"use client";
import * as React from "react";
import {
TextFieldRoot,
TextFieldInput,
DialogRoot,
DialogBackdrop,
DialogPositioner,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
Skeleton,
} from "@seed-design/react";
import { MercuryButton } from "@/ds/MercuryButton";
import { useCategories } from "@/api/hooks/reads";
import { useCategoryMutations } from "@/api/hooks/mutations";
import type { Category } from "@/api/schemas";
import { profileStrings } from "./strings";
// A small curated glyph set stands in for the iOS `SeedMulticolorIcon` catalog
// (not ported to web) — plain emoji stored verbatim in the `icon` string field.
const ICON_CHOICES = ["🍔", "🚗", "🏠", "💊", "🎮", "🛍️", "💡", "📚", "✈️", "🎁", "💰", "📱", "🐾", "⚽", "☕", "🧾"];
interface CategoryGroup {
name: string;
icon?: string | null;
children: Category[];
}
/** Mirrors `CategoriesModel.load()` in CategoriesView.swift: a depth-0 row
* starts a new group, every following depth>0 row until the next depth-0
* belongs to it. */
function groupCategories(categories: Category[]): CategoryGroup[] {
const groups: CategoryGroup[] = [];
let current: CategoryGroup | null = null;
for (const c of categories) {
if (c.depth === 0) {
current = { name: c.name, icon: c.icon, children: [] };
groups.push(current);
} else if (current) {
current.children.push(c);
}
}
return groups;
}
type SheetState = { mode: "add" } | { mode: "edit"; category: Category } | null;
export interface CategoriesManagerProps {
onBack?: () => void;
}
export function CategoriesManager({ onBack }: CategoriesManagerProps) {
const { data: categories, isLoading } = useCategories();
const { add, update, delete: remove } = useCategoryMutations();
const [sheet, setSheet] = React.useState<SheetState>(null);
const [deleteTarget, setDeleteTarget] = React.useState<string | null>(null);
const groups = React.useMemo(() => groupCategories(categories ?? []), [categories]);
const parentNames = React.useMemo(() => groups.map((g) => g.name), [groups]);
async function handleConfirmDelete() {
if (!deleteTarget) return;
await remove.mutateAsync(deleteTarget);
setDeleteTarget(null);
}
if (sheet) {
return (
<CategoryEditor
parents={parentNames}
initial={sheet.mode === "edit" ? sheet.category : undefined}
onCancel={() => setSheet(null)}
onSave={async (values) => {
if (sheet.mode === "add") {
await add.mutateAsync({
name: values.name,
kind: "expense",
parent: values.parent || undefined,
icon: values.icon,
});
} else {
await update.mutateAsync({
oldName: sheet.category.name,
newName: values.name,
parent: values.parent ? values.parent : null,
icon: values.icon,
});
}
setSheet(null);
}}
/>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{onBack && (
<button
type="button"
onClick={onBack}
aria-label={profileStrings.categories.back}
style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
>
</button>
)}
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0, flex: 1 }}>{profileStrings.categories.title}</h1>
<MercuryButton variant="ghost" onClick={() => setSheet({ mode: "add" })}>
+ {profileStrings.categories.add}
</MercuryButton>
</div>
{isLoading && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<Skeleton height="60px" />
<Skeleton height="60px" />
</div>
)}
{!isLoading && groups.length === 0 && (
<p style={{ fontSize: 14, color: "var(--seed-color-fg-muted, #6b7280)" }}>{profileStrings.categories.empty}</p>
)}
{groups.map((group) => (
<div key={group.name} style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<CategoryRow
icon={group.icon}
name={group.name}
heading
onEdit={() => setSheet({ mode: "edit", category: { name: group.name, kind: "expense", depth: 0, icon: group.icon } })}
onDelete={() => setDeleteTarget(group.name)}
/>
{group.children.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, paddingLeft: 12 }}>
{group.children.map((child) => (
<CategoryChip
key={child.name}
category={child}
onEdit={() => setSheet({ mode: "edit", category: child })}
onDelete={() => setDeleteTarget(child.name)}
/>
))}
</div>
)}
</div>
))}
<DialogRoot open={deleteTarget !== null} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<DialogBackdrop style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", zIndex: 40 }} />
<DialogPositioner
style={{
position: "fixed",
inset: 0,
zIndex: 41,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 16,
}}
>
<DialogContent
style={{
background: "var(--seed-color-bg-layer-floating)",
borderRadius: "var(--seed-radius-r3)",
padding: 20,
maxWidth: 360,
width: "100%",
}}
>
<DialogHeader>
<DialogTitle style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>
{profileStrings.categories.deleteTitle}
</DialogTitle>
</DialogHeader>
<DialogDescription style={{ fontSize: 14, color: "var(--seed-color-fg-muted, #6b7280)", marginTop: 8 }}>
{deleteTarget ? profileStrings.categories.deleteMessage(deleteTarget) : ""}
</DialogDescription>
<DialogFooter style={{ display: "flex", gap: 12, marginTop: 20 }}>
<MercuryButton variant="secondary" onClick={() => setDeleteTarget(null)} style={{ flex: 1 }}>
{profileStrings.categories.cancel}
</MercuryButton>
<MercuryButton
variant="primary"
onClick={handleConfirmDelete}
loading={remove.isPending}
style={{ flex: 1, background: "#d92626", color: "#fff" }}
>
{profileStrings.categories.deleteAction}
</MercuryButton>
</DialogFooter>
</DialogContent>
</DialogPositioner>
</DialogRoot>
</div>
);
}
function CategoryRow({
icon,
name,
heading,
onEdit,
onDelete,
}: {
icon?: string | null;
name: string;
heading?: boolean;
onEdit: () => void;
onDelete: () => void;
}) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span aria-hidden style={{ fontSize: heading ? 20 : 16 }}>{icon || "🏷️"}</span>
<span style={{ fontSize: heading ? 16 : 14, fontWeight: heading ? 600 : 400, flex: 1 }}>{name}</span>
<button
type="button"
onClick={onEdit}
aria-label={`${profileStrings.categories.editAction}: ${name}`}
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 13, color: "var(--seed-color-fg-muted, #6b7280)" }}
>
{profileStrings.categories.editAction}
</button>
<button
type="button"
onClick={onDelete}
aria-label={`${profileStrings.categories.deleteAction}: ${name}`}
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 13, color: "#d92626" }}
>
{profileStrings.categories.deleteAction}
</button>
</div>
);
}
function CategoryChip({
category,
onEdit,
onDelete,
}: {
category: Category;
onEdit: () => void;
onDelete: () => void;
}) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "6px 10px",
borderRadius: 999,
background: "var(--seed-color-bg-layer-default)",
}}
>
<span aria-hidden>{category.icon || "🏷️"}</span>
<span style={{ fontSize: 13 }}>{category.name}</span>
<button
type="button"
onClick={onEdit}
aria-label={`${profileStrings.categories.editAction}: ${category.name}`}
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)" }}
>
</button>
<button
type="button"
onClick={onDelete}
aria-label={`${profileStrings.categories.deleteAction}: ${category.name}`}
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 12, color: "#d92626" }}
>
</button>
</div>
);
}
function CategoryEditor({
parents,
initial,
onCancel,
onSave,
}: {
parents: string[];
initial?: Category;
onCancel: () => void;
onSave: (values: { name: string; parent: string; icon: string }) => Promise<void>;
}) {
const [name, setName] = React.useState(initial?.name ?? "");
const [parent, setParent] = React.useState("");
const [icon, setIcon] = React.useState(initial?.icon || ICON_CHOICES[0]);
const [saving, setSaving] = React.useState(false);
const editing = Boolean(initial);
const canSave = name.trim().length > 0 && !saving;
async function handleSave() {
if (!canSave) return;
setSaving(true);
try {
await onSave({ name: name.trim(), parent, icon });
} finally {
setSaving(false);
}
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<button
type="button"
onClick={onCancel}
aria-label={profileStrings.categories.cancel}
style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
>
</button>
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>
{editing ? profileStrings.categories.edit : profileStrings.categories.add}
</h1>
</div>
<TextFieldRoot value={name} onValueChange={setName} name="categoryName">
<TextFieldInput
aria-label={profileStrings.categories.name}
placeholder={profileStrings.categories.namePlaceholder}
autoFocus
/>
</TextFieldRoot>
<div>
<p style={{ fontSize: 13, color: "var(--seed-color-fg-muted, #6b7280)", marginBottom: 8 }}>
{profileStrings.categories.icon}
</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(8, 1fr)", gap: 8 }}>
{ICON_CHOICES.map((choice) => (
<button
key={choice}
type="button"
onClick={() => setIcon(choice)}
aria-pressed={icon === choice}
style={{
fontSize: 20,
padding: 8,
borderRadius: "var(--seed-radius-r3)",
border: "none",
cursor: "pointer",
background: icon === choice ? "var(--seed-color-bg-layer-default)" : "transparent",
}}
>
{choice}
</button>
))}
</div>
</div>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 13, color: "var(--seed-color-fg-muted, #6b7280)" }}>{profileStrings.categories.parent}</span>
<select
value={parent}
onChange={(e) => setParent(e.target.value)}
style={{
padding: "10px 12px",
borderRadius: "var(--seed-radius-r3)",
border: "1px solid var(--seed-color-border-default, #e5e7eb)",
}}
>
<option value="">{profileStrings.categories.parentNone}</option>
{parents
.filter((p) => p !== initial?.name)
.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</label>
<MercuryButton variant="primary" onClick={handleSave} disabled={!canSave} loading={saving}>
{profileStrings.categories.save}
</MercuryButton>
</div>
);
}

View file

@ -0,0 +1,78 @@
"use client";
import { useConnections } from "@/api/hooks/reads";
import { Skeleton } from "@seed-design/react";
import { profileStrings } from "./strings";
/**
* Read-only connected-banks list (Task 13 scope: NO connect/disconnect here
* that stays a mobile-only action per the brief). Mirrors the bank rows in
* `ProfileView.swift`'s `banksCard`, minus the connect/disconnect chips.
*/
export function ConnectedBanks() {
const { data: connections, isLoading } = useConnections();
return (
<section aria-labelledby="connected-banks-heading">
<h2
id="connected-banks-heading"
style={{ fontSize: 12, color: "var(--seed-color-fg-subtle, #6b7280)", margin: "0 0 12px" }}
>
{profileStrings.banks.title}
</h2>
{isLoading && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<Skeleton height="44px" />
<Skeleton height="44px" />
</div>
)}
{!isLoading && (!connections || connections.length === 0) && (
<p style={{ fontSize: 14, color: "var(--seed-color-fg-muted, #6b7280)" }}>
{profileStrings.banks.empty}
</p>
)}
{!isLoading && connections && connections.length > 0 && (
<ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 4 }}>
{connections.map((c) => (
<li
key={c.bank}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 0",
}}
>
<div style={{ display: "flex", flexDirection: "column" }}>
<span style={{ fontSize: 14, fontWeight: 600 }}>{c.bank}</span>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)" }}>
{c.username}
</span>
</div>
{c.courierManaged && (
<span
style={{
fontSize: 11,
padding: "4px 8px",
borderRadius: 999,
background: "var(--seed-color-bg-layer-default)",
color: "var(--seed-color-fg-muted, #6b7280)",
}}
>
{profileStrings.banks.courierManaged}
</span>
)}
</li>
))}
</ul>
)}
<p style={{ fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)", marginTop: 8 }}>
{profileStrings.banks.manageNote}
</p>
</section>
);
}

View file

@ -0,0 +1,124 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Card, HideAmountsToggle, MercuryButton } from "@/ds";
import { useMe, useSettings } from "@/api/hooks/reads";
import { profileStrings } from "./strings";
import { SettingsForm } from "./SettingsForm";
import { ConnectedBanks } from "./ConnectedBanks";
/** The Миний (profile) tab hub: account summary, hide-amounts toggle,
* entries into Categories/Subscriptions (their own routes) and Settings
* (rendered inline, no dedicated route), the read-only connected-banks list,
* and logout. Ports `ProfileView.swift`'s layout minus bank connect/disconnect
* (mobile-only per the Task 13 brief) and the not-yet-wired static rows
* (Шинэ мэдээ / Түгээмэл асуултууд / Санал хүсэлт / Үйлчилгээний нөхцөл). */
export function ProfileView() {
const router = useRouter();
const { data: me } = useMe();
const { data: settings } = useSettings();
const [showSettings, setShowSettings] = React.useState(false);
const [loggingOut, setLoggingOut] = React.useState(false);
const emailLocal = me?.email ? me.email.split("@")[0] : "";
const displayName = settings?.holderName || emailLocal || me?.email || "";
async function handleLogout() {
setLoggingOut(true);
try {
await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin" });
} finally {
router.push("/login");
}
}
if (showSettings) {
return <SettingsForm onBack={() => setShowSettings(false)} />;
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<h1 style={{ fontSize: 18, fontWeight: 600, margin: 0 }}>{profileStrings.header.title}</h1>
<HideAmountsToggle label={profileStrings.display.hideAmounts} />
</div>
<Card>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<span style={{ fontSize: 14, fontWeight: 600 }}>{displayName}</span>
{me?.email && (
<span style={{ fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)" }}>
{profileStrings.account.handlePrefix}
{emailLocal}
</span>
)}
</div>
</Card>
<Card>
<nav style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<MenuRow label={profileStrings.menu.settings} onClick={() => setShowSettings(true)} />
<MenuLink label={profileStrings.menu.categories} href="/profile/categories" />
<MenuLink label={profileStrings.menu.subscriptions} href="/profile/subscriptions" />
</nav>
</Card>
<Card>
<ConnectedBanks />
</Card>
<MercuryButton variant="secondary" onClick={handleLogout} loading={loggingOut}>
{profileStrings.logout}
</MercuryButton>
</div>
);
}
function MenuRow({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 0",
background: "none",
border: "none",
cursor: "pointer",
font: "inherit",
color: "inherit",
textAlign: "left",
}}
>
<span style={{ fontSize: 14 }}>{label}</span>
<span aria-hidden style={{ color: "var(--seed-color-fg-placeholder, #9ca3af)" }}>
</span>
</button>
);
}
function MenuLink({ label, href }: { label: string; href: string }) {
return (
<Link
href={href}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 0",
color: "inherit",
textDecoration: "none",
}}
>
<span style={{ fontSize: 14 }}>{label}</span>
<span aria-hidden style={{ color: "var(--seed-color-fg-placeholder, #9ca3af)" }}>
</span>
</Link>
);
}

View file

@ -0,0 +1,33 @@
import { render, screen } from "@testing-library/react";
import { it, expect, vi } from "vitest";
import type { Settings } from "@/api/schemas";
const settingsFixture: Settings = {
holderName: "Бат-Эрдэнэ",
employer: "Меркури ХХК",
salaryKeywords: ["цалин", "salary"],
payDays: [1, 15],
ownAccounts: ["1234567890"],
peerAccounts: ["0987654321"],
hideAmounts: false,
};
vi.mock("@/api/hooks/reads", () => ({
useSettings: () => ({ data: settingsFixture, isLoading: false, isSuccess: true }),
}));
vi.mock("@/api/hooks/mutations", () => ({
useSaveSettings: () => ({
mutateAsync: vi.fn().mockResolvedValue(settingsFixture),
isPending: false,
isError: false,
}),
}));
import { SettingsForm } from "./SettingsForm";
it("renders the holder-name field pre-filled from useSettings", () => {
render(<SettingsForm />);
const input = screen.getByLabelText("Данс эзэмшигчийн нэр") as HTMLInputElement;
expect(input.value).toBe(settingsFixture.holderName);
});

View file

@ -0,0 +1,202 @@
"use client";
import * as React from "react";
import { TextFieldRoot, TextFieldInput, SwitchRoot, SwitchControl, SwitchThumb, SwitchLabel, Skeleton } from "@seed-design/react";
import { MercuryButton } from "@/ds/MercuryButton";
import { useSettings } from "@/api/hooks/reads";
import { useSaveSettings } from "@/api/hooks/mutations";
import type { Settings } from "@/api/schemas";
import { profileStrings } from "./strings";
interface FormState {
holderName: string;
employer: string;
salaryKeywords: string;
payDays: string;
ownAccounts: string;
peerAccounts: string;
hideAmounts: boolean;
}
const EMPTY_FORM: FormState = {
holderName: "",
employer: "",
salaryKeywords: "",
payDays: "",
ownAccounts: "",
peerAccounts: "",
hideAmounts: false,
};
function toFormState(settings: Settings | undefined): FormState {
if (!settings) return EMPTY_FORM;
return {
holderName: settings.holderName,
employer: settings.employer,
salaryKeywords: settings.salaryKeywords.join(", "),
payDays: settings.payDays.join(", "),
ownAccounts: settings.ownAccounts.join(", "),
peerAccounts: settings.peerAccounts.join(", "),
hideAmounts: settings.hideAmounts ?? false,
};
}
function splitList(value: string): string[] {
return value
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0);
}
function splitNumberList(value: string): number[] {
return splitList(value)
.map((v) => Number(v))
.filter((n) => Number.isFinite(n));
}
export interface SettingsFormProps {
onBack?: () => void;
}
/** Edits the account-level settings (holder name, employer, salary detection
* keywords/pay days, own/peer account lists, hide-amounts) that back the
* backend's auto-categorization not currently exposed anywhere in the iOS
* app, but commissioned for the web app per the Task 13 brief. */
export function SettingsForm({ onBack }: SettingsFormProps) {
const query = useSettings();
const save = useSaveSettings();
const initialized = React.useRef(Boolean(query.data));
const [form, setForm] = React.useState<FormState>(() => toFormState(query.data));
const [savedAt, setSavedAt] = React.useState<number | null>(null);
React.useEffect(() => {
if (!initialized.current && query.data) {
setForm(toFormState(query.data));
initialized.current = true;
}
}, [query.data]);
function field(key: keyof Omit<FormState, "hideAmounts">) {
return {
value: form[key],
onValueChange: (v: string) => setForm((f) => ({ ...f, [key]: v })),
};
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSavedAt(null);
await save.mutateAsync({
holderName: form.holderName.trim(),
employer: form.employer.trim(),
salaryKeywords: splitList(form.salaryKeywords),
payDays: splitNumberList(form.payDays),
ownAccounts: splitList(form.ownAccounts),
peerAccounts: splitList(form.peerAccounts),
hideAmounts: form.hideAmounts,
});
setSavedAt(Date.now());
}
if (query.isLoading && !query.data) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<Skeleton height="44px" />
<Skeleton height="44px" />
<Skeleton height="44px" />
</div>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{onBack && (
<button
type="button"
onClick={onBack}
aria-label={profileStrings.settings.back}
style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
>
</button>
)}
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>{profileStrings.settings.title}</h1>
</div>
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<Labeled label={profileStrings.settings.holderName}>
<TextFieldRoot {...field("holderName")} name="holderName">
<TextFieldInput aria-label={profileStrings.settings.holderName} />
</TextFieldRoot>
</Labeled>
<Labeled label={profileStrings.settings.employer}>
<TextFieldRoot {...field("employer")} name="employer">
<TextFieldInput aria-label={profileStrings.settings.employer} />
</TextFieldRoot>
</Labeled>
<Labeled label={profileStrings.settings.salaryKeywords} hint={profileStrings.settings.salaryKeywordsHint}>
<TextFieldRoot {...field("salaryKeywords")} name="salaryKeywords">
<TextFieldInput aria-label={profileStrings.settings.salaryKeywords} />
</TextFieldRoot>
</Labeled>
<Labeled label={profileStrings.settings.payDays} hint={profileStrings.settings.payDaysHint}>
<TextFieldRoot {...field("payDays")} name="payDays">
<TextFieldInput aria-label={profileStrings.settings.payDays} />
</TextFieldRoot>
</Labeled>
<Labeled label={profileStrings.settings.ownAccounts} hint={profileStrings.settings.ownAccountsHint}>
<TextFieldRoot {...field("ownAccounts")} name="ownAccounts">
<TextFieldInput aria-label={profileStrings.settings.ownAccounts} />
</TextFieldRoot>
</Labeled>
<Labeled label={profileStrings.settings.peerAccounts} hint={profileStrings.settings.peerAccountsHint}>
<TextFieldRoot {...field("peerAccounts")} name="peerAccounts">
<TextFieldInput aria-label={profileStrings.settings.peerAccounts} />
</TextFieldRoot>
</Labeled>
<SwitchRoot
checked={form.hideAmounts}
onCheckedChange={(v: boolean) => setForm((f) => ({ ...f, hideAmounts: v }))}
>
<SwitchControl>
<SwitchThumb />
</SwitchControl>
<SwitchLabel>{profileStrings.settings.hideAmounts}</SwitchLabel>
</SwitchRoot>
{save.isError && (
<p role="alert" style={{ fontSize: 13, color: "#d92626" }}>
{profileStrings.settings.error}
</p>
)}
{savedAt && !save.isPending && (
<p style={{ fontSize: 13, color: "var(--seed-color-fg-muted, #6b7280)" }}>
{profileStrings.settings.saved}
</p>
)}
<MercuryButton type="submit" variant="primary" loading={save.isPending}>
{profileStrings.settings.save}
</MercuryButton>
</form>
</div>
);
}
function Labeled({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 13, color: "var(--seed-color-fg-muted, #6b7280)" }}>{label}</span>
{children}
{hint && <span style={{ fontSize: 11, color: "var(--seed-color-fg-placeholder, #9ca3af)" }}>{hint}</span>}
</label>
);
}

View file

@ -0,0 +1,261 @@
"use client";
import * as React from "react";
import { TextFieldRoot, TextFieldInput, Skeleton } from "@seed-design/react";
import { MercuryButton } from "@/ds/MercuryButton";
import { tugrik } from "@/ds/money";
import { useSubscriptions } from "@/api/hooks/reads";
import { useSubscriptionMutations } from "@/api/hooks/mutations";
import type { Subscription } from "@/api/schemas";
import { profileStrings } from "./strings";
export interface SubscriptionsViewProps {
onBack?: () => void;
}
/** Detected (subscriptions + bills) and manually-added recurring items,
* mirroring the `/subscriptions` response consumed by iOS's
* `ManualSubscriptionView`/`TransactionDetailView`. Detected rows can be
* deactivated (their matchKey stops being force-included); manual rows can
* be added and deleted. There is no "edit manual" hook exposed to this task,
* so manual rows are add/delete only. */
export function SubscriptionsView({ onBack }: SubscriptionsViewProps) {
const { data, isLoading } = useSubscriptions();
const { setSubscription, createManualSub, deleteManualSub } = useSubscriptionMutations();
const [showAdd, setShowAdd] = React.useState(false);
const all = [...(data?.subscriptions ?? []), ...(data?.bills ?? [])];
const detected = all.filter((s) => !s.manual);
const manual = all.filter((s) => s.manual);
async function handleDeactivate(sub: Subscription) {
if (!sub.matchKey) return;
await setSubscription.mutateAsync({ matchKey: sub.matchKey, name: sub.label, active: false });
}
async function handleDeleteManual(sub: Subscription) {
if (sub.id == null) return;
await deleteManualSub.mutateAsync(sub.id);
}
if (showAdd) {
return (
<ManualSubscriptionForm
onCancel={() => setShowAdd(false)}
onSave={async (values) => {
await createManualSub.mutateAsync(values);
setShowAdd(false);
}}
/>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{onBack && (
<button
type="button"
onClick={onBack}
aria-label={profileStrings.subscriptions.back}
style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
>
</button>
)}
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0, flex: 1 }}>{profileStrings.subscriptions.title}</h1>
<MercuryButton variant="ghost" onClick={() => setShowAdd(true)}>
+ {profileStrings.subscriptions.addManual}
</MercuryButton>
</div>
{isLoading && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<Skeleton height="52px" />
<Skeleton height="52px" />
</div>
)}
{!isLoading && all.length === 0 && (
<p style={{ fontSize: 14, color: "var(--seed-color-fg-muted, #6b7280)" }}>{profileStrings.subscriptions.empty}</p>
)}
{!isLoading && detected.length > 0 && (
<section>
<h2 style={{ fontSize: 12, color: "var(--seed-color-fg-subtle, #6b7280)", margin: "0 0 8px" }}>
{profileStrings.subscriptions.detected}
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{detected.map((s) => (
<SubscriptionRow
key={s.matchKey ?? s.label}
sub={s}
actionLabel={profileStrings.subscriptions.deactivate}
onAction={() => handleDeactivate(s)}
pending={setSubscription.isPending}
/>
))}
</div>
</section>
)}
{!isLoading && manual.length > 0 && (
<section>
<h2 style={{ fontSize: 12, color: "var(--seed-color-fg-subtle, #6b7280)", margin: "0 0 8px" }}>
{profileStrings.subscriptions.manual}
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{manual.map((s) => (
<SubscriptionRow
key={s.id}
sub={s}
actionLabel={profileStrings.subscriptions.delete}
onAction={() => handleDeleteManual(s)}
pending={deleteManualSub.isPending}
destructive
/>
))}
</div>
</section>
)}
</div>
);
}
function SubscriptionRow({
sub,
actionLabel,
onAction,
pending,
destructive,
}: {
sub: Subscription;
actionLabel: string;
onAction: () => void;
pending?: boolean;
destructive?: boolean;
}) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "8px 0" }}>
<div style={{ display: "flex", flexDirection: "column" }}>
<span style={{ fontSize: 14, fontWeight: 600 }}>{sub.label}</span>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)" }}>
{tugrik(sub.monthly)} / {sub.cadence}
{sub.nextDue ? ` · ${sub.nextDue}` : ""}
</span>
</div>
<button
type="button"
onClick={onAction}
disabled={pending}
aria-label={`${actionLabel}: ${sub.label}`}
style={{
background: "none",
border: "none",
cursor: "pointer",
fontSize: 13,
color: destructive ? "#d92626" : "var(--seed-color-fg-muted, #6b7280)",
}}
>
{actionLabel}
</button>
</div>
);
}
function ManualSubscriptionForm({
onCancel,
onSave,
}: {
onCancel: () => void;
onSave: (values: { name: string; amount: string; category?: string; nextDue?: string }) => Promise<void>;
}) {
const [name, setName] = React.useState("");
const [amount, setAmount] = React.useState("");
const [category, setCategory] = React.useState("");
const [nextDue, setNextDue] = React.useState("");
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | undefined>();
const canSave = name.trim().length > 0 && Number(amount) > 0 && !saving;
async function handleSave() {
if (!canSave) return;
setSaving(true);
setError(undefined);
try {
await onSave({
name: name.trim(),
amount,
category: category.trim() || undefined,
nextDue: nextDue || undefined,
});
} catch {
setError(profileStrings.subscriptions.saveError);
} finally {
setSaving(false);
}
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<button
type="button"
onClick={onCancel}
aria-label={profileStrings.subscriptions.cancel}
style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
>
</button>
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>{profileStrings.subscriptions.addManual}</h1>
</div>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 13, color: "var(--seed-color-fg-muted, #6b7280)" }}>{profileStrings.subscriptions.name}</span>
<TextFieldRoot value={name} onValueChange={setName} name="subName">
<TextFieldInput aria-label={profileStrings.subscriptions.name} placeholder={profileStrings.subscriptions.namePlaceholder} />
</TextFieldRoot>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 13, color: "var(--seed-color-fg-muted, #6b7280)" }}>{profileStrings.subscriptions.amount}</span>
<TextFieldRoot value={amount} onValueChange={setAmount} name="subAmount">
<TextFieldInput aria-label={profileStrings.subscriptions.amount} inputMode="numeric" placeholder="0" />
</TextFieldRoot>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 13, color: "var(--seed-color-fg-muted, #6b7280)" }}>{profileStrings.subscriptions.category}</span>
<TextFieldRoot value={category} onValueChange={setCategory} name="subCategory">
<TextFieldInput aria-label={profileStrings.subscriptions.category} placeholder={profileStrings.subscriptions.categoryPlaceholder} />
</TextFieldRoot>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 13, color: "var(--seed-color-fg-muted, #6b7280)" }}>{profileStrings.subscriptions.nextDue}</span>
<input
type="date"
value={nextDue}
onChange={(e) => setNextDue(e.target.value)}
aria-label={profileStrings.subscriptions.nextDue}
style={{
padding: "10px 12px",
borderRadius: "var(--seed-radius-r3)",
border: "1px solid var(--seed-color-border-default, #e5e7eb)",
}}
/>
</label>
{error && (
<p role="alert" style={{ fontSize: 13, color: "#d92626" }}>
{error}
</p>
)}
<MercuryButton variant="primary" onClick={handleSave} disabled={!canSave} loading={saving}>
{profileStrings.subscriptions.save}
</MercuryButton>
</div>
);
}

View file

@ -0,0 +1,88 @@
// Profile / Categories / Subscriptions / Connections copy, ported from
// ios/Mercury/Features/Home/ProfileView.swift, Categories/CategoriesView.swift,
// Subscriptions/ManualSubscriptionView.swift (Mongolian labels kept verbatim;
// the couple of English fragments in ProfileView.swift — "My Profile",
// "Banks", "Display & Privacy" — are localized here for consistency with the
// rest of the web app's Mongolian copy).
export const profileStrings = {
header: {
title: "Миний профайл",
},
account: {
handlePrefix: "@",
},
menu: {
settings: "Тохиргоо",
categories: "Ангилал",
subscriptions: "Subscriptions",
},
display: {
title: "Дэлгэц ба нууцлал",
hideAmounts: "Үнийн дүн нуух",
},
banks: {
title: "Банкууд",
empty: "Холбогдсон банк алга.",
manageNote:
"Банк холбох, салгах үйлдлийг зөвхөн гар утасны аппликейшнээс хийнэ үү.",
courierManaged: "Автомат синк",
},
logout: "Гарах",
settings: {
title: "Тохиргоо",
back: "Миний профайл",
holderName: "Данс эзэмшигчийн нэр",
employer: "Ажил олгогч",
salaryKeywords: "Цалингийн түлхүүр үг",
salaryKeywordsHint: "Таслалаар тусгаарлан бичнэ үү",
payDays: "Цалин өгдөг өдрүүд",
payDaysHint: "Сарын өдрийн дугаар, таслалаар тусгаарлана (жишээ: 1, 15)",
ownAccounts: "Өөрийн дансууд",
ownAccountsHint: "Дансны дугаар, таслалаар тусгаарлана",
peerAccounts: "Найз/хамаатны дансууд",
peerAccountsHint: "Дансны дугаар, таслалаар тусгаарлана",
hideAmounts: "Үнийн дүн нуух",
save: "Хадгалах",
saved: "Хадгалагдлаа",
error: "Хадгалж чадсангүй",
},
categories: {
title: "Категори",
back: "Миний профайл",
add: "Ангилал нэмэх",
edit: "Ангилал засах",
name: "Ангиллын нэр",
namePlaceholder: "Ангиллын нэр",
icon: "Дүрс тэмдэг",
parent: "Эцэг ангилал (заавал биш)",
parentNone: "Эцэг ангилал (заавал биш)",
save: "Хадгалах",
cancel: "Болих",
editAction: "Засах",
deleteAction: "Устгах",
deleteTitle: "Ангилал устгах уу?",
deleteMessage: (name: string) =>
`«${name}» болон түүнд хамаарах гүйлгээнүүд ангилалгүй болно.`,
empty: "Категори алга.",
},
subscriptions: {
title: "Subscriptions",
back: "Миний профайл",
detected: "Илэрсэн",
bills: "Тогтмол төлбөрүүд",
manual: "Гараар нэмсэн",
empty: "Одоогоор subscription алга.",
addManual: "Гараар нэмэх",
name: "Нэр",
namePlaceholder: "Жишээ: Netflix",
amount: "Сарын төлбөр",
category: "Ангилал",
categoryPlaceholder: "Ангилал",
nextDue: "Дараагийн төлөх огноо",
save: "Хадгалах",
cancel: "Болих",
delete: "Устгах",
deactivate: "Идэвхгүй болгох",
saveError: "Хадгалж чадсангүй",
},
} as const;