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) {}
@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,

View File

@ -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]
);

View File

@ -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">

View File

@ -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&apos;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>
);
}

View File

@ -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;
}

View File

@ -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}
/>
)}

View File

@ -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>

View File

@ -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 ?? [];

View File

@ -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

View File

@ -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"
)}
>

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@ -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>
))}

BIN
image.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 75 KiB