1
0
Fork 0
polaris-task-force/src/components/frontend/account/PreferencesForm.tsx
Z8MB1E 4c3c3373d3 feat(roster): implement leadOrActual display preference
- Fetch current user's leadOrActual preference in roster page via payload.auth()
- Add RosterDisplayContext to avoid prop drilling through org chart components
- MemberCard shows 'Lead' or 'Actual' based on user preference
- Add preference radio buttons to PreferencesForm under Display section
- Update updatePreferences server action to save leadOrActual
- Add TODO for restyling radio buttons later
2026-08-18 02:20:58 -04:00

182 lines
6.2 KiB
TypeScript

"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { MUTEABLE_NOTIFICATION_TYPES } from "@/lib/notifications/notificationTypes";
import { updatePreferences } from "@/app/(frontend)/account/actions";
interface PreferencesFormProps {
mutedAll: boolean;
mutedTypes: string[];
showCallsign: boolean;
leadOrActual: "lead" | "actual";
discordEnabled: boolean;
discordMutedTypes: string[];
}
export function PreferencesForm({
mutedAll: initialMutedAll,
mutedTypes: initialMutedTypes,
showCallsign: initialShowCallsign,
leadOrActual: initialLeadOrActual,
discordEnabled: initialDiscordEnabled,
discordMutedTypes: initialDiscordMutedTypes,
}: PreferencesFormProps) {
const router = useRouter();
const [mutedAll, setMutedAll] = useState(initialMutedAll);
const [mutedTypes, setMutedTypes] = useState<string[]>(initialMutedTypes);
const [showCallsign, setShowCallsign] = useState(initialShowCallsign);
const [leadOrActual, setLeadOrActual] = useState<"lead" | "actual">(initialLeadOrActual);
const [discordEnabled, setDiscordEnabled] = useState(initialDiscordEnabled);
const [discordMutedTypes, setDiscordMutedTypes] = useState<string[]>(initialDiscordMutedTypes);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const toggleType = (value: string, checked: boolean) => {
setMutedTypes((prev) =>
checked ? [...new Set([...prev, value])] : prev.filter((t) => t !== value),
);
};
const toggleDiscordType = (value: string, checked: boolean) => {
setDiscordMutedTypes((prev) =>
checked ? [...new Set([...prev, value])] : prev.filter((t) => t !== value),
);
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
setError(null);
setSuccess(null);
try {
const res = await updatePreferences({
mutedAll,
mutedTypes,
showCallsign,
leadOrActual,
discordEnabled,
discordMutedTypes,
});
if (!res.success) {
setError(res.error ?? "Failed to save preferences.");
return;
}
setSuccess("Preferences saved.");
router.refresh();
} catch {
setError("Something went wrong. Please try again.");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
<div className="flex flex-col gap-3">
<h3 className="text-sm font-medium">Notifications</h3>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={mutedAll} onCheckedChange={(c) => setMutedAll(c === true)} />
Mute all in-app notifications
</label>
<div
className={
mutedAll
? "flex flex-col gap-3 pl-6 opacity-50 pointer-events-none"
: "flex flex-col gap-3 pl-6"
}
>
{MUTEABLE_NOTIFICATION_TYPES.map((t) => (
<label key={t.value} className="flex items-center gap-2 text-sm">
<Checkbox
checked={mutedTypes.includes(t.value)}
onCheckedChange={(c) => toggleType(t.value, c === true)}
/>
{t.label}
</label>
))}
</div>
</div>
<div className="flex flex-col gap-3">
<h3 className="text-sm font-medium">Display</h3>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={showCallsign} onCheckedChange={(c) => setShowCallsign(c === true)} />
Show callsign (username) instead of display name
</label>
<div className="flex flex-col gap-2 pl-6">
<Label className="text-sm text-muted-foreground">
Roster leader label
</Label>
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="leadOrActual"
value="lead"
checked={leadOrActual === "lead"}
onChange={() => setLeadOrActual("lead")}
className="accent-primary"
/>
Lead
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="radio"
name="leadOrActual"
value="actual"
checked={leadOrActual === "actual"}
onChange={() => setLeadOrActual("actual")}
className="accent-primary"
/>
Actual
</label>
</div>
</div>
</div>
<div className="flex flex-col gap-3">
<h3 className="text-sm font-medium">Discord</h3>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={discordEnabled}
onCheckedChange={(c) => setDiscordEnabled(c === true)}
/>
Discord notifications (DM me about app events)
</label>
<div
className={
discordEnabled
? "flex flex-col gap-3 pl-6"
: "flex flex-col gap-3 pl-6 opacity-50 pointer-events-none"
}
>
{MUTEABLE_NOTIFICATION_TYPES.map((t) => (
<label key={t.value} className="flex items-center gap-2 text-sm">
<Checkbox
checked={discordMutedTypes.includes(t.value)}
onCheckedChange={(c) => toggleDiscordType(t.value, c === true)}
/>
Mute {t.label.toLowerCase()}
</label>
))}
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{success && <p className="text-sm text-emerald-500">{success}</p>}
<div>
<Button type="submit" disabled={loading}>
{loading && <Loader2Icon className="size-4 animate-spin" />}
{loading ? "Saving..." : "Save preferences"}
</Button>
</div>
</form>
);
}