Vitron-Front/app/routes/login.tsx
Arda Samadi d0571f8253 Login: 6-digit OTP + desktop layout
- OTP input now 6 digits (matches backend 6-digit codes): maxLength 6, six
  slots with responsive widths, verify enabled at length === 6
- login page: centered desktop card on a neutral canvas with brand mark
- MyLayout: auth pages (/login, /logout) get full-width desktop canvas and no
  mobile bottom nav at lg (no shopping header/footer)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 21:36:42 +03:30

354 lines
12 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 { authenticator } from "~/auth.server";
import {
ActionFunctionArgs,
json,
redirect,
LoaderFunctionArgs,
MetaFunction,
} from "@remix-run/node";
import { sessionStorage } from "~/sessions.server";
import { useServiceSendOtp, useServiceVerifyOtp } from "~/utils/RequestHandler";
import { useEffect, useState } from "react";
import verifyBanner from "app/assets/icons/login/verify-banner.svg";
import { Button } from "~/components/ui/button";
import { ArrowLeft, Loader2, Phone, RotateCcw } from "lucide-react";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "~/components/ui/input-otp";
import AppInput from "~/components/AppInput";
import { useToast } from "~/hooks/use-toast";
import { useSubmit, useLoaderData } from "@remix-run/react";
import { User } from "~/utils/token-manager.server";
import logo from "~/assets/logo/SVG-07.svg";
// Only allow same-site, path-relative redirects. Rejects absolute URLs
// (https://evil.com) and protocol-relative URLs (//evil.com) to prevent
// open-redirect / phishing after login.
function safeRedirect(
to: FormDataEntryValue | string | null,
defaultRedirect = "/"
): string {
if (!to || typeof to !== "string") return defaultRedirect;
if (!to.startsWith("/") || to.startsWith("//") || to.startsWith("/\\")) {
return defaultRedirect;
}
return to;
}
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const redirectTo = safeRedirect(url.searchParams.get("redirectTo"));
return json({ redirectTo });
}
// 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;
};
export default function LoginPage() {
const { redirectTo } = useLoaderData<typeof loader>();
const { toast } = useToast();
const [phoneNumber, setPhoneNumber] = useState("");
const [otpValue, setOtpValue] = useState("");
const [enableVerifySection, setEnableVerifySection] = useState(false);
const sendOtpMutation = useServiceSendOtp(
phoneNumber,
setEnableVerifySection
);
const verifyOtpMutation = useServiceVerifyOtp(phoneNumber, otpValue, toast);
const submit = useSubmit();
useEffect(() => {
if (
verifyOtpMutation.data &&
verifyOtpMutation.data.accessToken &&
verifyOtpMutation.data.refreshToken
) {
console.log("redirectTo", redirectTo);
submit(
{
phoneNumber: phoneNumber,
access_token: JSON.stringify(verifyOtpMutation.data.accessToken),
refresh_token: JSON.stringify(verifyOtpMutation.data.refreshToken),
redirectTo: redirectTo,
},
{
action: "/login",
method: "post",
encType: "application/x-www-form-urlencoded",
}
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [verifyOtpMutation.data]);
const [canResend, setCanResend] = useState(false);
useEffect(() => {
if (enableVerifySection) setCanResend(false);
}, [enableVerifySection]);
const handleTimerEnd = () => {
setCanResend(true);
};
return (
<div className="lg:min-h-screen lg:flex lg:items-center lg:justify-center lg:bg-WHITE2 lg:py-12">
<div className="flex flex-col gap-6 px-[20px] py-12 items-center w-full lg:max-w-[460px] lg:bg-WHITE lg:rounded-2xl lg:border lg:border-WHITE3 lg:shadow-lg lg:px-8 lg:py-10 lg:my-12">
{/* Brand (desktop) */}
<div className="hidden lg:flex items-center gap-2.5 mb-1">
<img src={logo} alt="ویترون" className="w-7 h-9 object-contain" />
<b className="text-[22px] font-extrabold text-VITROWN_BLUE">ویترون</b>
</div>
{enableVerifySection ? (
<>
<div className="flex flex-col gap-4 w-full items-center">
<img alt="" src={verifyBanner} className="w-[85px] h-[70px]" />
<p className={"text-R18"}>کد تایید شماره موبایل</p>
<p className="text-BLACK2 text-R12">
کد ارسال شده به {phoneNumber} را وارد کنید:
</p>
<Button
variant="ghost"
className="text-BLUE"
size={"lg"}
onClick={() => setEnableVerifySection(false)}
>
ویرایش شماره موبایل
<ArrowLeft />
</Button>
</div>
<div className="w-full mt-2 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-4 w-full flex flex-col items-center">
<Button
disabled={verifyOtpMutation.isPending || !(otpValue.length === 6)}
className="w-[90%]"
size={"xl"}
variant="dark"
onClick={() => verifyOtpMutation.mutate()}
>
{verifyOtpMutation.isPending ? (
<>
<Loader2 className="animate-spin" />
درحال تایید...
</>
) : (
"تایید شماره موبایل"
)}
</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: "کد مجددا ارسال گردید",
});
}}
>
<span className="text-B12">ارسال مجدد کد</span>
<RotateCcw className="size-5" />
</Button>
)}
</div>
</div>
</>
) : (
<>
<div className="flex flex-col gap-4 w-full items-center">
<p className="text-R18">به ویترون خوش آمدید!</p>
<p className="text-GRAY text-R14">
برای ادامه اطلاعات زیر را وارد کنید
</p>
</div>
<div className="w-full mt-8">
<AppInput
inputTitle={"شماره موبایل خود را وارد کنید:"}
inputIcon={Phone}
inputValue={phoneNumber}
inputPlaceholder={"مثل ۰۹۳۹۱۲۳۴۵۶۷"}
inputOnChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// Convert Persian numbers to English
const convertedValue = convertPersianToEnglish(value);
// Only allow English digits
if (/^\d*$/.test(convertedValue)) {
setPhoneNumber(convertedValue);
}
}}
/>
</div>
<Button
disabled={
sendOtpMutation.isPending ||
!(phoneNumber.length === 11 && phoneNumber.startsWith("0"))
}
size={"xl"}
variant={"dark"}
className="w-full"
onClick={() => {
sendOtpMutation.mutate();
}}
>
{sendOtpMutation.isPending ? (
<>
<Loader2 className="animate-spin" />
درحال ارسال کد
</>
) : (
"تایید و ادامه"
)}
</Button>
</>
)}
</div>
</div>
);
}
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 const meta: MetaFunction = () => {
const title = "ورود و ثبت نام | ویترون";
const description =
"ورود یا ثبت نام در ویترون - پلتفرم خرید آنلاین. دسترسی آسان و سریع به حساب کاربری خود برای تجربه خرید بهتر.";
const imageUrl =
"https://vitrownstatics.s3.ir-thr-at1.arvanstorage.ir/SVG-06.svg?versionId=";
return [
{ title },
{ name: "description", content: description },
{ name: "keywords", content: "ورود ویترون، ثبت نام، حساب کاربری، لاگین" },
{ name: "robots", content: "noindex, follow" },
// Open Graph
{ property: "og:type", content: "website" },
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "og:image", content: imageUrl },
{ property: "og:site_name", content: "ویترون" },
{ property: "og:locale", content: "fa_IR" },
// Twitter Card
{ name: "twitter:card", content: "summary" },
{ name: "twitter:title", content: title },
{ name: "twitter:description", content: description },
];
};
export async function action({ request }: ActionFunctionArgs) {
const clonedRequest = request.clone();
const formData = await clonedRequest.formData();
const redirectTo = safeRedirect(formData.get("redirectTo"));
try {
const user = (await authenticator.authenticate(
"vitrown-login",
request
)) as User;
if (!user.access_token) {
return redirect("/login");
}
const session = await sessionStorage.getSession(
request.headers.get("cookie")
);
const currentTime = Date.now();
// Store both tokens with their timestamps
session.set("user", {
access_token: user.access_token,
refresh_token: user.refresh_token,
access_token_timestamp: currentTime,
refresh_token_timestamp: currentTime,
});
return redirect(redirectTo, {
headers: {
"Set-Cookie": await sessionStorage.commitSession(session, {
expires: new Date(new Date().setMonth(new Date().getMonth() + 1)),
}),
},
});
} catch (error) {
console.log("error", error);
return json({ error: "Authentication failed" }, { status: 401 });
}
}