From 59cf55ae95b0bd3c8e10206fe0c88cf057271eec Mon Sep 17 00:00:00 2001 From: "T. Narantuya" Date: Thu, 11 Sep 2025 14:23:33 +0900 Subject: [PATCH] Refactor layout components and enforce import restrictions in portal pages - Removed direct imports of DashboardLayout from various portal pages to enforce the use of shared layout components. - Introduced ESLint rules to prevent importing DashboardLayout directly in (portal) pages, encouraging the use of the shared route-group layout. - Updated navigation methods to utilize Next.js router instead of window.location for improved routing consistency. - Enhanced loading states and error handling across multiple pages for better user experience. --- .../src/app/(portal)/account/billing/page.tsx | 19 +++--- .../src/app/(portal)/account/profile/page.tsx | 23 +++----- .../(portal)/billing/invoices/[id]/page.tsx | 52 +++++++--------- .../app/(portal)/billing/invoices/page.tsx | 4 +- .../src/app/(portal)/dashboard/page.tsx | 36 ++++++----- apps/portal/src/app/(portal)/layout.tsx | 7 +++ .../app/(portal)/subscriptions/[id]/page.tsx | 59 ++++++++----------- .../subscriptions/[id]/sim/cancel/page.tsx | 5 +- .../[id]/sim/change-plan/page.tsx | 5 +- .../subscriptions/[id]/sim/top-up/page.tsx | 5 +- .../src/app/(portal)/subscriptions/page.tsx | 4 +- .../src/app/(portal)/support/cases/page.tsx | 17 +++--- .../src/app/(portal)/support/new/page.tsx | 7 +-- .../components/layout/dashboard-layout.tsx | 24 +++----- .../src/components/layout/page-layout.tsx | 47 +++++++-------- .../billing/components/InvoiceItemRow.tsx | 4 +- .../dashboard/components/StatCard.tsx | 4 +- eslint.config.mjs | 50 ++++++++++++++++ 18 files changed, 188 insertions(+), 184 deletions(-) create mode 100644 apps/portal/src/app/(portal)/layout.tsx diff --git a/apps/portal/src/app/(portal)/account/billing/page.tsx b/apps/portal/src/app/(portal)/account/billing/page.tsx index 5605f68d..401a6d03 100644 --- a/apps/portal/src/app/(portal)/account/billing/page.tsx +++ b/apps/portal/src/app/(portal)/account/billing/page.tsx @@ -1,7 +1,6 @@ "use client"; import { useState, useEffect, Suspense } from "react"; -import { DashboardLayout } from "@/components/layout/dashboard-layout"; import { useSearchParams } from "next/navigation"; import { authenticatedApi } from "@/lib/api"; import { @@ -151,21 +150,17 @@ export default function BillingPage() { if (loading) { return ( - -
-
-
- - Loading billing information... - -
+
+
+
+ Loading billing information...
- +
); } return ( - + <>
@@ -431,6 +426,6 @@ export default function BillingPage() {
-
+ ); } diff --git a/apps/portal/src/app/(portal)/account/profile/page.tsx b/apps/portal/src/app/(portal)/account/profile/page.tsx index b170f607..8a04e39a 100644 --- a/apps/portal/src/app/(portal)/account/profile/page.tsx +++ b/apps/portal/src/app/(portal)/account/profile/page.tsx @@ -1,7 +1,6 @@ "use client"; import { useState, useEffect } from "react"; -import { DashboardLayout } from "@/components/layout/dashboard-layout"; import { useAuthStore } from "@/lib/auth/store"; import { authenticatedApi } from "@/lib/api"; import { logger } from "@/lib/logger"; @@ -278,23 +277,20 @@ export default function ProfilePage() { if (loading) { return ( - -
-
-
-
- Loading profile... -
+
+
+
+
+ Loading profile...
- +
); } return ( - -
-
+
+
{/* Header */}
@@ -760,8 +756,7 @@ export default function ProfilePage() {
-
- +
); } diff --git a/apps/portal/src/app/(portal)/billing/invoices/[id]/page.tsx b/apps/portal/src/app/(portal)/billing/invoices/[id]/page.tsx index 4b1f7fee..e790a0ff 100644 --- a/apps/portal/src/app/(portal)/billing/invoices/[id]/page.tsx +++ b/apps/portal/src/app/(portal)/billing/invoices/[id]/page.tsx @@ -4,7 +4,6 @@ import { logger } from "@/lib/logger"; import { useState } from "react"; import { useParams } from "next/navigation"; import Link from "next/link"; -import { DashboardLayout } from "@/components/layout/dashboard-layout"; import { SubCard } from "@/components/ui/sub-card"; import { StatusPill } from "@/components/ui/status-pill"; import { useAuthStore } from "@/lib/auth/store"; @@ -117,49 +116,42 @@ export default function InvoiceDetailPage() { if (isLoading) { return ( - -
-
-
-

Loading invoice...

-
+
+
+
+

Loading invoice...

- +
); } if (error || !invoice) { return ( - -
-
-
-
- +
+
+
+
+ +
+
+

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

-
+
+
+ +

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"],