Some checks failed
CI / Lint, Typecheck, Test & Build (push) Has been cancelled
Admin-only page at /dashboard/service-test with dropdown to test each VIN decode service (Corgi, PartsCatalogs, PL24, EMEX, VIN API) individually or the full cascade. Results shown as JSON on page, no DB writes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
446 lines
15 KiB
TypeScript
446 lines
15 KiB
TypeScript
import { createFileRoute, Outlet, Link, useNavigate } from "@tanstack/react-router";
|
||
import { useAuth } from "@/hooks/use-auth";
|
||
import { useTranslation } from "@/lib/i18n";
|
||
import { Button, Separator, Skeleton } from "@sase/ui";
|
||
import {
|
||
Search,
|
||
History,
|
||
CreditCard,
|
||
Receipt,
|
||
Settings,
|
||
Shield,
|
||
Users,
|
||
BarChart3,
|
||
DollarSign,
|
||
Share2,
|
||
Menu,
|
||
X,
|
||
LogOut,
|
||
PanelLeftClose,
|
||
PanelLeftOpen,
|
||
LayoutDashboard,
|
||
Bell,
|
||
ArrowRight,
|
||
Mail,
|
||
BookOpen,
|
||
Sun,
|
||
Moon,
|
||
Copy,
|
||
Library,
|
||
FlaskConical,
|
||
} from "lucide-react";
|
||
import { useState } from "react";
|
||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||
import { capture, resetUser } from "@/lib/posthog";
|
||
|
||
export const Route = createFileRoute("/dashboard")({
|
||
component: DashboardLayout,
|
||
});
|
||
|
||
// ─── 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;
|
||
|
||
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 supportItems = [
|
||
{ to: "/contact", label: "İletişim", icon: Mail },
|
||
{ to: "/blog", label: "Blog", icon: BookOpen },
|
||
] as const;
|
||
|
||
const adminItems = [
|
||
{ to: "/dashboard/admin", label: "Yönetim Paneli", icon: Shield },
|
||
{ to: "/dashboard/admin/users", label: "Kullanıcılar", icon: Users },
|
||
{ to: "/dashboard/admin/payments", label: "Ödemeler", icon: DollarSign },
|
||
{ 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;
|
||
|
||
// ─── 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/60">
|
||
{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;
|
||
}) {
|
||
return (
|
||
<Link
|
||
to={to}
|
||
title={collapsed ? label : undefined}
|
||
className={`flex items-center rounded-lg text-sm font-medium transition-colors hover:bg-accent [&.active]:bg-accent [&.active]:text-accent-foreground ${collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2"}`}
|
||
activeProps={{ className: "active" }}
|
||
activeOptions={exact ? { exact: true } : undefined}
|
||
onClick={onClick}
|
||
>
|
||
<Icon className="size-4 shrink-0" />
|
||
{!collapsed && <span>{label}</span>}
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
// ─── LAYOUT ───────────────────────────────────────────────────────────────────
|
||
|
||
function DashboardLayout() {
|
||
const { t } = useTranslation();
|
||
const { user, isLoading, signOut, isAdmin } = useAuth();
|
||
const navigate = useNavigate();
|
||
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";
|
||
});
|
||
|
||
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-screen">
|
||
<div className="hidden w-64 border-r border-border bg-background p-4 lg:block">
|
||
<Skeleton className="mb-8 h-8 w-32" />
|
||
{Array.from({ length: 5 }).map((_, i) => (
|
||
<Skeleton key={`nav-skel-${i}`} className="mb-3 h-10 w-full" />
|
||
))}
|
||
</div>
|
||
<div className="flex-1 p-6">
|
||
<Skeleton className="h-8 w-48" />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!user) {
|
||
navigate({ to: "/login" });
|
||
return null;
|
||
}
|
||
|
||
const initials = user.name
|
||
? user.name
|
||
.split(" ")
|
||
.map((w) => w[0])
|
||
.join("")
|
||
.toUpperCase()
|
||
.slice(0, 2)
|
||
: "?";
|
||
|
||
// Resolve labels (some need translation, some are static)
|
||
function resolveLabel(item: { label: string; translatable?: boolean }) {
|
||
return item.translatable ? t(item.label) : item.label;
|
||
}
|
||
|
||
// Sidebar content (shared between desktop & mobile)
|
||
function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
|
||
return (
|
||
<>
|
||
{/* ANA MENÜ */}
|
||
<NavSection title="Ana Menü" collapsed={collapsed && !onNavigate} />
|
||
{mainMenuItems.map((item) => (
|
||
<NavLink
|
||
key={item.to}
|
||
to={item.to}
|
||
label={resolveLabel(item)}
|
||
icon={item.icon}
|
||
collapsed={collapsed && !onNavigate}
|
||
onClick={onNavigate}
|
||
exact={(item as any).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} />
|
||
{accountItems.map((item) => (
|
||
<NavLink
|
||
key={item.to}
|
||
to={item.to}
|
||
label={resolveLabel(item)}
|
||
icon={item.icon}
|
||
collapsed={collapsed && !onNavigate}
|
||
onClick={onNavigate}
|
||
/>
|
||
))}
|
||
|
||
{/* ADMIN */}
|
||
{isAdmin && (
|
||
<>
|
||
<NavSection title="Yönetim" collapsed={collapsed && !onNavigate} />
|
||
{adminItems.map((item) => (
|
||
<NavLink
|
||
key={item.to}
|
||
to={item.to}
|
||
label={item.label}
|
||
icon={item.icon}
|
||
collapsed={collapsed && !onNavigate}
|
||
onClick={onNavigate}
|
||
/>
|
||
))}
|
||
</>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex min-h-screen 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-tight">
|
||
Sase.tr
|
||
</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 ────────────────────────────────────────── */}
|
||
<div className="flex 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="flex items-center gap-3">
|
||
<div className="hidden size-10 items-center justify-center rounded-full bg-muted text-sm font-semibold sm:flex">
|
||
{initials}
|
||
</div>
|
||
<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>
|
||
</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"
|
||
className="flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||
>
|
||
<Bell 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 className="flex-1 overflow-auto bg-muted/30 p-4 sm:p-6">
|
||
<Outlet />
|
||
</main>
|
||
|
||
{/* Footer */}
|
||
<div className="border-t border-border px-6 py-3">
|
||
<p className="text-center text-xs text-muted-foreground/60">
|
||
© {new Date().getFullYear()} Sase.tr | Gizlilik Politikası,
|
||
Kullanım Koşulları
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ─── Mobile Nav Overlay ───────────────────────────────────────── */}
|
||
{mobileOpen && (
|
||
<div className="fixed inset-0 z-50 lg:hidden">
|
||
<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">Sase.tr</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => setMobileOpen(false)}
|
||
>
|
||
<X className="size-4" />
|
||
</Button>
|
||
</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">
|
||
<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"
|
||
>
|
||
<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" />
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|