barsa 7d290c814d feat: enhance authentication and billing components for improved user experience
- Added rate limiting to the AuthController to prevent abuse of authentication endpoints.
- Updated OtpInput component to simplify completion logic for better usability.
- Refactored ForgotPasswordView to improve email confirmation handling and user feedback.
- Enhanced PaymentMethods components with refresh functionality for better payment management.
- Made minor UI adjustments across various components for improved consistency and clarity.
2026-03-06 17:52:57 +09:00

308 lines
11 KiB
TypeScript

"use client";
import { useState } from "react";
import { PageLayout } from "@/components/templates/PageLayout";
import { ErrorBoundary } from "@/components/molecules";
import { useSession } from "@/features/auth/hooks";
import { useAuthStore } from "@/features/auth/stores/auth.store";
import { isApiError } from "@/core/api";
import { openSsoLink } from "@/features/billing/utils/sso";
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
import {
PaymentMethodCard,
usePaymentMethods,
useCreatePaymentMethodsSsoLink,
} from "@/features/billing";
import type { PaymentMethodList } from "@customer-portal/domain/payments";
import { CreditCardIcon, ArrowPathIcon } 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 "@/core/logger";
function PaymentMethodsSkeleton() {
return (
<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>
);
}
function RefreshButton({ onClick, isRefreshing }: { onClick: () => void; isRefreshing: boolean }) {
return (
<Button variant="outline" size="default" onClick={onClick} disabled={isRefreshing}>
<ArrowPathIcon className={`h-4 w-4 mr-1.5 ${isRefreshing ? "animate-spin" : ""}`} />
Refresh
</Button>
);
}
function ManageCardsButton({ onClick, isPending }: { onClick: () => void; isPending: boolean }) {
return (
<Button
onClick={onClick}
disabled={isPending}
size="default"
className="bg-blue-600 text-white hover:bg-blue-700 shadow-sm font-medium px-6"
>
{isPending ? "Opening..." : "Manage Cards"}
</Button>
);
}
function PaymentMethodsListView({
paymentMethodsData,
onManage,
isPending,
onRefresh,
isRefreshing,
}: {
paymentMethodsData: PaymentMethodList;
onManage: () => void;
isPending: boolean;
onRefresh: () => void;
isRefreshing: boolean;
}) {
return (
<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="flex items-center gap-2">
<RefreshButton onClick={onRefresh} isRefreshing={isRefreshing} />
<ManageCardsButton onClick={onManage} isPending={isPending} />
</div>
</div>
</div>
<div className="p-6">
<div className="space-y-4">
{paymentMethodsData.paymentMethods.map(
(paymentMethod: (typeof paymentMethodsData.paymentMethods)[number]) => (
<PaymentMethodCard
key={paymentMethod.id}
paymentMethod={paymentMethod}
className="focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
/>
)
)}
</div>
</div>
</div>
);
}
function PaymentMethodsEmptyState({
onManage,
isPending,
onRefresh,
isRefreshing,
}: {
onManage: () => void;
isPending: boolean;
onRefresh: () => void;
isRefreshing: boolean;
}) {
return (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<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">
<div className="flex items-center justify-center gap-2">
<RefreshButton onClick={onRefresh} isRefreshing={isRefreshing} />
<Button
onClick={onManage}
disabled={isPending}
size="default"
className="bg-blue-600 hover:bg-blue-700 text-white font-medium px-8"
>
{isPending ? "Opening..." : "Manage Cards"}
</Button>
</div>
<p className="text-sm text-gray-500">Opens in a new tab for security</p>
</div>
</div>
</div>
);
}
function PaymentMethodsSidebar() {
return (
<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>
);
}
function getCombinedError(error: string | null, paymentMethodsError: unknown): Error | null {
if (error) return new Error(error);
if (paymentMethodsError instanceof Error) return paymentMethodsError;
if (paymentMethodsError) return new Error(String(paymentMethodsError));
return null;
}
function getOpenPaymentMethodsError(err: unknown): string {
if (
isApiError(err) &&
"response" in err &&
typeof err.response === "object" &&
err.response !== null &&
"status" in err.response &&
err.response.status === 401
) {
return "Authentication failed. Please log in again.";
}
return "Unable to access payment methods. Please try again later.";
}
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();
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)),
});
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("Failed to open payment methods", err);
setError(getOpenPaymentMethodsError(err));
}
};
const combinedError = getCombinedError(error, paymentMethodsError);
if (combinedError) {
return (
<PageLayout icon={<CreditCardIcon />} title="Payment Methods">
<AsyncBlock error={combinedError} variant="page">
<></>
</AsyncBlock>
</PageLayout>
);
}
const isDataLoading = !hasCheckedAuth || isLoadingPaymentMethods || isFetchingPaymentMethods;
const hasMethods = paymentMethodsData && paymentMethodsData.paymentMethods.length > 0;
const handleManage = () => {
void openPaymentMethods();
};
return (
<PageLayout icon={<CreditCardIcon />} title="Payment Methods">
<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">
{isDataLoading && <PaymentMethodsSkeleton />}
{!isDataLoading && hasMethods && (
<PaymentMethodsListView
paymentMethodsData={paymentMethodsData}
onManage={handleManage}
isPending={createPaymentMethodsSsoLink.isPending}
onRefresh={() => void paymentRefresh.triggerRefresh()}
isRefreshing={paymentRefresh.isRefreshing}
/>
)}
{!isDataLoading && !hasMethods && (
<PaymentMethodsEmptyState
onManage={handleManage}
isPending={createPaymentMethodsSsoLink.isPending}
onRefresh={() => void paymentRefresh.triggerRefresh()}
isRefreshing={paymentRefresh.isRefreshing}
/>
)}
</div>
<PaymentMethodsSidebar />
</div>
</ErrorBoundary>
</PageLayout>
);
}
export default PaymentMethodsContainer;