diff --git a/apps/bff/src/subscriptions/sim-management.service.ts b/apps/bff/src/subscriptions/sim-management.service.ts index 54f9ee93..1a0d06af 100644 --- a/apps/bff/src/subscriptions/sim-management.service.ts +++ b/apps/bff/src/subscriptions/sim-management.service.ts @@ -28,6 +28,13 @@ export interface SimTopUpHistoryRequest { toDate: string; // YYYYMMDD } +export interface SimFeaturesUpdateRequest { + voiceMailEnabled?: boolean; + callWaitingEnabled?: boolean; + internationalRoamingEnabled?: boolean; + networkType?: '4G' | '5G'; +} + @Injectable() export class SimManagementService { constructor( @@ -327,6 +334,41 @@ export class SimManagementService { } } + /** + * Update SIM features (voicemail, call waiting, roaming, network type) + */ + async updateSimFeatures( + userId: string, + subscriptionId: number, + request: SimFeaturesUpdateRequest + ): Promise { + try { + const { account } = await this.validateSimSubscription(userId, subscriptionId); + + // Validate network type if provided + if (request.networkType && !['4G', '5G'].includes(request.networkType)) { + throw new BadRequestException('networkType must be either "4G" or "5G"'); + } + + await this.freebititService.updateSimFeatures(account, request); + + this.logger.log(`Updated SIM features for subscription ${subscriptionId}`, { + userId, + subscriptionId, + account, + ...request, + }); + } catch (error) { + this.logger.error(`Failed to update SIM features for subscription ${subscriptionId}`, { + error: getErrorMessage(error), + userId, + subscriptionId, + ...request, + }); + throw error; + } + } + /** * Cancel SIM service */ diff --git a/apps/bff/src/subscriptions/subscriptions.controller.ts b/apps/bff/src/subscriptions/subscriptions.controller.ts index b7c1a202..09f9d522 100644 --- a/apps/bff/src/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/subscriptions/subscriptions.controller.ts @@ -388,4 +388,38 @@ export class SubscriptionsController { await this.simManagementService.reissueEsimProfile(req.user.id, subscriptionId); return { success: true, message: "eSIM profile reissue completed successfully" }; } + + @Post(":id/sim/features") + @ApiOperation({ + summary: "Update SIM features", + description: "Enable/disable voicemail, call waiting, international roaming, and switch network type (4G/5G)", + }) + @ApiParam({ name: "id", type: Number, description: "Subscription ID" }) + @ApiBody({ + description: "Features update request", + schema: { + type: "object", + properties: { + voiceMailEnabled: { type: "boolean" }, + callWaitingEnabled: { type: "boolean" }, + internationalRoamingEnabled: { type: "boolean" }, + networkType: { type: "string", enum: ["4G", "5G"] }, + }, + }, + }) + @ApiResponse({ status: 200, description: "Features update successful" }) + async updateSimFeatures( + @Request() req: RequestWithUser, + @Param("id", ParseIntPipe) subscriptionId: number, + @Body() + body: { + voiceMailEnabled?: boolean; + callWaitingEnabled?: boolean; + internationalRoamingEnabled?: boolean; + networkType?: '4G' | '5G'; + } + ) { + await this.simManagementService.updateSimFeatures(req.user.id, subscriptionId, body); + return { success: true, message: "SIM features updated successfully" }; + } } diff --git a/apps/bff/src/vendors/freebit/freebit.service.ts b/apps/bff/src/vendors/freebit/freebit.service.ts index d375ffef..61e223cd 100644 --- a/apps/bff/src/vendors/freebit/freebit.service.ts +++ b/apps/bff/src/vendors/freebit/freebit.service.ts @@ -24,7 +24,9 @@ import { SimDetails, SimUsage, SimTopUpHistory, - FreebititError + FreebititError, + FreebititAddSpecRequest, + FreebititAddSpecResponse } from './interfaces/freebit.types'; @Injectable() @@ -476,6 +478,55 @@ export class FreebititService { } } + /** + * Update SIM optional features (voicemail, call waiting, international roaming, network type) + * Uses AddSpec endpoint for immediate changes + */ + async updateSimFeatures(account: string, features: { + voiceMailEnabled?: boolean; + callWaitingEnabled?: boolean; + internationalRoamingEnabled?: boolean; + networkType?: string; // '4G' | '5G' + }): Promise { + try { + const request: Omit = { + account, + }; + + if (typeof features.voiceMailEnabled === 'boolean') { + request.voiceMail = features.voiceMailEnabled ? '10' as const : '20' as const; + request.voicemail = request.voiceMail; // include alternate casing for compatibility + } + if (typeof features.callWaitingEnabled === 'boolean') { + request.callWaiting = features.callWaitingEnabled ? '10' as const : '20' as const; + request.callwaiting = request.callWaiting; + } + if (typeof features.internationalRoamingEnabled === 'boolean') { + request.worldWing = features.internationalRoamingEnabled ? '10' as const : '20' as const; + request.worldwing = request.worldWing; + } + if (features.networkType) { + request.contractLine = features.networkType; + } + + await this.makeAuthenticatedRequest('/master/addSpec/', request); + + this.logger.log(`Updated SIM features for account ${account}`, { + account, + voiceMailEnabled: features.voiceMailEnabled, + callWaitingEnabled: features.callWaitingEnabled, + internationalRoamingEnabled: features.internationalRoamingEnabled, + networkType: features.networkType, + }); + } catch (error: any) { + this.logger.error(`Failed to update SIM features for account ${account}`, { + error: error.message, + account, + }); + throw error; + } + } + /** * Cancel SIM service */ diff --git a/apps/bff/src/vendors/freebit/interfaces/freebit.types.ts b/apps/bff/src/vendors/freebit/interfaces/freebit.types.ts index ab87923f..37a3c086 100644 --- a/apps/bff/src/vendors/freebit/interfaces/freebit.types.ts +++ b/apps/bff/src/vendors/freebit/interfaces/freebit.types.ts @@ -115,6 +115,28 @@ export interface FreebititTopUpResponse { }; } +// AddSpec request for updating SIM options/features immediately +export interface FreebititAddSpecRequest { + authKey: string; + account: string; + // Feature flags: 10 = enabled, 20 = disabled + voiceMail?: '10' | '20'; + voicemail?: '10' | '20'; + callWaiting?: '10' | '20'; + callwaiting?: '10' | '20'; + worldWing?: '10' | '20'; + worldwing?: '10' | '20'; + contractLine?: string; // '4G' or '5G' +} + +export interface FreebititAddSpecResponse { + resultCode: string; + status: { + message: string; + statusCode: string; + }; +} + export interface FreebititQuotaHistoryRequest { authKey: string; account: string; diff --git a/apps/portal/src/app/subscriptions/[id]/page.tsx b/apps/portal/src/app/subscriptions/[id]/page.tsx index 63af0370..a356f995 100644 --- a/apps/portal/src/app/subscriptions/[id]/page.tsx +++ b/apps/portal/src/app/subscriptions/[id]/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { useState } from "react"; -import { useParams } from "next/navigation"; +import { useEffect, useState } from "react"; +import { useParams, useSearchParams } from "next/navigation"; import Link from "next/link"; import { DashboardLayout } from "@/components/layout/dashboard-layout"; import { @@ -22,8 +22,11 @@ import { SimManagementSection } from "@/features/sim-management"; export default function SubscriptionDetailPage() { const params = useParams(); + const searchParams = useSearchParams(); const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 10; + const [showInvoices, setShowInvoices] = useState(true); + const [showSimManagement, setShowSimManagement] = useState(false); const subscriptionId = parseInt(params.id as string); const { data: subscription, isLoading, error } = useSubscription(subscriptionId); @@ -36,6 +39,31 @@ export default function SubscriptionDetailPage() { const invoices = invoiceData?.invoices || []; const pagination = invoiceData?.pagination; + // Control what sections to show based on URL hash + useEffect(() => { + const updateVisibility = () => { + const hash = typeof window !== 'undefined' ? window.location.hash : ''; + const service = (searchParams.get('service') || '').toLowerCase(); + const isSimContext = hash.includes('sim-management') || service === 'sim'; + + if (isSimContext) { + // Show only SIM management, hide invoices + setShowInvoices(false); + setShowSimManagement(true); + } else { + // Show only invoices, hide SIM management + setShowInvoices(true); + setShowSimManagement(false); + } + }; + updateVisibility(); + if (typeof window !== 'undefined') { + window.addEventListener('hashchange', updateVisibility); + return () => window.removeEventListener('hashchange', updateVisibility); + } + return; + }, [searchParams]); + const getStatusIcon = (status: string) => { switch (status) { case "Active": @@ -175,7 +203,7 @@ export default function SubscriptionDetailPage() { return (
-
+
{/* Header */}
@@ -191,6 +219,7 @@ export default function SubscriptionDetailPage() {
+
@@ -247,12 +276,51 @@ export default function SubscriptionDetailPage() { - {/* SIM Management Section - Only show for SIM services */} + {/* Navigation tabs for SIM services - More visible and mobile-friendly */} {subscription.productName.toLowerCase().includes('sim') && ( +
+
+
+
+

Service Management

+

Switch between billing and SIM management views

+
+
+ + + SIM Management + + + + Billing + +
+
+
+
+ )} + + {/* SIM Management Section - Only show when in SIM context and for SIM services */} + {showSimManagement && subscription.productName.toLowerCase().includes('sim') && ( )} - {/* Related Invoices */} + {/* Related Invoices (hidden when viewing SIM management directly) */} + {showInvoices && (
@@ -427,6 +495,7 @@ export default function SubscriptionDetailPage() { )}
+ )}
diff --git a/apps/portal/src/components/layout/dashboard-layout.tsx b/apps/portal/src/components/layout/dashboard-layout.tsx index abf03482..b3bbd357 100644 --- a/apps/portal/src/components/layout/dashboard-layout.tsx +++ b/apps/portal/src/components/layout/dashboard-layout.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { useAuthStore } from "@/lib/auth/store"; @@ -18,6 +18,8 @@ import { Squares2X2Icon, ClipboardDocumentListIcon, } from "@heroicons/react/24/outline"; +import { useActiveSubscriptions } from "@/hooks/useSubscriptions"; +import type { Subscription } from "@customer-portal/shared"; interface DashboardLayoutProps { children: React.ReactNode; @@ -37,7 +39,7 @@ interface NavigationItem { isLogout?: boolean; } -const navigation = [ +const baseNavigation: NavigationItem[] = [ { name: "Dashboard", href: "/dashboard", icon: HomeIcon }, { name: "Orders", href: "/orders", icon: ClipboardDocumentListIcon }, { @@ -48,7 +50,12 @@ const navigation = [ { name: "Payment Methods", href: "/billing/payments" }, ], }, - { name: "Subscriptions", href: "/subscriptions", icon: ServerIcon }, + { + name: "Subscriptions", + icon: ServerIcon, + // Children are added dynamically based on user subscriptions; default child keeps access to list + children: [{ name: "All Subscriptions", href: "/subscriptions" }], + }, { name: "Catalog", href: "/catalog", icon: Squares2X2Icon }, { name: "Support", @@ -78,6 +85,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) { const { user, isAuthenticated, checkAuth } = useAuthStore(); const pathname = usePathname(); const router = useRouter(); + const { data: activeSubscriptions } = useActiveSubscriptions(); useEffect(() => { setMounted(true); @@ -91,6 +99,13 @@ export function DashboardLayout({ children }: DashboardLayoutProps) { } }, [mounted, isAuthenticated, router]); + // Auto-expand Subscriptions when browsing subscription routes + useEffect(() => { + if (pathname.startsWith("/subscriptions") && !expandedItems.includes("Subscriptions")) { + setExpandedItems(prev => [...prev, "Subscriptions"]); + } + }, [pathname, expandedItems]); + const toggleExpanded = (itemName: string) => { setExpandedItems(prev => prev.includes(itemName) ? prev.filter(name => name !== itemName) : [...prev, itemName] @@ -129,7 +144,7 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
({ + ...item, + children: item.children ? [...item.children] : undefined, + })); + + // Inject dynamic submenu under Subscriptions + const subIdx = nav.findIndex(n => n.name === "Subscriptions"); + if (subIdx >= 0) { + const baseChildren = nav[subIdx].children ?? []; + + const dynamicChildren: NavigationChild[] = (activeSubscriptions || []).map(sub => { + const hrefBase = `/subscriptions/${sub.id}`; + // Link to the main subscription page - users can use the tabs to navigate to SIM management + const href = hrefBase; + return { + name: truncate(sub.productName || `Subscription ${sub.id}`, 28), + href, + } as NavigationChild; + }); + + nav[subIdx] = { + ...nav[subIdx], + children: [ + // Keep the list entry first + { name: "All Subscriptions", href: "/subscriptions" }, + // Divider-like label is avoided; we just list items + ...dynamicChildren, + ], + }; + } + + return nav; +} + +function truncate(text: string, max: number): string { + if (text.length <= max) return text; + return text.slice(0, Math.max(0, max - 1)) + "…"; +} + function DesktopSidebar({ navigation, pathname, @@ -287,7 +343,7 @@ function NavigationItem({ const hasChildren = item.children && item.children.length > 0; const isActive = hasChildren - ? item.children?.some((child: NavigationChild) => pathname.startsWith(child.href)) || false + ? item.children?.some((child: NavigationChild) => pathname.startsWith((child.href || "").split(/[?#]/)[0])) || false : item.href ? pathname === item.href : false; @@ -331,7 +387,7 @@ function NavigationItem({ key={child.name} href={child.href} className={` - ${pathname === child.href ? "bg-blue-50 text-blue-700 border-r-2 border-blue-700" : "text-gray-600 hover:bg-gray-50 hover:text-gray-900"} + ${pathname === (child.href || "").split(/[?#]/)[0] ? "bg-blue-50 text-blue-700 border-r-2 border-blue-700" : "text-gray-600 hover:bg-gray-50 hover:text-gray-900"} group w-full flex items-center pl-11 pr-2 py-2 text-sm rounded-md `} > diff --git a/apps/portal/src/features/service-management/components/ServiceManagementSection.tsx b/apps/portal/src/features/service-management/components/ServiceManagementSection.tsx new file mode 100644 index 00000000..cb4ee179 --- /dev/null +++ b/apps/portal/src/features/service-management/components/ServiceManagementSection.tsx @@ -0,0 +1,137 @@ +"use client"; + +import React, { useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { + WrenchScrewdriverIcon, + LockClosedIcon, + GlobeAltIcon, + DevicePhoneMobileIcon, + ShieldCheckIcon, +} from "@heroicons/react/24/outline"; +import { SimManagementSection } from "@/features/sim-management"; + +interface ServiceManagementSectionProps { + subscriptionId: number; + productName: string; +} + +type ServiceKey = "SIM" | "INTERNET" | "NETGEAR" | "VPN"; + +export function ServiceManagementSection({ + subscriptionId, + productName, +}: ServiceManagementSectionProps) { + const isSimService = useMemo( + () => productName?.toLowerCase().includes("sim"), + [productName] + ); + + const [selectedService, setSelectedService] = useState( + isSimService ? "SIM" : "INTERNET" + ); + + const searchParams = useSearchParams(); + + useEffect(() => { + const s = (searchParams.get("service") || "").toLowerCase(); + if (s === "sim") setSelectedService("SIM"); + else if (s === "internet") setSelectedService("INTERNET"); + else if (s === "netgear") setSelectedService("NETGEAR"); + else if (s === "vpn") setSelectedService("VPN"); + }, [searchParams]); + + const renderHeader = () => ( +
+
+ +
+

Service Management

+

Manage settings for your subscription

+
+
+ +
+ + +
+
+ ); + + const ComingSoon = ({ + icon: Icon, + title, + description, + }: { + icon: React.ComponentType>; + title: string; + description: string; + }) => ( +
+ +

{title}

+

{description}

+ + Coming soon + +
+ ); + + return ( +
+
{renderHeader()}
+ + {selectedService === "SIM" ? ( + isSimService ? ( + + ) : ( +
+ +

+ SIM management not available +

+

+ This subscription is not a SIM service. +

+
+ ) + ) : selectedService === "INTERNET" ? ( +
+ +
+ ) : selectedService === "NETGEAR" ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ ); +} diff --git a/apps/portal/src/features/service-management/index.ts b/apps/portal/src/features/service-management/index.ts new file mode 100644 index 00000000..917b2cfa --- /dev/null +++ b/apps/portal/src/features/service-management/index.ts @@ -0,0 +1 @@ +export { ServiceManagementSection } from './components/ServiceManagementSection'; diff --git a/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx b/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx new file mode 100644 index 00000000..be43395a --- /dev/null +++ b/apps/portal/src/features/sim-management/components/ChangePlanModal.tsx @@ -0,0 +1,116 @@ +"use client"; + +import React, { useState } from "react"; +import { authenticatedApi } from "@/lib/api"; +import { XMarkIcon } from "@heroicons/react/24/outline"; + +interface ChangePlanModalProps { + subscriptionId: number; + onClose: () => void; + onSuccess: () => void; + onError: (message: string) => void; +} + +export function ChangePlanModal({ subscriptionId, onClose, onSuccess, onError }: ChangePlanModalProps) { + const [newPlanCode, setNewPlanCode] = useState(""); + const [assignGlobalIp, setAssignGlobalIp] = useState(false); + const [scheduledAt, setScheduledAt] = useState(""); // YYYY-MM-DD + const [loading, setLoading] = useState(false); + + const submit = async () => { + if (!newPlanCode.trim()) { + onError("Please enter a new plan code"); + return; + } + setLoading(true); + try { + await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/change-plan`, { + newPlanCode: newPlanCode.trim(), + assignGlobalIp, + scheduledAt: scheduledAt ? scheduledAt.replaceAll("-", "") : undefined, + }); + onSuccess(); + } catch (e: any) { + onError(e instanceof Error ? e.message : "Failed to change plan"); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ + + +
+
+
+
+
+

Change SIM Plan

+ +
+
+
+ + setNewPlanCode(e.target.value)} + placeholder="e.g. LTE3G_P01" + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm" + /> +
+
+ setAssignGlobalIp(e.target.checked)} + className="h-4 w-4 text-blue-600 border-gray-300 rounded" + /> + +
+
+ + setScheduledAt(e.target.value)} + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm" + /> +

If empty, the plan change is processed immediately.

+
+
+
+
+
+
+ + +
+
+
+
+ ); +} + diff --git a/apps/portal/src/features/sim-management/components/DataUsageChart.tsx b/apps/portal/src/features/sim-management/components/DataUsageChart.tsx index d5949b97..acd83c93 100644 --- a/apps/portal/src/features/sim-management/components/DataUsageChart.tsx +++ b/apps/portal/src/features/sim-management/components/DataUsageChart.tsx @@ -23,12 +23,13 @@ interface DataUsageChartProps { remainingQuotaMb: number; isLoading?: boolean; error?: string | null; + embedded?: boolean; // when true, render content without card container } -export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error }: DataUsageChartProps) { +export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error, embedded = false }: DataUsageChartProps) { const formatUsage = (usageMb: number) => { if (usageMb >= 1024) { - return `${(usageMb / 1024).toFixed(2)} GB`; + return `${(usageMb / 1024).toFixed(1)} GB`; } return `${usageMb.toFixed(0)} MB`; }; @@ -49,7 +50,7 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error }: Da if (isLoading) { return ( -
+
@@ -65,7 +66,7 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error }: Da if (error) { return ( -
+

Error Loading Usage Data

@@ -81,20 +82,22 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error }: Da const usagePercentage = totalQuota > 0 ? (totalRecentUsage / totalQuota) * 100 : 0; return ( -
+
{/* Header */} -
+
- +
+ +
-

Data Usage

-

Current month usage and remaining quota

+

Data Usage

+

Current month usage and remaining quota

{/* Content */} -
+
{/* Current Usage Overview */}
@@ -122,19 +125,37 @@ export function DataUsageChart({ usage, remainingQuotaMb, isLoading, error }: Da
{/* Today's Usage */} -
-
-
- {formatUsage(usage.todayUsageMb)} +
+
+
+
+
+ {formatUsage(usage.todayUsageMb)} +
+
Used today
+
+
+ + + +
-
Used today
-
-
- {formatUsage(remainingQuotaMb)} +
+
+
+
+ {formatUsage(remainingQuotaMb)} +
+
Remaining
+
+
+ + + +
-
Remaining
diff --git a/apps/portal/src/features/sim-management/components/SimActions.tsx b/apps/portal/src/features/sim-management/components/SimActions.tsx index fc9f83be..32f36b1f 100644 --- a/apps/portal/src/features/sim-management/components/SimActions.tsx +++ b/apps/portal/src/features/sim-management/components/SimActions.tsx @@ -9,6 +9,7 @@ import { CheckCircleIcon } from '@heroicons/react/24/outline'; import { TopUpModal } from './TopUpModal'; +import { ChangePlanModal } from './ChangePlanModal'; import { authenticatedApi } from '@/lib/api'; interface SimActionsProps { @@ -19,6 +20,7 @@ interface SimActionsProps { onPlanChangeSuccess?: () => void; onCancelSuccess?: () => void; onReissueSuccess?: () => void; + embedded?: boolean; // when true, render content without card container } export function SimActions({ @@ -28,7 +30,8 @@ export function SimActions({ onTopUpSuccess, onPlanChangeSuccess, onCancelSuccess, - onReissueSuccess + onReissueSuccess, + embedded = false }: SimActionsProps) { const [showTopUpModal, setShowTopUpModal] = useState(false); const [showCancelConfirm, setShowCancelConfirm] = useState(false); @@ -36,6 +39,7 @@ export function SimActions({ const [loading, setLoading] = useState(null); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); + const [showChangePlanModal, setShowChangePlanModal] = useState(false); const isActive = status === 'active'; const canTopUp = isActive; @@ -85,18 +89,28 @@ export function SimActions({ }, 5000); return () => clearTimeout(timer); } + return; }, [success, error]); return ( -
+
{/* Header */} -
-

SIM Management Actions

-

Manage your SIM service

+
+
+
+ + + +
+
+

SIM Management Actions

+

Manage your SIM service

+
+
{/* Content */} -
+
{/* Status Messages */} {success && (
@@ -128,19 +142,23 @@ export function SimActions({ )} {/* Action Buttons */} -
- {/* Top Up Data */} +
+ {/* Top Up Data - Primary Action */} {/* Reissue eSIM (only for eSIMs) */} @@ -148,29 +166,57 @@ export function SimActions({ )} - {/* Cancel SIM */} + {/* Cancel SIM - Destructive Action */} + + {/* Change Plan - Secondary Action */} +
@@ -198,6 +244,15 @@ export function SimActions({ Cancel SIM: Permanently cancel your SIM service. This action cannot be undone and will terminate your service immediately.
+ +
+ + + +
+ Change Plan: Switch to a different data plan. Important: Plan changes must be requested before the 25th of the month. Changes will take effect on the 1st of the following month. +
+
@@ -215,6 +270,8 @@ export function SimActions({ /> )} + {/* Change Plan handled in Feature Toggles */} + {/* Reissue eSIM Confirmation */} {showReissueConfirm && (
diff --git a/apps/portal/src/features/sim-management/components/SimDetailsCard.tsx b/apps/portal/src/features/sim-management/components/SimDetailsCard.tsx index 9e4c6fa4..8ce6fd55 100644 --- a/apps/portal/src/features/sim-management/components/SimDetailsCard.tsx +++ b/apps/portal/src/features/sim-management/components/SimDetailsCard.tsx @@ -42,9 +42,11 @@ interface SimDetailsCardProps { simDetails: SimDetails; isLoading?: boolean; error?: string | null; + embedded?: boolean; // when true, render content without card container + showFeaturesSummary?: boolean; // show the right-side Service Features summary } -export function SimDetailsCard({ simDetails, isLoading, error }: SimDetailsCardProps) { +export function SimDetailsCard({ simDetails, isLoading, error, embedded = false, showFeaturesSummary = true }: SimDetailsCardProps) { const getStatusIcon = (status: string) => { switch (status) { case 'active': @@ -96,33 +98,36 @@ export function SimDetailsCard({ simDetails, isLoading, error }: SimDetailsCardP }; if (isLoading) { - return ( -
+ const Skeleton = ( +
-
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
); + return Skeleton; } if (error) { return ( -
+
- -

Error Loading SIM Details

-

{error}

+
+ +
+

Error Loading SIM Details

+

{error}

); @@ -131,56 +136,81 @@ export function SimDetailsCard({ simDetails, isLoading, error }: SimDetailsCardP // Specialized, minimal eSIM details view if (simDetails.simType === 'esim') { return ( -
-
-
+
+ {/* Header */} +
+
- +
+ +
-

eSIM Details

-

Current Plan: {simDetails.planCode}

+

eSIM Details

+

Current Plan: {simDetails.planCode}

- + {simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
-
-
-
-

SIM Information

-
-
- -

{simDetails.msisdn}

+
+
+
+
+

+ + SIM Information +

+
+
+ +

{simDetails.msisdn}

+
+
-
- -

{formatQuota(simDetails.remainingQuotaMb)}

-
+
+ +

{formatQuota(simDetails.remainingQuotaMb)}

-
-

Service Features

-
-
Voice Mail (¥300/month){simDetails.voiceMailEnabled ? 'Enabled' : 'Disabled'}
-
Call Waiting (¥300/month){simDetails.callWaitingEnabled ? 'Enabled' : 'Disabled'}
-
International Roaming{simDetails.internationalRoamingEnabled ? 'Enabled' : 'Disabled'}
-
4G/5G{simDetails.networkType || '5G'}
+ {showFeaturesSummary && ( +
+

+ + Service Features +

+
+
+ Voice Mail (¥300/month) + + {simDetails.voiceMailEnabled ? 'Enabled' : 'Disabled'} + +
+
+ Call Waiting (¥300/month) + + {simDetails.callWaitingEnabled ? 'Enabled' : 'Disabled'} + +
+
+ International Roaming + + {simDetails.internationalRoamingEnabled ? 'Enabled' : 'Disabled'} + +
+
+ 4G/5G + + {simDetails.networkType || '5G'} + +
+
-
-
- - {/* Plan quick action */} -
-
Current Plan: {simDetails.planCode}
- - Change Plan - + )}
@@ -188,20 +218,18 @@ export function SimDetailsCard({ simDetails, isLoading, error }: SimDetailsCardP } return ( -
+
{/* Header */} -
+
- {simDetails.simType === 'esim' ? : } +
-

- {simDetails.simType === 'esim' ? 'eSIM Details' : 'Physical SIM Details'} -

+

Physical SIM Details

- {simDetails.planCode} • {simDetails.simType === 'physical' ? `${simDetails.size} SIM` : 'eSIM'} + {simDetails.planCode} • {`${simDetails.size} SIM`}

@@ -215,7 +243,7 @@ export function SimDetailsCard({ simDetails, isLoading, error }: SimDetailsCardP
{/* Content */} -
+
{/* SIM Information */}
@@ -259,46 +287,48 @@ export function SimDetailsCard({ simDetails, isLoading, error }: SimDetailsCardP
{/* Service Features */} -
-

- Service Features -

-
-
- -

{formatQuota(simDetails.remainingQuotaMb)}

-
- -
-
- - - Voice {simDetails.hasVoice ? 'Enabled' : 'Disabled'} - -
-
- - - SMS {simDetails.hasSms ? 'Enabled' : 'Disabled'} - -
-
- - {(simDetails.ipv4 || simDetails.ipv6) && ( + {showFeaturesSummary && ( +
+

+ Service Features +

+
- -
- {simDetails.ipv4 && ( -

IPv4: {simDetails.ipv4}

- )} - {simDetails.ipv6 && ( -

IPv6: {simDetails.ipv6}

- )} + +

{formatQuota(simDetails.remainingQuotaMb)}

+
+ +
+
+ + + Voice {simDetails.hasVoice ? 'Enabled' : 'Disabled'} + +
+
+ + + SMS {simDetails.hasSms ? 'Enabled' : 'Disabled'} +
- )} + + {(simDetails.ipv4 || simDetails.ipv6) && ( +
+ +
+ {simDetails.ipv4 && ( +

IPv4: {simDetails.ipv4}

+ )} + {simDetails.ipv6 && ( +

IPv6: {simDetails.ipv6}

+ )} +
+
+ )} +
-
+ )}
{/* Pending Operations */} diff --git a/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx b/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx new file mode 100644 index 00000000..222af827 --- /dev/null +++ b/apps/portal/src/features/sim-management/components/SimFeatureToggles.tsx @@ -0,0 +1,329 @@ +"use client"; + +import React, { useEffect, useMemo, useState } from "react"; +import { authenticatedApi } from "@/lib/api"; +import type { SimPlan } from "@/shared/types/catalog.types"; + +interface SimFeatureTogglesProps { + subscriptionId: number; + voiceMailEnabled?: boolean; + callWaitingEnabled?: boolean; + internationalRoamingEnabled?: boolean; + networkType?: string; // '4G' | '5G' + currentPlanCode?: string; + onChanged?: () => void; +} + +export function SimFeatureToggles({ + subscriptionId, + voiceMailEnabled, + callWaitingEnabled, + internationalRoamingEnabled, + networkType, + currentPlanCode, + onChanged, +}: SimFeatureTogglesProps) { + // Initial values + const initial = useMemo(() => ({ + vm: !!voiceMailEnabled, + cw: !!callWaitingEnabled, + ir: !!internationalRoamingEnabled, + nt: networkType === '5G' ? '5G' : '4G', + plan: currentPlanCode || '', + }), [voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType, currentPlanCode]); + + // Working values + const [vm, setVm] = useState(initial.vm); + const [cw, setCw] = useState(initial.cw); + const [ir, setIr] = useState(initial.ir); + const [nt, setNt] = useState<'4G' | '5G'>(initial.nt as '4G' | '5G'); + const [plan, setPlan] = useState(initial.plan); + + // Plans list + const [plans, setPlans] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + useEffect(() => { + setVm(initial.vm); + setCw(initial.cw); + setIr(initial.ir); + setNt(initial.nt as '4G' | '5G'); + setPlan(initial.plan); + }, [initial.vm, initial.cw, initial.ir, initial.nt, initial.plan]); + + useEffect(() => { + let ignore = false; + (async () => { + try { + const data = await authenticatedApi.get("/catalog/sim/plans"); + if (!ignore) setPlans(data); + } catch (e) { + // silent; leave plans empty + } + })(); + return () => { ignore = true; }; + }, []); + + const reset = () => { + setVm(initial.vm); + setCw(initial.cw); + setIr(initial.ir); + setNt(initial.nt as '4G' | '5G'); + setPlan(initial.plan); + setError(null); + setSuccess(null); + }; + + const applyChanges = async () => { + setLoading(true); + setError(null); + setSuccess(null); + try { + const featurePayload: any = {}; + if (vm !== initial.vm) featurePayload.voiceMailEnabled = vm; + if (cw !== initial.cw) featurePayload.callWaitingEnabled = cw; + if (ir !== initial.ir) featurePayload.internationalRoamingEnabled = ir; + if (nt !== initial.nt) featurePayload.networkType = nt; + + if (Object.keys(featurePayload).length > 0) { + await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/features`, featurePayload); + } + + if (plan && plan !== initial.plan) { + await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/change-plan`, { newPlanCode: plan }); + } + + setSuccess('Changes submitted successfully'); + onChanged?.(); + } catch (e: any) { + setError(e instanceof Error ? e.message : 'Failed to submit changes'); + } finally { + setLoading(false); + setTimeout(() => setSuccess(null), 3000); + } + }; + + return ( +
+ + {/* Service Options */} +
+ +
+ {/* Voice Mail */} +
+
+
+
+ + + +
+
+
Voice Mail
+
¥300/month
+
+
+
+
+
+ Current: + + {initial.vm ? 'Enabled' : 'Disabled'} + +
+
+ +
+
+ + {/* Call Waiting */} +
+
+
+
+ + + +
+
+
Call Waiting
+
¥300/month
+
+
+
+
+
+ Current: + + {initial.cw ? 'Enabled' : 'Disabled'} + +
+
+ +
+
+ + {/* International Roaming */} +
+
+
+
+ + + +
+
+
International Roaming
+
Global connectivity
+
+
+
+
+
+ Current: + + {initial.ir ? 'Enabled' : 'Disabled'} + +
+
+ +
+
+ + {/* Network Type */} +
+
+
+
+ + + +
+
+
Network Type
+
4G/5G connectivity
+
+
+
+
+
+ Current: + {initial.nt} +
+
+ +
+
+
+
+ + {/* Notes and Actions */} +
+
+
+ + + +
+

Important Notes:

+
    +
  • Changes will take effect instantaneously (approx. 30min)
  • +
  • May require smartphone/device restart after changes are applied
  • +
  • 5G requires a compatible smartphone/device. Will not function on 4G devices
  • +
  • Changes to Voice Mail / Call Waiting must be requested before the 25th of the month
  • +
+
+
+
+ + {success && ( +
+
+ + + +

{success}

+
+
+ )} + + {error && ( +
+
+ + + +

{error}

+
+
+ )} + +
+ + +
+
+
+ ); +} diff --git a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx index 85aea7ab..227f025b 100644 --- a/apps/portal/src/features/sim-management/components/SimManagementSection.tsx +++ b/apps/portal/src/features/sim-management/components/SimManagementSection.tsx @@ -10,6 +10,7 @@ import { SimDetailsCard, type SimDetails } from './SimDetailsCard'; import { DataUsageChart, type SimUsage } from './DataUsageChart'; import { SimActions } from './SimActions'; import { authenticatedApi } from '@/lib/api'; +import { SimFeatureToggles } from './SimFeatureToggles'; interface SimManagementSectionProps { subscriptionId: number; @@ -63,16 +64,25 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro if (loading) { return ( -
-
-
- -

SIM Management

+
+
+
+
+ +
+
+

SIM Management

+

Loading your SIM service details...

+
-
-
-
-
+
+
+
+
+
+
+
+
@@ -81,20 +91,27 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro if (error) { return ( -
-
- -

SIM Management

+
+
+
+ +
+
+

SIM Management

+

Unable to load SIM information

+
-
- -

Unable to Load SIM Information

-

{error}

+
+
+ +
+

Unable to Load SIM Information

+

{error}

@@ -107,65 +124,101 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro } return ( -
- {/* Section Header */} -
-
-
- -
-

SIM Management

-

Manage your SIM service and data usage

+
+ {/* SIM Details and Usage - Main Content */} +
+ {/* Main Content Area - Actions and Settings (Left Side) */} +
+ {/* SIM Management Actions */} + + + {/* Plan Settings Card */} +
+
+
+ + + + +
+
+

Plan Settings

+

Modify service options

+
+ +
- +
-
- {/* SIM Details */} - + {/* Sidebar - Compact Info (Right Side) */} +
+ {/* Important Information Card */} +
+
+
+ + + +
+

Important Information

+
+
    +
  • + + Data usage is updated in real-time and may take a few minutes to reflect recent activity +
  • +
  • + + Top-up data will be available immediately after successful processing +
  • +
  • + + SIM cancellation is permanent and cannot be undone +
  • + {simInfo.details.simType === 'esim' && ( +
  • + + eSIM profile reissue will provide a new QR code for activation +
  • + )} +
+
- {/* Data Usage */} - - - {/* SIM Actions */} - - - {/* Additional Information */} -
-

Important Information

-
    -
  • • Data usage is updated in real-time and may take a few minutes to reflect recent activity
  • -
  • • Top-up data will be available immediately after successful processing
  • -
  • • SIM cancellation is permanent and cannot be undone
  • - {simInfo.details.simType === 'esim' && ( -
  • • eSIM profile reissue will provide a new QR code for activation
  • - )} -
+ + + +
); diff --git a/apps/portal/src/features/sim-management/index.ts b/apps/portal/src/features/sim-management/index.ts index 47750da0..f5cb6a5c 100644 --- a/apps/portal/src/features/sim-management/index.ts +++ b/apps/portal/src/features/sim-management/index.ts @@ -3,6 +3,7 @@ export { SimDetailsCard } from './components/SimDetailsCard'; export { DataUsageChart } from './components/DataUsageChart'; export { SimActions } from './components/SimActions'; export { TopUpModal } from './components/TopUpModal'; +export { SimFeatureToggles } from './components/SimFeatureToggles'; export type { SimDetails } from './components/SimDetailsCard'; export type { SimUsage } from './components/DataUsageChart'; diff --git a/docs/FREEBIT-SIM-MANAGEMENT.md b/docs/FREEBIT-SIM-MANAGEMENT.md index bd6e0a58..9d69d82e 100644 --- a/docs/FREEBIT-SIM-MANAGEMENT.md +++ b/docs/FREEBIT-SIM-MANAGEMENT.md @@ -6,9 +6,14 @@ This document outlines the complete implementation of Freebit SIM management features, including backend API integration, frontend UI components, and Salesforce data tracking requirements. +Where to find it in the portal: +- Subscriptions > [Subscription] > SIM Management section on the page +- Direct link from sidebar goes to `#sim-management` anchor +- Component: `apps/portal/src/features/sim-management/components/SimManagementSection.tsx` + **Last Updated**: January 2025 **Implementation Status**: ✅ Complete and Deployed -**Total Development Sessions**: 2 (GPT-4 + Claude Sonnet 4) +**Latest Updates**: Enhanced UI/UX design, improved layout, and streamlined interface ## 🏗️ Implementation Summary @@ -31,6 +36,10 @@ This document outlines the complete implementation of Freebit SIM management fea - ✅ Integrated into subscription detail page - ✅ **Fixed**: Updated all components to use `authenticatedApi` utility - ✅ **Fixed**: Proper API routing to BFF (port 4000) instead of frontend (port 3000) + - ✅ **Enhanced**: Modern responsive layout with 2/3 + 1/3 grid structure + - ✅ **Enhanced**: Soft color scheme matching website design language + - ✅ **Enhanced**: Improved dropdown styling and form consistency + - ✅ **Enhanced**: Streamlined service options interface 3. **Features Implemented** - ✅ View SIM details (ICCID, MSISDN, plan, status) @@ -124,10 +133,33 @@ apps/portal/src/features/sim-management/ │ ├── SimDetailsCard.tsx # SIM information display │ ├── DataUsageChart.tsx # Usage visualization │ ├── SimActions.tsx # Action buttons and confirmations +│ ├── SimFeatureToggles.tsx # Service options (Voice Mail, Call Waiting, etc.) │ └── TopUpModal.tsx # Data top-up interface └── index.ts # Exports ``` +### Current Layout Structure +``` +┌─────────────────────────────────────────────────────────────┐ +│ Subscription Detail Page │ +│ (max-w-7xl container) │ +├─────────────────────────────────────────────────────────────┤ +│ Left Side (2/3 width) │ Right Side (1/3 width) │ +│ ┌─────────────────────────┐ │ ┌─────────────────────┐ │ +│ │ SIM Management Actions │ │ │ Important Info │ │ +│ │ (2x2 button grid) │ │ │ (notices & warnings)│ │ +│ └─────────────────────────┘ │ └─────────────────────┘ │ +│ ┌─────────────────────────┐ │ ┌─────────────────────┐ │ +│ │ Plan Settings │ │ │ eSIM Details │ │ +│ │ (Service Options) │ │ │ (compact view) │ │ +│ └─────────────────────────┘ │ └─────────────────────┘ │ +│ │ ┌─────────────────────┐ │ +│ │ │ Data Usage Chart │ │ +│ │ │ (compact view) │ │ +│ │ └─────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + ### Features - **Responsive Design**: Works on desktop and mobile - **Real-time Updates**: Automatic refresh after actions @@ -135,6 +167,57 @@ apps/portal/src/features/sim-management/ - **Error Handling**: Comprehensive error messages and recovery - **Accessibility**: Proper ARIA labels and keyboard navigation +## 🎨 Recent UI/UX Enhancements (January 2025) + +### Layout Improvements +- **Wider Container**: Changed from `max-w-4xl` to `max-w-7xl` to match subscriptions page width +- **Optimized Grid Layout**: 2/3 + 1/3 responsive grid for better content distribution + - **Left Side (2/3 width)**: SIM Management Actions + Plan Settings (content-heavy sections) + - **Right Side (1/3 width)**: Important Information + eSIM Details + Data Usage (compact info) +- **Mobile-First Design**: Stacks vertically on smaller screens, horizontal on desktop + +### Visual Design Updates +- **Soft Color Scheme**: Replaced solid gradients with website-consistent soft colors + - **Top Up Data**: Blue theme (`bg-blue-50`, `text-blue-700`, `border-blue-200`) + - **Reissue eSIM**: Green theme (`bg-green-50`, `text-green-700`, `border-green-200`) + - **Cancel SIM**: Red theme (`bg-red-50`, `text-red-700`, `border-red-200`) + - **Change Plan**: Purple theme (`bg-purple-50`, `text-purple-700`, `border-purple-300`) +- **Enhanced Dropdowns**: Consistent styling with subtle borders and focus states +- **Improved Cards**: Better shadows, spacing, and visual hierarchy + +### Interface Streamlining +- **Removed Plan Management Section**: Consolidated plan change info into action descriptions +- **Removed Service Options Header**: Cleaner, more focused interface +- **Enhanced Action Descriptions**: Added important notices and timing information +- **Important Information Repositioned**: Moved to top of right sidebar for better visibility + +### User Experience Improvements +- **2x2 Action Button Grid**: Better organization and space utilization +- **Consistent Icon Usage**: Color-coded icons with background containers +- **Better Information Hierarchy**: Important notices prominently displayed +- **Improved Form Styling**: Modern dropdowns and form elements + +### Action Descriptions & Important Notices +The SIM Management Actions now include comprehensive descriptions with important timing information: + +- **Top Up Data**: Add additional data quota with scheduling options +- **Reissue eSIM**: Generate new QR code for eSIM profile (eSIM only) +- **Cancel SIM**: Permanently cancel service (cannot be undone) +- **Change Plan**: Switch data plans with **important timing notice**: + - "Important: Plan changes must be requested before the 25th of the month. Changes will take effect on the 1st of the following month." + +### Service Options Interface +The Plan Settings section includes streamlined service options: +- **Voice Mail** (¥300/month): Enable/disable with current status display +- **Call Waiting** (¥300/month): Enable/disable with current status display +- **International Roaming**: Global connectivity options +- **Network Type**: 4G/5G connectivity selection + +Each option shows: +- Current status with color-coded indicators +- Clean dropdown for status changes +- Consistent styling with website design + ## 🗄️ Required Salesforce Custom Fields To enable proper SIM data tracking in Salesforce, add these custom fields: diff --git a/docs/SUBSCRIPTION-SERVICE-MANAGEMENT.md b/docs/SUBSCRIPTION-SERVICE-MANAGEMENT.md new file mode 100644 index 00000000..a8ecb090 --- /dev/null +++ b/docs/SUBSCRIPTION-SERVICE-MANAGEMENT.md @@ -0,0 +1,46 @@ +# Subscription Service Management + +Guidance for the unified Service Management area in the Subscriptions detail page. This area provides a dropdown to switch between different service types for a given subscription. + +- Location: `Subscriptions > [Subscription] > Service Management` +- Selector: Service dropdown with options: `SIM`, `Internet`, `Netgear`, `VPN` +- Current status: `SIM` available now; others are placeholders (coming soon) + +## UI Structure + +``` +apps/portal/src/features/service-management/ +├── components/ +│ └── ServiceManagementSection.tsx # Container with service dropdown +└── index.ts +``` + +- Header: Title + description, service dropdown selector +- Body: Renders the active service panel +- Default selection: `SIM` for SIM products; otherwise `Internet` + +## Service Panels + +- SIM: Renders the existing SIM management UI + - Source: `apps/portal/src/features/sim-management/components/SimManagementSection.tsx` + - Backend: `/api/subscriptions/{id}/sim/*` +- Internet: Placeholder (coming soon) +- Netgear: Placeholder (coming soon) +- VPN: Placeholder (coming soon) + +## Integration + +- Entry point: `apps/portal/src/app/subscriptions/[id]/page.tsx` renders `ServiceManagementSection` +- Detection: SIM availability is inferred from `subscription.productName` including `sim` (case-insensitive) + +## Future Expansion + +- Replace placeholders with actual feature modules per service type +- Gate options per subscription capabilities (disable/hide unsupported services) +- Deep-linking: support `?service=sim|internet|netgear|vpn` to preselect a panel +- Telemetry: track panel usage and feature adoption + +## Notes + +- This structure avoids breaking changes to the existing SIM workflow while preparing a clean surface for additional services. +- SIM documentation remains at `docs/FREEBIT-SIM-MANAGEMENT.md` and is unchanged functionally.