diff --git a/apps/portal/src/components/molecules/error-fallbacks.tsx b/apps/portal/src/components/molecules/error-fallbacks.tsx
new file mode 100644
index 00000000..3b808d11
--- /dev/null
+++ b/apps/portal/src/components/molecules/error-fallbacks.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { Button } from "@/components/atoms/button";
+
+/**
+ * Full-page fallback for root-level errors
+ * Used when the entire application crashes
+ */
+export function GlobalErrorFallback() {
+ return (
+
+
+
Something went wrong
+
+ An unexpected error occurred. Please refresh the page.
+
+
+
+
+ );
+}
+
+/**
+ * Content area fallback - keeps nav/sidebar functional
+ * Used for errors within the main content area
+ */
+export function PageErrorFallback() {
+ return (
+
+
+
Something went wrong
+
+ This section encountered an error. Please try again.
+
+
+
+
+ );
+}
diff --git a/apps/portal/src/components/molecules/index.ts b/apps/portal/src/components/molecules/index.ts
index deb20ef7..1f64544e 100644
--- a/apps/portal/src/components/molecules/index.ts
+++ b/apps/portal/src/components/molecules/index.ts
@@ -31,3 +31,4 @@ export * from "./StatusBadge";
// Performance and lazy loading utilities
export { ErrorBoundary } from "./error-boundary";
+export { GlobalErrorFallback, PageErrorFallback } from "./error-fallbacks";
diff --git a/apps/portal/src/components/organisms/AppShell/AppShell.tsx b/apps/portal/src/components/organisms/AppShell/AppShell.tsx
index 3a6753fe..f2b0c542 100644
--- a/apps/portal/src/components/organisms/AppShell/AppShell.tsx
+++ b/apps/portal/src/components/organisms/AppShell/AppShell.tsx
@@ -7,13 +7,26 @@ import { useActiveSubscriptions } from "@/features/subscriptions/hooks";
import { accountService } from "@/features/account/api/account.api";
import { Sidebar } from "./Sidebar";
import { Header } from "./Header";
-import { computeNavigation } from "./navigation";
+import { computeNavigation, type NavigationItem } from "./navigation";
import type { Subscription } from "@customer-portal/domain/subscriptions";
interface AppShellProps {
children: React.ReactNode;
}
+function collectPrefetchUrls(navigation: NavigationItem[]): string[] {
+ const hrefs = new Set();
+ for (const item of navigation) {
+ if (item.href && item.href !== "#") hrefs.add(item.href);
+ if (!item.children || item.children.length === 0) continue;
+ // Prefetch only the first few children to avoid heavy prefetch
+ for (const child of item.children.slice(0, 5)) {
+ if (child.href && child.href !== "#") hrefs.add(child.href);
+ }
+ }
+ return [...hrefs];
+}
+
// Sidebar and navigation are modularized in ./Sidebar and ./navigation
export function AppShell({ children }: AppShellProps) {
@@ -121,17 +134,8 @@ export function AppShell({ children }: AppShellProps) {
// Proactively prefetch primary routes to speed up first navigation
useEffect(() => {
try {
- const hrefs = new Set();
- for (const item of navigation) {
- if (item.href && item.href !== "#") hrefs.add(item.href);
- if (item.children && item.children.length > 0) {
- // Prefetch only the first few children to avoid heavy prefetch
- for (const child of item.children.slice(0, 5)) {
- if (child.href && child.href !== "#") hrefs.add(child.href);
- }
- }
- }
- for (const href of hrefs) {
+ const urls = collectPrefetchUrls(navigation);
+ for (const href of urls) {
try {
router.prefetch(href);
} catch {
diff --git a/apps/portal/src/components/organisms/AppShell/Header.tsx b/apps/portal/src/components/organisms/AppShell/Header.tsx
index db6a9618..a663c41b 100644
--- a/apps/portal/src/components/organisms/AppShell/Header.tsx
+++ b/apps/portal/src/components/organisms/AppShell/Header.tsx
@@ -5,24 +5,38 @@ import { memo } from "react";
import { Bars3Icon, QuestionMarkCircleIcon } from "@heroicons/react/24/outline";
import { NotificationBell } from "@/features/notifications";
+interface UserInfo {
+ firstName?: string | null;
+ lastName?: string | null;
+ email?: string | null;
+}
+
+function getDisplayName(user: UserInfo | null, profileReady: boolean): string {
+ const fullName = [user?.firstName, user?.lastName].filter(Boolean).join(" ");
+ const emailPrefix = user?.email?.split("@")[0];
+
+ if (profileReady) {
+ return fullName || emailPrefix || "Account";
+ }
+ return emailPrefix || "Account";
+}
+
+function getInitials(user: UserInfo | null, profileReady: boolean, displayName: string): string {
+ if (profileReady && user?.firstName && user?.lastName) {
+ return `${user.firstName[0]}${user.lastName[0]}`.toUpperCase();
+ }
+ return displayName.slice(0, 2).toUpperCase();
+}
+
interface HeaderProps {
onMenuClick: () => void;
- user: { firstName?: string | null; lastName?: string | null; email?: string | null } | null;
+ user: UserInfo | null;
profileReady: boolean;
}
export const Header = memo(function Header({ onMenuClick, user, profileReady }: HeaderProps) {
- const displayName = profileReady
- ? [user?.firstName, user?.lastName].filter(Boolean).join(" ") ||
- user?.email?.split("@")[0] ||
- "Account"
- : user?.email?.split("@")[0] || "Account";
-
- // Get initials for avatar
- const initials =
- profileReady && user?.firstName && user?.lastName
- ? `${user.firstName[0]}${user.lastName[0]}`.toUpperCase()
- : displayName.slice(0, 2).toUpperCase();
+ const displayName = getDisplayName(user, profileReady);
+ const initials = getInitials(user, profileReady, displayName);
return (