"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Bell, Check, CircleAlert, CreditCard, KeyRound, Loader2, Search, Shield, User, UserCog } from "lucide-react";
import { cn } from "@/lib/utils";
import {
DEFAULT_DRAFT,
DEFAULT_INVOICES,
DEFAULT_KEYS,
DEFAULT_PLAN,
DEFAULT_SESSIONS,
DEFAULT_USAGE,
NOTIFICATION_EVENTS,
type ApiKey,
type Invoice,
type NotificationEvent,
type Plan,
type Session,
type SettingsDraft,
type SettingsSectionId,
type UsageMeter,
} from "./settings-data";
import { AccountSection, ProfileSection, validateDraft } from "./settings-profile";
import { NotificationsSection } from "./settings-notifications";
import { BillingSection } from "./settings-billing";
import { SecuritySection } from "./settings-security";
import { ApiKeysSection } from "./settings-api-keys";
import { buttonCls, focusRing, initials } from "./settings-ui";
export type * from "./settings-data";
export interface SettingsPageProps {
brand?: string;
initial?: SettingsDraft;
initialSection?: SettingsSectionId;
notificationEvents?: NotificationEvent[];
plan?: Plan;
usage?: UsageMeter[];
invoices?: Invoice[];
sessions?: Session[];
apiKeys?: ApiKey[];
/** Persist the draft. Resolve when saved; throw to keep the bar open with an error. */
onSave?: (draft: SettingsDraft) => Promise<void>;
className?: string;
}
const NAV: { id: SettingsSectionId; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
{ id: "profile", label: "Profile", icon: User },
{ id: "account", label: "Account", icon: UserCog },
{ id: "notifications", label: "Notifications", icon: Bell },
{ id: "billing", label: "Billing", icon: CreditCard },
{ id: "security", label: "Security", icon: Shield },
{ id: "api-keys", label: "API keys", icon: KeyRound },
];
function countChanges(a: SettingsDraft, b: SettingsDraft) {
let n = 0;
(Object.keys(a.profile) as (keyof SettingsDraft["profile"])[]).forEach((k) => a.profile[k] !== b.profile[k] && n++);
(Object.keys(a.account) as (keyof SettingsDraft["account"])[]).forEach((k) => a.account[k] !== b.account[k] && n++);
Object.keys(a.notifications).forEach((id) =>
(["email", "push", "inapp"] as const).forEach((c) => a.notifications[id]?.[c] !== b.notifications[id]?.[c] && n++),
);
return n;
}
export function SettingsPage({
brand = "Northwind",
initial = DEFAULT_DRAFT,
initialSection = "profile",
notificationEvents = NOTIFICATION_EVENTS,
plan = DEFAULT_PLAN,
usage = DEFAULT_USAGE,
invoices = DEFAULT_INVOICES,
sessions = DEFAULT_SESSIONS,
apiKeys = DEFAULT_KEYS,
onSave,
className,
}: SettingsPageProps) {
const reduce = useReducedMotion();
const uid = React.useId();
const [section, setSection] = React.useState<SettingsSectionId>(initialSection);
const [saved, setSaved] = React.useState(initial);
const [draft, setDraft] = React.useState(initial);
const [saving, setSaving] = React.useState(false);
const [showErrors, setShowErrors] = React.useState(false);
const [saveError, setSaveError] = React.useState("");
const [toast, setToast] = React.useState<{ id: number; msg: string } | null>(null);
const [query, setQuery] = React.useState("");
const mainRef = React.useRef<HTMLDivElement>(null);
const changes = countChanges(draft, saved);
const dirty = changes > 0;
const errors = validateDraft(draft);
const errorCount = Object.keys(errors).length;
const notify = React.useCallback((msg: string) => setToast({ id: Date.now(), msg }), []);
React.useEffect(() => {
if (!toast) return;
const t = setTimeout(() => setToast(null), 2600);
return () => clearTimeout(t);
}, [toast]);
const save = React.useCallback(async () => {
if (!dirty || saving) return;
if (errorCount) {
setShowErrors(true);
const k = Object.keys(errors)[0];
const target = k === "email" ? "account" : "profile";
setSection(target);
setTimeout(() => document.getElementById(`${uid}-${k}`)?.focus(), 80);
return;
}
setSaving(true);
setSaveError("");
try {
if (onSave) await onSave(draft);
else await new Promise((r) => setTimeout(r, 900));
setSaved(draft);
setShowErrors(false);
notify("Changes saved");
} catch (e) {
setSaveError(e instanceof Error ? e.message : "Couldn't save. Try again.");
} finally {
setSaving(false);
}
}, [dirty, saving, errorCount, errors, onSave, draft, notify, uid]);
const reset = () => {
setDraft(saved);
setShowErrors(false);
setSaveError("");
};
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") {
if (!dirty) return;
e.preventDefault();
save();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [dirty, save]);
const go = (id: SettingsSectionId, el?: HTMLElement) => {
setSection(id);
el?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: reduce ? "auto" : "smooth" });
mainRef.current?.scrollTo({ top: 0, behavior: reduce ? "auto" : "smooth" });
};
const navItems = NAV.filter((n) => n.label.toLowerCase().includes(query.trim().toLowerCase()));
const sectionHasErrors = (id: SettingsSectionId) => showErrors && ((id === "profile" && Object.keys(errors).some((k) => k !== "email")) || (id === "account" && !!errors.email));
return (
<section className={cn("relative flex h-[800px] w-full flex-col overflow-hidden bg-background text-foreground md:flex-row", className)}>
{/* Sidebar (desktop) / tab strip (mobile) */}
<aside className="shrink-0 border-b bg-muted/30 md:w-60 md:border-b-0 md:border-r lg:w-64">
<div className="flex items-center gap-2.5 px-4 pt-4 md:px-5 md:pt-6">
<span className="grid size-8 place-items-center rounded-full bg-gradient-to-br from-indigo-500 to-fuchsia-500 text-xs font-semibold text-white">{initials(saved.profile.name)}</span>
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{saved.profile.name}</p>
<p className="truncate text-xs text-muted-foreground">{brand} workspace</p>
</div>
</div>
<div className="relative mx-5 mt-5 hidden md:block">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<label htmlFor={`${uid}-navsearch`} className="sr-only">
Search settings
</label>
<input
id={`${uid}-navsearch`}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search settings"
className="h-8 w-full rounded-md border bg-background pl-8 pr-2 text-sm outline-none transition placeholder:text-muted-foreground/70 focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/20"
/>
</div>
<nav aria-label="Settings sections" className="mt-3 md:mt-4">
<ul className="flex gap-1 overflow-x-auto px-3 pb-2 [scrollbar-width:none] md:flex-col md:gap-0.5 md:overflow-visible md:px-3 md:pb-0">
{navItems.map((n) => {
const active = section === n.id;
const Icon = n.icon;
return (
<li key={n.id} className="shrink-0">
<button
type="button"
onClick={(e) => go(n.id, e.currentTarget)}
aria-current={active ? "page" : undefined}
className={cn(
"relative flex h-9 w-full items-center gap-2.5 whitespace-nowrap rounded-lg px-3 text-sm transition-colors",
focusRing,
active ? "font-medium text-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
>
{active && (
<motion.span
layoutId={`${uid}-nav`}
className="absolute inset-0 rounded-lg bg-background shadow-xs ring-1 ring-border"
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 38 }}
/>
)}
<Icon className="relative size-4" />
<span className="relative">{n.label}</span>
{sectionHasErrors(n.id) && <span className="relative ml-auto size-1.5 rounded-full bg-destructive" aria-label="has errors" />}
</button>
</li>
);
})}
{!navItems.length && <li className="px-3 py-2 text-xs text-muted-foreground">No matching settings</li>}
</ul>
</nav>
</aside>
{/* Content */}
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
<div ref={mainRef} className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-3xl px-4 pb-28 pt-6 sm:px-8 md:pt-10">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={section}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
transition={{ duration: 0.18 }}
>
{section === "profile" && <ProfileSection uid={uid} value={draft.profile} onChange={(profile) => setDraft((d) => ({ ...d, profile }))} errors={errors} showErrors={showErrors} />}
{section === "account" && (
<AccountSection uid={uid} value={draft.account} onChange={(account) => setDraft((d) => ({ ...d, account }))} errors={errors} showErrors={showErrors} username={saved.profile.username} onToast={notify} />
)}
{section === "notifications" && <NotificationsSection events={notificationEvents} value={draft.notifications} onChange={(notifications) => setDraft((d) => ({ ...d, notifications }))} />}
{section === "billing" && <BillingSection plan={plan} usage={usage} invoices={invoices} onToast={notify} />}
{section === "security" && <SecuritySection uid={uid} sessions={sessions} onToast={notify} />}
{section === "api-keys" && <ApiKeysSection uid={uid} keys={apiKeys} brand={brand} onToast={notify} />}
</motion.div>
</AnimatePresence>
</div>
</div>
{/* Unsaved changes bar */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 px-3 pb-4 sm:px-6">
<AnimatePresence>
{dirty && (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 40, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: 40, scale: 0.98 }}
transition={{ type: "spring", stiffness: 420, damping: 34 }}
role="region"
aria-label="Unsaved changes"
className="pointer-events-auto mx-auto flex max-w-2xl flex-wrap items-center gap-x-3 gap-y-2 rounded-xl border bg-popover/95 p-2.5 pl-4 text-popover-foreground shadow-2xl shadow-black/15 backdrop-blur-md"
>
<span className={cn("grid size-6 shrink-0 place-items-center rounded-full", showErrors && errorCount ? "bg-destructive/12 text-destructive" : "bg-amber-500/15 text-amber-600 dark:text-amber-400")}>
<CircleAlert className="size-3.5" />
</span>
<p className="min-w-0 flex-1 text-sm" aria-live="polite">
{saveError ? (
<span className="text-destructive">{saveError}</span>
) : showErrors && errorCount ? (
<span className="font-medium text-destructive">
Fix {errorCount} error{errorCount > 1 ? "s" : ""} before saving
</span>
) : (
<>
<span className="font-medium">Unsaved changes</span>
<span className="hidden text-muted-foreground sm:inline">
{" "}
· {changes} field{changes > 1 ? "s" : ""} edited
</span>
</>
)}
</p>
<div className="flex gap-2">
<button type="button" onClick={reset} disabled={saving} className={buttonCls("ghost", "sm")}>
Reset
</button>
<button type="button" onClick={save} disabled={saving} className={buttonCls("primary", "sm")}>
{saving ? <Loader2 className="size-3.5 animate-spin" /> : null}
{saving ? "Saving…" : "Save changes"}
{!saving && <kbd className="ml-1 hidden rounded border border-background/25 px-1 font-sans text-[10px] opacity-70 sm:inline">⌘S</kbd>}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Toast */}
<div className="pointer-events-none absolute right-4 top-4 z-30" aria-live="polite" role="status">
<AnimatePresence>
{toast && (
<motion.div
key={toast.id}
initial={{ opacity: 0, y: -10, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -10, scale: 0.96 }}
className="flex items-center gap-2 rounded-lg border bg-popover px-3 py-2 text-sm text-popover-foreground shadow-lg"
>
<span className="grid size-5 place-items-center rounded-full bg-emerald-500 text-white">
<Check className="size-3" strokeWidth={3} />
</span>
{toast.msg}
</motion.div>
)}
</AnimatePresence>
</div>
</section>
);
}