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:
T. Narantuya 2025-09-11 13:17:10 +09:00
parent 0bf872e249
commit 4c819a9a7b
5 changed files with 3719 additions and 7638 deletions

View File

@ -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
}

View File

@ -25,6 +25,8 @@
"skipLibCheck": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"strictPropertyInitialization": false
"strictPropertyInitialization": false,
"types": ["node"],
"typeRoots": ["./node_modules/@types"]
}
}

View File

@ -1,13 +1,7 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
},
"experimentalDecorators": true,
"emitDecoratorMetadata": true
"noEmit": true
},
"include": ["src/**/*"]
}

View File

@ -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

File diff suppressed because it is too large Load Diff