merge feat/aux: feature build-out

This commit is contained in:
Munkherdene 2026-08-22 23:02:25 +08:00
commit 245ef6ea03
9 changed files with 462 additions and 26 deletions

View file

@ -0,0 +1,24 @@
"use client";
import { Suspense } from "react";
import { ResetForm } from "@/features/auth/ResetForm";
import { AuthHeader } from "@/features/auth/AuthHeader";
// The password-reset landing: reached from the emailed link (?token=...).
// Wrapped in Suspense because ResetForm reads useSearchParams, which Next.js
// requires to sit below a Suspense boundary.
export default function ResetPasswordPage() {
return (
<>
<AuthHeader />
<div style={{ flex: 1, minHeight: 40 }} />
<Suspense fallback={null}>
<ResetForm />
</Suspense>
<div style={{ paddingBottom: 60 }} />
</>
);
}

View file

@ -0,0 +1,24 @@
"use client";
import { Suspense } from "react";
import { VerifyStatus } from "@/features/auth/VerifyStatus";
import { AuthHeader } from "@/features/auth/AuthHeader";
// The email-verify landing: reached from the emailed link (?token=...).
// Wrapped in Suspense because VerifyStatus reads useSearchParams, which
// Next.js requires to sit below a Suspense boundary.
export default function VerifyEmailPage() {
return (
<>
<AuthHeader />
<div style={{ flex: 1, minHeight: 40 }} />
<Suspense fallback={null}>
<VerifyStatus />
</Suspense>
<div style={{ paddingBottom: 60 }} />
</>
);
}

View file

@ -0,0 +1,106 @@
"use client";
import * as React from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { TextFieldRoot, TextFieldInput } from "@seed-design/react";
import { MercuryButton } from "../../ds/MercuryButton";
import { authStrings } from "./strings";
import { submitReset } from "./actions";
/** Set-new-password form for the emailed reset link (`/reset?token=...`).
* Ported in spirit from ForgotPasswordView.swift's field/CTA styling there is
* no iOS screen for this step since the reset itself only happens on web (see
* that file's header comment). Same 8-char minimum as AuthForm's register step. */
export function ResetForm() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token") ?? "";
const [password, setPassword] = React.useState("");
const [confirmPassword, setConfirmPassword] = React.useState("");
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [errorMessage, setErrorMessage] = React.useState<string | undefined>();
const [done, setDone] = React.useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (isSubmitting) return;
setErrorMessage(undefined);
if (!token) {
setErrorMessage(authStrings.reset.missingToken);
return;
}
if (password.length < 8) {
setErrorMessage(authStrings.errors.passwordTooShort);
return;
}
if (password !== confirmPassword) {
setErrorMessage(authStrings.errors.passwordMismatch);
return;
}
setIsSubmitting(true);
try {
const result = await submitReset(token, password);
if (result.ok) {
setDone(true);
} else {
setErrorMessage(result.error ?? authStrings.errors.unknown);
}
} finally {
setIsSubmitting(false);
}
}
if (done) {
return (
<div className="flex flex-col gap-5" style={{ textAlign: "center" }}>
<p className="text-sm" style={{ color: "var(--mercury-subtle, #6b7280)", margin: 0 }}>
{authStrings.reset.success}
</p>
<MercuryButton variant="primary" onClick={() => router.push("/login")}>
{authStrings.reset.loginLink}
</MercuryButton>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
<p className="text-center text-sm" style={{ color: "var(--mercury-subtle, #6b7280)", margin: 0 }}>
{authStrings.reset.subtitle}
</p>
<div className="flex flex-col gap-3">
<TextFieldRoot value={password} onValueChange={setPassword} name="password">
<TextFieldInput
type="password"
autoComplete="new-password"
placeholder={authStrings.reset.password}
aria-label={authStrings.reset.password}
/>
</TextFieldRoot>
<TextFieldRoot value={confirmPassword} onValueChange={setConfirmPassword} name="confirmPassword">
<TextFieldInput
type="password"
autoComplete="new-password"
placeholder={authStrings.reset.confirmPassword}
aria-label={authStrings.reset.confirmPassword}
/>
</TextFieldRoot>
</div>
{errorMessage && (
<p role="alert" className="text-sm" style={{ color: "var(--seed-color-fg-critical)", margin: 0 }}>
{errorMessage}
</p>
)}
<MercuryButton type="submit" variant="primary" loading={isSubmitting}>
{authStrings.reset.submit}
</MercuryButton>
</form>
);
}

View file

@ -0,0 +1,69 @@
"use client";
import * as React from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { MercuryButton } from "../../ds/MercuryButton";
import { authStrings } from "./strings";
import { submitVerify } from "./actions";
type State = "verifying" | "success" | "error";
/** Email-verify landing (`/verify?token=...`): posts the token once on mount
* and shows a verifying/success/failure state. The backend's email-verification
* feature may be disabled server-side (Config.EmailVerificationEnabled), in
* which case the route 404s and this renders the generic error message
* still a graceful, non-broken screen rather than a blocked build. */
export function VerifyStatus() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token") ?? "";
const [state, setState] = React.useState<State>("verifying");
const [errorMessage, setErrorMessage] = React.useState<string | undefined>();
const attempted = React.useRef(false);
React.useEffect(() => {
if (attempted.current) return;
attempted.current = true;
if (!token) {
setErrorMessage(authStrings.verify.missingToken);
setState("error");
return;
}
submitVerify(token).then((result) => {
if (result.ok) {
setState("success");
} else {
setErrorMessage(result.error ?? authStrings.errors.unknown);
setState("error");
}
});
}, [token]);
return (
<div className="flex flex-col items-center gap-5" style={{ textAlign: "center" }}>
{state === "verifying" && (
<p className="text-sm" style={{ color: "var(--mercury-subtle, #6b7280)", margin: 0 }}>
{authStrings.verify.verifying}
</p>
)}
{state === "success" && (
<p className="text-sm" style={{ color: "var(--mercury-subtle, #6b7280)", margin: 0 }}>
{authStrings.verify.success}
</p>
)}
{state === "error" && (
<p role="alert" className="text-sm" style={{ color: "var(--seed-color-fg-critical)", margin: 0 }}>
{errorMessage}
</p>
)}
{state !== "verifying" && (
<MercuryButton variant="primary" onClick={() => router.push("/login")}>
{authStrings.verify.loginLink}
</MercuryButton>
)}
</div>
);
}

View file

@ -15,6 +15,7 @@ const ERROR_MESSAGE_BY_CODE: Record<string, string> = {
email_taken: authStrings.errors.emailTaken,
invalid_code: authStrings.errors.invalidCode,
invalid_challenge: authStrings.errors.invalidCode,
invalid_token: authStrings.errors.invalidToken,
};
function messageForCode(code: string | undefined): string {
@ -23,7 +24,7 @@ function messageForCode(code: string | undefined): string {
}
async function postAuth(
path: "login" | "register" | "forgot" | "2fa",
path: "login" | "register" | "forgot" | "2fa" | "reset" | "verify",
body: unknown,
): Promise<AuthActionResult> {
let res: Response;
@ -73,3 +74,20 @@ export function submit2FA(
export function submitForgot(email: string): Promise<AuthActionResult> {
return postAuth("forgot", { email });
}
// Backend contract (internal/transport/api/password_handlers.go
// handleResetPassword): { token, newPassword } — the response body carries no
// user/session, just { status: "ok" }, so `postAuth`'s ok/error handling is
// all callers need.
export function submitReset(
token: string,
password: string,
): Promise<AuthActionResult> {
return postAuth("reset", { token, newPassword: password });
}
// Backend contract (internal/transport/api/verify_handlers.go
// handleVerifyEmail): { token } → { status: "verified" } on success.
export function submitVerify(token: string): Promise<AuthActionResult> {
return postAuth("verify", { token });
}

View file

@ -28,6 +28,26 @@ export const authStrings = {
submit: "Сэргээх холбоос авах",
sent: "Хэрэв бүртгэл байгаа бол сэргээх холбоосыг имэйлээр илгээлээ.",
},
// /reset — reached from the emailed link (?token=...). No iOS counterpart:
// ForgotPasswordView.swift is request-only, the actual change happens here
// on web (see that file's header comment).
reset: {
subtitle: "Шинэ нууц үгээ хоёр удаа оруулна уу.",
password: "Шинэ нууц үг",
confirmPassword: "Нууц үг давтах",
submit: "Нууц үг шинэчлэх",
success: "Нууц үг амжилттай шинэчлэгдлээ. Шинэ нууц үгээрээ нэвтэрнэ үү.",
loginLink: "Нэвтрэх хуудас руу очих",
missingToken: "Холбоос буруу байна. Имэйлээр ирсэн холбоосоор дахин орно уу.",
},
// /verify — reached from the emailed link (?token=...). Also no iOS
// counterpart; verification is a web-only flow today.
verify: {
verifying: "Имэйл хаягийг баталгаажуулж байна…",
success: "Имэйл хаяг амжилттай баталгаажлаа.",
loginLink: "Нэвтрэх хуудас руу очих",
missingToken: "Баталгаажуулах холбоос буруу байна.",
},
twoFactor: {
subtitle: "Имэйлээр илгээсэн баталгаажуулах кодоо оруулна уу.",
code: "Баталгаажуулах код",
@ -40,6 +60,7 @@ export const authStrings = {
invalidCredentials: "Имэйл эсвэл нууц үг буруу байна.",
emailTaken: "Энэ имэйл бүртгэлтэй байна.",
invalidCode: "Код буруу эсвэл хугацаа дууссан байна.",
invalidToken: "Холбоосны хугацаа дууссан эсвэл буруу байна.",
network: "Mercury-тэй холбогдож чадсангүй — холболтоо шалгана уу.",
unknown: "Алдаа гарлаа. Дахин оролдоно уу.",
},

View file

@ -28,6 +28,7 @@ import { tugrik, tugrikShort } from "@/ds/money";
import { useBudget, useNetWorth } from "@/api/hooks/reads";
import { usePutBudget, useSavingsGoalMutations } from "@/api/hooks/mutations";
import type { Budget, SavingsGoal, Account } from "@/api/schemas";
import { SpendBreakdown } from "./SpendBreakdown";
import { plannerStrings as s } from "./strings";
type Horizon = "day" | "week" | "month";
@ -82,6 +83,7 @@ export function PlannerView() {
const goalMutations = useSavingsGoalMutations();
const [horizon, setHorizon] = React.useState<Horizon>("day");
const [breakdownOpen, setBreakdownOpen] = React.useState(false);
const accounts: Account[] = netWorth?.accounts ?? [];
@ -167,6 +169,15 @@ export function PlannerView() {
limit={overallLimit}
loading={isLoading}
onSave={(v) => saveOverall(horizon, v)}
onOpenBreakdown={() => setBreakdownOpen(true)}
/>
<SpendBreakdown
open={breakdownOpen}
onOpenChange={setBreakdownOpen}
horizonLabel={OVERALL_LABEL[horizon]}
total={overallSpent}
rows={rows}
/>
<CategoryList
@ -306,12 +317,14 @@ function OverallCard({
limit,
loading,
onSave,
onOpenBreakdown,
}: {
horizon: Horizon;
spent: number;
limit: number;
loading: boolean;
onSave: (value: number) => void;
onOpenBreakdown: () => void;
}) {
const [editing, setEditing] = React.useState(false);
const [draft, setDraft] = React.useState("");
@ -352,31 +365,54 @@ function OverallCard({
</div>
</div>
) : (
<button
type="button"
onClick={() => {
setDraft(limit > 0 ? String(limit) : "");
setEditing(true);
}}
className="flex w-full items-center gap-4 text-left"
style={{ background: "none", border: "none", padding: 0, cursor: "pointer", color: "#fff", justifyContent: "space-between" }}
>
<div className="flex items-center gap-4" style={{ minWidth: 0 }}>
<ProgressCircleRoot value={percent} maxValue={100} style={{ width: 49, height: 49, flexShrink: 0 }}>
<ProgressCircleTrack style={{ opacity: 0.3 }} />
<ProgressCircleRange />
</ProgressCircleRoot>
<div className="flex flex-col gap-1">
<span style={{ fontSize: 12, opacity: 0.85 }}>{OVERALL_LABEL[horizon]}</span>
<span style={{ fontSize: 26, fontWeight: 700 }}>
{tugrikShort(spent)}
{limitSet ? ` / ${tugrikShort(limit)}` : ""}
</span>
{!limitSet && <span style={{ fontSize: 12, opacity: 0.75 }}>{s.overall.unsetHint}</span>}
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-end">
<button
type="button"
onClick={onOpenBreakdown}
className="flex items-center gap-1"
style={{
background: "none",
border: "none",
padding: "2px 0",
cursor: "pointer",
color: "#fff",
opacity: 0.85,
fontSize: 12,
fontWeight: 600,
}}
>
<Icon name="trending" size={13} />
{s.overall.breakdown}
</button>
</div>
<Icon name="chevron-right" size={14} style={{ opacity: 0.8, flexShrink: 0 }} />
</button>
<button
type="button"
onClick={() => {
setDraft(limit > 0 ? String(limit) : "");
setEditing(true);
}}
className="flex w-full items-center gap-4 text-left"
style={{ background: "none", border: "none", padding: 0, cursor: "pointer", color: "#fff", justifyContent: "space-between" }}
>
<div className="flex items-center gap-4" style={{ minWidth: 0 }}>
<ProgressCircleRoot value={percent} maxValue={100} style={{ width: 49, height: 49, flexShrink: 0 }}>
<ProgressCircleTrack style={{ opacity: 0.3 }} />
<ProgressCircleRange />
</ProgressCircleRoot>
<div className="flex flex-col gap-1">
<span style={{ fontSize: 12, opacity: 0.85 }}>{OVERALL_LABEL[horizon]}</span>
<span style={{ fontSize: 26, fontWeight: 700 }}>
{tugrikShort(spent)}
{limitSet ? ` / ${tugrikShort(limit)}` : ""}
</span>
{!limitSet && <span style={{ fontSize: 12, opacity: 0.75 }}>{s.overall.unsetHint}</span>}
</div>
</div>
<Icon name="chevron-right" size={14} style={{ opacity: 0.8, flexShrink: 0 }} />
</button>
</div>
)}
</div>
);
@ -590,7 +626,7 @@ function AmountField({ label, value, onChange }: { label: string; value: string;
);
}
function ProgressBar({ percent, tone }: { percent: number; tone: "brand" | "critical" }) {
export function ProgressBar({ percent, tone }: { percent: number; tone: "brand" | "critical" }) {
return (
<div style={{ height: 6, borderRadius: 999, background: "var(--seed-color-bg-neutral-weak, #eee)", overflow: "hidden" }}>
<div

View file

@ -0,0 +1,130 @@
"use client";
import Link from "next/link";
import {
ContentDialogRoot,
ContentDialogBackdrop,
ContentDialogPositioner,
ContentDialogContent,
ContentDialogHeader,
ContentDialogTitle,
ContentDialogBody,
ContentDialogFooter,
} from "@seed-design/react";
import { IconChip, EmptyState, MercuryButton } from "@/ds";
import { categoryStyle } from "@/ds/categoryStyle";
import { tugrik } from "@/ds/money";
import { ProgressBar } from "./PlannerView";
import { plannerStrings as s } from "./strings";
export interface SpendBreakdownRow {
category: string;
spent: string;
limit: string;
}
function dec(v: string | undefined | null): number {
return parseFloat(v ?? "0") || 0;
}
export interface SpendBreakdownProps {
open: boolean;
onOpenChange: (open: boolean) => void;
horizonLabel: string;
total: number;
rows: SpendBreakdownRow[];
}
/** Opened from the overall-spend card's "Задаргаа" affordance: how the
* horizon's total spend is composed, category by category, biggest first
* "how did this number get so big?". Ports SpendBreakdownView.swift; each row
* links to the same `/planner/[category]` transactions page the category-limit
* list already uses. */
export function SpendBreakdown({ open, onOpenChange, horizonLabel, total, rows }: SpendBreakdownProps) {
const spent = rows
.filter((r) => dec(r.spent) > 0)
.slice()
.sort((a, b) => dec(b.spent) - dec(a.spent));
return (
<ContentDialogRoot open={open} onOpenChange={onOpenChange}>
<ContentDialogBackdrop />
<ContentDialogPositioner>
<ContentDialogContent style={{ maxWidth: 420, width: "100%" }}>
<ContentDialogHeader>
<ContentDialogTitle>{s.spendBreakdown.title(horizonLabel)}</ContentDialogTitle>
</ContentDialogHeader>
<ContentDialogBody>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--seed-color-fg-placeholder)" }}>
{tugrik(total)}
</span>
{spent.length === 0 ? (
<EmptyState icon="layers" title={s.spendBreakdown.empty} compact />
) : (
<ul className="flex flex-col gap-3">
{spent.map((row) => {
const style = categoryStyle(row.category);
const value = dec(row.spent);
const percent = total > 0 ? Math.min(100, (value / total) * 100) : 0;
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
return (
<li key={row.category}>
<Link
href={`/planner/${encodeURIComponent(row.category)}`}
onClick={() => onOpenChange(false)}
className="flex items-center gap-3"
style={{ color: "inherit", textDecoration: "none" }}
>
<IconChip icon={style.icon} tint={style.tint} fg={style.fg} size={36} />
<div className="flex flex-1 flex-col gap-2" style={{ minWidth: 0 }}>
<div className="flex items-center justify-between gap-2">
<span
style={{
fontWeight: 700,
fontSize: 14,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{style.name}
</span>
<span style={{ fontWeight: 700, fontSize: 13, flexShrink: 0 }}>{tugrik(value)}</span>
</div>
<div className="flex items-center gap-2">
<div style={{ flex: 1 }}>
<ProgressBar percent={percent} tone="brand" />
</div>
<span
style={{
fontSize: 11,
width: 32,
textAlign: "right",
flexShrink: 0,
color: "var(--seed-color-fg-placeholder)",
}}
>
{pct}%
</span>
</div>
</div>
</Link>
</li>
);
})}
</ul>
)}
</div>
</ContentDialogBody>
<ContentDialogFooter>
<MercuryButton variant="secondary" onClick={() => onOpenChange(false)}>
{s.spendBreakdown.close}
</MercuryButton>
</ContentDialogFooter>
</ContentDialogContent>
</ContentDialogPositioner>
</ContentDialogRoot>
);
}

View file

@ -25,6 +25,14 @@ export const plannerStrings = {
},
editTitle: (horizonLabel: string) => `${horizonLabel} — нийт лимит`,
unsetHint: "Лимит тохируулаагүй",
breakdown: "Задаргаа",
},
// Spend-breakdown dialog, ported from SpendBreakdownView.swift: how a
// horizon's spend total is composed, category by category.
spendBreakdown: {
title: (horizonLabel: string) => `${horizonLabel} — задаргаа`,
empty: "Гүйлгээ алга",
close: "Хаах",
},
categories: {
title: "Ангиллын лимит",