-
+
+
+
+
+
+
+
+
Error loading invoice
+
+ {error instanceof Error ? error.message : "Invoice not found"}
-
-
Error loading invoice
-
- {error instanceof Error ? error.message : "Invoice not found"}
-
-
-
- ← Back to invoices
-
-
+
+
+ ← Back to invoices
+
-
+
);
}
return (
-
+ <>
{/* Back Button */}
@@ -377,6 +369,6 @@ export default function InvoiceDetailPage() {
-
+ >
);
}
diff --git a/apps/portal/src/app/(portal)/billing/invoices/page.tsx b/apps/portal/src/app/(portal)/billing/invoices/page.tsx
index 1160c149..1236ede0 100644
--- a/apps/portal/src/app/(portal)/billing/invoices/page.tsx
+++ b/apps/portal/src/app/(portal)/billing/invoices/page.tsx
@@ -1,6 +1,7 @@
"use client";
import React, { useState, useMemo } from "react";
+import { useRouter } from "next/navigation";
import Link from "next/link";
import { PageLayout } from "@/components/layout/page-layout";
import { SubCard } from "@/components/ui/sub-card";
@@ -24,6 +25,7 @@ import type { Invoice } from "@customer-portal/shared";
import { formatCurrency, getCurrencyLocale } from "@/utils/currency";
export default function InvoicesPage() {
+ const router = useRouter();
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [currentPage, setCurrentPage] = useState(1);
@@ -293,7 +295,7 @@ export default function InvoicesPage() {
? "No invoices match your filter criteria."
: "No invoices have been generated yet.",
}}
- onRowClick={invoice => (window.location.href = `/billing/invoices/${invoice.id}`)}
+ onRowClick={invoice => router.push(`/billing/invoices/${invoice.id}`)}
/>
diff --git a/apps/portal/src/app/(portal)/dashboard/page.tsx b/apps/portal/src/app/(portal)/dashboard/page.tsx
index cfe7b1a5..8647bb48 100644
--- a/apps/portal/src/app/(portal)/dashboard/page.tsx
+++ b/apps/portal/src/app/(portal)/dashboard/page.tsx
@@ -3,8 +3,8 @@ import { logger } from "@/lib/logger";
import { useState } from "react";
import Link from "next/link";
+import { useRouter } from "next/navigation";
import { useAuthStore } from "@/lib/auth/store";
-import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { useDashboardSummary } from "@/features/dashboard/hooks";
import type { Activity } from "@customer-portal/shared";
@@ -34,6 +34,7 @@ import { ErrorState } from "@/components/ui/error-state";
import { formatCurrency, getCurrencyLocale } from "@/utils/currency";
export default function DashboardPage() {
+ const router = useRouter();
const { user, isAuthenticated, isLoading: authLoading } = useAuthStore();
const { data: summary, isLoading: summaryLoading, error } = useDashboardSummary();
@@ -64,39 +65,35 @@ export default function DashboardPage() {
if (activity.type === "invoice_created" || activity.type === "invoice_paid") {
// Use the related invoice ID for navigation
if (activity.relatedId) {
- window.location.href = `/billing/invoices/${activity.relatedId}`;
+ router.push(`/billing/invoices/${activity.relatedId}`);
}
}
};
if (authLoading || summaryLoading || !isAuthenticated) {
return (
-
-
-
-
-
Loading dashboard...
-
+
);
}
// Handle error state
if (error) {
return (
-
-
-
+
);
}
return (
-
+ <>
{/* Modern Header */}
@@ -276,7 +273,7 @@ export default function DashboardPage() {
-
+ >
);
}
@@ -287,6 +284,7 @@ function truncateName(name: string, len = 28) {
}
function TasksChip({ summaryLoading, summary }: { summaryLoading: boolean; summary: any }) {
+ const router = useRouter();
if (summaryLoading) return null;
const tasks: Array<{ label: string; href: string }> = [];
if (summary?.nextInvoice) tasks.push({ label: "Pay upcoming invoice", href: "#attention" });
@@ -300,7 +298,7 @@ function TasksChip({ summaryLoading, summary }: { summaryLoading: boolean; summa
const el = document.querySelector(first.href);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
} else {
- window.location.href = first.href;
+ router.push(first.href);
}
}}
className="inline-flex items-center rounded-full bg-blue-50 text-blue-700 px-2.5 py-1 text-xs font-medium hover:bg-blue-100"
diff --git a/apps/portal/src/app/(portal)/layout.tsx b/apps/portal/src/app/(portal)/layout.tsx
new file mode 100644
index 00000000..fa7ea48a
--- /dev/null
+++ b/apps/portal/src/app/(portal)/layout.tsx
@@ -0,0 +1,7 @@
+import type { ReactNode } from "react";
+import { DashboardLayout } from "@/components/layout/dashboard-layout";
+
+export default function PortalLayout({ children }: { children: ReactNode }) {
+ return
{children};
+}
+
diff --git a/apps/portal/src/app/(portal)/subscriptions/[id]/page.tsx b/apps/portal/src/app/(portal)/subscriptions/[id]/page.tsx
index 23af22bc..66106cab 100644
--- a/apps/portal/src/app/(portal)/subscriptions/[id]/page.tsx
+++ b/apps/portal/src/app/(portal)/subscriptions/[id]/page.tsx
@@ -1,9 +1,8 @@
"use client";
import { useEffect, useState } from "react";
-import { useParams, useSearchParams } from "next/navigation";
+import { useParams, useSearchParams, useRouter } from "next/navigation";
import Link from "next/link";
-import { DashboardLayout } from "@/components/layout/dashboard-layout";
import {
ArrowLeftIcon,
ServerIcon,
@@ -21,6 +20,7 @@ import { formatCurrency as sharedFormatCurrency, getCurrencyLocale } from "@/uti
import { SimManagementSection } from "@/features/sim-management";
export default function SubscriptionDetailPage() {
+ const router = useRouter();
const params = useParams();
const searchParams = useSearchParams();
const [currentPage, setCurrentPage] = useState(1);
@@ -159,49 +159,42 @@ export default function SubscriptionDetailPage() {
if (isLoading) {
return (
-
-
-
-
-
Loading subscription...
-
+
+
+
+
Loading subscription...
-
+
);
}
if (error || !subscription) {
return (
-
-
-
-
-
-
+
+
+
+
+
+
+
+
Error loading subscription
+
+ {error instanceof Error ? error.message : "Subscription not found"}
-
-
Error loading subscription
-
- {error instanceof Error ? error.message : "Subscription not found"}
-
-
-
- ← Back to subscriptions
-
-
+
+
+ ← Back to subscriptions
+
-
+
);
}
return (
-
+ <>
{/* Header */}
@@ -402,9 +395,7 @@ export default function SubscriptionDetailPage() {
-
+ >
);
}
diff --git a/apps/portal/src/app/(portal)/subscriptions/[id]/sim/cancel/page.tsx b/apps/portal/src/app/(portal)/subscriptions/[id]/sim/cancel/page.tsx
index ce821ce8..d8f6bda2 100644
--- a/apps/portal/src/app/(portal)/subscriptions/[id]/sim/cancel/page.tsx
+++ b/apps/portal/src/app/(portal)/subscriptions/[id]/sim/cancel/page.tsx
@@ -1,7 +1,6 @@
"use client";
import Link from "next/link";
-import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { useParams } from "next/navigation";
import { useState } from "react";
import { authenticatedApi } from "@/lib/api";
@@ -28,8 +27,7 @@ export default function SimCancelPage() {
};
return (
-
-
+
-
);
}
diff --git a/apps/portal/src/app/(portal)/subscriptions/[id]/sim/change-plan/page.tsx b/apps/portal/src/app/(portal)/subscriptions/[id]/sim/change-plan/page.tsx
index d928a4ed..5d14bd4a 100644
--- a/apps/portal/src/app/(portal)/subscriptions/[id]/sim/change-plan/page.tsx
+++ b/apps/portal/src/app/(portal)/subscriptions/[id]/sim/change-plan/page.tsx
@@ -2,7 +2,6 @@
import { useState, useMemo } from "react";
import Link from "next/link";
-import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { useParams } from "next/navigation";
import { authenticatedApi } from "@/lib/api";
@@ -55,8 +54,7 @@ export default function SimChangePlanPage() {
};
return (
-
-
+
-
);
}
diff --git a/apps/portal/src/app/(portal)/subscriptions/[id]/sim/top-up/page.tsx b/apps/portal/src/app/(portal)/subscriptions/[id]/sim/top-up/page.tsx
index 4711c8a1..ac464b8f 100644
--- a/apps/portal/src/app/(portal)/subscriptions/[id]/sim/top-up/page.tsx
+++ b/apps/portal/src/app/(portal)/subscriptions/[id]/sim/top-up/page.tsx
@@ -2,7 +2,6 @@
import { useState } from "react";
import Link from "next/link";
-import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { useParams } from "next/navigation";
import { authenticatedApi } from "@/lib/api";
@@ -40,8 +39,7 @@ export default function SimTopUpPage() {
};
return (
-
-
+
-
);
}
diff --git a/apps/portal/src/app/(portal)/subscriptions/page.tsx b/apps/portal/src/app/(portal)/subscriptions/page.tsx
index 208967ab..a43081d6 100644
--- a/apps/portal/src/app/(portal)/subscriptions/page.tsx
+++ b/apps/portal/src/app/(portal)/subscriptions/page.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState, useMemo } from "react";
+import { useRouter } from "next/navigation";
import Link from "next/link";
import { PageLayout } from "@/components/layout/page-layout";
import { DataTable } from "@/components/ui/data-table";
@@ -26,6 +27,7 @@ import type { Subscription } from "@customer-portal/shared";
// Removed unused SubscriptionStatusBadge in favor of StatusPill
export default function SubscriptionsPage() {
+ const router = useRouter();
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
@@ -328,7 +330,7 @@ export default function SubscriptionsPage() {
? "Try adjusting your search or filter criteria."
: "No active subscriptions at this time.",
}}
- onRowClick={subscription => (window.location.href = `/subscriptions/${subscription.id}`)}
+ onRowClick={subscription => router.push(`/subscriptions/${subscription.id}`)}
/>
diff --git a/apps/portal/src/app/(portal)/support/cases/page.tsx b/apps/portal/src/app/(portal)/support/cases/page.tsx
index 6d198fd1..ad1f57ff 100644
--- a/apps/portal/src/app/(portal)/support/cases/page.tsx
+++ b/apps/portal/src/app/(portal)/support/cases/page.tsx
@@ -2,7 +2,6 @@
import { useState, useEffect } from "react";
import Link from "next/link";
-import { DashboardLayout } from "@/components/layout/dashboard-layout";
import {
ChatBubbleLeftRightIcon,
MagnifyingGlassIcon,
@@ -170,19 +169,17 @@ export default function SupportCasesPage() {
if (loading) {
return (
-
-
-
-
-
Loading support cases...
-
+
+
+
+
Loading support cases...
-
+
);
}
return (
-
+ <>
{/* Header */}
@@ -420,6 +417,6 @@ export default function SupportCasesPage() {
-
+ >
);
}
diff --git a/apps/portal/src/app/(portal)/support/new/page.tsx b/apps/portal/src/app/(portal)/support/new/page.tsx
index 7b2ecb52..df85cfd4 100644
--- a/apps/portal/src/app/(portal)/support/new/page.tsx
+++ b/apps/portal/src/app/(portal)/support/new/page.tsx
@@ -4,7 +4,6 @@ import { logger } from "@/lib/logger";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
-import { DashboardLayout } from "@/components/layout/dashboard-layout";
import {
ArrowLeftIcon,
PaperAirplaneIcon,
@@ -51,9 +50,8 @@ export default function NewSupportCasePage() {
const isFormValid = formData.subject.trim() && formData.description.trim();
return (
-
-
-
+
+
{/* Header */}
@@ -258,6 +256,5 @@ export default function NewSupportCasePage() {
-
);
}
diff --git a/apps/portal/src/components/layout/dashboard-layout.tsx b/apps/portal/src/components/layout/dashboard-layout.tsx
index cb48a48f..528c31ab 100644
--- a/apps/portal/src/components/layout/dashboard-layout.tsx
+++ b/apps/portal/src/components/layout/dashboard-layout.tsx
@@ -172,7 +172,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
}
return (
-
+
{/* Mobile sidebar overlay */}
{sidebarOpen && (
@@ -228,17 +228,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
- {/* Brand / Home */}
-
-
-
- Portal
-
-
+ {/* Brand removed from header per design */}
{/* Spacer */}
@@ -275,7 +265,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
{children}
-
+
);
}
@@ -339,8 +329,8 @@ const DesktopSidebar = memo(function DesktopSidebar({
-
Portal
-
Assist Solutions
+
Assist Solutions
+
Customer Portal
@@ -383,8 +373,8 @@ const MobileSidebar = memo(function MobileSidebar({
-
Portal
-
Assist Solutions
+
Assist Solutions
+
Customer Portal
diff --git a/apps/portal/src/components/layout/page-layout.tsx b/apps/portal/src/components/layout/page-layout.tsx
index 8210b7f6..d78bca37 100644
--- a/apps/portal/src/components/layout/page-layout.tsx
+++ b/apps/portal/src/components/layout/page-layout.tsx
@@ -1,5 +1,4 @@
import type { ReactNode } from "react";
-import { DashboardLayout } from "@/components/layout/dashboard-layout";
interface PageLayoutProps {
icon: ReactNode;
@@ -11,37 +10,31 @@ interface PageLayoutProps {
export function PageLayout({ icon, title, description, actions, children }: PageLayoutProps) {
return (
-
-
-
- {/* Header */}
-
-
-
- {icon && (
-
- {icon}
-
+
+
+ {/* Header */}
+
+
+
+ {icon && (
+
{icon}
+ )}
+
+
{title}
+ {description && (
+
+ {description}
+
)}
-
-
- {title}
-
- {description && (
-
- {description}
-
- )}
-
- {actions &&
{actions}
}
+ {actions &&
{actions}
}
-
- {/* Content */}
-
{children}
+
+ {/* Content */}
+
{children}
-
+
);
}
diff --git a/apps/portal/src/features/billing/components/InvoiceItemRow.tsx b/apps/portal/src/features/billing/components/InvoiceItemRow.tsx
index 8c596efe..3b3fe85b 100644
--- a/apps/portal/src/features/billing/components/InvoiceItemRow.tsx
+++ b/apps/portal/src/features/billing/components/InvoiceItemRow.tsx
@@ -1,5 +1,6 @@
"use client";
import { formatCurrency } from "@/utils/currency";
+import { useRouter } from "next/navigation";
export function InvoiceItemRow({
id,
@@ -16,6 +17,7 @@ export function InvoiceItemRow({
quantity?: number;
serviceId?: number;
}) {
+ const router = useRouter();
return (
(window.location.href = `/subscriptions/${serviceId}`) : undefined}
+ onClick={serviceId ? () => router.push(`/subscriptions/${serviceId}`) : undefined}
>
@@ -34,7 +36,7 @@ export function StatCard({
// Prevent card navigation if clicking the hint
e.preventDefault();
e.stopPropagation();
- window.location.href = zeroHint.href;
+ router.push(zeroHint.href);
}}
className="mt-1 inline-flex items-center text-xs font-medium text-blue-600 hover:text-blue-700 cursor-pointer"
>
diff --git a/eslint.config.mjs b/eslint.config.mjs
index f852c6ff..2e79e10f 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -99,6 +99,56 @@ export default [
},
},
+ // Prevent importing the DashboardLayout directly in (portal) pages.
+ // Pages should rely on the shared route-group layout at (portal)/layout.tsx.
+ {
+ files: ["apps/portal/src/app/(portal)/**/*.{ts,tsx}"],
+ rules: {
+ "no-restricted-imports": [
+ "error",
+ {
+ patterns: [
+ {
+ group: ["@/components/layout/dashboard-layout"],
+ message:
+ "Use the shared (portal)/layout.tsx instead of importing DashboardLayout in pages.",
+ },
+ ],
+ },
+ ],
+ // Prefer Next.js and router, forbid window/location hard reload in portal pages
+ "no-restricted-syntax": [
+ "error",
+ {
+ selector:
+ "MemberExpression[object.name='window'][property.name='location']",
+ message:
+ "Use next/link or useRouter for navigation, not window.location.",
+ },
+ {
+ selector:
+ "MemberExpression[object.name='location'][property.name=/^(href|assign|replace)$/]",
+ message:
+ "Use next/link or useRouter for navigation, not location.*.",
+ },
+ ],
+ },
+ },
+ // Allow the shared layout file itself to import the layout component
+ {
+ files: ["apps/portal/src/app/(portal)/layout.tsx"],
+ rules: {
+ "no-restricted-imports": "off",
+ },
+ },
+ // Allow controlled window.location usage for invoice SSO download
+ {
+ files: ["apps/portal/src/app/(portal)/billing/invoices/[id]/page.tsx"],
+ rules: {
+ "no-restricted-syntax": "off",
+ },
+ },
+
// Node globals for Next config file
{
files: ["apps/portal/next.config.mjs"],