wacrm/src/components/layout/sidebar.tsx
Arnas Donauskas 54a969c56d
feat(settings): color theme picker (5 themes)
Adds a new "Appearance" tab in Settings with 5 dark color themes
(Violet, Emerald, Cobalt, Amber, Rose). Pick a theme → applied
immediately and persisted to localStorage. Boot script in the root
layout replays the pick before first paint so there's no
flash-of-default on subsequent loads.

Implementation, ordered the way it's read:

1. `globals.css` — 5 `html[data-theme="..."]` blocks extracted
   verbatim from the design handoff at project/Color Themes.html.
   `:root` mirrors Violet so SSR / first-paint renders correctly
   even before the boot script runs. Three new tokens
   (`--primary-hover`, `--primary-soft`, `--primary-soft-2`) added
   to all themes — used by tinted-primary surfaces.

2. `src/lib/themes.ts` — single source of truth for the catalog
   (id, name, tagline, swatch). Adding a theme = append a CSS
   block + append an entry here.

3. `src/hooks/use-theme.tsx` — ThemeProvider client component. Reads
   what the boot script already applied, syncs across tabs via the
   `storage` event, persists to localStorage. Falls back to a no-op
   setter when used outside the provider.

4. `src/app/layout.tsx` — inline `beforeInteractive` script that
   reads localStorage and sets `documentElement.dataset.theme`
   before React hydrates. Knowledge of valid theme IDs comes from
   the THEME_IDS constant so adding a theme doesn't silently break
   the boot path. Wraps the tree in ThemeProvider.

5. `src/components/settings/appearance-panel.tsx` — picker UI. 5
   cards, swatch + name + tagline + a 4-tone preview strip. Active
   card carries a primary-tinted ring + "Active" chip.

6. `src/app/(dashboard)/settings/page.tsx` — new "appearance" tab
   alongside profile/whatsapp/templates/tags.

7. Key-surface tokenization — replaced hard-coded `violet-*` with
   `primary` tokens on the high-visibility surfaces so the theme
   switch feels real: sidebar logo + active nav + unread dot +
   avatar fallback, header avatar, dashboard-shell loading
   spinner, Flows header icon + entry-node picker icon + entry
   badge + flash ring, Flows list page (template card hover,
   FlowCard icon), Flows editor + runs "back" links, Settings tabs
   active state.

NOT touched (intentional follow-ups):
- Per-node-type colors in flow-builder.tsx NODE_META — those are a
  deliberate per-type rainbow, not a brand accent.
- The inbox/contacts/pipelines surfaces — many `violet-*` usages
  there. Sweep is a contained follow-up; the user explicitly
  scoped this PR to "tokens + tab + key surfaces".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:48:40 +03:00

292 lines
11 KiB
TypeScript

"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { cn } from "@/lib/utils";
import { useAuth } from "@/hooks/use-auth";
import { useTotalUnread } from "@/hooks/use-total-unread";
import { isFlowsEnabled } from "@/lib/flows/feature-flag";
import {
LayoutDashboard,
MessageSquare,
Users,
GitBranch,
Radio,
Zap,
Workflow,
Settings,
LogOut,
User,
X,
} from "lucide-react";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface NavItem {
href: string;
label: string;
icon: typeof LayoutDashboard;
/**
* If set, only included in the rendered nav when the predicate
* returns true. Used to gate beta-only entries (e.g. /flows) on
* `profiles.beta_features`.
*/
betaKey?: string;
}
const navItems: NavItem[] = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/inbox", label: "Inbox", icon: MessageSquare },
{ href: "/contacts", label: "Contacts", icon: Users },
{ href: "/pipelines", label: "Pipelines", icon: GitBranch },
{ href: "/broadcasts", label: "Broadcasts", icon: Radio },
{ href: "/automations", label: "Automations", icon: Zap },
// Flows is gated on the per-account beta flag; the entry is invisible
// for accounts that haven't opted in. Server-side route guards make
// this a UX choice, not a security one — the API + pages 404 anyway.
{ href: "/flows", label: "Flows", icon: Workflow, betaKey: "flows" },
];
const bottomNavItems = [
{ href: "/settings", label: "Settings", icon: Settings },
];
interface SidebarProps {
/** Controlled on mobile by the Header's hamburger button. Ignored on lg+. */
open?: boolean;
onClose?: () => void;
}
export function Sidebar({ open = false, onClose }: SidebarProps) {
const pathname = usePathname();
const { profile, signOut } = useAuth();
const totalUnread = useTotalUnread();
// Close the drawer when route changes — users opened it to navigate,
// so once they pick a destination the drawer should get out of the way.
useEffect(() => {
onClose?.();
// Only pathname drives this — onClose identity doesn't need to re-run it.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
// Lock body scroll and allow Escape to close while the drawer is open on
// mobile. No-ops on desktop because the sidebar isn't positioned there.
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose?.();
};
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prev;
window.removeEventListener("keydown", onKey);
};
}, [open, onClose]);
return (
<>
{/* Backdrop — only exists on mobile and only when open. Clicking
it closes the drawer. Hidden from lg+ since the sidebar is
part of the main flex row there. */}
<button
type="button"
aria-label="Close menu"
onClick={onClose}
className={cn(
"fixed inset-0 z-30 bg-slate-950/70 backdrop-blur-sm transition-opacity lg:hidden",
open
? "pointer-events-auto opacity-100"
: "pointer-events-none opacity-0",
)}
/>
<aside
className={cn(
// Mobile: fixed drawer that slides in from the left.
"fixed inset-y-0 left-0 z-40 flex h-full w-64 flex-col border-r border-slate-800 bg-slate-900",
"transition-transform duration-200 ease-out will-change-transform",
open ? "translate-x-0" : "-translate-x-full",
// Desktop: static, always visible — reset all the mobile framing.
"lg:static lg:z-0 lg:w-60 lg:translate-x-0 lg:transition-none",
)}
aria-label="Primary"
>
{/* Logo row. On mobile we put a close button here; on desktop the
close button is hidden since the sidebar is always-visible. */}
<div className="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-slate-800 px-4">
<Link href="/dashboard" className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<MessageSquare className="h-4 w-4" />
</div>
<span className="text-sm font-semibold text-white">
CRM Template for WhatsApp
</span>
</Link>
<button
type="button"
onClick={onClose}
aria-label="Close menu"
className="flex h-9 w-9 items-center justify-center rounded-md text-slate-400 hover:bg-slate-800 hover:text-white lg:hidden"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Main navigation */}
<nav className="flex-1 overflow-y-auto px-3 py-4">
<ul className="flex flex-col gap-1">
{navItems.map((item) => {
// Per-account beta gate. Today only 'flows' uses this;
// extending the navItems list with new betaKeys is a
// single-line change.
if (item.betaKey === "flows" && !isFlowsEnabled(profile)) {
return null;
}
const isActive =
pathname === item.href ||
(item.href !== "/dashboard" && pathname.startsWith(item.href));
const showUnreadDot =
item.href === "/inbox" && totalUnread > 0 && !isActive;
return (
<li key={item.href}>
<Link
href={item.href}
className={cn(
// Taller on mobile so fingers can hit the row reliably (≥44px).
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors lg:py-2",
isActive
? "bg-primary/10 text-primary"
: "text-slate-400 hover:bg-slate-800 hover:text-white",
)}
>
<item.icon className="h-4 w-4" />
<span className="flex-1">{item.label}</span>
{showUnreadDot && (
<span
aria-label={`${totalUnread} unread conversation${totalUnread === 1 ? "" : "s"}`}
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="relative inline-flex h-2 w-2 rounded-full bg-primary" />
</span>
)}
</Link>
</li>
);
})}
</ul>
<div className="my-4 border-t border-slate-800" />
<ul className="flex flex-col gap-1">
{bottomNavItems.map((item) => {
const isActive = pathname.startsWith(item.href);
return (
<li key={item.href}>
<Link
href={item.href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors lg:py-2",
isActive
? "bg-primary/10 text-primary"
: "text-slate-400 hover:bg-slate-800 hover:text-white",
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</Link>
</li>
);
})}
</ul>
</nav>
{/* User section */}
<div className="shrink-0 border-t border-slate-800 p-3">
<DropdownMenu>
<DropdownMenuTrigger className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-slate-800/60 focus:bg-slate-800/60 focus:outline-none data-popup-open:bg-slate-800/60">
<Avatar className="size-8 shrink-0">
{profile?.avatar_url ? (
<AvatarImage
src={profile.avatar_url}
alt={profile.full_name ?? "Avatar"}
/>
) : null}
<AvatarFallback className="bg-primary/10 text-sm font-medium text-primary">
{profile?.full_name?.charAt(0)?.toUpperCase() ??
profile?.email?.charAt(0)?.toUpperCase() ??
"U"}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white">
{profile?.full_name ?? "User"}
</p>
<p className="truncate text-xs text-slate-400">
{profile?.email ?? ""}
</p>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="top"
sideOffset={6}
className="min-w-56 bg-slate-900 text-slate-100 ring-slate-700"
>
<DropdownMenuItem
render={
<Link
href="/settings?tab=profile"
onClick={onClose}
className="text-slate-200 focus:bg-slate-800 focus:text-white"
/>
}
>
<User className="size-4" />
Profile
</DropdownMenuItem>
<DropdownMenuItem
render={
<Link
href="/settings?tab=whatsapp"
onClick={onClose}
className="text-slate-200 focus:bg-slate-800 focus:text-white"
/>
}
>
<Settings className="size-4" />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-slate-800" />
<DropdownMenuItem
onClick={signOut}
className="text-slate-200 focus:bg-slate-800 focus:text-white"
>
<LogOut className="size-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</aside>
</>
);
}