barsa a2e81798ef Refactor color tokens across components for improved consistency and clarity
- Removed legacy color aliases from globals.css to streamline design tokens.
- Updated various components, including Badge, Button, Checkbox, and Input, to utilize new color tokens for error states.
- Enhanced error messaging styles in components like ErrorBoundary and AlertBanner for better visual coherence.
- Standardized color usage in billing and subscription components to align with updated design tokens.
- Improved overall styling consistency across the application by adopting the new color system.
2025-12-16 18:12:12 +09:00

429 lines
16 KiB
TypeScript

"use client";
import React, { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import {
PlusIcon,
ArrowPathIcon,
XMarkIcon,
ExclamationTriangleIcon,
} from "@heroicons/react/24/outline";
import { TopUpModal } from "./TopUpModal";
import { ChangePlanModal } from "./ChangePlanModal";
import { ReissueSimModal } from "./ReissueSimModal";
import { apiClient } from "@/lib/api";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms/button";
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(
process.env.NODE_ENV === "development"
? error instanceof Error
? error.message
: "Failed to cancel SIM service"
: "Unable to cancel SIM service right now. Please try again."
);
} 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-card shadow-[var(--cp-shadow-1)] rounded-xl border border-border"}`}
>
{/* Header */}
{!embedded && (
<div className="px-6 py-6 border-b border-border">
<h3 className="text-lg font-semibold tracking-tight text-foreground mb-1">
SIM Management Actions
</h3>
<p className="text-sm text-muted-foreground">Manage your SIM service</p>
</div>
)}
{/* Content */}
<div className={`${embedded ? "" : "px-6 lg:px-8 py-6"}`}>
{/* Status Messages */}
{success && (
<div className="mb-4">
<AlertBanner variant="success" title="Success" size="sm" elevated>
{success}
</AlertBanner>
</div>
)}
{error && (
<div className="mb-4">
<AlertBanner variant="error" title="Unable to complete action" size="sm" elevated>
{error}
</AlertBanner>
</div>
)}
{!isActive && (
<div className="mb-4">
<AlertBanner variant="warning" title="Not available" size="sm" elevated>
SIM management actions are only available for active services.
</AlertBanner>
</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-colors duration-[var(--cp-duration-normal)] ${
canTopUp && loading === null
? "text-primary-foreground bg-primary hover:bg-primary-hover shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)] focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
: "text-muted-foreground bg-muted 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-colors duration-[var(--cp-duration-normal)] ${
isActive && loading === null
? "text-secondary-foreground bg-secondary hover:bg-secondary/80 shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)] focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
: "text-muted-foreground bg-muted 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-colors duration-[var(--cp-duration-normal)] ${
canReissue && loading === null
? "border-success/30 bg-success-soft text-foreground hover:bg-success-soft/80 shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)] focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
: "text-muted-foreground bg-muted border-border 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-warning/25 bg-warning-soft px-3 py-2 text-xs text-muted-foreground">
{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-colors duration-[var(--cp-duration-normal)] ${
canCancel && loading === null
? "text-danger bg-danger-soft border border-danger/30 hover:bg-danger-soft/80 shadow-[var(--cp-shadow-1)] hover:shadow-[var(--cp-shadow-2)] focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background"
: "text-muted-foreground bg-muted border border-border 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-muted-foreground bg-muted border border-border rounded-lg p-4">
{activeInfo === "topup" && (
<div className="flex items-start">
<PlusIcon className="h-4 w-4 text-primary 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-success 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-danger 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-primary 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-warning 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-background/70 backdrop-blur-sm transition-opacity"></div>
<div className="inline-block align-bottom bg-card rounded-lg text-left overflow-hidden shadow-[var(--cp-shadow-3)] border border-border transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-card 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-danger-soft sm:mx-0 sm:h-10 sm:w-10">
<ExclamationTriangleIcon className="h-6 w-6 text-danger" />
</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-foreground">
Cancel SIM Service
</h3>
<div className="mt-2">
<p className="text-sm text-muted-foreground">
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-muted px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse gap-3">
<Button
variant="destructive"
onClick={() => void handleCancelSim()}
loading={loading === "cancel"}
loadingText="Processing…"
>
Cancel SIM
</Button>
<Button
variant="outline"
onClick={() => {
setShowCancelConfirm(false);
setActiveInfo(null);
}}
disabled={loading === "cancel"}
>
Back
</Button>
</div>
</div>
</div>
</div>
)}
</div>
);
}