mercury-web/src/features/profile/SettingsForm.tsx

217 lines
7.3 KiB
TypeScript

"use client";
import * as React from "react";
import {
TextFieldRoot,
TextFieldInput,
SwitchRoot,
SwitchControl,
SwitchThumb,
SwitchLabel,
SwitchHiddenInput,
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 }))}
>
{/* The actual interactive/accessible element (role="switch",
checked/onChange) lives on the hidden input — SwitchControl and
SwitchThumb are purely decorative (aria-hidden). Without this
the switch renders but nothing is clickable or announced to
assistive tech (same bug as ds/HideAmountsToggle.tsx). */}
<SwitchHiddenInput />
<SwitchControl>
<SwitchThumb />
</SwitchControl>
<SwitchLabel>{profileStrings.settings.hideAmounts}</SwitchLabel>
</SwitchRoot>
{save.isError && (
<p role="alert" style={{ fontSize: 13, color: "var(--seed-color-fg-critical)" }}>
{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>
);
}