- Moved metrics tracking and logging from the queueing phase to the execution phase in SalesforceRequestQueueService for better accuracy. - Updated CSRF token generation in CsrfController to accept parameters in a more flexible manner. - Enhanced CacheService to handle immediate expiry requests without leaking stale values. - Improved error handling and re-authentication logic in SalesforceConnection for better resilience during session expiration. - Refactored logout functionality in AuthFacade to handle optional userId and improve logging during token revocation. - Updated AuthController to apply rate limit headers and improved type handling in various request contexts. - Streamlined imports and improved overall code organization across multiple modules for better maintainability.
256 lines
10 KiB
TypeScript
256 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { PageLayout } from "@/components/templates/PageLayout";
|
|
import { ErrorBoundary } from "@/components/molecules";
|
|
import { useSession } from "@/features/auth/hooks";
|
|
import { useAuthStore } from "@/features/auth/services/auth.store";
|
|
|
|
import { isApiError } from "@/lib/api";
|
|
import { openSsoLink } from "@/features/billing/utils/sso";
|
|
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
|
|
import {
|
|
PaymentMethodCard,
|
|
usePaymentMethods,
|
|
useCreatePaymentMethodsSsoLink,
|
|
} from "@/features/billing";
|
|
import { CreditCardIcon } from "@heroicons/react/24/outline";
|
|
import { InlineToast } from "@/components/atoms/inline-toast";
|
|
import { Button } from "@/components/atoms/button";
|
|
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
|
|
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
|
import { logger } from "@/lib/logger";
|
|
|
|
export function PaymentMethodsContainer() {
|
|
const [error, setError] = useState<string | null>(null);
|
|
const { isAuthenticated } = useSession();
|
|
|
|
const paymentMethodsQuery = usePaymentMethods();
|
|
const {
|
|
data: paymentMethodsData,
|
|
isLoading: isLoadingPaymentMethods,
|
|
isFetching: isFetchingPaymentMethods,
|
|
error: paymentMethodsError,
|
|
} = paymentMethodsQuery;
|
|
|
|
const createPaymentMethodsSsoLink = useCreatePaymentMethodsSsoLink();
|
|
|
|
// Auth hydration flag to avoid showing empty state before auth is checked
|
|
const { hasCheckedAuth } = useAuthStore();
|
|
|
|
const paymentRefresh = usePaymentRefresh({
|
|
refetch: async () => {
|
|
const result = await paymentMethodsQuery.refetch();
|
|
return { data: result.data };
|
|
},
|
|
hasMethods: data => Boolean(data && (data.totalCount > 0 || data.paymentMethods.length > 0)),
|
|
attachFocusListeners: true,
|
|
});
|
|
|
|
const openPaymentMethods = async () => {
|
|
if (!isAuthenticated) {
|
|
setError("Please log in to access payment methods.");
|
|
return;
|
|
}
|
|
|
|
setError(null);
|
|
|
|
try {
|
|
const ssoLink = await createPaymentMethodsSsoLink.mutateAsync();
|
|
openSsoLink(ssoLink.url, { newTab: true });
|
|
} catch (err: unknown) {
|
|
logger.error(err, "Failed to open payment methods");
|
|
// Check if error looks like an API error with response
|
|
if (
|
|
isApiError(err) &&
|
|
"response" in err &&
|
|
typeof err.response === "object" &&
|
|
err.response !== null &&
|
|
"status" in err.response &&
|
|
err.response.status === 401
|
|
) {
|
|
setError("Authentication failed. Please log in again.");
|
|
} else {
|
|
setError("Unable to access payment methods. Please try again later.");
|
|
}
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
// Placeholder hook for future logic when returning from WHMCS
|
|
}, [isAuthenticated]);
|
|
|
|
const combinedError = error
|
|
? new Error(error)
|
|
: paymentMethodsError instanceof Error
|
|
? paymentMethodsError
|
|
: paymentMethodsError
|
|
? new Error(String(paymentMethodsError))
|
|
: null;
|
|
|
|
if (combinedError) {
|
|
return (
|
|
<PageLayout
|
|
icon={<CreditCardIcon />}
|
|
title="Payment Methods"
|
|
description="Manage your saved payment methods and billing information"
|
|
>
|
|
<AsyncBlock error={combinedError} variant="page">
|
|
<></>
|
|
</AsyncBlock>
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<PageLayout
|
|
icon={<CreditCardIcon />}
|
|
title="Payment Methods"
|
|
description="Manage your saved payment methods and billing information"
|
|
>
|
|
<ErrorBoundary>
|
|
<InlineToast
|
|
visible={paymentRefresh.toast.visible}
|
|
text={paymentRefresh.toast.text}
|
|
tone={paymentRefresh.toast.tone}
|
|
/>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-4 xl:grid-cols-3 gap-6">
|
|
<div className="lg:col-span-3 xl:col-span-2">
|
|
{!hasCheckedAuth || isLoadingPaymentMethods || isFetchingPaymentMethods ? (
|
|
<div className="space-y-6">
|
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-6 w-48" />
|
|
<Skeleton className="h-4 w-32" />
|
|
</div>
|
|
<Skeleton className="h-10 w-32" />
|
|
</div>
|
|
<div className="space-y-4">
|
|
{Array.from({ length: 2 }).map((_, i) => (
|
|
<div key={i} className="bg-gray-50 rounded-lg p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<Skeleton className="h-12 w-12 rounded-lg" />
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-5 w-40" />
|
|
<Skeleton className="h-4 w-24" />
|
|
</div>
|
|
</div>
|
|
<Skeleton className="h-9 w-28" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : paymentMethodsData && paymentMethodsData.paymentMethods.length > 0 ? (
|
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
|
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 px-6 py-5 border-b border-gray-200">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-xl font-semibold text-gray-900">Your Payment Methods</h2>
|
|
<p className="text-sm text-gray-600 mt-1">
|
|
{paymentMethodsData.paymentMethods.length} payment method
|
|
{paymentMethodsData.paymentMethods.length !== 1 ? "s" : ""} on file
|
|
</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<Button
|
|
onClick={() => {
|
|
void openPaymentMethods();
|
|
}}
|
|
disabled={createPaymentMethodsSsoLink.isPending}
|
|
size="default"
|
|
className="bg-blue-600 text-white hover:bg-blue-700 shadow-sm font-medium px-6"
|
|
>
|
|
{createPaymentMethodsSsoLink.isPending ? "Opening..." : "Manage Cards"}
|
|
</Button>
|
|
<p className="text-xs text-gray-500 mt-1">Opens in a new tab for security</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-6">
|
|
<div className="space-y-4">
|
|
{paymentMethodsData.paymentMethods.map(paymentMethod => (
|
|
<PaymentMethodCard
|
|
key={paymentMethod.id}
|
|
paymentMethod={paymentMethod}
|
|
className="focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
|
{!hasCheckedAuth && !paymentMethodsData ? (
|
|
<div className="p-12">
|
|
<AsyncBlock isLoading loadingText="Loading payment methods...">
|
|
<></>
|
|
</AsyncBlock>
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-16 px-6">
|
|
<div className="mx-auto w-24 h-24 bg-gray-100 rounded-full flex items-center justify-center mb-6">
|
|
<CreditCardIcon className="h-12 w-12 text-gray-400" />
|
|
</div>
|
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">No Payment Methods</h3>
|
|
<p className="text-gray-600 mb-8 max-w-md mx-auto">
|
|
Open the billing portal to add a card.
|
|
</p>
|
|
<div className="space-y-3">
|
|
<Button
|
|
onClick={() => void openPaymentMethods()}
|
|
disabled={createPaymentMethodsSsoLink.isPending}
|
|
size="lg"
|
|
className="bg-blue-600 hover:bg-blue-700 text-white font-medium px-8"
|
|
>
|
|
{createPaymentMethodsSsoLink.isPending ? "Opening..." : "Manage Cards"}
|
|
</Button>
|
|
<p className="text-sm text-gray-500">Opens in a new tab for security</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="lg:col-span-1 xl:col-span-1">
|
|
<div className="space-y-6 sticky top-6">
|
|
<div className="bg-blue-50 rounded-lg p-4 border border-blue-200">
|
|
<div className="flex items-start">
|
|
<div className="flex-shrink-0">
|
|
<CreditCardIcon className="h-5 w-5 text-blue-400" />
|
|
</div>
|
|
<div className="ml-3">
|
|
<h3 className="text-sm font-medium text-blue-800">Secure & Encrypted</h3>
|
|
<p className="text-sm text-blue-700 mt-1">
|
|
All payment information is securely encrypted and protected with
|
|
industry-standard security.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
|
<h3 className="text-sm font-medium text-gray-800 mb-2">
|
|
Supported Payment Methods
|
|
</h3>
|
|
<ul className="text-sm text-gray-600 space-y-1">
|
|
<li>• Credit Cards (Visa, MasterCard, American Express)</li>
|
|
<li>• Debit Cards</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</ErrorBoundary>
|
|
</PageLayout>
|
|
);
|
|
}
|
|
|
|
export default PaymentMethodsContainer;
|