diff --git a/src/app/(auth)/reset/page.tsx b/src/app/(auth)/reset/page.tsx new file mode 100644 index 0000000..7585331 --- /dev/null +++ b/src/app/(auth)/reset/page.tsx @@ -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 ( + <> + + +
+ + + + + +
+ + ); +} diff --git a/src/app/(auth)/verify/page.tsx b/src/app/(auth)/verify/page.tsx new file mode 100644 index 0000000..38e3892 --- /dev/null +++ b/src/app/(auth)/verify/page.tsx @@ -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 ( + <> + + +
+ + + + + +
+ + ); +} diff --git a/src/features/auth/ResetForm.tsx b/src/features/auth/ResetForm.tsx new file mode 100644 index 0000000..462ff76 --- /dev/null +++ b/src/features/auth/ResetForm.tsx @@ -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(); + 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 ( +
+

+ {authStrings.reset.success} +

+ router.push("/login")}> + {authStrings.reset.loginLink} + +
+ ); + } + + return ( +
+

+ {authStrings.reset.subtitle} +

+ +
+ + + + + + + +
+ + {errorMessage && ( +

+ {errorMessage} +

+ )} + + + {authStrings.reset.submit} + +
+ ); +} diff --git a/src/features/auth/VerifyStatus.tsx b/src/features/auth/VerifyStatus.tsx new file mode 100644 index 0000000..c14c825 --- /dev/null +++ b/src/features/auth/VerifyStatus.tsx @@ -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("verifying"); + const [errorMessage, setErrorMessage] = React.useState(); + 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 ( +
+ {state === "verifying" && ( +

+ {authStrings.verify.verifying} +

+ )} + {state === "success" && ( +

+ {authStrings.verify.success} +

+ )} + {state === "error" && ( +

+ {errorMessage} +

+ )} + {state !== "verifying" && ( + router.push("/login")}> + {authStrings.verify.loginLink} + + )} +
+ ); +} diff --git a/src/features/auth/actions.ts b/src/features/auth/actions.ts index a1bde2c..e9b6c55 100644 --- a/src/features/auth/actions.ts +++ b/src/features/auth/actions.ts @@ -15,6 +15,7 @@ const ERROR_MESSAGE_BY_CODE: Record = { 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 { let res: Response; @@ -73,3 +74,20 @@ export function submit2FA( export function submitForgot(email: string): Promise { 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 { + 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 { + return postAuth("verify", { token }); +} diff --git a/src/features/auth/strings.ts b/src/features/auth/strings.ts index 445f687..b2d43f8 100644 --- a/src/features/auth/strings.ts +++ b/src/features/auth/strings.ts @@ -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: "Алдаа гарлаа. Дахин оролдоно уу.", }, diff --git a/src/features/planner/PlannerView.tsx b/src/features/planner/PlannerView.tsx index 748de30..415ebe4 100644 --- a/src/features/planner/PlannerView.tsx +++ b/src/features/planner/PlannerView.tsx @@ -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("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)} + /> + + void; + onOpenBreakdown: () => void; }) { const [editing, setEditing] = React.useState(false); const [draft, setDraft] = React.useState(""); @@ -352,31 +365,54 @@ function OverallCard({
) : ( -
- - + + +
)} ); @@ -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 (
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 ( + + + + + + {s.spendBreakdown.title(horizonLabel)} + + +
+ + {tugrik(total)} + + + {spent.length === 0 ? ( + + ) : ( +
    + {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 ( +
  • + onOpenChange(false)} + className="flex items-center gap-3" + style={{ color: "inherit", textDecoration: "none" }} + > + +
    +
    + + {style.name} + + {tugrik(value)} +
    +
    +
    + +
    + + {pct}% + +
    +
    + +
  • + ); + })} +
+ )} +
+
+ + onOpenChange(false)}> + {s.spendBreakdown.close} + + +
+
+
+ ); +} diff --git a/src/features/planner/strings.ts b/src/features/planner/strings.ts index 4228fc5..8b1b748 100644 --- a/src/features/planner/strings.ts +++ b/src/features/planner/strings.ts @@ -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: "Ангиллын лимит",