Update pnpm-lock.yaml and TypeScript configurations for improved dependency management and code structure
- Standardized string quotes in pnpm-lock.yaml for consistency. - Updated TypeScript configuration files to include type definitions and type roots for better type management. - Enhanced SalesforcePubSubSubscriber class with replay corruption recovery logic and refactored subscription handling for improved reliability. - Removed unused backup file for dashboard layout to streamline the codebase.
This commit is contained in:
parent
0bf872e249
commit
4c819a9a7b
@ -46,6 +46,8 @@ type PubSubCtor = new (opts: {
|
||||
export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy {
|
||||
private client: PubSubClient | null = null;
|
||||
private channel!: string;
|
||||
private replayCorruptionRecovered = false;
|
||||
private subscribeCallback!: SubscribeCallback;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
@ -94,7 +96,6 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy
|
||||
const client = this.client;
|
||||
|
||||
const replayKey = sfReplayKey(this.channel);
|
||||
const storedReplay = await this.cache.get<string>(replayKey);
|
||||
const replayMode = this.config.get<string>("SF_EVENTS_REPLAY", "LATEST");
|
||||
const numRequested = Number(this.config.get("SF_PUBSUB_NUM_REQUESTED", "50")) || 50;
|
||||
const maxQueue = Number(this.config.get("SF_PUBSUB_QUEUE_MAX", "100")) || 100;
|
||||
@ -104,7 +105,7 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy
|
||||
since: Date.now(),
|
||||
});
|
||||
|
||||
const subscribeCallback: SubscribeCallback = async (subscription, callbackType, data) => {
|
||||
this.subscribeCallback = async (subscription, callbackType, data) => {
|
||||
try {
|
||||
const argTypes = [typeof subscription, typeof callbackType, typeof data];
|
||||
const type = callbackType;
|
||||
@ -186,6 +187,35 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy
|
||||
// No-op; informational
|
||||
} else if (typeNorm === "error") {
|
||||
this.logger.warn("SF Pub/Sub stream error", { topic, data });
|
||||
try {
|
||||
// Detect replay id corruption and auto-recover once by clearing the cursor and resubscribing
|
||||
const maybeObj = (data || {}) as Record<string, unknown>;
|
||||
const details = typeof maybeObj["details"] === "string" ? (maybeObj["details"] as string) : "";
|
||||
const metadata = (maybeObj["metadata"] || {}) as Record<string, unknown>;
|
||||
const errorCodes = Array.isArray((metadata as { [k: string]: unknown })["error-code"]) ? ((metadata as { [k: string]: unknown })["error-code"] as unknown[]) : [];
|
||||
const hasCorruptionCode = errorCodes.some((v) => String(v).includes("replayid.corrupted"));
|
||||
const mentionsReplayValidation = /Replay ID validation failed/i.test(details);
|
||||
|
||||
if ((hasCorruptionCode || mentionsReplayValidation) && !this.replayCorruptionRecovered) {
|
||||
this.replayCorruptionRecovered = true;
|
||||
const key = sfReplayKey(this.channel);
|
||||
await this.cache.del(key);
|
||||
this.logger.warn("Cleared invalid Salesforce Pub/Sub replay cursor; retrying subscription", {
|
||||
channel: this.channel,
|
||||
key,
|
||||
});
|
||||
await this.cache.set(sfStatusKey(this.channel), {
|
||||
status: "reconnecting",
|
||||
since: Date.now(),
|
||||
});
|
||||
// Try re-subscribing without the invalid cursor
|
||||
await this.subscribeWithPolicy();
|
||||
}
|
||||
} catch (recoveryErr) {
|
||||
this.logger.warn("SF Pub/Sub replay corruption auto-recovery failed", {
|
||||
error: recoveryErr instanceof Error ? recoveryErr.message : String(recoveryErr),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Unknown callback type: log once with minimal context
|
||||
const maybeEvent = data as Record<string, unknown> | undefined;
|
||||
@ -208,24 +238,7 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy
|
||||
}
|
||||
};
|
||||
|
||||
if (storedReplay && replayMode !== "ALL") {
|
||||
await this.client.subscribeFromReplayId(
|
||||
this.channel,
|
||||
subscribeCallback,
|
||||
numRequested,
|
||||
Number(storedReplay)
|
||||
);
|
||||
} else if (replayMode === "ALL") {
|
||||
await this.client.subscribeFromEarliestEvent(this.channel, subscribeCallback, numRequested);
|
||||
} else {
|
||||
await this.client.subscribe(this.channel, subscribeCallback, numRequested);
|
||||
}
|
||||
|
||||
await this.cache.set(sfStatusKey(this.channel), {
|
||||
status: "connected",
|
||||
since: Date.now(),
|
||||
});
|
||||
this.logger.log("Salesforce Pub/Sub subscription active", { channel: this.channel });
|
||||
await this.subscribeWithPolicy();
|
||||
} catch (error) {
|
||||
this.logger.error("Salesforce Pub/Sub subscription failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
@ -260,5 +273,38 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy
|
||||
}
|
||||
}
|
||||
|
||||
private async subscribeWithPolicy(): Promise<void> {
|
||||
if (!this.client) throw new Error("Pub/Sub client not initialized");
|
||||
if (!this.subscribeCallback) throw new Error("Subscribe callback not initialized");
|
||||
|
||||
const replayMode = this.config.get<string>("SF_EVENTS_REPLAY", "LATEST");
|
||||
const numRequested = Number(this.config.get("SF_PUBSUB_NUM_REQUESTED", "50")) || 50;
|
||||
const replayKey = sfReplayKey(this.channel);
|
||||
const storedReplay = replayMode !== "ALL" ? await this.cache.get<string>(replayKey) : null;
|
||||
|
||||
if (storedReplay && replayMode !== "ALL") {
|
||||
await this.client.subscribeFromReplayId(
|
||||
this.channel,
|
||||
this.subscribeCallback,
|
||||
numRequested,
|
||||
Number(storedReplay)
|
||||
);
|
||||
} else if (replayMode === "ALL") {
|
||||
await this.client.subscribeFromEarliestEvent(
|
||||
this.channel,
|
||||
this.subscribeCallback,
|
||||
numRequested
|
||||
);
|
||||
} else {
|
||||
await this.client.subscribe(this.channel, this.subscribeCallback, numRequested);
|
||||
}
|
||||
|
||||
await this.cache.set(sfStatusKey(this.channel), {
|
||||
status: "connected",
|
||||
since: Date.now(),
|
||||
});
|
||||
this.logger.log("Salesforce Pub/Sub subscription active", { channel: this.channel });
|
||||
}
|
||||
|
||||
// keys moved to shared util
|
||||
}
|
||||
|
||||
@ -25,6 +25,8 @@
|
||||
"skipLibCheck": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"strictPropertyInitialization": false
|
||||
"strictPropertyInitialization": false,
|
||||
"types": ["node"],
|
||||
"typeRoots": ["./node_modules/@types"]
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,13 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
||||
@ -1,535 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, memo } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@/lib/auth/store";
|
||||
import { Logo } from "@/components/ui/logo";
|
||||
import {
|
||||
HomeIcon,
|
||||
CreditCardIcon,
|
||||
ServerIcon,
|
||||
ChatBubbleLeftRightIcon,
|
||||
UserIcon,
|
||||
Bars3Icon,
|
||||
XMarkIcon,
|
||||
BellIcon,
|
||||
ArrowRightStartOnRectangleIcon,
|
||||
Squares2X2Icon,
|
||||
ClipboardDocumentListIcon,
|
||||
PlusIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { useActiveSubscriptions } from "@/hooks/useSubscriptions";
|
||||
import type { Subscription } from "@customer-portal/shared";
|
||||
|
||||
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 { data: activeSubscriptions } = useActiveSubscriptions();
|
||||
|
||||
// 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();
|
||||
}, [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="relative z-10 flex-shrink-0 bg-white border-b border-[var(--cp-header-border)]">
|
||||
<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 / Home */}
|
||||
<div className="hidden md:flex items-center">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="inline-flex items-center text-sm font-semibold text-gray-900 hover:text-primary"
|
||||
aria-label="Home"
|
||||
>
|
||||
<HomeIcon className="h-5 w-5 mr-2" />
|
||||
Portal
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 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)]">Portal</span>
|
||||
<p className="text-xs text-[var(--cp-sidebar-text)]/60">Assist Solutions</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)]">Portal</span>
|
||||
<p className="text-xs text-[var(--cp-sidebar-text)]/60">Assist Solutions</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>
|
||||
);
|
||||
});
|
||||
10724
pnpm-lock.yaml
generated
10724
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user