mirror of
https://github.com/tiennm99/bsk.git
synced 2026-08-05 06:21:32 +00:00
feat(ui): clinician-focused UX pass
- responsive app shell: persistent sidebar at md+, accessible off-canvas drawer (focus trap, Esc, dialog semantics) + hamburger below md - active-route highlight, localized role labels, greet by name - visible focus rings, AA-contrast muted text, 44px primary actions - sign-in: autofocus, show/hide password, always-enabled submit - Be Vietnam Pro via next/font; prefers-reduced-motion guard; skip-link
This commit is contained in:
@@ -36,6 +36,7 @@ import { inviteUserAction } from "./actions";
|
||||
|
||||
export function InviteUserForm() {
|
||||
const t = useTranslations("admin.invite");
|
||||
const tRoles = useTranslations("roles");
|
||||
|
||||
const [state, dispatchAction, isPending] = useActionState<InviteUserState, FormData>(
|
||||
inviteUserAction,
|
||||
@@ -120,7 +121,7 @@ export function InviteUserForm() {
|
||||
>
|
||||
{appRoles.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
{tRoles(r)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -138,12 +139,7 @@ export function InviteUserForm() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isPending || (!form.formState.isValid && form.formState.isDirty)}
|
||||
aria-disabled={isPending}
|
||||
>
|
||||
<Button type="submit" size="lg" className="w-full" disabled={isPending}>
|
||||
{isPending ? t("submitting") : t("submit")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -13,20 +13,23 @@ export default async function DashboardPage({ params }: { params: Promise<{ loca
|
||||
await params;
|
||||
|
||||
const t = await getTranslations("dashboard");
|
||||
const tRoles = await getTranslations("roles");
|
||||
const session = await getServerSession();
|
||||
|
||||
// session is guaranteed non-null by the parent (app)/layout.tsx gate,
|
||||
// but we guard here to satisfy TypeScript's strict null checks.
|
||||
const email = session?.user.email ?? "";
|
||||
const role = session?.role ?? "";
|
||||
const role = session?.role;
|
||||
// Clinicians think in names, not emails — greet by full_name when set.
|
||||
const name = session?.fullName || email;
|
||||
|
||||
return (
|
||||
<div className="px-8 py-10">
|
||||
<h1 className="text-foreground text-2xl font-semibold">{t("welcome", { email })}</h1>
|
||||
<h1 className="text-foreground text-2xl font-semibold">{t("welcome", { name })}</h1>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
{t("roleLabel")}{" "}
|
||||
<span className="bg-muted text-foreground rounded px-1.5 py-0.5 text-xs font-medium">
|
||||
{role}
|
||||
{role ? tRoles(role) : ""}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-6 text-sm">{t("placeholder")}</p>
|
||||
|
||||
@@ -50,10 +50,10 @@ export default async function AppLayout({
|
||||
return null;
|
||||
}
|
||||
|
||||
const { user, role } = session;
|
||||
const { user, role, fullName } = session;
|
||||
|
||||
return (
|
||||
<AppShell email={user.email ?? ""} role={role} locale={locale}>
|
||||
<AppShell email={user.email ?? ""} fullName={fullName} role={role} locale={locale}>
|
||||
{children}
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
@@ -14,10 +14,11 @@
|
||||
* side or server-side.
|
||||
*/
|
||||
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -48,6 +49,10 @@ export function SignInForm() {
|
||||
|
||||
const { errors: fieldErrors } = form.formState;
|
||||
|
||||
// Password reveal toggle — shared clinic PCs + VN keyboards make mistyped
|
||||
// passwords common; letting the doctor verify what they typed cuts retries.
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// Sync server-returned fieldErrors into RHF so the inline error UI is
|
||||
// consistent regardless of where the error originated.
|
||||
useEffect(() => {
|
||||
@@ -83,6 +88,7 @@ export function SignInForm() {
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoFocus
|
||||
autoComplete="email"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
@@ -101,15 +107,28 @@ export function SignInForm() {
|
||||
{/* Password field */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">{t("passwordLabel")}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
disabled={isPending}
|
||||
aria-invalid={!!fieldErrors.password}
|
||||
aria-describedby={fieldErrors.password ? "password-error" : undefined}
|
||||
{...form.register("password")}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
disabled={isPending}
|
||||
className="pr-10"
|
||||
aria-invalid={!!fieldErrors.password}
|
||||
aria-describedby={fieldErrors.password ? "password-error" : undefined}
|
||||
{...form.register("password")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
disabled={isPending}
|
||||
aria-label={showPassword ? t("hidePassword") : t("showPassword")}
|
||||
aria-pressed={showPassword}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute inset-y-0 right-0 flex w-10 items-center justify-center rounded-md focus:outline-none focus-visible:ring-2"
|
||||
>
|
||||
{showPassword ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{fieldErrors.password && (
|
||||
<p id="password-error" className="text-destructive text-sm" role="alert">
|
||||
{fieldErrors.password.message}
|
||||
@@ -125,12 +144,9 @@ export function SignInForm() {
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isPending || (!form.formState.isValid && form.formState.isDirty)}
|
||||
aria-disabled={isPending}
|
||||
>
|
||||
{/* Submit stays enabled on partial/invalid input: a greyed-out primary
|
||||
button reads as "app broken". Errors surface inline on submit. */}
|
||||
<Button type="submit" size="lg" className="w-full" disabled={isPending}>
|
||||
{isPending ? t("submitting") : t("submit")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
+12
-1
@@ -4,6 +4,7 @@
|
||||
import { NextIntlClientProvider, hasLocale } from "next-intl";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Be_Vietnam_Pro } from "next/font/google";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import { routing } from "@/i18n/routing";
|
||||
@@ -12,6 +13,16 @@ import { SessionProvider } from "@/lib/auth/session-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import "../globals.css";
|
||||
|
||||
// Vietnamese-complete web font. `vietnamese` subset covers stacked diacritics
|
||||
// (ế, ữ, ộ …) that render inconsistently across the low-end machines clinics
|
||||
// actually own. Exposed as a CSS variable consumed by --font-sans in globals.css.
|
||||
const beVietnamPro = Be_Vietnam_Pro({
|
||||
subsets: ["latin", "vietnamese"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
variable: "--font-be-vietnam-pro",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "BSK Clinic",
|
||||
description: "Educational clinic management rewrite",
|
||||
@@ -58,7 +69,7 @@ export default async function LocaleLayout({
|
||||
// explicitly protected path prefixes (/dashboard, /admin).
|
||||
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<html lang={locale} className={beVietnamPro.variable} suppressHydrationWarning>
|
||||
<body>
|
||||
<NextIntlClientProvider>
|
||||
{/* Toaster is mounted once globally here so toast() calls from any
|
||||
|
||||
+15
-2
@@ -6,9 +6,10 @@
|
||||
--color-primary: oklch(0.205 0 0);
|
||||
--color-primary-foreground: oklch(0.985 0 0);
|
||||
--color-muted: oklch(0.97 0 0);
|
||||
--color-muted-foreground: oklch(0.556 0 0);
|
||||
/* Darkened from 0.556 → 0.50 for WCAG AA at small (text-xs) sizes. */
|
||||
--color-muted-foreground: oklch(0.5 0 0);
|
||||
--color-border: oklch(0.922 0 0);
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif;
|
||||
--font-sans: var(--font-be-vietnam-pro), ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: ui-monospace, "Courier New", monospace;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
@@ -35,3 +36,15 @@ body {
|
||||
color: var(--color-foreground);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Respect users who ask the OS to reduce motion (vestibular safety). */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* AppShellFrame — Client Component.
|
||||
*
|
||||
* Owns the responsive chrome: a persistent sidebar at `md+`, and an off-canvas
|
||||
* drawer + hamburger top-bar below `md`. The (server-rendered) sidebar tree is
|
||||
* passed in as the `sidebar` prop so this file stays free of data fetching.
|
||||
*
|
||||
* The mobile drawer is a proper modal: focus moves in on open, Tab is trapped,
|
||||
* Esc closes it, body scroll is locked, and focus returns to the trigger on
|
||||
* close. Uses `h-dvh` (not `h-screen`) so mobile browser chrome doesn't clip
|
||||
* the layout. Drawer auto-closes on route change.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Menu } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { usePathname } from "@/i18n/navigation";
|
||||
|
||||
const DRAWER_ID = "app-mobile-drawer";
|
||||
const MAIN_ID = "main-content";
|
||||
|
||||
type AppShellFrameProps = {
|
||||
sidebar: ReactNode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function AppShellFrame({ sidebar, children }: AppShellFrameProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations("app");
|
||||
const drawerRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Close the drawer on route change (nav tap, redirect). Adjusting state
|
||||
// during render on a changed value is the React-recommended pattern — no
|
||||
// effect, no cascading render.
|
||||
const [lastPath, setLastPath] = useState(pathname);
|
||||
if (pathname !== lastPath) {
|
||||
setLastPath(pathname);
|
||||
if (open) setOpen(false);
|
||||
}
|
||||
|
||||
// Modal behaviors while the drawer is open: move focus in, trap Tab, Esc to
|
||||
// close, lock body scroll, restore focus to the trigger on close. These are
|
||||
// external-system syncs (DOM focus / listeners / body style), the legitimate
|
||||
// use of an effect.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const drawer = drawerRef.current;
|
||||
if (!drawer) return;
|
||||
|
||||
const focusable = () =>
|
||||
Array.from(
|
||||
drawer.querySelectorAll<HTMLElement>(
|
||||
'a[href],button:not([disabled]),select,input,textarea,[tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
);
|
||||
|
||||
focusable()[0]?.focus();
|
||||
|
||||
const trigger = triggerRef.current;
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab") return;
|
||||
const items = focusable();
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
if (!first || !last) return;
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
// focus the trigger captured at open-time (ref may have changed by now).
|
||||
trigger?.focus();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh overflow-hidden">
|
||||
{/* Skip-link: first focusable, jumps keyboard users past the nav. */}
|
||||
<a
|
||||
href={`#${MAIN_ID}`}
|
||||
className="bg-background text-foreground focus-visible:ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:shadow focus-visible:ring-2"
|
||||
>
|
||||
{t("skipToContent")}
|
||||
</a>
|
||||
|
||||
{/* Persistent sidebar — desktop only. */}
|
||||
<div className="hidden md:flex">{sidebar}</div>
|
||||
|
||||
{/* Off-canvas drawer — below md, only when opened. */}
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-40 md:hidden">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
ref={drawerRef}
|
||||
id={DRAWER_ID}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("menu")}
|
||||
className="absolute inset-y-0 left-0 flex"
|
||||
>
|
||||
{sidebar}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Mobile top bar with hamburger — below md only. */}
|
||||
<header className="border-border flex h-14 shrink-0 items-center gap-2 border-b px-3 md:hidden">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label={t("openMenu")}
|
||||
aria-expanded={open}
|
||||
aria-controls={DRAWER_ID}
|
||||
className="text-foreground hover:bg-muted focus-visible:ring-ring flex size-11 items-center justify-center rounded-md focus:outline-none focus-visible:ring-2"
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</button>
|
||||
<span className="text-foreground text-lg font-bold tracking-tight">BSK</span>
|
||||
</header>
|
||||
|
||||
<main id={MAIN_ID} tabIndex={-1} className="flex-1 overflow-y-auto outline-none">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +1,30 @@
|
||||
/**
|
||||
* AppShell — Server Component.
|
||||
*
|
||||
* Composes the full authenticated layout: fixed sidebar on the left,
|
||||
* scrollable main content area on the right. Receives user info from the
|
||||
* (app) layout which has already validated session + role.
|
||||
*
|
||||
* No client state here — sidebar is server-rendered, top-bar lives inside
|
||||
* the sidebar's bottom section for Phase 1 simplicity.
|
||||
* Renders the server-side Sidebar and hands it to AppShellFrame, which owns the
|
||||
* responsive chrome (persistent at md+, off-canvas drawer below md). Receives
|
||||
* user info from the (app) layout which has already validated session + role.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { AppRole } from "@/lib/db/roles";
|
||||
import { Sidebar } from "@/components/app-shell/sidebar";
|
||||
import { AppShellFrame } from "@/components/app-shell/app-shell-frame";
|
||||
|
||||
type AppShellProps = {
|
||||
email: string;
|
||||
fullName: string | null;
|
||||
role: AppRole;
|
||||
locale: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function AppShell({ email, role, locale, children }: AppShellProps) {
|
||||
export function AppShell({ email, fullName, role, locale, children }: AppShellProps) {
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<Sidebar email={email} role={role} locale={locale} />
|
||||
<main className="flex-1 overflow-y-auto">{children}</main>
|
||||
</div>
|
||||
<AppShellFrame
|
||||
sidebar={<Sidebar email={email} fullName={fullName} role={role} locale={locale} />}
|
||||
>
|
||||
{children}
|
||||
</AppShellFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* locale prefix rewriting transparently.
|
||||
*/
|
||||
|
||||
import { useId } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { usePathname, useRouter } from "@/i18n/navigation";
|
||||
import { routing } from "@/i18n/routing";
|
||||
@@ -17,6 +18,9 @@ export function LocaleSwitcher() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations("app.localeSwitcher");
|
||||
// useId keeps the id unique even when the sidebar is rendered in two DOM
|
||||
// slots (desktop rail + mobile drawer) — no duplicate id / label collision.
|
||||
const selectId = useId();
|
||||
|
||||
function handleChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const nextLocale = e.target.value as (typeof routing.locales)[number];
|
||||
@@ -25,15 +29,15 @@ export function LocaleSwitcher() {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label htmlFor="locale-select" className="text-muted-foreground sr-only text-xs">
|
||||
<label htmlFor={selectId} className="text-muted-foreground sr-only text-xs">
|
||||
{t("label")}
|
||||
</label>
|
||||
<select
|
||||
id="locale-select"
|
||||
id={selectId}
|
||||
value={locale}
|
||||
onChange={handleChange}
|
||||
aria-label={t("label")}
|
||||
className="border-border bg-background text-foreground rounded-md border px-2 py-1 text-xs focus:outline-none"
|
||||
className="border-border bg-background text-foreground focus-visible:ring-ring rounded-md border px-2 py-2 text-xs focus:outline-none focus-visible:ring-2"
|
||||
>
|
||||
{routing.locales.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Sidebar navigation list — Client Component.
|
||||
*
|
||||
* Split out from the (Server) Sidebar so it can read the current pathname and
|
||||
* mark the active route. It imports ROLE_MENU directly (icons are client-safe
|
||||
* lucide components) rather than receiving menu items as props — component
|
||||
* types can't cross the RSC boundary.
|
||||
*
|
||||
* Active detection: next-intl's usePathname() returns the locale-stripped
|
||||
* pathname (e.g. "/dashboard"), matching the locale-relative item.href.
|
||||
*/
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Link, usePathname } from "@/i18n/navigation";
|
||||
import { ROLE_MENU } from "@/lib/auth/role-menu";
|
||||
import type { AppRole } from "@/lib/db/roles";
|
||||
|
||||
type SidebarNavProps = {
|
||||
role: AppRole;
|
||||
locale: string;
|
||||
};
|
||||
|
||||
export function SidebarNav({ role, locale }: SidebarNavProps) {
|
||||
const t = useTranslations();
|
||||
const pathname = usePathname();
|
||||
const items = ROLE_MENU[role];
|
||||
|
||||
return (
|
||||
<nav className="flex-1 overflow-y-auto px-2 py-3" aria-label="Main navigation">
|
||||
<ul className="space-y-0.5">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive =
|
||||
pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
locale={locale as "vi" | "en"}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={`flex min-h-11 items-center gap-2.5 rounded-md px-2.5 py-2 text-sm transition-colors ${
|
||||
isActive
|
||||
? "bg-muted text-foreground font-medium"
|
||||
: "text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
{t(item.labelKey as Parameters<typeof t>[0])}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -7,21 +7,20 @@
|
||||
*/
|
||||
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { ROLE_MENU } from "@/lib/auth/role-menu";
|
||||
import type { AppRole } from "@/lib/db/roles";
|
||||
import { SidebarNav } from "@/components/app-shell/sidebar-nav";
|
||||
import { SignOutButton } from "@/components/app-shell/sign-out-button";
|
||||
import { LocaleSwitcher } from "@/components/app-shell/locale-switcher";
|
||||
|
||||
type SidebarProps = {
|
||||
email: string;
|
||||
fullName: string | null;
|
||||
role: AppRole;
|
||||
locale: string;
|
||||
};
|
||||
|
||||
export async function Sidebar({ email, role, locale }: SidebarProps) {
|
||||
const t = await getTranslations();
|
||||
const items = ROLE_MENU[role];
|
||||
export async function Sidebar({ email, fullName, role, locale }: SidebarProps) {
|
||||
const tRoles = await getTranslations("roles");
|
||||
|
||||
return (
|
||||
<aside className="bg-background border-border flex h-full w-56 shrink-0 flex-col border-r">
|
||||
@@ -31,34 +30,17 @@ export async function Sidebar({ email, role, locale }: SidebarProps) {
|
||||
<p className="text-muted-foreground text-xs">Clinic Management</p>
|
||||
</div>
|
||||
|
||||
{/* User info */}
|
||||
{/* User info — greet by name when set; email drops to a secondary line. */}
|
||||
<div className="border-border border-b px-4 py-3">
|
||||
<p className="text-foreground truncate text-sm font-medium">{email}</p>
|
||||
<span className="bg-muted text-muted-foreground mt-1 inline-block rounded px-1.5 py-0.5 text-xs font-medium">
|
||||
{role}
|
||||
<p className="text-foreground truncate text-sm font-medium">{fullName || email}</p>
|
||||
{fullName && <p className="text-muted-foreground truncate text-xs">{email}</p>}
|
||||
<span className="bg-muted text-foreground mt-1 inline-block rounded px-1.5 py-0.5 text-xs font-medium">
|
||||
{tRoles(role)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
<nav className="flex-1 overflow-y-auto px-2 py-3" aria-label="Main navigation">
|
||||
<ul className="space-y-0.5">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
locale={locale as "vi" | "en"}
|
||||
className="text-foreground hover:bg-muted flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm transition-colors"
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
{t(item.labelKey as Parameters<typeof t>[0])}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
{/* Nav items (client component: marks the active route) */}
|
||||
<SidebarNav role={role} locale={locale} />
|
||||
|
||||
{/* Bottom actions */}
|
||||
<div className="border-border space-y-1 border-t px-2 py-3">
|
||||
|
||||
@@ -22,7 +22,6 @@ function SignOutButtonInner() {
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={pending}
|
||||
className="w-full justify-start gap-2"
|
||||
>
|
||||
|
||||
@@ -22,7 +22,7 @@ const buttonVariants = cva(
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
lg: "h-11 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
|
||||
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"border-input selection:bg-primary selection:text-primary-foreground file:text-foreground placeholder:text-muted-foreground dark:bg-input/30 h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"border-input selection:bg-primary selection:text-primary-foreground file:text-foreground placeholder:text-muted-foreground dark:bg-input/30 h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
|
||||
+17
-2
@@ -18,18 +18,30 @@
|
||||
"success": "Invited {email}. If the email does not arrive within 5 minutes, copy the invite link from the Supabase dashboard under Authentication → Users.",
|
||||
"errorForbidden": "You do not have permission to invite users.",
|
||||
"errorEmailTaken": "This user is already enrolled in BSK.",
|
||||
"errorGeneric": "Something went wrong. Please try again."
|
||||
"errorGeneric": "Something went wrong. Please try again.",
|
||||
"tooManyRequests": "Too many invites sent. Please try again in a few minutes."
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"admin": "Admin",
|
||||
"doctor": "Doctor",
|
||||
"nurse": "Nurse",
|
||||
"receptionist": "Receptionist",
|
||||
"cashier": "Cashier",
|
||||
"patient": "Patient"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": {
|
||||
"title": "Sign in to BSK",
|
||||
"subtitle": "Enter your credentials to access the clinic system.",
|
||||
"emailLabel": "Email",
|
||||
"passwordLabel": "Password",
|
||||
"showPassword": "Show password",
|
||||
"hidePassword": "Hide password",
|
||||
"submit": "Sign in",
|
||||
"submitting": "Signing in…",
|
||||
"invalidCredentials": "Invalid email or password.",
|
||||
"tooManyAttempts": "Too many attempts. Please try again in a few minutes.",
|
||||
"genericError": "Something went wrong. Please try again."
|
||||
}
|
||||
},
|
||||
@@ -50,6 +62,9 @@
|
||||
},
|
||||
"app": {
|
||||
"signOut": "Sign out",
|
||||
"openMenu": "Open navigation menu",
|
||||
"menu": "Navigation menu",
|
||||
"skipToContent": "Skip to content",
|
||||
"unenrolledError": "Your account is not enrolled. Please contact an administrator.",
|
||||
"localeSwitcher": {
|
||||
"label": "Language",
|
||||
@@ -60,7 +75,7 @@
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"welcome": "Welcome, {email}",
|
||||
"welcome": "Welcome, {name}",
|
||||
"roleLabel": "Your role:",
|
||||
"placeholder": "Dashboard content is coming in a future phase."
|
||||
}
|
||||
|
||||
+17
-2
@@ -18,18 +18,30 @@
|
||||
"success": "Đã mời {email}. Nếu email không đến trong vòng 5 phút, hãy sao chép liên kết mời từ bảng điều khiển Supabase tại Authentication → Users.",
|
||||
"errorForbidden": "Bạn không có quyền mời người dùng.",
|
||||
"errorEmailTaken": "Người dùng này đã được đăng ký trong BSK.",
|
||||
"errorGeneric": "Đã xảy ra lỗi. Vui lòng thử lại."
|
||||
"errorGeneric": "Đã xảy ra lỗi. Vui lòng thử lại.",
|
||||
"tooManyRequests": "Bạn đã gửi quá nhiều lời mời. Vui lòng thử lại sau ít phút."
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"admin": "Quản trị",
|
||||
"doctor": "Bác sĩ",
|
||||
"nurse": "Điều dưỡng",
|
||||
"receptionist": "Lễ tân",
|
||||
"cashier": "Thu ngân",
|
||||
"patient": "Bệnh nhân"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": {
|
||||
"title": "Đăng nhập BSK",
|
||||
"subtitle": "Nhập thông tin đăng nhập để truy cập hệ thống phòng khám.",
|
||||
"emailLabel": "Email",
|
||||
"passwordLabel": "Mật khẩu",
|
||||
"showPassword": "Hiện mật khẩu",
|
||||
"hidePassword": "Ẩn mật khẩu",
|
||||
"submit": "Đăng nhập",
|
||||
"submitting": "Đang đăng nhập…",
|
||||
"invalidCredentials": "Email hoặc mật khẩu không đúng.",
|
||||
"tooManyAttempts": "Quá nhiều lần thử. Vui lòng thử lại sau ít phút.",
|
||||
"genericError": "Đã xảy ra lỗi. Vui lòng thử lại."
|
||||
}
|
||||
},
|
||||
@@ -50,6 +62,9 @@
|
||||
},
|
||||
"app": {
|
||||
"signOut": "Đăng xuất",
|
||||
"openMenu": "Mở menu điều hướng",
|
||||
"menu": "Menu điều hướng",
|
||||
"skipToContent": "Bỏ qua đến nội dung",
|
||||
"unenrolledError": "Tài khoản của bạn chưa được đăng ký. Vui lòng liên hệ quản trị viên.",
|
||||
"localeSwitcher": {
|
||||
"label": "Ngôn ngữ",
|
||||
@@ -60,7 +75,7 @@
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"welcome": "Xin chào, {email}",
|
||||
"welcome": "Xin chào, {name}",
|
||||
"roleLabel": "Vai trò của bạn:",
|
||||
"placeholder": "Nội dung trang chủ sẽ được bổ sung trong giai đoạn tiếp theo."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user