Refactor Subscription Module and Enhance Subscription Detail View
- Updated the subscriptions module to register CancellationController alongside SimController, ensuring more specific route matching for cancellation actions. - Enhanced SubscriptionDetail component by adding a header action button for canceling Internet services, improving user experience and navigation. - Removed deprecated Internet service actions from the SubscriptionDetail view, streamlining the component and promoting cleaner code.
This commit is contained in:
parent
b19c213931
commit
655d26da4f
@ -14,8 +14,10 @@ import { SimManagementModule } from "./sim-management/sim-management.module.js";
|
|||||||
import { InternetManagementModule } from "./internet-management/internet-management.module.js";
|
import { InternetManagementModule } from "./internet-management/internet-management.module.js";
|
||||||
import { CallHistoryModule } from "./call-history/call-history.module.js";
|
import { CallHistoryModule } from "./call-history/call-history.module.js";
|
||||||
import { CancellationModule } from "./cancellation/cancellation.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 { SimController } from "./sim-management/sim.controller.js";
|
||||||
|
import { CancellationController } from "./cancellation/cancellation.controller.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -24,16 +26,19 @@ import { SimController } from "./sim-management/sim.controller.js";
|
|||||||
MappingsModule,
|
MappingsModule,
|
||||||
FreebitModule,
|
FreebitModule,
|
||||||
EmailModule,
|
EmailModule,
|
||||||
// Import SimManagementModule for its services and other controllers
|
|
||||||
SimManagementModule,
|
SimManagementModule,
|
||||||
InternetManagementModule,
|
InternetManagementModule,
|
||||||
CallHistoryModule,
|
CallHistoryModule,
|
||||||
CancellationModule,
|
CancellationModule,
|
||||||
],
|
],
|
||||||
// Register SimController BEFORE SubscriptionsController to ensure more specific routes
|
// Register specific route controllers BEFORE SubscriptionsController
|
||||||
// (like :id/sim) are matched before less specific routes (like :id)
|
// to ensure routes like :id/sim and :id/cancel are matched before :id
|
||||||
// This fixes the 404 error for /api/subscriptions/:id/sim
|
controllers: [
|
||||||
controllers: [SimController, SubscriptionsController, SimOrdersController],
|
SimController,
|
||||||
|
CancellationController,
|
||||||
|
SubscriptionsController,
|
||||||
|
SimOrdersController,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
SubscriptionsService,
|
SubscriptionsService,
|
||||||
SimManagementService,
|
SimManagementService,
|
||||||
|
|||||||
22
apps/portal/src/app/account/subscriptions/(list)/loading.tsx
Normal file
22
apps/portal/src/app/account/subscriptions/(list)/loading.tsx
Normal file
@ -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 (
|
||||||
|
<RouteLoading
|
||||||
|
icon={<Server />}
|
||||||
|
title="Subscriptions"
|
||||||
|
description="View and manage your subscriptions"
|
||||||
|
mode="content"
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SubscriptionStatsCardsSkeleton />
|
||||||
|
<SubscriptionTableSkeleton rows={6} />
|
||||||
|
</div>
|
||||||
|
</RouteLoading>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,59 +1,21 @@
|
|||||||
import { RouteLoading } from "@/components/molecules/RouteLoading";
|
import { RouteLoading } from "@/components/molecules/RouteLoading";
|
||||||
import { Server } from "lucide-react";
|
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 (
|
return (
|
||||||
<RouteLoading icon={<Server />} title="Service" description="Service details" mode="content">
|
<RouteLoading
|
||||||
|
icon={<Server />}
|
||||||
|
title="Subscription"
|
||||||
|
description="Loading subscription details..."
|
||||||
|
mode="content"
|
||||||
|
>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Main Subscription Card */}
|
<SubscriptionDetailStatsSkeleton />
|
||||||
<div className="bg-card border border-border rounded-2xl shadow-sm overflow-hidden">
|
<InvoiceListSkeleton rows={5} />
|
||||||
{/* Header */}
|
|
||||||
<div className="px-6 py-5 border-b border-border">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Skeleton className="h-10 w-10 rounded-xl" />
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Skeleton className="h-5 w-48" />
|
|
||||||
<Skeleton className="h-4 w-32" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-6 w-24 rounded-full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Stats Row */}
|
|
||||||
<div className="px-6 py-5 grid grid-cols-1 md:grid-cols-3 gap-6 bg-muted/20">
|
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<div key={i}>
|
|
||||||
<Skeleton className="h-3 w-24 mb-2" />
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Skeleton className="h-6 w-32" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tabs / Billing History Header */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Skeleton className="h-7 w-32" />
|
|
||||||
</div>
|
|
||||||
{/* Invoice List (Table-like) */}
|
|
||||||
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<div key={i} className="p-4 flex justify-between items-center">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Skeleton className="h-4 w-32" />
|
|
||||||
<Skeleton className="h-3 w-24" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-4 w-24" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</RouteLoading>
|
</RouteLoading>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 (
|
|
||||||
<RouteLoading
|
|
||||||
icon={<Server />}
|
|
||||||
title="Services"
|
|
||||||
description="View and manage your services"
|
|
||||||
mode="content"
|
|
||||||
>
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Stats Cards */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
|
||||||
<div key={i} className="bg-card rounded-xl border border-border p-5 shadow-sm">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Skeleton className="h-10 w-10 rounded-lg" />
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Skeleton className="h-4 w-20" />
|
|
||||||
<Skeleton className="h-6 w-12" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search and Table */}
|
|
||||||
<div className="bg-card rounded-xl border border-border shadow-sm overflow-hidden">
|
|
||||||
<div className="px-6 py-4 border-b border-border">
|
|
||||||
<Skeleton className="h-10 w-full max-w-md rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<div className="p-0">
|
|
||||||
{/* Custom Table Skeleton to match embedded style */}
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="border-b border-border p-4">
|
|
||||||
<div className="grid grid-cols-5 gap-4">
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-4 w-20" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
{Array.from({ length: 5 }).map((_, rowIndex) => (
|
|
||||||
<div key={rowIndex} className="p-4">
|
|
||||||
<div className="grid grid-cols-5 gap-4">
|
|
||||||
{Array.from({ length: 5 }).map((_, colIndex) => (
|
|
||||||
<Skeleton key={colIndex} className="h-4 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</RouteLoading>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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 (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="bg-card rounded-xl border border-border p-5 shadow-sm">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Skeleton className="h-10 w-10 rounded-lg" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="h-6 w-12" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscription table skeleton (3 columns: Service, Amount, Next Due) */
|
||||||
|
export function SubscriptionTableSkeleton({ rows = 6 }: { rows?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card rounded-xl border border-border shadow-sm overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-border">
|
||||||
|
<Skeleton className="h-10 w-full max-w-md rounded-lg" />
|
||||||
|
</div>
|
||||||
|
<div className="w-full">
|
||||||
|
{/* Header skeleton */}
|
||||||
|
<div className="bg-muted/50 px-6 py-4 border-b border-border">
|
||||||
|
<div className="grid grid-cols-3 gap-6">
|
||||||
|
<Skeleton className="h-3 w-16" />
|
||||||
|
<Skeleton className="h-3 w-16 ml-auto" />
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Row skeletons */}
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
|
<div key={i} className="px-6 py-5">
|
||||||
|
<div className="grid grid-cols-3 gap-6 items-center">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Skeleton className="h-5 w-5 rounded-full flex-shrink-0" />
|
||||||
|
<Skeleton className="h-4 w-48" />
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<Skeleton className="h-4 w-32 ml-auto" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-4 w-4" />
|
||||||
|
<Skeleton className="h-4 w-28" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscription detail stats skeleton (4 columns) */
|
||||||
|
export function SubscriptionDetailStatsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="bg-card border border-border rounded-2xl shadow-sm overflow-hidden">
|
||||||
|
<div className="px-6 py-5 grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||||
|
<div>
|
||||||
|
<Skeleton className="h-3 w-24 mb-2" />
|
||||||
|
<Skeleton className="h-7 w-20 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Skeleton className="h-3 w-24 mb-2" />
|
||||||
|
<Skeleton className="h-8 w-28" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Skeleton className="h-3 w-24 mb-2" />
|
||||||
|
<Skeleton className="h-6 w-32" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Skeleton className="h-3 w-28 mb-2" />
|
||||||
|
<Skeleton className="h-6 w-32" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invoice/Billing list skeleton */
|
||||||
|
export function InvoiceListSkeleton({ rows = 5 }: { rows?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-7 w-32" />
|
||||||
|
<div className="bg-card border border-border rounded-xl overflow-hidden">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
|
<div key={i} className="p-4 flex justify-between items-center">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Note: PageLoadingState is now handled by PageLayout component with proper skeleton loading
|
// Note: PageLoadingState is now handled by PageLayout component with proper skeleton loading
|
||||||
// FullPageLoadingState removed - use skeleton loading instead
|
// FullPageLoadingState removed - use skeleton loading instead
|
||||||
|
|||||||
@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { MagnifyingGlassIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
|
import { MagnifyingGlassIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
import { Spinner } from "@/components/atoms";
|
||||||
import { LoadingTable } from "@/components/atoms/loading-skeleton";
|
|
||||||
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
||||||
import { PaginationBar } from "@/components/molecules/PaginationBar/PaginationBar";
|
import { PaginationBar } from "@/components/molecules/PaginationBar/PaginationBar";
|
||||||
import { InvoiceTable } from "@/features/billing/components/InvoiceTable/InvoiceTable";
|
import { InvoiceTable } from "@/features/billing/components/InvoiceTable/InvoiceTable";
|
||||||
@ -51,9 +50,10 @@ export function InvoicesList({
|
|||||||
|
|
||||||
const invoicesQuery = isSubscriptionMode ? subscriptionInvoicesQuery : allInvoicesQuery;
|
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 } };
|
data?: { invoices: Invoice[]; pagination?: { totalItems: number; totalPages: number } };
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
isPending: boolean;
|
||||||
error: unknown;
|
error: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -83,13 +83,25 @@ export function InvoicesList({
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading || error) {
|
// Loading state - show centered spinner
|
||||||
|
if (isPending) {
|
||||||
return (
|
return (
|
||||||
<SubCard>
|
<div className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden">
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Spinner size="lg" className="text-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden">
|
||||||
<AsyncBlock isLoading={false} error={error}>
|
<AsyncBlock isLoading={false} error={error}>
|
||||||
<LoadingTable rows={6} columns={5} />
|
<div />
|
||||||
</AsyncBlock>
|
</AsyncBlock>
|
||||||
</SubCard>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -24,7 +24,9 @@ export function InvoiceDetailContainer() {
|
|||||||
const rawInvoiceParam = params.id;
|
const rawInvoiceParam = params.id;
|
||||||
const invoiceIdParam = Array.isArray(rawInvoiceParam) ? rawInvoiceParam[0] : rawInvoiceParam;
|
const invoiceIdParam = Array.isArray(rawInvoiceParam) ? rawInvoiceParam[0] : rawInvoiceParam;
|
||||||
const createSsoLinkMutation = useCreateInvoiceSsoLink();
|
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") => {
|
const handleCreateSsoLink = (target: "view" | "download" | "pay" = "view") => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
|||||||
@ -295,7 +295,9 @@ export function InternetPlansContainer() {
|
|||||||
const servicesBasePath = useServicesBasePath();
|
const servicesBasePath = useServicesBasePath();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { user } = useAuthSession();
|
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 eligibilityQuery = useInternetEligibility();
|
||||||
const eligibilityLoading = eligibilityQuery.isLoading;
|
const eligibilityLoading = eligibilityQuery.isLoading;
|
||||||
const refetchEligibility = eligibilityQuery.refetch;
|
const refetchEligibility = eligibilityQuery.refetch;
|
||||||
|
|||||||
@ -133,7 +133,9 @@ export function PublicInternetPlansContent({
|
|||||||
heroTitle = "Internet Service Plans",
|
heroTitle = "Internet Service Plans",
|
||||||
heroDescription = "NTT Optical Fiber with full English support",
|
heroDescription = "NTT Optical Fiber with full English support",
|
||||||
}: PublicInternetPlansContentProps) {
|
}: 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 servicesBasePath = useServicesBasePath();
|
||||||
const defaultCtaPath = `${servicesBasePath}/internet/configure`;
|
const defaultCtaPath = `${servicesBasePath}/internet/configure`;
|
||||||
const ctaPath = propCtaPath ?? defaultCtaPath;
|
const ctaPath = propCtaPath ?? defaultCtaPath;
|
||||||
|
|||||||
@ -19,8 +19,10 @@ import {
|
|||||||
export function PublicSimPlansView() {
|
export function PublicSimPlansView() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const servicesBasePath = useServicesBasePath();
|
const servicesBasePath = useServicesBasePath();
|
||||||
const { data, isLoading, error } = usePublicSimCatalog();
|
const { data, error } = usePublicSimCatalog();
|
||||||
const plans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]);
|
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<SimPlansTab>("data-voice");
|
const [activeTab, setActiveTab] = useState<SimPlansTab>("data-voice");
|
||||||
|
|
||||||
const handleSelectPlan = (planSku: string) => {
|
const handleSelectPlan = (planSku: string) => {
|
||||||
|
|||||||
@ -17,9 +17,11 @@ import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePa
|
|||||||
*/
|
*/
|
||||||
export function PublicVpnPlansView() {
|
export function PublicVpnPlansView() {
|
||||||
const servicesBasePath = useServicesBasePath();
|
const servicesBasePath = useServicesBasePath();
|
||||||
const { data, isLoading, error } = usePublicVpnCatalog();
|
const { data, error } = usePublicVpnCatalog();
|
||||||
const vpnPlans = data?.plans || [];
|
const vpnPlans = data?.plans || [];
|
||||||
const activationFees = data?.activationFees || [];
|
const activationFees = data?.activationFees || [];
|
||||||
|
// Simple loading check: show skeleton until we have data or an error
|
||||||
|
const isLoading = !data && !error;
|
||||||
|
|
||||||
if (isLoading || error) {
|
if (isLoading || error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -19,8 +19,10 @@ import {
|
|||||||
export function SimPlansContainer() {
|
export function SimPlansContainer() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const servicesBasePath = useServicesBasePath();
|
const servicesBasePath = useServicesBasePath();
|
||||||
const { data, isLoading, error } = useAccountSimCatalog();
|
const { data, error } = useAccountSimCatalog();
|
||||||
const plans: SimCatalogProduct[] = useMemo(() => data?.plans ?? [], [data?.plans]);
|
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<SimPlansTab>("data-voice");
|
const [activeTab, setActiveTab] = useState<SimPlansTab>("data-voice");
|
||||||
|
|
||||||
const handleSelectPlan = (planSku: string) => {
|
const handleSelectPlan = (planSku: string) => {
|
||||||
|
|||||||
@ -13,9 +13,11 @@ import { useServicesBasePath } from "@/features/services/hooks/useServicesBasePa
|
|||||||
|
|
||||||
export function VpnPlansView() {
|
export function VpnPlansView() {
|
||||||
const servicesBasePath = useServicesBasePath();
|
const servicesBasePath = useServicesBasePath();
|
||||||
const { data, isLoading, error } = useAccountVpnCatalog();
|
const { data, error } = useAccountVpnCatalog();
|
||||||
const vpnPlans = data?.plans || [];
|
const vpnPlans = data?.plans || [];
|
||||||
const activationFees = data?.activationFees || [];
|
const activationFees = data?.activationFees || [];
|
||||||
|
// Simple loading check: show skeleton until we have data or an error
|
||||||
|
const isLoading = !data && !error;
|
||||||
|
|
||||||
if (isLoading || error) {
|
if (isLoading || error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import {
|
|||||||
ServerIcon,
|
ServerIcon,
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
DocumentTextIcon,
|
DocumentTextIcon,
|
||||||
GlobeAltIcon,
|
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import { useSubscription } from "@/features/subscriptions/hooks";
|
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 { Formatting } from "@customer-portal/domain/toolkit";
|
||||||
import { PageLayout } from "@/components/templates/PageLayout";
|
import { PageLayout } from "@/components/templates/PageLayout";
|
||||||
import { StatusPill } from "@/components/atoms/status-pill";
|
import { StatusPill } from "@/components/atoms/status-pill";
|
||||||
|
import {
|
||||||
|
SubscriptionDetailStatsSkeleton,
|
||||||
|
InvoiceListSkeleton,
|
||||||
|
} from "@/components/atoms/loading-skeleton";
|
||||||
import { formatIsoDate } from "@/shared/utils";
|
import { formatIsoDate } from "@/shared/utils";
|
||||||
|
|
||||||
const { formatCurrency: sharedFormatCurrency } = Formatting;
|
const { formatCurrency: sharedFormatCurrency } = Formatting;
|
||||||
@ -31,7 +34,10 @@ export function SubscriptionDetailContainer() {
|
|||||||
const [activeTab, setActiveTab] = useState<"overview" | "sim">("overview");
|
const [activeTab, setActiveTab] = useState<"overview" | "sim">("overview");
|
||||||
|
|
||||||
const subscriptionId = parseInt(params.id as string);
|
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(() => {
|
useEffect(() => {
|
||||||
const updateTab = () => {
|
const updateTab = () => {
|
||||||
@ -52,14 +58,12 @@ export function SubscriptionDetailContainer() {
|
|||||||
|
|
||||||
const formatCurrency = (amount: number) => sharedFormatCurrency(amount || 0);
|
const formatCurrency = (amount: number) => sharedFormatCurrency(amount || 0);
|
||||||
|
|
||||||
const pageError =
|
// Show error message (only when we have an error, not during loading)
|
||||||
error || !subscription
|
const pageError = error
|
||||||
? process.env.NODE_ENV === "development"
|
? process.env.NODE_ENV === "development" && error instanceof Error
|
||||||
? error instanceof Error
|
? error.message
|
||||||
? error.message
|
: "Unable to load subscription details. Please try again."
|
||||||
: "Subscription not found"
|
: null;
|
||||||
: "Unable to load subscription details. Please try again."
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const productNameLower = subscription?.productName?.toLowerCase() ?? "";
|
const productNameLower = subscription?.productName?.toLowerCase() ?? "";
|
||||||
const isSimService = productNameLower.includes("sim");
|
const isSimService = productNameLower.includes("sim");
|
||||||
@ -70,47 +74,66 @@ export function SubscriptionDetailContainer() {
|
|||||||
(productNameLower.includes("ntt") && productNameLower.includes("fiber"));
|
(productNameLower.includes("ntt") && productNameLower.includes("fiber"));
|
||||||
const canCancel = subscription?.status === "Active";
|
const canCancel = subscription?.status === "Active";
|
||||||
|
|
||||||
|
// Header action: cancel button (for Internet services)
|
||||||
|
const headerActions =
|
||||||
|
isInternetService && canCancel ? (
|
||||||
|
<Link
|
||||||
|
href={`/account/subscriptions/${subscriptionId}/cancel`}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-danger-foreground bg-danger hover:bg-danger/90 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<XCircleIcon className="h-4 w-4" />
|
||||||
|
Cancel Service
|
||||||
|
</Link>
|
||||||
|
) : undefined;
|
||||||
|
|
||||||
|
// Render skeleton matching loading.tsx structure when loading
|
||||||
|
if (showLoading) {
|
||||||
|
return (
|
||||||
|
<PageLayout
|
||||||
|
icon={<ServerIcon className="h-6 w-6" />}
|
||||||
|
title="Subscription"
|
||||||
|
description="Loading subscription details..."
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: "Subscriptions", href: "/account/subscriptions" },
|
||||||
|
{ label: "Subscription" },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SubscriptionDetailStatsSkeleton />
|
||||||
|
<InvoiceListSkeleton rows={5} />
|
||||||
|
</div>
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout
|
<PageLayout
|
||||||
icon={<ServerIcon className="h-6 w-6" />}
|
icon={<ServerIcon className="h-6 w-6" />}
|
||||||
title={subscription?.productName ?? "Subscription"}
|
title={subscription?.productName ?? "Subscription"}
|
||||||
description={
|
actions={headerActions}
|
||||||
subscription ? `Service ID: ${subscription.serviceId}` : "View your subscription details"
|
|
||||||
}
|
|
||||||
breadcrumbs={[
|
breadcrumbs={[
|
||||||
{ label: "Subscriptions", href: "/account/subscriptions" },
|
{ label: "Subscriptions", href: "/account/subscriptions" },
|
||||||
{ label: subscription?.productName ?? "Subscription" },
|
{ label: subscription?.productName ?? "Subscription" },
|
||||||
]}
|
]}
|
||||||
loading={isLoading}
|
|
||||||
error={pageError}
|
error={pageError}
|
||||||
>
|
>
|
||||||
{subscription ? (
|
{subscription ? (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Main Subscription Card */}
|
{/* Subscription Stats */}
|
||||||
<div className="bg-card border border-border rounded-2xl shadow-sm overflow-hidden">
|
<div className="bg-card border border-border rounded-2xl shadow-sm overflow-hidden">
|
||||||
{/* Header with status */}
|
<div className="px-6 py-5 grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||||
<div className="px-6 py-5 border-b border-border">
|
<div>
|
||||||
<div className="flex items-center justify-between">
|
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
<div className="flex items-center gap-3">
|
Service Status
|
||||||
<div className="h-10 w-10 rounded-xl bg-primary/10 flex items-center justify-center">
|
</h4>
|
||||||
<ServerIcon className="h-5 w-5 text-primary" />
|
<div className="mt-2">
|
||||||
</div>
|
<StatusPill
|
||||||
<div>
|
label={subscription.status}
|
||||||
<h2 className="text-lg font-semibold text-foreground">Subscription Details</h2>
|
variant={getSubscriptionStatusVariant(subscription.status)}
|
||||||
<p className="text-sm text-muted-foreground">
|
size="lg"
|
||||||
Service subscription information
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<StatusPill
|
|
||||||
label={subscription.status}
|
|
||||||
variant={getSubscriptionStatusVariant(subscription.status)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Row */}
|
|
||||||
<div className="px-6 py-5 grid grid-cols-1 md:grid-cols-3 gap-6 bg-muted/20">
|
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
Billing Amount
|
Billing Amount
|
||||||
@ -193,49 +216,6 @@ export function SubscriptionDetailContainer() {
|
|||||||
<InvoicesList subscriptionId={subscriptionId} pageSize={5} showFilters={false} />
|
<InvoicesList subscriptionId={subscriptionId} pageSize={5} showFilters={false} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Internet Service Actions */}
|
|
||||||
{isInternetService && activeTab === "overview" && (
|
|
||||||
<div className="bg-card border border-border rounded-2xl shadow-sm overflow-hidden">
|
|
||||||
<div className="px-6 py-5 border-b border-border">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="h-10 w-10 rounded-xl bg-primary/10 flex items-center justify-center">
|
|
||||||
<GlobeAltIcon className="h-5 w-5 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">Service Actions</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Manage your Internet subscription
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-5">
|
|
||||||
{canCancel ? (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-foreground">Cancel Service</h4>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
Request cancellation of your Internet subscription
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href={`/account/subscriptions/${subscriptionId}/cancel`}
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-danger-foreground bg-danger hover:bg-danger/90 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<XCircleIcon className="h-4 w-4" />
|
|
||||||
Request Cancellation
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Service actions are not available for {subscription.status.toLowerCase()}{" "}
|
|
||||||
subscriptions.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|||||||
@ -5,7 +5,10 @@ import { Button } from "@/components/atoms/button";
|
|||||||
import { ErrorBoundary } from "@/components/molecules";
|
import { ErrorBoundary } from "@/components/molecules";
|
||||||
import { PageLayout } from "@/components/templates/PageLayout";
|
import { PageLayout } from "@/components/templates/PageLayout";
|
||||||
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar";
|
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 { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
||||||
import { SubscriptionTable } from "@/features/subscriptions/components/SubscriptionTable";
|
import { SubscriptionTable } from "@/features/subscriptions/components/SubscriptionTable";
|
||||||
import { Server, CheckCircle, XCircle } from "lucide-react";
|
import { Server, CheckCircle, XCircle } from "lucide-react";
|
||||||
@ -24,11 +27,16 @@ export function SubscriptionsListContainer() {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
data: subscriptionData,
|
data: subscriptionData,
|
||||||
isLoading,
|
|
||||||
error,
|
error,
|
||||||
} = useSubscriptions({ status: statusFilter === "all" ? undefined : statusFilter });
|
isFetching,
|
||||||
|
} = useSubscriptions({
|
||||||
|
status: statusFilter === "all" ? undefined : statusFilter,
|
||||||
|
});
|
||||||
const { data: stats } = useSubscriptionStats();
|
const { data: stats } = useSubscriptionStats();
|
||||||
|
|
||||||
|
// Simple loading check: show skeleton until we have data or an error
|
||||||
|
const showLoading = !subscriptionData && !error;
|
||||||
|
|
||||||
const subscriptions = useMemo((): Subscription[] => {
|
const subscriptions = useMemo((): Subscription[] => {
|
||||||
if (!subscriptionData) return [];
|
if (!subscriptionData) return [];
|
||||||
if (Array.isArray(subscriptionData)) return subscriptionData;
|
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 (
|
return (
|
||||||
<PageLayout
|
<PageLayout
|
||||||
icon={<Server />}
|
icon={<Server />}
|
||||||
@ -65,47 +74,8 @@ export function SubscriptionsListContainer() {
|
|||||||
>
|
>
|
||||||
<AsyncBlock isLoading={false} error={error}>
|
<AsyncBlock isLoading={false} error={error}>
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
<SubscriptionStatsCardsSkeleton />
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
<SubscriptionTableSkeleton rows={6} />
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="bg-card rounded-xl border border-border p-5 shadow-[var(--cp-shadow-1)]"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Skeleton className="h-10 w-10 rounded-lg" />
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Skeleton className="h-4 w-24" />
|
|
||||||
<Skeleton className="h-6 w-12" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="bg-card rounded-xl border border-border shadow-[var(--cp-shadow-1)] overflow-hidden">
|
|
||||||
<div className="px-6 py-4 border-b border-border">
|
|
||||||
<Skeleton className="h-10 w-full max-w-md rounded-lg" />
|
|
||||||
</div>
|
|
||||||
<div className="w-full">
|
|
||||||
<div className="border-b border-border p-4">
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-4 w-20" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
{Array.from({ length: 5 }).map((_, rowIndex) => (
|
|
||||||
<div key={rowIndex} className="p-4">
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
|
||||||
{Array.from({ length: 4 }).map((_, colIndex) => (
|
|
||||||
<Skeleton key={colIndex} className="h-4 w-full" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</AsyncBlock>
|
</AsyncBlock>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
@ -179,7 +149,7 @@ export function SubscriptionsListContainer() {
|
|||||||
{/* Subscriptions Table */}
|
{/* Subscriptions Table */}
|
||||||
<SubscriptionTable
|
<SubscriptionTable
|
||||||
subscriptions={filteredSubscriptions}
|
subscriptions={filteredSubscriptions}
|
||||||
loading={isLoading}
|
loading={isFetching && !!subscriptionData}
|
||||||
className="border-0 rounded-none shadow-none"
|
className="border-0 rounded-none shadow-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user