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