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:
parent
de5a210e6f
commit
7d290c814d
@ -74,6 +74,8 @@ class CheckPasswordNeededResponseDto extends createZodDto(checkPasswordNeededRes
|
||||
class LoginVerifyOtpRequestDto extends createZodDto(loginVerifyOtpRequestSchema) {}
|
||||
|
||||
@Controller("auth")
|
||||
@UseGuards(RateLimitGuard)
|
||||
@RateLimit({ limit: 10, ttl: 300 }) // 10 requests per 5 minutes per IP+UA (controller default)
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private authOrchestrator: AuthOrchestrator,
|
||||
@ -181,8 +183,6 @@ export class AuthController {
|
||||
*/
|
||||
@Public()
|
||||
@Post("login/verify-otp")
|
||||
@UseGuards(RateLimitGuard)
|
||||
@RateLimit({ limit: 10, ttl: 300 }) // 10 attempts per 5 minutes per IP
|
||||
@HttpCode(200)
|
||||
async verifyLoginOtp(
|
||||
@Body() body: LoginVerifyOtpRequestDto,
|
||||
@ -260,8 +260,6 @@ export class AuthController {
|
||||
|
||||
@Public()
|
||||
@Post("refresh")
|
||||
@UseGuards(RateLimitGuard)
|
||||
@RateLimit({ limit: 10, ttl: 300 }) // 10 attempts per 5 minutes per IP
|
||||
async refreshToken(
|
||||
@Body() body: RefreshTokenRequestDto,
|
||||
@Req() req: RequestWithCookies,
|
||||
@ -280,8 +278,6 @@ export class AuthController {
|
||||
|
||||
@Public()
|
||||
@Post("set-password")
|
||||
@UseGuards(RateLimitGuard)
|
||||
@RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP+UA (industry standard)
|
||||
async setPassword(
|
||||
@Body() setPasswordData: SetPasswordRequestDto,
|
||||
@Req() _req: Request,
|
||||
@ -309,8 +305,6 @@ export class AuthController {
|
||||
|
||||
@Public()
|
||||
@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) {
|
||||
await this.passwordWorkflow.requestPasswordReset(body.email, req);
|
||||
return { message: "If an account exists, a reset email has been sent" };
|
||||
@ -319,8 +313,6 @@ export class AuthController {
|
||||
@Public()
|
||||
@Post("reset-password")
|
||||
@HttpCode(200)
|
||||
@UseGuards(RateLimitGuard)
|
||||
@RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations)
|
||||
async resetPassword(
|
||||
@Body() body: ResetPasswordRequestDto,
|
||||
@Res({ passthrough: true }) res: Response
|
||||
@ -333,8 +325,6 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Post("change-password")
|
||||
@UseGuards(RateLimitGuard)
|
||||
@RateLimit({ limit: 5, ttl: 300 }) // 5 attempts per 5 minutes
|
||||
async changePassword(
|
||||
@Req() req: Request & { user: { id: string } },
|
||||
@Body() body: ChangePasswordRequestDto,
|
||||
|
||||
@ -65,7 +65,7 @@ function useOtpHandlers({
|
||||
const newValue = newDigits.join("");
|
||||
onChange(newValue);
|
||||
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]
|
||||
);
|
||||
|
||||
@ -5,6 +5,7 @@ import { Skeleton } from "@/components/atoms/loading-skeleton";
|
||||
import { ErrorState } from "@/components/atoms/error-state";
|
||||
|
||||
interface PageLayoutProps {
|
||||
/** @deprecated Icons no longer rendered in page headers */
|
||||
icon?: ReactNode | undefined;
|
||||
title: string;
|
||||
description?: string | undefined;
|
||||
@ -19,7 +20,6 @@ interface PageLayoutProps {
|
||||
}
|
||||
|
||||
export function PageLayout({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
@ -47,8 +47,7 @@ export function PageLayout({
|
||||
{backLink.label}
|
||||
</Link>
|
||||
)}
|
||||
{icon && <div className="h-6 w-6 text-primary mr-2.5 flex-shrink-0">{icon}</div>}
|
||||
<h1 className="text-lg font-bold text-foreground leading-tight truncate">{title}</h1>
|
||||
<h1 className="text-xl font-bold text-foreground leading-tight truncate">{title}</h1>
|
||||
{statusPill && <div className="ml-2.5 flex-shrink-0">{statusPill}</div>}
|
||||
{description && (
|
||||
<p className="hidden sm:block text-sm text-muted-foreground ml-3 truncate">
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { Mail } from "lucide-react";
|
||||
import { Mail, ArrowLeft, Clock, CheckCircle2 } from "lucide-react";
|
||||
import { AuthLayout } from "../components";
|
||||
import { PasswordResetForm } from "@/features/auth/components";
|
||||
import { Button, AnimatedContainer } from "@/components/atoms";
|
||||
@ -10,12 +10,22 @@ import { useAuthStore } from "../stores/auth.store";
|
||||
|
||||
const RESEND_COOLDOWN_SECONDS = 60;
|
||||
|
||||
function ResendCountdown({ email, onBackToForm }: { email: string; onBackToForm: () => void }) {
|
||||
const [secondsLeft, setSecondsLeft] = useState(RESEND_COOLDOWN_SECONDS);
|
||||
function EmailSentConfirmation({
|
||||
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 [resendError, setResendError] = useState<string | null>(null);
|
||||
const [resendCount, setResendCount] = useState(0);
|
||||
const requestPasswordReset = useAuthStore(state => state.requestPasswordReset);
|
||||
const cooldownEndRef = useRef(Date.now() + RESEND_COOLDOWN_SECONDS * 1000);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
@ -25,7 +35,7 @@ function ResendCountdown({ email, onBackToForm }: { email: string; onBackToForm:
|
||||
update();
|
||||
const interval = setInterval(update, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
}, [cooldownEndRef]);
|
||||
|
||||
const handleResend = useCallback(async () => {
|
||||
setResending(true);
|
||||
@ -34,30 +44,44 @@ function ResendCountdown({ email, onBackToForm }: { email: string; onBackToForm:
|
||||
await requestPasswordReset(email);
|
||||
cooldownEndRef.current = Date.now() + RESEND_COOLDOWN_SECONDS * 1000;
|
||||
setSecondsLeft(RESEND_COOLDOWN_SECONDS);
|
||||
setResendCount(c => c + 1);
|
||||
} catch {
|
||||
setResendError("Failed to resend. Please try again.");
|
||||
} finally {
|
||||
setResending(false);
|
||||
}
|
||||
}, [email, requestPasswordReset]);
|
||||
}, [email, requestPasswordReset, cooldownEndRef]);
|
||||
|
||||
const canResend = secondsLeft === 0 && !resending;
|
||||
|
||||
return (
|
||||
<AnimatedContainer animation="fade-up">
|
||||
<div className="space-y-6 text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-primary/10">
|
||||
<Mail className="h-7 w-7 text-primary" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold text-foreground">Check your email</h3>
|
||||
<div className="w-full space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="mx-auto w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center mb-4">
|
||||
<Mail className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-foreground">Check your email</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We sent a password reset link to{" "}
|
||||
<span className="font-medium text-foreground">{email}</span>
|
||||
</p>
|
||||
</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">
|
||||
{canResend ? (
|
||||
<Button
|
||||
@ -70,27 +94,35 @@ function ResendCountdown({ email, onBackToForm }: { email: string; onBackToForm:
|
||||
Resend reset link
|
||||
</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 className="flex flex-col gap-2">
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
Didn't receive the email? Check your spam folder or try resending.
|
||||
</p>
|
||||
</div>
|
||||
</AnimatedContainer>
|
||||
);
|
||||
@ -98,11 +130,24 @@ function ResendCountdown({ email, onBackToForm }: { email: string; onBackToForm:
|
||||
|
||||
export function ForgotPasswordView() {
|
||||
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) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -112,12 +157,7 @@ export function ForgotPasswordView() {
|
||||
title="Forgot password"
|
||||
subtitle="Enter your email address and we'll send you a reset link"
|
||||
>
|
||||
<PasswordResetForm
|
||||
mode="request"
|
||||
onSuccess={email => {
|
||||
if (email) setSentToEmail(email);
|
||||
}}
|
||||
/>
|
||||
<PasswordResetForm mode="request" onSuccess={handleSuccess} />
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -8,21 +8,15 @@ import { useAuthSession } from "@/features/auth/stores/auth.store";
|
||||
type Tone = "info" | "success" | "warning" | "error";
|
||||
|
||||
interface UsePaymentRefreshOptions {
|
||||
// Refetch function from usePaymentMethods
|
||||
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;
|
||||
}
|
||||
|
||||
export function usePaymentRefresh({
|
||||
refetch,
|
||||
attachFocusListeners = false,
|
||||
hasMethods,
|
||||
}: UsePaymentRefreshOptions) {
|
||||
export function usePaymentRefresh({ refetch, hasMethods }: UsePaymentRefreshOptions) {
|
||||
const { isAuthenticated } = useAuthSession();
|
||||
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 }>({
|
||||
visible: false,
|
||||
text: "",
|
||||
@ -36,19 +30,20 @@ export function usePaymentRefresh({
|
||||
}
|
||||
};
|
||||
|
||||
const triggerRefresh = useCallback(async () => {
|
||||
// Don't trigger refresh if not authenticated
|
||||
if (!isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
const COOLDOWN_MS = 10_000;
|
||||
|
||||
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" });
|
||||
try {
|
||||
try {
|
||||
await apiClient.POST("/api/invoices/payment-methods/refresh");
|
||||
} catch {
|
||||
// Soft-fail cache refresh, still attempt refetch
|
||||
// Payment methods cache refresh failed - silently continue
|
||||
}
|
||||
const result = await refetch();
|
||||
const parsed = paymentMethodListSchema.safeParse(result.data ?? null);
|
||||
@ -65,13 +60,14 @@ export function usePaymentRefresh({
|
||||
} catch {
|
||||
setToast({ visible: true, text: "Could not refresh payment methods", tone: "warning" });
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
clearHideToastTimeout();
|
||||
hideToastTimeout.current = window.setTimeout(() => {
|
||||
setToast(t => ({ ...t, visible: false }));
|
||||
hideToastTimeout.current = null;
|
||||
}, 2200);
|
||||
}
|
||||
}, [isAuthenticated, refetch, hasMethods]);
|
||||
}, [isAuthenticated, isRefreshing, refetch, hasMethods]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@ -79,22 +75,5 @@ export function usePaymentRefresh({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
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;
|
||||
return { toast, triggerRefresh, isRefreshing, setToast } as const;
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 { ErrorBoundary } from "@/components/molecules";
|
||||
import { useSession } from "@/features/auth/hooks";
|
||||
@ -52,10 +52,14 @@ function PaymentMethodsSection({
|
||||
paymentMethodsData,
|
||||
onManage,
|
||||
isPending,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
}: {
|
||||
paymentMethodsData: PaymentMethodList;
|
||||
onManage: () => void;
|
||||
isPending: boolean;
|
||||
onRefresh: () => void;
|
||||
isRefreshing: boolean;
|
||||
}) {
|
||||
const hasMethods = paymentMethodsData.paymentMethods.length > 0;
|
||||
|
||||
@ -71,9 +75,15 @@ function PaymentMethodsSection({
|
||||
: "No payment methods on file"}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onManage} disabled={isPending} size="default">
|
||||
{isPending ? "Opening..." : "Manage Cards"}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="default" onClick={onRefresh} disabled={isRefreshing}>
|
||||
<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>
|
||||
{hasMethods && (
|
||||
@ -108,7 +118,6 @@ export function BillingOverview() {
|
||||
return { data: result.data };
|
||||
},
|
||||
hasMethods: data => Boolean(data && (data.totalCount > 0 || data.paymentMethods.length > 0)),
|
||||
attachFocusListeners: true,
|
||||
});
|
||||
|
||||
const openPaymentMethods = async () => {
|
||||
@ -161,6 +170,8 @@ export function BillingOverview() {
|
||||
paymentMethodsData={paymentMethodsData}
|
||||
onManage={() => void openPaymentMethods()}
|
||||
isPending={createPaymentMethodsSsoLink.isPending}
|
||||
onRefresh={() => void paymentRefresh.triggerRefresh()}
|
||||
isRefreshing={paymentRefresh.isRefreshing}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@ import {
|
||||
useCreatePaymentMethodsSsoLink,
|
||||
} from "@/features/billing";
|
||||
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 { Button } from "@/components/atoms/button";
|
||||
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 }) {
|
||||
return (
|
||||
<Button
|
||||
@ -71,10 +80,14 @@ 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">
|
||||
@ -87,9 +100,9 @@ function PaymentMethodsListView({
|
||||
{paymentMethodsData.paymentMethods.length === 1 ? "" : "s"} on file
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshButton onClick={onRefresh} isRefreshing={isRefreshing} />
|
||||
<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>
|
||||
@ -113,9 +126,13 @@ function PaymentMethodsListView({
|
||||
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">
|
||||
@ -128,14 +145,17 @@ function PaymentMethodsEmptyState({
|
||||
Open the billing portal to add a card.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={onManage}
|
||||
disabled={isPending}
|
||||
size="lg"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-medium px-8"
|
||||
>
|
||||
{isPending ? "Opening..." : "Manage Cards"}
|
||||
</Button>
|
||||
<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>
|
||||
@ -213,7 +233,6 @@ export function PaymentMethodsContainer() {
|
||||
return { data: result.data };
|
||||
},
|
||||
hasMethods: data => Boolean(data && (data.totalCount > 0 || data.paymentMethods.length > 0)),
|
||||
attachFocusListeners: true,
|
||||
});
|
||||
|
||||
const openPaymentMethods = async () => {
|
||||
@ -265,12 +284,16 @@ export function PaymentMethodsContainer() {
|
||||
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>
|
||||
|
||||
@ -54,7 +54,7 @@ function hasCompleteAddress(address: Record<string, unknown> | null | undefined)
|
||||
|
||||
function useCheckoutPayment() {
|
||||
const { data: paymentMethods, isLoading, error, refetch } = usePaymentMethods();
|
||||
const paymentRefresh = usePaymentRefresh({ refetch, attachFocusListeners: false });
|
||||
const paymentRefresh = usePaymentRefresh({ refetch });
|
||||
const { showToast } = useCheckoutToast({ setToast: paymentRefresh.setToast });
|
||||
|
||||
const list = paymentMethods?.paymentMethods ?? [];
|
||||
|
||||
@ -24,6 +24,12 @@ export function VerificationStep() {
|
||||
setCode(value);
|
||||
};
|
||||
|
||||
const handleComplete = (completedCode: string) => {
|
||||
if (!loading) {
|
||||
send({ type: "VERIFY_CODE", code: completedCode });
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify = () => {
|
||||
if (code.length === 6) {
|
||||
send({ type: "VERIFY_CODE", code });
|
||||
@ -53,6 +59,7 @@ export function VerificationStep() {
|
||||
<OtpInput
|
||||
value={code}
|
||||
onChange={handleCodeChange}
|
||||
onComplete={handleComplete}
|
||||
disabled={loading}
|
||||
error={error ?? undefined}
|
||||
autoFocus
|
||||
|
||||
@ -132,19 +132,17 @@ function PublicTierCard({ tier }: { tier: TierInfo }) {
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<div className="flex items-baseline gap-0.5">
|
||||
<span className="text-lg font-bold text-foreground">
|
||||
¥{tier.monthlyPrice.toLocaleString()}
|
||||
{tier.maxMonthlyPrice &&
|
||||
tier.maxMonthlyPrice > tier.monthlyPrice &&
|
||||
`~${tier.maxMonthlyPrice.toLocaleString()}`}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">/mo</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-foreground">
|
||||
¥{tier.monthlyPrice.toLocaleString()}
|
||||
{tier.maxMonthlyPrice &&
|
||||
tier.maxMonthlyPrice > tier.monthlyPrice &&
|
||||
`~${tier.maxMonthlyPrice.toLocaleString()}`}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">/mo</span>
|
||||
{tier.pricingNote && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] block",
|
||||
"text-[10px] ml-0.5",
|
||||
tier.tier === "Platinum" ? "text-primary" : "text-amber-600"
|
||||
)}
|
||||
>
|
||||
|
||||
BIN
apps/portal/src/features/services/components/internet/image.png
Normal file
BIN
apps/portal/src/features/services/components/internet/image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
@ -182,7 +182,7 @@ function TierCard({ tier }: { tier: TierInfo }) {
|
||||
return (
|
||||
<div
|
||||
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
|
||||
)}
|
||||
>
|
||||
@ -194,25 +194,15 @@ function TierCard({ tier }: { tier: TierInfo }) {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<h4
|
||||
className={cn(
|
||||
"font-bold text-lg mb-2",
|
||||
tier.tier === "Gold" ? "mt-2" : "",
|
||||
tierStyles[tier.tier].accent
|
||||
)}
|
||||
>
|
||||
{tier.tier}
|
||||
</h4>
|
||||
<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>
|
||||
<h4 className={cn("font-bold text-lg mb-2", tierStyles[tier.tier].accent)}>{tier.tier}</h4>
|
||||
<div className="mb-3 flex items-baseline gap-1 flex-wrap">
|
||||
<span className="text-2xl font-bold text-foreground whitespace-nowrap">
|
||||
¥{tier.monthlyPrice.toLocaleString()}
|
||||
{tier.maxMonthlyPrice &&
|
||||
tier.maxMonthlyPrice > tier.monthlyPrice &&
|
||||
`~${tier.maxMonthlyPrice.toLocaleString()}`}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">/mo</span>
|
||||
{tier.pricingNote && (
|
||||
<span className={`text-xs ${pricingNoteClass}`}>{tier.pricingNote}</span>
|
||||
)}
|
||||
@ -325,7 +315,7 @@ function UnifiedInternetCard({
|
||||
variants={tierContainerVariants}
|
||||
>
|
||||
{displayTiers.map(tier => (
|
||||
<motion.div key={tier.tier} variants={cardVariants}>
|
||||
<motion.div key={tier.tier} variants={cardVariants} className="h-full">
|
||||
<TierCard tier={tier} />
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user