Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
Product changelog isn't an account setting. Give it a dedicated /dashboard/changelog route and a "What's New" sidebar link (Support section), and drop the changelog tab from settings. The header page title now resolves support-section routes too. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
553 lines
19 KiB
TypeScript
553 lines
19 KiB
TypeScript
import { LanguageSwitcher } from "@/components/language-switcher";
|
|
import { SiteFooter } from "@/components/site-footer";
|
|
import { TrialUrgencyBanner } from "@/components/trial-urgency-banner";
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import { api } from "@/lib/api-client";
|
|
import { useTranslation } from "@/lib/i18n";
|
|
import { KEYS_5 } from "@/lib/keys";
|
|
import { capture, resetUser } from "@/lib/posthog";
|
|
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
|
import {
|
|
Button,
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
Separator,
|
|
Sheet,
|
|
SheetContent,
|
|
SheetTitle,
|
|
Skeleton,
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@sase/ui";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { Link, Outlet, createFileRoute, useNavigate, useRouterState } from "@tanstack/react-router";
|
|
import {
|
|
BarChart3,
|
|
BookOpen,
|
|
CalendarDays,
|
|
ChevronsUpDown,
|
|
Copy,
|
|
CreditCard,
|
|
FlaskConical,
|
|
History,
|
|
LayoutDashboard,
|
|
Library,
|
|
LogOut,
|
|
Mail,
|
|
Menu,
|
|
Moon,
|
|
PanelLeftClose,
|
|
PanelLeftOpen,
|
|
Receipt,
|
|
Search,
|
|
Settings,
|
|
Share2,
|
|
Shield,
|
|
Sparkles,
|
|
Sun,
|
|
Users,
|
|
} from "lucide-react";
|
|
import { useEffect, useState } from "react";
|
|
|
|
export const Route = createFileRoute("/dashboard")({
|
|
component: DashboardLayout,
|
|
});
|
|
|
|
// ─── NAV SECTIONS ─────────────────────────────────────────────────────────────
|
|
|
|
type NavItem = {
|
|
to: string;
|
|
labelKey: string;
|
|
icon: React.ComponentType<{ className?: string }>;
|
|
exact?: boolean;
|
|
};
|
|
|
|
const mainMenuItems: readonly NavItem[] = [
|
|
{ to: "/dashboard", labelKey: "nav.dashboard", icon: LayoutDashboard, exact: true },
|
|
{ to: "/dashboard/search", labelKey: "nav.search", icon: Search },
|
|
{ to: "/dashboard/catalog", labelKey: "nav.catalog", icon: Library },
|
|
{ to: "/dashboard/history", labelKey: "nav.history", icon: History },
|
|
];
|
|
|
|
const accountItems: readonly NavItem[] = [
|
|
{ to: "/dashboard/subscription", labelKey: "nav.subscription", icon: CreditCard },
|
|
{ to: "/dashboard/billing", labelKey: "nav.billing", icon: Receipt },
|
|
{ to: "/dashboard/settings", labelKey: "nav.settings", icon: Settings },
|
|
];
|
|
|
|
const supportItems: readonly NavItem[] = [
|
|
{ to: "/dashboard/changelog", labelKey: "nav.changelog", icon: CalendarDays },
|
|
{ to: "/contact", labelKey: "nav.contact", icon: Mail },
|
|
{ to: "/blog", labelKey: "nav.blog", icon: BookOpen },
|
|
];
|
|
|
|
const adminItems: readonly NavItem[] = [
|
|
{ to: "/dashboard/admin", labelKey: "nav.adminPanel", icon: Shield },
|
|
{ to: "/dashboard/admin/users", labelKey: "nav.users", icon: Users },
|
|
{ to: "/dashboard/admin/analytics", labelKey: "nav.analytics", icon: BarChart3 },
|
|
{ to: "/dashboard/admin/copy-logs", labelKey: "nav.copyLogs", icon: Copy },
|
|
{ to: "/dashboard/admin/referrals", labelKey: "nav.referrals", icon: Share2 },
|
|
{ to: "/dashboard/service-test", labelKey: "nav.serviceTest", icon: FlaskConical },
|
|
];
|
|
|
|
// ─── HELPERS ──────────────────────────────────────────────────────────────────
|
|
|
|
function NavSection({ title, collapsed }: { title: string; collapsed: boolean }) {
|
|
if (collapsed) {
|
|
return <Separator className="my-2 bg-border/50" />;
|
|
}
|
|
return (
|
|
<p className="mb-1 mt-4 px-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">
|
|
{title}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
function NavLink({
|
|
to,
|
|
label,
|
|
icon: Icon,
|
|
collapsed,
|
|
onClick,
|
|
exact,
|
|
}: {
|
|
to: string;
|
|
label: string;
|
|
icon: React.ComponentType<{ className?: string }>;
|
|
collapsed: boolean;
|
|
onClick?: () => void;
|
|
exact?: boolean;
|
|
}) {
|
|
const link = (
|
|
<Link
|
|
to={to}
|
|
className={`group relative flex items-center rounded-lg text-sm font-medium text-muted-foreground transition-all duration-200 hover:bg-accent hover:text-foreground [&.active]:bg-accent [&.active]:font-semibold [&.active]:text-foreground ${collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2.5"}`}
|
|
activeProps={{ className: "active", "aria-current": "page" }}
|
|
activeOptions={exact ? { exact: true } : undefined}
|
|
onClick={onClick}
|
|
>
|
|
{/* Left accent bar — visible when active (both collapsed & expanded) */}
|
|
<span
|
|
className="absolute left-0 top-1/2 h-5 w-0.5 -translate-y-1/2 rounded-r-full bg-brand opacity-0 transition-opacity duration-200 group-[.active]:opacity-100"
|
|
aria-hidden="true"
|
|
/>
|
|
<Icon className="size-4 shrink-0 text-muted-foreground transition-colors duration-200 group-hover:text-foreground group-[.active]:text-brand" />
|
|
{!collapsed && <span>{label}</span>}
|
|
</Link>
|
|
);
|
|
|
|
if (collapsed) {
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
|
<TooltipContent side="right">{label}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
return link;
|
|
}
|
|
|
|
// ─── LAYOUT ───────────────────────────────────────────────────────────────────
|
|
|
|
function DashboardLayout() {
|
|
const { t } = useTranslation();
|
|
const { user, isLoading, signOut, isAdmin } = useAuth();
|
|
const navigate = useNavigate();
|
|
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
const [collapsed, setCollapsed] = useState(() => getUserSettings().sidebarCollapsed ?? false);
|
|
const [isDark, setIsDark] = useState(() => {
|
|
const theme = getUserSettings().theme ?? "dark";
|
|
if (theme === "system") {
|
|
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
}
|
|
return theme === "dark";
|
|
});
|
|
|
|
// Shared with TrialUrgencyBanner via the same query key — drives the upsell button.
|
|
const { data: subData } = useQuery({
|
|
queryKey: ["subscription", "me"],
|
|
queryFn: () =>
|
|
api.get<{ subscription: { status: string } | null; eligibleForTrial: boolean }>(
|
|
"/subscriptions/me",
|
|
),
|
|
enabled: !!user,
|
|
});
|
|
const hasActivePlan = subData?.subscription?.status === "active";
|
|
|
|
// Redirect unauthenticated users without mutating router state during render.
|
|
useEffect(() => {
|
|
if (!isLoading && !user) {
|
|
navigate({ to: "/login" });
|
|
}
|
|
}, [isLoading, user, navigate]);
|
|
|
|
const handleSignOut = () => {
|
|
capture("user_logged_out");
|
|
resetUser();
|
|
signOut();
|
|
};
|
|
|
|
const toggleTheme = () => {
|
|
const next = isDark ? "light" : "dark";
|
|
document.documentElement.classList.toggle("dark", next === "dark");
|
|
setUserSetting("theme", next);
|
|
setIsDark(next === "dark");
|
|
};
|
|
|
|
const toggleCollapsed = () => {
|
|
const next = !collapsed;
|
|
setCollapsed(next);
|
|
setUserSetting("sidebarCollapsed", next);
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex min-h-[100dvh]">
|
|
<div
|
|
className={`hidden border-r border-border bg-background p-4 lg:block ${collapsed ? "w-[68px]" : "w-64"}`}
|
|
>
|
|
<Skeleton className="mb-8 h-8 w-32" />
|
|
{KEYS_5.map((__k) => (
|
|
<Skeleton key={__k} className="mb-3 h-10 w-full" />
|
|
))}
|
|
</div>
|
|
<div className="flex-1 p-6">
|
|
<Skeleton className="h-8 w-48" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
const initials = user.name
|
|
? user.name
|
|
.split(" ")
|
|
.map((w) => w[0])
|
|
.join("")
|
|
.toUpperCase()
|
|
.slice(0, 2)
|
|
: "?";
|
|
// Capture narrowed (non-null) user fields for the nested ProfileMenu closure.
|
|
const userName = user.name;
|
|
const userEmail = user.email;
|
|
|
|
// Current page title for the header — longest matching nav `to` wins.
|
|
const allNavItems = [...mainMenuItems, ...accountItems, ...supportItems, ...adminItems];
|
|
const currentItem = allNavItems
|
|
.filter((i) =>
|
|
i.exact ? pathname === i.to : pathname === i.to || pathname.startsWith(`${i.to}/`),
|
|
)
|
|
.sort((a, b) => b.to.length - a.to.length)[0];
|
|
const pageTitle = currentItem ? t(currentItem.labelKey) : t("nav.dashboard");
|
|
|
|
// Upgrade CTA shown above the profile — only for users without a paid plan.
|
|
function UpgradeBadge({
|
|
collapsed: isCollapsed,
|
|
onClick,
|
|
}: {
|
|
collapsed: boolean;
|
|
onClick?: () => void;
|
|
}) {
|
|
if (hasActivePlan) return null;
|
|
|
|
if (isCollapsed) {
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Link
|
|
to="/dashboard/subscription"
|
|
onClick={onClick}
|
|
aria-label={t("nav.upgradeAccount")}
|
|
className="mb-1 flex items-center justify-center rounded-lg border border-brand/30 bg-brand/10 p-2.5 text-brand transition-colors hover:bg-brand/15"
|
|
>
|
|
<Sparkles className="size-4" />
|
|
</Link>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right">{t("nav.upgradeAccount")}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Link
|
|
to="/dashboard/subscription"
|
|
onClick={onClick}
|
|
className="mb-2 flex items-center gap-2 rounded-lg border border-brand/30 bg-brand/10 px-3 py-2 text-xs font-semibold text-brand transition-colors hover:bg-brand/15"
|
|
>
|
|
<Sparkles className="size-4 shrink-0" />
|
|
<span>{t("nav.upgradeAccount")}</span>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
// Profile menu — replaces the old "click-to-logout" trap with a real menu.
|
|
function ProfileMenu({
|
|
collapsed: isCollapsed,
|
|
onItemSelect,
|
|
}: {
|
|
collapsed: boolean;
|
|
onItemSelect?: () => void;
|
|
}) {
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className={`flex w-full items-center rounded-lg text-left transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${isCollapsed ? "justify-center p-2" : "gap-3 px-3 py-2.5"}`}
|
|
>
|
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
|
|
{initials}
|
|
</div>
|
|
{!isCollapsed && (
|
|
<>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-medium">{userName}</p>
|
|
<p className="truncate text-xs text-muted-foreground">{userEmail}</p>
|
|
</div>
|
|
<ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" />
|
|
</>
|
|
)}
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent side="top" align="start" className="w-56">
|
|
<DropdownMenuLabel className="font-normal">
|
|
<p className="truncate text-sm font-medium">{userName}</p>
|
|
<p className="truncate text-xs font-normal text-muted-foreground">{userEmail}</p>
|
|
</DropdownMenuLabel>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem asChild>
|
|
<Link to="/dashboard/settings" onClick={onItemSelect}>
|
|
<Settings />
|
|
{t("nav.settings")}
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem asChild>
|
|
<Link to="/dashboard/subscription" onClick={onItemSelect}>
|
|
<CreditCard />
|
|
{t("nav.subscription")}
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onSelect={handleSignOut}
|
|
className="text-destructive focus:text-destructive [&>svg]:text-destructive"
|
|
>
|
|
<LogOut />
|
|
{t("nav.logout")}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|
|
|
|
// Sidebar content (shared between desktop & mobile)
|
|
function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
|
|
const isCollapsed = collapsed && !onNavigate;
|
|
return (
|
|
<>
|
|
<NavSection title={t("nav.sectionMain")} collapsed={isCollapsed} />
|
|
{mainMenuItems.map((item) => (
|
|
<NavLink
|
|
key={item.to}
|
|
to={item.to}
|
|
label={t(item.labelKey)}
|
|
icon={item.icon}
|
|
collapsed={isCollapsed}
|
|
onClick={onNavigate}
|
|
exact={item.exact}
|
|
/>
|
|
))}
|
|
|
|
<NavSection title={t("nav.sectionAccount")} collapsed={isCollapsed} />
|
|
{accountItems.map((item) => (
|
|
<NavLink
|
|
key={item.to}
|
|
to={item.to}
|
|
label={t(item.labelKey)}
|
|
icon={item.icon}
|
|
collapsed={isCollapsed}
|
|
onClick={onNavigate}
|
|
/>
|
|
))}
|
|
|
|
<NavSection title={t("nav.sectionSupport")} collapsed={isCollapsed} />
|
|
{supportItems.map((item) => (
|
|
<NavLink
|
|
key={item.to}
|
|
to={item.to}
|
|
label={t(item.labelKey)}
|
|
icon={item.icon}
|
|
collapsed={isCollapsed}
|
|
onClick={onNavigate}
|
|
/>
|
|
))}
|
|
|
|
{isAdmin && (
|
|
<>
|
|
<NavSection title={t("nav.sectionAdmin")} collapsed={isCollapsed} />
|
|
{adminItems.map((item) => (
|
|
<NavLink
|
|
key={item.to}
|
|
to={item.to}
|
|
label={t(item.labelKey)}
|
|
icon={item.icon}
|
|
collapsed={isCollapsed}
|
|
onClick={onNavigate}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<TooltipProvider delayDuration={0}>
|
|
<div className="flex min-h-[100dvh] bg-background">
|
|
{/* ─── Desktop Sidebar ──────────────────────────────────────────── */}
|
|
<aside
|
|
className={`hidden flex-shrink-0 border-r border-border transition-[width] duration-200 lg:flex lg:flex-col ${collapsed ? "w-[68px]" : "w-64"}`}
|
|
>
|
|
{/* Logo + Collapse */}
|
|
<div
|
|
className={`flex h-16 items-center border-b border-border ${collapsed ? "justify-center px-2" : "justify-between px-4"}`}
|
|
>
|
|
{!collapsed && (
|
|
<Link to="/" className="text-xl font-bold tracking-wider">
|
|
SASE
|
|
</Link>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={toggleCollapsed}
|
|
aria-label={collapsed ? t("nav.expandSidebar") : t("nav.collapseSidebar")}
|
|
className="flex size-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
>
|
|
{collapsed ? (
|
|
<PanelLeftOpen className="size-5" />
|
|
) : (
|
|
<PanelLeftClose className="size-5" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Navigation */}
|
|
<nav
|
|
aria-label={t("nav.primaryNav")}
|
|
className={`flex-1 space-y-0.5 overflow-y-auto ${collapsed ? "p-2" : "px-3 py-2"}`}
|
|
>
|
|
<SidebarNav />
|
|
</nav>
|
|
|
|
{/* User Profile - Bottom */}
|
|
<div className={`border-t border-border ${collapsed ? "p-2" : "p-3"}`}>
|
|
<UpgradeBadge collapsed={collapsed} />
|
|
<ProfileMenu collapsed={collapsed} />
|
|
</div>
|
|
</aside>
|
|
|
|
{/* ─── Main Content Area ────────────────────────────────────────── */}
|
|
{/* min-w-0: lets this flex column shrink below its content's intrinsic
|
|
width so wide children (e.g. the subscription feature-comparison
|
|
table) scroll inside their own overflow-x container instead of
|
|
forcing the whole mobile layout viewport wider than the device. */}
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
{/* Top Header Bar */}
|
|
<header className="flex h-16 items-center justify-between border-b border-border px-4 sm:px-6">
|
|
{/* Left: Mobile menu + current page */}
|
|
<div className="flex items-center gap-3">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="lg:hidden"
|
|
aria-label={t("nav.openMenu")}
|
|
onClick={() => setMobileOpen(true)}
|
|
>
|
|
<Menu className="size-5" />
|
|
</Button>
|
|
|
|
<div>
|
|
<p className="text-sm font-semibold leading-tight">{pageTitle}</p>
|
|
<p className="hidden text-xs text-muted-foreground sm:block">{t("nav.welcome")}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right: Actions */}
|
|
<div className="flex items-center gap-2">
|
|
{!hasActivePlan && (
|
|
<Link to="/dashboard/subscription">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="hidden rounded-full border-border text-xs sm:inline-flex"
|
|
>
|
|
<CreditCard className="mr-1.5 size-3.5" />
|
|
{t("nav.upgradePlan")}
|
|
</Button>
|
|
</Link>
|
|
)}
|
|
<LanguageSwitcher variant="dropdown" />
|
|
<button
|
|
type="button"
|
|
onClick={toggleTheme}
|
|
className="flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
aria-label={t("nav.toggleTheme")}
|
|
>
|
|
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Page Content */}
|
|
<main id="main-content" className="flex-1 overflow-auto bg-muted/30">
|
|
<TrialUrgencyBanner />
|
|
<div className="p-4 sm:p-6">
|
|
<Outlet />
|
|
</div>
|
|
</main>
|
|
|
|
{/* Footer */}
|
|
<SiteFooter variant="compact" />
|
|
</div>
|
|
|
|
{/* ─── Mobile Nav Drawer (Radix Dialog: focus-trap, Esc, scroll-lock) ─── */}
|
|
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
|
<SheetContent side="left" className="w-72 p-0" aria-describedby={undefined}>
|
|
<div className="flex h-16 items-center border-b border-border px-4">
|
|
<SheetTitle asChild>
|
|
<Link
|
|
to="/"
|
|
className="text-xl font-bold tracking-wider"
|
|
onClick={() => setMobileOpen(false)}
|
|
>
|
|
SASE
|
|
</Link>
|
|
</SheetTitle>
|
|
</div>
|
|
<nav
|
|
aria-label={t("nav.primaryNav")}
|
|
className="flex-1 space-y-0.5 overflow-y-auto px-3 py-2"
|
|
>
|
|
<SidebarNav onNavigate={() => setMobileOpen(false)} />
|
|
</nav>
|
|
<div className="border-t border-border p-3">
|
|
<UpgradeBadge collapsed={false} onClick={() => setMobileOpen(false)} />
|
|
<ProfileMenu collapsed={false} onItemSelect={() => setMobileOpen(false)} />
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
</div>
|
|
</TooltipProvider>
|
|
);
|
|
}
|