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 */}