feat(web): dashboard sidebar UX/UI + a11y overhaul
Some checks failed
QA Gate (P0/P1) / Test affected app (pull_request) Has been cancelled

Replace the click-to-logout profile trap with a proper DropdownMenu
(settings / subscription / logout), swap the hand-rolled mobile overlay
for a Radix-Dialog Sheet (focus-trap, Esc, scroll-lock), and fix
accessibility gaps: aria-labels on icon buttons, aria-current + labelled
nav landmarks, and effect-based auth redirect instead of navigating
during render.

Also: reorder nav (Main → Account → Support → Admin), full i18n of
labels/sections, conditional "upgrade plan" button, header page title,
collapsed-mode active indicator + tooltips, contrast/touch-target
tweaks, and a typed NavItem to drop the `as any` exact cast.

Adds DropdownMenu, Tooltip and Sheet primitives to @sase/ui.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 13:54:53 +03:00
parent 57b3cb7ebe
commit 71242a72be
7 changed files with 594 additions and 248 deletions

View File

@@ -31,6 +31,7 @@
"register": "Sign Up"
},
"nav": {
"dashboard": "Dashboard",
"search": "Search",
"history": "History",
"catalog": "Catalog",
@@ -38,7 +39,28 @@
"billing": "Payment",
"settings": "Settings",
"admin": "Admin Panel",
"logout": "Log Out"
"logout": "Log Out",
"contact": "Contact",
"blog": "Blog",
"account": "Account",
"sectionMain": "Main Menu",
"sectionAccount": "Account",
"sectionSupport": "Support",
"sectionAdmin": "Administration",
"adminPanel": "Admin Panel",
"users": "Users",
"analytics": "Analytics",
"copyLogs": "OEM Copies",
"referrals": "Referrals",
"serviceTest": "Service Test",
"upgradePlan": "Upgrade Plan",
"welcome": "Welcome to Sase.tr",
"primaryNav": "Main navigation",
"openMenu": "Open menu",
"closeMenu": "Close menu",
"collapseSidebar": "Collapse sidebar",
"expandSidebar": "Expand sidebar",
"toggleTheme": "Toggle theme"
},
"catalog": {
"title": "Parts Catalog",

View File

@@ -31,6 +31,7 @@
"register": "Kayıt Ol"
},
"nav": {
"dashboard": "Gösterge Paneli",
"search": "Arama",
"history": "Geçmiş",
"catalog": "Katalog",
@@ -38,7 +39,28 @@
"billing": "Ödeme",
"settings": "Ayarlar",
"admin": "Admin Panel",
"logout": ıkış Yap"
"logout": ıkış Yap",
"contact": "İletişim",
"blog": "Blog",
"account": "Hesap",
"sectionMain": "Ana Menü",
"sectionAccount": "Hesap",
"sectionSupport": "Destek",
"sectionAdmin": "Yönetim",
"adminPanel": "Yönetim Paneli",
"users": "Kullanıcılar",
"analytics": "Analitik",
"copyLogs": "OEM Kopyalama",
"referrals": "Referanslar",
"serviceTest": "Servis Test",
"upgradePlan": "Plan Yükselt",
"welcome": "Sase.tr'ye hoş geldiniz",
"primaryNav": "Ana navigasyon",
"openMenu": "Menüyü aç",
"closeMenu": "Menüyü kapat",
"collapseSidebar": "Menüyü daralt",
"expandSidebar": "Menüyü genişlet",
"toggleTheme": "Tema değiştir"
},
"catalog": {
"title": "Parça Kataloğu",

View File

@@ -1,15 +1,34 @@
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, Separator, Skeleton } from "@sase/ui";
import { Link, Outlet, createFileRoute, useNavigate } from "@tanstack/react-router";
import {
ArrowRight,
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,
ChevronsUpDown,
Copy,
CreditCard,
FlaskConical,
@@ -29,9 +48,8 @@ import {
Shield,
Sun,
Users,
X,
} from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
export const Route = createFileRoute("/dashboard")({
component: DashboardLayout,
@@ -39,52 +57,48 @@ export const Route = createFileRoute("/dashboard")({
// ─── NAV SECTIONS ─────────────────────────────────────────────────────────────
const mainMenuItems = [
{ to: "/dashboard", label: "Gösterge Paneli", icon: LayoutDashboard, exact: true },
{ to: "/dashboard/search", label: "nav.search", translatable: true, icon: Search },
{ to: "/dashboard/catalog", label: "nav.catalog", translatable: true, icon: Library },
{ to: "/dashboard/history", label: "nav.history", translatable: true, icon: History },
] as const;
type NavItem = {
to: string;
labelKey: string;
icon: React.ComponentType<{ className?: string }>;
exact?: boolean;
};
const accountItems = [
{
to: "/dashboard/subscription",
label: "nav.subscription",
translatable: true,
icon: CreditCard,
},
{ to: "/dashboard/billing", label: "nav.billing", translatable: true, icon: Receipt },
{ to: "/dashboard/settings", label: "nav.settings", translatable: true, icon: Settings },
] as const;
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 supportItems = [
{ to: "/contact", label: "İletişim", icon: Mail },
{ to: "/blog", label: "Blog", icon: BookOpen },
] as const;
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 adminItems = [
{ to: "/dashboard/admin", label: "Yönetim Paneli", icon: Shield },
{ to: "/dashboard/admin/users", label: "Kullanıcılar", icon: Users },
{ to: "/dashboard/admin/analytics", label: "Analitik", icon: BarChart3 },
{ to: "/dashboard/admin/copy-logs", label: "OEM Kopyalama", icon: Copy },
{ to: "/dashboard/admin/referrals", label: "Referanslar", icon: Share2 },
{ to: "/dashboard/service-test", label: "Servis Test", icon: FlaskConical },
] as const;
const supportItems: readonly NavItem[] = [
{ 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;
}) {
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/60">
<p className="mb-1 mt-4 px-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">
{title}
</p>
);
@@ -105,24 +119,33 @@ function NavLink({
onClick?: () => void;
exact?: boolean;
}) {
return (
const link = (
<Link
to={to}
title={collapsed ? label : undefined}
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]:text-foreground [&.active]:font-semibold ${collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2"}`}
activeProps={{ className: "active" }}
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 — only visible when active */}
{/* 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 ${collapsed ? "hidden" : ""}`}
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 ───────────────────────────────────────────────────────────────────
@@ -131,6 +154,7 @@ 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(() => {
@@ -141,6 +165,24 @@ function DashboardLayout() {
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();
@@ -163,7 +205,9 @@ function DashboardLayout() {
if (isLoading) {
return (
<div className="flex min-h-[100dvh]">
<div className="hidden w-64 border-r border-border bg-background p-4 lg:block">
<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" />
@@ -177,7 +221,6 @@ function DashboardLayout() {
}
if (!user) {
navigate({ to: "/login" });
return null;
}
@@ -189,67 +232,131 @@ function DashboardLayout() {
.toUpperCase()
.slice(0, 2)
: "?";
// Capture narrowed (non-null) user fields for the nested ProfileMenu closure.
const userName = user.name;
const userEmail = user.email;
// Resolve labels (some need translation, some are static)
function resolveLabel(item: { label: string; translatable?: boolean }) {
return item.translatable ? t(item.label) : item.label;
// Current page title for the header — longest matching nav `to` wins.
const allNavItems = [...mainMenuItems, ...accountItems, ...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");
// 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 (
<>
{/* ANA MENÜ */}
<NavSection title="Ana Menü" collapsed={collapsed && !onNavigate} />
<NavSection title={t("nav.sectionMain")} collapsed={isCollapsed} />
{mainMenuItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
label={resolveLabel(item)}
label={t(item.labelKey)}
icon={item.icon}
collapsed={collapsed && !onNavigate}
collapsed={isCollapsed}
onClick={onNavigate}
exact={(item as any).exact}
exact={item.exact}
/>
))}
{/* DESTEK */}
<NavSection title="Destek" collapsed={collapsed && !onNavigate} />
{supportItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
label={resolveLabel(item)}
icon={item.icon}
collapsed={collapsed && !onNavigate}
onClick={onNavigate}
/>
))}
{/* HESAP */}
<NavSection title="Hesap" collapsed={collapsed && !onNavigate} />
<NavSection title={t("nav.sectionAccount")} collapsed={isCollapsed} />
{accountItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
label={resolveLabel(item)}
label={t(item.labelKey)}
icon={item.icon}
collapsed={collapsed && !onNavigate}
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}
/>
))}
{/* ADMIN */}
{isAdmin && (
<>
<NavSection title="Yönetim" collapsed={collapsed && !onNavigate} />
<NavSection title={t("nav.sectionAdmin")} collapsed={isCollapsed} />
{adminItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
label={item.label}
label={t(item.labelKey)}
icon={item.icon}
collapsed={collapsed && !onNavigate}
collapsed={isCollapsed}
onClick={onNavigate}
/>
))}
@@ -260,183 +367,149 @@ function DashboardLayout() {
}
return (
<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"}`}
<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"}`}
>
{!collapsed && (
<Link to="/" className="text-xl font-bold tracking-wider">
SASE
</Link>
)}
<button
type="button"
onClick={toggleCollapsed}
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 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"}`}>
<button
type="button"
onClick={handleSignOut}
title={collapsed ? (user.name ?? ıkış") : undefined}
className={`flex w-full items-center rounded-lg text-left transition-colors hover:bg-accent ${collapsed ? "justify-center p-2.5" : "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>
{!collapsed && (
<>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{user.name}</p>
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
</div>
<LogOut className="size-4 shrink-0 text-muted-foreground" />
</>
)}
</button>
</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 + User welcome */}
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
className="lg:hidden"
onClick={() => setMobileOpen(true)}
>
<Menu className="size-5" />
</Button>
<div className="hidden sm:block">
<p className="text-sm font-semibold">{user.name}</p>
<p className="text-xs text-muted-foreground">Sase.tr'ye hoş geldiniz</p>
</div>
</div>
{/* Right: Actions */}
<div className="flex items-center gap-2">
<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" />
Plan Yükselt
</Button>
</Link>
<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="Tema değiştir"
>
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</button>
<button
type="button"
onClick={handleSignOut}
className="flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground lg:hidden"
>
<LogOut 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 */}
<div className="border-t border-border px-6 py-3">
<p className="text-center text-xs text-muted-foreground/60">
&copy; {new Date().getFullYear()} Sase.tr ·{" "}
<Link to="/privacy" className="transition-colors hover:text-foreground">
Gizlilik Politikası
</Link>{" "}
·{" "}
<Link to="/terms" className="transition-colors hover:text-foreground">
Kullanım Koşulları
</Link>
</p>
</div>
</div>
{/* ─── Mobile Nav Overlay ───────────────────────────────────────── */}
{mobileOpen && (
<div className="fixed inset-0 z-50 lg:hidden">
{/* Logo + Collapse */}
<div
className="absolute inset-0 bg-black/50"
onClick={() => setMobileOpen(false)}
onKeyDown={() => {}}
role="button"
tabIndex={-1}
/>
<aside className="absolute left-0 top-0 flex h-full w-64 flex-col bg-background shadow-lg">
<div className="flex h-16 items-center justify-between border-b border-border px-4">
<span className="text-xl font-bold tracking-wider">SASE</span>
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(false)}>
<X className="size-4" />
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"}`}>
<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>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 py-2">
<SidebarNav onNavigate={() => setMobileOpen(false)} />
</nav>
{/* User section - bottom */}
<div className="border-t border-border p-3">
{/* 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>
)}
<button
type="button"
onClick={() => {
handleSignOut();
setMobileOpen(false);
}}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors hover:bg-accent"
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")}
>
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
{initials}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{user.name}</p>
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
</div>
<LogOut className="size-4 shrink-0 text-muted-foreground" />
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</button>
</div>
</aside>
</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 */}
<div className="border-t border-border px-6 py-3">
<p className="text-center text-xs text-muted-foreground/70">
&copy; {new Date().getFullYear()} Sase.tr ·{" "}
<Link to="/privacy" className="transition-colors hover:text-foreground">
Gizlilik Politikası
</Link>{" "}
·{" "}
<Link to="/terms" className="transition-colors hover:text-foreground">
Kullanım Koşulları
</Link>
</p>
</div>
</div>
)}
</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">
<ProfileMenu collapsed={false} onItemSelect={() => setMobileOpen(false)} />
</div>
</SheetContent>
</Sheet>
</div>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,79 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check } from "lucide-react";
import * as React from "react";
import { cn } from "./utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-lg border border-border bg-background p-1 text-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center gap-2.5 rounded-md px-2.5 py-2 text-sm font-medium outline-none transition-colors focus:bg-accent focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-muted-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2.5 py-1.5 text-sm font-semibold", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuPortal,
Check as DropdownMenuCheckIcon,
};

View File

@@ -27,3 +27,24 @@ export {
DialogTitle,
DialogDescription,
} from "./dialog";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuPortal,
} from "./dropdown-menu";
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "./tooltip";
export {
Sheet,
SheetTrigger,
SheetClose,
SheetPortal,
SheetOverlay,
SheetContent,
SheetTitle,
SheetDescription,
} from "./sheet";

102
packages/ui/src/sheet.tsx Normal file
View File

@@ -0,0 +1,102 @@
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { type VariantProps, cva } from "class-variance-authority";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "./utils";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ComponentRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 flex flex-col gap-0 bg-background shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-200 data-[state=open]:duration-300",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-72 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
right:
"inset-y-0 right-0 h-full w-72 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
},
},
defaultVariants: { side: "right" },
},
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {
hideClose?: boolean;
}
const SheetContent = React.forwardRef<
React.ComponentRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, hideClose, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{children}
{!hideClose && (
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 text-muted-foreground opacity-80 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
<X className="size-5" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetTitle = React.forwardRef<
React.ComponentRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ComponentRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetTrigger,
SheetClose,
SheetPortal,
SheetOverlay,
SheetContent,
SheetTitle,
SheetDescription,
};

View File

@@ -0,0 +1,27 @@
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import * as React from "react";
import { cn } from "./utils";
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ComponentRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 6, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-foreground px-2.5 py-1.5 text-xs font-medium text-background shadow-md data-[state=delayed-open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0 data-[side=right]:slide-in-from-left-1",
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };