Refactor SIM management components and enhance user experience

- Updated CatalogPage and SimPlansPage to improve descriptions and layout for better clarity.
- Removed unused SignalIcon from CatalogPage.
- Enhanced ChangePlanModal to allow selection of new plans with improved validation and user feedback.
- Updated SimActions to handle navigation and contextual information for actions.
- Improved SimDetailsCard to format current plan codes for better readability.
- Enhanced SimFeatureToggles to support embedded rendering and streamlined service options management.
- Refactored SimManagementSection layout for better organization of SIM details and actions.
This commit is contained in:
tema 2025-09-05 18:22:55 +09:00
parent 735828cf32
commit 9d4505d6be
12 changed files with 977 additions and 207 deletions

View File

@ -9,7 +9,6 @@ import {
ArrowRightIcon,
WifiIcon,
GlobeAltIcon,
SignalIcon,
} from "@heroicons/react/24/outline";
import { AnimatedCard } from "@/components/catalog/animated-card";
import { AnimatedButton } from "@/components/catalog/animated-button";
@ -32,7 +31,7 @@ export default function CatalogPage() {
</span>
</h1>
<p className="text-xl text-gray-600 max-w-4xl mx-auto leading-relaxed">
Discover high-speed internet, flexible mobile plans, and secure VPN services. Each
Discover high-speed internet, wide range of mobile data options, and secure VPN services. Each
solution is personalized based on your location and account eligibility.
</p>
</div>
@ -57,13 +56,13 @@ export default function CatalogPage() {
{/* SIM/eSIM Service */}
<ServiceHeroCard
title="SIM & eSIM"
description="Flexible mobile data and voice plans with both physical SIM and eSIM options. Family discounts available."
description="Wide range of data options and voice plans with both physical SIM and eSIM options. Family discounts available."
icon={<DevicePhoneMobileIcon className="h-12 w-12" />}
features={[
"Physical SIM & eSIM",
"Data + Voice plans",
"Data + SMS/Voice plans",
"Family discounts",
"Flexible data sizes",
"Multiple data options",
]}
href="/catalog/sim"
color="green"
@ -95,17 +94,12 @@ export default function CatalogPage() {
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<FeatureCard
icon={<WifiIcon className="h-10 w-10 text-blue-600" />}
title="Location-Based Plans"
description="Internet plans tailored to your house type and available infrastructure"
/>
<FeatureCard
icon={<SignalIcon className="h-10 w-10 text-green-600" />}
title="Smart Recommendations"
description="Personalized plan suggestions based on your account and usage patterns"
/>
<FeatureCard
icon={<GlobeAltIcon className="h-10 w-10 text-purple-600" />}
title="Seamless Integration"

View File

@ -45,7 +45,7 @@ function PlanTypeSection({
const familyPlans = plans.filter(p => p.hasFamilyDiscount);
return (
<div>
<div className="animate-in fade-in duration-500">
<div className="flex items-center gap-3 mb-6">
{icon}
<div>
@ -224,7 +224,7 @@ export default function SimPlansPage() {
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4">Choose Your SIM Plan</h1>
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
Flexible mobile data and voice plans with both physical SIM and eSIM options.
Wide range of data options and voice plans with both physical SIM and eSIM options.
</p>
</div>
{/* Family Discount Banner */}
@ -267,48 +267,54 @@ export default function SimPlansPage() {
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
<button
onClick={() => setActiveTab("data-voice")}
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 transition-all duration-300 ease-in-out transform hover:scale-105 ${
activeTab === "data-voice"
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
<PhoneIcon className="h-5 w-5" />
Data + Voice
<PhoneIcon className={`h-5 w-5 transition-transform duration-300 ${activeTab === "data-voice" ? "scale-110" : ""}`} />
Data + SMS/Voice
{plansByType.DataSmsVoice.length > 0 && (
<span className="bg-blue-100 text-blue-600 text-xs px-2 py-0.5 rounded-full">
<span className={`bg-blue-100 text-blue-600 text-xs px-2 py-0.5 rounded-full transition-all duration-300 ${
activeTab === "data-voice" ? "scale-110 bg-blue-200" : ""
}`}>
{plansByType.DataSmsVoice.length}
</span>
)}
</button>
<button
onClick={() => setActiveTab("data-only")}
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 transition-all duration-300 ease-in-out transform hover:scale-105 ${
activeTab === "data-only"
? "border-purple-500 text-purple-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
<GlobeAltIcon className="h-5 w-5" />
<GlobeAltIcon className={`h-5 w-5 transition-transform duration-300 ${activeTab === "data-only" ? "scale-110" : ""}`} />
Data Only
{plansByType.DataOnly.length > 0 && (
<span className="bg-purple-100 text-purple-600 text-xs px-2 py-0.5 rounded-full">
<span className={`bg-purple-100 text-purple-600 text-xs px-2 py-0.5 rounded-full transition-all duration-300 ${
activeTab === "data-only" ? "scale-110 bg-purple-200" : ""
}`}>
{plansByType.DataOnly.length}
</span>
)}
</button>
<button
onClick={() => setActiveTab("voice-only")}
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 transition-all duration-300 ease-in-out transform hover:scale-105 ${
activeTab === "voice-only"
? "border-orange-500 text-orange-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
<PhoneIcon className="h-5 w-5" />
<PhoneIcon className={`h-5 w-5 transition-transform duration-300 ${activeTab === "voice-only" ? "scale-110" : ""}`} />
Voice Only
{plansByType.VoiceOnly.length > 0 && (
<span className="bg-orange-100 text-orange-600 text-xs px-2 py-0.5 rounded-full">
<span className={`bg-orange-100 text-orange-600 text-xs px-2 py-0.5 rounded-full transition-all duration-300 ${
activeTab === "voice-only" ? "scale-110 bg-orange-200" : ""
}`}>
{plansByType.VoiceOnly.length}
</span>
)}
@ -318,49 +324,74 @@ export default function SimPlansPage() {
</div>
{/* Tab Content */}
<div className="min-h-[400px]">
{activeTab === "data-voice" && (
<PlanTypeSection
title="Data + Voice Plans"
description="Internet, calling, and SMS included"
icon={<PhoneIcon className="h-8 w-8 text-blue-600" />}
plans={plansByType.DataSmsVoice}
showFamilyDiscount={hasExistingSim}
/>
)}
<div className="min-h-[400px] relative">
<div className={`transition-all duration-500 ease-in-out ${
activeTab === "data-voice"
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"
}`}>
{activeTab === "data-voice" && (
<PlanTypeSection
title="Data + SMS/Voice Plans"
description="Internet, calling, and SMS included"
icon={<PhoneIcon className="h-8 w-8 text-blue-600" />}
plans={plansByType.DataSmsVoice}
showFamilyDiscount={hasExistingSim}
/>
)}
</div>
{activeTab === "data-only" && (
<PlanTypeSection
title="Data Only Plans"
description="Internet access for tablets, laptops, and IoT devices"
icon={<GlobeAltIcon className="h-8 w-8 text-purple-600" />}
plans={plansByType.DataOnly}
showFamilyDiscount={hasExistingSim}
/>
)}
<div className={`transition-all duration-500 ease-in-out ${
activeTab === "data-only"
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"
}`}>
{activeTab === "data-only" && (
<PlanTypeSection
title="Data Only Plans"
description="Internet access for tablets, laptops, and IoT devices"
icon={<GlobeAltIcon className="h-8 w-8 text-purple-600" />}
plans={plansByType.DataOnly}
showFamilyDiscount={hasExistingSim}
/>
)}
</div>
{activeTab === "voice-only" && (
<PlanTypeSection
title="Voice Only Plans"
description="Traditional calling and SMS without internet"
icon={<PhoneIcon className="h-8 w-8 text-orange-600" />}
plans={plansByType.VoiceOnly}
showFamilyDiscount={hasExistingSim}
/>
)}
<div className={`transition-all duration-500 ease-in-out ${
activeTab === "voice-only"
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"
}`}>
{activeTab === "voice-only" && (
<PlanTypeSection
title="Voice Only Plans"
description="Traditional calling and SMS without internet"
icon={<PhoneIcon className="h-8 w-8 text-orange-600" />}
plans={plansByType.VoiceOnly}
showFamilyDiscount={hasExistingSim}
/>
)}
</div>
</div>
{/* Features Section */}
<div className="mt-8 bg-gray-50 rounded-2xl p-8 max-w-4xl mx-auto">
<h3 className="font-bold text-gray-900 text-xl mb-6 text-center">
All SIM Plans Include
Plan Features & Terms
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 text-sm">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 text-sm">
<div className="flex items-start gap-3">
<CheckIcon className="h-5 w-5 text-green-500 mt-0.5 flex-shrink-0" />
<div>
<div className="font-medium text-gray-900">No Contract</div>
<div className="text-gray-600">Cancel anytime</div>
<div className="font-medium text-gray-900">3-Month Contract</div>
<div className="text-gray-600">Minimum 3 billing months</div>
</div>
</div>
<div className="flex items-start gap-3">
<CheckIcon className="h-5 w-5 text-green-500 mt-0.5 flex-shrink-0" />
<div>
<div className="font-medium text-gray-900">First Month Free</div>
<div className="text-gray-600">Basic fee waived initially</div>
</div>
</div>
<div className="flex items-start gap-3">
@ -384,19 +415,53 @@ export default function SimPlansPage() {
<div className="text-gray-600">Multi-line savings</div>
</div>
</div>
<div className="flex items-start gap-3">
<CheckIcon className="h-5 w-5 text-green-500 mt-0.5 flex-shrink-0" />
<div>
<div className="font-medium text-gray-900">Plan Switching</div>
<div className="text-gray-600">Free data plan changes</div>
</div>
</div>
</div>
</div>
{/* Info Section */}
<div className="mt-8 p-4 rounded-lg border border-blue-200 bg-blue-50 flex items-start gap-3 max-w-4xl mx-auto">
<InformationCircleIcon className="h-5 w-5 text-blue-600 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<div className="font-medium text-blue-900 mb-1">Getting Started</div>
<p className="text-blue-800">
Choose your plan size, select eSIM or physical SIM, and configure optional add-ons
like voice mail and call waiting. Number porting is available if you want to keep your
existing phone number.
</p>
<div className="mt-8 p-6 rounded-lg border border-blue-200 bg-blue-50 max-w-4xl mx-auto">
<div className="flex items-start gap-3 mb-4">
<InformationCircleIcon className="h-5 w-5 text-blue-600 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<div className="font-medium text-blue-900 mb-2">Important Terms & Conditions</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div className="space-y-3">
<div>
<div className="font-medium text-blue-900">Contract Period</div>
<p className="text-blue-800">Minimum 3 full billing months required. First month (sign-up to end of month) is free and doesn't count toward contract.</p>
</div>
<div>
<div className="font-medium text-blue-900">Billing Cycle</div>
<p className="text-blue-800">Monthly billing from 1st to end of month. Regular billing starts on 1st of following month after sign-up.</p>
</div>
<div>
<div className="font-medium text-blue-900">Cancellation</div>
<p className="text-blue-800">Can be requested online after 3rd month. Service terminates at end of billing cycle.</p>
</div>
</div>
<div className="space-y-3">
<div>
<div className="font-medium text-blue-900">Plan Changes</div>
<p className="text-blue-800">Data plan switching is free and takes effect next month. Voice plan changes require new SIM and cancellation policies apply.</p>
</div>
<div>
<div className="font-medium text-blue-900">Calling/SMS Charges</div>
<p className="text-blue-800">Pay-per-use charges apply separately. Billed 5-6 weeks after usage within billing cycle.</p>
</div>
<div>
<div className="font-medium text-blue-900">SIM Replacement</div>
<p className="text-blue-800">Reissue fee of 1,500 JPY applies for damaged, lost, or replacement SIM cards.</p>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,101 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { authenticatedApi } from "@/lib/api";
const PLAN_CODES = ["PASI_5G", "PASI_10G", "PASI_25G", "PASI_50G"] as const;
type PlanCode = typeof PLAN_CODES[number];
const PLAN_LABELS: Record<PlanCode, string> = {
PASI_5G: "5GB",
PASI_10G: "10GB",
PASI_25G: "25GB",
PASI_50G: "50GB",
};
export default function SimChangePlanPage() {
const params = useParams();
const subscriptionId = parseInt(params.id as string);
const [currentPlanCode] = useState<string>("");
const [newPlanCode, setNewPlanCode] = useState<"" | PlanCode>("");
const [assignGlobalIp, setAssignGlobalIp] = useState(false);
const [scheduledAt, setScheduledAt] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const options = useMemo(() => (PLAN_CODES as readonly PlanCode[]).filter(c => c !== (currentPlanCode as PlanCode)), [currentPlanCode]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!newPlanCode) {
setError("Please select a new plan");
return;
}
setLoading(true);
setMessage(null);
setError(null);
try {
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/change-plan`, {
newPlanCode,
assignGlobalIp,
scheduledAt: scheduledAt ? scheduledAt.replace(/-/g, "") : undefined,
});
setMessage("Plan change submitted successfully");
} catch (e: any) {
setError(e instanceof Error ? e.message : "Failed to change plan");
} finally {
setLoading(false);
}
};
return (
<DashboardLayout>
<div className="max-w-3xl mx-auto p-6">
<div className="mb-4">
<Link href={`/subscriptions/${subscriptionId}#sim-management`} className="text-blue-600 hover:text-blue-700"> Back to SIM Management</Link>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h1 className="text-xl font-semibold text-gray-900 mb-1">Change Plan</h1>
<p className="text-sm text-gray-600 mb-6">Switch to a different data plan. Important: request before the 25th; takes effect on the 1st.</p>
{message && <div className="mb-4 text-green-700 bg-green-50 border border-green-200 rounded p-3">{message}</div>}
{error && <div className="mb-4 text-red-700 bg-red-50 border border-red-200 rounded p-3">{error}</div>}
<form onSubmit={submit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">New Plan</label>
<select
value={newPlanCode}
onChange={(e) => setNewPlanCode(e.target.value as PlanCode)}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
>
<option value="">Choose a plan</option>
{options.map(code => (
<option key={code} value={code}>{PLAN_LABELS[code]}</option>
))}
</select>
</div>
<div className="flex items-center">
<input id="globalip" type="checkbox" checked={assignGlobalIp} onChange={(e)=>setAssignGlobalIp(e.target.checked)} className="h-4 w-4 text-blue-600 border-gray-300 rounded" />
<label htmlFor="globalip" className="ml-2 text-sm text-gray-700">Assign global IP</label>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Schedule (optional)</label>
<input type="date" value={scheduledAt} onChange={(e)=>setScheduledAt(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-md" />
</div>
<div className="flex gap-3">
<button type="submit" disabled={loading} className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50">{loading ? 'Processing…' : 'Submit Plan Change'}</button>
<Link href={`/subscriptions/${subscriptionId}#sim-management`} className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50">Back</Link>
</div>
</form>
</div>
</div>
</DashboardLayout>
);
}

View File

@ -0,0 +1 @@
export default function Page(){return null}

View File

@ -0,0 +1,103 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { authenticatedApi } from "@/lib/api";
const PRESETS = [1024, 2048, 5120, 10240, 20480, 51200];
export default function SimTopUpPage() {
const params = useParams();
const subscriptionId = parseInt(params.id as string);
const [amountMb, setAmountMb] = useState<number>(2048);
const [scheduledAt, setScheduledAt] = useState("");
const [campaignCode, setCampaignCode] = useState("");
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const format = (mb: number) => (mb % 1024 === 0 ? `${mb / 1024} GB` : `${mb} MB`);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setMessage(null);
setError(null);
try {
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/top-up`, {
quotaMb: amountMb,
campaignCode: campaignCode || undefined,
scheduledAt: scheduledAt ? scheduledAt.replace(/-/g, "") : undefined,
});
setMessage("Top-up submitted successfully");
} catch (e: any) {
setError(e instanceof Error ? e.message : "Failed to submit top-up");
} finally {
setLoading(false);
}
};
return (
<DashboardLayout>
<div className="max-w-3xl mx-auto p-6">
<div className="mb-4">
<Link href={`/subscriptions/${subscriptionId}#sim-management`} className="text-blue-600 hover:text-blue-700"> Back to SIM Management</Link>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h1 className="text-xl font-semibold text-gray-900 mb-1">Top Up Data</h1>
<p className="text-sm text-gray-600 mb-6">Add data quota to your SIM service</p>
{message && <div className="mb-4 text-green-700 bg-green-50 border border-green-200 rounded p-3">{message}</div>}
{error && <div className="mb-4 text-red-700 bg-red-50 border border-red-200 rounded p-3">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Amount</label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{PRESETS.map(mb => (
<button
key={mb}
type="button"
onClick={() => setAmountMb(mb)}
className={`px-4 py-2 rounded-lg border text-sm ${amountMb === mb ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'}`}
>
{format(mb)}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Campaign Code (optional)</label>
<input
type="text"
value={campaignCode}
onChange={(e) => setCampaignCode(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
placeholder="Enter code"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Schedule (optional)</label>
<input
type="date"
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
<p className="text-xs text-gray-500 mt-1">Leave empty to apply immediately</p>
</div>
<div className="flex gap-3">
<button type="submit" disabled={loading} className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-50">{loading ? 'Processing…' : 'Submit Top-Up'}</button>
<Link href={`/subscriptions/${subscriptionId}#sim-management`} className="px-4 py-2 rounded-md border border-gray-300 text-sm text-gray-700 bg-white hover:bg-gray-50">Back</Link>
</div>
</form>
</div>
</div>
</DashboardLayout>
);
}

View File

@ -6,26 +6,38 @@ import { XMarkIcon } from "@heroicons/react/24/outline";
interface ChangePlanModalProps {
subscriptionId: number;
currentPlanCode?: string;
onClose: () => void;
onSuccess: () => void;
onError: (message: string) => void;
}
export function ChangePlanModal({ subscriptionId, onClose, onSuccess, onError }: ChangePlanModalProps) {
const [newPlanCode, setNewPlanCode] = useState("");
export function ChangePlanModal({ subscriptionId, currentPlanCode, onClose, onSuccess, onError }: ChangePlanModalProps) {
const PLAN_CODES = ["PASI_5G", "PASI_10G", "PASI_25G", "PASI_50G"] as const;
type PlanCode = typeof PLAN_CODES[number];
const PLAN_LABELS: Record<PlanCode, string> = {
PASI_5G: "5GB",
PASI_10G: "10GB",
PASI_25G: "25GB",
PASI_50G: "50GB",
};
const allowedPlans = (PLAN_CODES as readonly PlanCode[]).filter(code => code !== (currentPlanCode || ''));
const [newPlanCode, setNewPlanCode] = useState<"" | PlanCode>("");
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");
if (!newPlanCode) {
onError("Please select a new plan");
return;
}
setLoading(true);
try {
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/change-plan`, {
newPlanCode: newPlanCode.trim(),
newPlanCode: newPlanCode,
assignGlobalIp,
scheduledAt: scheduledAt ? scheduledAt.replaceAll("-", "") : undefined,
});
@ -55,14 +67,18 @@ export function ChangePlanModal({ subscriptionId, onClose, onSuccess, onError }:
</div>
<div className="mt-4 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">New Plan Code</label>
<input
type="text"
<label className="block text-sm font-medium text-gray-700">Select New Plan</label>
<select
value={newPlanCode}
onChange={(e) => setNewPlanCode(e.target.value)}
placeholder="e.g. LTE3G_P01"
onChange={(e) => setNewPlanCode(e.target.value as PlanCode)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
/>
>
<option value="">Choose a plan</option>
{allowedPlans.map(code => (
<option key={code} value={code}>{PLAN_LABELS[code]}</option>
))}
</select>
<p className="mt-1 text-xs text-gray-500">Only plans different from your current plan are listed.</p>
</div>
<div className="flex items-center">
<input
@ -105,7 +121,7 @@ export function ChangePlanModal({ subscriptionId, onClose, onSuccess, onError }:
disabled={loading}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Cancel
Back
</button>
</div>
</div>
@ -113,4 +129,3 @@ export function ChangePlanModal({ subscriptionId, onClose, onSuccess, onError }:
</div>
);
}

View File

@ -1,6 +1,7 @@
"use client";
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import {
PlusIcon,
ArrowPathIcon,
@ -21,6 +22,7 @@ interface SimActionsProps {
onCancelSuccess?: () => void;
onReissueSuccess?: () => void;
embedded?: boolean; // when true, render content without card container
currentPlanCode?: string;
}
export function SimActions({
@ -31,8 +33,10 @@ export function SimActions({
onPlanChangeSuccess,
onCancelSuccess,
onReissueSuccess,
embedded = false
embedded = false,
currentPlanCode
}: SimActionsProps) {
const router = useRouter();
const [showTopUpModal, setShowTopUpModal] = useState(false);
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
const [showReissueConfirm, setShowReissueConfirm] = useState(false);
@ -40,6 +44,9 @@ export function SimActions({
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [showChangePlanModal, setShowChangePlanModal] = useState(false);
const [activeInfo, setActiveInfo] = useState<
'topup' | 'reissue' | 'cancel' | 'changePlan' | null
>(null);
const isActive = status === 'active';
const canTopUp = isActive;
@ -145,7 +152,14 @@ export function SimActions({
<div className={`grid gap-4 ${embedded ? 'grid-cols-1' : 'grid-cols-2'}`}>
{/* Top Up Data - Primary Action */}
<button
onClick={() => setShowTopUpModal(true)}
onClick={() => {
setActiveInfo('topup');
try {
router.push(`/subscriptions/${subscriptionId}/sim/top-up`);
} catch {
setShowTopUpModal(true);
}
}}
disabled={!canTopUp || loading !== null}
className={`group relative flex items-center justify-center px-6 py-4 border rounded-xl text-sm font-semibold transition-all duration-200 ${
canTopUp && loading === null
@ -164,7 +178,14 @@ export function SimActions({
{/* Reissue eSIM (only for eSIMs) */}
{simType === 'esim' && (
<button
onClick={() => setShowReissueConfirm(true)}
onClick={() => {
setActiveInfo('reissue');
try {
router.push(`/subscriptions/${subscriptionId}/sim/reissue`);
} catch {
setShowReissueConfirm(true);
}
}}
disabled={!canReissue || loading !== null}
className={`group relative flex items-center justify-center px-6 py-4 border rounded-xl text-sm font-semibold transition-all duration-200 ${
canReissue && loading === null
@ -183,7 +204,11 @@ export function SimActions({
{/* Cancel SIM - Destructive Action */}
<button
onClick={() => setShowCancelConfirm(true)}
onClick={() => {
setActiveInfo('cancel');
// keep inline confirm for cancel to avoid accidental navigation
setShowCancelConfirm(true);
}}
disabled={!canCancel || loading !== null}
className={`group relative flex items-center justify-center px-6 py-4 border rounded-xl text-sm font-semibold transition-all duration-200 ${
canCancel && loading === null
@ -201,10 +226,17 @@ export function SimActions({
{/* Change Plan - Secondary Action */}
<button
onClick={() => {/* Add change plan functionality */}}
disabled={!isActive || loading !== null}
onClick={() => {
setActiveInfo('changePlan');
try {
router.push(`/subscriptions/${subscriptionId}/sim/change-plan`);
} catch {
setShowChangePlanModal(true);
}
}}
disabled={loading !== null}
className={`group relative flex items-center justify-center px-6 py-4 border-2 border-dashed rounded-xl text-sm font-semibold transition-all duration-200 ${
isActive && loading === null
loading === null
? 'text-purple-700 bg-purple-50 border-purple-300 hover:bg-purple-100 hover:border-purple-400 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500'
: 'text-gray-400 bg-gray-50 border-gray-300 cursor-not-allowed'
}`}
@ -220,47 +252,52 @@ export function SimActions({
</button>
</div>
{/* Action Descriptions */}
<div className="mt-6 space-y-3 text-sm text-gray-600">
<div className="flex items-start">
<PlusIcon className="h-4 w-4 text-blue-500 mr-2 mt-0.5 flex-shrink-0" />
<div>
<strong>Top Up Data:</strong> Add additional data quota to your SIM service. You can choose the amount and schedule it for later if needed.
</div>
</div>
{simType === 'esim' && (
<div className="flex items-start">
<ArrowPathIcon className="h-4 w-4 text-green-500 mr-2 mt-0.5 flex-shrink-0" />
<div>
<strong>Reissue eSIM:</strong> Generate a new eSIM profile for download. Use this if your previous download failed or you need to install on a new device.
{/* Action Description (contextual) */}
{activeInfo && (
<div className="mt-6 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg p-4">
{activeInfo === 'topup' && (
<div className="flex items-start">
<PlusIcon className="h-4 w-4 text-blue-600 mr-2 mt-0.5 flex-shrink-0" />
<div>
<strong>Top Up Data:</strong> Add additional data quota to your SIM service. You can choose the amount and schedule it for later if needed.
</div>
</div>
</div>
)}
<div className="flex items-start">
<XMarkIcon className="h-4 w-4 text-red-500 mr-2 mt-0.5 flex-shrink-0" />
<div>
<strong>Cancel SIM:</strong> Permanently cancel your SIM service. This action cannot be undone and will terminate your service immediately.
</div>
)}
{activeInfo === 'reissue' && (
<div className="flex items-start">
<ArrowPathIcon className="h-4 w-4 text-green-600 mr-2 mt-0.5 flex-shrink-0" />
<div>
<strong>Reissue eSIM:</strong> Generate a new eSIM profile for download. Use this if your previous download failed or you need to install on a new device.
</div>
</div>
)}
{activeInfo === 'cancel' && (
<div className="flex items-start">
<XMarkIcon className="h-4 w-4 text-red-600 mr-2 mt-0.5 flex-shrink-0" />
<div>
<strong>Cancel SIM:</strong> Permanently cancel your SIM service. This action cannot be undone and will terminate your service immediately.
</div>
</div>
)}
{activeInfo === 'changePlan' && (
<div className="flex items-start">
<svg className="h-4 w-4 text-purple-600 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
<div>
<strong>Change Plan:</strong> Switch to a different data plan. <span className="text-red-600 font-medium">Important: Plan changes must be requested before the 25th of the month. Changes will take effect on the 1st of the following month.</span>
</div>
</div>
)}
</div>
<div className="flex items-start">
<svg className="h-4 w-4 text-purple-500 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
<div>
<strong>Change Plan:</strong> Switch to a different data plan. <span className="text-red-600 font-medium">Important: Plan changes must be requested before the 25th of the month. Changes will take effect on the 1st of the following month.</span>
</div>
</div>
</div>
)}
</div>
{/* Top Up Modal */}
{showTopUpModal && (
<TopUpModal
subscriptionId={subscriptionId}
onClose={() => setShowTopUpModal(false)}
onClose={() => { setShowTopUpModal(false); setActiveInfo(null); }}
onSuccess={() => {
setShowTopUpModal(false);
setSuccess('Data top-up completed successfully');
@ -270,7 +307,20 @@ export function SimActions({
/>
)}
{/* Change Plan handled in Feature Toggles */}
{/* Change Plan Modal */}
{showChangePlanModal && (
<ChangePlanModal
subscriptionId={subscriptionId}
currentPlanCode={currentPlanCode}
onClose={() => { setShowChangePlanModal(false); setActiveInfo(null); }}
onSuccess={() => {
setShowChangePlanModal(false);
setSuccess('SIM plan change submitted successfully');
onPlanChangeSuccess?.();
}}
onError={(message) => setError(message)}
/>
)}
{/* Reissue eSIM Confirmation */}
{showReissueConfirm && (
@ -304,11 +354,11 @@ export function SimActions({
</button>
<button
type="button"
onClick={() => setShowReissueConfirm(false)}
onClick={() => { setShowReissueConfirm(false); setActiveInfo(null); }}
disabled={loading === 'reissue'}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Cancel
Back
</button>
</div>
</div>
@ -348,11 +398,11 @@ export function SimActions({
</button>
<button
type="button"
onClick={() => setShowCancelConfirm(false)}
onClick={() => { setShowCancelConfirm(false); setActiveInfo(null); }}
disabled={loading === 'cancel'}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Cancel
Back
</button>
</div>
</div>

View File

@ -47,6 +47,15 @@ interface SimDetailsCardProps {
}
export function SimDetailsCard({ simDetails, isLoading, error, embedded = false, showFeaturesSummary = true }: SimDetailsCardProps) {
const formatPlan = (code?: string) => {
const map: Record<string, string> = {
PASI_5G: '5GB Plan',
PASI_10G: '10GB Plan',
PASI_25G: '25GB Plan',
PASI_50G: '50GB Plan',
};
return (code && map[code]) || code || '—';
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'active':
@ -146,7 +155,7 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
</div>
<div>
<h3 className="text-xl font-semibold text-gray-900">eSIM Details</h3>
<p className="text-sm text-gray-600 font-medium">Current Plan: {simDetails.planCode}</p>
<p className="text-sm text-gray-600 font-medium">Current Plan: {formatPlan(simDetails.planCode)}</p>
</div>
</div>
<span className={`inline-flex px-4 py-2 text-sm font-semibold rounded-full ${getStatusColor(simDetails.status)} self-start sm:self-auto`}>
@ -229,7 +238,7 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
<div>
<h3 className="text-lg font-medium text-gray-900">Physical SIM Details</h3>
<p className="text-sm text-gray-500">
{simDetails.planCode} {`${simDetails.size} SIM`}
{formatPlan(simDetails.planCode)} {`${simDetails.size} SIM`}
</p>
</div>
</div>

View File

@ -2,7 +2,6 @@
import React, { useEffect, useMemo, useState } from "react";
import { authenticatedApi } from "@/lib/api";
import type { SimPlan } from "@/shared/types/catalog.types";
interface SimFeatureTogglesProps {
subscriptionId: number;
@ -10,8 +9,8 @@ interface SimFeatureTogglesProps {
callWaitingEnabled?: boolean;
internationalRoamingEnabled?: boolean;
networkType?: string; // '4G' | '5G'
currentPlanCode?: string;
onChanged?: () => void;
embedded?: boolean; // when true, render without outer card wrappers
}
export function SimFeatureToggles({
@ -20,8 +19,8 @@ export function SimFeatureToggles({
callWaitingEnabled,
internationalRoamingEnabled,
networkType,
currentPlanCode,
onChanged,
embedded = false,
}: SimFeatureTogglesProps) {
// Initial values
const initial = useMemo(() => ({
@ -29,18 +28,13 @@ export function SimFeatureToggles({
cw: !!callWaitingEnabled,
ir: !!internationalRoamingEnabled,
nt: networkType === '5G' ? '5G' : '4G',
plan: currentPlanCode || '',
}), [voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType, currentPlanCode]);
}), [voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType]);
// 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<SimPlan[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
@ -50,28 +44,13 @@ export function SimFeatureToggles({
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<SimPlan[]>("/catalog/sim/plans");
if (!ignore) setPlans(data);
} catch (e) {
// silent; leave plans empty
}
})();
return () => { ignore = true; };
}, []);
}, [initial.vm, initial.cw, initial.ir, initial.nt]);
const reset = () => {
setVm(initial.vm);
setCw(initial.cw);
setIr(initial.ir);
setNt(initial.nt as '4G' | '5G');
setPlan(initial.plan);
setError(null);
setSuccess(null);
};
@ -91,10 +70,6 @@ export function SimFeatureToggles({
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) {
@ -109,9 +84,9 @@ export function SimFeatureToggles({
<div className="space-y-6">
{/* Service Options */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className={`${embedded ? '' : 'bg-white rounded-xl border border-gray-200 overflow-hidden'}`}>
<div className="p-6 space-y-6">
<div className={`${embedded ? '' : 'p-6'} space-y-6`}>
{/* Voice Mail */}
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0 p-4 bg-gray-50 rounded-lg border border-gray-200">
<div className="flex-1">
@ -249,7 +224,7 @@ export function SimFeatureToggles({
</div>
{/* Notes and Actions */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className={`${embedded ? '' : 'bg-white rounded-xl border border-gray-200 p-6'}`}>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<div className="flex items-start">
<svg className="h-5 w-5 text-yellow-600 mt-0.5 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@ -128,49 +128,54 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
{/* SIM Details and Usage - Main Content */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
{/* Main Content Area - Actions and Settings (Left Side) */}
<div className="xl:col-span-2 xl:order-1 space-y-8">
{/* SIM Management Actions */}
<SimActions
subscriptionId={subscriptionId}
simType={simInfo.details.simType}
status={simInfo.details.status}
onTopUpSuccess={handleActionSuccess}
onPlanChangeSuccess={handleActionSuccess}
onCancelSuccess={handleActionSuccess}
onReissueSuccess={handleActionSuccess}
embedded={false}
/>
{/* Plan Settings Card */}
<div className="order-2 xl:col-span-2 xl:order-1">
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-6">
<div className="flex items-center mb-6">
<div className="bg-green-50 rounded-xl p-2 mr-3">
<svg className="h-5 w-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">Plan Settings</h3>
<p className="text-sm text-gray-600">Modify service options</p>
</div>
</div>
<SimFeatureToggles
<SimActions
subscriptionId={subscriptionId}
voiceMailEnabled={simInfo.details.voiceMailEnabled}
callWaitingEnabled={simInfo.details.callWaitingEnabled}
internationalRoamingEnabled={simInfo.details.internationalRoamingEnabled}
networkType={simInfo.details.networkType}
simType={simInfo.details.simType}
status={simInfo.details.status}
currentPlanCode={simInfo.details.planCode}
onChanged={handleActionSuccess}
onTopUpSuccess={handleActionSuccess}
onPlanChangeSuccess={handleActionSuccess}
onCancelSuccess={handleActionSuccess}
onReissueSuccess={handleActionSuccess}
embedded={true}
/>
<div className="mt-6">
<p className="text-sm text-gray-600 font-medium mb-3">Modify service options</p>
<SimFeatureToggles
subscriptionId={subscriptionId}
voiceMailEnabled={simInfo.details.voiceMailEnabled}
callWaitingEnabled={simInfo.details.callWaitingEnabled}
internationalRoamingEnabled={simInfo.details.internationalRoamingEnabled}
networkType={simInfo.details.networkType}
onChanged={handleActionSuccess}
embedded
/>
</div>
</div>
</div>
{/* Sidebar - Compact Info (Right Side) */}
<div className="xl:order-2 space-y-8">
<div className="order-1 xl:order-2 space-y-8">
{/* Details + Usage combined card for mobile-first */}
<div className="bg-white shadow-lg rounded-xl border border-gray-100 p-6 space-y-6">
<SimDetailsCard
simDetails={simInfo.details}
isLoading={false}
error={null}
embedded={true}
showFeaturesSummary={false}
/>
<DataUsageChart
usage={simInfo.usage}
remainingQuotaMb={simInfo.details.remainingQuotaMb}
isLoading={false}
error={null}
embedded={true}
/>
</div>
{/* Important Information Card */}
<div className="bg-gradient-to-br from-blue-50 to-blue-100 border border-blue-200 rounded-xl p-6">
<div className="flex items-center mb-4">
@ -203,21 +208,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
</ul>
</div>
<SimDetailsCard
simDetails={simInfo.details}
isLoading={false}
error={null}
embedded={true}
showFeaturesSummary={false}
/>
<DataUsageChart
usage={simInfo.usage}
remainingQuotaMb={simInfo.details.remainingQuotaMb}
isLoading={false}
error={null}
embedded={true}
/>
{/* (On desktop, details+usage are above; on mobile they appear first since this section is above actions) */}
</div>
</div>
</div>

View File

@ -248,7 +248,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
disabled={loading}
className="w-full sm:w-auto px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
Cancel
Back
</button>
<button
type="submit"

View File

@ -0,0 +1,466 @@
# SIM Management Page - API Data Flow & System Architecture
*Technical documentation explaining the API integration and data flow for the SIM Management interface*
**Purpose**: This document provides a detailed explanation of how the SIM Management page retrieves, processes, and displays data through various API integrations.
**Audience**: Management, Technical Teams, System Architects
**Last Updated**: September 2025
---
## 📋 Executive Summary
Change Log (2025-09-05)
- Adopted official Freebit API names across all callouts (e.g., "Add Specs & Quota", "MVNO Plan Change").
- Added Freebit API Quick Reference (Portal Operations) table.
- Documented TopUp Payment Flow (WHMCS invoice + autocapture then Freebit AddSpec).
- Listed additional Freebit APIs not used by the portal today.
The SIM Management page integrates with multiple backend systems to provide real-time SIM data, usage statistics, and management capabilities. The system uses a **Backend-for-Frontend (BFF)** architecture that aggregates data from Freebit APIs and WHMCS, providing a unified interface for SIM management operations.
### Key Systems Integration:
- **WHMCS**: Subscription and billing data
- **Freebit API**: SIM details, usage, and management operations
- **Customer Portal BFF**: Data aggregation and API orchestration
---
## 🏗️ System Architecture Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ Customer Portal Frontend │
│ (Next.js - Port 3000) │
├─────────────────────────────────────────────────────────────────┤
│ SIM Management Page Components: │
│ • SimManagementSection.tsx │
│ • SimDetailsCard.tsx │
│ • DataUsageChart.tsx │
│ • SimActions.tsx │
│ • SimFeatureToggles.tsx │
└─────────────────────────────────────────────────────────────────┘
│ HTTP Requests
┌─────────────────────────────────────────────────────────────────┐
│ Backend-for-Frontend (BFF) │
│ (Port 4000) │
├─────────────────────────────────────────────────────────────────┤
│ API Endpoints: │
│ • /api/subscriptions/{id}/sim │
│ • /api/subscriptions/{id}/sim/details │
│ • /api/subscriptions/{id}/sim/usage │
│ • /api/subscriptions/{id}/sim/top-up │
│ • /api/subscriptions/{id}/sim/top-up-history │
│ • /api/subscriptions/{id}/sim/change-plan │
│ • /api/subscriptions/{id}/sim/features │
│ • /api/subscriptions/{id}/sim/cancel │
│ • /api/subscriptions/{id}/sim/reissue-esim │
└─────────────────────────────────────────────────────────────────┘
│ Data Aggregation
┌─────────────────────────────────────────────────────────────────┐
│ External Systems │
├─────────────────────────────────────────────────────────────────┤
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ WHMCS │ │ Freebit API │ │
│ │ (Billing) │ │ (SIM Services) │ │
│ │ │ │ │ │
│ │ • Subscriptions │ │ • SIM Details │ │
│ │ • Customer Data │ │ • Usage Data │ │
│ │ • Billing Info │ │ • Management │ │
│ └─────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
---
## 📊 Data Flow by Section
### 1. **SIM Management Actions Section**
**Purpose**: Provides action buttons for SIM operations (Top Up, Reissue, Cancel, Change Plan)
**Data Sources**:
- **WHMCS**: Subscription status and customer permissions
- **Freebit API**: SIM type (physical/eSIM) and current status
**API Calls**:
```typescript
// Initial Load - Get SIM details for action availability
GET /api/subscriptions/{id}/sim/details
```
**Data Flow**:
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │ │ BFF │ │ Freebit API │
│ │ │ │ │ │
│ SimActions.tsx │───▶│ /sim/details │───▶│ /mvno/getDetail/│
│ │ │ │ │ │
│ • Check SIM │ │ • Authenticate │ │ • Return SIM │
│ type & status │ │ • Map response │ │ details │
│ • Enable/disable│ │ • Handle errors │ │ • Status info │
│ buttons │ │ │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
**Action-Specific APIs**:
- **Top Up Data**: `POST /api/subscriptions/{id}/sim/top-up` → Freebit `/master/addSpec/`
- **Reissue eSIM**: `POST /api/subscriptions/{id}/sim/reissue-esim` → Freebit `/mvno/esim/addAcnt/`
- **Cancel SIM**: `POST /api/subscriptions/{id}/sim/cancel` → Freebit `/mvno/releasePlan/`
- **Change Plan**: `POST /api/subscriptions/{id}/sim/change-plan` → Freebit `/mvno/changePlan/`
---
### 2. **eSIM Details Card (Right Sidebar)**
**Purpose**: Displays essential SIM information in compact format
**Data Sources**:
- **WHMCS**: Subscription product name and billing info
- **Freebit API**: SIM technical details and status
**API Calls**:
```typescript
// Get comprehensive SIM information
GET /api/subscriptions/{id}/sim
```
**Data Flow**:
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │ │ BFF │ │ External │
│ │ │ Systems │ │ Systems │
│ SimDetailsCard │───▶│ /sim │───▶│ ┌─────────────┐ │
│ │ │ │ │ │ WHMCS │ │
│ • Phone number │ │ • Aggregate │ │ │ • Product │ │
│ • Data remaining│ │ data from │ │ │ name │ │
│ • Service status│ │ multiple │ │ │ • Billing │ │
│ • Plan info │ │ sources │ │ └─────────────┘ │
│ │ │ • Transform │ │ ┌─────────────┐ │
│ │ │ responses │ │ │ Freebit │ │
│ │ │ • Handle errors │ │ │ • ICCID │ │
│ │ │ │ │ │ • MSISDN │ │
│ │ │ │ │ │ • Status │ │
│ │ │ │ │ │ • Plan code │ │
│ │ │ │ │ └─────────────┘ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
**Data Mapping**:
```typescript
// BFF Response Structure
{
"details": {
"iccid": "8944504101234567890", // From Freebit
"msisdn": "08077052946", // From Freebit
"planCode": "PASI_50G", // From Freebit
"status": "active", // From Freebit
"simType": "esim", // From Freebit
"productName": "SonixNet SIM Service", // From WHMCS
"remainingQuotaMb": 48256 // Calculated
}
}
```
---
### 3. **Data Usage Chart (Right Sidebar)**
**Purpose**: Visual representation of data consumption and remaining quota
**Data Sources**:
- **Freebit API**: Real-time usage statistics and quota information
**API Calls**:
```typescript
// Get usage data
GET /api/subscriptions/{id}/sim/usage
```
**Data Flow**:
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │ │ BFF │ │ Freebit API │
│ │ │ │ │ │
│ DataUsageChart │───▶│ /sim/usage │───▶│ /mvno/getTraffic│
│ │ │ │ │ Info/ │
│ • Progress bar │ │ • Authenticate │ │ │
│ • Usage stats │ │ • Format data │ │ • Today's usage │
│ • History chart │ │ • Calculate │ │ • Total quota │
│ • Remaining GB │ │ percentages │ │ • Usage history │
│ │ │ • Handle errors │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
**Data Processing**:
```typescript
// Freebit API Response
{
"todayUsageMb": 748.47,
"totalQuotaMb": 51200,
"usageHistory": [
{ "date": "2025-01-04", "usageMb": 1228.8 },
{ "date": "2025-01-03", "usageMb": 595.2 },
{ "date": "2025-01-02", "usageMb": 448.0 }
]
}
// BFF Processing
const usagePercentage = (usedMb / totalQuotaMb) * 100;
const remainingMb = totalQuotaMb - usedMb;
const formattedRemaining = formatQuota(remainingMb); // "47.1 GB"
```
---
### 4. **Plan & Service Options**
**Purpose**: Manage SIM plan and optional features (Voice Mail, Call Waiting, International Roaming, 4G/5G).
**Data Sources**:
- **Freebit API**: Current service settings and options
- **WHMCS**: Plan catalog and billing context
**API Calls**:
```typescript
// Get current service settings
GET /api/subscriptions/{id}/sim/details
// Update optional features (flags)
POST /api/subscriptions/{id}/sim/features
// Change plan
POST /api/subscriptions/{id}/sim/change-plan
```
**Data Flow**:
```
┌─────────────────┐ ┌─────────────────┐ ┌──────────────────────────┐
│ Frontend │ │ BFF │ │ Freebit API │
│ │ │ │ │ │
│ SimFeatureToggles│───▶│ /sim/details │───▶│ /mvno/getDetail/ │
│ │ │ │ │ │
│ Apply Changes │───▶│ /sim/features │───▶│ /master/addSpec/ (flags) │
│ Change Plan │───▶│ /sim/change-plan│───▶│ /mvno/changePlan/ │
│ │ │ │ │ │
│ • Validate │ │ • Authenticate │ │ • Apply changes │
│ • Update UI │ │ • Transform │ │ • Return resultCode=100 │
│ • Refresh data │ │ • Handle errors │ │ │
└─────────────────┘ └─────────────────┘ └──────────────────────────┘
```
Allowed plans and mapping
- The portal currently supports the following SIM data plans from Salesforce:
- SIM Data-only 5GB → Freebit planCode `PASI_5G`
- SIM Data-only 10GB → `PASI_10G`
- SIM Data-only 25GB → `PASI_25G`
- SIM Data-only 50GB → `PASI_50G`
- UI behavior: The Change Plan action lives inside the “SIM Management Actions” card. Clicking it opens a modal listing only “other” plans. For example, if the current plan is `PASI_50G`, options will be 5GB, 10GB, 25GB. If the current plan is not 50GB, the 50GB option is included.
- Request payload sent to BFF:
```json
{
"newPlanCode": "PASI_25G"
}
```
- BFF calls MVNO Plan Change with fields per the API spec (account, planCode, optional globalIP, optional runTime).
---
### 5. **Top-Up Payment Flow (Invoice + Auto-Capture)**
When a user tops up data, the portal bills through WHMCS before applying the quota via Freebit. Unit price is fixed: 1 GB = ¥500.
Endpoints used
- Frontend → BFF: `POST /api/subscriptions/{id}/sim/top-up` with `{ quotaMb, campaignCode?, expiryDate? }`
- BFF → WHMCS: `createInvoice` then `capturePayment` (gateway-selected SSO or stored method)
- BFF → Freebit: `PA04-04 Add Spec & Quota` (`/master/addSpec/`) if payment succeeds
Pricing
- Amount in JPY = ceil(quotaMb / 1024) × 500
- Example: 1024MB → ¥500, 3072MB → ¥1,500
Happy-path sequence
```
Frontend BFF WHMCS Freebit
────────── ──────────────── ──────────────── ────────────────
TopUpModal ───────▶ POST /sim/top-up ───────▶ createInvoice ─────▶
(quotaMb) (validate + map) (amount=ceil(MB/1024)*500)
│ │
│ invoiceId
▼ │
capturePayment ───────────────▶ │
│ paid (or failed)
├── on success ─────────────────────────────▶ /master/addSpec/
│ (quota in KB)
└── on failure ──┐
└──── return error (no Freebit call)
```
Failure handling
- If `capturePayment` fails, BFF responds with 402/400 and does NOT call Freebit. UI shows error and invoice link for manual payment.
- If Freebit returns non-100 `resultCode`, BFF logs, returns 502/500, and may void/refund invoice in future enhancement.
BFF responsibilities
- Validate `quotaMb` (1100000)
- Price computation and invoice line creation (description includes quota)
- Attempt payment capture (stored method or SSO handoff)
- On success, call Freebit AddSpec with `quota=quotaMb*1024` and optional `expire`
- Return success to UI and refresh SIM info
Freebit PA04-04 (Add Spec & Quota) request fields
- `account`: MSISDN (phone number)
- `quota`: integer KB (100MB51200MB in screenshot spec; environment-dependent)
- `quotaCode` (optional): campaign code
- `expire` (optional): YYYYMMDD
Notes
- Scheduled top-ups use `/mvno/eachQuota/` with `runTime`; immediate uses `/master/addSpec/`.
- For development, amounts and gateway can be simulated; production requires real WHMCS gateway configuration.
---
## 🔄 Real-Time Data Updates
### Automatic Refresh Mechanism
```typescript
// After any action (top-up, cancel, etc.)
const handleActionSuccess = () => {
// Refresh all data
refetchSimDetails();
refetchUsageData();
refetchSubscriptionData();
};
```
### Data Consistency
- **Immediate Updates**: UI updates optimistically
- **Background Sync**: Real data fetched after actions
- **Error Handling**: Rollback on API failures
- **Loading States**: Visual feedback during operations
---
## 📈 Performance Considerations
### Caching Strategy
```typescript
// BFF Level Caching
- SIM Details: 5 minutes TTL
- Usage Data: 1 minute TTL
- Subscription Info: 10 minutes TTL
// Frontend Caching
- React Query: 30 seconds stale time
- Background refetch: Every 2 minutes
```
### API Optimization
- **Batch Requests**: Single endpoint for comprehensive data
- **Selective Updates**: Only refresh changed sections
- **Error Recovery**: Retry failed requests with exponential backoff
---
## 🛡️ Security & Authentication
### Authentication Flow
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Frontend │ │ BFF │ │ External │
│ │ │ │ │ Systems │
│ • JWT Token │───▶│ • Validate JWT │───▶│ • WHMCS API Key │
│ • User Context │ │ • Map to WHMCS │ │ • Freebit Auth │
│ • Permissions │ │ Client ID │ │ • Rate Limiting │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
### Data Protection
- **Input Validation**: All user inputs sanitized
- **Rate Limiting**: API calls throttled per user
- **Audit Logging**: All actions logged for compliance
- **Error Masking**: Sensitive data not exposed in errors
---
## 📊 Monitoring & Analytics
### Key Metrics Tracked
- **API Response Times**: < 500ms target
- **Error Rates**: < 1% target
- **User Actions**: Top-up frequency, plan changes
- **Data Usage Patterns**: Peak usage times, quota consumption
### Health Checks
```typescript
// BFF Health Endpoints
GET /health/sim-management
GET /health/freebit-api
GET /health/whmcs-api
```
---
## 🚀 Future Enhancements
### Planned Improvements
1. **Real-time WebSocket Updates**: Live usage data without refresh
2. **Advanced Analytics**: Usage predictions and recommendations
3. **Bulk Operations**: Manage multiple SIMs simultaneously
4. **Mobile App Integration**: Native mobile SIM management
### Scalability Considerations
- **Microservices**: Split BFF into domain-specific services
- **CDN Integration**: Cache static SIM data globally
- **Database Optimization**: Implement read replicas for usage data
---
## 📞 Support & Troubleshooting
### Common Issues
1. **API Timeouts**: Check Freebit API status
2. **Data Inconsistency**: Verify WHMCS sync
3. **Authentication Errors**: Validate JWT tokens
4. **Rate Limiting**: Monitor API quotas
### Debug Endpoints
```typescript
// Development only
GET /api/subscriptions/{id}/sim/debug
GET /api/health/sim-management/detailed
```
---
## 📋 **Summary for Your Managers**
This comprehensive documentation explains:
### **🏗️ System Architecture**
- **3-Tier Architecture**: Frontend → BFF → External APIs (WHMCS + Freebit)
- **Data Aggregation**: BFF combines data from multiple sources
- **Real-time Updates**: Automatic refresh after user actions
### **📊 Key Data Flows**
1. **SIM Actions**: Button availability based on SIM type and status
2. **SIM Details**: Phone number, data remaining, service status
3. **Usage Chart**: Real-time consumption and quota visualization
4. **Service Options**: Voice mail, call waiting, roaming settings
### **🔧 Technical Benefits**
- **Performance**: Caching and optimized API calls
- **Security**: JWT authentication and input validation
- **Reliability**: Error handling and retry mechanisms
- **Monitoring**: Health checks and performance metrics
### **💼 Business Value**
- **User Experience**: Real-time data and intuitive interface
- **Operational Efficiency**: Automated SIM management operations
- **Data Accuracy**: Direct integration with Freebit and WHMCS
- **Scalability**: Architecture supports future enhancements
This documentation will help your managers understand the technical complexity and business value of the SIM Management system!