Configuración
BetaPerfil, cuenta y notificaciones en pestañas, con guardado por sección, campos bloqueados con motivo y borrado con confirmación.
registry/examples/templates/settings.tsx"use client";
import { useState } from "react";
import { Avatar } from "@/registry/ds/avatar";
import { Breadcrumb, BreadcrumbItem } from "@/registry/ds/breadcrumb";
import { Button } from "@/registry/ds/button";
import { Card, CardBody, CardDescription, CardFooter, CardTitle } from "@/registry/ds/card";
import { Divider } from "@/registry/ds/divider";
import { AlertTriangle, BarChart, Bell, BookOpen, Calendar, FileText, Home, LogOut, Settings, User } from "@/registry/ds/icons";
import { Input, Textarea } from "@/registry/ds/input";
import { Menu, MenuItem, MenuSeparator } from "@/registry/ds/menu";
import { Modal, ModalBody, ModalClose, ModalFooter, ModalHeader } from "@/registry/ds/modal";
import { Navbar, NavbarActions, NavbarBrand, NavbarMobile } from "@/registry/ds/navbar";
import { Select } from "@/registry/ds/select";
import { Sidebar, SidebarGroup, SidebarItem, SidebarNav } from "@/registry/ds/sidebar";
import { Switch } from "@/registry/ds/switch";
import { Tab, TabList, TabPanel, Tabs } from "@/registry/ds/tabs";
import { ToastProvider, useToast } from "@/registry/ds/toast";
import { ToggleGroup, ToggleItem } from "@/registry/ds/toggle-group";
import { Tooltip, TooltipProvider } from "@/registry/ds/tooltip";
const Logo = () => (
<svg viewBox="0 0 24 24" aria-hidden>
<rect width="24" height="24" rx="6" fill="var(--primary)" />
<path d="M7 12.5l3.5 3.5L17 9" stroke="var(--primary-fg)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
);
const sedes = ["Campus Norte", "Campus Centro", "Campus Sur"].map((s) => ({ label: s, value: s }));
const idiomas = [
{ label: "Español", value: "es" },
{ label: "English", value: "en" },
{ label: "Português", value: "pt" },
];
const avisos = [
{ id: "notas", label: "Notas publicadas", supporting: "Cuando un docente carga una nota tuya.", on: true },
{ id: "finales", label: "Fechas de finales", supporting: "Apertura de inscripción y recordatorio 48 h antes.", on: true },
{ id: "cupos", label: "Cupos liberados", supporting: "Si se libera un lugar en una materia que seguís.", on: false },
{ id: "pagos", label: "Pagos y cuotas", supporting: "Vencimientos y comprobantes.", on: true },
];
const nav = (
<>
<SidebarItem href="#" icon={<Home />}>
Inicio
</SidebarItem>
<SidebarGroup label="Académico">
<SidebarItem href="#" icon={<BookOpen />}>
Materias
</SidebarItem>
<SidebarItem href="#" icon={<Calendar />}>
Calendario
</SidebarItem>
<SidebarItem href="#" icon={<BarChart />}>
Notas
</SidebarItem>
<SidebarItem href="#" icon={<FileText />}>
Certificados
</SidebarItem>
</SidebarGroup>
<SidebarGroup label="Cuenta">
<SidebarItem href="#" icon={<Settings />} active>
Configuración
</SidebarItem>
</SidebarGroup>
</>
);
function SettingsPage() {
const toast = useToast();
const [confirm, setConfirm] = useState("");
const [deleteOpen, setDeleteOpen] = useState(false);
const [saving, setSaving] = useState<string | null>(null);
const save = (section: string) => {
setSaving(section);
window.setTimeout(() => {
setSaving(null);
toast({ color: "success", title: "Cambios guardados" });
}, 700);
};
return (
<div className="flex min-h-dvh flex-col bg-bg-subtle">
<Navbar collapse="md" width="full">
<NavbarBrand href="#">
<Logo />
Campus
</NavbarBrand>
<NavbarActions>
<Tooltip content="Notificaciones">
<Button variant="text" size="sm" iconOnly={<Bell />} aria-label="Notificaciones" />
</Tooltip>
<Menu
align="end"
trigger={
<button type="button" aria-label="Cuenta de Julia Arce" className="rounded-full focus-visible:shadow-[0_0_0_2px_var(--focus-ring)] focus-visible:outline-none">
<Avatar name="Julia Arce" size="sm" />
</button>
}
>
<MenuItem icon={<User />}>Mi perfil</MenuItem>
<MenuItem icon={<Settings />} href="#">
Configuración
</MenuItem>
<MenuSeparator />
<MenuItem icon={<LogOut />} destructive>
Cerrar sesión
</MenuItem>
</Menu>
<NavbarMobile>{nav}</NavbarMobile>
</NavbarActions>
</Navbar>
<div className="flex flex-1">
<Sidebar collapsible className="max-md:hidden sticky top-14 h-[calc(100dvh-3.5rem)] self-start">
<SidebarNav>{nav}</SidebarNav>
</Sidebar>
<main className="min-w-0 flex-1">
<div className="mx-auto w-full max-w-4xl space-y-6 p-4 sm:p-6 lg:p-8">
<Breadcrumb size="sm">
<BreadcrumbItem href="#" separator={false} icon={<Home />}>
Inicio
</BreadcrumbItem>
<BreadcrumbItem current>Configuración</BreadcrumbItem>
</Breadcrumb>
<header>
<h1 className="text-heading-sm font-semibold text-fg">Configuración</h1>
<p className="mt-1 text-sm text-fg-muted">Tu perfil, el acceso a la cuenta y qué avisos querés recibir.</p>
</header>
<Tabs defaultValue="perfil">
<TabList aria-label="Secciones de configuración">
<Tab value="perfil">Perfil</Tab>
<Tab value="cuenta">Cuenta</Tab>
<Tab value="avisos">Notificaciones</Tab>
</TabList>
<TabPanel value="perfil" className="mt-6">
<Card>
<CardBody className="gap-6">
<div className="flex flex-wrap items-center gap-4">
<Avatar name="Julia Arce" size="xl" />
<div className="flex gap-2">
<Button variant="stroke" size="sm">
Cambiar foto
</Button>
<Button variant="text" size="sm">
Quitar
</Button>
</div>
</div>
<div className="grid gap-5 sm:grid-cols-2">
<Input label="Nombre" defaultValue="Julia" autoComplete="given-name" required />
<Input label="Apellido" defaultValue="Arce" autoComplete="family-name" required />
</div>
<Input label="Correo institucional" type="email" defaultValue="julia.arce@campus.edu" disabled hint="Lo administra la universidad. Para cambiarlo, escribí a alumnos@campus.edu." />
<Textarea label="Sobre vos" placeholder="Qué estudiás, qué te interesa…" hint="Lo ven docentes y compañeros de tus materias." defaultValue="Estudiante de Ingeniería en Sistemas, tercer año. Me interesan los datos y la accesibilidad." />
<div className="grid gap-5 sm:grid-cols-2">
<Select label="Sede principal" options={sedes} defaultValue="Campus Centro" />
<Select label="Idioma de la interfaz" options={idiomas} defaultValue="es" />
</div>
</CardBody>
<CardFooter>
<span className="text-xs text-fg-muted">Última edición: hace 3 días</span>
<Button onClick={() => save("perfil")} loading={saving === "perfil"}>
Guardar cambios
</Button>
</CardFooter>
</Card>
</TabPanel>
<TabPanel value="cuenta" className="mt-6 space-y-6">
<Card>
<CardBody className="gap-5">
<div>
<CardTitle as="h2">Contraseña</CardTitle>
<CardDescription>Mínimo 8 caracteres. Cambiarla cierra la sesión en los demás dispositivos.</CardDescription>
</div>
<Input label="Contraseña actual" type="password" autoComplete="current-password" />
<div className="grid gap-5 sm:grid-cols-2">
<Input label="Nueva contraseña" type="password" autoComplete="new-password" />
<Input label="Repetir contraseña" type="password" autoComplete="new-password" />
</div>
</CardBody>
<CardFooter>
<span />
<Button onClick={() => save("cuenta")} loading={saving === "cuenta"}>
Actualizar contraseña
</Button>
</CardFooter>
</Card>
<Card>
<CardBody className="gap-4">
<div>
<CardTitle as="h2">Seguridad</CardTitle>
<CardDescription>Capas extra para proteger tu cuenta.</CardDescription>
</div>
<Switch label="Verificación en dos pasos" supporting="Te pedimos un código del celular al ingresar desde un dispositivo nuevo." defaultChecked />
<Divider />
<Switch label="Avisarme de ingresos nuevos" supporting="Un correo cada vez que alguien entra a tu cuenta desde otro lugar." defaultChecked />
</CardBody>
</Card>
<Card className="border-danger-border">
<CardBody>
<CardTitle as="h2">Eliminar la cuenta</CardTitle>
<CardDescription>Se borran tus datos personales y preferencias. Las notas y certificados oficiales quedan en la universidad.</CardDescription>
<div className="mt-2">
<Button variant="stroke" onClick={() => setDeleteOpen(true)}>
Eliminar mi cuenta…
</Button>
</div>
</CardBody>
</Card>
</TabPanel>
<TabPanel value="avisos" className="mt-6">
<Card>
<CardBody className="gap-4">
<div>
<CardTitle as="h2">Avisos por correo</CardTitle>
<CardDescription>Elegí qué te mandamos. Los avisos del sistema (seguridad, pagos vencidos) llegan siempre.</CardDescription>
</div>
{avisos.map((a, i) => (
<div key={a.id}>
{i > 0 && <Divider className="mb-4" />}
<Switch label={a.label} supporting={a.supporting} defaultChecked={a.on} />
</div>
))}
<Divider />
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-fg" id="resumen-label">
Resumen semanal
</span>
<ToggleGroup aria-labelledby="resumen-label" size="sm" defaultValue={["lunes"]}>
<ToggleItem value="lunes">Lunes</ToggleItem>
<ToggleItem value="viernes">Viernes</ToggleItem>
<ToggleItem value="nunca">Nunca</ToggleItem>
</ToggleGroup>
</div>
</CardBody>
<CardFooter>
<span />
<Button onClick={() => save("avisos")} loading={saving === "avisos"}>
Guardar preferencias
</Button>
</CardFooter>
</Card>
</TabPanel>
</Tabs>
</div>
</main>
</div>
<Modal open={deleteOpen} onOpenChange={setDeleteOpen} size="sm">
<ModalHeader icon={<AlertTriangle />} title="¿Eliminar tu cuenta?" subtitle="Esta acción no se puede deshacer." closable={false} />
<ModalBody className="space-y-4">
<p>Vas a perder tus preferencias, el historial de inscripciones y los archivos que subiste.</p>
<Input label={<>Escribí <strong>eliminar</strong> para confirmar</>} value={confirm} onChange={(e) => setConfirm(e.target.value)} autoComplete="off" />
</ModalBody>
<ModalFooter>
<ModalClose>
<Button variant="stroke">Cancelar</Button>
</ModalClose>
<Button
disabled={confirm.trim().toLowerCase() !== "eliminar"}
onClick={() => {
setDeleteOpen(false);
setConfirm("");
toast({ color: "info", title: "Solicitud enviada", description: "Te escribimos para confirmar en las próximas 24 horas." });
}}
>
Eliminar cuenta
</Button>
</ModalFooter>
</Modal>
</div>
);
}
/**
* Template Settings — Configuración de cuenta en pestañas (perfil, cuenta, notificaciones) con guardado por sección y borrado con confirmación.
*/
export default function SettingsTemplate() {
return (
<TooltipProvider>
<ToastProvider>
<SettingsPage />
</ToastProvider>
</TooltipProvider>
);
}Qué incluye
- Tabs
underlinecon una Card por sección y su propio botón de guardar conloading. - Perfil: Avatar
xl, Input, Textarea, dos Select. - Cuenta: contraseñas con
autoComplete, Switch de seguridad separados por Divider, y la zona de borrado con Modal de confirmación. - Notificaciones: lista de Switch con línea de apoyo y un Toggle group para la frecuencia.
- Toast de confirmación en cada guardado.
Decisiones
- Guardar por sección, no un botón global. Cada card es una unidad: el
usuario sabe qué está guardando y el
loadingqueda donde hizo clic. - El correo está deshabilitado con el motivo en el hint. Un campo bloqueado sin explicación parece un bug.
- Borrar la cuenta exige escribir "eliminar". La acción es irreversible:
el Modal no tiene ✕ (
closable={false}), el botón queda deshabilitado hasta que la palabra coincide y el resultado se confirma por otro canal. - El botón de borrado es
stroke, no rojo. Polen hoy tiene un solo color de Button; el peligro lo marcan el bordedangerde la card, el icono y el texto del Modal. - Los Switch llevan
supporting: qué pasa exactamente si lo prendés.
Cómo adaptarlo
- Conectá
save(section)con tu API y mostrá el error con un Alertdangerarriba de la card si falla. - Si hay más de cuatro secciones, pasá de Tabs a un Sidebar secundario a la izquierda.
Instalación
npx shadcn@latest add https://polen.cdc.cool/r/template-settings.json