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.
This commit is contained in:
barsa 2026-03-06 17:52:57 +09:00
parent de5a210e6f
commit 7d290c814d
13 changed files with 162 additions and 125 deletions

View File

@ -74,6 +74,8 @@ class CheckPasswordNeededResponseDto extends createZodDto(checkPasswordNeededRes
class LoginVerifyOtpRequestDto extends createZodDto(loginVerifyOtpRequestSchema) {} class LoginVerifyOtpRequestDto extends createZodDto(loginVerifyOtpRequestSchema) {}
@Controller("auth") @Controller("auth")
@UseGuards(RateLimitGuard)
@RateLimit({ limit: 10, ttl: 300 }) // 10 requests per 5 minutes per IP+UA (controller default)
export class AuthController { export class AuthController {
constructor( constructor(
private authOrchestrator: AuthOrchestrator, private authOrchestrator: AuthOrchestrator,
@ -181,8 +183,6 @@ export class AuthController {
*/ */
@Public() @Public()
@Post("login/verify-otp") @Post("login/verify-otp")
@UseGuards(RateLimitGuard)
@RateLimit({ limit: 10, ttl: 300 }) // 10 attempts per 5 minutes per IP
@HttpCode(200) @HttpCode(200)
async verifyLoginOtp( async verifyLoginOtp(
@Body() body: LoginVerifyOtpRequestDto, @Body() body: LoginVerifyOtpRequestDto,
@ -260,8 +260,6 @@ export class AuthController {
@Public() @Public()
@Post("refresh") @Post("refresh")
@UseGuards(RateLimitGuard)
@RateLimit({ limit: 10, ttl: 300 }) // 10 attempts per 5 minutes per IP
async refreshToken( async refreshToken(
@Body() body: RefreshTokenRequestDto, @Body() body: RefreshTokenRequestDto,
@Req() req: RequestWithCookies, @Req() req: RequestWithCookies,
@ -280,8 +278,6 @@ export class AuthController {
@Public() @Public()
@Post("set-password") @Post("set-password")
@UseGuards(RateLimitGuard)
@RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP+UA (industry standard)
async setPassword( async setPassword(
@Body() setPasswordData: SetPasswordRequestDto, @Body() setPasswordData: SetPasswordRequestDto,
@Req() _req: Request, @Req() _req: Request,
@ -309,8 +305,6 @@ export class AuthController {
@Public() @Public()
@Post("request-password-reset") @Post("request-password-reset")
@UseGuards(RateLimitGuard)
@RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations)
async requestPasswordReset(@Body() body: PasswordResetRequestDto, @Req() req: Request) { async requestPasswordReset(@Body() body: PasswordResetRequestDto, @Req() req: Request) {
await this.passwordWorkflow.requestPasswordReset(body.email, req); await this.passwordWorkflow.requestPasswordReset(body.email, req);
return { message: "If an account exists, a reset email has been sent" }; return { message: "If an account exists, a reset email has been sent" };
@ -319,8 +313,6 @@ export class AuthController {
@Public() @Public()
@Post("reset-password") @Post("reset-password")
@HttpCode(200) @HttpCode(200)
@UseGuards(RateLimitGuard)
@RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations)
async resetPassword( async resetPassword(
@Body() body: ResetPasswordRequestDto, @Body() body: ResetPasswordRequestDto,
@Res({ passthrough: true }) res: Response @Res({ passthrough: true }) res: Response
@ -333,8 +325,6 @@ export class AuthController {
} }
@Post("change-password") @Post("change-password")
@UseGuards(RateLimitGuard)
@RateLimit({ limit: 5, ttl: 300 }) // 5 attempts per 5 minutes
async changePassword( async changePassword(
@Req() req: Request & { user: { id: string } }, @Req() req: Request & { user: { id: string } },
@Body() body: ChangePasswordRequestDto, @Body() body: ChangePasswordRequestDto,

View File

@ -65,7 +65,7 @@ function useOtpHandlers({
const newValue = newDigits.join(""); const newValue = newDigits.join("");
onChange(newValue); onChange(newValue);
if (char && index < length - 1) focusInput(index + 1); if (char && index < length - 1) focusInput(index + 1);
if (newValue.length === length && !newValue.includes("")) onComplete?.(newValue); if (newValue.length === length) onComplete?.(newValue);
}, },
[digits, length, onChange, onComplete, focusInput] [digits, length, onChange, onComplete, focusInput]
); );

View File

@ -5,6 +5,7 @@ import { Skeleton } from "@/components/atoms/loading-skeleton";
import { ErrorState } from "@/components/atoms/error-state"; import { ErrorState } from "@/components/atoms/error-state";
interface PageLayoutProps { interface PageLayoutProps {
/** @deprecated Icons no longer rendered in page headers */
icon?: ReactNode | undefined; icon?: ReactNode | undefined;
title: string; title: string;
description?: string | undefined; description?: string | undefined;
@ -19,7 +20,6 @@ interface PageLayoutProps {
} }
export function PageLayout({ export function PageLayout({
icon,
title, title,
description, description,
actions, actions,
@ -47,8 +47,7 @@ export function PageLayout({
{backLink.label} {backLink.label}
</Link> </Link>
)} )}
{icon && <div className="h-6 w-6 text-primary mr-2.5 flex-shrink-0">{icon}</div>} <h1 className="text-xl font-bold text-foreground leading-tight truncate">{title}</h1>
<h1 className="text-lg font-bold text-foreground leading-tight truncate">{title}</h1>
{statusPill && <div className="ml-2.5 flex-shrink-0">{statusPill}</div>} {statusPill && <div className="ml-2.5 flex-shrink-0">{statusPill}</div>}
{description && ( {description && (
<p className="hidden sm:block text-sm text-muted-foreground ml-3 truncate"> <p className="hidden sm:block text-sm text-muted-foreground ml-3 truncate">

View File

@ -2,7 +2,7 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import Link from "next/link"; import Link from "next/link";
import { Mail } from "lucide-react"; import { Mail, ArrowLeft, Clock, CheckCircle2 } from "lucide-react";
import { AuthLayout } from "../components"; import { AuthLayout } from "../components";
import { PasswordResetForm } from "@/features/auth/components"; import { PasswordResetForm } from "@/features/auth/components";
import { Button, AnimatedContainer } from "@/components/atoms"; import { Button, AnimatedContainer } from "@/components/atoms";
@ -10,12 +10,22 @@ import { useAuthStore } from "../stores/auth.store";
const RESEND_COOLDOWN_SECONDS = 60; const RESEND_COOLDOWN_SECONDS = 60;
function ResendCountdown({ email, onBackToForm }: { email: string; onBackToForm: () => void }) { function EmailSentConfirmation({
const [secondsLeft, setSecondsLeft] = useState(RESEND_COOLDOWN_SECONDS); email,
cooldownEndRef,
onBackToForm,
}: {
email: string;
cooldownEndRef: React.RefObject<number>;
onBackToForm: () => void;
}) {
const [secondsLeft, setSecondsLeft] = useState(() =>
Math.max(0, Math.ceil((cooldownEndRef.current - Date.now()) / 1000))
);
const [resending, setResending] = useState(false); const [resending, setResending] = useState(false);
const [resendError, setResendError] = useState<string | null>(null); const [resendError, setResendError] = useState<string | null>(null);
const [resendCount, setResendCount] = useState(0);
const requestPasswordReset = useAuthStore(state => state.requestPasswordReset); const requestPasswordReset = useAuthStore(state => state.requestPasswordReset);
const cooldownEndRef = useRef(Date.now() + RESEND_COOLDOWN_SECONDS * 1000);
useEffect(() => { useEffect(() => {
const update = () => { const update = () => {
@ -25,7 +35,7 @@ function ResendCountdown({ email, onBackToForm }: { email: string; onBackToForm:
update(); update();
const interval = setInterval(update, 1000); const interval = setInterval(update, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, [cooldownEndRef]);
const handleResend = useCallback(async () => { const handleResend = useCallback(async () => {
setResending(true); setResending(true);
@ -34,30 +44,44 @@ function ResendCountdown({ email, onBackToForm }: { email: string; onBackToForm:
await requestPasswordReset(email); await requestPasswordReset(email);
cooldownEndRef.current = Date.now() + RESEND_COOLDOWN_SECONDS * 1000; cooldownEndRef.current = Date.now() + RESEND_COOLDOWN_SECONDS * 1000;
setSecondsLeft(RESEND_COOLDOWN_SECONDS); setSecondsLeft(RESEND_COOLDOWN_SECONDS);
setResendCount(c => c + 1);
} catch { } catch {
setResendError("Failed to resend. Please try again."); setResendError("Failed to resend. Please try again.");
} finally { } finally {
setResending(false); setResending(false);
} }
}, [email, requestPasswordReset]); }, [email, requestPasswordReset, cooldownEndRef]);
const canResend = secondsLeft === 0 && !resending; const canResend = secondsLeft === 0 && !resending;
return ( return (
<AnimatedContainer animation="fade-up"> <AnimatedContainer animation="fade-up">
<div className="space-y-6 text-center"> <div className="w-full space-y-6">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-primary/10"> <div className="text-center space-y-2">
<Mail className="h-7 w-7 text-primary" /> <div className="mx-auto w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
</div> <Mail className="w-6 h-6 text-primary" />
</div>
<div className="space-y-2"> <h2 className="text-xl font-semibold text-foreground">Check your email</h2>
<h3 className="text-lg font-semibold text-foreground">Check your email</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
We sent a password reset link to{" "} We sent a password reset link to{" "}
<span className="font-medium text-foreground">{email}</span> <span className="font-medium text-foreground">{email}</span>
</p> </p>
</div> </div>
<div className="rounded-lg border border-border bg-muted/30 p-4 space-y-3">
<div className="flex items-start gap-3">
<CheckCircle2 className="w-5 h-5 text-green-500 mt-0.5 shrink-0" />
<div className="text-sm text-muted-foreground space-y-1">
<p>Click the link in the email to reset your password.</p>
<p>The link expires in 15 minutes.</p>
</div>
</div>
</div>
{resendCount > 0 && (
<p className="text-xs text-center text-green-600">Reset link resent successfully</p>
)}
<div className="space-y-3"> <div className="space-y-3">
{canResend ? ( {canResend ? (
<Button <Button
@ -70,27 +94,35 @@ function ResendCountdown({ email, onBackToForm }: { email: string; onBackToForm:
Resend reset link Resend reset link
</Button> </Button>
) : ( ) : (
<p className="text-sm text-muted-foreground">Resend available in {secondsLeft}s</p> <div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Clock className="w-4 h-4" />
<span>Resend available in {secondsLeft}s</span>
</div>
)} )}
{resendError && <p className="text-sm text-red-600">{resendError}</p>} {resendError && <p className="text-sm text-center text-red-600">{resendError}</p>}
</div> </div>
<div className="flex flex-col gap-2"> <div className="space-y-2">
<button <button
type="button" type="button"
onClick={onBackToForm} onClick={onBackToForm}
className="text-sm text-muted-foreground hover:text-foreground transition-colors duration-[var(--cp-duration-normal)]" className="flex items-center justify-center gap-2 w-full text-sm text-muted-foreground hover:text-foreground transition-colors duration-200"
> >
Try a different email <ArrowLeft className="w-4 h-4" />
<span>Try a different email</span>
</button> </button>
<Link <Link
href="/auth/login" href="/auth/login"
className="text-sm text-primary hover:underline font-medium transition-colors duration-[var(--cp-duration-normal)]" className="block text-center text-sm text-primary hover:underline font-medium transition-colors duration-[var(--cp-duration-normal)]"
> >
Back to login Back to login
</Link> </Link>
</div> </div>
<p className="text-xs text-center text-muted-foreground">
Didn&apos;t receive the email? Check your spam folder or try resending.
</p>
</div> </div>
</AnimatedContainer> </AnimatedContainer>
); );
@ -98,11 +130,24 @@ function ResendCountdown({ email, onBackToForm }: { email: string; onBackToForm:
export function ForgotPasswordView() { export function ForgotPasswordView() {
const [sentToEmail, setSentToEmail] = useState<string | null>(null); const [sentToEmail, setSentToEmail] = useState<string | null>(null);
// Cooldown persists across form/confirmation transitions — prevents bypass
const cooldownEndRef = useRef(0);
const handleSuccess = useCallback((email?: string) => {
if (email) {
cooldownEndRef.current = Date.now() + RESEND_COOLDOWN_SECONDS * 1000;
setSentToEmail(email);
}
}, []);
if (sentToEmail) { if (sentToEmail) {
return ( return (
<AuthLayout title="Forgot password" subtitle="Follow the instructions in your email"> <AuthLayout title="Forgot password" subtitle="Follow the instructions in your email">
<ResendCountdown email={sentToEmail} onBackToForm={() => setSentToEmail(null)} /> <EmailSentConfirmation
email={sentToEmail}
cooldownEndRef={cooldownEndRef}
onBackToForm={() => setSentToEmail(null)}
/>
</AuthLayout> </AuthLayout>
); );
} }
@ -112,12 +157,7 @@ export function ForgotPasswordView() {
title="Forgot password" title="Forgot password"
subtitle="Enter your email address and we'll send you a reset link" subtitle="Enter your email address and we'll send you a reset link"
> >
<PasswordResetForm <PasswordResetForm mode="request" onSuccess={handleSuccess} />
mode="request"
onSuccess={email => {
if (email) setSentToEmail(email);
}}
/>
</AuthLayout> </AuthLayout>
); );
} }

View File

@ -8,21 +8,15 @@ import { useAuthSession } from "@/features/auth/stores/auth.store";
type Tone = "info" | "success" | "warning" | "error"; type Tone = "info" | "success" | "warning" | "error";
interface UsePaymentRefreshOptions { interface UsePaymentRefreshOptions {
// Refetch function from usePaymentMethods
refetch: () => Promise<{ data: PaymentMethodList | undefined }>; refetch: () => Promise<{ data: PaymentMethodList | undefined }>;
// When true, attaches focus/visibility listeners to refresh automatically
attachFocusListeners?: boolean;
// Optional custom detector for whether payment methods exist
hasMethods?: (data?: PaymentMethodList) => boolean; hasMethods?: (data?: PaymentMethodList) => boolean;
} }
export function usePaymentRefresh({ export function usePaymentRefresh({ refetch, hasMethods }: UsePaymentRefreshOptions) {
refetch,
attachFocusListeners = false,
hasMethods,
}: UsePaymentRefreshOptions) {
const { isAuthenticated } = useAuthSession(); const { isAuthenticated } = useAuthSession();
const hideToastTimeout = useRef<number | null>(null); const hideToastTimeout = useRef<number | null>(null);
const lastRefreshAt = useRef<number>(0);
const [isRefreshing, setIsRefreshing] = useState(false);
const [toast, setToast] = useState<{ visible: boolean; text: string; tone: Tone }>({ const [toast, setToast] = useState<{ visible: boolean; text: string; tone: Tone }>({
visible: false, visible: false,
text: "", text: "",
@ -36,19 +30,20 @@ export function usePaymentRefresh({
} }
}; };
const triggerRefresh = useCallback(async () => { const COOLDOWN_MS = 10_000;
// Don't trigger refresh if not authenticated
if (!isAuthenticated) {
return;
}
const triggerRefresh = useCallback(async () => {
const now = Date.now();
if (!isAuthenticated || isRefreshing || now - lastRefreshAt.current < COOLDOWN_MS) return;
lastRefreshAt.current = now;
setIsRefreshing(true);
setToast({ visible: true, text: "Refreshing payment methods...", tone: "info" }); setToast({ visible: true, text: "Refreshing payment methods...", tone: "info" });
try { try {
try { try {
await apiClient.POST("/api/invoices/payment-methods/refresh"); await apiClient.POST("/api/invoices/payment-methods/refresh");
} catch { } catch {
// Soft-fail cache refresh, still attempt refetch // Soft-fail cache refresh, still attempt refetch
// Payment methods cache refresh failed - silently continue
} }
const result = await refetch(); const result = await refetch();
const parsed = paymentMethodListSchema.safeParse(result.data ?? null); const parsed = paymentMethodListSchema.safeParse(result.data ?? null);
@ -65,13 +60,14 @@ export function usePaymentRefresh({
} catch { } catch {
setToast({ visible: true, text: "Could not refresh payment methods", tone: "warning" }); setToast({ visible: true, text: "Could not refresh payment methods", tone: "warning" });
} finally { } finally {
setIsRefreshing(false);
clearHideToastTimeout(); clearHideToastTimeout();
hideToastTimeout.current = window.setTimeout(() => { hideToastTimeout.current = window.setTimeout(() => {
setToast(t => ({ ...t, visible: false })); setToast(t => ({ ...t, visible: false }));
hideToastTimeout.current = null; hideToastTimeout.current = null;
}, 2200); }, 2200);
} }
}, [isAuthenticated, refetch, hasMethods]); }, [isAuthenticated, isRefreshing, refetch, hasMethods]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@ -79,22 +75,5 @@ export function usePaymentRefresh({
}; };
}, []); }, []);
useEffect(() => { return { toast, triggerRefresh, isRefreshing, setToast } as const;
if (!attachFocusListeners) return;
const onFocus = () => {
void triggerRefresh();
};
const onVis = () => {
if (document.visibilityState === "visible") void triggerRefresh();
};
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVis);
return () => {
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onVis);
};
}, [attachFocusListeners, triggerRefresh]);
return { toast, triggerRefresh, setToast } as const;
} }

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { CreditCardIcon } from "@heroicons/react/24/outline"; import { CreditCardIcon, ArrowPathIcon } from "@heroicons/react/24/outline";
import { PageLayout } from "@/components/templates/PageLayout"; import { PageLayout } from "@/components/templates/PageLayout";
import { ErrorBoundary } from "@/components/molecules"; import { ErrorBoundary } from "@/components/molecules";
import { useSession } from "@/features/auth/hooks"; import { useSession } from "@/features/auth/hooks";
@ -52,10 +52,14 @@ function PaymentMethodsSection({
paymentMethodsData, paymentMethodsData,
onManage, onManage,
isPending, isPending,
onRefresh,
isRefreshing,
}: { }: {
paymentMethodsData: PaymentMethodList; paymentMethodsData: PaymentMethodList;
onManage: () => void; onManage: () => void;
isPending: boolean; isPending: boolean;
onRefresh: () => void;
isRefreshing: boolean;
}) { }) {
const hasMethods = paymentMethodsData.paymentMethods.length > 0; const hasMethods = paymentMethodsData.paymentMethods.length > 0;
@ -71,9 +75,15 @@ function PaymentMethodsSection({
: "No payment methods on file"} : "No payment methods on file"}
</p> </p>
</div> </div>
<Button onClick={onManage} disabled={isPending} size="default"> <div className="flex items-center gap-2">
{isPending ? "Opening..." : "Manage Cards"} <Button variant="outline" size="default" onClick={onRefresh} disabled={isRefreshing}>
</Button> <ArrowPathIcon className={`h-4 w-4 mr-1.5 ${isRefreshing ? "animate-spin" : ""}`} />
Refresh
</Button>
<Button onClick={onManage} disabled={isPending} size="default">
{isPending ? "Opening..." : "Manage Cards"}
</Button>
</div>
</div> </div>
</div> </div>
{hasMethods && ( {hasMethods && (
@ -108,7 +118,6 @@ export function BillingOverview() {
return { data: result.data }; return { data: result.data };
}, },
hasMethods: data => Boolean(data && (data.totalCount > 0 || data.paymentMethods.length > 0)), hasMethods: data => Boolean(data && (data.totalCount > 0 || data.paymentMethods.length > 0)),
attachFocusListeners: true,
}); });
const openPaymentMethods = async () => { const openPaymentMethods = async () => {
@ -161,6 +170,8 @@ export function BillingOverview() {
paymentMethodsData={paymentMethodsData} paymentMethodsData={paymentMethodsData}
onManage={() => void openPaymentMethods()} onManage={() => void openPaymentMethods()}
isPending={createPaymentMethodsSsoLink.isPending} isPending={createPaymentMethodsSsoLink.isPending}
onRefresh={() => void paymentRefresh.triggerRefresh()}
isRefreshing={paymentRefresh.isRefreshing}
/> />
)} )}

View File

@ -15,7 +15,7 @@ import {
useCreatePaymentMethodsSsoLink, useCreatePaymentMethodsSsoLink,
} from "@/features/billing"; } from "@/features/billing";
import type { PaymentMethodList } from "@customer-portal/domain/payments"; import type { PaymentMethodList } from "@customer-portal/domain/payments";
import { CreditCardIcon } from "@heroicons/react/24/outline"; import { CreditCardIcon, ArrowPathIcon } from "@heroicons/react/24/outline";
import { InlineToast } from "@/components/atoms/inline-toast"; import { InlineToast } from "@/components/atoms/inline-toast";
import { Button } from "@/components/atoms/button"; import { Button } from "@/components/atoms/button";
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
@ -54,6 +54,15 @@ function PaymentMethodsSkeleton() {
); );
} }
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 }) { function ManageCardsButton({ onClick, isPending }: { onClick: () => void; isPending: boolean }) {
return ( return (
<Button <Button
@ -71,10 +80,14 @@ function PaymentMethodsListView({
paymentMethodsData, paymentMethodsData,
onManage, onManage,
isPending, isPending,
onRefresh,
isRefreshing,
}: { }: {
paymentMethodsData: PaymentMethodList; paymentMethodsData: PaymentMethodList;
onManage: () => void; onManage: () => void;
isPending: boolean; isPending: boolean;
onRefresh: () => void;
isRefreshing: boolean;
}) { }) {
return ( return (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden"> <div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
@ -87,9 +100,9 @@ function PaymentMethodsListView({
{paymentMethodsData.paymentMethods.length === 1 ? "" : "s"} on file {paymentMethodsData.paymentMethods.length === 1 ? "" : "s"} on file
</p> </p>
</div> </div>
<div className="text-right"> <div className="flex items-center gap-2">
<RefreshButton onClick={onRefresh} isRefreshing={isRefreshing} />
<ManageCardsButton onClick={onManage} isPending={isPending} /> <ManageCardsButton onClick={onManage} isPending={isPending} />
<p className="text-xs text-gray-500 mt-1">Opens in a new tab for security</p>
</div> </div>
</div> </div>
</div> </div>
@ -113,9 +126,13 @@ function PaymentMethodsListView({
function PaymentMethodsEmptyState({ function PaymentMethodsEmptyState({
onManage, onManage,
isPending, isPending,
onRefresh,
isRefreshing,
}: { }: {
onManage: () => void; onManage: () => void;
isPending: boolean; isPending: boolean;
onRefresh: () => void;
isRefreshing: boolean;
}) { }) {
return ( return (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden"> <div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
@ -128,14 +145,17 @@ function PaymentMethodsEmptyState({
Open the billing portal to add a card. Open the billing portal to add a card.
</p> </p>
<div className="space-y-3"> <div className="space-y-3">
<Button <div className="flex items-center justify-center gap-2">
onClick={onManage} <RefreshButton onClick={onRefresh} isRefreshing={isRefreshing} />
disabled={isPending} <Button
size="lg" onClick={onManage}
className="bg-blue-600 hover:bg-blue-700 text-white font-medium px-8" disabled={isPending}
> size="default"
{isPending ? "Opening..." : "Manage Cards"} className="bg-blue-600 hover:bg-blue-700 text-white font-medium px-8"
</Button> >
{isPending ? "Opening..." : "Manage Cards"}
</Button>
</div>
<p className="text-sm text-gray-500">Opens in a new tab for security</p> <p className="text-sm text-gray-500">Opens in a new tab for security</p>
</div> </div>
</div> </div>
@ -213,7 +233,6 @@ export function PaymentMethodsContainer() {
return { data: result.data }; return { data: result.data };
}, },
hasMethods: data => Boolean(data && (data.totalCount > 0 || data.paymentMethods.length > 0)), hasMethods: data => Boolean(data && (data.totalCount > 0 || data.paymentMethods.length > 0)),
attachFocusListeners: true,
}); });
const openPaymentMethods = async () => { const openPaymentMethods = async () => {
@ -265,12 +284,16 @@ export function PaymentMethodsContainer() {
paymentMethodsData={paymentMethodsData} paymentMethodsData={paymentMethodsData}
onManage={handleManage} onManage={handleManage}
isPending={createPaymentMethodsSsoLink.isPending} isPending={createPaymentMethodsSsoLink.isPending}
onRefresh={() => void paymentRefresh.triggerRefresh()}
isRefreshing={paymentRefresh.isRefreshing}
/> />
)} )}
{!isDataLoading && !hasMethods && ( {!isDataLoading && !hasMethods && (
<PaymentMethodsEmptyState <PaymentMethodsEmptyState
onManage={handleManage} onManage={handleManage}
isPending={createPaymentMethodsSsoLink.isPending} isPending={createPaymentMethodsSsoLink.isPending}
onRefresh={() => void paymentRefresh.triggerRefresh()}
isRefreshing={paymentRefresh.isRefreshing}
/> />
)} )}
</div> </div>

View File

@ -54,7 +54,7 @@ function hasCompleteAddress(address: Record<string, unknown> | null | undefined)
function useCheckoutPayment() { function useCheckoutPayment() {
const { data: paymentMethods, isLoading, error, refetch } = usePaymentMethods(); const { data: paymentMethods, isLoading, error, refetch } = usePaymentMethods();
const paymentRefresh = usePaymentRefresh({ refetch, attachFocusListeners: false }); const paymentRefresh = usePaymentRefresh({ refetch });
const { showToast } = useCheckoutToast({ setToast: paymentRefresh.setToast }); const { showToast } = useCheckoutToast({ setToast: paymentRefresh.setToast });
const list = paymentMethods?.paymentMethods ?? []; const list = paymentMethods?.paymentMethods ?? [];

View File

@ -24,6 +24,12 @@ export function VerificationStep() {
setCode(value); setCode(value);
}; };
const handleComplete = (completedCode: string) => {
if (!loading) {
send({ type: "VERIFY_CODE", code: completedCode });
}
};
const handleVerify = () => { const handleVerify = () => {
if (code.length === 6) { if (code.length === 6) {
send({ type: "VERIFY_CODE", code }); send({ type: "VERIFY_CODE", code });
@ -53,6 +59,7 @@ export function VerificationStep() {
<OtpInput <OtpInput
value={code} value={code}
onChange={handleCodeChange} onChange={handleCodeChange}
onComplete={handleComplete}
disabled={loading} disabled={loading}
error={error ?? undefined} error={error ?? undefined}
autoFocus autoFocus

View File

@ -132,19 +132,17 @@ function PublicTierCard({ tier }: { tier: TierInfo }) {
</div> </div>
<div className="mb-2"> <div className="mb-2">
<div className="flex items-baseline gap-0.5"> <span className="text-lg font-bold text-foreground">
<span className="text-lg font-bold text-foreground"> ¥{tier.monthlyPrice.toLocaleString()}
¥{tier.monthlyPrice.toLocaleString()} {tier.maxMonthlyPrice &&
{tier.maxMonthlyPrice && tier.maxMonthlyPrice > tier.monthlyPrice &&
tier.maxMonthlyPrice > tier.monthlyPrice && `~${tier.maxMonthlyPrice.toLocaleString()}`}
`~${tier.maxMonthlyPrice.toLocaleString()}`} </span>
</span> <span className="text-[10px] text-muted-foreground">/mo</span>
<span className="text-[10px] text-muted-foreground">/mo</span>
</div>
{tier.pricingNote && ( {tier.pricingNote && (
<span <span
className={cn( className={cn(
"text-[10px] block", "text-[10px] ml-0.5",
tier.tier === "Platinum" ? "text-primary" : "text-amber-600" tier.tier === "Platinum" ? "text-primary" : "text-amber-600"
)} )}
> >

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@ -182,7 +182,7 @@ function TierCard({ tier }: { tier: TierInfo }) {
return ( return (
<div <div
className={cn( className={cn(
"rounded-xl border p-4 transition-all duration-200 flex flex-col relative", "rounded-xl border p-4 pt-6 transition-all duration-200 flex flex-col relative h-full",
tierStyles[tier.tier].card tierStyles[tier.tier].card
)} )}
> >
@ -194,25 +194,15 @@ function TierCard({ tier }: { tier: TierInfo }) {
</span> </span>
</div> </div>
)} )}
<h4 <h4 className={cn("font-bold text-lg mb-2", tierStyles[tier.tier].accent)}>{tier.tier}</h4>
className={cn( <div className="mb-3 flex items-baseline gap-1 flex-wrap">
"font-bold text-lg mb-2", <span className="text-2xl font-bold text-foreground whitespace-nowrap">
tier.tier === "Gold" ? "mt-2" : "", ¥{tier.monthlyPrice.toLocaleString()}
tierStyles[tier.tier].accent {tier.maxMonthlyPrice &&
)} tier.maxMonthlyPrice > tier.monthlyPrice &&
> `~${tier.maxMonthlyPrice.toLocaleString()}`}
{tier.tier} </span>
</h4> <span className="text-sm text-muted-foreground">/mo</span>
<div className="mb-3">
<div className="flex items-baseline gap-0.5 flex-wrap">
<span className="text-2xl font-bold text-foreground">
¥{tier.monthlyPrice.toLocaleString()}
{tier.maxMonthlyPrice &&
tier.maxMonthlyPrice > tier.monthlyPrice &&
`~${tier.maxMonthlyPrice.toLocaleString()}`}
</span>
<span className="text-sm text-muted-foreground">/mo</span>
</div>
{tier.pricingNote && ( {tier.pricingNote && (
<span className={`text-xs ${pricingNoteClass}`}>{tier.pricingNote}</span> <span className={`text-xs ${pricingNoteClass}`}>{tier.pricingNote}</span>
)} )}
@ -325,7 +315,7 @@ function UnifiedInternetCard({
variants={tierContainerVariants} variants={tierContainerVariants}
> >
{displayTiers.map(tier => ( {displayTiers.map(tier => (
<motion.div key={tier.tier} variants={cardVariants}> <motion.div key={tier.tier} variants={cardVariants} className="h-full">
<TierCard tier={tier} /> <TierCard tier={tier} />
</motion.div> </motion.div>
))} ))}

BIN
image.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 75 KiB