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) {}
|
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,
|
||||||
|
|||||||
@ -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]
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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'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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 ?? [];
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
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 (
|
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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user