- Introduced `validateSignup` endpoint in AuthController for customer number validation during signup. - Added `healthCheck` method in AuthService to verify service integrations and database connectivity. - Implemented `getPaymentMethods`, `getPaymentGateways`, and `refreshPaymentMethods` endpoints in InvoicesController for managing user payment options. - Enhanced InvoicesService with methods to invalidate payment methods cache and improved error handling. - Updated currency handling across various services and components to reflect JPY as the default currency. - Added new dependencies in package.json for ESLint configuration.
521 lines
21 KiB
TypeScript
521 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useMemo, Suspense } from "react";
|
|
import { useSearchParams, useRouter } from "next/navigation";
|
|
import { PageLayout } from "@/components/layout/page-layout";
|
|
import { ShieldCheckIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
|
import { authenticatedApi } from "@/lib/api";
|
|
import { AddressConfirmation } from "@/components/checkout/address-confirmation";
|
|
import { usePaymentMethods } from "@/hooks/useInvoices";
|
|
|
|
import {
|
|
InternetPlan,
|
|
InternetAddon,
|
|
InternetInstallation,
|
|
SimPlan,
|
|
SimActivationFee,
|
|
SimAddon,
|
|
CheckoutState,
|
|
OrderItem,
|
|
buildInternetOrderItems,
|
|
buildSimOrderItems,
|
|
calculateTotals,
|
|
buildOrderSKUs,
|
|
} from "@/shared/types/catalog.types";
|
|
|
|
interface Address {
|
|
street: string | null;
|
|
streetLine2: string | null;
|
|
city: string | null;
|
|
state: string | null;
|
|
postalCode: string | null;
|
|
country: string | null;
|
|
}
|
|
|
|
function CheckoutContent() {
|
|
const params = useSearchParams();
|
|
const router = useRouter();
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [addressConfirmed, setAddressConfirmed] = useState(false);
|
|
const [confirmedAddress, setConfirmedAddress] = useState<Address | null>(null);
|
|
const [checkoutState, setCheckoutState] = useState<CheckoutState>({
|
|
loading: true,
|
|
error: null,
|
|
orderItems: [],
|
|
totals: { monthlyTotal: 0, oneTimeTotal: 0 },
|
|
});
|
|
|
|
// Fetch payment methods to check if user has payment method on file
|
|
const { data: paymentMethods, isLoading: paymentMethodsLoading, error: paymentMethodsError, refetch: refetchPaymentMethods } = usePaymentMethods();
|
|
|
|
const orderType = (() => {
|
|
const type = params.get("type") || "internet";
|
|
// Map to backend expected values
|
|
switch (type.toLowerCase()) {
|
|
case "sim":
|
|
return "SIM";
|
|
case "internet":
|
|
return "Internet";
|
|
case "vpn":
|
|
return "VPN";
|
|
default:
|
|
return "Other";
|
|
}
|
|
})();
|
|
|
|
const selections = useMemo(() => {
|
|
const obj: Record<string, string> = {};
|
|
params.forEach((v, k) => {
|
|
if (k !== "type") obj[k] = v;
|
|
});
|
|
return obj;
|
|
}, [params]);
|
|
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
void (async () => {
|
|
try {
|
|
setCheckoutState(prev => ({ ...prev, loading: true, error: null }));
|
|
|
|
// Validate required parameters
|
|
if (!selections.plan) {
|
|
throw new Error("No plan selected. Please go back and select a plan.");
|
|
}
|
|
|
|
let orderItems: OrderItem[] = [];
|
|
|
|
if (orderType === "Internet") {
|
|
// Fetch Internet data
|
|
const [plans, addons, installations] = await Promise.all([
|
|
authenticatedApi.get<InternetPlan[]>("/catalog/internet/plans"),
|
|
authenticatedApi.get<InternetAddon[]>("/catalog/internet/addons"),
|
|
authenticatedApi.get<InternetInstallation[]>("/catalog/internet/installations"),
|
|
]);
|
|
|
|
const plan = plans.find(p => p.sku === selections.plan);
|
|
if (!plan) {
|
|
throw new Error(
|
|
`Internet plan not found for SKU: ${selections.plan}. Please go back and select a valid plan.`
|
|
);
|
|
}
|
|
|
|
// Handle addon SKUs like SIM flow
|
|
const addonSkus: string[] = [];
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
urlParams.getAll("addonSku").forEach(sku => {
|
|
if (sku && !addonSkus.includes(sku)) {
|
|
addonSkus.push(sku);
|
|
}
|
|
});
|
|
|
|
orderItems = buildInternetOrderItems(plan, addons, installations, {
|
|
installationSku: selections.installationSku,
|
|
addonSkus: addonSkus.length > 0 ? addonSkus : undefined,
|
|
});
|
|
} else if (orderType === "SIM") {
|
|
// Fetch SIM data
|
|
const [plans, activationFees, addons] = await Promise.all([
|
|
authenticatedApi.get<SimPlan[]>("/catalog/sim/plans"),
|
|
authenticatedApi.get<SimActivationFee[]>("/catalog/sim/activation-fees"),
|
|
authenticatedApi.get<SimAddon[]>("/catalog/sim/addons"),
|
|
]);
|
|
|
|
const plan = plans.find(p => p.sku === selections.plan); // Look up by SKU instead of ID
|
|
if (!plan) {
|
|
throw new Error(
|
|
`SIM plan not found for SKU: ${selections.plan}. Please go back and select a valid plan.`
|
|
);
|
|
}
|
|
// Handle multiple addons from URL parameters
|
|
const addonSkus: string[] = [];
|
|
if (selections.addonSku) {
|
|
// Single addon (legacy support)
|
|
addonSkus.push(selections.addonSku);
|
|
}
|
|
// Check for multiple addonSku parameters
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
urlParams.getAll("addonSku").forEach(sku => {
|
|
if (sku && !addonSkus.includes(sku)) {
|
|
addonSkus.push(sku);
|
|
}
|
|
});
|
|
|
|
orderItems = buildSimOrderItems(plan, activationFees, addons, {
|
|
addonSkus: addonSkus.length > 0 ? addonSkus : undefined,
|
|
});
|
|
}
|
|
|
|
if (mounted) {
|
|
const totals = calculateTotals(orderItems);
|
|
setCheckoutState(prev => ({
|
|
...prev,
|
|
loading: false,
|
|
orderItems,
|
|
totals,
|
|
}));
|
|
}
|
|
} catch (error) {
|
|
if (mounted) {
|
|
setCheckoutState(prev => ({
|
|
...prev,
|
|
loading: false,
|
|
error: error instanceof Error ? error.message : "Failed to load checkout data",
|
|
}));
|
|
}
|
|
}
|
|
})();
|
|
return () => {
|
|
mounted = false;
|
|
};
|
|
}, [orderType, selections]);
|
|
|
|
const handleSubmitOrder = async () => {
|
|
try {
|
|
setSubmitting(true);
|
|
|
|
const skus = buildOrderSKUs(checkoutState.orderItems);
|
|
|
|
// Validate we have SKUs before proceeding
|
|
if (!skus || skus.length === 0) {
|
|
throw new Error("No products selected for order. Please go back and select products.");
|
|
}
|
|
|
|
// Send SKUs + configurations - backend resolves product data from SKUs,
|
|
// uses configurations for fields that cannot be inferred
|
|
const configurations: Record<string, unknown> = {};
|
|
|
|
// Extract configurations from URL params (these come from configure pages)
|
|
if (selections.accessMode) configurations.accessMode = selections.accessMode;
|
|
if (selections.simType) configurations.simType = selections.simType;
|
|
if (selections.eid) configurations.eid = selections.eid;
|
|
// VPN region is inferred from product VPN_Region__c field, no configuration needed
|
|
if (selections.activationType) configurations.activationType = selections.activationType;
|
|
if (selections.scheduledAt) configurations.scheduledAt = selections.scheduledAt;
|
|
|
|
// MNP fields (must match backend field expectations exactly)
|
|
if (selections.isMnp) configurations.isMnp = selections.isMnp;
|
|
if (selections.reservationNumber) configurations.mnpNumber = selections.reservationNumber;
|
|
if (selections.expiryDate) configurations.mnpExpiry = selections.expiryDate;
|
|
if (selections.phoneNumber) configurations.mnpPhone = selections.phoneNumber;
|
|
if (selections.mvnoAccountNumber)
|
|
configurations.mvnoAccountNumber = selections.mvnoAccountNumber;
|
|
if (selections.portingLastName) configurations.portingLastName = selections.portingLastName;
|
|
if (selections.portingFirstName)
|
|
configurations.portingFirstName = selections.portingFirstName;
|
|
if (selections.portingLastNameKatakana)
|
|
configurations.portingLastNameKatakana = selections.portingLastNameKatakana;
|
|
if (selections.portingFirstNameKatakana)
|
|
configurations.portingFirstNameKatakana = selections.portingFirstNameKatakana;
|
|
if (selections.portingGender) configurations.portingGender = selections.portingGender;
|
|
if (selections.portingDateOfBirth)
|
|
configurations.portingDateOfBirth = selections.portingDateOfBirth;
|
|
|
|
// Include address in configurations if it was updated during checkout
|
|
if (confirmedAddress) {
|
|
configurations.address = confirmedAddress;
|
|
}
|
|
|
|
const orderData = {
|
|
orderType,
|
|
skus: skus,
|
|
...(Object.keys(configurations).length > 0 && { configurations }),
|
|
};
|
|
|
|
const response = await authenticatedApi.post<{ sfOrderId: string }>("/orders", orderData);
|
|
router.push(`/orders/${response.sfOrderId}?status=success`);
|
|
} catch (error) {
|
|
console.error("Order submission failed:", error);
|
|
|
|
let errorMessage = "Order submission failed";
|
|
if (error instanceof Error) {
|
|
errorMessage = error.message;
|
|
}
|
|
|
|
setCheckoutState(prev => ({
|
|
...prev,
|
|
error: errorMessage,
|
|
}));
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleAddressConfirmed = (address?: Address) => {
|
|
setAddressConfirmed(true);
|
|
setConfirmedAddress(address || null);
|
|
};
|
|
|
|
const handleAddressIncomplete = () => {
|
|
setAddressConfirmed(false);
|
|
setConfirmedAddress(null);
|
|
};
|
|
|
|
if (checkoutState.loading) {
|
|
return (
|
|
<PageLayout
|
|
title="Submit Order"
|
|
description="Loading order details"
|
|
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
|
>
|
|
<div className="text-center py-12">Loading order submission...</div>
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
if (checkoutState.error) {
|
|
return (
|
|
<PageLayout
|
|
title="Submit Order"
|
|
description="Error loading order submission"
|
|
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
|
>
|
|
<div className="text-center py-12">
|
|
<p className="text-red-600 mb-4">{checkoutState.error}</p>
|
|
<button onClick={() => router.back()} className="text-blue-600 hover:text-blue-800">
|
|
Go Back
|
|
</button>
|
|
</div>
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<PageLayout
|
|
title="Submit Order"
|
|
description="Submit your order for review and approval"
|
|
icon={<ShieldCheckIcon className="h-6 w-6" />}
|
|
>
|
|
<div className="max-w-2xl mx-auto">
|
|
{/* Address Confirmation */}
|
|
<AddressConfirmation
|
|
onAddressConfirmed={handleAddressConfirmed}
|
|
onAddressIncomplete={handleAddressIncomplete}
|
|
orderType={orderType}
|
|
/>
|
|
|
|
{/* Order Submission Message */}
|
|
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 border border-blue-200 rounded-xl p-6 mb-6 text-center">
|
|
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
<ShieldCheckIcon className="w-8 h-8 text-blue-600" />
|
|
</div>
|
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">Submit Your Order for Review</h2>
|
|
<p className="text-gray-600 mb-4">
|
|
You've configured your service and reviewed all details. Your order will be
|
|
submitted for review and approval.
|
|
</p>
|
|
<div className="bg-white rounded-lg p-4 border border-blue-200">
|
|
<h3 className="font-semibold text-gray-900 mb-2">What happens next?</h3>
|
|
<div className="text-sm text-gray-600 space-y-1">
|
|
<p>• Your order will be reviewed by our team</p>
|
|
<p>• We'll set up your services in our system</p>
|
|
<p>• Payment will be processed using your card on file</p>
|
|
<p>• You'll receive confirmation once everything is ready</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quick Totals Summary */}
|
|
<div className="mt-4 bg-white rounded-lg p-4 border border-gray-200">
|
|
<div className="flex justify-between items-center">
|
|
<span className="font-medium text-gray-700">Total:</span>
|
|
<div className="text-right">
|
|
<div className="text-xl font-bold text-gray-900">
|
|
¥{checkoutState.totals.monthlyTotal.toLocaleString()}/mo
|
|
</div>
|
|
{checkoutState.totals.oneTimeTotal > 0 && (
|
|
<div className="text-sm text-orange-600 font-medium">
|
|
+ ¥{checkoutState.totals.oneTimeTotal.toLocaleString()} one-time
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white border border-gray-200 rounded-xl p-6 mb-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Billing Information</h3>
|
|
{paymentMethodsLoading ? (
|
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
|
|
<span className="text-gray-600 text-sm">Checking payment methods...</span>
|
|
</div>
|
|
</div>
|
|
) : paymentMethodsError ? (
|
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
|
<div className="flex items-start gap-3">
|
|
<ExclamationTriangleIcon className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
|
|
<div className="flex-1">
|
|
<p className="text-amber-800 text-sm font-medium">Unable to verify payment methods</p>
|
|
<p className="text-amber-700 text-sm mt-1">
|
|
We couldn't check your payment methods. If you just added a payment method, try refreshing.
|
|
</p>
|
|
<div className="flex gap-2 mt-2">
|
|
<button
|
|
onClick={async () => {
|
|
try {
|
|
// First try to refresh cache on backend
|
|
await authenticatedApi.post('/invoices/payment-methods/refresh');
|
|
console.log('Backend cache refreshed successfully');
|
|
} catch (error) {
|
|
console.warn('Backend cache refresh failed, using frontend refresh:', error);
|
|
}
|
|
|
|
// Always refetch from frontend to get latest data
|
|
try {
|
|
await refetchPaymentMethods();
|
|
console.log('Frontend cache refreshed successfully');
|
|
} catch (error) {
|
|
console.error('Frontend refresh also failed:', error);
|
|
}
|
|
}}
|
|
className="bg-amber-600 text-white px-3 py-1 rounded text-sm hover:bg-amber-700 transition-colors"
|
|
>
|
|
Refresh Cache
|
|
</button>
|
|
<button
|
|
onClick={() => router.push('/billing/payments')}
|
|
className="bg-blue-600 text-white px-3 py-1 rounded text-sm hover:bg-blue-700 transition-colors"
|
|
>
|
|
Add Payment Method
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : paymentMethods && paymentMethods.paymentMethods.length > 0 ? (
|
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
|
<div className="flex items-start gap-3">
|
|
<div className="w-5 h-5 bg-green-500 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
|
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<p className="text-green-800 text-sm font-medium">Payment method verified</p>
|
|
<p className="text-green-700 text-sm mt-1">
|
|
After order approval, payment will be automatically processed using your existing
|
|
payment method on file. No additional payment steps required.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
|
<div className="flex items-start gap-3">
|
|
<ExclamationTriangleIcon className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
|
|
<div>
|
|
<p className="text-red-800 text-sm font-medium">No payment method on file</p>
|
|
<p className="text-red-700 text-sm mt-1">
|
|
You need to add a payment method before submitting your order. Please add a credit card or other payment method to proceed.
|
|
</p>
|
|
<button
|
|
onClick={() => router.push('/billing/payments')}
|
|
className="mt-2 bg-red-600 text-white px-3 py-1 rounded text-sm hover:bg-red-700 transition-colors"
|
|
>
|
|
Add Payment Method
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Debug Info - Remove in production */}
|
|
<div className="bg-gray-100 border rounded-lg p-3 mb-4 text-xs text-gray-600">
|
|
<strong>Debug Info:</strong> Address Confirmed: {addressConfirmed ? '✅' : '❌'} |
|
|
Payment Methods: {paymentMethodsLoading ? '⏳ Loading...' : paymentMethodsError ? '❌ Error' : paymentMethods ? `✅ ${paymentMethods.paymentMethods.length} found` : '❌ None'} |
|
|
Order Items: {checkoutState.orderItems.length} |
|
|
Can Submit: {!(
|
|
submitting ||
|
|
checkoutState.orderItems.length === 0 ||
|
|
!addressConfirmed ||
|
|
paymentMethodsLoading ||
|
|
!paymentMethods ||
|
|
paymentMethods.paymentMethods.length === 0
|
|
) ? '✅' : '❌'}
|
|
</div>
|
|
|
|
<div className="flex gap-4">
|
|
<button
|
|
onClick={() => {
|
|
// Construct the configure URL with current parameters to preserve data
|
|
// Add step parameter to go directly to review step
|
|
const urlParams = new URLSearchParams(params.toString());
|
|
const reviewStep = orderType === "Internet" ? "4" : "5";
|
|
urlParams.set("step", reviewStep);
|
|
|
|
const configureUrl =
|
|
orderType === "Internet"
|
|
? `/catalog/internet/configure?${urlParams.toString()}`
|
|
: `/catalog/sim/configure?${urlParams.toString()}`;
|
|
router.push(configureUrl);
|
|
}}
|
|
className="flex-1 px-6 py-4 border border-gray-300 rounded-lg text-center hover:bg-gray-50 transition-colors font-medium"
|
|
>
|
|
← Back to Review
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => void handleSubmitOrder()}
|
|
disabled={
|
|
submitting ||
|
|
checkoutState.orderItems.length === 0 ||
|
|
!addressConfirmed ||
|
|
paymentMethodsLoading ||
|
|
!paymentMethods ||
|
|
paymentMethods.paymentMethods.length === 0
|
|
}
|
|
className="flex-1 px-6 py-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 transition-colors font-semibold text-lg shadow-md hover:shadow-lg"
|
|
>
|
|
{submitting ? (
|
|
<span className="flex items-center justify-center">
|
|
<svg
|
|
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<circle
|
|
className="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
strokeWidth="4"
|
|
></circle>
|
|
<path
|
|
className="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
></path>
|
|
</svg>
|
|
Submitting Order...
|
|
</span>
|
|
) : !addressConfirmed ? (
|
|
"📍 Complete Address to Continue"
|
|
) : paymentMethodsLoading ? (
|
|
"⏳ Verifying Payment Method..."
|
|
) : !paymentMethods || paymentMethods.paymentMethods.length === 0 ? (
|
|
"💳 Add Payment Method to Continue"
|
|
) : (
|
|
"📋 Submit Order for Review"
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
export default function CheckoutPage() {
|
|
return (
|
|
<Suspense fallback={<div className="text-center py-12">Loading checkout...</div>}>
|
|
<CheckoutContent />
|
|
</Suspense>
|
|
);
|
|
}
|