Initial commit
This commit is contained in:
BIN
public/images/icons/eye-open.png
Normal file
BIN
public/images/icons/eye-open.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/images/icons/eye-slash.png
Normal file
BIN
public/images/icons/eye-slash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
@@ -1,12 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
|
||||
<url><loc>https://modstagram.com/sitemap.xml</loc><lastmod>2026-07-02T19:42:50.918Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://modstagram.com/robots.txt</loc><lastmod>2026-07-02T19:42:50.919Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://modstagram.com/explore</loc><lastmod>2026-07-02T19:42:50.919Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://modstagram.com/academy/payment/failed</loc><lastmod>2026-07-02T19:42:50.919Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://modstagram.com/academy/payment/success</loc><lastmod>2026-07-02T19:42:50.919Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://modstagram.com/about-us</loc><lastmod>2026-07-02T19:42:50.919Z</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>
|
||||
<url><loc>https://modstagram.com/robots.txt</loc><lastmod>2026-07-07T08:04:14.348Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://modstagram.com/sitemap.xml</loc><lastmod>2026-07-07T08:04:14.349Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://modstagram.com/explore</loc><lastmod>2026-07-07T08:04:14.350Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://modstagram.com/academy/payment/failed</loc><lastmod>2026-07-07T08:04:14.350Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://modstagram.com/academy/payment/success</loc><lastmod>2026-07-07T08:04:14.350Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://modstagram.com/about-us</loc><lastmod>2026-07-07T08:04:14.350Z</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>
|
||||
<url><loc>https://modstagram.com</loc><changefreq>daily</changefreq><priority>1</priority></url>
|
||||
<url><loc>https://modstagram.com/projects</loc><changefreq>daily</changefreq><priority>0.9</priority></url>
|
||||
<url><loc>https://modstagram.com/billboards</loc><changefreq>daily</changefreq><priority>0.9</priority></url>
|
||||
<url><loc>https://modstagram.com/billboards/6a4b9700a05fe9d26fa4312f/%D8%AE%D8%AF%D9%85%D8%A7%D8%AA%20%D8%B3%D8%A7%D9%84%D9%86%20%D8%B2%DB%8C%D8%A8%D8%A7%DB%8C%DB%8C%20%D8%AF%DA%A9%D8%AA%D8%B1%20%D8%B2%D8%A7%D8%B1%D8%A7</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
|
||||
<url><loc>https://modstagram.com/billboards/6a0ef05d12cadb57c40ee4b2/%D8%B9%DA%A9%D8%A7%D8%B3%DB%8C%20%D8%A8%D8%A8%D8%B1%DB%8C%20%D8%A2%D8%B1%D8%AA</loc><changefreq>daily</changefreq><priority>0.8</priority></url>
|
||||
</urlset>
|
||||
@@ -41,12 +41,16 @@ export async function fetchBillboards(
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[fetchBillboards] fetch failed:", apiUrl, error);
|
||||
return { advertisings: [], totalItems: 0, totalPages: 0 };
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
console.error("[fetchBillboards] bad response:", response.status, apiUrl);
|
||||
return { advertisings: [], totalItems: 0, totalPages: 0 };
|
||||
}
|
||||
|
||||
return response.json();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import AuthButton from "@/components/auth/AuthButton";
|
||||
import AuthHead from "@/components/auth/AuthHead";
|
||||
import AuthInput from "@/components/auth/AuthInput";
|
||||
import AuthPasswordInput from "@/components/auth/AuthPasswordInput";
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthRules from "@/components/auth/AuthRules";
|
||||
import Link from "next/link";
|
||||
@@ -71,9 +71,8 @@ function ChangePassword() {
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthInput
|
||||
<AuthPasswordInput
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="کلمه عبور جدید"
|
||||
className={`border mt-4 ${
|
||||
formik.touched.password && formik.errors.password
|
||||
@@ -90,9 +89,8 @@ function ChangePassword() {
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthInput
|
||||
<AuthPasswordInput
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
placeholder="تکرار کلمه عبور"
|
||||
className={`border mt-4 ${
|
||||
formik.touched.confirmPassword && formik.errors.confirmPassword
|
||||
|
||||
@@ -7,13 +7,14 @@ import Container from "@/components/elements/Container";
|
||||
import AuthRules from "@/components/auth/AuthRules";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
import OtpInput from "react-otp-input";
|
||||
import AuthOtpInput from "@/components/auth/AuthOtpInput";
|
||||
import AuthOtpStatus from "@/components/auth/AuthOtpStatus";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { IVerifyOtp } from "@/types/types";
|
||||
import Cookies from "js-cookie";
|
||||
import { setAuthSession } from "@/lib/auth/session";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
@@ -26,6 +27,8 @@ const schema = yup.object({
|
||||
function ForgetPasswordOtp() {
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const [otpExpired, setOtpExpired] = useState(false);
|
||||
const [resendLoading, setResendLoading] = useState(false);
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
@@ -47,12 +50,7 @@ function ForgetPasswordOtp() {
|
||||
mobile,
|
||||
otp: values.otp.trim(),
|
||||
})) as IVerifyOtp;
|
||||
document.cookie = `token=${response.token}; path=/; Secure`;
|
||||
Cookies.set("token", response.token, {
|
||||
secure: false,
|
||||
path: "/",
|
||||
expires: 365
|
||||
});
|
||||
await setAuthSession(response.token);
|
||||
await localStorage.setItem("otp", values.otp);
|
||||
router.push("/change-passowrd");
|
||||
} catch (err: any) {
|
||||
@@ -65,31 +63,32 @@ function ForgetPasswordOtp() {
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="فراموش کردن کلمه عبور" />
|
||||
<small className="mb-10">کد 6 رقمی به شماره {mobile} ارسال شد</small>
|
||||
<AuthOtpStatus
|
||||
mobile={mobile}
|
||||
resendLoading={resendLoading}
|
||||
onExpiredChange={setOtpExpired}
|
||||
onResend={async () => {
|
||||
if (!mobile) return;
|
||||
setResendLoading(true);
|
||||
try {
|
||||
await request("POST", "/login", { mobile });
|
||||
formik.setFieldValue("otp", "");
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<OtpInput
|
||||
<AuthOtpInput
|
||||
className="mt-3"
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => {
|
||||
if (formik.values.otp !== otp) {
|
||||
formik.setFieldValue("otp", otp);
|
||||
if (otp.length === 6) {
|
||||
setTimeout(() => {
|
||||
formik.submitForm();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}}
|
||||
numInputs={6}
|
||||
containerStyle="mt-3 dir-ltr flex gap-x-4 justify-center"
|
||||
inputStyle={`text-center min-w-[30px] border bg-secondary-light dark:bg-secondary-dark rounded-2xl py-3 ${
|
||||
formik.touched.otp && formik.errors.otp
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-border-primary-light dark:border-border-primary-dark"
|
||||
} focus:outline-none`}
|
||||
renderInput={(props) => <input {...props} />}
|
||||
onChange={(otp) => formik.setFieldValue("otp", otp)}
|
||||
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
|
||||
error={Boolean(formik.touched.otp && formik.errors.otp)}
|
||||
disabled={loading || otpExpired}
|
||||
/>
|
||||
{formik.touched.otp && formik.errors.otp && (
|
||||
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
|
||||
@@ -97,7 +96,7 @@ function ForgetPasswordOtp() {
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
disabled={loading || formik.values.otp.length !== 6}
|
||||
disabled={loading || otpExpired || formik.values.otp.length !== 6}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "تایید کد"}
|
||||
</AuthButton>
|
||||
|
||||
@@ -9,7 +9,7 @@ import React from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { markOtpSent } from "@/lib/auth/otpTimer";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
@@ -32,6 +32,7 @@ function ForgetPassword() {
|
||||
try {
|
||||
await request("POST", "/login", { mobile: values.mobile });
|
||||
await localStorage.setItem("mobile", values.mobile);
|
||||
markOtpSent();
|
||||
router.push("/forget-password-otp");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import AuthButton from "@/components/auth/AuthButton";
|
||||
import AuthHead from "@/components/auth/AuthHead";
|
||||
import AuthInput from "@/components/auth/AuthInput";
|
||||
import AuthPasswordInput from "@/components/auth/AuthPasswordInput";
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthRules from "@/components/auth/AuthRules";
|
||||
import Link from "next/link";
|
||||
@@ -13,7 +14,7 @@ import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { IVerifyOtp } from "@/types/types";
|
||||
import Cookies from "js-cookie";
|
||||
import { setAuthSession } from "@/lib/auth/session";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object().shape({
|
||||
@@ -50,27 +51,19 @@ function LoginWithUsername() {
|
||||
})) as IVerifyOtp;
|
||||
switch (response?.page) {
|
||||
case "home":
|
||||
document.cookie = `token=${response.token}; path=/; Secure`;
|
||||
Cookies.set("token", response.token, {
|
||||
secure: false,
|
||||
path: "/",
|
||||
expires: 365,
|
||||
await setAuthSession(response.token, {
|
||||
id: response.id,
|
||||
user_type: response.user_type,
|
||||
step: response.step,
|
||||
});
|
||||
|
||||
await localStorage.setItem("usertype", response.user_type);
|
||||
await localStorage.setItem("id", response.id);
|
||||
await localStorage.setItem("step", response.step);
|
||||
router.refresh();
|
||||
router.push("/");
|
||||
break;
|
||||
case "auth-page":
|
||||
Cookies.set("token", response.token, {
|
||||
secure: false,
|
||||
path: "/",
|
||||
expires: 365,
|
||||
await setAuthSession(response.token, {
|
||||
id: response.id,
|
||||
step: response.step,
|
||||
});
|
||||
// await localStorage.setItem("usertype", response.user_type);
|
||||
await localStorage.setItem("id", response.id);
|
||||
await localStorage.setItem("step", response.step);
|
||||
router.push("/auth");
|
||||
break;
|
||||
default:
|
||||
@@ -108,9 +101,8 @@ function LoginWithUsername() {
|
||||
{formik.errors.username}
|
||||
</small>
|
||||
)}
|
||||
<AuthInput
|
||||
<AuthPasswordInput
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="کلمه عبور"
|
||||
className={`border mt-4 ${
|
||||
formik.touched.password && formik.errors.password
|
||||
|
||||
@@ -10,7 +10,7 @@ import React, { useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { markOtpSent } from "@/lib/auth/otpTimer";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
@@ -34,6 +34,7 @@ function Login() {
|
||||
try {
|
||||
await request("POST", "/login", { mobile: values.mobile });
|
||||
await localStorage.setItem("mobile", values.mobile);
|
||||
markOtpSent();
|
||||
router.push("/verify-otp");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
|
||||
@@ -7,13 +7,14 @@ import Container from "@/components/elements/Container";
|
||||
import AuthRules from "@/components/auth/AuthRules";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
import OtpInput from "react-otp-input";
|
||||
import AuthOtpInput from "@/components/auth/AuthOtpInput";
|
||||
import AuthOtpStatus from "@/components/auth/AuthOtpStatus";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { IVerifyOtp } from "@/types/types";
|
||||
import Cookies from "js-cookie";
|
||||
import { setAuthSession } from "@/lib/auth/session";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
@@ -26,6 +27,8 @@ const schema = yup.object({
|
||||
function VerifyOtp() {
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const [otpExpired, setOtpExpired] = useState(false);
|
||||
const [resendLoading, setResendLoading] = useState(false);
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
@@ -50,25 +53,19 @@ function VerifyOtp() {
|
||||
|
||||
switch (response?.page) {
|
||||
case "home":
|
||||
Cookies.set("token", response.token, {
|
||||
secure: false,
|
||||
path: "/",
|
||||
expires: 365,
|
||||
await setAuthSession(response.token, {
|
||||
id: response.id,
|
||||
user_type: response.user_type,
|
||||
step: response.step,
|
||||
});
|
||||
await localStorage.setItem("usertype", response.user_type);
|
||||
await localStorage.setItem("id", response.id);
|
||||
await localStorage.setItem("step", response.step);
|
||||
router.refresh();
|
||||
router.push("/");
|
||||
break;
|
||||
case "auth-page":
|
||||
Cookies.set("token", response.token, {
|
||||
secure: false,
|
||||
path: "/",
|
||||
expires: 365,
|
||||
await setAuthSession(response.token, {
|
||||
id: response.id,
|
||||
step: response.step,
|
||||
});
|
||||
// await localStorage.setItem("usertype", response.user_type);
|
||||
await localStorage.setItem("id", response.id);
|
||||
await localStorage.setItem("step", response.step);
|
||||
|
||||
switch (response.step) {
|
||||
case "user_name":
|
||||
@@ -102,34 +99,32 @@ function VerifyOtp() {
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="ورود" />
|
||||
<small className="mb-10">کد 6 رقمی به شماره {mobile} ارسال شد</small>
|
||||
<AuthOtpStatus
|
||||
mobile={mobile}
|
||||
resendLoading={resendLoading}
|
||||
onExpiredChange={setOtpExpired}
|
||||
onResend={async () => {
|
||||
if (!mobile) return;
|
||||
setResendLoading(true);
|
||||
try {
|
||||
await request("POST", "/login", { mobile });
|
||||
formik.setFieldValue("otp", "");
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<OtpInput
|
||||
<AuthOtpInput
|
||||
className="mt-3"
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => {
|
||||
formik.setFieldValue("otp", otp);
|
||||
if (otp.length === 6) {
|
||||
setTimeout(() => formik.submitForm(), 500);
|
||||
}
|
||||
}}
|
||||
numInputs={6}
|
||||
containerStyle="mt-3 dir-ltr flex gap-x-4 justify-center"
|
||||
inputStyle={`text-center min-w-[30px] border bg-secondary-light dark:bg-secondary-dark rounded-2xl py-3 ${
|
||||
formik.touched.otp && formik.errors.otp
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-border-primary-light "
|
||||
} focus:outline-none`}
|
||||
renderInput={(props) => (
|
||||
<input
|
||||
{...props}
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
/>
|
||||
)}
|
||||
onChange={(otp) => formik.setFieldValue("otp", otp)}
|
||||
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
|
||||
error={Boolean(formik.touched.otp && formik.errors.otp)}
|
||||
disabled={loading || otpExpired}
|
||||
/>
|
||||
|
||||
{formik.touched.otp && formik.errors.otp && (
|
||||
@@ -138,7 +133,7 @@ function VerifyOtp() {
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
disabled={loading || formik.values.otp.length !== 6}
|
||||
disabled={loading || otpExpired || formik.values.otp.length !== 6}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "تایید کد"}
|
||||
</AuthButton>
|
||||
|
||||
@@ -8,13 +8,14 @@ import Container from "@/components/elements/Container";
|
||||
import AuthRules from "@/components/auth/AuthRules";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
import OtpInput from "react-otp-input";
|
||||
import AuthOtpInput from "@/components/auth/AuthOtpInput";
|
||||
import AuthOtpStatus from "@/components/auth/AuthOtpStatus";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { IVerifyOtp } from "@/types/types";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Cookies from "js-cookie";
|
||||
import { setAuthSession } from "@/lib/auth/session";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
@@ -27,6 +28,8 @@ const schema = yup.object({
|
||||
function RegisterOtp() {
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const [otpExpired, setOtpExpired] = useState(false);
|
||||
const [resendLoading, setResendLoading] = useState(false);
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
@@ -49,12 +52,7 @@ function RegisterOtp() {
|
||||
otp: values.otp.trim(),
|
||||
})) as IVerifyOtp;
|
||||
await localStorage.setItem("otp", values.otp);
|
||||
Cookies.set("token", response.token, {
|
||||
secure: false,
|
||||
path: "/",
|
||||
expires: 365
|
||||
});
|
||||
await localStorage.setItem("step", response.step);
|
||||
await setAuthSession(response.token, { step: response.step });
|
||||
response.step === "user_name"
|
||||
? router.push("/register/username")
|
||||
: response.step === "password"
|
||||
@@ -74,31 +72,32 @@ function RegisterOtp() {
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="ثبت نام" />
|
||||
<small className="mb-10">کد 6 رقمی به شماره {mobile} ارسال شد</small>
|
||||
<AuthOtpStatus
|
||||
mobile={mobile}
|
||||
resendLoading={resendLoading}
|
||||
onExpiredChange={setOtpExpired}
|
||||
onResend={async () => {
|
||||
if (!mobile) return;
|
||||
setResendLoading(true);
|
||||
try {
|
||||
await request("POST", "/register", { mobile });
|
||||
formik.setFieldValue("otp", "");
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<OtpInput
|
||||
<AuthOtpInput
|
||||
className="mt-3"
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => {
|
||||
if (formik.values.otp !== otp) {
|
||||
formik.setFieldValue("otp", otp);
|
||||
if (otp.length === 6) {
|
||||
setTimeout(() => {
|
||||
formik.submitForm();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}}
|
||||
numInputs={6}
|
||||
containerStyle="mt-3 dir-ltr flex gap-x-4 justify-center"
|
||||
inputStyle={`text-center min-w-[30px] border bg-secondary-light dark:bg-secondary-dark rounded-2xl py-3 ${
|
||||
formik.touched.otp && formik.errors.otp
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-border-primary-light dark:border-border-primary-dark"
|
||||
} focus:outline-none`}
|
||||
renderInput={(props) => <input {...props} />}
|
||||
onChange={(otp) => formik.setFieldValue("otp", otp)}
|
||||
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
|
||||
error={Boolean(formik.touched.otp && formik.errors.otp)}
|
||||
disabled={loading || otpExpired}
|
||||
/>
|
||||
{formik.touched.otp && formik.errors.otp && (
|
||||
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
|
||||
@@ -106,7 +105,7 @@ function RegisterOtp() {
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
disabled={loading || formik.values.otp.length !== 6}
|
||||
disabled={loading || otpExpired || formik.values.otp.length !== 6}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "تایید کد"}
|
||||
</AuthButton>
|
||||
|
||||
@@ -9,7 +9,7 @@ import React, { useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { markOtpSent } from "@/lib/auth/otpTimer";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
@@ -33,6 +33,7 @@ function Register() {
|
||||
try {
|
||||
await request("POST", "/register", { mobile: values.mobile });
|
||||
await localStorage.setItem("mobile", values.mobile);
|
||||
markOtpSent();
|
||||
router.push("/register-otp");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"use client";
|
||||
|
||||
import AuthHead from "@/components/auth/AuthHead";
|
||||
import AuthInput from "@/components/auth/AuthInput";
|
||||
import AuthPasswordInput from "@/components/auth/AuthPasswordInput";
|
||||
import Container from "@/components/elements/Container";
|
||||
import React from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
@@ -68,9 +68,8 @@ function PasswordPage() {
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthInput
|
||||
<AuthPasswordInput
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="کلمه عبور"
|
||||
className={`border mt-4 ${
|
||||
formik.touched.password && formik.errors.password
|
||||
@@ -87,9 +86,8 @@ function PasswordPage() {
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthInput
|
||||
<AuthPasswordInput
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
placeholder="تکرار کلمه عبور"
|
||||
className={`border mt-4 ${
|
||||
formik.touched.confirmPassword && formik.errors.confirmPassword
|
||||
|
||||
@@ -4,27 +4,21 @@
|
||||
import AuthHead from "@/components/auth/AuthHead";
|
||||
import AuthInput from "@/components/auth/AuthInput";
|
||||
import Container from "@/components/elements/Container";
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { IVerifyOtp } from "@/types/types";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
// ✅ فقط چک میکنیم مقدار وارد شده باشه
|
||||
const schema = yup.object().shape({
|
||||
username: yup
|
||||
.string()
|
||||
.required("نام کاربری الزامی است")
|
||||
.min(1, "نام کاربری باید حداقل ۱ کاراکتر باشد")
|
||||
.max(100, "نام کاربری نمیتواند بیشتر از ۱۰۰ کاراکتر باشد"),
|
||||
});
|
||||
import UsernameSuggestions from "@/components/auth/UsernameSuggestions";
|
||||
import { usernameFormSchema } from "@/lib/validation/usernameSchema";
|
||||
import { sanitizeUsernameInput } from "@/lib/validation/username";
|
||||
|
||||
function UsernamePage() {
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const [duplicateError, setDuplicateError] = useState<string | null>(null);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
@@ -32,27 +26,42 @@ function UsernamePage() {
|
||||
initialValues: {
|
||||
username: "",
|
||||
},
|
||||
validationSchema: schema,
|
||||
validationSchema: usernameFormSchema,
|
||||
onSubmit: async (values) => {
|
||||
setDuplicateError(null);
|
||||
setSuggestions([]);
|
||||
|
||||
try {
|
||||
await request("POST", "/register/username", {
|
||||
mobile,
|
||||
user_name: values.username,
|
||||
user_name: values.username.trim().toLowerCase(),
|
||||
}) as IVerifyOtp;
|
||||
|
||||
localStorage.setItem("username", values.username);
|
||||
localStorage.setItem("username", values.username.trim().toLowerCase());
|
||||
router.push("/register/password");
|
||||
} catch (err: any) {
|
||||
console.log("Unhandled error:", err?.message);
|
||||
if (err?.response?.data?.message) {
|
||||
toast.error(err.response.data.message);
|
||||
} else {
|
||||
toast.error("خطایی رخ داد، دوباره تلاش کنید");
|
||||
const data = err?.response?.data;
|
||||
if (data?.message) {
|
||||
setDuplicateError(data.message);
|
||||
setSuggestions(Array.isArray(data.suggestions) ? data.suggestions : []);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleUsernameChange = (value: string) => {
|
||||
setDuplicateError(null);
|
||||
setSuggestions([]);
|
||||
formik.setFieldValue("username", sanitizeUsernameInput(value));
|
||||
};
|
||||
|
||||
const handleSuggestionSelect = (username: string) => {
|
||||
formik.setFieldValue("username", username);
|
||||
formik.setFieldTouched("username", true, false);
|
||||
setDuplicateError(null);
|
||||
setSuggestions([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
@@ -64,23 +73,37 @@ function UsernamePage() {
|
||||
<AuthInput
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="نام کاربری خود را وارد کنید"
|
||||
dir="auto" // ✅ اجازه ورود همه کاراکترها (فارسی، انگلیسی، ایموجی...)
|
||||
placeholder="example_user"
|
||||
dir="ltr"
|
||||
autoComplete="username"
|
||||
spellCheck={false}
|
||||
className={`border ${
|
||||
formik.touched.username && formik.errors.username
|
||||
(formik.touched.username && formik.errors.username) || duplicateError
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.username}
|
||||
onChange={formik.handleChange}
|
||||
onChange={(e) => handleUsernameChange(e.target.value)}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.username && formik.errors.username && (
|
||||
<small className="text-gray-500 dark:text-gray-400 text-xs mt-2 text-center leading-5">
|
||||
فقط حروف انگلیسی، اعداد و کاراکترهای . _ - (حداقل ۶ کاراکتر)
|
||||
</small>
|
||||
{formik.touched.username && formik.errors.username && !duplicateError && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.username}
|
||||
</small>
|
||||
)}
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
<UsernameSuggestions
|
||||
message={duplicateError || undefined}
|
||||
suggestions={suggestions}
|
||||
onSelect={handleSuggestionSelect}
|
||||
/>
|
||||
<AuthNextButton
|
||||
type="submit"
|
||||
className="mt-20"
|
||||
disabled={loading || formik.values.username.length < 6}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
|
||||
40
src/app/api/auth/session/route.ts
Normal file
40
src/app/api/auth/session/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const TOKEN_KEY = "token";
|
||||
const MAX_AGE = 365 * 24 * 60 * 60;
|
||||
|
||||
function cookieOptions() {
|
||||
return {
|
||||
path: "/",
|
||||
maxAge: MAX_AGE,
|
||||
sameSite: "lax" as const,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
httpOnly: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const token = typeof body?.token === "string" ? body.token.trim() : "";
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "token required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(TOKEN_KEY, token, cookieOptions());
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "invalid request" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete({ name: TOKEN_KEY, path: "/" });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"use client";
|
||||
|
||||
import AuthHead from "@/components/auth/AuthHead";
|
||||
import AuthInput from "@/components/auth/AuthInput";
|
||||
import AuthPasswordInput from "@/components/auth/AuthPasswordInput";
|
||||
import Container from "@/components/elements/Container";
|
||||
import React from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
@@ -64,9 +64,8 @@ function PasswordPage() {
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthInput
|
||||
<AuthPasswordInput
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="کلمه عبور"
|
||||
className={`border mt-4 ${
|
||||
formik.touched.password && formik.errors.password
|
||||
@@ -83,9 +82,8 @@ function PasswordPage() {
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthInput
|
||||
<AuthPasswordInput
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
placeholder="تکرار کلمه عبور"
|
||||
className={`border mt-4 ${
|
||||
formik.touched.confirmPassword && formik.errors.confirmPassword
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
@@ -10,41 +9,55 @@ import AuthHead from "@/components/auth/AuthHead";
|
||||
import AuthInput from "@/components/auth/AuthInput";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import Container from "@/components/elements/Container";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object().shape({
|
||||
username: yup.string().required("نام کاربری الزامی است"),
|
||||
});
|
||||
import UsernameSuggestions from "@/components/auth/UsernameSuggestions";
|
||||
import { usernameFormSchema } from "@/lib/validation/usernameSchema";
|
||||
import { sanitizeUsernameInput } from "@/lib/validation/username";
|
||||
|
||||
function UsernamePage() {
|
||||
const router = useRouter();
|
||||
const user = useUser();
|
||||
const { request, loading } = useAxios();
|
||||
const [duplicateError, setDuplicateError] = useState<string | null>(null);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
username: user?.user_name || "",
|
||||
},
|
||||
validationSchema: schema,
|
||||
validationSchema: usernameFormSchema,
|
||||
enableReinitialize: true,
|
||||
onSubmit: async (values) => {
|
||||
setDuplicateError(null);
|
||||
setSuggestions([]);
|
||||
|
||||
try {
|
||||
await request("PATCH", "/register/username", {
|
||||
user_name: values.username,
|
||||
user_name: values.username.trim().toLowerCase(),
|
||||
});
|
||||
localStorage.setItem("username", values.username);
|
||||
localStorage.setItem("username", values.username.trim().toLowerCase());
|
||||
router.push("/settings/edit");
|
||||
} catch (err: any) {
|
||||
console.log("Unhandled error:", err?.message);
|
||||
const data = err?.response?.data;
|
||||
if (data?.message) {
|
||||
setDuplicateError(data.message);
|
||||
setSuggestions(Array.isArray(data.suggestions) ? data.suggestions : []);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// آپدیت مقدار username در صورتی که user.user_name مقدار داشته باشد
|
||||
useEffect(() => {
|
||||
if (user?.user_name) {
|
||||
formik.setValues({ username: user.user_name });
|
||||
}
|
||||
}, [user?.user_name]);
|
||||
const handleUsernameChange = (value: string) => {
|
||||
setDuplicateError(null);
|
||||
setSuggestions([]);
|
||||
formik.setFieldValue("username", sanitizeUsernameInput(value));
|
||||
};
|
||||
|
||||
const handleSuggestionSelect = (username: string) => {
|
||||
formik.setFieldValue("username", username);
|
||||
formik.setFieldTouched("username", true, false);
|
||||
setDuplicateError(null);
|
||||
setSuggestions([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
@@ -57,22 +70,37 @@ function UsernamePage() {
|
||||
<AuthInput
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="نام کاربری خود را وارد کنید"
|
||||
placeholder="example_user"
|
||||
dir="ltr"
|
||||
autoComplete="username"
|
||||
spellCheck={false}
|
||||
className={`border ${
|
||||
formik.touched.username && formik.errors.username
|
||||
(formik.touched.username && formik.errors.username) || duplicateError
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.username}
|
||||
onChange={formik.handleChange}
|
||||
onChange={(e) => handleUsernameChange(e.target.value)}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.username && formik.errors.username && (
|
||||
<small className="text-gray-500 dark:text-gray-400 text-xs mt-2 text-center leading-5">
|
||||
فقط حروف انگلیسی، اعداد و کاراکترهای . _ - (حداقل ۶ کاراکتر)
|
||||
</small>
|
||||
{formik.touched.username && formik.errors.username && !duplicateError && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.username}
|
||||
</small>
|
||||
)}
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
<UsernameSuggestions
|
||||
message={duplicateError || undefined}
|
||||
suggestions={suggestions}
|
||||
onSelect={handleSuggestionSelect}
|
||||
/>
|
||||
<AuthNextButton
|
||||
type="submit"
|
||||
className="mt-20"
|
||||
disabled={loading || formik.values.username.length < 6}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ویرایش"}
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
|
||||
@@ -2,16 +2,23 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { isAuthenticated } from "@/lib/auth/session";
|
||||
|
||||
const ROUTES = ["/academy", "/billboards", "/settings", "/explore"];
|
||||
const PUBLIC_ROUTES = ["/academy", "/billboards", "/explore"];
|
||||
|
||||
export default function BackgroundPrefetch() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => {
|
||||
ROUTES.forEach((route) => router.prefetch(route));
|
||||
PUBLIC_ROUTES.forEach((route) => router.prefetch(route));
|
||||
|
||||
// settings فقط وقتی لاگین است prefetch شود
|
||||
if (isAuthenticated()) {
|
||||
router.prefetch("/settings");
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
return () => window.clearTimeout(t);
|
||||
}, [router]);
|
||||
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import React, { ComponentProps } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TInput = ComponentProps<"input"> & {};
|
||||
|
||||
function AuthInput({ style, className, ...rest }: TInput) {
|
||||
return (
|
||||
<input
|
||||
className={`w-full dir-ltr max-w-[290px] p-3 text-center rounded-2xl border border-border-secondary-light dark:border-border-secondary-dark bg-secondary-light dark:bg-secondary-dark font-medium ${className}`}
|
||||
className={cn(
|
||||
"w-full dir-ltr max-w-[290px] p-3.5 text-center text-lg font-semibold rounded-2xl",
|
||||
"border-2 border-gray-200 dark:border-gray-300",
|
||||
"bg-white text-gray-900 placeholder:text-gray-400",
|
||||
"dark:bg-white dark:text-gray-900 dark:placeholder:text-gray-500",
|
||||
"focus:outline-none focus:border-[#387E65] focus:ring-2 focus:ring-[#387E65]/30",
|
||||
"shadow-sm transition-[border-color,box-shadow] duration-200",
|
||||
className
|
||||
)}
|
||||
style={{ ...style }}
|
||||
{...rest}
|
||||
/>
|
||||
|
||||
82
src/components/auth/AuthOtpInput.tsx
Normal file
82
src/components/auth/AuthOtpInput.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import OtpInput from "react-otp-input";
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AuthOtpInputProps {
|
||||
value: string;
|
||||
onChange: (otp: string) => void;
|
||||
onComplete?: (otp: string) => void;
|
||||
numInputs?: number;
|
||||
error?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const cellBase =
|
||||
"w-11 h-14 sm:w-12 sm:h-14 min-w-[44px] text-center text-2xl font-bold tabular-nums rounded-2xl border-[2.5px] bg-white text-gray-900 shadow-md outline-none caret-[#387E65] selection:bg-sky-200 dark:bg-white dark:text-gray-900 dark:caret-[#387E65]";
|
||||
|
||||
export default function AuthOtpInput({
|
||||
value,
|
||||
onChange,
|
||||
onComplete,
|
||||
numInputs = 6,
|
||||
error = false,
|
||||
disabled = false,
|
||||
className,
|
||||
}: AuthOtpInputProps) {
|
||||
return (
|
||||
<div className={cn("w-full flex justify-center", className)}>
|
||||
<OtpInput
|
||||
value={value}
|
||||
onChange={(otp) => {
|
||||
onChange(otp);
|
||||
if (otp.length === numInputs) {
|
||||
onComplete?.(otp);
|
||||
}
|
||||
}}
|
||||
numInputs={numInputs}
|
||||
inputType="tel"
|
||||
shouldAutoFocus
|
||||
skipDefaultStyles
|
||||
containerStyle={{
|
||||
display: "flex",
|
||||
direction: "ltr",
|
||||
gap: "0.5rem",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
}}
|
||||
renderInput={(inputProps, index) => {
|
||||
const filled = Boolean(inputProps.value);
|
||||
|
||||
return (
|
||||
<motion.input
|
||||
{...inputProps}
|
||||
disabled={disabled}
|
||||
animate={
|
||||
filled
|
||||
? { scale: [0.88, 1.1, 1], y: [6, -2, 0], opacity: 1 }
|
||||
: { scale: 1, y: 0, opacity: 1 }
|
||||
}
|
||||
transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
|
||||
className={cn(
|
||||
cellBase,
|
||||
filled
|
||||
? "border-[#387E65] shadow-lg ring-2 ring-[#387E65]/25"
|
||||
: "border-gray-500 dark:border-gray-400 shadow-md",
|
||||
error && "border-red-500 ring-red-500/30",
|
||||
"focus:border-[#387E65] focus:ring-2 focus:ring-[#387E65]/40 focus:shadow-lg",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autoComplete={index === 0 ? "one-time-code" : "off"}
|
||||
aria-label={`رقم ${index + 1} از ${numInputs}`}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
src/components/auth/AuthOtpStatus.tsx
Normal file
87
src/components/auth/AuthOtpStatus.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
formatOtpCountdown,
|
||||
getOtpRemainingSeconds,
|
||||
markOtpSent,
|
||||
OTP_VALID_SECONDS,
|
||||
} from "@/lib/auth/otpTimer";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AuthOtpStatusProps {
|
||||
mobile: string | null;
|
||||
onResend: () => Promise<void>;
|
||||
resendLoading?: boolean;
|
||||
className?: string;
|
||||
onExpiredChange?: (expired: boolean) => void;
|
||||
}
|
||||
|
||||
export default function AuthOtpStatus({
|
||||
mobile,
|
||||
onResend,
|
||||
resendLoading = false,
|
||||
className,
|
||||
onExpiredChange,
|
||||
}: AuthOtpStatusProps) {
|
||||
const [remaining, setRemaining] = useState(OTP_VALID_SECONDS);
|
||||
const expired = remaining <= 0;
|
||||
|
||||
useEffect(() => {
|
||||
setRemaining(getOtpRemainingSeconds());
|
||||
const interval = window.setInterval(() => {
|
||||
setRemaining(getOtpRemainingSeconds());
|
||||
}, 1000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onExpiredChange?.(expired);
|
||||
}, [expired, onExpiredChange]);
|
||||
|
||||
const handleResend = async () => {
|
||||
await onResend();
|
||||
markOtpSent();
|
||||
setRemaining(OTP_VALID_SECONDS);
|
||||
};
|
||||
|
||||
if (!mobile) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("w-full max-w-sm text-center mb-6 space-y-2", className)}>
|
||||
<p className="text-sm sm:text-base font-medium text-gray-800 dark:text-gray-100 leading-relaxed">
|
||||
کد ۶ رقمی به شماره{" "}
|
||||
<span dir="ltr" className="font-bold text-[#387E65] inline-block">
|
||||
{mobile}
|
||||
</span>{" "}
|
||||
ارسال شد
|
||||
</p>
|
||||
|
||||
{!expired ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
اعتبار کد:{" "}
|
||||
<span
|
||||
dir="ltr"
|
||||
className="font-bold tabular-nums text-[#FF5C00] text-base"
|
||||
>
|
||||
{formatOtpCountdown(remaining)}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-red-600 dark:text-red-400">
|
||||
کد منقضی شده است. لطفاً دوباره درخواست ارسال کد دهید.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResend}
|
||||
disabled={resendLoading}
|
||||
className="text-sm font-bold text-[#667AC1] hover:text-[#0033EA] disabled:opacity-50"
|
||||
>
|
||||
{resendLoading ? "در حال ارسال..." : "ارسال مجدد کد"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/components/auth/AuthPasswordInput.tsx
Normal file
54
src/components/auth/AuthPasswordInput.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import React, { ComponentProps, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AuthPasswordInputProps = Omit<ComponentProps<"input">, "type"> & {
|
||||
wrapperClassName?: string;
|
||||
};
|
||||
|
||||
function AuthPasswordInput({
|
||||
style,
|
||||
className,
|
||||
wrapperClassName,
|
||||
...rest
|
||||
}: AuthPasswordInputProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={cn("relative w-full max-w-[290px]", wrapperClassName)}>
|
||||
<input
|
||||
type={visible ? "text" : "password"}
|
||||
className={cn(
|
||||
"w-full dir-ltr p-3.5 px-12 text-center text-lg font-semibold rounded-2xl",
|
||||
"border-2 border-gray-200 dark:border-gray-300",
|
||||
"bg-white text-gray-900 placeholder:text-gray-400",
|
||||
"dark:bg-white dark:text-gray-900 dark:placeholder:text-gray-500",
|
||||
"focus:outline-none focus:border-[#387E65] focus:ring-2 focus:ring-[#387E65]/30",
|
||||
"shadow-sm transition-[border-color,box-shadow] duration-200",
|
||||
className
|
||||
)}
|
||||
style={{ ...style }}
|
||||
{...rest}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible((prev) => !prev)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-200 transition-colors"
|
||||
aria-label={visible ? "مخفی کردن کلمه عبور" : "نمایش کلمه عبور"}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Image
|
||||
src={visible ? "/images/icons/eye-slash.png" : "/images/icons/eye-open.png"}
|
||||
alt=""
|
||||
width={22}
|
||||
height={22}
|
||||
className="opacity-80"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthPasswordInput;
|
||||
48
src/components/auth/UsernameSuggestions.tsx
Normal file
48
src/components/auth/UsernameSuggestions.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface UsernameSuggestionsProps {
|
||||
message?: string;
|
||||
suggestions?: string[];
|
||||
onSelect: (username: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function UsernameSuggestions({
|
||||
message,
|
||||
suggestions = [],
|
||||
onSelect,
|
||||
className,
|
||||
}: UsernameSuggestionsProps) {
|
||||
if (!message && suggestions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("w-full max-w-sm mt-3 space-y-3", className)}>
|
||||
{message && (
|
||||
<p className="text-red-500 text-sm text-center font-medium">{message}</p>
|
||||
)}
|
||||
|
||||
{suggestions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300 text-center">
|
||||
پیشنهادهای در دسترس:
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{suggestions.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
onClick={() => onSelect(item)}
|
||||
dir="ltr"
|
||||
className="px-3 py-2 rounded-xl border-2 border-[#387E65]/40 bg-white dark:bg-white text-[#387E65] text-sm font-semibold hover:bg-[#387E65]/10 hover:border-[#387E65] transition-colors"
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
// لوکال: NEXT_PUBLIC_BASE_URL=http://localhost:3002/api/v1
|
||||
// export const SOCKET_URL = "http://localhost:8000";
|
||||
// export const BASE_URL = "http://localhost:8000/api/v1";
|
||||
// export const IMAGE_BASE_URL = "http://localhost:8000/storage";
|
||||
|
||||
export const SOCKET_URL = "https://api.modstagram.com";
|
||||
export const BASE_URL = "https://api.modstagram.com/api/v1";
|
||||
export const IMAGE_BASE_URL = "https://api.modstagram.com/storage";
|
||||
/** app.modstagram.ir — SSL معتبر برای fetch سمت سرور Next.js */
|
||||
export const SOCKET_URL =
|
||||
process.env.NEXT_PUBLIC_SOCKET_URL ?? "https://app.modstagram.ir";
|
||||
export const BASE_URL =
|
||||
process.env.NEXT_PUBLIC_BASE_URL ?? "https://app.modstagram.ir/api/v1";
|
||||
export const IMAGE_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_IMAGE_BASE_URL ?? "https://app.modstagram.ir/storage";
|
||||
|
||||
/** ساخت URL کامل فایل storage — مسیرهای DB را یکسان میکند */
|
||||
export function buildStorageUrl(path: string | null | undefined): string {
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
"use client";
|
||||
import Cookies from "js-cookie";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import Modal from "../../elements/Modal";
|
||||
import RoundedButton from "../../elements/RoundedButton";
|
||||
import { clearAuthSession } from "@/lib/auth/session";
|
||||
|
||||
function Logout() {
|
||||
const [showModal, setShowModal] = useState<boolean>(false);
|
||||
const router = useRouter();
|
||||
const logoutHandler = () => {
|
||||
localStorage.removeItem("id");
|
||||
localStorage.removeItem("mobile");
|
||||
localStorage.removeItem("otp");
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("usertype");
|
||||
localStorage.removeItem("first_name");
|
||||
localStorage.removeItem("last_name");
|
||||
localStorage.removeItem("username");
|
||||
localStorage.removeItem("step");
|
||||
Cookies.remove("token");
|
||||
const logoutHandler = async () => {
|
||||
await clearAuthSession();
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
};
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -11,6 +11,7 @@ import axios, {
|
||||
import toast from "react-hot-toast";
|
||||
import { BASE_URL as base } from "@/components/main/BaseUrl";
|
||||
import Cookies from "js-cookie";
|
||||
import { clearAuthSession } from "@/lib/auth/session";
|
||||
|
||||
const BASE_URL = base;
|
||||
const axiosInstance = axios.create({
|
||||
@@ -88,9 +89,14 @@ axiosInstance.interceptors.response.use(
|
||||
);
|
||||
|
||||
const handleUnauthorized = (): void => {
|
||||
if (typeof window !== "undefined") {
|
||||
// toast.error("برای انجام عملیات لطفا وارد حساب کاربری خود شوید");
|
||||
}
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const path = window.location.pathname;
|
||||
if (path.startsWith("/login") || path.startsWith("/register")) return;
|
||||
|
||||
clearAuthSession().finally(() => {
|
||||
window.location.href = "/login";
|
||||
});
|
||||
};
|
||||
|
||||
// تایپ اصلاحشده برای درخواستها
|
||||
|
||||
23
src/lib/auth/otpTimer.ts
Normal file
23
src/lib/auth/otpTimer.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export const OTP_VALID_SECONDS = 3 * 60;
|
||||
const OTP_SENT_AT_KEY = "otp_sent_at";
|
||||
|
||||
export function markOtpSent(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
sessionStorage.setItem(OTP_SENT_AT_KEY, Date.now().toString());
|
||||
}
|
||||
|
||||
export function getOtpRemainingSeconds(): number {
|
||||
if (typeof window === "undefined") return OTP_VALID_SECONDS;
|
||||
|
||||
const sent = sessionStorage.getItem(OTP_SENT_AT_KEY);
|
||||
if (!sent) return OTP_VALID_SECONDS;
|
||||
|
||||
const elapsed = Math.floor((Date.now() - Number(sent)) / 1000);
|
||||
return Math.max(0, OTP_VALID_SECONDS - elapsed);
|
||||
}
|
||||
|
||||
export function formatOtpCountdown(totalSeconds: number): string {
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
70
src/lib/auth/session.ts
Normal file
70
src/lib/auth/session.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
const TOKEN_KEY = "token";
|
||||
|
||||
export type AuthUserMeta = {
|
||||
id?: string;
|
||||
user_type?: string;
|
||||
step?: string;
|
||||
};
|
||||
|
||||
const cookieOptions = (): Cookies.CookieAttributes => ({
|
||||
path: "/",
|
||||
expires: 365,
|
||||
sameSite: "Lax",
|
||||
secure: typeof window !== "undefined" && window.location.protocol === "https:",
|
||||
});
|
||||
|
||||
/** ذخیره توکن در کوکی مرورگر + Route Handler سرور (برای middleware) */
|
||||
export async function setAuthSession(
|
||||
token: string,
|
||||
meta?: AuthUserMeta
|
||||
): Promise<void> {
|
||||
if (!token) return;
|
||||
|
||||
Cookies.set(TOKEN_KEY, token, cookieOptions());
|
||||
|
||||
await fetch("/api/auth/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
credentials: "same-origin",
|
||||
});
|
||||
|
||||
if (meta?.id) localStorage.setItem("id", meta.id);
|
||||
if (meta?.user_type) localStorage.setItem("usertype", meta.user_type);
|
||||
if (meta?.step) localStorage.setItem("step", meta.step);
|
||||
}
|
||||
|
||||
/** پاک کردن کامل session */
|
||||
export async function clearAuthSession(): Promise<void> {
|
||||
Cookies.remove(TOKEN_KEY, { path: "/" });
|
||||
|
||||
try {
|
||||
await fetch("/api/auth/session", {
|
||||
method: "DELETE",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
localStorage.removeItem("id");
|
||||
localStorage.removeItem("mobile");
|
||||
localStorage.removeItem("otp");
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("usertype");
|
||||
localStorage.removeItem("first_name");
|
||||
localStorage.removeItem("last_name");
|
||||
localStorage.removeItem("username");
|
||||
localStorage.removeItem("step");
|
||||
}
|
||||
|
||||
export function getAuthToken(): string | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
return Cookies.get(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return Boolean(getAuthToken());
|
||||
}
|
||||
21
src/lib/validation/username.ts
Normal file
21
src/lib/validation/username.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/** حروف انگلیسی، اعداد و . _ - — بدون فارسی و کاراکترهای غیرمعمول */
|
||||
export const USERNAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._-]{5,99}$/;
|
||||
|
||||
export const USERNAME_MIN_LENGTH = 6;
|
||||
export const USERNAME_MAX_LENGTH = 100;
|
||||
|
||||
export const USERNAME_ALLOWED_CHARS = /[^a-zA-Z0-9._-]/g;
|
||||
|
||||
export const USERNAME_VALIDATION_MESSAGE =
|
||||
"نام کاربری فقط میتواند شامل حروف انگلیسی، اعداد و کاراکترهای . _ - باشد";
|
||||
|
||||
export const USERNAME_START_MESSAGE =
|
||||
"نام کاربری باید با حرف یا عدد انگلیسی شروع شود";
|
||||
|
||||
export function sanitizeUsernameInput(value: string): string {
|
||||
return value.replace(USERNAME_ALLOWED_CHARS, "");
|
||||
}
|
||||
|
||||
export function isValidUsername(value: string): boolean {
|
||||
return USERNAME_REGEX.test(value);
|
||||
}
|
||||
20
src/lib/validation/usernameSchema.ts
Normal file
20
src/lib/validation/usernameSchema.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as yup from "yup";
|
||||
import {
|
||||
USERNAME_MAX_LENGTH,
|
||||
USERNAME_MIN_LENGTH,
|
||||
USERNAME_REGEX,
|
||||
USERNAME_START_MESSAGE,
|
||||
USERNAME_VALIDATION_MESSAGE,
|
||||
} from "./username";
|
||||
|
||||
export const usernameSchema = yup
|
||||
.string()
|
||||
.required("نام کاربری الزامی است")
|
||||
.min(USERNAME_MIN_LENGTH, `نام کاربری باید حداقل ${USERNAME_MIN_LENGTH} کاراکتر باشد`)
|
||||
.max(USERNAME_MAX_LENGTH, `نام کاربری نمیتواند بیشتر از ${USERNAME_MAX_LENGTH} کاراکتر باشد`)
|
||||
.matches(/^[a-zA-Z0-9]/, USERNAME_START_MESSAGE)
|
||||
.matches(USERNAME_REGEX, USERNAME_VALIDATION_MESSAGE);
|
||||
|
||||
export const usernameFormSchema = yup.object({
|
||||
username: usernameSchema,
|
||||
});
|
||||
@@ -1,17 +1,26 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export function middleware(req: NextRequest) {
|
||||
const token = req.cookies.get('token'); // دریافت توکن از کوکی
|
||||
const isPrefetch =
|
||||
req.headers.get("next-router-prefetch") === "1" ||
|
||||
req.headers.get("purpose") === "prefetch";
|
||||
|
||||
// بررسی اینکه مسیر داخل settings هست
|
||||
if (req.nextUrl.pathname.startsWith('/settings') && !token) {
|
||||
return NextResponse.redirect(new URL('/login', req.url)); // انتقال به صفحه لاگین
|
||||
// prefetch بدون کوکی نباید redirect به /login کش شود
|
||||
if (isPrefetch) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
return NextResponse.next(); // ادامه پردازش عادی
|
||||
const token = req.cookies.get("token")?.value?.trim();
|
||||
|
||||
if (req.nextUrl.pathname.startsWith("/settings") && !token) {
|
||||
const loginUrl = new URL("/login", req.url);
|
||||
loginUrl.searchParams.set("redirect", req.nextUrl.pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// تعیین مسیرهایی که این middleware روی آنها اعمال میشود
|
||||
export const config = {
|
||||
matcher: ['/settings/:path*'], // فقط روی مسیرهای settings اعمال شود
|
||||
matcher: ["/settings/:path*"],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user