diff --git a/apps/bff/src/modules/subscriptions/subscriptions.module.ts b/apps/bff/src/modules/subscriptions/subscriptions.module.ts
index 88665d81..2e21b6e2 100644
--- a/apps/bff/src/modules/subscriptions/subscriptions.module.ts
+++ b/apps/bff/src/modules/subscriptions/subscriptions.module.ts
@@ -14,8 +14,10 @@ import { SimManagementModule } from "./sim-management/sim-management.module.js";
import { InternetManagementModule } from "./internet-management/internet-management.module.js";
import { CallHistoryModule } from "./call-history/call-history.module.js";
import { CancellationModule } from "./cancellation/cancellation.module.js";
-// Import SimController to register it directly in this module before SubscriptionsController
+// Import controllers to register them directly in this module before SubscriptionsController
+// This ensures more specific routes (like :id/sim, :id/cancel) are matched before :id
import { SimController } from "./sim-management/sim.controller.js";
+import { CancellationController } from "./cancellation/cancellation.controller.js";
@Module({
imports: [
@@ -24,16 +26,19 @@ import { SimController } from "./sim-management/sim.controller.js";
MappingsModule,
FreebitModule,
EmailModule,
- // Import SimManagementModule for its services and other controllers
SimManagementModule,
InternetManagementModule,
CallHistoryModule,
CancellationModule,
],
- // Register SimController BEFORE SubscriptionsController to ensure more specific routes
- // (like :id/sim) are matched before less specific routes (like :id)
- // This fixes the 404 error for /api/subscriptions/:id/sim
- controllers: [SimController, SubscriptionsController, SimOrdersController],
+ // Register specific route controllers BEFORE SubscriptionsController
+ // to ensure routes like :id/sim and :id/cancel are matched before :id
+ controllers: [
+ SimController,
+ CancellationController,
+ SubscriptionsController,
+ SimOrdersController,
+ ],
providers: [
SubscriptionsService,
SimManagementService,
diff --git a/apps/portal/src/app/account/subscriptions/(list)/loading.tsx b/apps/portal/src/app/account/subscriptions/(list)/loading.tsx
new file mode 100644
index 00000000..cc8e6a67
--- /dev/null
+++ b/apps/portal/src/app/account/subscriptions/(list)/loading.tsx
@@ -0,0 +1,22 @@
+import { RouteLoading } from "@/components/molecules/RouteLoading";
+import { Server } from "lucide-react";
+import {
+ SubscriptionStatsCardsSkeleton,
+ SubscriptionTableSkeleton,
+} from "@/components/atoms/loading-skeleton";
+
+export default function AccountSubscriptionsLoading() {
+ return (
+ }
+ title="Subscriptions"
+ description="View and manage your subscriptions"
+ mode="content"
+ >
+
+
+
+
+
+ );
+}
diff --git a/apps/portal/src/app/account/subscriptions/page.tsx b/apps/portal/src/app/account/subscriptions/(list)/page.tsx
similarity index 100%
rename from apps/portal/src/app/account/subscriptions/page.tsx
rename to apps/portal/src/app/account/subscriptions/(list)/page.tsx
diff --git a/apps/portal/src/app/account/subscriptions/[id]/loading.tsx b/apps/portal/src/app/account/subscriptions/[id]/loading.tsx
index 98608c18..86c2e3ed 100644
--- a/apps/portal/src/app/account/subscriptions/[id]/loading.tsx
+++ b/apps/portal/src/app/account/subscriptions/[id]/loading.tsx
@@ -1,59 +1,21 @@
import { RouteLoading } from "@/components/molecules/RouteLoading";
import { Server } from "lucide-react";
-import { Skeleton } from "@/components/atoms/loading-skeleton";
+import {
+ SubscriptionDetailStatsSkeleton,
+ InvoiceListSkeleton,
+} from "@/components/atoms/loading-skeleton";
-export default function AccountServiceDetailLoading() {
+export default function SubscriptionDetailLoading() {
return (
- } title="Service" description="Service details" mode="content">
+ }
+ title="Subscription"
+ description="Loading subscription details..."
+ mode="content"
+ >
- {/* Main Subscription Card */}
-
- {/* Header */}
-
- {/* Stats Row */}
-
- {Array.from({ length: 3 }).map((_, i) => (
-
- ))}
-
-
-
- {/* Tabs / Billing History Header */}
-
-
-
-
- {/* Invoice List (Table-like) */}
-
-
- {Array.from({ length: 5 }).map((_, i) => (
-
- ))}
-
-
-
+
+
);
diff --git a/apps/portal/src/app/account/subscriptions/loading.tsx b/apps/portal/src/app/account/subscriptions/loading.tsx
deleted file mode 100644
index 781ca24e..00000000
--- a/apps/portal/src/app/account/subscriptions/loading.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import { RouteLoading } from "@/components/molecules/RouteLoading";
-import { Server } from "lucide-react";
-import { Skeleton } from "@/components/atoms/loading-skeleton";
-
-export default function AccountServicesLoading() {
- return (
- }
- title="Services"
- description="View and manage your services"
- mode="content"
- >
-
- {/* Stats Cards */}
-
- {Array.from({ length: 3 }).map((_, i) => (
-
- ))}
-
-
- {/* Search and Table */}
-
-
-
-
-
- {/* Custom Table Skeleton to match embedded style */}
-
-
-
- {Array.from({ length: 5 }).map((_, i) => (
-
- ))}
-
-
-
- {Array.from({ length: 5 }).map((_, rowIndex) => (
-
-
- {Array.from({ length: 5 }).map((_, colIndex) => (
-
- ))}
-
-
- ))}
-
-
-
-
-
-
- );
-}
diff --git a/apps/portal/src/components/atoms/loading-skeleton.tsx b/apps/portal/src/components/atoms/loading-skeleton.tsx
index aa68ba76..64a7a41e 100644
--- a/apps/portal/src/components/atoms/loading-skeleton.tsx
+++ b/apps/portal/src/components/atoms/loading-skeleton.tsx
@@ -84,5 +84,117 @@ export function LoadingStats({ count = 4 }: { count?: number }) {
);
}
+// =============================================================================
+// Subscription Skeletons - for consistent loading across route and component
+// =============================================================================
+
+/** Stats cards skeleton (3 cards for subscriptions list) */
+export function SubscriptionStatsCardsSkeleton() {
+ return (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+/** Subscription table skeleton (3 columns: Service, Amount, Next Due) */
+export function SubscriptionTableSkeleton({ rows = 6 }: { rows?: number }) {
+ return (
+
+
+
+
+
+ {/* Header skeleton */}
+
+ {/* Row skeletons */}
+
+ {Array.from({ length: rows }).map((_, i) => (
+
+ ))}
+
+
+
+ );
+}
+
+/** Subscription detail stats skeleton (4 columns) */
+export function SubscriptionDetailStatsSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+/** Invoice/Billing list skeleton */
+export function InvoiceListSkeleton({ rows = 5 }: { rows?: number }) {
+ return (
+
+
+
+
+ {Array.from({ length: rows }).map((_, i) => (
+
+ ))}
+
+
+
+ );
+}
+
// Note: PageLoadingState is now handled by PageLayout component with proper skeleton loading
// FullPageLoadingState removed - use skeleton loading instead
diff --git a/apps/portal/src/features/billing/components/InvoiceList/InvoiceList.tsx b/apps/portal/src/features/billing/components/InvoiceList/InvoiceList.tsx
index a8ba189f..14dd1a37 100644
--- a/apps/portal/src/features/billing/components/InvoiceList/InvoiceList.tsx
+++ b/apps/portal/src/features/billing/components/InvoiceList/InvoiceList.tsx
@@ -2,8 +2,7 @@
import React, { useMemo, useState } from "react";
import { MagnifyingGlassIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
-import { SubCard } from "@/components/molecules/SubCard/SubCard";
-import { LoadingTable } from "@/components/atoms/loading-skeleton";
+import { Spinner } from "@/components/atoms";
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
import { PaginationBar } from "@/components/molecules/PaginationBar/PaginationBar";
import { InvoiceTable } from "@/features/billing/components/InvoiceTable/InvoiceTable";
@@ -51,9 +50,10 @@ export function InvoicesList({
const invoicesQuery = isSubscriptionMode ? subscriptionInvoicesQuery : allInvoicesQuery;
- const { data, isLoading, error } = invoicesQuery as {
+ const { data, isLoading, isPending, error } = invoicesQuery as {
data?: { invoices: Invoice[]; pagination?: { totalItems: number; totalPages: number } };
isLoading: boolean;
+ isPending: boolean;
error: unknown;
};
@@ -83,13 +83,25 @@ export function InvoicesList({
[]
);
- if (isLoading || error) {
+ // Loading state - show centered spinner
+ if (isPending) {
return (
-
+
+ );
+ }
+
+ // Error state
+ if (error) {
+ return (
+
);
}
diff --git a/apps/portal/src/features/billing/views/InvoiceDetail.tsx b/apps/portal/src/features/billing/views/InvoiceDetail.tsx
index af618484..6e1117c7 100644
--- a/apps/portal/src/features/billing/views/InvoiceDetail.tsx
+++ b/apps/portal/src/features/billing/views/InvoiceDetail.tsx
@@ -24,7 +24,9 @@ export function InvoiceDetailContainer() {
const rawInvoiceParam = params.id;
const invoiceIdParam = Array.isArray(rawInvoiceParam) ? rawInvoiceParam[0] : rawInvoiceParam;
const createSsoLinkMutation = useCreateInvoiceSsoLink();
- const { data: invoice, isLoading, error } = useInvoice(invoiceIdParam ?? "");
+ const { data: invoice, error } = useInvoice(invoiceIdParam ?? "");
+ // Simple loading check: show skeleton until we have data or an error
+ const isLoading = !invoice && !error;
const handleCreateSsoLink = (target: "view" | "download" | "pay" = "view") => {
void (async () => {
diff --git a/apps/portal/src/features/services/views/InternetPlans.tsx b/apps/portal/src/features/services/views/InternetPlans.tsx
index acdb11c1..45217d31 100644
--- a/apps/portal/src/features/services/views/InternetPlans.tsx
+++ b/apps/portal/src/features/services/views/InternetPlans.tsx
@@ -295,7 +295,9 @@ export function InternetPlansContainer() {
const servicesBasePath = useServicesBasePath();
const searchParams = useSearchParams();
const { user } = useAuthSession();
- const { data, isLoading, error } = useAccountInternetCatalog();
+ const { data, error } = useAccountInternetCatalog();
+ // Simple loading check: show skeleton until we have data or an error
+ const isLoading = !data && !error;
const eligibilityQuery = useInternetEligibility();
const eligibilityLoading = eligibilityQuery.isLoading;
const refetchEligibility = eligibilityQuery.refetch;
diff --git a/apps/portal/src/features/services/views/PublicInternetPlans.tsx b/apps/portal/src/features/services/views/PublicInternetPlans.tsx
index 9e8423d8..12a24205 100644
--- a/apps/portal/src/features/services/views/PublicInternetPlans.tsx
+++ b/apps/portal/src/features/services/views/PublicInternetPlans.tsx
@@ -133,7 +133,9 @@ export function PublicInternetPlansContent({
heroTitle = "Internet Service Plans",
heroDescription = "NTT Optical Fiber with full English support",
}: PublicInternetPlansContentProps) {
- const { data: servicesCatalog, isLoading, error } = usePublicInternetCatalog();
+ const { data: servicesCatalog, error } = usePublicInternetCatalog();
+ // Simple loading check: show skeleton until we have data or an error
+ const isLoading = !servicesCatalog && !error;
const servicesBasePath = useServicesBasePath();
const defaultCtaPath = `${servicesBasePath}/internet/configure`;
const ctaPath = propCtaPath ?? defaultCtaPath;
diff --git a/apps/portal/src/features/services/views/PublicSimPlans.tsx b/apps/portal/src/features/services/views/PublicSimPlans.tsx
index 2b30f103..58d6ced4 100644
--- a/apps/portal/src/features/services/views/PublicSimPlans.tsx
+++ b/apps/portal/src/features/services/views/PublicSimPlans.tsx
@@ -19,8 +19,10 @@ import {
export function PublicSimPlansView() {
const router = useRouter();
const servicesBasePath = useServicesBasePath();
- const { data, isLoading, error } = usePublicSimCatalog();
+ const { data, error } = usePublicSimCatalog();
const plans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]);
+ // Simple loading check: show skeleton until we have data or an error
+ const isLoading = !data && !error;
const [activeTab, setActiveTab] = useState("data-voice");
const handleSelectPlan = (planSku: string) => {
diff --git a/apps/portal/src/features/services/views/PublicVpnPlans.tsx b/apps/portal/src/features/services/views/PublicVpnPlans.tsx
index ba86dc7c..b3b775a3 100644
--- a/apps/portal/src/features/services/views/PublicVpnPlans.tsx
+++ b/apps/portal/src/features/services/views/PublicVpnPlans.tsx
@@ -17,9 +17,11 @@ import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePa
*/
export function PublicVpnPlansView() {
const servicesBasePath = useServicesBasePath();
- const { data, isLoading, error } = usePublicVpnCatalog();
+ const { data, error } = usePublicVpnCatalog();
const vpnPlans = data?.plans || [];
const activationFees = data?.activationFees || [];
+ // Simple loading check: show skeleton until we have data or an error
+ const isLoading = !data && !error;
if (isLoading || error) {
return (
diff --git a/apps/portal/src/features/services/views/SimPlans.tsx b/apps/portal/src/features/services/views/SimPlans.tsx
index 0881df75..f3849ed4 100644
--- a/apps/portal/src/features/services/views/SimPlans.tsx
+++ b/apps/portal/src/features/services/views/SimPlans.tsx
@@ -19,8 +19,10 @@ import {
export function SimPlansContainer() {
const router = useRouter();
const servicesBasePath = useServicesBasePath();
- const { data, isLoading, error } = useAccountSimCatalog();
+ const { data, error } = useAccountSimCatalog();
const plans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]);
+ // Simple loading check: show skeleton until we have data or an error
+ const isLoading = !data && !error;
const [activeTab, setActiveTab] = useState("data-voice");
const handleSelectPlan = (planSku: string) => {
diff --git a/apps/portal/src/features/services/views/VpnPlans.tsx b/apps/portal/src/features/services/views/VpnPlans.tsx
index 7cebfcdd..3d32ceb6 100644
--- a/apps/portal/src/features/services/views/VpnPlans.tsx
+++ b/apps/portal/src/features/services/views/VpnPlans.tsx
@@ -13,9 +13,11 @@ import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePa
export function VpnPlansView() {
const servicesBasePath = useServicesBasePath();
- const { data, isLoading, error } = useAccountVpnCatalog();
+ const { data, error } = useAccountVpnCatalog();
const vpnPlans = data?.plans || [];
const activationFees = data?.activationFees || [];
+ // Simple loading check: show skeleton until we have data or an error
+ const isLoading = !data && !error;
if (isLoading || error) {
return (
diff --git a/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx b/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx
index 0968b746..acf01ce3 100644
--- a/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx
+++ b/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx
@@ -7,7 +7,6 @@ import {
ServerIcon,
CalendarIcon,
DocumentTextIcon,
- GlobeAltIcon,
XCircleIcon,
} from "@heroicons/react/24/outline";
import { useSubscription } from "@/features/subscriptions/hooks";
@@ -15,6 +14,10 @@ import { InvoicesList } from "@/features/billing/components/InvoiceList/InvoiceL
import { Formatting } from "@customer-portal/domain/toolkit";
import { PageLayout } from "@/components/templates/PageLayout";
import { StatusPill } from "@/components/atoms/status-pill";
+import {
+ SubscriptionDetailStatsSkeleton,
+ InvoiceListSkeleton,
+} from "@/components/atoms/loading-skeleton";
import { formatIsoDate } from "@/shared/utils";
const { formatCurrency: sharedFormatCurrency } = Formatting;
@@ -31,7 +34,10 @@ export function SubscriptionDetailContainer() {
const [activeTab, setActiveTab] = useState<"overview" | "sim">("overview");
const subscriptionId = parseInt(params.id as string);
- const { data: subscription, isLoading, error } = useSubscription(subscriptionId);
+ const { data: subscription, error } = useSubscription(subscriptionId);
+
+ // Simple loading check: show skeleton until we have data or an error
+ const showLoading = !subscription && !error;
useEffect(() => {
const updateTab = () => {
@@ -52,14 +58,12 @@ export function SubscriptionDetailContainer() {
const formatCurrency = (amount: number) => sharedFormatCurrency(amount || 0);
- const pageError =
- error || !subscription
- ? process.env.NODE_ENV === "development"
- ? error instanceof Error
- ? error.message
- : "Subscription not found"
- : "Unable to load subscription details. Please try again."
- : null;
+ // Show error message (only when we have an error, not during loading)
+ const pageError = error
+ ? process.env.NODE_ENV === "development" && error instanceof Error
+ ? error.message
+ : "Unable to load subscription details. Please try again."
+ : null;
const productNameLower = subscription?.productName?.toLowerCase() ?? "";
const isSimService = productNameLower.includes("sim");
@@ -70,47 +74,66 @@ export function SubscriptionDetailContainer() {
(productNameLower.includes("ntt") && productNameLower.includes("fiber"));
const canCancel = subscription?.status === "Active";
+ // Header action: cancel button (for Internet services)
+ const headerActions =
+ isInternetService && canCancel ? (
+
+
+ Cancel Service
+
+ ) : undefined;
+
+ // Render skeleton matching loading.tsx structure when loading
+ if (showLoading) {
+ return (
+ }
+ title="Subscription"
+ description="Loading subscription details..."
+ breadcrumbs={[
+ { label: "Subscriptions", href: "/account/subscriptions" },
+ { label: "Subscription" },
+ ]}
+ >
+
+
+
+
+
+ );
+ }
+
return (
}
title={subscription?.productName ?? "Subscription"}
- description={
- subscription ? `Service ID: ${subscription.serviceId}` : "View your subscription details"
- }
+ actions={headerActions}
breadcrumbs={[
{ label: "Subscriptions", href: "/account/subscriptions" },
{ label: subscription?.productName ?? "Subscription" },
]}
- loading={isLoading}
error={pageError}
>
{subscription ? (
- {/* Main Subscription Card */}
+ {/* Subscription Stats */}
- {/* Header with status */}
-
-
-
-
-
-
-
-
Subscription Details
-
- Service subscription information
-
-
+
+
+
+ Service Status
+
+
+
-
-
-
- {/* Stats Row */}
-
Billing Amount
@@ -193,49 +216,6 @@ export function SubscriptionDetailContainer() {
)}
-
- {/* Internet Service Actions */}
- {isInternetService && activeTab === "overview" && (
-
-
-
-
-
-
-
-
Service Actions
-
- Manage your Internet subscription
-
-
-
-
-
- {canCancel ? (
-
-
-
Cancel Service
-
- Request cancellation of your Internet subscription
-
-
-
-
- Request Cancellation
-
-
- ) : (
-
- Service actions are not available for {subscription.status.toLowerCase()}{" "}
- subscriptions.
-
- )}
-
-
- )}
) : null}
diff --git a/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx b/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx
index 799903cc..8b0fa1a4 100644
--- a/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx
+++ b/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx
@@ -5,7 +5,10 @@ import { Button } from "@/components/atoms/button";
import { ErrorBoundary } from "@/components/molecules";
import { PageLayout } from "@/components/templates/PageLayout";
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar";
-import { Skeleton } from "@/components/atoms/loading-skeleton";
+import {
+ SubscriptionStatsCardsSkeleton,
+ SubscriptionTableSkeleton,
+} from "@/components/atoms/loading-skeleton";
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
import { SubscriptionTable } from "@/features/subscriptions/components/SubscriptionTable";
import { Server, CheckCircle, XCircle } from "lucide-react";
@@ -24,11 +27,16 @@ export function SubscriptionsListContainer() {
const {
data: subscriptionData,
- isLoading,
error,
- } = useSubscriptions({ status: statusFilter === "all" ? undefined : statusFilter });
+ isFetching,
+ } = useSubscriptions({
+ status: statusFilter === "all" ? undefined : statusFilter,
+ });
const { data: stats } = useSubscriptionStats();
+ // Simple loading check: show skeleton until we have data or an error
+ const showLoading = !subscriptionData && !error;
+
const subscriptions = useMemo((): Subscription[] => {
if (!subscriptionData) return [];
if (Array.isArray(subscriptionData)) return subscriptionData;
@@ -56,7 +64,8 @@ export function SubscriptionsListContainer() {
[]
);
- if (isLoading || error) {
+ // Show skeleton when loading, error state when error
+ if (showLoading || error) {
return (
}
@@ -65,47 +74,8 @@ export function SubscriptionsListContainer() {
>
-
- {Array.from({ length: 3 }).map((_, i) => (
-
- ))}
-
-
-
-
-
-
-
-
- {Array.from({ length: 4 }).map((_, i) => (
-
- ))}
-
-
-
- {Array.from({ length: 5 }).map((_, rowIndex) => (
-
-
- {Array.from({ length: 4 }).map((_, colIndex) => (
-
- ))}
-
-
- ))}
-
-
-
+
+
@@ -179,7 +149,7 @@ export function SubscriptionsListContainer() {
{/* Subscriptions Table */}