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, ArrowRightIcon,
WifiIcon, WifiIcon,
GlobeAltIcon, GlobeAltIcon,
SignalIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { AnimatedCard } from "@/components/catalog/animated-card"; import { AnimatedCard } from "@/components/catalog/animated-card";
import { AnimatedButton } from "@/components/catalog/animated-button"; import { AnimatedButton } from "@/components/catalog/animated-button";
@ -32,7 +31,7 @@ export default function CatalogPage() {
</span> </span>
</h1> </h1>
<p className="text-xl text-gray-600 max-w-4xl mx-auto leading-relaxed"> <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. solution is personalized based on your location and account eligibility.
</p> </p>
</div> </div>
@ -57,13 +56,13 @@ export default function CatalogPage() {
{/* SIM/eSIM Service */} {/* SIM/eSIM Service */}
<ServiceHeroCard <ServiceHeroCard
title="SIM & eSIM" 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" />} icon={<DevicePhoneMobileIcon className="h-12 w-12" />}
features={[ features={[
"Physical SIM & eSIM", "Physical SIM & eSIM",
"Data + Voice plans", "Data + SMS/Voice plans",
"Family discounts", "Family discounts",
"Flexible data sizes", "Multiple data options",
]} ]}
href="/catalog/sim" href="/catalog/sim"
color="green" color="green"
@ -95,17 +94,12 @@ export default function CatalogPage() {
</p> </p>
</div> </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 <FeatureCard
icon={<WifiIcon className="h-10 w-10 text-blue-600" />} icon={<WifiIcon className="h-10 w-10 text-blue-600" />}
title="Location-Based Plans" title="Location-Based Plans"
description="Internet plans tailored to your house type and available infrastructure" 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 <FeatureCard
icon={<GlobeAltIcon className="h-10 w-10 text-purple-600" />} icon={<GlobeAltIcon className="h-10 w-10 text-purple-600" />}
title="Seamless Integration" title="Seamless Integration"

View File

@ -45,7 +45,7 @@ function PlanTypeSection({
const familyPlans = plans.filter(p => p.hasFamilyDiscount); const familyPlans = plans.filter(p => p.hasFamilyDiscount);
return ( return (
<div> <div className="animate-in fade-in duration-500">
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
{icon} {icon}
<div> <div>
@ -224,7 +224,7 @@ export default function SimPlansPage() {
<div className="text-center mb-12"> <div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4">Choose Your SIM Plan</h1> <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"> <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> </p>
</div> </div>
{/* Family Discount Banner */} {/* Family Discount Banner */}
@ -267,48 +267,54 @@ export default function SimPlansPage() {
<nav className="-mb-px flex space-x-8" aria-label="Tabs"> <nav className="-mb-px flex space-x-8" aria-label="Tabs">
<button <button
onClick={() => setActiveTab("data-voice")} 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" activeTab === "data-voice"
? "border-blue-500 text-blue-600" ? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" : "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 === "data-voice" ? "scale-110" : ""}`} />
Data + Voice Data + SMS/Voice
{plansByType.DataSmsVoice.length > 0 && ( {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} {plansByType.DataSmsVoice.length}
</span> </span>
)} )}
</button> </button>
<button <button
onClick={() => setActiveTab("data-only")} 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" activeTab === "data-only"
? "border-purple-500 text-purple-600" ? "border-purple-500 text-purple-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" : "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 Data Only
{plansByType.DataOnly.length > 0 && ( {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} {plansByType.DataOnly.length}
</span> </span>
)} )}
</button> </button>
<button <button
onClick={() => setActiveTab("voice-only")} 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" activeTab === "voice-only"
? "border-orange-500 text-orange-600" ? "border-orange-500 text-orange-600"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" : "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 Voice Only
{plansByType.VoiceOnly.length > 0 && ( {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} {plansByType.VoiceOnly.length}
</span> </span>
)} )}
@ -318,49 +324,74 @@ export default function SimPlansPage() {
</div> </div>
{/* Tab Content */} {/* Tab Content */}
<div className="min-h-[400px]"> <div className="min-h-[400px] relative">
{activeTab === "data-voice" && ( <div className={`transition-all duration-500 ease-in-out ${
<PlanTypeSection activeTab === "data-voice"
title="Data + Voice Plans" ? "opacity-100 translate-y-0"
description="Internet, calling, and SMS included" : "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"
icon={<PhoneIcon className="h-8 w-8 text-blue-600" />} }`}>
plans={plansByType.DataSmsVoice} {activeTab === "data-voice" && (
showFamilyDiscount={hasExistingSim} <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" && ( <div className={`transition-all duration-500 ease-in-out ${
<PlanTypeSection activeTab === "data-only"
title="Data Only Plans" ? "opacity-100 translate-y-0"
description="Internet access for tablets, laptops, and IoT devices" : "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"
icon={<GlobeAltIcon className="h-8 w-8 text-purple-600" />} }`}>
plans={plansByType.DataOnly} {activeTab === "data-only" && (
showFamilyDiscount={hasExistingSim} <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" && ( <div className={`transition-all duration-500 ease-in-out ${
<PlanTypeSection activeTab === "voice-only"
title="Voice Only Plans" ? "opacity-100 translate-y-0"
description="Traditional calling and SMS without internet" : "opacity-0 translate-y-4 absolute inset-0 pointer-events-none"
icon={<PhoneIcon className="h-8 w-8 text-orange-600" />} }`}>
plans={plansByType.VoiceOnly} {activeTab === "voice-only" && (
showFamilyDiscount={hasExistingSim} <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> </div>
{/* Features Section */} {/* Features Section */}
<div className="mt-8 bg-gray-50 rounded-2xl p-8 max-w-4xl mx-auto"> <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"> <h3 className="font-bold text-gray-900 text-xl mb-6 text-center">
All SIM Plans Include Plan Features & Terms
</h3> </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"> <div className="flex items-start gap-3">
<CheckIcon className="h-5 w-5 text-green-500 mt-0.5 flex-shrink-0" /> <CheckIcon className="h-5 w-5 text-green-500 mt-0.5 flex-shrink-0" />
<div> <div>
<div className="font-medium text-gray-900">No Contract</div> <div className="font-medium text-gray-900">3-Month Contract</div>
<div className="text-gray-600">Cancel anytime</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> </div>
<div className="flex items-start gap-3"> <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 className="text-gray-600">Multi-line savings</div>
</div> </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>
</div> </div>
{/* Info Section */} {/* 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"> <div className="mt-8 p-6 rounded-lg border border-blue-200 bg-blue-50 max-w-4xl mx-auto">
<InformationCircleIcon className="h-5 w-5 text-blue-600 mt-0.5 flex-shrink-0" /> <div className="flex items-start gap-3 mb-4">
<div className="text-sm"> <InformationCircleIcon className="h-5 w-5 text-blue-600 mt-0.5 flex-shrink-0" />
<div className="font-medium text-blue-900 mb-1">Getting Started</div> <div className="text-sm">
<p className="text-blue-800"> <div className="font-medium text-blue-900 mb-2">Important Terms & Conditions</div>
Choose your plan size, select eSIM or physical SIM, and configure optional add-ons </div>
like voice mail and call waiting. Number porting is available if you want to keep your </div>
existing phone number. <div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
</p> <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> </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 { interface ChangePlanModalProps {
subscriptionId: number; subscriptionId: number;
currentPlanCode?: string;
onClose: () => void; onClose: () => void;
onSuccess: () => void; onSuccess: () => void;
onError: (message: string) => void; onError: (message: string) => void;
} }
export function ChangePlanModal({ subscriptionId, onClose, onSuccess, onError }: ChangePlanModalProps) { export function ChangePlanModal({ subscriptionId, currentPlanCode, onClose, onSuccess, onError }: ChangePlanModalProps) {
const [newPlanCode, setNewPlanCode] = useState(""); 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 [assignGlobalIp, setAssignGlobalIp] = useState(false);
const [scheduledAt, setScheduledAt] = useState(""); // YYYY-MM-DD const [scheduledAt, setScheduledAt] = useState(""); // YYYY-MM-DD
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const submit = async () => { const submit = async () => {
if (!newPlanCode.trim()) { if (!newPlanCode) {
onError("Please enter a new plan code"); onError("Please select a new plan");
return; return;
} }
setLoading(true); setLoading(true);
try { try {
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/change-plan`, { await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/change-plan`, {
newPlanCode: newPlanCode.trim(), newPlanCode: newPlanCode,
assignGlobalIp, assignGlobalIp,
scheduledAt: scheduledAt ? scheduledAt.replaceAll("-", "") : undefined, scheduledAt: scheduledAt ? scheduledAt.replaceAll("-", "") : undefined,
}); });
@ -55,14 +67,18 @@ export function ChangePlanModal({ subscriptionId, onClose, onSuccess, onError }:
</div> </div>
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700">New Plan Code</label> <label className="block text-sm font-medium text-gray-700">Select New Plan</label>
<input <select
type="text"
value={newPlanCode} value={newPlanCode}
onChange={(e) => setNewPlanCode(e.target.value)} onChange={(e) => setNewPlanCode(e.target.value as PlanCode)}
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" 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>
<div className="flex items-center"> <div className="flex items-center">
<input <input
@ -105,7 +121,7 @@ export function ChangePlanModal({ subscriptionId, onClose, onSuccess, onError }:
disabled={loading} 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" 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> </button>
</div> </div>
</div> </div>
@ -113,4 +129,3 @@ export function ChangePlanModal({ subscriptionId, onClose, onSuccess, onError }:
</div> </div>
); );
} }

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { import {
PlusIcon, PlusIcon,
ArrowPathIcon, ArrowPathIcon,
@ -21,6 +22,7 @@ interface SimActionsProps {
onCancelSuccess?: () => void; onCancelSuccess?: () => void;
onReissueSuccess?: () => void; onReissueSuccess?: () => void;
embedded?: boolean; // when true, render content without card container embedded?: boolean; // when true, render content without card container
currentPlanCode?: string;
} }
export function SimActions({ export function SimActions({
@ -31,8 +33,10 @@ export function SimActions({
onPlanChangeSuccess, onPlanChangeSuccess,
onCancelSuccess, onCancelSuccess,
onReissueSuccess, onReissueSuccess,
embedded = false embedded = false,
currentPlanCode
}: SimActionsProps) { }: SimActionsProps) {
const router = useRouter();
const [showTopUpModal, setShowTopUpModal] = useState(false); const [showTopUpModal, setShowTopUpModal] = useState(false);
const [showCancelConfirm, setShowCancelConfirm] = useState(false); const [showCancelConfirm, setShowCancelConfirm] = useState(false);
const [showReissueConfirm, setShowReissueConfirm] = useState(false); const [showReissueConfirm, setShowReissueConfirm] = useState(false);
@ -40,6 +44,9 @@ export function SimActions({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
const [showChangePlanModal, setShowChangePlanModal] = useState(false); const [showChangePlanModal, setShowChangePlanModal] = useState(false);
const [activeInfo, setActiveInfo] = useState<
'topup' | 'reissue' | 'cancel' | 'changePlan' | null
>(null);
const isActive = status === 'active'; const isActive = status === 'active';
const canTopUp = isActive; const canTopUp = isActive;
@ -145,7 +152,14 @@ export function SimActions({
<div className={`grid gap-4 ${embedded ? 'grid-cols-1' : 'grid-cols-2'}`}> <div className={`grid gap-4 ${embedded ? 'grid-cols-1' : 'grid-cols-2'}`}>
{/* Top Up Data - Primary Action */} {/* Top Up Data - Primary Action */}
<button <button
onClick={() => setShowTopUpModal(true)} onClick={() => {
setActiveInfo('topup');
try {
router.push(`/subscriptions/${subscriptionId}/sim/top-up`);
} catch {
setShowTopUpModal(true);
}
}}
disabled={!canTopUp || loading !== null} 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 ${ 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 canTopUp && loading === null
@ -164,7 +178,14 @@ export function SimActions({
{/* Reissue eSIM (only for eSIMs) */} {/* Reissue eSIM (only for eSIMs) */}
{simType === 'esim' && ( {simType === 'esim' && (
<button <button
onClick={() => setShowReissueConfirm(true)} onClick={() => {
setActiveInfo('reissue');
try {
router.push(`/subscriptions/${subscriptionId}/sim/reissue`);
} catch {
setShowReissueConfirm(true);
}
}}
disabled={!canReissue || loading !== null} 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 ${ 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 canReissue && loading === null
@ -183,7 +204,11 @@ export function SimActions({
{/* Cancel SIM - Destructive Action */} {/* Cancel SIM - Destructive Action */}
<button <button
onClick={() => setShowCancelConfirm(true)} onClick={() => {
setActiveInfo('cancel');
// keep inline confirm for cancel to avoid accidental navigation
setShowCancelConfirm(true);
}}
disabled={!canCancel || loading !== null} 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 ${ 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 canCancel && loading === null
@ -201,10 +226,17 @@ export function SimActions({
{/* Change Plan - Secondary Action */} {/* Change Plan - Secondary Action */}
<button <button
onClick={() => {/* Add change plan functionality */}} onClick={() => {
disabled={!isActive || loading !== null} 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 ${ 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-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' : 'text-gray-400 bg-gray-50 border-gray-300 cursor-not-allowed'
}`} }`}
@ -220,47 +252,52 @@ export function SimActions({
</button> </button>
</div> </div>
{/* Action Descriptions */} {/* Action Description (contextual) */}
<div className="mt-6 space-y-3 text-sm text-gray-600"> {activeInfo && (
<div className="flex items-start"> <div className="mt-6 text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg p-4">
<PlusIcon className="h-4 w-4 text-blue-500 mr-2 mt-0.5 flex-shrink-0" /> {activeInfo === 'topup' && (
<div> <div className="flex items-start">
<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. <PlusIcon className="h-4 w-4 text-blue-600 mr-2 mt-0.5 flex-shrink-0" />
</div> <div>
</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>
{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.
</div> </div>
</div> )}
)} {activeInfo === 'reissue' && (
<div className="flex items-start">
<div className="flex items-start"> <ArrowPathIcon className="h-4 w-4 text-green-600 mr-2 mt-0.5 flex-shrink-0" />
<XMarkIcon className="h-4 w-4 text-red-500 mr-2 mt-0.5 flex-shrink-0" /> <div>
<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.
<strong>Cancel SIM:</strong> Permanently cancel your SIM service. This action cannot be undone and will terminate your service immediately. </div>
</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>
)}
<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> </div>
{/* Top Up Modal */} {/* Top Up Modal */}
{showTopUpModal && ( {showTopUpModal && (
<TopUpModal <TopUpModal
subscriptionId={subscriptionId} subscriptionId={subscriptionId}
onClose={() => setShowTopUpModal(false)} onClose={() => { setShowTopUpModal(false); setActiveInfo(null); }}
onSuccess={() => { onSuccess={() => {
setShowTopUpModal(false); setShowTopUpModal(false);
setSuccess('Data top-up completed successfully'); 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 */} {/* Reissue eSIM Confirmation */}
{showReissueConfirm && ( {showReissueConfirm && (
@ -304,11 +354,11 @@ export function SimActions({
</button> </button>
<button <button
type="button" type="button"
onClick={() => setShowReissueConfirm(false)} onClick={() => { setShowReissueConfirm(false); setActiveInfo(null); }}
disabled={loading === 'reissue'} 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" 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> </button>
</div> </div>
</div> </div>
@ -348,11 +398,11 @@ export function SimActions({
</button> </button>
<button <button
type="button" type="button"
onClick={() => setShowCancelConfirm(false)} onClick={() => { setShowCancelConfirm(false); setActiveInfo(null); }}
disabled={loading === 'cancel'} 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" 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> </button>
</div> </div>
</div> </div>

View File

@ -47,6 +47,15 @@ interface SimDetailsCardProps {
} }
export function SimDetailsCard({ simDetails, isLoading, error, embedded = false, showFeaturesSummary = true }: 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) => { const getStatusIcon = (status: string) => {
switch (status) { switch (status) {
case 'active': case 'active':
@ -146,7 +155,7 @@ export function SimDetailsCard({ simDetails, isLoading, error, embedded = false,
</div> </div>
<div> <div>
<h3 className="text-xl font-semibold text-gray-900">eSIM Details</h3> <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>
</div> </div>
<span className={`inline-flex px-4 py-2 text-sm font-semibold rounded-full ${getStatusColor(simDetails.status)} self-start sm:self-auto`}> <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> <div>
<h3 className="text-lg font-medium text-gray-900">Physical SIM Details</h3> <h3 className="text-lg font-medium text-gray-900">Physical SIM Details</h3>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
{simDetails.planCode} {`${simDetails.size} SIM`} {formatPlan(simDetails.planCode)} {`${simDetails.size} SIM`}
</p> </p>
</div> </div>
</div> </div>

View File

@ -2,7 +2,6 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { authenticatedApi } from "@/lib/api"; import { authenticatedApi } from "@/lib/api";
import type { SimPlan } from "@/shared/types/catalog.types";
interface SimFeatureTogglesProps { interface SimFeatureTogglesProps {
subscriptionId: number; subscriptionId: number;
@ -10,8 +9,8 @@ interface SimFeatureTogglesProps {
callWaitingEnabled?: boolean; callWaitingEnabled?: boolean;
internationalRoamingEnabled?: boolean; internationalRoamingEnabled?: boolean;
networkType?: string; // '4G' | '5G' networkType?: string; // '4G' | '5G'
currentPlanCode?: string;
onChanged?: () => void; onChanged?: () => void;
embedded?: boolean; // when true, render without outer card wrappers
} }
export function SimFeatureToggles({ export function SimFeatureToggles({
@ -20,8 +19,8 @@ export function SimFeatureToggles({
callWaitingEnabled, callWaitingEnabled,
internationalRoamingEnabled, internationalRoamingEnabled,
networkType, networkType,
currentPlanCode,
onChanged, onChanged,
embedded = false,
}: SimFeatureTogglesProps) { }: SimFeatureTogglesProps) {
// Initial values // Initial values
const initial = useMemo(() => ({ const initial = useMemo(() => ({
@ -29,18 +28,13 @@ export function SimFeatureToggles({
cw: !!callWaitingEnabled, cw: !!callWaitingEnabled,
ir: !!internationalRoamingEnabled, ir: !!internationalRoamingEnabled,
nt: networkType === '5G' ? '5G' : '4G', nt: networkType === '5G' ? '5G' : '4G',
plan: currentPlanCode || '', }), [voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType]);
}), [voiceMailEnabled, callWaitingEnabled, internationalRoamingEnabled, networkType, currentPlanCode]);
// Working values // Working values
const [vm, setVm] = useState(initial.vm); const [vm, setVm] = useState(initial.vm);
const [cw, setCw] = useState(initial.cw); const [cw, setCw] = useState(initial.cw);
const [ir, setIr] = useState(initial.ir); const [ir, setIr] = useState(initial.ir);
const [nt, setNt] = useState<'4G' | '5G'>(initial.nt as '4G' | '5G'); 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 [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
@ -50,28 +44,13 @@ export function SimFeatureToggles({
setCw(initial.cw); setCw(initial.cw);
setIr(initial.ir); setIr(initial.ir);
setNt(initial.nt as '4G' | '5G'); setNt(initial.nt as '4G' | '5G');
setPlan(initial.plan); }, [initial.vm, initial.cw, initial.ir, initial.nt]);
}, [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; };
}, []);
const reset = () => { const reset = () => {
setVm(initial.vm); setVm(initial.vm);
setCw(initial.cw); setCw(initial.cw);
setIr(initial.ir); setIr(initial.ir);
setNt(initial.nt as '4G' | '5G'); setNt(initial.nt as '4G' | '5G');
setPlan(initial.plan);
setError(null); setError(null);
setSuccess(null); setSuccess(null);
}; };
@ -91,10 +70,6 @@ export function SimFeatureToggles({
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/features`, featurePayload); 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'); setSuccess('Changes submitted successfully');
onChanged?.(); onChanged?.();
} catch (e: any) { } catch (e: any) {
@ -109,9 +84,9 @@ export function SimFeatureToggles({
<div className="space-y-6"> <div className="space-y-6">
{/* Service Options */} {/* 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 */} {/* 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 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"> <div className="flex-1">
@ -249,7 +224,7 @@ export function SimFeatureToggles({
</div> </div>
{/* Notes and Actions */} {/* 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="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<div className="flex items-start"> <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"> <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 */} {/* SIM Details and Usage - Main Content */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8"> <div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
{/* Main Content Area - Actions and Settings (Left Side) */} {/* Main Content Area - Actions and Settings (Left Side) */}
<div className="xl:col-span-2 xl:order-1 space-y-8"> <div className="order-2 xl:col-span-2 xl:order-1">
{/* 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="bg-white shadow-lg rounded-xl border border-gray-100 p-6"> <div className="bg-white shadow-lg rounded-xl border border-gray-100 p-6">
<div className="flex items-center mb-6"> <SimActions
<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
subscriptionId={subscriptionId} subscriptionId={subscriptionId}
voiceMailEnabled={simInfo.details.voiceMailEnabled} simType={simInfo.details.simType}
callWaitingEnabled={simInfo.details.callWaitingEnabled} status={simInfo.details.status}
internationalRoamingEnabled={simInfo.details.internationalRoamingEnabled}
networkType={simInfo.details.networkType}
currentPlanCode={simInfo.details.planCode} 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>
</div> </div>
{/* Sidebar - Compact Info (Right Side) */} {/* 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 */} {/* 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="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"> <div className="flex items-center mb-4">
@ -203,21 +208,7 @@ export function SimManagementSection({ subscriptionId }: SimManagementSectionPro
</ul> </ul>
</div> </div>
<SimDetailsCard {/* (On desktop, details+usage are above; on mobile they appear first since this section is above actions) */}
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> </div>
</div> </div>
</div> </div>

View File

@ -248,7 +248,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
disabled={loading} 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" 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>
<button <button
type="submit" 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!