217 lines
7.3 KiB
TypeScript
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>
|
|
);
|
|
}
|