538 lines
19 KiB
TypeScript
538 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useMemo, memo } from "react";
|
|
import Link from "next/link";
|
|
import { usePathname, useRouter } from "next/navigation";
|
|
import { useAuthStore } from "@/features/auth/services/auth.store";
|
|
import { Logo } from "@/components/atoms/logo";
|
|
import {
|
|
HomeIcon,
|
|
CreditCardIcon,
|
|
ServerIcon,
|
|
ChatBubbleLeftRightIcon,
|
|
UserIcon,
|
|
Bars3Icon,
|
|
XMarkIcon,
|
|
BellIcon,
|
|
ArrowRightStartOnRectangleIcon,
|
|
Squares2X2Icon,
|
|
ClipboardDocumentListIcon,
|
|
QuestionMarkCircleIcon,
|
|
} from "@heroicons/react/24/outline";
|
|
import { useActiveSubscriptions } from "@/features/subscriptions/hooks";
|
|
import type { Subscription } from "@customer-portal/domain";
|
|
|
|
interface DashboardLayoutProps {
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
interface NavigationChild {
|
|
name: string;
|
|
href: string;
|
|
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
|
tooltip?: string; // full text for truncated labels
|
|
}
|
|
|
|
interface NavigationItem {
|
|
name: string;
|
|
href?: string;
|
|
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
|
children?: NavigationChild[];
|
|
isLogout?: boolean;
|
|
}
|
|
|
|
const baseNavigation: NavigationItem[] = [
|
|
{ name: "Dashboard", href: "/dashboard", icon: HomeIcon },
|
|
{ name: "Orders", href: "/orders", icon: ClipboardDocumentListIcon },
|
|
{
|
|
name: "Billing",
|
|
icon: CreditCardIcon,
|
|
children: [
|
|
{ name: "Invoices", href: "/billing/invoices" },
|
|
{ name: "Payment Methods", href: "/billing/payments" },
|
|
],
|
|
},
|
|
{
|
|
name: "Subscriptions",
|
|
icon: ServerIcon,
|
|
// Children are added dynamically based on user subscriptions; default child keeps access to list
|
|
children: [{ name: "All Subscriptions", href: "/subscriptions" }],
|
|
},
|
|
{ name: "Catalog", href: "/catalog", icon: Squares2X2Icon },
|
|
{
|
|
name: "Support",
|
|
icon: ChatBubbleLeftRightIcon,
|
|
children: [
|
|
{ name: "Cases", href: "/support/cases" },
|
|
{ name: "New Case", href: "/support/new" },
|
|
{ name: "Knowledge Base", href: "/support/kb" },
|
|
],
|
|
},
|
|
{
|
|
name: "Account",
|
|
icon: UserIcon,
|
|
children: [
|
|
{ name: "Profile", href: "/account/profile" },
|
|
{ name: "Security", href: "/account/security" },
|
|
{ name: "Notifications", href: "/account/notifications" },
|
|
],
|
|
},
|
|
{ name: "Log out", href: "#", icon: ArrowRightStartOnRectangleIcon, isLogout: true },
|
|
];
|
|
|
|
export function DashboardLayout({ children }: DashboardLayoutProps) {
|
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
const [mounted, setMounted] = useState(false);
|
|
const { user, isAuthenticated, checkAuth } = useAuthStore();
|
|
const pathname = usePathname();
|
|
const router = useRouter();
|
|
const activeSubscriptionsQuery = useActiveSubscriptions();
|
|
const activeSubscriptions = activeSubscriptionsQuery.data ?? [];
|
|
|
|
// Initialize expanded items from localStorage or defaults
|
|
const [expandedItems, setExpandedItems] = useState<string[]>(() => {
|
|
if (typeof window !== "undefined") {
|
|
const saved = localStorage.getItem("sidebar-expanded-items");
|
|
if (saved) {
|
|
try {
|
|
const parsed = JSON.parse(saved) as unknown;
|
|
if (Array.isArray(parsed) && parsed.every(x => typeof x === "string")) {
|
|
return parsed;
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
return [];
|
|
});
|
|
|
|
// Save expanded items to localStorage whenever they change
|
|
useEffect(() => {
|
|
if (mounted) {
|
|
localStorage.setItem("sidebar-expanded-items", JSON.stringify(expandedItems));
|
|
}
|
|
}, [expandedItems, mounted]);
|
|
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
// Check auth on mount
|
|
void checkAuth();
|
|
|
|
// Set up automatic token refresh check every 5 minutes
|
|
const interval = setInterval(() => {
|
|
void checkAuth();
|
|
}, 5 * 60 * 1000);
|
|
|
|
return () => clearInterval(interval);
|
|
}, [checkAuth]);
|
|
|
|
useEffect(() => {
|
|
if (mounted && !isAuthenticated) {
|
|
router.push("/auth/login");
|
|
}
|
|
}, [mounted, isAuthenticated, router]);
|
|
|
|
// Auto-expand sections when browsing their routes (only if not already expanded)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
useEffect(() => {
|
|
const newExpanded: string[] = [];
|
|
|
|
if (pathname.startsWith("/subscriptions") && !expandedItems.includes("Subscriptions")) {
|
|
newExpanded.push("Subscriptions");
|
|
}
|
|
if (pathname.startsWith("/billing") && !expandedItems.includes("Billing")) {
|
|
newExpanded.push("Billing");
|
|
}
|
|
if (pathname.startsWith("/support") && !expandedItems.includes("Support")) {
|
|
newExpanded.push("Support");
|
|
}
|
|
if (pathname.startsWith("/account") && !expandedItems.includes("Account")) {
|
|
newExpanded.push("Account");
|
|
}
|
|
|
|
if (newExpanded.length > 0) {
|
|
setExpandedItems(prev => [...prev, ...newExpanded]);
|
|
}
|
|
}, [pathname]); // expandedItems intentionally excluded to avoid loops
|
|
|
|
const toggleExpanded = (itemName: string) => {
|
|
setExpandedItems(prev =>
|
|
prev.includes(itemName) ? prev.filter(name => name !== itemName) : [...prev, itemName]
|
|
);
|
|
};
|
|
|
|
// Removed unused initials computation
|
|
|
|
// Memoize navigation to prevent unnecessary re-renders
|
|
const navigation = useMemo(() => computeNavigation(activeSubscriptions), [activeSubscriptions]);
|
|
|
|
// Show loading state until mounted and auth is checked
|
|
if (!mounted) {
|
|
return (
|
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
|
<p className="mt-4 text-muted-foreground">Loading...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-screen flex overflow-hidden bg-background">
|
|
{/* Mobile sidebar overlay */}
|
|
|
|
{sidebarOpen && (
|
|
<div className="fixed inset-0 flex z-50 md:hidden">
|
|
<div
|
|
className="fixed inset-0 bg-black/50 animate-in fade-in duration-300"
|
|
onClick={() => setSidebarOpen(false)}
|
|
/>
|
|
<div className="relative flex-1 flex flex-col max-w-xs w-full bg-[var(--cp-sidebar-bg)] border-r border-[var(--cp-sidebar-border)] animate-in slide-in-from-left duration-300 shadow-2xl">
|
|
<div className="absolute top-0 right-0 -mr-12 pt-2">
|
|
<button
|
|
type="button"
|
|
className="ml-1 flex items-center justify-center h-10 w-10 rounded-full bg-white/10 backdrop-blur-sm text-white hover:bg-white/20 focus:outline-none focus:ring-2 focus:ring-white/50 transition-colors duration-200"
|
|
onClick={() => setSidebarOpen(false)}
|
|
>
|
|
<XMarkIcon className="h-6 w-6" />
|
|
</button>
|
|
</div>
|
|
<MobileSidebar
|
|
navigation={navigation}
|
|
pathname={pathname}
|
|
expandedItems={expandedItems}
|
|
toggleExpanded={toggleExpanded}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Desktop sidebar */}
|
|
<div className="hidden md:flex md:flex-shrink-0">
|
|
<div className="flex flex-col w-[240px] border-r border-[var(--cp-sidebar-border)] bg-[var(--cp-sidebar-bg)] shadow-sm">
|
|
<DesktopSidebar
|
|
navigation={navigation}
|
|
pathname={pathname}
|
|
expandedItems={expandedItems}
|
|
toggleExpanded={toggleExpanded}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main content */}
|
|
<div className="flex flex-col w-0 flex-1 overflow-hidden">
|
|
{/* Slim App Bar */}
|
|
<div className="test-div">
|
|
<div className="flex items-center h-16 gap-3 px-4 sm:px-6">
|
|
{/* Mobile menu button */}
|
|
<button
|
|
type="button"
|
|
className="md:hidden p-2 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-100 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/20"
|
|
onClick={() => setSidebarOpen(true)}
|
|
aria-label="Open navigation"
|
|
>
|
|
<Bars3Icon className="h-6 w-6" />
|
|
</button>
|
|
|
|
{/* Brand removed from header per design */}
|
|
|
|
{/* Spacer */}
|
|
<div className="flex-1" />
|
|
|
|
{/* Global Utilities: Notifications, Help, Profile */}
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
className="relative p-2 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-100 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-primary/20"
|
|
aria-label="Notifications"
|
|
>
|
|
<BellIcon className="h-5 w-5" />
|
|
<span className="absolute top-1 right-1 h-2 w-2 bg-red-500 rounded-full"></span>
|
|
</button>
|
|
|
|
<Link
|
|
href="/support/kb"
|
|
aria-label="Help"
|
|
className="hidden sm:inline-flex p-2 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-100 transition-colors"
|
|
title="Help Center"
|
|
>
|
|
<QuestionMarkCircleIcon className="h-5 w-5" />
|
|
</Link>
|
|
|
|
<Link
|
|
href="/account/profile"
|
|
className="hidden sm:inline-flex items-center px-2.5 py-1.5 text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors duration-200"
|
|
>
|
|
{user?.firstName || user?.email?.split("@")[0] || "Account"}
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<main className="flex-1 relative overflow-y-auto focus:outline-none">{children}</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function computeNavigation(activeSubscriptions?: Subscription[]): NavigationItem[] {
|
|
// Clone base structure
|
|
const nav: NavigationItem[] = baseNavigation.map(item => ({
|
|
...item,
|
|
children: item.children ? [...item.children] : undefined,
|
|
}));
|
|
|
|
// Inject dynamic submenu under Subscriptions
|
|
const subIdx = nav.findIndex(n => n.name === "Subscriptions");
|
|
if (subIdx >= 0) {
|
|
const dynamicChildren: NavigationChild[] = (activeSubscriptions || []).map(sub => {
|
|
const hrefBase = `/subscriptions/${sub.id}`;
|
|
// Link to the main subscription page - users can use the tabs to navigate to SIM management
|
|
const href = hrefBase;
|
|
return {
|
|
name: truncate(sub.productName || `Subscription ${sub.id}`, 28),
|
|
href,
|
|
tooltip: sub.productName || `Subscription ${sub.id}`,
|
|
} as NavigationChild;
|
|
});
|
|
|
|
nav[subIdx] = {
|
|
...nav[subIdx],
|
|
children: [
|
|
// Keep the list entry first
|
|
{ name: "All Subscriptions", href: "/subscriptions" },
|
|
// Divider-like label is avoided; we just list items
|
|
...dynamicChildren,
|
|
],
|
|
};
|
|
}
|
|
|
|
return nav;
|
|
}
|
|
|
|
function truncate(text: string, max: number): string {
|
|
if (text.length <= max) return text;
|
|
return text.slice(0, Math.max(0, max - 1)) + "…";
|
|
}
|
|
|
|
const DesktopSidebar = memo(function DesktopSidebar({
|
|
navigation,
|
|
pathname,
|
|
expandedItems,
|
|
toggleExpanded,
|
|
}: {
|
|
navigation: NavigationItem[];
|
|
pathname: string;
|
|
expandedItems: string[];
|
|
toggleExpanded: (name: string) => void;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col h-0 flex-1 bg-[var(--cp-sidebar-bg)]">
|
|
{/* Logo Section - Match header height */}
|
|
<div className="flex items-center flex-shrink-0 h-16 px-6 border-b border-[var(--cp-sidebar-border)]">
|
|
<div className="flex items-center space-x-3">
|
|
<div className="p-2 bg-white rounded-xl border border-[var(--cp-sidebar-border)] shadow-sm">
|
|
<Logo size={20} />
|
|
</div>
|
|
<div>
|
|
<span className="text-base font-bold text-[var(--cp-sidebar-text)]">Assist Solutions</span>
|
|
<p className="text-xs text-[var(--cp-sidebar-text)]/60">Customer Portal</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Navigation */}
|
|
<div className="flex-1 flex flex-col pt-6 pb-4 overflow-y-auto">
|
|
<nav className="flex-1 px-3 space-y-1">
|
|
{navigation.map(item => (
|
|
<NavigationItem
|
|
key={item.name}
|
|
item={item}
|
|
pathname={pathname}
|
|
isExpanded={expandedItems.includes(item.name)}
|
|
toggleExpanded={toggleExpanded}
|
|
/>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
const MobileSidebar = memo(function MobileSidebar({
|
|
navigation,
|
|
pathname,
|
|
expandedItems,
|
|
toggleExpanded,
|
|
}: {
|
|
navigation: NavigationItem[];
|
|
pathname: string;
|
|
expandedItems: string[];
|
|
toggleExpanded: (name: string) => void;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col h-0 flex-1 bg-[var(--cp-sidebar-bg)]">
|
|
{/* Logo Section - Match header height */}
|
|
<div className="flex items-center flex-shrink-0 h-16 px-6 border-b border-[var(--cp-sidebar-border)]">
|
|
<div className="flex items-center space-x-3">
|
|
<div className="p-2 bg-white rounded-xl border border-[var(--cp-sidebar-border)] shadow-sm">
|
|
<Logo size={20} />
|
|
</div>
|
|
<div>
|
|
<span className="text-base font-bold text-[var(--cp-sidebar-text)]">Assist Solutions</span>
|
|
<p className="text-xs text-[var(--cp-sidebar-text)]/60">Customer Portal</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Navigation */}
|
|
<div className="flex-1 flex flex-col pt-6 pb-4 overflow-y-auto">
|
|
<nav className="flex-1 px-3 space-y-1">
|
|
{navigation.map(item => (
|
|
<NavigationItem
|
|
key={item.name}
|
|
item={item}
|
|
pathname={pathname}
|
|
isExpanded={expandedItems.includes(item.name)}
|
|
toggleExpanded={toggleExpanded}
|
|
/>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
const NavigationItem = memo(function NavigationItem({
|
|
item,
|
|
pathname,
|
|
isExpanded,
|
|
toggleExpanded,
|
|
}: {
|
|
item: NavigationItem;
|
|
pathname: string;
|
|
isExpanded: boolean;
|
|
toggleExpanded: (name: string) => void;
|
|
}) {
|
|
const { logout } = useAuthStore();
|
|
const router = useRouter();
|
|
|
|
const hasChildren = item.children && item.children.length > 0;
|
|
const isActive = hasChildren
|
|
? (item.children?.some((child: NavigationChild) =>
|
|
pathname.startsWith((child.href || "").split(/[?#]/)[0])
|
|
) || false)
|
|
: (item.href ? pathname === item.href : false);
|
|
|
|
const handleLogout = () => {
|
|
void logout().then(() => {
|
|
router.push("/");
|
|
});
|
|
};
|
|
|
|
if (hasChildren) {
|
|
return (
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => toggleExpanded(item.name)}
|
|
aria-expanded={isExpanded}
|
|
className={`group w-full flex items-center px-3 py-2.5 text-left text-sm font-medium rounded-lg transition-all duration-200 relative ${isActive ? "text-[var(--cp-sidebar-active-text)] bg-[var(--cp-sidebar-active-bg)]" : "text-[var(--cp-sidebar-text)] hover:text-[var(--cp-sidebar-text-hover)] hover:bg-[var(--cp-sidebar-hover-bg)]"} focus:outline-none focus:ring-2 focus:ring-primary/20`}
|
|
>
|
|
{/* Active indicator */}
|
|
{isActive && (
|
|
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-primary rounded-r-full" />
|
|
)}
|
|
|
|
<div
|
|
className={`p-1.5 rounded-md mr-3 transition-colors duration-200 ${isActive ? "bg-primary/10 text-primary" : "text-[var(--cp-sidebar-text)]/70 group-hover:text-[var(--cp-sidebar-text-hover)] group-hover:bg-gray-100"}`}
|
|
>
|
|
<item.icon className="h-5 w-5" />
|
|
</div>
|
|
|
|
<span className="flex-1 font-medium">{item.name}</span>
|
|
|
|
<svg
|
|
className={`h-4 w-4 transition-transform duration-200 ease-out ${isExpanded ? "rotate-90" : ""} ${isActive ? "text-primary" : "text-[var(--cp-sidebar-text)]/50"}`}
|
|
viewBox="0 0 20 20"
|
|
fill="currentColor"
|
|
>
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
|
|
{/* Animated dropdown */}
|
|
<div
|
|
className={`overflow-hidden transition-all duration-300 ease-out ${isExpanded ? "max-h-96 opacity-100" : "max-h-0 opacity-0"}`}
|
|
>
|
|
<div className="mt-1 ml-6 space-y-0.5 border-l border-[var(--cp-sidebar-border)] pl-4">
|
|
{item.children?.map((child: NavigationChild) => {
|
|
const isChildActive = pathname === (child.href || "").split(/[?#]/)[0];
|
|
return (
|
|
<Link
|
|
key={child.name}
|
|
href={child.href}
|
|
className={`group flex items-center px-3 py-2 text-sm rounded-md transition-all duration-200 relative ${isChildActive ? "text-[var(--cp-sidebar-active-text)] bg-[var(--cp-sidebar-active-bg)] font-medium" : "text-[var(--cp-sidebar-text)]/80 hover:text-[var(--cp-sidebar-text-hover)] hover:bg-[var(--cp-sidebar-hover-bg)]"}`}
|
|
title={child.tooltip || child.name}
|
|
aria-current={isChildActive ? "page" : undefined}
|
|
>
|
|
{/* Child active indicator */}
|
|
{isChildActive && (
|
|
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-4 bg-primary rounded-full" />
|
|
)}
|
|
|
|
<div
|
|
className={`w-1.5 h-1.5 rounded-full mr-3 transition-colors duration-200 ${isChildActive ? "bg-primary" : "bg-[var(--cp-sidebar-text)]/30 group-hover:bg-[var(--cp-sidebar-text)]/50"}`}
|
|
/>
|
|
|
|
<span className="truncate">{child.name}</span>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (item.isLogout) {
|
|
return (
|
|
<button
|
|
onClick={handleLogout}
|
|
className="group w-full flex items-center px-3 py-2.5 text-sm font-medium text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-red-200"
|
|
>
|
|
<div className="p-1.5 rounded-md mr-3 text-red-500 group-hover:text-red-600 group-hover:bg-red-100 transition-colors duration-200">
|
|
<item.icon className="h-5 w-5" />
|
|
</div>
|
|
<span>{item.name}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Link
|
|
href={item.href || "#"}
|
|
className={`group w-full flex items-center px-3 py-2.5 text-sm font-medium rounded-lg transition-all duration-200 relative ${isActive ? "text-[var(--cp-sidebar-active-text)] bg-[var(--cp-sidebar-active-bg)]" : "text-[var(--cp-sidebar-text)] hover:text-[var(--cp-sidebar-text-hover)] hover:bg-[var(--cp-sidebar-hover-bg)]"} focus:outline-none focus:ring-2 focus:ring-primary/20`}
|
|
aria-current={isActive ? "page" : undefined}
|
|
>
|
|
{/* Active indicator */}
|
|
{isActive && (
|
|
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-6 bg-primary rounded-r-full" />
|
|
)}
|
|
|
|
<div
|
|
className={`p-1.5 rounded-md mr-3 transition-colors duration-200 ${isActive ? "bg-primary/10 text-primary" : "text-[var(--cp-sidebar-text)]/70 group-hover:text-[var(--cp-sidebar-text-hover)] group-hover:bg-gray-100"}`}
|
|
>
|
|
<item.icon className="h-5 w-5" />
|
|
</div>
|
|
|
|
<span className="truncate">{item.name}</span>
|
|
</Link>
|
|
);
|
|
});
|