- 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.
426 lines
17 KiB
TypeScript
426 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import React, { useMemo, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import {
|
|
PlusIcon,
|
|
ArrowPathIcon,
|
|
XMarkIcon,
|
|
ExclamationTriangleIcon,
|
|
CheckCircleIcon,
|
|
} from "@heroicons/react/24/outline";
|
|
import { TopUpModal } from "./TopUpModal";
|
|
import { ChangePlanModal } from "./ChangePlanModal";
|
|
import { ReissueSimModal } from "./ReissueSimModal";
|
|
import { apiClient } from "@/lib/api";
|
|
|
|
interface SimActionsProps {
|
|
subscriptionId: number;
|
|
simType: "physical" | "esim";
|
|
status: string;
|
|
onTopUpSuccess?: () => void;
|
|
onPlanChangeSuccess?: () => void;
|
|
onCancelSuccess?: () => void;
|
|
onReissueSuccess?: () => void;
|
|
embedded?: boolean; // when true, render content without card container
|
|
currentPlanCode?: string;
|
|
}
|
|
|
|
export function SimActions({
|
|
subscriptionId,
|
|
simType,
|
|
status,
|
|
onTopUpSuccess,
|
|
onPlanChangeSuccess,
|
|
onCancelSuccess,
|
|
onReissueSuccess,
|
|
embedded = false,
|
|
currentPlanCode,
|
|
}: SimActionsProps) {
|
|
const router = useRouter();
|
|
const [showTopUpModal, setShowTopUpModal] = useState(false);
|
|
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
|
|
const [showReissueModal, setShowReissueModal] = useState(false);
|
|
const [loading, setLoading] = useState<string | null>(null);
|
|
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;
|
|
const canReissue = isActive;
|
|
const canCancel = isActive;
|
|
|
|
const reissueDisabledReason = useMemo(() => {
|
|
if (!isActive) {
|
|
return "SIM must be active to request a reissue.";
|
|
}
|
|
return null;
|
|
}, [isActive]);
|
|
|
|
const handleCancelSim = async () => {
|
|
setLoading("cancel");
|
|
setError(null);
|
|
|
|
try {
|
|
await apiClient.POST("/api/subscriptions/{id}/sim/cancel", {
|
|
params: { path: { id: subscriptionId } },
|
|
body: {},
|
|
});
|
|
|
|
setSuccess("SIM service cancelled successfully");
|
|
setShowCancelConfirm(false);
|
|
onCancelSuccess?.();
|
|
} catch (error: unknown) {
|
|
setError(error instanceof Error ? error.message : "Failed to cancel SIM service");
|
|
} finally {
|
|
setLoading(null);
|
|
}
|
|
};
|
|
|
|
// Clear success/error messages after 5 seconds
|
|
React.useEffect(() => {
|
|
if (success || error) {
|
|
const timer = setTimeout(() => {
|
|
setSuccess(null);
|
|
setError(null);
|
|
}, 5000);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
return;
|
|
}, [success, error]);
|
|
|
|
return (
|
|
<div
|
|
id="sim-actions"
|
|
className={`${embedded ? "" : "bg-white shadow-md rounded-xl border border-gray-100"}`}
|
|
>
|
|
{/* Header */}
|
|
{!embedded && (
|
|
<div className="px-6 py-6 border-b border-gray-200">
|
|
<h3 className="text-lg font-semibold tracking-tight text-slate-900 mb-1">
|
|
SIM Management Actions
|
|
</h3>
|
|
<p className="text-sm text-slate-600">Manage your SIM service</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Content */}
|
|
<div className={`${embedded ? "" : "px-6 lg:px-8 py-6"}`}>
|
|
{/* Status Messages */}
|
|
{success && (
|
|
<div className="mb-4 bg-green-50 border border-green-200 rounded-lg p-4">
|
|
<div className="flex items-center">
|
|
<CheckCircleIcon className="h-5 w-5 text-green-500 mr-2" />
|
|
<p className="text-sm text-green-800">{success}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-4">
|
|
<div className="flex items-center">
|
|
<ExclamationTriangleIcon className="h-5 w-5 text-red-500 mr-2" />
|
|
<p className="text-sm text-red-800">{error}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!isActive && (
|
|
<div className="mb-4 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
|
<div className="flex items-center">
|
|
<ExclamationTriangleIcon className="h-5 w-5 text-yellow-500 mr-2" />
|
|
<p className="text-sm text-yellow-800">
|
|
SIM management actions are only available for active services.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Action Buttons */}
|
|
<div className="space-y-3">
|
|
{/* Top Up Data - Primary Action */}
|
|
<button
|
|
onClick={() => {
|
|
setActiveInfo("topup");
|
|
try {
|
|
router.push(`/subscriptions/${subscriptionId}/sim/top-up`);
|
|
} catch {
|
|
setShowTopUpModal(true);
|
|
}
|
|
}}
|
|
disabled={!canTopUp || loading !== null}
|
|
className={`w-full flex items-center justify-start px-4 py-4 rounded-lg text-sm font-medium transition-all duration-200 ${
|
|
canTopUp && loading === null
|
|
? "text-white bg-blue-600 hover:bg-blue-700 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 active:scale-[0.98]"
|
|
: "text-gray-400 bg-gray-100 cursor-not-allowed"
|
|
}`}
|
|
>
|
|
<div className="flex items-center">
|
|
<PlusIcon className="h-4 w-4 mr-3" />
|
|
<div className="text-left">
|
|
<div className="font-medium">
|
|
{loading === "topup" ? "Processing..." : "Top Up Data"}
|
|
</div>
|
|
<div className="text-xs opacity-90">Add more data to your plan</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
{/* Change Plan - Secondary Action */}
|
|
<button
|
|
onClick={() => {
|
|
setActiveInfo("changePlan");
|
|
try {
|
|
router.push(`/subscriptions/${subscriptionId}/sim/change-plan`);
|
|
} catch {
|
|
setShowChangePlanModal(true);
|
|
}
|
|
}}
|
|
disabled={!isActive || loading !== null}
|
|
className={`w-full flex items-center justify-start px-4 py-4 rounded-lg text-sm font-medium transition-all duration-200 ${
|
|
isActive && loading === null
|
|
? "text-slate-700 bg-slate-100 hover:bg-slate-200 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-500 active:scale-[0.98]"
|
|
: "text-gray-400 bg-gray-100 cursor-not-allowed"
|
|
}`}
|
|
>
|
|
<div className="flex items-center">
|
|
<ArrowPathIcon className="h-4 w-4 mr-3" />
|
|
<div className="text-left">
|
|
<div className="font-medium">
|
|
{loading === "change-plan" ? "Processing..." : "Change Plan"}
|
|
</div>
|
|
<div className="text-xs opacity-70">Switch to a different plan</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
{/* Reissue SIM */}
|
|
<button
|
|
onClick={() => {
|
|
setActiveInfo("reissue");
|
|
setShowReissueModal(true);
|
|
}}
|
|
disabled={!canReissue || loading !== null}
|
|
className={`w-full flex flex-col items-start justify-start rounded-lg border px-4 py-4 text-left text-sm font-medium transition-all duration-200 ${
|
|
canReissue && loading === null
|
|
? "border-green-200 bg-green-50 text-green-900 hover:bg-green-100 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 active:scale-[0.98]"
|
|
: "text-gray-400 bg-gray-100 border-gray-200 cursor-not-allowed"
|
|
}`}
|
|
>
|
|
<div className="flex w-full items-center justify-between">
|
|
<div className="flex items-center">
|
|
<ArrowPathIcon className="h-4 w-4 mr-3" />
|
|
<div className="text-left">
|
|
<div className="font-medium">{"Reissue SIM"}</div>
|
|
<div className="text-xs opacity-70">
|
|
Configure replacement options and submit your request.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{!canReissue && reissueDisabledReason && (
|
|
<div className="mt-3 w-full rounded-md border border-yellow-200 bg-yellow-50 px-3 py-2 text-xs text-yellow-800">
|
|
{reissueDisabledReason}
|
|
</div>
|
|
)}
|
|
</button>
|
|
|
|
{/* Cancel SIM - Destructive Action */}
|
|
<button
|
|
onClick={() => {
|
|
setActiveInfo("cancel");
|
|
try {
|
|
router.push(`/subscriptions/${subscriptionId}/sim/cancel`);
|
|
} catch {
|
|
// Fallback to inline confirmation modal if navigation is unavailable
|
|
setShowCancelConfirm(true);
|
|
}
|
|
}}
|
|
disabled={!canCancel || loading !== null}
|
|
className={`w-full flex items-center justify-start px-4 py-4 rounded-lg text-sm font-medium transition-all duration-200 ${
|
|
canCancel && loading === null
|
|
? "text-red-700 bg-white border border-red-200 hover:bg-red-50 hover:border-red-300 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 active:scale-[0.98]"
|
|
: "text-gray-400 bg-gray-100 cursor-not-allowed"
|
|
}`}
|
|
>
|
|
<div className="flex items-center">
|
|
<XMarkIcon className="h-4 w-4 mr-3" />
|
|
<div className="text-left">
|
|
<div className="font-medium">
|
|
{loading === "cancel" ? "Processing..." : "Cancel SIM"}
|
|
</div>
|
|
<div className="text-xs opacity-70">Permanently cancel service</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
{/* 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>
|
|
)}
|
|
{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 SIM:</strong> Submit a replacement request for either a physical SIM or an eSIM. eSIM users can optionally supply a new EID to pair with the replacement profile.
|
|
</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>
|
|
|
|
{/* Top Up Modal */}
|
|
{showTopUpModal && (
|
|
<TopUpModal
|
|
subscriptionId={subscriptionId}
|
|
onClose={() => {
|
|
setShowTopUpModal(false);
|
|
setActiveInfo(null);
|
|
}}
|
|
onSuccess={() => {
|
|
setShowTopUpModal(false);
|
|
setSuccess("Data top-up completed successfully");
|
|
onTopUpSuccess?.();
|
|
}}
|
|
onError={message => setError(message)}
|
|
/>
|
|
)}
|
|
|
|
{/* 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 SIM Modal */}
|
|
{showReissueModal && (
|
|
<ReissueSimModal
|
|
subscriptionId={subscriptionId}
|
|
currentSimType={simType}
|
|
onClose={() => {
|
|
setShowReissueModal(false);
|
|
setActiveInfo(null);
|
|
}}
|
|
onSuccess={() => {
|
|
setShowReissueModal(false);
|
|
setSuccess("SIM reissue request submitted successfully");
|
|
onReissueSuccess?.();
|
|
}}
|
|
onError={message => {
|
|
setError(message);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Cancel Confirmation */}
|
|
{showCancelConfirm && (
|
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
|
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
|
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
|
|
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
|
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
|
<div className="sm:flex sm:items-start">
|
|
<div className="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
|
<ExclamationTriangleIcon className="h-6 w-6 text-red-600" />
|
|
</div>
|
|
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
|
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
|
Cancel SIM Service
|
|
</h3>
|
|
<div className="mt-2">
|
|
<p className="text-sm text-gray-500">
|
|
Are you sure you want to cancel this SIM service? This action cannot be
|
|
undone and will permanently terminate your service.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleCancelSim()}
|
|
disabled={loading === "cancel"}
|
|
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
|
|
>
|
|
{loading === "cancel" ? "Processing..." : "Cancel SIM"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
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"
|
|
>
|
|
Back
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|