feat(web): profile settings, categories, subscriptions, connected banks (read-only)
This commit is contained in:
parent
2555e42f21
commit
24642be543
10 changed files with 1197 additions and 0 deletions
9
src/app/(app)/profile/categories/page.tsx
Normal file
9
src/app/(app)/profile/categories/page.tsx
Normal 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")} />;
|
||||
}
|
||||
5
src/app/(app)/profile/page.tsx
Normal file
5
src/app/(app)/profile/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { ProfileView } from "@/features/profile/ProfileView";
|
||||
|
||||
export default function ProfilePage() {
|
||||
return <ProfileView />;
|
||||
}
|
||||
9
src/app/(app)/profile/subscriptions/page.tsx
Normal file
9
src/app/(app)/profile/subscriptions/page.tsx
Normal 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")} />;
|
||||
}
|
||||
388
src/features/profile/CategoriesManager.tsx
Normal file
388
src/features/profile/CategoriesManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
src/features/profile/ConnectedBanks.tsx
Normal file
78
src/features/profile/ConnectedBanks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/features/profile/ProfileView.tsx
Normal file
124
src/features/profile/ProfileView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/features/profile/SettingsForm.test.tsx
Normal file
33
src/features/profile/SettingsForm.test.tsx
Normal 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);
|
||||
});
|
||||
202
src/features/profile/SettingsForm.tsx
Normal file
202
src/features/profile/SettingsForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
261
src/features/profile/SubscriptionsView.tsx
Normal file
261
src/features/profile/SubscriptionsView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
src/features/profile/strings.ts
Normal file
88
src/features/profile/strings.ts
Normal 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;
|
||||
Loading…
Add table
Reference in a new issue