- Deleted migration file that removed cached profile fields from the users table, centralizing profile data retrieval from WHMCS. - Updated CsrfMiddleware to include new public authentication endpoints for password reset, setting password, and WHMCS account linking. - Enhanced error handling in password and WHMCS linking workflows to provide clearer feedback on missing mappings and improve user experience. - Adjusted user creation and update methods in UsersFacade to handle cases where WHMCS mappings are not yet available, ensuring smoother account setup.
417 lines
15 KiB
TypeScript
417 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import React from "react";
|
|
import { formatPlanShort } from "@/lib/utils";
|
|
import {
|
|
DevicePhoneMobileIcon,
|
|
WifiIcon,
|
|
SignalIcon,
|
|
ClockIcon,
|
|
CheckCircleIcon,
|
|
ExclamationTriangleIcon,
|
|
XCircleIcon,
|
|
} from "@heroicons/react/24/outline";
|
|
|
|
export interface SimDetails {
|
|
account: string;
|
|
msisdn: string;
|
|
iccid?: string;
|
|
imsi?: string;
|
|
eid?: string;
|
|
planCode: string;
|
|
status: "active" | "suspended" | "cancelled" | "pending";
|
|
simType: "physical" | "esim";
|
|
size: "standard" | "nano" | "micro" | "esim";
|
|
hasVoice: boolean;
|
|
hasSms: boolean;
|
|
remainingQuotaKb: number;
|
|
remainingQuotaMb: number;
|
|
startDate?: string;
|
|
ipv4?: string;
|
|
ipv6?: string;
|
|
voiceMailEnabled?: boolean;
|
|
callWaitingEnabled?: boolean;
|
|
internationalRoamingEnabled?: boolean;
|
|
networkType?: string;
|
|
pendingOperations?: Array<{
|
|
operation: string;
|
|
scheduledDate: string;
|
|
}>;
|
|
}
|
|
|
|
interface SimDetailsCardProps {
|
|
simDetails: SimDetails;
|
|
isLoading?: boolean;
|
|
error?: string | null;
|
|
embedded?: boolean; // when true, render content without card container
|
|
showFeaturesSummary?: boolean; // show the right-side Service Features summary
|
|
}
|
|
|
|
export function SimDetailsCard({
|
|
simDetails,
|
|
isLoading,
|
|
error,
|
|
embedded = false,
|
|
showFeaturesSummary = true,
|
|
}: SimDetailsCardProps) {
|
|
const formatPlan = (code?: string) => {
|
|
const formatted = formatPlanShort(code);
|
|
// Remove "PASI" prefix if present
|
|
return formatted?.replace(/^PASI\s*/, "") || formatted;
|
|
};
|
|
const getStatusIcon = (status: string) => {
|
|
switch (status) {
|
|
case "active":
|
|
return <CheckCircleIcon className="h-6 w-6 text-green-500" />;
|
|
case "suspended":
|
|
return <ExclamationTriangleIcon className="h-6 w-6 text-yellow-500" />;
|
|
case "cancelled":
|
|
return <XCircleIcon className="h-6 w-6 text-red-500" />;
|
|
case "pending":
|
|
return <ClockIcon className="h-6 w-6 text-blue-500" />;
|
|
default:
|
|
return <DevicePhoneMobileIcon className="h-6 w-6 text-gray-500" />;
|
|
}
|
|
};
|
|
|
|
const getStatusColor = (status: string) => {
|
|
switch (status) {
|
|
case "active":
|
|
return "bg-green-100 text-green-800";
|
|
case "suspended":
|
|
return "bg-yellow-100 text-yellow-800";
|
|
case "cancelled":
|
|
return "bg-red-100 text-red-800";
|
|
case "pending":
|
|
return "bg-blue-100 text-blue-800";
|
|
default:
|
|
return "bg-gray-100 text-gray-800";
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateString: string) => {
|
|
try {
|
|
const date = new Date(dateString);
|
|
return date.toLocaleDateString("en-US", {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
} catch {
|
|
return dateString;
|
|
}
|
|
};
|
|
|
|
const formatQuota = (quotaMb: number) => {
|
|
if (quotaMb >= 1000) {
|
|
return `${(quotaMb / 1000).toFixed(1)} GB`;
|
|
}
|
|
return `${quotaMb.toFixed(0)} MB`;
|
|
};
|
|
|
|
if (isLoading) {
|
|
const Skeleton = (
|
|
<div
|
|
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300 "}p-6 lg:p-8`}
|
|
>
|
|
<div className="animate-pulse">
|
|
<div className="flex items-center space-x-4">
|
|
<div className="rounded-full bg-gradient-to-br from-blue-200 to-blue-300 h-14 w-14"></div>
|
|
<div className="flex-1 space-y-3">
|
|
<div className="h-5 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-3/4"></div>
|
|
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-1/2"></div>
|
|
</div>
|
|
</div>
|
|
<div className="mt-8 space-y-4">
|
|
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg"></div>
|
|
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-5/6"></div>
|
|
<div className="h-4 bg-gradient-to-r from-gray-200 to-gray-300 rounded-lg w-4/6"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
return Skeleton;
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div
|
|
className={`${embedded ? "" : "bg-white shadow-lg rounded-xl border border-red-100 "}p-6 lg:p-8`}
|
|
>
|
|
<div className="text-center">
|
|
<div className="bg-red-50 rounded-full p-3 w-16 h-16 mx-auto mb-4">
|
|
<ExclamationTriangleIcon className="h-10 w-10 text-red-500 mx-auto" />
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">Error Loading SIM Details</h3>
|
|
<p className="text-red-600 text-sm">{error}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Modern eSIM details view with usage visualization
|
|
if (simDetails.simType === "esim") {
|
|
const remainingGB = simDetails.remainingQuotaMb / 1000;
|
|
const totalGB = 1048.6; // Mock total - should come from API
|
|
const usedGB = totalGB - remainingGB;
|
|
const usagePercentage = (usedGB / totalGB) * 100;
|
|
|
|
// Usage Sparkline Component
|
|
const UsageSparkline = ({ data }: { data: Array<{ date: string; usedMB: number }> }) => {
|
|
const maxValue = Math.max(...data.map(d => d.usedMB), 1);
|
|
const width = 80;
|
|
const height = 16;
|
|
|
|
const points = data.map((d, i) => {
|
|
const x = (i / (data.length - 1)) * width;
|
|
const y = height - (d.usedMB / maxValue) * height;
|
|
return `${x},${y}`;
|
|
}).join(' ');
|
|
|
|
return (
|
|
<svg width={width} height={height} className="text-blue-500">
|
|
<polyline
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.5"
|
|
points={points}
|
|
/>
|
|
</svg>
|
|
);
|
|
};
|
|
|
|
// Usage Donut Component
|
|
const UsageDonut = ({ size = 120 }: { size?: number }) => {
|
|
const radius = (size - 16) / 2;
|
|
const circumference = 2 * Math.PI * radius;
|
|
const strokeDashoffset = circumference - (usagePercentage / 100) * circumference;
|
|
|
|
return (
|
|
<div className="relative flex items-center justify-center">
|
|
<svg width={size} height={size} className="transform -rotate-90">
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
fill="none"
|
|
stroke="rgb(241 245 249)"
|
|
strokeWidth="8"
|
|
/>
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
fill="none"
|
|
stroke="rgb(59 130 246)"
|
|
strokeWidth="8"
|
|
strokeLinecap="round"
|
|
strokeDasharray={circumference}
|
|
strokeDashoffset={strokeDashoffset}
|
|
className="transition-all duration-300"
|
|
/>
|
|
</svg>
|
|
<div className="absolute text-center">
|
|
<div className="text-3xl font-semibold text-slate-900">{remainingGB.toFixed(1)}</div>
|
|
<div className="text-sm text-slate-500 -mt-1">GB remaining</div>
|
|
<div className="text-xs text-slate-400 mt-1">{usagePercentage.toFixed(1)}% used</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className={`${embedded ? "" : "bg-white shadow-md rounded-xl border border-gray-100"}`}>
|
|
{/* Compact Header Bar */}
|
|
<div className={`${embedded ? "" : "px-6 py-4 border-b border-gray-200"}`}>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<span
|
|
className={`inline-flex px-3 py-1 text-xs font-medium rounded-full ${getStatusColor(simDetails.status)}`}
|
|
>
|
|
{simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
|
|
</span>
|
|
<span className="text-lg font-semibold text-slate-900">
|
|
{formatPlan(simDetails.planCode)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="text-sm text-slate-600 mt-1">{simDetails.msisdn}</div>
|
|
</div>
|
|
|
|
<div className={`${embedded ? "" : "px-6 py-6"}`}>
|
|
{/* Usage Visualization */}
|
|
<div className="flex justify-center mb-6">
|
|
<UsageDonut size={160} />
|
|
</div>
|
|
|
|
<div className="border-t border-gray-200 pt-4">
|
|
<h4 className="text-sm font-medium text-slate-900 mb-3">Recent Usage History</h4>
|
|
<div className="space-y-2">
|
|
{[
|
|
{ date: "Sep 29", usage: "0 MB" },
|
|
{ date: "Sep 28", usage: "0 MB" },
|
|
{ date: "Sep 27", usage: "0 MB" },
|
|
].map((entry, index) => (
|
|
<div key={index} className="flex justify-between items-center text-xs">
|
|
<span className="text-slate-600">{entry.date}</span>
|
|
<span className="text-slate-900">{entry.usage}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Default view for physical SIM cards
|
|
return (
|
|
<div className={`${embedded ? "" : "bg-white shadow-md rounded-xl border border-gray-100"}`}>
|
|
{/* Header */}
|
|
<div className={`${embedded ? "" : "px-6 py-4 border-b border-gray-200"}`}>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center">
|
|
<div className="text-2xl mr-3">
|
|
<DevicePhoneMobileIcon className="h-8 w-8 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-medium text-gray-900">Physical SIM Details</h3>
|
|
<p className="text-sm text-gray-500">
|
|
{formatPlan(simDetails.planCode)} • {`${simDetails.size} SIM`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center space-x-3">
|
|
{getStatusIcon(simDetails.status)}
|
|
<span
|
|
className={`inline-flex px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(simDetails.status)}`}
|
|
>
|
|
{simDetails.status.charAt(0).toUpperCase() + simDetails.status.slice(1)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className={`${embedded ? "" : "px-6 py-4"}`}>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{/* SIM Information */}
|
|
<div>
|
|
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
|
|
SIM Information
|
|
</h4>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="text-xs text-gray-500">Phone Number</label>
|
|
<p className="text-sm font-medium text-gray-900">{simDetails.msisdn}</p>
|
|
</div>
|
|
|
|
{simDetails.simType === "physical" && (
|
|
<div>
|
|
<label className="text-xs text-gray-500">ICCID</label>
|
|
<p className="text-sm font-mono text-gray-900 break-all">{simDetails.iccid}</p>
|
|
</div>
|
|
)}
|
|
|
|
{simDetails.eid && (
|
|
<div>
|
|
<label className="text-xs text-gray-500">EID (eSIM)</label>
|
|
<p className="text-sm font-mono text-gray-900 break-all">{simDetails.eid}</p>
|
|
</div>
|
|
)}
|
|
|
|
{simDetails.imsi && (
|
|
<div>
|
|
<label className="text-xs text-gray-500">IMSI</label>
|
|
<p className="text-sm font-mono text-gray-900">{simDetails.imsi}</p>
|
|
</div>
|
|
)}
|
|
|
|
{simDetails.startDate && (
|
|
<div>
|
|
<label className="text-xs text-gray-500">Service Start Date</label>
|
|
<p className="text-sm text-gray-900">{formatDate(simDetails.startDate)}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Service Features */}
|
|
{showFeaturesSummary && (
|
|
<div>
|
|
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
|
|
Service Features
|
|
</h4>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="text-xs text-gray-500">Data Remaining</label>
|
|
<p className="text-lg font-semibold text-green-600">
|
|
{formatQuota(simDetails.remainingQuotaMb)}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-4">
|
|
<div className="flex items-center">
|
|
<SignalIcon
|
|
className={`h-4 w-4 mr-1 ${simDetails.hasVoice ? "text-green-500" : "text-gray-400"}`}
|
|
/>
|
|
<span
|
|
className={`text-sm ${simDetails.hasVoice ? "text-green-600" : "text-gray-500"}`}
|
|
>
|
|
Voice {simDetails.hasVoice ? "Enabled" : "Disabled"}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center">
|
|
<DevicePhoneMobileIcon
|
|
className={`h-4 w-4 mr-1 ${simDetails.hasSms ? "text-green-500" : "text-gray-400"}`}
|
|
/>
|
|
<span
|
|
className={`text-sm ${simDetails.hasSms ? "text-green-600" : "text-gray-500"}`}
|
|
>
|
|
SMS {simDetails.hasSms ? "Enabled" : "Disabled"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{(simDetails.ipv4 || simDetails.ipv6) && (
|
|
<div>
|
|
<label className="text-xs text-gray-500">IP Address</label>
|
|
<div className="space-y-1">
|
|
{simDetails.ipv4 && (
|
|
<p className="text-sm font-mono text-gray-900">IPv4: {simDetails.ipv4}</p>
|
|
)}
|
|
{simDetails.ipv6 && (
|
|
<p className="text-sm font-mono text-gray-900">IPv6: {simDetails.ipv6}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Pending Operations */}
|
|
{simDetails.pendingOperations && simDetails.pendingOperations.length > 0 && (
|
|
<div className="mt-6 pt-6 border-t border-gray-200">
|
|
<h4 className="text-sm font-medium text-gray-500 uppercase tracking-wider mb-3">
|
|
Pending Operations
|
|
</h4>
|
|
<div className="bg-blue-50 rounded-lg p-4">
|
|
{simDetails.pendingOperations.map((operation, index) => (
|
|
<div key={index} className="flex items-center text-sm">
|
|
<ClockIcon className="h-4 w-4 text-blue-500 mr-2" />
|
|
<span className="text-blue-800">
|
|
{operation.operation} scheduled for {formatDate(operation.scheduledDate)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|