Refactor Salesforce Opportunity Integration for Enhanced Cancellation Handling

- Updated opportunity field mappings to align with new requirements for SIM and Internet cancellations, improving data accuracy.
- Introduced distinct data structures for SIM and Internet cancellations, enhancing type safety and validation processes.
- Refactored SalesforceOpportunityService to manage updates for both cancellation types, ensuring precise data handling.
- Enhanced cancellation query fields to accommodate new SIM cancellation specifications, streamlining the cancellation workflow.
- Cleaned up portal integration to reflect updated opportunity source fields, promoting better data integrity and tracking.
This commit is contained in:
barsa 2026-01-05 16:33:17 +09:00
parent 6096c15659
commit 922fd3dab0
2 changed files with 490 additions and 0 deletions

View File

@ -0,0 +1,478 @@
"use client";
import Link from "next/link";
import { useState, type ReactNode } from "react";
import { PageLayout, type BreadcrumbItem } from "@/components/templates/PageLayout";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms";
import { ArrowLeftIcon, CheckIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
// ============================================================================
// Types
// ============================================================================
export type Step = 1 | 2 | 3;
export interface CancellationMonth {
value: string;
label: string;
}
export interface CancellationFlowProps {
/** Page icon */
icon: ReactNode;
/** Page title */
title: string;
/** Page description / subtitle */
description: string;
/** Breadcrumb items */
breadcrumbs: BreadcrumbItem[];
/** Back link URL */
backHref: string;
/** Back link label */
backLabel: string;
/** Available cancellation months */
availableMonths: CancellationMonth[];
/** Customer email for confirmation */
customerEmail: string;
/** Loading state */
loading?: boolean;
/** Page-level blocking error */
error?: string | null;
/** Success message after submission */
successMessage?: string | null;
/** Non-blocking error message */
formError?: string | null;
/** Submitting state */
submitting?: boolean;
/** Service info to display in step 1 */
serviceInfo: ReactNode;
/** Notice/terms content for step 2 */
termsContent: ReactNode;
/** Summary content for step 3 (before email and comments) */
summaryContent: ReactNode;
/** Optional extra content for step 3 (after summary, before email) */
step3ExtraContent?: ReactNode;
/** Optional warning banner (e.g., minimum contract term) */
warningBanner?: ReactNode;
/** Confirmation message shown in the browser confirm dialog */
confirmMessage: string;
/** Called when user submits the cancellation */
onSubmit: (data: {
cancellationMonth: string;
confirmRead: boolean;
confirmCancel: boolean;
comments?: string;
}) => void | Promise<void>;
}
// ============================================================================
// Shared Components
// ============================================================================
const STEP_LABELS = ["Select Date", "Review Terms", "Confirm"] as const;
export function Notice({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="bg-warning-soft/50 border-l-4 border-warning rounded-r-lg p-4">
<div className="text-sm font-medium text-foreground mb-1">{title}</div>
<div className="text-sm text-muted-foreground leading-relaxed">{children}</div>
</div>
);
}
export function InfoNotice({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="p-4 bg-info-soft/50 border-l-4 border-info rounded-r-lg">
<div className="text-sm font-medium text-foreground mb-1">{title}</div>
<div className="text-sm text-muted-foreground">{children}</div>
</div>
);
}
function StepIndicator({ currentStep }: { currentStep: Step }) {
return (
<div className="flex items-center justify-center gap-2 mb-8">
{STEP_LABELS.map((label, idx) => {
const stepNum = (idx + 1) as Step;
const isCompleted = stepNum < currentStep;
const isCurrent = stepNum === currentStep;
return (
<div key={stepNum} className="flex items-center">
<div className="flex items-center gap-2">
<div
className={`
w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium
${isCompleted ? "bg-primary text-primary-foreground" : ""}
${isCurrent ? "bg-primary text-primary-foreground ring-4 ring-primary/20" : ""}
${!isCompleted && !isCurrent ? "bg-muted text-muted-foreground" : ""}
`}
>
{isCompleted ? <CheckIcon className="w-4 h-4" /> : stepNum}
</div>
<span
className={`text-sm hidden sm:inline ${isCurrent ? "font-medium text-foreground" : "text-muted-foreground"}`}
>
{label}
</span>
</div>
{stepNum < 3 && (
<div
className={`w-8 sm:w-12 h-0.5 mx-2 ${stepNum < currentStep ? "bg-primary" : "bg-border"}`}
/>
)}
</div>
);
})}
</div>
);
}
// ============================================================================
// Main Component
// ============================================================================
export function CancellationFlow({
icon,
title,
description,
breadcrumbs,
backHref,
backLabel,
availableMonths,
customerEmail,
loading = false,
error = null,
successMessage = null,
formError = null,
submitting = false,
serviceInfo,
termsContent,
summaryContent,
step3ExtraContent,
warningBanner,
confirmMessage,
onSubmit,
}: CancellationFlowProps) {
const [step, setStep] = useState<Step>(1);
const [selectedMonth, setSelectedMonth] = useState<string>("");
const [acceptTerms, setAcceptTerms] = useState(false);
const [confirmMonthEnd, setConfirmMonthEnd] = useState(false);
const [comments, setComments] = useState("");
const selectedMonthInfo = availableMonths.find(m => m.value === selectedMonth);
const canProceedStep2 = !!selectedMonth;
const canProceedStep3 = acceptTerms && confirmMonthEnd;
const handleSubmit = () => {
const message = confirmMessage.replace("{month}", selectedMonthInfo?.label || selectedMonth);
if (window.confirm(message)) {
void onSubmit({
cancellationMonth: selectedMonth,
confirmRead: acceptTerms,
confirmCancel: confirmMonthEnd,
comments: comments.trim() || undefined,
});
}
};
const headerActions = step === 3 && canProceedStep3 && (
<Button
variant="destructive"
onClick={handleSubmit}
disabled={submitting}
loading={submitting}
loadingText="Processing…"
>
Request Cancellation
</Button>
);
return (
<PageLayout
icon={icon}
title={title}
description={description}
actions={headerActions}
breadcrumbs={breadcrumbs}
loading={loading}
error={error}
>
<div className="max-w-2xl mx-auto">
{/* Back link */}
<Link
href={backHref}
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground mb-6"
>
<ArrowLeftIcon className="w-4 h-4" />
{backLabel}
</Link>
{/* Warning banner (e.g., minimum contract) */}
{warningBanner}
{/* Alerts */}
{formError && (
<div className="mb-6">
<AlertBanner variant="error" title="Unable to proceed" elevated>
{formError}
</AlertBanner>
</div>
)}
{successMessage && (
<div className="mb-6">
<AlertBanner variant="success" title="Request submitted" elevated>
{successMessage}
</AlertBanner>
</div>
)}
{/* Step indicator */}
<StepIndicator currentStep={step} />
{/* Step content */}
<div className="bg-card border border-border rounded-xl p-6 sm:p-8">
{step === 1 && (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-foreground mb-1">
Select Cancellation Date
</h2>
<p className="text-sm text-muted-foreground">
Choose when you would like your service to end.
</p>
</div>
{/* Service info (passed from parent) */}
{serviceInfo}
{/* Month selection */}
<div>
<label
htmlFor="cancelMonth"
className="block text-sm font-medium text-foreground mb-2"
>
Cancellation Month
</label>
<select
id="cancelMonth"
value={selectedMonth}
onChange={e => {
setSelectedMonth(e.target.value);
setConfirmMonthEnd(false);
}}
className="w-full border border-input rounded-lg px-4 py-3 text-sm bg-background text-foreground focus:ring-2 focus:ring-primary focus:border-primary"
>
<option value="">Select a month...</option>
{availableMonths.map(month => (
<option key={month.value} value={month.value}>
{month.label}
</option>
))}
</select>
<p className="text-xs text-muted-foreground mt-2">
Service will end at the end of the selected month.
</p>
</div>
<div className="flex justify-end pt-4">
<Button disabled={!canProceedStep2} onClick={() => setStep(2)}>
Continue
</Button>
</div>
</div>
)}
{step === 2 && (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-foreground mb-1">
Review Cancellation Terms
</h2>
<p className="text-sm text-muted-foreground">
Please read the following information carefully.
</p>
</div>
{/* Terms content (passed from parent) */}
{termsContent}
<div className="space-y-4 p-4 bg-muted/50 rounded-lg">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={acceptTerms}
onChange={e => setAcceptTerms(e.target.checked)}
className="h-5 w-5 text-primary border-input rounded mt-0.5 focus:ring-2 focus:ring-primary"
/>
<span className="text-sm text-foreground">
I have read and understood the cancellation terms above.
</span>
</label>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={confirmMonthEnd}
onChange={e => setConfirmMonthEnd(e.target.checked)}
disabled={!selectedMonth}
className="h-5 w-5 text-primary border-input rounded mt-0.5 focus:ring-2 focus:ring-primary disabled:opacity-50"
/>
<span className="text-sm text-foreground">
I confirm cancellation at the end of{" "}
<strong>{selectedMonthInfo?.label || "the selected month"}</strong>.
</span>
</label>
</div>
<div className="flex justify-between pt-4">
<Button variant="ghost" onClick={() => setStep(1)}>
Back
</Button>
<Button disabled={!canProceedStep3} onClick={() => setStep(3)}>
Continue
</Button>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-foreground mb-1">Confirm Your Request</h2>
<p className="text-sm text-muted-foreground">
Review your cancellation details and submit.
</p>
</div>
{/* Summary content (passed from parent) */}
{summaryContent}
{/* Extra content (e.g., Voice SIM notice) */}
{step3ExtraContent}
{/* Email confirmation */}
<div className="p-4 bg-muted/50 rounded-lg">
<div className="text-sm text-muted-foreground">Confirmation will be sent to:</div>
<div className="text-sm font-medium text-foreground">{customerEmail || "—"}</div>
</div>
{/* Comments */}
<div>
<label
htmlFor="comments"
className="block text-sm font-medium text-foreground mb-2"
>
Additional Comments{" "}
<span className="font-normal text-muted-foreground">(optional)</span>
</label>
<textarea
id="comments"
className="w-full border border-input rounded-lg px-4 py-3 text-sm bg-background text-foreground focus:ring-2 focus:ring-primary focus:border-primary resize-none"
rows={3}
value={comments}
onChange={e => setComments(e.target.value)}
placeholder="Any questions or special requests..."
/>
</div>
<div className="flex justify-between pt-4 border-t border-border">
<Button variant="ghost" onClick={() => setStep(2)}>
Back
</Button>
<Button
variant="destructive"
onClick={handleSubmit}
disabled={submitting || !canProceedStep3}
loading={submitting}
loadingText="Processing…"
>
Request Cancellation
</Button>
</div>
</div>
)}
</div>
</div>
</PageLayout>
);
}
// ============================================================================
// Helper Components for Service Info
// ============================================================================
export function ServiceInfoGrid({ children }: { children: ReactNode }) {
return <div className="grid grid-cols-3 gap-4 p-4 bg-muted/50 rounded-lg">{children}</div>;
}
export function ServiceInfoItem({
label,
value,
mono = false,
}: {
label: string;
value: string;
mono?: boolean;
}) {
return (
<div>
<div className="text-xs text-muted-foreground uppercase tracking-wide">{label}</div>
<div
className={`text-sm font-medium text-foreground mt-0.5 ${mono ? "font-mono text-xs" : ""}`}
>
{value || "—"}
</div>
</div>
);
}
// ============================================================================
// Helper Components for Summary
// ============================================================================
export function CancellationSummary({
items,
selectedMonth,
}: {
items: Array<{ label: string; value: string }>;
selectedMonth: string;
}) {
return (
<div className="p-4 bg-primary/5 border border-primary/20 rounded-lg">
<div className="text-sm font-medium text-foreground mb-3">Cancellation Summary</div>
<div className="grid grid-cols-2 gap-4 text-sm">
{items.map(item => (
<div key={item.label}>
<span className="text-muted-foreground">{item.label}:</span>
<div className="font-medium text-foreground">{item.value}</div>
</div>
))}
<div>
<span className="text-muted-foreground">Effective Date:</span>
<div className="font-medium text-foreground">End of {selectedMonth}</div>
</div>
</div>
</div>
);
}
// ============================================================================
// Warning Banner Component
// ============================================================================
export function MinimumContractWarning({ endDate }: { endDate: string }) {
return (
<div className="mb-6 flex items-start gap-3 p-4 bg-danger-soft border border-danger/20 rounded-lg">
<ExclamationTriangleIcon className="w-5 h-5 text-danger flex-shrink-0 mt-0.5" />
<div>
<div className="text-sm font-medium text-foreground">Minimum Contract Term Warning</div>
<div className="text-sm text-muted-foreground mt-1">
Your subscription is within the minimum contract period (ends {endDate}). Early
cancellation may incur additional charges.
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,12 @@
export {
CancellationFlow,
Notice,
InfoNotice,
ServiceInfoGrid,
ServiceInfoItem,
CancellationSummary,
MinimumContractWarning,
type CancellationFlowProps,
type CancellationMonth,
type Step,
} from "./CancellationFlow";