src/components/user-control.tsx
Linked repository file from this build.
"use client";
import Image from "next/image";
import { fieldClass } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useEffect, useRef, useState } from "react";
import { SignInButton, UserButton, useClerk, useUser } from "@clerk/nextjs";
import { NotificationBell } from "./notification-bell";
import { HeaderPopover } from "./header-popover";
import { Bell, Eye, Palette, UserRound, SlidersHorizontal } from "lucide-react";
import { useReviewNavigation } from "@/features/reviews/provider";
import { useSession } from "@/lib/auth/client";
import { demoRoster } from "@/lib/auth/roster";
import { roleLabels } from "@/features/reviews/types";
import { AppearanceSettings, type Appearance } from "./appearance-control";
import { subscribeAppearanceChanges, useAppearance } from "./appearance-provider";
const notifications = [
["Distributed", "New assignments"],
["Documents", "Documents from others"],
["Kickbacks", "Changes requested"],
["Approvals", "Approvals and drops"],
["FinalDecisions", "PEC and final decisions"],
["Appointments", "Appointment changes"],
] as const;
function NotificationSettings() {
const { user } = useUser();
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const supervisor = user?.publicMetadata.role === "supervisor";
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Notifications</h2>
<p>Choose what appears in your Home updates and notification bell.</p>
{[false, ...(supervisor ? [true] : [])].map((team) => (
<fieldset key={String(team)} className="space-y-3">
<legend className="mb-2 font-medium">{team ? "Team reviews" : "Your reviews"}</legend>
{notifications.map(([key, label]) => {
const metadataKey = `notify${team ? "Team" : ""}${key}`;
return (
<label key={metadataKey} className="flex items-center gap-3">
<Switch
aria-label={label}
disabled={busy}
checked={user?.unsafeMetadata[metadataKey] !== false}
onCheckedChange={async (checked) => {
setBusy(true);
setError("");
try {
await user?.updateMetadata({
unsafeMetadata: { [metadataKey]: checked },
});
window.dispatchEvent(new Event("qccrm:notifications"));
} catch {
setError("Unable to save notification settings. Try again.");
} finally {
setBusy(false);
}
}}
/>
{label}
</label>
);
})}
</fieldset>
))}
{error && <p role="alert">{error}</p>}
</div>
);
}
function ReviewSettings() {
const { user } = useUser();
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Review settings</h2>
{(
[
[
"autosave",
"Autosave after one minute",
"When off, fields save on Save, navigation, and page hide. Computation columns use their Save button.",
],
[
"showComputationDiffs",
"Show computation differences",
"Show each column's differences from Eligibility.",
],
] as const
).map(([key, label, help]) => (
<label key={key} className="block space-y-1">
<span className="flex items-center gap-3">
<Switch
aria-label={label}
disabled={busy}
checked={user?.unsafeMetadata[key] === true}
onCheckedChange={async (checked) => {
setBusy(true);
setError("");
try {
await user?.updateMetadata({ unsafeMetadata: { [key]: checked } });
} catch {
setError("Unable to save review settings. Try again.");
} finally {
setBusy(false);
}
}}
/>
{label}
</span>
<span className="block text-sm text-muted-foreground">{help}</span>
</label>
))}
{error && <p role="alert">{error}</p>}
</div>
);
}
export function UserControl() {
const { user } = useUser();
const { signOut } = useClerk();
const { actor, isSignedIn } = useSession();
const { flush } = useReviewNavigation();
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const { sync } = useAppearance();
const appearanceSaves = useRef(Promise.resolve());
const pendingAppearanceSaves = useRef(0);
const remoteTheme = user?.unsafeMetadata.theme;
const remotePalette = user?.unsafeMetadata.palette;
const remoteLayout = user?.unsafeMetadata.layout;
const remoteVision = user?.unsafeMetadata.vision;
const remoteAnimationDuration = user?.unsafeMetadata.animationDuration;
useEffect(() => {
if (!user) return;
if (
!pendingAppearanceSaves.current &&
(remoteTheme || remotePalette || remoteLayout || remoteVision || remoteAnimationDuration)
)
sync({
...user.unsafeMetadata,
theme: remoteTheme,
palette: remotePalette,
layout: remoteLayout,
vision: remoteVision,
animationDuration: remoteAnimationDuration,
});
const save = (value: Appearance) => {
pendingAppearanceSaves.current += 1;
appearanceSaves.current = appearanceSaves.current
.then(async () => {
await user.updateMetadata({ unsafeMetadata: { ...value } });
setError("");
})
.catch(() => {
setError("Appearance is saved on this device. Account sync failed; try again.");
})
.finally(() => {
pendingAppearanceSaves.current -= 1;
});
};
return subscribeAppearanceChanges(save);
}, [user, remoteTheme, remotePalette, remoteLayout, remoteVision, remoteAnimationDuration, sync]);
async function switchTo(staffId: string) {
if (!staffId || busy) return;
setBusy(true);
setError("");
try {
if (!(await flush())) throw new Error("Save this review before switching accounts.");
const response = await fetch("/api/demo/view-as", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ staffId }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error);
sessionStorage.setItem("qccrm.demo-ticket", data.ticket);
await signOut({ redirectUrl: "/demo-switch" });
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Unable to switch account.");
setBusy(false);
}
}
if (!isSignedIn)
return (
<SignInButton mode="redirect">
<button aria-label="Sign in" title="Sign in" className="app-nav-link">
<UserRound className="size-4" />
</button>
</SignInButton>
);
const showProfilePicker = process.env.NEXT_PUBLIC_VERCEL_ENV !== "production";
return (
<div className="account-tools flex items-center gap-2">
{showProfilePicker && (
<HeaderPopover label="View as" trigger={<Eye className="size-4" />}>
<h2 className="font-semibold">View as</h2>
<p className="mb-3 text-sm text-muted-foreground">Current view: {actor.name}</p>
<label htmlFor="view-as" className="sr-only">
Profile
</label>
<select
id="view-as"
aria-label="Profile"
className={fieldClass}
value=""
disabled={busy}
onChange={(event) => void switchTo(event.target.value)}
>
<option value="">{busy ? "Switching…" : "Choose a profile…"}</option>
{demoRoster
.filter((entry) => entry.staffId !== actor.staffId)
.map((entry) => (
<option key={entry.staffId} value={entry.staffId}>
{entry.name} · {roleLabels[entry.role]}
</option>
))}
</select>
</HeaderPopover>
)}
<NotificationBell />
<UserButton
fallback={
<span
className="account-profile-trigger"
role="img"
aria-label="Loading account menu"
aria-busy="true"
>
<span className="relative grid size-7 place-items-center overflow-hidden rounded-full bg-muted">
<UserRound className="size-4" aria-hidden />
{user?.imageUrl && (
<Image
unoptimized
src={user.imageUrl}
alt=""
width={28}
height={28}
className="absolute inset-0 size-7 rounded-full object-cover"
/>
)}
</span>
</span>
}
appearance={{
elements: { userButtonTrigger: "account-profile-trigger", avatarBox: "size-7" },
}}
>
<UserButton.UserProfilePage
label="Review settings"
url="review-settings"
labelIcon={<SlidersHorizontal className="size-4" />}
>
<ReviewSettings />
</UserButton.UserProfilePage>
<UserButton.UserProfilePage
label="Appearance"
url="appearance"
labelIcon={<Palette className="size-4" />}
>
<AppearanceSettings page />
</UserButton.UserProfilePage>
<UserButton.UserProfilePage
label="Notifications"
url="notifications"
labelIcon={<Bell className="size-4" />}
>
<NotificationSettings />
</UserButton.UserProfilePage>
</UserButton>
{error && (
<p role="alert" className="max-w-64 text-xs text-destructive">
{error}
</p>
)}
</div>
);
}