mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
The Flows feature was previously gated on a per-account `profiles.beta_features` opt-in: the sidebar entry was invisible to non-beta accounts, every /api/flows route 404'd, the webhook dispatcher was a no-op, and the agent-handoff pause was skipped. This PR opens the surface to every authenticated user. A small "Beta" chip in the sidebar nav + on the /flows page H1 keeps the "young feature" signal without holding anyone out. Removed: - `src/lib/flows/feature-flag.ts` + its tests (-11 tests). - The `requireFlowsBeta` / `requireFlowsBetaAndOwnership` helpers in the API routes — collapsed to plain `requireUser` / RLS ownership. - The `isProfileFlowsEnabled` helper + branch in the webhook dispatcher — the runner takes the cheap "no_match" exit on accounts with no active flows (single indexed SELECT). - The agent-handoff pause gate in /api/whatsapp/send — the UPDATE matches zero rows when no active run exists, also cheap. - Client-side beta gates in the 3 flows pages (and the redundant `useAuth` + `profileLoading` plumbing they needed). - The defensive message-bubble gate that would have rendered interactive replies as plain text for non-beta accounts. - The `betaKey` field on the sidebar NavItem schema, replaced by a simpler boolean `beta` that purely drives the visual chip. Kept: - `profiles.beta_features` column. Generic mechanism for future beta gates; no current feature reads it. Comments in `src/types/index.ts` + `use-auth.tsx` updated to say so. - The chip can be removed in a one-liner once the team's satisfied Flows has bedded in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
288 lines
10 KiB
TypeScript
288 lines
10 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 {
|
|
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;
|
|
/**
|
|
* When true, the nav row renders a small "Beta" chip after the label.
|
|
* Purely informational — doesn't affect routing or access.
|
|
*/
|
|
beta?: boolean;
|
|
}
|
|
|
|
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 },
|
|
{ href: "/flows", label: "Flows", icon: Workflow, beta: true },
|
|
];
|
|
|
|
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) => {
|
|
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>
|
|
{item.beta && (
|
|
<span
|
|
aria-label="Beta feature"
|
|
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
|
|
</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>
|
|
</>
|
|
);
|
|
}
|