Files
sase.tr/apps/web/src/routes/dashboard.tsx
Semih Yesilyurt 10cd67911d
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled
refactor(web): move changelog out of settings into its own route
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>
2026-05-26 23:37:28 +03:00

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>
);
}