- Introduced email notifications for various SIM management actions (e.g., top-ups, plan changes, cancellations) in SimManagementService. - Updated SendGridEmailProvider to allow custom 'from' addresses in email options. - Enhanced the SimCancelPage component to provide user feedback and confirmation regarding cancellation requests. - Refactored email service integration to improve error handling and logging for email notifications.
477 lines
20 KiB
TypeScript
477 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import React, { 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 { authenticatedApi } 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 [showReissueConfirm, setShowReissueConfirm] = 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 && simType === "esim";
|
|
const canCancel = isActive;
|
|
|
|
const handleReissueEsim = async () => {
|
|
setLoading("reissue");
|
|
setError(null);
|
|
|
|
try {
|
|
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/reissue-esim`);
|
|
|
|
setSuccess("eSIM profile reissued successfully");
|
|
setShowReissueConfirm(false);
|
|
onReissueSuccess?.();
|
|
} catch (error: unknown) {
|
|
setError(error instanceof Error ? error.message : "Failed to reissue eSIM profile");
|
|
} finally {
|
|
setLoading(null);
|
|
}
|
|
};
|
|
|
|
const handleCancelSim = async () => {
|
|
setLoading("cancel");
|
|
setError(null);
|
|
|
|
try {
|
|
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/cancel`, {});
|
|
|
|
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-lg rounded-xl border border-gray-100 hover:shadow-xl transition-shadow duration-300"}`}
|
|
>
|
|
{/* Header */}
|
|
<div className={`${embedded ? "" : "px-6 lg:px-8 py-5 border-b border-gray-200"}`}>
|
|
<div className="flex items-center">
|
|
<div className="bg-blue-50 rounded-xl p-2 mr-4">
|
|
<svg
|
|
className="h-6 w-6 text-blue-600"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 100 4m0-4v2m0-6V4"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h3 className="text-xl font-semibold text-gray-900">SIM Management Actions</h3>
|
|
<p className="text-sm text-gray-600 mt-1">Manage your SIM service</p>
|
|
</div>
|
|
</div>
|
|
</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={`grid gap-4 ${embedded ? "grid-cols-1" : "grid-cols-2"}`}>
|
|
{/* 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={`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
|
|
? "text-blue-700 bg-blue-50 border-blue-200 hover:bg-blue-100 hover:border-blue-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
|
: "text-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed"
|
|
}`}
|
|
>
|
|
<div className="flex items-center">
|
|
<div className="bg-blue-100 rounded-lg p-1 mr-3">
|
|
<PlusIcon className="h-5 w-5 text-blue-600" />
|
|
</div>
|
|
<span>{loading === "topup" ? "Processing..." : "Top Up Data"}</span>
|
|
</div>
|
|
</button>
|
|
|
|
{/* Reissue eSIM (only for eSIMs) */}
|
|
{simType === "esim" && (
|
|
<button
|
|
onClick={() => {
|
|
setActiveInfo("reissue");
|
|
try {
|
|
router.push(`/subscriptions/${subscriptionId}/sim/reissue`);
|
|
} catch {
|
|
setShowReissueConfirm(true);
|
|
}
|
|
}}
|
|
disabled={!canReissue || loading !== null}
|
|
className={`group relative flex items-center justify-center px-6 py-4 border rounded-xl text-sm font-semibold transition-all duration-200 ${
|
|
canReissue && loading === null
|
|
? "text-green-700 bg-green-50 border-green-200 hover:bg-green-100 hover:border-green-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
|
|
: "text-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed"
|
|
}`}
|
|
>
|
|
<div className="flex items-center">
|
|
<div className="bg-green-100 rounded-lg p-1 mr-3">
|
|
<ArrowPathIcon className="h-5 w-5 text-green-600" />
|
|
</div>
|
|
<span>{loading === "reissue" ? "Processing..." : "Reissue eSIM"}</span>
|
|
</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={`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
|
|
? "text-red-700 bg-red-50 border-red-200 hover:bg-red-100 hover:border-red-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
|
: "text-gray-400 bg-gray-50 border-gray-200 cursor-not-allowed"
|
|
}`}
|
|
>
|
|
<div className="flex items-center">
|
|
<div className="bg-red-100 rounded-lg p-1 mr-3">
|
|
<XMarkIcon className="h-5 w-5 text-red-600" />
|
|
</div>
|
|
<span>{loading === "cancel" ? "Processing..." : "Cancel SIM"}</span>
|
|
</div>
|
|
</button>
|
|
|
|
{/* Change Plan - Secondary Action */}
|
|
<button
|
|
onClick={() => {
|
|
setActiveInfo("changePlan");
|
|
try {
|
|
router.push(`/subscriptions/${subscriptionId}/sim/change-plan`);
|
|
} catch {
|
|
setShowChangePlanModal(true);
|
|
}
|
|
}}
|
|
disabled={loading !== null}
|
|
className={`group relative flex items-center justify-center px-6 py-4 border-2 border-dashed rounded-xl text-sm font-semibold transition-all duration-200 ${
|
|
loading === null
|
|
? "text-purple-700 bg-purple-50 border-purple-300 hover:bg-purple-100 hover:border-purple-400 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500"
|
|
: "text-gray-400 bg-gray-50 border-gray-300 cursor-not-allowed"
|
|
}`}
|
|
>
|
|
<div className="flex items-center">
|
|
<div className="bg-purple-100 rounded-lg p-1 mr-3">
|
|
<svg
|
|
className="h-5 w-5 text-purple-600"
|
|
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>
|
|
<span>Change Plan</span>
|
|
</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 eSIM:</strong> Generate a new eSIM profile for download. Use this
|
|
if your previous download failed or you need to install on a new device.
|
|
</div>
|
|
</div>
|
|
)}
|
|
{activeInfo === "cancel" && (
|
|
<div className="flex items-start">
|
|
<XMarkIcon className="h-4 w-4 text-red-600 mr-2 mt-0.5 flex-shrink-0" />
|
|
<div>
|
|
<strong>Cancel SIM:</strong> Permanently cancel your SIM service. This action
|
|
cannot be undone and will terminate your service immediately.
|
|
</div>
|
|
</div>
|
|
)}
|
|
{activeInfo === "changePlan" && (
|
|
<div className="flex items-start">
|
|
<svg
|
|
className="h-4 w-4 text-purple-600 mr-2 mt-0.5 flex-shrink-0"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
|
|
/>
|
|
</svg>
|
|
<div>
|
|
<strong>Change Plan:</strong> Switch to a different data plan.{" "}
|
|
<span className="text-red-600 font-medium">
|
|
Important: Plan changes must be requested before the 25th of the month. Changes
|
|
will take effect on the 1st of the following month.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 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 eSIM Confirmation */}
|
|
{showReissueConfirm && (
|
|
<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-green-100 sm:mx-0 sm:h-10 sm:w-10">
|
|
<ArrowPathIcon className="h-6 w-6 text-green-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">
|
|
Reissue eSIM Profile
|
|
</h3>
|
|
<div className="mt-2">
|
|
<p className="text-sm text-gray-500">
|
|
This will generate a new eSIM profile for download. Your current eSIM will
|
|
remain active until you activate the new profile.
|
|
</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 handleReissueEsim()}
|
|
disabled={loading === "reissue"}
|
|
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-green-600 text-base font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
|
|
>
|
|
{loading === "reissue" ? "Processing..." : "Reissue eSIM"}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setShowReissueConfirm(false);
|
|
setActiveInfo(null);
|
|
}}
|
|
disabled={loading === "reissue"}
|
|
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
|
>
|
|
Back
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 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>
|
|
);
|
|
}
|