Vitron-Front/app/components/LoginRequiredDialog.tsx
Arda Samadi 501cf28b9a fix(auth): 6-digit OTP in LoginRequiredDialog
The login-required popup still rendered a 4-slot OTP (maxLength=4, gated on
length===4) while the main login page already used 6. Users who triggered
login via the dialog saw the old 4-digit code. Match login.tsx: 6 slots,
length===6, same slot sizing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:53:01 +03:30

301 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Dialog, DialogContent, DialogOverlay } from "./ui/dialog";
import { Button } from "./ui/button";
import { ArrowLeft, Loader2, Phone, RotateCcw, LogIn } from "lucide-react";
import { useState, useEffect } from "react";
import { useSubmit, useRevalidator } from "@remix-run/react";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "~/components/ui/input-otp";
import AppInput from "~/components/AppInput";
import { useToast } from "~/hooks/use-toast";
import {
useServiceSendOtp,
useServiceVerifyOtp,
} from "~/utils/RequestHandler";
interface LoginRequiredDialogProps {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
loginTitle: string;
}
// Helper function to convert Persian numbers to English
const convertPersianToEnglish = (str: string): string => {
const persianNumbers = ["۰", "۱", "۲", "۳", "۴", "۵", "۶", "۷", "۸", "۹"];
const englishNumbers = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"];
let result = str;
for (let i = 0; i < 10; i++) {
result = result.replace(
new RegExp(persianNumbers[i], "g"),
englishNumbers[i]
);
}
return result;
};
const ResendTimer = ({
initialTime = 120,
onTimerEnd,
}: {
initialTime?: number;
onTimerEnd?: () => void;
}) => {
const [timeLeft, setTimeLeft] = useState(initialTime);
useEffect(() => {
if (timeLeft <= 0) {
if (onTimerEnd) onTimerEnd();
return;
}
const timer = setInterval(() => {
setTimeLeft((prev) => prev - 1);
}, 1000);
return () => clearInterval(timer);
}, [timeLeft, onTimerEnd]);
const formatTime = (seconds: number) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds < 10 ? `0${remainingSeconds}` : remainingSeconds}`;
};
return (
<p className="text-GRAY text-R12">
{formatTime(timeLeft)} &nbsp;تا ارسال مجدد کد
</p>
);
};
export function LoginRequiredDialog({
isOpen,
onOpenChange,
loginTitle,
}: LoginRequiredDialogProps) {
const { toast } = useToast();
const submit = useSubmit();
const revalidator = useRevalidator();
const [phoneNumber, setPhoneNumber] = useState("");
const [otpValue, setOtpValue] = useState("");
const [enableVerifySection, setEnableVerifySection] = useState(false);
const [canResend, setCanResend] = useState(false);
const sendOtpMutation = useServiceSendOtp(
phoneNumber,
setEnableVerifySection
);
const verifyOtpMutation = useServiceVerifyOtp(phoneNumber, otpValue, toast);
// Handle successful verification
useEffect(() => {
if (
verifyOtpMutation.data &&
verifyOtpMutation.data.accessToken &&
verifyOtpMutation.data.refreshToken
) {
const currentUrl = window.location.pathname;
submit(
{
phoneNumber: phoneNumber,
access_token: JSON.stringify(verifyOtpMutation.data.accessToken),
refresh_token: JSON.stringify(verifyOtpMutation.data.refreshToken),
redirectTo: currentUrl,
},
{
action: "/login",
method: "post",
encType: "application/x-www-form-urlencoded",
}
);
// Close modal and revalidate after successful login
onOpenChange(false);
revalidator.revalidate();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [verifyOtpMutation.data]);
useEffect(() => {
if (enableVerifySection) setCanResend(false);
}, [enableVerifySection]);
const handleTimerEnd = () => {
setCanResend(true);
};
// Reset state when modal closes
useEffect(() => {
if (!isOpen) {
setPhoneNumber("");
setOtpValue("");
setEnableVerifySection(false);
setCanResend(false);
}
}, [isOpen]);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogOverlay />
<DialogContent className="p-4 bg-WHITE rounded-[15px] w-[calc(100%-40px)] max-h-[90vh] overflow-y-auto">
<div className="flex flex-col gap-6 items-center">
{enableVerifySection ? (
// OTP Verification Section
<>
<div className="w-16 h-16 rounded-full bg-PRIMARY/10 flex items-center justify-center">
<LogIn size={32} className="text-PRIMARY" />
</div>
<div className="flex flex-col gap-2 text-center w-full">
<h3 className="text-B16 font-bold">کد تایید شماره موبایل</h3>
<p className="text-R14 text-GRAY">
کد ارسال شده به {phoneNumber} را وارد کنید:
</p>
<Button
variant="ghost"
className="text-BLUE mx-auto"
size="lg"
onClick={() => setEnableVerifySection(false)}
>
ویرایش شماره موبایل
<ArrowLeft className="mr-1" />
</Button>
</div>
<div className="w-full flex justify-center">
<InputOTP
maxLength={6}
onComplete={(finalValue) => {
const convertedValue = convertPersianToEnglish(finalValue);
setOtpValue(convertedValue);
}}
value={otpValue}
onChange={(value) => {
const convertedValue = convertPersianToEnglish(value);
setOtpValue(convertedValue);
}}
>
<InputOTPGroup dir="ltr" className="gap-1.5 sm:gap-3">
<InputOTPSlot index={0} className="!w-9 sm:!w-12" />
<InputOTPSlot index={1} className="!w-9 sm:!w-12" />
<InputOTPSlot index={2} className="!w-9 sm:!w-12" />
<InputOTPSlot index={3} className="!w-9 sm:!w-12" />
<InputOTPSlot index={4} className="!w-9 sm:!w-12" />
<InputOTPSlot index={5} className="!w-9 sm:!w-12" />
</InputOTPGroup>
</InputOTP>
</div>
<div className="gap-3 w-full flex flex-col items-center">
<Button
disabled={verifyOtpMutation.isPending || !(otpValue.length === 6)}
className="w-full"
size="xl"
variant="dark"
onClick={() => verifyOtpMutation.mutate()}
>
{verifyOtpMutation.isPending ? (
<>
<Loader2 className="animate-spin ml-2" />
درحال تایید...
</>
) : (
"تایید شماره موبایل"
)}
</Button>
<div>
{!canResend ? (
<ResendTimer initialTime={120} onTimerEnd={handleTimerEnd} />
) : (
<Button
variant="link"
size="lg"
className="text-BLUE font-bold p-0 h-auto gap-1"
onClick={() => {
setCanResend(false);
setOtpValue("");
sendOtpMutation.mutate();
toast({
description: "کد مجددا ارسال گردید",
title: "ارسال کد",
});
}}
>
<span className="text-B12">ارسال مجدد کد</span>
<RotateCcw className="size-5" />
</Button>
)}
</div>
<Button
variant="outline"
size="lg"
className="w-full"
onClick={() => onOpenChange(false)}
>
انصراف
</Button>
</div>
</>
) : (
// Phone Number Entry Section
<>
<div className="w-16 h-16 rounded-full bg-PRIMARY/10 flex items-center justify-center">
<LogIn size={32} className="text-PRIMARY" />
</div>
<div className="flex flex-col gap-2 text-center">
<h3 className="text-B16 font-bold">ورود به حساب کاربری</h3>
<p className="text-R14 text-GRAY">{loginTitle}</p>
</div>
<div className="w-full">
<AppInput
inputTitle="شماره موبایل خود را وارد کنید:"
inputIcon={Phone}
inputValue={phoneNumber}
inputPlaceholder="مثل ۰۹۳۹۱۲۳۴۵۶۷"
inputOnChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const convertedValue = convertPersianToEnglish(value);
if (/^\d*$/.test(convertedValue)) {
setPhoneNumber(convertedValue);
}
}}
/>
</div>
<div className="flex flex-col gap-3 w-full">
<Button
disabled={
sendOtpMutation.isPending ||
!(phoneNumber.length === 11 && phoneNumber.startsWith("0"))
}
variant="dark"
size="xl"
className="w-full"
onClick={() => sendOtpMutation.mutate()}
>
{sendOtpMutation.isPending ? (
<>
<Loader2 className="animate-spin ml-2" />
درحال ارسال کد
</>
) : (
"تایید و ادامه"
)}
</Button>
<Button
variant="outline"
size="lg"
className="w-full"
onClick={() => onOpenChange(false)}
>
انصراف
</Button>
</div>
</>
)}
</div>
</DialogContent>
</Dialog>
);
}