feat(i18n): translate dashboard layout components

- Added `Sidebar`, `Header`, and `ModeToggle` namespaces to English dictionary.
- Refactored `sidebar.tsx` and `header.tsx` to use dynamic translations.
- Updated accessibility labels for navigation and theme switcher.
This commit is contained in:
Ivan Mercedes 2026-07-07 13:43:06 -04:00
parent 79565c1fbe
commit 07b20f42d4
4 changed files with 110 additions and 53 deletions

View File

@ -13,5 +13,51 @@
"signIn": "Sign in", "signIn": "Sign in",
"noAccount": "Don't have an account?", "noAccount": "Don't have an account?",
"createAccount": "Create account" "createAccount": "Create account"
},
"Sidebar": {
"title": "CRM Template for WhatsApp",
"dashboard": "Dashboard",
"inbox": "Inbox",
"notifications": "Notifications",
"contacts": "Contacts",
"pipelines": "Pipelines",
"broadcasts": "Broadcasts",
"automations": "Automations",
"flows": "Flows",
"aiAgents": "AI Agents",
"settings": "Settings",
"beta": "Beta",
"unreadConversations": "{count} unread {count, plural, =1 {conversation} other {conversations}}",
"unreadNotifications": "{count} unread {count, plural, =1 {notification} other {notifications}}",
"roleOwner": "Owner",
"roleAdmin": "Admin",
"roleAgent": "Agent",
"roleViewer": "Viewer",
"closeMenu": "Close menu",
"defaultUser": "User",
"defaultAvatar": "Avatar",
"menuProfile": "Profile",
"menuSettings": "Settings",
"menuSignOut": "Sign out"
},
"Header": {
"dashboard": "Dashboard",
"inbox": "Inbox",
"notifications": "Notifications",
"contacts": "Contacts",
"pipelines": "Pipelines",
"broadcasts": "Broadcasts",
"automations": "Automations",
"settings": "Settings",
"openMenu": "Open menu",
"openAccountMenu": "Open account menu",
"defaultUser": "User",
"defaultAvatar": "Avatar",
"menuProfile": "Profile",
"menuSettings": "Settings",
"menuSignOut": "Sign out"
},
"ModeToggle": {
"switchMode": "Switch to {mode} mode"
} }
} }

View File

@ -19,22 +19,22 @@ import {
import { ModeToggle } from "@/components/layout/mode-toggle"; import { ModeToggle } from "@/components/layout/mode-toggle";
const pageTitles: Record<string, string> = { const pageTitles: Record<string, string> = {
"/dashboard": "Dashboard", "/dashboard": "dashboard",
"/inbox": "Inbox", "/inbox": "inbox",
"/notifications": "Notifications", "/notifications": "notifications",
"/contacts": "Contacts", "/contacts": "contacts",
"/pipelines": "Pipelines", "/pipelines": "pipelines",
"/broadcasts": "Broadcasts", "/broadcasts": "broadcasts",
"/automations": "Automations", "/automations": "automations",
"/settings": "Settings", "/settings": "settings",
}; };
function getPageTitle(pathname: string): string { function getPageTitleKey(pathname: string): string {
if (pageTitles[pathname]) return pageTitles[pathname]; if (pageTitles[pathname]) return pageTitles[pathname];
const match = Object.entries(pageTitles).find(([path]) => const match = Object.entries(pageTitles).find(([path]) =>
pathname.startsWith(path), pathname.startsWith(path),
); );
return match ? match[1] : "Dashboard"; return match ? match[1] : "dashboard";
} }
interface HeaderProps { interface HeaderProps {
@ -43,10 +43,13 @@ interface HeaderProps {
onOpenSidebar?: () => void; onOpenSidebar?: () => void;
} }
import { useTranslations } from "next-intl";
export function Header({ onOpenSidebar }: HeaderProps) { export function Header({ onOpenSidebar }: HeaderProps) {
const t = useTranslations("Header");
const pathname = usePathname(); const pathname = usePathname();
const { profile, signOut } = useAuth(); const { profile, signOut } = useAuth();
const title = getPageTitle(pathname); const titleKey = getPageTitleKey(pathname);
const initial = const initial =
profile?.full_name?.charAt(0)?.toUpperCase() ?? profile?.full_name?.charAt(0)?.toUpperCase() ??
@ -60,13 +63,13 @@ export function Header({ onOpenSidebar }: HeaderProps) {
<button <button
type="button" type="button"
onClick={onOpenSidebar} onClick={onOpenSidebar}
aria-label="Open menu" aria-label={t("openMenu")}
className="flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground lg:hidden" className="flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground lg:hidden"
> >
<Menu className="h-5 w-5" /> <Menu className="h-5 w-5" />
</button> </button>
<h1 className="truncate text-base font-semibold text-foreground sm:text-lg"> <h1 className="truncate text-base font-semibold text-foreground sm:text-lg">
{title} {t(titleKey as any)}
</h1> </h1>
</div> </div>
@ -76,13 +79,13 @@ export function Header({ onOpenSidebar }: HeaderProps) {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
className="flex items-center gap-2 rounded-md px-1 py-1 transition-colors hover:bg-muted/70 focus:bg-muted/70 focus:outline-none data-popup-open:bg-muted/70 sm:gap-3 sm:pl-1 sm:pr-3" className="flex items-center gap-2 rounded-md px-1 py-1 transition-colors hover:bg-muted/70 focus:bg-muted/70 focus:outline-none data-popup-open:bg-muted/70 sm:gap-3 sm:pl-1 sm:pr-3"
aria-label="Open account menu" aria-label={t("openAccountMenu")}
> >
<Avatar className="size-8"> <Avatar className="size-8">
{profile?.avatar_url ? ( {profile?.avatar_url ? (
<AvatarImage <AvatarImage
src={profile.avatar_url} src={profile.avatar_url}
alt={profile.full_name ?? "Avatar"} alt={profile.full_name ?? t("defaultAvatar")}
/> />
) : null} ) : null}
<AvatarFallback className="bg-primary/10 text-sm font-medium text-primary"> <AvatarFallback className="bg-primary/10 text-sm font-medium text-primary">
@ -90,7 +93,7 @@ export function Header({ onOpenSidebar }: HeaderProps) {
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<span className="hidden text-sm font-medium text-foreground sm:inline"> <span className="hidden text-sm font-medium text-foreground sm:inline">
{profile?.full_name ?? "User"} {profile?.full_name ?? t("defaultUser")}
</span> </span>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
@ -100,7 +103,7 @@ export function Header({ onOpenSidebar }: HeaderProps) {
> >
<div className="px-2 py-1.5"> <div className="px-2 py-1.5">
<p className="truncate text-sm font-medium text-foreground"> <p className="truncate text-sm font-medium text-foreground">
{profile?.full_name ?? "User"} {profile?.full_name ?? t("defaultUser")}
</p> </p>
<p className="truncate text-xs text-muted-foreground"> <p className="truncate text-xs text-muted-foreground">
{profile?.email ?? ""} {profile?.email ?? ""}
@ -116,7 +119,7 @@ export function Header({ onOpenSidebar }: HeaderProps) {
} }
> >
<User className="size-4" /> <User className="size-4" />
Profile {t("menuProfile")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
render={ render={
@ -127,7 +130,7 @@ export function Header({ onOpenSidebar }: HeaderProps) {
} }
> >
<SettingsIcon className="size-4" /> <SettingsIcon className="size-4" />
Settings {t("menuSettings")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator className="bg-border" /> <DropdownMenuSeparator className="bg-border" />
<DropdownMenuItem <DropdownMenuItem
@ -135,7 +138,7 @@ export function Header({ onOpenSidebar }: HeaderProps) {
className="text-popover-foreground focus:bg-accent focus:text-accent-foreground" className="text-popover-foreground focus:bg-accent focus:text-accent-foreground"
> >
<LogOut className="size-4" /> <LogOut className="size-4" />
Sign out {t("menuSignOut")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@ -5,6 +5,8 @@ import { Moon, Sun } from "lucide-react";
import { useTheme } from "@/hooks/use-theme"; import { useTheme } from "@/hooks/use-theme";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
/** /**
* Light/dark mode toggle — a single icon button that flips the app * Light/dark mode toggle — a single icon button that flips the app
* between the two modes. Sun shows in light mode (click → go dark), * between the two modes. Sun shows in light mode (click → go dark),
@ -14,14 +16,17 @@ import { cn } from "@/lib/utils";
* 40×40 hit target to match the header's other touch controls. * 40×40 hit target to match the header's other touch controls.
*/ */
export function ModeToggle({ className }: { className?: string }) { export function ModeToggle({ className }: { className?: string }) {
const t = useTranslations("ModeToggle");
const { mode, toggleMode } = useTheme(); const { mode, toggleMode } = useTheme();
const goingTo = mode === "dark" ? "light" : "dark"; const goingTo = mode === "dark" ? "light" : "dark";
const switchLabel = t("switchMode", { mode: goingTo });
return ( return (
<button <button
type="button" type="button"
onClick={toggleMode} onClick={toggleMode}
aria-label={`Switch to ${goingTo} mode`} aria-label={switchLabel}
title={`Switch to ${goingTo} mode`} title={switchLabel}
className={cn( className={cn(
"flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground", "flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
className, className,

View File

@ -34,32 +34,32 @@ import type { AccountRole } from "@/lib/auth/roles";
// wants to recolour "agent" rows, this is the one diff. // wants to recolour "agent" rows, this is the one diff.
const ROLE_CHIP: Record< const ROLE_CHIP: Record<
AccountRole, AccountRole,
{ icon: typeof Crown; label: string; className: string } { icon: typeof Crown; labelKey: string; className: string }
> = { > = {
owner: { owner: {
icon: Crown, icon: Crown,
label: "Owner", labelKey: "roleOwner",
// Amber: scarce, immutable, "the boss" — gets visual emphasis. // Amber: scarce, immutable, "the boss" — gets visual emphasis.
className: className:
"border-amber-500/40 bg-amber-500/10 text-amber-300", "border-amber-500/40 bg-amber-500/10 text-amber-300",
}, },
admin: { admin: {
icon: Shield, icon: Shield,
label: "Admin", labelKey: "roleAdmin",
// Primary-tinted: significant but not as scarce as owner. // Primary-tinted: significant but not as scarce as owner.
className: className:
"border-primary/40 bg-primary/10 text-primary", "border-primary/40 bg-primary/10 text-primary",
}, },
agent: { agent: {
icon: UserCog, icon: UserCog,
label: "Agent", labelKey: "roleAgent",
// Neutral slate: the operational default. // Neutral slate: the operational default.
className: className:
"border-border bg-muted text-foreground", "border-border bg-muted text-foreground",
}, },
viewer: { viewer: {
icon: User, icon: User,
label: "Viewer", labelKey: "roleViewer",
// Muted slate: read-only role; visually quieter than agent. // Muted slate: read-only role; visually quieter than agent.
className: className:
"border-border bg-card text-muted-foreground", "border-border bg-card text-muted-foreground",
@ -80,7 +80,7 @@ import {
interface NavItem { interface NavItem {
href: string; href: string;
label: string; labelKey: string;
icon: typeof LayoutDashboard; icon: typeof LayoutDashboard;
/** /**
* When true, the nav row renders a small "Beta" chip after the label. * When true, the nav row renders a small "Beta" chip after the label.
@ -90,19 +90,19 @@ interface NavItem {
} }
const navItems: NavItem[] = [ const navItems: NavItem[] = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, { href: "/dashboard", labelKey: "dashboard", icon: LayoutDashboard },
{ href: "/inbox", label: "Inbox", icon: MessageSquare }, { href: "/inbox", labelKey: "inbox", icon: MessageSquare },
{ href: "/notifications", label: "Notifications", icon: Bell }, { href: "/notifications", labelKey: "notifications", icon: Bell },
{ href: "/contacts", label: "Contacts", icon: Users }, { href: "/contacts", labelKey: "contacts", icon: Users },
{ href: "/pipelines", label: "Pipelines", icon: GitBranch }, { href: "/pipelines", labelKey: "pipelines", icon: GitBranch },
{ href: "/broadcasts", label: "Broadcasts", icon: Radio }, { href: "/broadcasts", labelKey: "broadcasts", icon: Radio },
{ href: "/automations", label: "Automations", icon: Zap }, { href: "/automations", labelKey: "automations", icon: Zap },
{ href: "/flows", label: "Flows", icon: Workflow, beta: true }, { href: "/flows", labelKey: "flows", icon: Workflow, beta: true },
{ href: "/agents", label: "AI Agents", icon: Bot }, { href: "/agents", labelKey: "aiAgents", icon: Bot },
]; ];
const bottomNavItems = [ const bottomNavItems = [
{ href: "/settings", label: "Settings", icon: Settings }, { href: "/settings", labelKey: "settings", icon: Settings },
]; ];
interface SidebarProps { interface SidebarProps {
@ -111,7 +111,10 @@ interface SidebarProps {
onClose?: () => void; onClose?: () => void;
} }
import { useTranslations } from "next-intl";
export function Sidebar({ open = false, onClose }: SidebarProps) { export function Sidebar({ open = false, onClose }: SidebarProps) {
const t = useTranslations("Sidebar");
const pathname = usePathname(); const pathname = usePathname();
const { profile, profileLoading, account, accountRole, signOut } = useAuth(); const { profile, profileLoading, account, accountRole, signOut } = useAuth();
const totalUnread = useTotalUnread(); const totalUnread = useTotalUnread();
@ -160,7 +163,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
part of the main flex row there. */} part of the main flex row there. */}
<button <button
type="button" type="button"
aria-label="Close menu" aria-label={t("closeMenu")}
onClick={onClose} onClick={onClose}
className={cn( className={cn(
"fixed inset-0 z-30 bg-background/70 backdrop-blur-sm transition-opacity lg:hidden", "fixed inset-0 z-30 bg-background/70 backdrop-blur-sm transition-opacity lg:hidden",
@ -189,13 +192,13 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
<MessageSquare className="h-4 w-4" /> <MessageSquare className="h-4 w-4" />
</div> </div>
<span className="text-sm font-semibold text-foreground"> <span className="text-sm font-semibold text-foreground">
CRM Template for WhatsApp {t("title")}
</span> </span>
</Link> </Link>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
aria-label="Close menu" aria-label={t("closeMenu")}
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground lg:hidden" className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground lg:hidden"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
@ -233,18 +236,18 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
)} )}
> >
<item.icon className="h-4 w-4" /> <item.icon className="h-4 w-4" />
<span className="flex-1">{item.label}</span> <span className="flex-1">{t(item.labelKey as any)}</span>
{item.beta && ( {item.beta && (
<span <span
aria-label="Beta feature" aria-label={t("beta")}
className="rounded-full border border-amber-500/40 bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-300" className="rounded-full border border-amber-500/40 bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-300"
> >
Beta {t("beta")}
</span> </span>
)} )}
{showUnreadDot && ( {showUnreadDot && (
<span <span
aria-label={`${totalUnread} unread conversation${totalUnread === 1 ? "" : "s"}`} aria-label={t("unreadConversations", { count: totalUnread })}
className="relative flex h-2 w-2" className="relative flex h-2 w-2"
> >
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" /> <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
@ -253,7 +256,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
)} )}
{showNotificationBadge && ( {showNotificationBadge && (
<span <span
aria-label={`${unreadNotifications} unread notification${unreadNotifications === 1 ? "" : "s"}`} aria-label={t("unreadNotifications", { count: unreadNotifications })}
className="flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold text-primary-foreground" className="flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold text-primary-foreground"
> >
{unreadNotifications > 9 ? "9+" : unreadNotifications} {unreadNotifications > 9 ? "9+" : unreadNotifications}
@ -282,7 +285,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
)} )}
> >
<item.icon className="h-4 w-4" /> <item.icon className="h-4 w-4" />
{item.label} {t(item.labelKey as any)}
</Link> </Link>
</li> </li>
); );
@ -320,7 +323,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
className={`ml-auto inline-flex shrink-0 items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider ${meta.className}`} className={`ml-auto inline-flex shrink-0 items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider ${meta.className}`}
> >
<Icon className="size-3" /> <Icon className="size-3" />
{meta.label} {t(meta.labelKey as any)}
</span> </span>
); );
})() })()
@ -333,7 +336,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
{profile?.avatar_url ? ( {profile?.avatar_url ? (
<AvatarImage <AvatarImage
src={profile.avatar_url} src={profile.avatar_url}
alt={profile.full_name ?? "Avatar"} alt={profile.full_name ?? t("defaultAvatar")}
/> />
) : null} ) : null}
<AvatarFallback className="bg-primary/10 text-sm font-medium text-primary"> <AvatarFallback className="bg-primary/10 text-sm font-medium text-primary">
@ -344,7 +347,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
</Avatar> </Avatar>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground"> <p className="truncate text-sm font-medium text-foreground">
{profile?.full_name ?? "User"} {profile?.full_name ?? t("defaultUser")}
</p> </p>
<p className="truncate text-xs text-muted-foreground"> <p className="truncate text-xs text-muted-foreground">
{profile?.email ?? ""} {profile?.email ?? ""}
@ -367,7 +370,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
} }
> >
<User className="size-4" /> <User className="size-4" />
Profile {t("menuProfile")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
render={ render={
@ -379,7 +382,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
} }
> >
<Settings className="size-4" /> <Settings className="size-4" />
Settings {t("menuSettings")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator className="bg-border" /> <DropdownMenuSeparator className="bg-border" />
<DropdownMenuItem <DropdownMenuItem
@ -387,7 +390,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
className="text-popover-foreground focus:bg-accent focus:text-accent-foreground" className="text-popover-foreground focus:bg-accent focus:text-accent-foreground"
> >
<LogOut className="size-4" /> <LogOut className="size-4" />
Sign out {t("menuSignOut")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>