This commit is contained in:
payacom
2026-07-16 20:08:49 +03:30
parent 39b12a0986
commit 58314d0ab5
40 changed files with 986 additions and 535 deletions

11
package-lock.json generated
View File

@@ -37,6 +37,7 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/themes": "^3.2.1",
"@react-oauth/google": "^0.13.5",
"@shadcn/ui": "^0.0.4",
"@tabler/icons-react": "^3.34.1",
"@tanstack/react-query": "^5.87.1",
@@ -2986,6 +2987,16 @@
"react-dom": "^18.0.0"
}
},
"node_modules/@react-oauth/google": {
"version": "0.13.5",
"resolved": "https://registry.npmmirror.com/@react-oauth/google/-/google-0.13.5.tgz",
"integrity": "sha512-xQWri2s/3nNekZJ4uuov2aAfQYu83bN3864KcFqw2pK1nNbFurQIjPFDXhWaKH3IjYJ2r/9yyIIpsn5lMqrheQ==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@react-types/shared": {
"version": "3.36.0",
"resolved": "https://registry.npmmirror.com/@react-types/shared/-/shared-3.36.0.tgz",

View File

@@ -39,6 +39,7 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/themes": "^3.2.1",
"@react-oauth/google": "^0.13.5",
"@shadcn/ui": "^0.0.4",
"@tabler/icons-react": "^3.34.1",
"@tanstack/react-query": "^5.87.1",

View File

@@ -1,21 +1,23 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import AuthButton from "@/components/auth/AuthButton";
import AuthHead from "@/components/auth/AuthHead";
import AuthPasswordInput from "@/components/auth/AuthPasswordInput";
import AuthOtpInput from "@/components/auth/AuthOtpInput";
import Container from "@/components/elements/Container";
import AuthRules from "@/components/auth/AuthRules";
import Link from "next/link";
import React, { useState } from "react";
import React, { useMemo, 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 toast from "react-hot-toast";
// Validation schema using Yup
const schema = yup.object().shape({
otp: yup
.string()
.matches(/^\d{6}$/, "کد وارد شده صحیح نیست")
.required("کد تایید را وارد کنید"),
password: yup
.string()
.required("کلمه عبور نمی\u200Cتواند خالی باشد")
@@ -30,35 +32,63 @@ const schema = yup.object().shape({
.oneOf(
[yup.ref("password")],
"تکرار کلمه عبور باید با کلمه عبور مطابقت داشته باشد"
)
.required("تکرار کلمه عبور الزامی است"),
),
});
function ChangePassword() {
const router = useRouter();
const [isModalOpen, setModalOpen] = useState(false);
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
const otp =
typeof window !== "undefined" ? localStorage.getItem("otp") : null;
const [otpError, setOtpError] = useState(false);
const resetIdentifier = useMemo(
() =>
typeof window !== "undefined"
? localStorage.getItem("reset_identifier")
: null,
[]
);
const resetChannel = useMemo(
() =>
typeof window !== "undefined"
? localStorage.getItem("reset_channel")
: null,
[]
);
const isEmailReset = resetChannel === "email";
const formik = useFormik({
initialValues: {
otp: "",
password: "",
confirmPassword: "",
},
validationSchema: schema,
onSubmit: async (values) => {
if (!resetIdentifier) {
toast.error("ابتدا از صفحه فراموشی رمز، کد دریافت کنید");
router.push("/forget-password");
return;
}
const isMobile = /^09\d{9}$/.test(resetIdentifier);
try {
(await request("POST", "/login/change_password", {
mobile,
otp,
await request("POST", "/login/change_password", {
...(isMobile
? { mobile: resetIdentifier }
: { email: resetIdentifier }),
otp: values.otp.trim(),
new_password: values.password,
})) as IVerifyOtp;
router.push("/login");
} catch (err: any) {
console.log("Unhandled error:", err?.message);
});
localStorage.removeItem("reset_identifier");
localStorage.removeItem("reset_channel");
toast.success("کلمه عبور با موفقیت تغییر کرد");
router.push("/login-with-username");
} catch (err: unknown) {
setOtpError(true);
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || "تغییر کلمه عبور ناموفق بود";
toast.error(message);
}
},
});
@@ -67,10 +97,31 @@ function ChangePassword() {
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<AuthHead title="تغییر کلمه عبور" />
<p className="text-sm text-gray-500 text-center mb-4 max-w-sm">
{isEmailReset
? "کد ارسال‌شده به ایمیل و کلمه عبور جدید را وارد کنید."
: "کد ارسال‌شده به موبایل و کلمه عبور جدید را وارد کنید."}
</p>
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<AuthOtpInput
className="mt-3"
value={formik.values.otp}
onChange={(otp) => {
formik.setFieldValue("otp", otp);
setOtpError(false);
}}
error={
otpError || Boolean(formik.touched.otp && formik.errors.otp)
}
disabled={loading}
/>
{formik.touched.otp && formik.errors.otp && (
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
)}
<AuthPasswordInput
name="password"
placeholder="کلمه عبور جدید"
@@ -109,17 +160,26 @@ function ChangePassword() {
</small>
)}
<AuthButton type="submit" className="mt-5" loading={loading} disabled={loading}>
<AuthButton
type="submit"
className="mt-5"
loading={loading}
disabled={loading || formik.values.otp.length !== 6}
>
تغییر کلمه عبور
</AuthButton>
</form>
<Link href={"/register"}>
<small className="text-[#292D32] font-bold text-xs mt-8 block">
حساب کاربری ندارید؟ <span className="text-[#0033EA]">ثبت نام</span>
<Link href="/forget-password">
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
ارسال مجدد کد
</small>
</Link>
<Link href="/login-with-username">
<small className="text-[#FF5C00] font-bold text-xs mt-4 block">
بازگشت به ورود
</small>
</Link>
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
</div>
</Container>
);

View File

@@ -5,38 +5,63 @@ import AuthHead from "@/components/auth/AuthHead";
import AuthInput from "@/components/auth/AuthInput";
import Container from "@/components/elements/Container";
import Link from "next/link";
import React from "react";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
import { markOtpSent } from "@/lib/auth/otpTimer";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
// Validation schema using Yup
const schema = yup.object({
mobile: yup
identifier: yup
.string()
.matches(/^(09\d{9})$/, "شماره موبایل معتبر نیست")
.required("شماره موبایل الزامی است"),
.required("ایمیل یا شماره موبایل الزامی است")
.test("identifier", "ایمیل یا شماره موبایل معتبر نیست", (value) => {
if (!value) return false;
const trimmed = value.trim();
return (
/^(09\d{9})$/.test(trimmed) ||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)
);
}),
});
function ForgetPassword() {
function ForgetPasswordPage() {
const router = useRouter();
const { request, loading } = useAxios();
const [destination, setDestination] = useState("");
const formik = useFormik({
initialValues: {
mobile: "",
},
initialValues: { identifier: "" },
validationSchema: schema,
onSubmit: async (values) => {
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);
const identifier = values.identifier.trim();
const isMobile = /^09\d{9}$/.test(identifier);
const response = (await request("POST", "/login/forgot_password", {
identifier,
...(isMobile ? { mobile: identifier } : { email: identifier }),
})) as {
channel?: "sms" | "email";
destination?: string;
message?: string;
};
localStorage.setItem("reset_identifier", identifier);
if (response.channel) {
localStorage.setItem("reset_channel", response.channel);
}
if (response.destination) {
setDestination(response.destination);
}
toast.success(response.message || "کد بازیابی ارسال شد");
router.push("/change-passowrd");
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || "ارسال کد ناموفق بود";
toast.error(message);
}
},
});
@@ -44,38 +69,51 @@ function ForgetPassword() {
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<AuthHead title="فراموش کردن کلمه عبور" />
<AuthHead title="فراموشی کلمه عبور" />
<p className="text-sm text-gray-500 text-center mb-4 max-w-sm">
ایمیل (برای حساب گوگل) یا شماره موبایل خود را وارد کنید تا کد بازیابی
ارسال شود.
</p>
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
>
<AuthInput
name="mobile"
maxLength={11}
name="identifier"
type="text"
dir="ltr"
placeholder="09 _ _ _ _ _ _ _ _ _"
placeholder="email@example.com یا 09xxxxxxxxx"
className={`border ${
formik.touched.mobile && formik.errors.mobile
formik.touched.identifier && formik.errors.identifier
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.mobile}
value={formik.values.identifier}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.mobile && formik.errors.mobile && (
{formik.touched.identifier && formik.errors.identifier && (
<small className="text-red-500 mt-2 block">
{formik.errors.mobile}
{formik.errors.identifier}
</small>
)}
<AuthButton type="submit" className="mt-5" loading={loading} disabled={loading}>
ارسال کد
{destination && (
<small className="text-green-600 mt-2 block text-center">
کد به {destination} ارسال شد
</small>
)}
<AuthButton
type="submit"
className="mt-5"
loading={loading}
disabled={loading}
>
ارسال کد بازیابی
</AuthButton>
</form>
<Link href={"/register"}>
<small className="text-[#292D32] font-bold text-xs mt-8 block">
حساب کاربری ندارید؟ <span className="text-[#0033EA]">ثبت نام</span>
<Link href="/login-with-username">
<small className="text-[#667AC1] font-bold text-xs mt-8 block">
بازگشت به ورود
</small>
</Link>
</div>
@@ -83,4 +121,4 @@ function ForgetPassword() {
);
}
export default ForgetPassword;
export default ForgetPasswordPage;

View File

@@ -22,6 +22,8 @@ const schema = yup.object({
});
import { getSafeRedirectPath } from "@/lib/auth/postLogin";
import GoogleSignInButton from "@/components/auth/GoogleSignInButton";
import { MOBILE_NUMERIC_INPUT_PROPS } from "@/lib/auth/inputProps";
function Login() {
const router = useRouter();
@@ -61,9 +63,8 @@ function Login() {
<AuthInput
name="mobile"
maxLength={11}
type="tel"
pattern="[0-9]*"
dir="ltr"
{...MOBILE_NUMERIC_INPUT_PROPS}
dir="ltr"
placeholder="09 _ _ _ _ _ _ _ _ _"
className={`border ${
formik.touched.mobile && formik.errors.mobile
@@ -83,6 +84,7 @@ function Login() {
ارسال کد
</AuthButton>
</form>
<GoogleSignInButton mode="login" />
<Link href={usernameLoginHref}>
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
با نام کاربری و کلمه عبور خود وارد شوید

View File

@@ -16,6 +16,7 @@ import { useRouter } from "next/navigation";
import { IVerifyOtp } from "@/types/types";
import { setAuthSession } from "@/lib/auth/session";
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
import { getRegistrationRoute } from "@/lib/auth/registrationRoutes";
// Validation schema using Yup
const schema = yup.object({
@@ -60,6 +61,8 @@ function VerifyOtp() {
id: response.id,
user_type: response.user_type,
step: response.step,
auth_provider: response.auth_provider,
email: response.email ?? undefined,
});
setIsSuccess(true);
await pause(AUTH_SUCCESS_DELAY_MS);
@@ -73,24 +76,7 @@ function VerifyOtp() {
});
setIsSuccess(true);
await pause(AUTH_SUCCESS_DELAY_MS);
switch (response.step) {
case "user_name":
router.push("/register/username");
break;
case "password":
router.push("/register/password");
break;
case "first_name":
router.push("/register/fullname");
break;
case "user_type":
router.push("/register/usertype");
break;
default:
router.push("/verify/avatar");
break;
}
router.push(getRegistrationRoute(response.step));
break;
default:
break;

View File

@@ -17,6 +17,7 @@ import { IVerifyOtp } from "@/types/types";
import { useRouter } from "next/navigation";
import { setAuthSession } from "@/lib/auth/session";
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
import { getRegistrationRoute } from "@/lib/auth/registrationRoutes";
// Validation schema using Yup
const schema = yup.object({
@@ -55,18 +56,22 @@ function RegisterOtp() {
otp: values.otp.trim(),
})) as IVerifyOtp;
await localStorage.setItem("otp", values.otp);
await setAuthSession(response.token, { step: response.step });
await setAuthSession(response.token, {
id: response.id,
step: response.step,
user_type: response.user_type,
auth_provider: response.auth_provider,
email: response.email ?? undefined,
});
setIsSuccess(true);
await pause(AUTH_SUCCESS_DELAY_MS);
response.step === "user_name"
? router.push("/register/username")
: response.step === "password"
? router.push("/register/password")
: response.step === "first_name"
? router.push("/register/fullname")
: response.step === "user_type"
? router.push("/register/usertype")
: router.push("/verify/avatar");
if (response.page === "home") {
router.push("/");
return;
}
router.push(getRegistrationRoute(response.step));
} catch (err: any) {
setOtpError(true);
setIsSuccess(false);

View File

@@ -0,0 +1,79 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import AuthHead from "@/components/auth/AuthHead";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React, { useState } from "react";
import useAxios from "@/hooks/useAxios";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import { getSafeRedirectPath } from "@/lib/auth/postLogin";
function RegisterCompletePage() {
const router = useRouter();
const { request, loading } = useAxios();
const [action, setAction] = useState<"app" | "continue" | null>(null);
const finishRegistration = async (next: "app" | "continue") => {
setAction(next);
try {
await request("POST", "/register/user_type", {
user_type: "user",
});
localStorage.setItem("usertype", "user");
if (next === "continue") {
router.push("/verify/avatar");
} else {
router.push(getSafeRedirectPath());
}
} catch (err: any) {
toast.error(err?.response?.data?.message || "خطا در تکمیل ثبت‌نام");
} finally {
setAction(null);
}
};
return (
<AuthPageLayout>
<div className="flex w-full flex-1 flex-col items-center">
<AuthPageContent>
<AuthHead title="ثبت‌نام اولیه تکمیل شد" />
<p className="text-sm text-gray-500 text-center max-w-sm mt-2">
میتوانید وارد برنامه شوید یا ثبتنام کامل (تایید هویت و پروفایل)
را ادامه دهید.
</p>
</AuthPageContent>
<AuthFormFooter>
<div className="flex flex-col sm:flex-row gap-3 w-full max-w-sm justify-center items-center">
<AuthNextButton
type="button"
onClick={() => finishRegistration("continue")}
className="!border-[#0C8002] !text-[#0C8002] w-full sm:w-auto"
disabled={loading}
loading={loading && action === "continue"}
>
ادامه ثبت نام
</AuthNextButton>
<AuthNextButton
type="button"
onClick={() => finishRegistration("app")}
className="!border-[#FF0000] !text-[#FF0000] w-full sm:w-auto"
disabled={loading}
loading={loading && action === "app"}
>
ورود به برنامه
</AuthNextButton>
</div>
</AuthFormFooter>
</div>
</AuthPageLayout>
);
}
export default RegisterCompletePage;

View File

@@ -7,25 +7,27 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
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";
import { getSafeRedirectPath } from "@/lib/auth/postLogin";
// Validation schema using Yup
const schema = yup.object().shape({
family: yup.string().required("نام خانوادگی الزامی است"),
name: yup.string().required("نام الزامی است"),
name: yup.string().trim().required("نام الزامی است").min(2, "نام کوتاه است"),
family: yup.string().trim().optional(),
});
function FullNamePage() {
const router = useRouter();
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
const [nameSaved, setNameSaved] = useState(false);
const [choiceLoading, setChoiceLoading] = useState<"app" | "continue" | null>(
null
);
const formik = useFormik({
initialValues: {
@@ -33,20 +35,50 @@ function FullNamePage() {
name: "",
},
validationSchema: schema,
onSubmit: async (values) => {
onSubmit: async (values, { setSubmitting }) => {
try {
(await request("POST", "/register/fullname", {
mobile: mobile,
first_name: values.name,
last_name: values.family,
})) as IVerifyOtp;
router.push("/register/usertype");
await request("POST", "/register/fullname", {
first_name: values.name.trim(),
last_name: values.family.trim() || undefined,
});
localStorage.setItem("first_name", values.name.trim());
if (values.family.trim()) {
localStorage.setItem("last_name", values.family.trim());
} else {
localStorage.removeItem("last_name");
}
setNameSaved(true);
toast.success("نام با موفقیت ثبت شد");
} catch (err: any) {
console.log("Unhandled error:", err?.message);
toast.error(err?.response?.data?.message || "خطا در ذخیره نام");
} finally {
setSubmitting(false);
}
},
});
const finishRegistration = async (action: "app" | "continue") => {
setChoiceLoading(action);
try {
await request("POST", "/register/user_type", {
user_type: "user",
});
localStorage.setItem("usertype", "user");
if (action === "continue") {
router.push("/verify/avatar");
} else {
router.push(getSafeRedirectPath());
}
} catch (err: any) {
toast.error(err?.response?.data?.message || "خطا در تکمیل ثبت‌نام");
} finally {
setChoiceLoading(null);
}
};
return (
<AuthPageLayout>
<form
@@ -56,52 +88,87 @@ function FullNamePage() {
<AuthPageContent>
<AuthHead title="نام و نام خانوادگی" />
<div className="w-full max-w-sm flex flex-col items-center">
<AuthInput
name="name"
type="text"
placeholder="نام"
dir="rtl"
className={`border mt-4 ${
formik.touched.name && formik.errors.name
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.name && formik.errors.name && (
<small className="text-red-500 mt-2 block text-center">
{formik.errors.name}
</small>
)}
<AuthInput
name="name"
type="text"
placeholder="نام *"
dir="rtl"
disabled={nameSaved || loading}
className={`border mt-4 ${
formik.touched.name && formik.errors.name
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.name}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.name && formik.errors.name && (
<small className="text-red-500 mt-2 block text-center">
{formik.errors.name}
</small>
)}
<AuthInput
name="family"
type="text"
placeholder="نام خانوادگی"
dir="rtl"
className={`border mt-4 ${
formik.touched.family && formik.errors.family
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.family}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.family && formik.errors.family && (
<small className="text-red-500 mt-2 block text-center">
{formik.errors.family}
</small>
)}
<AuthInput
name="family"
type="text"
placeholder="نام خانوادگی (اختیاری)"
dir="rtl"
disabled={nameSaved || loading}
className={`border mt-4 ${
formik.touched.family && formik.errors.family
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.family}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.family && formik.errors.family && (
<small className="text-red-500 mt-2 block text-center">
{formik.errors.family}
</small>
)}
{nameSaved && (
<p className="text-sm text-green-600 text-center mt-4">
نام شما ثبت شد. یکی از گزینههای زیر را انتخاب کنید.
</p>
)}
</div>
</AuthPageContent>
<AuthFormFooter>
<AuthNextButton type="submit" loading={loading} disabled={loading}>
ثبت و ادامه
</AuthNextButton>
{!nameSaved ? (
<AuthNextButton
type="submit"
disabled={loading || !formik.values.name.trim()}
loading={loading}
>
ثبت و ادامه
</AuthNextButton>
) : (
<div className="flex flex-col sm:flex-row gap-3 w-full max-w-sm justify-center items-center">
<AuthNextButton
type="button"
onClick={() => finishRegistration("continue")}
className="!border-[#0C8002] !text-[#0C8002] w-full sm:w-auto"
disabled={Boolean(choiceLoading)}
loading={choiceLoading === "continue"}
>
ادامه ثبت نام
</AuthNextButton>
<AuthNextButton
type="button"
onClick={() => finishRegistration("app")}
className="!border-[#FF0000] !text-[#FF0000] w-full sm:w-auto"
disabled={Boolean(choiceLoading)}
loading={choiceLoading === "app"}
>
ورود به برنامه
</AuthNextButton>
</div>
)}
</AuthFormFooter>
</form>
</AuthPageLayout>

View File

@@ -11,6 +11,8 @@ import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import { markOtpSent } from "@/lib/auth/otpTimer";
import GoogleSignInButton from "@/components/auth/GoogleSignInButton";
import { MOBILE_NUMERIC_INPUT_PROPS } from "@/lib/auth/inputProps";
// Validation schema using Yup
const schema = yup.object({
@@ -53,7 +55,7 @@ function Register() {
<AuthInput
name="mobile"
maxLength={11}
type="text"
{...MOBILE_NUMERIC_INPUT_PROPS}
dir="ltr"
placeholder="09 _ _ _ _ _ _ _ _ _"
className={`border ${
@@ -74,6 +76,7 @@ function Register() {
ارسال کد
</AuthButton>
</form>
<GoogleSignInButton mode="register" />
<button onClick={() => setModalOpen(true)} className="mb-2">
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
قوانین و مقررات

View File

@@ -14,8 +14,8 @@ 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";
// Validation schema using Yup
const schema = yup.object().shape({
password: yup
.string()
@@ -31,17 +31,12 @@ const schema = yup.object().shape({
.oneOf(
[yup.ref("password")],
"تکرار کلمه عبور باید با کلمه عبور مطابقت داشته باشد"
)
.required("تکرار کلمه عبور الزامی است"),
),
});
function PasswordPage() {
const router = useRouter();
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
const otp =
typeof window !== "undefined" ? localStorage.getItem("otp") : null;
const formik = useFormik({
initialValues: {
@@ -51,14 +46,14 @@ function PasswordPage() {
validationSchema: schema,
onSubmit: async (values) => {
try {
(await request("POST", "/login/change_password", {
mobile,
otp,
(await request("POST", "/register/password", {
new_password: values.password,
})) as IVerifyOtp;
router.push("/register/fullname");
} catch (err: any) {
console.log("Unhandled error:", err?.message);
const message =
err?.response?.data?.message || "ذخیره کلمه عبور ناموفق بود";
toast.error(message);
}
},
});
@@ -72,49 +67,49 @@ function PasswordPage() {
<AuthPageContent>
<AuthHead title="کلمه عبور" />
<div className="w-full max-w-sm flex flex-col items-center">
<AuthPasswordInput
name="password"
placeholder="کلمه عبور"
wrapperClassName="mt-4"
className={`border ${
formik.touched.password && formik.errors.password
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.password}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.password && formik.errors.password && (
<small className="text-red-500 mt-2 block">
{formik.errors.password}
</small>
)}
<AuthPasswordInput
name="password"
placeholder="کلمه عبور"
wrapperClassName="mt-4"
className={`border ${
formik.touched.password && formik.errors.password
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.password}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.password && formik.errors.password && (
<small className="text-red-500 mt-2 block">
{formik.errors.password}
</small>
)}
<AuthPasswordInput
name="confirmPassword"
placeholder="تکرار کلمه عبور"
wrapperClassName="mt-4"
className={`border ${
formik.touched.confirmPassword && formik.errors.confirmPassword
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.confirmPassword}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.confirmPassword && formik.errors.confirmPassword && (
<small className="text-red-500 mt-2 block">
{formik.errors.confirmPassword}
</small>
)}
<AuthPasswordInput
name="confirmPassword"
placeholder="تکرار کلمه عبور"
wrapperClassName="mt-4"
className={`border ${
formik.touched.confirmPassword && formik.errors.confirmPassword
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.confirmPassword}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.confirmPassword && formik.errors.confirmPassword && (
<small className="text-red-500 mt-2 block">
{formik.errors.confirmPassword}
</small>
)}
</div>
</AuthPageContent>
<AuthFormFooter>
<AuthNextButton type="submit" loading={loading} disabled={loading}>
تایید کلمه عبور
ثبت و ادامه
</AuthNextButton>
</AuthFormFooter>
</form>

View File

@@ -15,7 +15,11 @@ import { IVerifyOtp } from "@/types/types";
import AuthNextButton from "@/components/auth/AuthNextButton";
import UsernameSuggestions from "@/components/auth/UsernameSuggestions";
import { usernameFormSchema } from "@/lib/validation/usernameSchema";
import { sanitizeUsernameInput, USERNAME_MIN_LENGTH } from "@/lib/validation/username";
import {
sanitizeUsernameInput,
USERNAME_MIN_LENGTH,
USERNAME_VALIDATION_MESSAGE,
} from "@/lib/validation/username";
function UsernamePage() {
const router = useRouter();
@@ -36,7 +40,7 @@ function UsernamePage() {
try {
await request("POST", "/register/username", {
mobile,
...(mobile ? { mobile } : {}),
user_name: values.username.trim().toLowerCase(),
}) as IVerifyOtp;
@@ -76,39 +80,49 @@ function UsernamePage() {
<AuthPageContent>
<AuthHead title="نام کاربری" />
<div className="w-full max-w-sm flex flex-col items-center">
<AuthInput
name="username"
type="text"
placeholder="نام کاربری"
dir="ltr"
autoComplete="username"
spellCheck={false}
className={`border ${
(formik.touched.username && formik.errors.username) || isDuplicate
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.username}
onChange={(e) => handleUsernameChange(e.target.value)}
onBlur={formik.handleBlur}
/>
<p className="text-xs text-gray-500 text-center mb-3 max-w-[290px]">
حداقل {USERNAME_MIN_LENGTH} کاراکتر؛ فقط حروف انگلیسی، اعداد و
. _ -
</p>
<AuthInput
name="username"
type="text"
placeholder="نام کاربری"
dir="ltr"
autoComplete="username"
spellCheck={false}
className={`border ${
(formik.touched.username && formik.errors.username) || isDuplicate
? "border-red-500 dark:border-red-500"
: "border-gray-300"
}`}
value={formik.values.username}
onChange={(e) => handleUsernameChange(e.target.value)}
onBlur={formik.handleBlur}
/>
{isDuplicate && (
<small className="text-red-500 mt-2 block text-center font-medium">
نام کاربری تکراری است
</small>
)}
{isDuplicate && (
<small className="text-red-500 mt-2 block text-center font-medium">
نام کاربری تکراری است
</small>
)}
{formik.touched.username && formik.errors.username && !isDuplicate && (
<small className="text-red-500 mt-2 block text-center">
{formik.errors.username}
</small>
)}
{formik.touched.username && formik.errors.username && !isDuplicate && (
<small className="text-red-500 mt-2 block text-center">
{formik.errors.username}
</small>
)}
<UsernameSuggestions
suggestions={suggestions}
onSelect={handleSuggestionSelect}
/>
{!formik.errors.username && !isDuplicate && (
<small className="text-gray-400 mt-2 block text-center text-xs">
{USERNAME_VALIDATION_MESSAGE}
</small>
)}
<UsernameSuggestions
suggestions={suggestions}
onSelect={handleSuggestionSelect}
/>
</div>
</AuthPageContent>
@@ -116,7 +130,9 @@ function UsernamePage() {
<AuthNextButton
type="submit"
loading={loading}
disabled={loading || formik.values.username.length < USERNAME_MIN_LENGTH}
disabled={
loading || formik.values.username.length < USERNAME_MIN_LENGTH
}
>
ثبت و ادامه
</AuthNextButton>

View File

@@ -1,129 +1,15 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import AuthHead from "@/components/auth/AuthHead";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React, { useState } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
// Validation schema using Yup
const schema = yup.object().shape({
selectedButton: yup.string(),
});
function UsertypePage() {
/** مسیر قدیمی — به صفحه انتخاب جدید هدایت می‌شود */
export default function UsertypeRedirectPage() {
const router = useRouter();
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
// State for managing submit action
const [submitAction, setSubmitAction] = useState<"register" | "login" | null>(
null
);
useEffect(() => {
router.replace("/register/complete");
}, [router]);
const formik = useFormik({
initialValues: {
selectedButton: "",
},
validationSchema: schema,
onSubmit: async (values, { setSubmitting }) => {
try {
await request("POST", "/register/user_type", {
mobile: mobile,
// user_type: values.selectedButton,
user_type: "user",
});
if (submitAction === "register") {
// localStorage.setItem("usertype", values.selectedButton);
localStorage.setItem("usertype", "user");
router.push("/verify/avatar");
} else if (submitAction === "login") {
// localStorage.setItem("usertype", values.selectedButton);
localStorage.setItem("usertype", "user");
router.push("/");
}
} catch (err: any) {
toast.error(err?.message || "خطای نامشخصی رخ داد.");
} finally {
setSubmitting(false);
}
},
});
return (
<AuthPageLayout>
<form
onSubmit={formik.handleSubmit}
className="flex w-full flex-1 flex-col items-center"
>
<AuthPageContent>
<AuthHead title="" />
<div className="w-full max-w-sm flex flex-col items-center">
{/* User Type Buttons */}
{/* <AuthNextButton
type="button"
onClick={() => formik.setFieldValue("selectedButton", "user")}
className={`mt-5 w-full py-3 max-w-[250px] ${
formik.values.selectedButton === "user"
? "!bg-[#FC8EAC] !border-[#FC8EAC] !text-white"
: ""
}`}
>
کاربر
</AuthNextButton>
<AuthNextButton
type="button"
onClick={() => formik.setFieldValue("selectedButton", "employer")}
className={`mt-5 w-full py-3 max-w-[250px] ${
formik.values.selectedButton === "employer"
? "!bg-[#FC8EAC] !border-[#FC8EAC] !text-white"
: ""
}`}
>
کارفرما
</AuthNextButton> */}
{formik.errors.selectedButton && formik.touched.selectedButton && (
<div className="text-red-500 mt-2 text-sm">
{formik.errors.selectedButton}
</div>
)}
</div>
</AuthPageContent>
<AuthFormFooter>
<div className="flex gap-3">
<AuthNextButton
type="submit"
onClick={() => setSubmitAction("register")}
className="!border-[#0C8002] !text-[#0C8002]"
disabled={loading}
>
ادامه ثبت نام
</AuthNextButton>
<AuthNextButton
type="submit"
onClick={() => setSubmitAction("login")}
className="!border-[#FF0000] !text-[#FF0000]"
disabled={loading}
>
ورود به برنامه
</AuthNextButton>
</div>
</AuthFormFooter>
</form>
</AuthPageLayout>
);
return null;
}
export default UsertypePage;

View File

@@ -0,0 +1,11 @@
"use client";
import { GoogleAuthRootProvider } from "@/components/auth/GoogleSignInButton";
export default function AuthProviders({
children,
}: {
children: React.ReactNode;
}) {
return <GoogleAuthRootProvider>{children}</GoogleAuthRootProvider>;
}

View File

@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import AuthProviders from "./AuthProviders";
export const metadata: Metadata = {
title: {
@@ -14,5 +15,5 @@ export default function AuthLayout({
}: {
children: React.ReactNode;
}) {
return children;
return <AuthProviders>{children}</AuthProviders>;
}

View File

@@ -10,6 +10,7 @@ import Link from "next/link";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import VerificationBadge from "@/components/main/VerificationBadge";
import { useUser } from "@/hooks/useUser";
import { formatFullName } from "@/lib/formatFullName";
import useAxios from "@/hooks/useAxios";
function Confirm() {
@@ -59,9 +60,7 @@ function Confirm() {
rounded="2xl"
/>
<div className="flex flex-col justify-center items-center gap-1 mt-1">
<span>
{user?.first_name && user?.first_name + " " + user?.last_name}
</span>
<span>{formatFullName(user?.first_name, user?.last_name)}</span>
<div className="flex items-center gap-1 mt-1">
<span className="inline-flex items-center gap-1">
{user?.user_name}

View File

@@ -12,9 +12,9 @@ import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import AuthUserDetails from "@/components/auth/AuthUserDetails";
import ExpertisePicker from "@/components/auth/ExpertisePicker";
import { IExpertise } from "@/types/types";
// Validation schema using Yup
const schema = yup.object().shape({
subExpertise: yup
.array()
@@ -32,14 +32,13 @@ function Expertise() {
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
// Fetch expertise list
const fetchData = async () => {
try {
const response = await request<{ expertises: IExpertise[] }>(
"GET",
"/expertise"
);
setExpertiseList(response?.expertises);
setExpertiseList(response?.expertises ?? []);
} catch (err) {
console.log(err);
}
@@ -75,22 +74,6 @@ function Expertise() {
}
};
const handleButtonPress = (expertise: string) => {
setExpertise(expertise);
setSubExpertise([]);
};
const handleSubButtonPress = (sub_expertise: string) => {
const updatedSubExpertise = subExpertise.includes(sub_expertise)
? subExpertise.filter((item) => item !== sub_expertise)
: [...subExpertise, sub_expertise];
setSubExpertise(updatedSubExpertise);
};
const selectedExpertise = expertiseList?.find(
(item) => item.expertise === expertise
);
return (
<AuthPageLayout>
<AuthPageContent>
@@ -100,35 +83,22 @@ function Expertise() {
<p className="mt-8 text-center">در چه زمینه ای تخصص دارید؟</p>
<div className="grid grid-cols-3 gap-2 mt-4 max-w-[330px]">
{expertiseList?.map((item) => (
<button
key={item._id}
className={`p-1 min-w-24 rounded-3xl border-2 overflow-hidden text-xs ${
expertise === item.expertise
? "bg-yellow-500 text-white border-yellow-500"
: "bg-white text-black border-gray-400"
}`}
onClick={() => handleButtonPress(item.expertise)}
>
{item.expertise}
</button>
))}
{selectedExpertise?.sub_expertise.map((item) => (
<button
key={item._id}
className={`p-1 min-w-24 rounded-3xl border-2 overflow-hidden text-xs ${
subExpertise.includes(item.name)
? "bg-pink-500 text-white border-pink-500"
: "bg-white text-black border-gray-400"
}`}
onClick={() => handleSubButtonPress(item.name)}
>
{item.name}
</button>
))}
</div>
<ExpertisePicker
expertiseList={expertiseList}
expertise={expertise}
subExpertise={subExpertise}
onExpertiseSelect={(value) => {
setExpertise(value);
setSubExpertise([]);
}}
onSubExpertiseToggle={(value) => {
setSubExpertise((prev) =>
prev.includes(value)
? prev.filter((item) => item !== value)
: [...prev, value]
);
}}
/>
</AuthPageContent>
<AuthFormFooter onSkip={() => router.push("/verify/services")}>

View File

@@ -27,6 +27,7 @@ import { useStableMessageKeys } from "@/hooks/useStableMessageKeys";
import { getStoredUserId } from "@/lib/auth/session";
import { buildExpiresAtIso } from "@/lib/chat/timedMessages";
import { isViewOnceMediaType } from "@/lib/chat/viewOnce";
import { filterResolvedPendingMessages } from "@/lib/chat/dedupeMessages";
interface ITicketChatProps {
params: Promise<{ id: string; username: string }>;
@@ -218,6 +219,36 @@ function TicketChat({ params }: ITicketChatProps) {
};
};
const handleIncomingMessage = useCallback((msg: ChatMessage) => {
setPendingMessages((prev) => filterResolvedPendingMessages([msg], prev));
}, []);
const confirmSentMessage = useCallback(
(tempId: string, serverMsg: ChatMessage, replyPayload?: ChatMessage["replyTo"]) => {
const sentMsg: ChatMessage = {
...serverMsg,
status: "sent",
replyTo: serverMsg.replyTo ?? replyPayload,
};
const currentUserId = normalizeThreadId(user?._id ?? getStoredUserId());
const targetReceiverId = normalizeThreadId(
userTwoDetail?._id ?? receiverId ?? chatPartnerId
);
if (currentUserId && targetReceiverId) {
linkIds(tempId, sentMsg._id);
queryClient.setQueryData(
chatThreadQueryKey(currentUserId, targetReceiverId),
(oldData) => appendMessageToThreadCache(oldData, sentMsg)
);
}
setPendingMessages((prev) => prev.filter((m) => m._id !== tempId));
},
[user?._id, userTwoDetail?._id, receiverId, chatPartnerId, linkIds, queryClient]
);
const processSendMessage = async (
fileToUpload: File | null,
fileType?: ChatMessage["fileType"],
@@ -269,15 +300,10 @@ function TicketChat({ params }: ITicketChatProps) {
const isFileUpload = fileForUpload && fileType !== "location";
try {
if (!socketRef.current) {
throw new Error("Socket not connected");
}
let messagePayload: Record<string, unknown> = {
content: textContent,
receiverId: targetReceiverId,
senderId: currentUserId,
tempId: tempId,
};
if (replySource?._id && !replySource._id.startsWith("temp")) {
@@ -293,6 +319,8 @@ function TicketChat({ params }: ITicketChatProps) {
messagePayload.viewOnce = true;
}
let response: ChatMessage | { data: ChatMessage };
if (isFileUpload) {
const formData = new FormData();
for (const key in messagePayload) {
@@ -300,48 +328,28 @@ function TicketChat({ params }: ITicketChatProps) {
}
formData.append("file", fileForUpload);
// ارسال فایل از طریق HTTP (معمولاً فایل‌ها از طریق HTTP ارسال می‌شوند و اطلاعات از طریق Socket.IO)
const response = await request<ChatMessage | { data: ChatMessage }>(
response = await request<ChatMessage | { data: ChatMessage }>(
"post",
"/chat/file",
formData
);
const serverMsg = normalizeMessage(response as ChatMessage);
socketRef.current.emit("sendMessage", { ...serverMsg, tempId });
} else {
socketRef.current.emit("sendMessage", messagePayload);
response = await request<ChatMessage | { data: ChatMessage }>(
"POST",
"/chat",
messagePayload
);
}
// اپتیمیستیک آپدیت (این بخش باید توسط رویداد Socket.IO سرور تایید شود)
// const serverMsg = normalizeMessage(response as ChatMessage);
// const sentMsg: ChatMessage = {
// ...serverMsg,
// status: "sent",
// replyTo: serverMsg.replyTo ?? replyPayload,
// };
// if (currentUserId && targetReceiverId) {
// linkIds(tempId, sentMsg._id);
// queryClient.setQueryData(
// chatThreadQueryKey(currentUserId, targetReceiverId),
// (oldData) => appendMessageToThreadCache(oldData, sentMsg)
// );
// }
// setPendingMessages((prev) => prev.filter((m) => m._id !== tempId));
// این موارد پس از دریافت تاییدیه از Socket.IO باید انجام شوند
// setReplyingTo(null);
// activeReplyRef.current = null;
// setSelfDestructSeconds(null);
// if (applyViewOnce) setViewOnceMedia(false);
const serverMsg = normalizeMessage(response as ChatMessage);
confirmSentMessage(tempId, serverMsg, replyPayload);
} catch (error) {
console.error("Error sending message:", error);
setPendingMessages((prev) => prev.filter((m) => m._id !== tempId));
toast.error("ارسال پیام ناموفق بود.");
return;
}
// پاک کردن وضعیت پیام پس از ارسال
setReplyingTo(null);
activeReplyRef.current = null;
setSelfDestructSeconds(null);
@@ -477,6 +485,7 @@ function TicketChat({ params }: ITicketChatProps) {
selectedDeleteIds={selectedDeleteIds}
onToggleDeleteSelect={toggleDeleteSelect}
onExpirePending={handleExpirePending}
onIncomingMessage={handleIncomingMessage}
extraBottomRem={
(replyingTo ? 2.75 : 0) +
(selfDestructSeconds != null ? 2 : 0) +

View File

@@ -8,6 +8,7 @@ import AuthNextButton from "@/components/auth/AuthNextButton";
import Image from "next/image";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import { useUser } from "@/hooks/useUser";
import { formatFullName } from "@/lib/formatFullName";
import { BASE_URL, IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
@@ -188,9 +189,7 @@ function LicensePage() {
fallback="/images/placeholder.png"
/>
<div className="flex flex-col justify-center items-center gap-1 mt-1">
<span>
{user?.first_name && user?.first_name + " " + user?.last_name}
</span>
<span>{formatFullName(user?.first_name, user?.last_name)}</span>
<div className="flex flex-col justify-center items-center gap-1 mt-1">
{user?.user_name}

View File

@@ -10,8 +10,8 @@ import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import { IExpertise } from "@/types/types";
import { useUser } from "@/hooks/useUser";
import ExpertisePicker from "@/components/auth/ExpertisePicker";
// Validation schema using Yup
const schema = yup.object().shape({
subExpertise: yup
.array()
@@ -25,23 +25,24 @@ function Expertise() {
const [expertiseList, setExpertiseList] = useState<IExpertise[] | null>(null);
const [subExpertise, setSubExpertise] = useState<string[]>([]);
const user = useUser();
useEffect(() => {
setExpertise(user?.expertise || "");
setSubExpertise(user?.sub_expertise || []);
}, [user]);
const router = useRouter();
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
// Fetch expertise list
const fetchData = async () => {
try {
const response = await request<{ expertises: IExpertise[] }>(
"GET",
"/expertise"
);
setExpertiseList(response?.expertises);
setExpertiseList(response?.expertises ?? []);
} catch (err) {
console.log(err);
}
@@ -77,64 +78,36 @@ function Expertise() {
}
};
const handleButtonPress = (expertise: string) => {
setExpertise(expertise);
setSubExpertise([]);
};
const handleSubButtonPress = (sub_expertise: string) => {
const updatedSubExpertise = subExpertise.includes(sub_expertise)
? subExpertise.filter((item) => item !== sub_expertise)
: [...subExpertise, sub_expertise];
setSubExpertise(updatedSubExpertise);
};
const selectedExpertise = expertiseList?.find(
(item) => item.expertise === expertise
);
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<div className="p-4 flex flex-col items-center min-h-screen justify-center">
<span className="text-xl font-bold text-foreground">تخصص</span>
<p className="mt-8 text-center">در چه زمینه ای تخصص دارید؟</p>
<div className="grid grid-cols-3 gap-2 mt-4 max-w-[330px]">
{expertiseList?.map((item) => (
<button
key={item._id}
className={`p-1 min-w-24 rounded-3xl border-2 overflow-hidden text-xs ${
expertise === item.expertise
? "bg-yellow-500 text-white border-yellow-500"
: "bg-white text-black border-gray-400"
}`}
onClick={() => handleButtonPress(item.expertise)}
>
{item.expertise}
</button>
))}
{selectedExpertise?.sub_expertise.map((item) => (
<button
key={item._id}
className={`p-1 min-w-24 rounded-3xl border-2 overflow-hidden text-xs ${
subExpertise.includes(item.name)
? "bg-pink-500 text-white border-pink-500"
: "bg-white text-black border-gray-400"
}`}
onClick={() => handleSubButtonPress(item.name)}
>
{item.name}
</button>
))}
</div>
<ExpertisePicker
expertiseList={expertiseList}
expertise={expertise}
subExpertise={subExpertise}
onExpertiseSelect={(value) => {
setExpertise(value);
setSubExpertise([]);
}}
onSubExpertiseToggle={(value) => {
setSubExpertise((prev) =>
prev.includes(value)
? prev.filter((item) => item !== value)
: [...prev, value]
);
}}
/>
<AuthNextButton
type="button"
onClick={handleCheck}
className="mt-20"
loading={loading} disabled={loading}
loading={loading}
disabled={loading}
>
ویرایش
</AuthNextButton>

View File

@@ -12,6 +12,7 @@ import { useRouter } from "next/navigation";
import { IVerifyOtp } from "@/types/types";
import AuthNextButton from "@/components/auth/AuthNextButton";
import { useUser } from "@/hooks/useUser";
import toast from "react-hot-toast";
// Validation schema using Yup
const schema = yup.object().shape({
@@ -37,6 +38,12 @@ function PasswordPage() {
const router = useRouter();
const user = useUser();
const { request, loading } = useAxios();
const authProvider =
typeof window !== "undefined"
? localStorage.getItem("auth_provider")
: null;
const isGoogleUser = authProvider === "google" || user?.auth_provider === "google";
const formik = useFormik({
initialValues: {
password: "",
@@ -46,9 +53,14 @@ function PasswordPage() {
onSubmit: async (values) => {
try {
(await request("PATCH", "/login/change_password", {
mobile: user?.mobile,
...(user?.mobile ? { mobile: user.mobile } : {}),
new_password: values.password,
})) as IVerifyOtp;
toast.success(
isGoogleUser
? "کلمه عبور تنظیم شد. از این پس می‌توانید با نام کاربری وارد شوید."
: "کلمه عبور با موفقیت تغییر کرد"
);
router.push("/settings/edit");
} catch (err: any) {
console.log("Unhandled error:", err?.message);
@@ -59,7 +71,13 @@ function PasswordPage() {
return (
<Container>
<div className="p-4 flex flex-col items-center h-screen justify-center">
<AuthHead title="تغییر کلمه عبور" />
<AuthHead title={isGoogleUser ? "تنظیم کلمه عبور" : "تغییر کلمه عبور"} />
{isGoogleUser && (
<p className="text-sm text-gray-500 text-center mb-4 max-w-sm">
حساب شما با گوگل ساخته شده است. در صورت تمایل میتوانید کلمه عبور
جداگانه تنظیم کنید.
</p>
)}
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"

View File

@@ -39,7 +39,7 @@ export default function AuthOtpInput({
}
}}
numInputs={numInputs}
inputType="tel"
inputType="number"
shouldAutoFocus
skipDefaultStyles
containerStyle={{

View File

@@ -2,6 +2,7 @@
import ProfileAvatar from "@/components/main/ProfileAvatar";
import useAxios from "@/hooks/useAxios";
import { IProfileData } from "@/types/types";
import { formatFullName } from "@/lib/formatFullName";
import React, { useEffect, useState } from "react";
function AuthUserDetails() {
@@ -31,7 +32,7 @@ function AuthUserDetails() {
className="mb-3"
/>
<span className="text-lg font-bold">
{userDetail?.first_name} {userDetail?.last_name}
{formatFullName(userDetail?.first_name, userDetail?.last_name)}
</span>
<p className="text-sm text-gray-500">{userDetail?.user_name}</p>
</div>

View File

@@ -0,0 +1,99 @@
import { IExpertise } from "@/types/types";
import { cn } from "@/lib/utils";
function gridColsClass(count: number): string {
if (count <= 1) return "grid-cols-1";
if (count === 2) return "grid-cols-2";
if (count === 3) return "grid-cols-3";
if (count === 4) return "grid-cols-4";
if (count === 5) return "grid-cols-5";
return "grid-cols-3 sm:grid-cols-4 md:grid-cols-5";
}
function gridMaxWidth(count: number): number {
const cols = Math.min(Math.max(count, 1), 5);
return cols * 108;
}
type ExpertisePickerProps = {
expertiseList: IExpertise[] | null;
expertise: string;
subExpertise: string[];
onExpertiseSelect: (expertise: string) => void;
onSubExpertiseToggle: (subExpertise: string) => void;
};
export default function ExpertisePicker({
expertiseList,
expertise,
subExpertise,
onExpertiseSelect,
onSubExpertiseToggle,
}: ExpertisePickerProps) {
const mainCount = expertiseList?.length ?? 0;
const selectedExpertise = expertiseList?.find(
(item) => item.expertise === expertise
);
const subCount = selectedExpertise?.sub_expertise.length ?? 0;
if (!mainCount) {
return (
<p className="mt-4 text-center text-sm text-gray-500">
در حال بارگذاری تخصصها...
</p>
);
}
return (
<div className="w-full flex flex-col items-center mt-4 px-2">
<div
className={cn("grid gap-2 w-full", gridColsClass(mainCount))}
style={{ maxWidth: gridMaxWidth(mainCount) }}
>
{expertiseList?.map((item) => (
<button
key={item._id}
type="button"
className={cn(
"p-2 min-h-10 rounded-3xl border-2 text-xs leading-tight transition-colors",
expertise === item.expertise
? "bg-yellow-500 text-white border-yellow-500"
: "bg-white text-black border-gray-400 dark:bg-white dark:text-black"
)}
onClick={() => onExpertiseSelect(item.expertise)}
>
{item.expertise}
</button>
))}
</div>
{selectedExpertise && subCount > 0 && (
<div className="w-full mt-6 flex flex-col items-center">
<p className="text-xs text-gray-500 mb-2 text-center">
زیرمجموعه {selectedExpertise.expertise}
</p>
<div
className={cn("grid gap-2 w-full", gridColsClass(subCount))}
style={{ maxWidth: gridMaxWidth(subCount) }}
>
{selectedExpertise.sub_expertise.map((item) => (
<button
key={item._id}
type="button"
className={cn(
"p-2 min-h-10 rounded-3xl border-2 text-xs leading-tight transition-colors",
subExpertise.includes(item.name)
? "bg-pink-500 text-white border-pink-500"
: "bg-white text-black border-gray-400 dark:bg-white dark:text-black"
)}
onClick={() => onSubExpertiseToggle(item.name)}
>
{item.name}
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,144 @@
"use client";
import { GoogleLogin, GoogleOAuthProvider } from "@react-oauth/google";
import type { CredentialResponse } from "@react-oauth/google";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import useAxios from "@/hooks/useAxios";
import type { IVerifyOtp } from "@/types/types";
import { completeLogin, getSafeRedirectPath } from "@/lib/auth/postLogin";
import { cn } from "@/lib/utils";
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID ?? "";
type GoogleSignInButtonProps = {
mode?: "login" | "register";
};
function GoogleIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
);
}
function GoogleSignInInner({ mode = "login" }: GoogleSignInButtonProps) {
const router = useRouter();
const { request, loading } = useAxios();
const redirectPath = getSafeRedirectPath();
const label = mode === "register" ? "ثبت‌نام با گوگل" : "ورود با گوگل";
const handleSuccess = async (response: CredentialResponse) => {
if (!response.credential) {
toast.error("ورود با گوگل ناموفق بود");
return;
}
try {
const authResponse = (await request("POST", "/auth/google", {
credential: response.credential,
})) as IVerifyOtp;
if (authResponse.email) {
localStorage.setItem("email", authResponse.email);
}
localStorage.setItem(
"auth_provider",
authResponse.auth_provider || "google"
);
localStorage.removeItem("mobile");
localStorage.removeItem("otp");
const loggedIn = await completeLogin(router, authResponse, {
redirectTo: redirectPath,
});
if (!loggedIn) {
toast.error("پاسخ سرور نامعتبر بود");
}
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || "ورود با گوگل ناموفق بود";
toast.error(message);
}
};
return (
<div className="w-full max-w-[290px] mt-5 flex flex-col items-center">
<div className="relative flex items-center justify-center mb-4 w-full">
<span className="absolute inset-x-0 h-px bg-gray-200 dark:bg-gray-700" />
<span className="relative bg-white dark:bg-[#1a1a1a] px-3 text-xs text-gray-500">
{mode === "register" ? "یا ثبت‌نام با" : "یا ورود با"}
</span>
</div>
<div
className={cn(
"relative w-full max-w-[290px]",
loading && "pointer-events-none opacity-60"
)}
>
<div
aria-hidden
className="flex w-full items-center justify-center gap-2 rounded-2xl border-2 border-gray-200 bg-white p-3 text-center text-base font-bold text-gray-800 shadow-sm dark:bg-white dark:text-gray-900"
>
<GoogleIcon />
<span>{label}</span>
</div>
<div className="absolute inset-0 overflow-hidden rounded-2xl opacity-[0.01]">
<GoogleLogin
onSuccess={handleSuccess}
onError={() => toast.error("ورود با گوگل لغو شد")}
theme="outline"
size="large"
shape="pill"
text={mode === "register" ? "signup_with" : "signin_with"}
locale="fa"
width="290"
containerProps={{
className: "flex h-full w-full items-center justify-center",
style: { height: "100%", minHeight: "48px" },
}}
/>
</div>
</div>
</div>
);
}
export default function GoogleSignInButton(props: GoogleSignInButtonProps) {
if (!GOOGLE_CLIENT_ID) return null;
return <GoogleSignInInner {...props} />;
}
export function GoogleAuthRootProvider({
children,
}: {
children: React.ReactNode;
}) {
if (!GOOGLE_CLIENT_ID) return <>{children}</>;
return (
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
{children}
</GoogleOAuthProvider>
);
}

View File

@@ -11,7 +11,7 @@ import {
import ChatMessageCard, { ChatMessage } from "./ChatMessageCard";
import ChatDateSeparator from "./ChatDateSeparator";
import { groupMessagesByDate } from "@/lib/chat/groupMessagesByDate";
import { dedupeChatMessages } from "@/lib/chat/dedupeMessages";
import { dedupeChatMessages, filterResolvedPendingMessages } from "@/lib/chat/dedupeMessages";
import { io } from "socket.io-client";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { User } from "@/types/types";
@@ -58,6 +58,7 @@ interface ChatMessageListProps {
onToggleDeleteSelect?: (message: ChatMessage) => void;
getStableKey?: (id: string) => string;
onExpirePending?: (ids: string[]) => void;
onIncomingMessage?: (message: ChatMessage) => void;
/** فضای اضافه وقتی بنر پاسخ/زماندار بالای input است */
extraBottomRem?: number;
}
@@ -80,6 +81,7 @@ const ChatMessageList = ({
onToggleDeleteSelect,
getStableKey = (id) => id,
onExpirePending,
onIncomingMessage,
extraBottomRem = 0,
}: ChatMessageListProps) => {
const scrollRef = useRef<HTMLDivElement | null>(null);
@@ -211,6 +213,7 @@ const ChatMessageList = ({
chatThreadQueryKey(senderId, receiverId),
(oldData) => appendMessageToThreadCache(oldData, message)
);
onIncomingMessage?.(message);
setToEnd(true);
if (message.senderId === receiverId) {
@@ -290,13 +293,15 @@ const ChatMessageList = ({
socket.off("userStoppedTyping");
socket.disconnect();
};
}, [threadReady, senderId, receiverId, queryClient, onTypingChange]);
}, [threadReady, senderId, receiverId, queryClient, onTypingChange, onIncomingMessage]);
const allMessages = useMemo(() => {
const server =
data?.pages.flatMap((page) => page.messages) || [];
const serverIds = new Set(server.map((m) => m._id));
const pendingOnly = pendingMessages.filter((p) => !serverIds.has(p._id));
const pendingOnly = filterResolvedPendingMessages(server, pendingMessages).filter(
(p) => !serverIds.has(p._id)
);
const merged = dedupeChatMessages([...server, ...pendingOnly]);
const q = searchQuery.trim().toLowerCase();
if (!q) return merged;

View File

@@ -3,6 +3,7 @@ import React from "react";
import Link from "next/link";
import ProfileAvatar from "./ProfileAvatar";
import VerificationBadge from "./VerificationBadge";
import { formatFullName } from "@/lib/formatFullName";
interface IUserInfoProps {
profile_image: string | null | undefined;
@@ -44,7 +45,7 @@ function UserInfo({
<span className="text-[#387E65]">
سطح: {user_level ? user_level : "تازه وارد"}
</span>
<h1>{first_name && first_name + " " + last_name}</h1>
<h1>{formatFullName(first_name, last_name)}</h1>
<h2 className="flex items-center gap-1 mt-1">
<span className="inline-flex items-center gap-1">
{user_name}

View File

@@ -6,6 +6,7 @@ import VerificationBadge from "../main/VerificationBadge";
import CompleteRegister from "./settings/CompleteRegister";
import Link from "next/link";
import { useUser } from "@/hooks/useUser";
import { formatFullName } from "@/lib/formatFullName";
function UserDetails() {
const [isRegister, setIsRegister] = useState<string | undefined>("false");
@@ -33,9 +34,7 @@ function UserDetails() {
سطح: {user?.user_level ? user?.user_level : "تازه وارد"}
</span>
<span>
{user?.first_name && user?.first_name + " " + user?.last_name}
</span>
<span>{formatFullName(user?.first_name, user?.last_name)}</span>
<div className="flex items-center gap-1 mt-1">
<span className="inline-flex items-center gap-1">
{user?.user_name}

View File

@@ -1,5 +1,6 @@
import { useRouter } from "next/navigation";
import React from "react";
import { getRegistrationRoute } from "@/lib/auth/registrationRoutes";
function CompleteRegister({
user_name,
@@ -13,19 +14,23 @@ function CompleteRegister({
const router = useRouter();
const step =
typeof window !== "undefined" ? localStorage.getItem("step") : null;
const resolveStep = () => {
if (!user_name) return "user_name";
if (step === "password") return "password";
if (!first_name) return "first_name";
if (!user_type) return "registration_choice";
return "complete";
};
return (
<button
onClick={() => {
if (!user_name) {
router.push("/register/username");
} else if (step === "password") {
router.push("/register/password");
} else if (!first_name) {
router.push("/register/fullname");
} else if (!user_type) {
router.push("/register/usertype");
} else {
const targetStep = resolveStep();
if (targetStep === "complete") {
router.push("/verify/avatar");
} else {
router.push(getRegistrationRoute(targetStep));
}
}}
className="flex items-center gap-3 text-xs md:text-sm font-semibold mt-4"

View File

@@ -27,8 +27,19 @@ const getToken = (): string => {
return "";
};
const isPublicAuthRequest = (url: string): boolean =>
url.includes("/login") || url.includes("/register");
const isPublicAuthRequest = (url: string): boolean => {
const path = url.split("?")[0];
const publicPatterns = [
/^\/login\/?$/,
/^\/login\/verify$/,
/^\/login\/login_username$/,
/^\/login\/forgot_password$/,
/^\/register\/?$/,
/^\/register\/verify$/,
/^\/auth\/google$/,
];
return publicPatterns.some((pattern) => pattern.test(path));
};
// اضافه کردن توکن به درخواست‌های محافظت‌شده (نه لاگین/ثبت‌نام)
axiosInstance.interceptors.request.use(

View File

@@ -5,6 +5,21 @@ type ProxyOptions = {
stripAuth?: boolean;
};
const PUBLIC_AUTH_PATTERNS = [
/^\/login\/?$/,
/^\/login\/verify$/,
/^\/login\/login_username$/,
/^\/login\/forgot_password$/,
/^\/register\/?$/,
/^\/register\/verify$/,
/^\/auth\/google$/,
];
export function isPublicAuthPath(pathSegments: string[]): boolean {
const path = `/${pathSegments.join("/")}`;
return PUBLIC_AUTH_PATTERNS.some((pattern) => pattern.test(path));
}
export async function proxyToUpstream(
req: Request,
upstreamPath: string,
@@ -43,8 +58,3 @@ export async function proxyToUpstream(
headers: responseHeaders,
});
}
export function isPublicAuthPath(pathSegments: string[]): boolean {
const path = pathSegments.join("/");
return path.startsWith("login") || path.startsWith("register");
}

View File

@@ -0,0 +1,6 @@
export const MOBILE_NUMERIC_INPUT_PROPS = {
type: "tel" as const,
inputMode: "numeric" as const,
pattern: "[0-9]*",
autoComplete: "tel-national" as const,
};

View File

@@ -2,6 +2,7 @@ import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.
import type { IVerifyOtp } from "@/types/types";
import { setAuthSession } from "@/lib/auth/session";
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
import { getRegistrationRoute } from "@/lib/auth/registrationRoutes";
export function getSafeRedirectPath(): string {
if (typeof window === "undefined") return "/";
@@ -16,23 +17,7 @@ export async function navigateIncompleteRegistration(
router: AppRouterInstance,
step?: string
) {
switch (step) {
case "user_name":
router.push("/register/username");
break;
case "password":
router.push("/register/password");
break;
case "first_name":
router.push("/register/fullname");
break;
case "user_type":
router.push("/register/usertype");
break;
default:
router.push("/verify/avatar");
break;
}
router.push(getRegistrationRoute(step));
}
export async function completeLogin(
@@ -46,6 +31,8 @@ export async function completeLogin(
id: response.id,
user_type: response.user_type,
step: response.step,
auth_provider: response.auth_provider,
email: response.email ?? undefined,
});
await pause(AUTH_SUCCESS_DELAY_MS);

View File

@@ -0,0 +1,16 @@
export function getRegistrationRoute(step?: string): string {
switch (step) {
case "user_name":
return "/register/username";
case "password":
return "/register/password";
case "first_name":
return "/register/fullname";
case "registration_choice":
return "/register/complete";
case "user_type":
return "/register/complete";
default:
return "/register/username";
}
}

View File

@@ -6,6 +6,8 @@ export type AuthUserMeta = {
id?: string;
user_type?: string;
step?: string;
auth_provider?: string;
email?: string;
};
const cookieOptions = (): Cookies.CookieAttributes => ({
@@ -34,6 +36,8 @@ export async function setAuthSession(
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);
if (meta?.auth_provider) localStorage.setItem("auth_provider", meta.auth_provider);
if (meta?.email) localStorage.setItem("email", meta.email);
}
/** پاک کردن کامل session */
@@ -58,6 +62,8 @@ export async function clearAuthSession(): Promise<void> {
localStorage.removeItem("last_name");
localStorage.removeItem("username");
localStorage.removeItem("step");
localStorage.removeItem("auth_provider");
localStorage.removeItem("email");
}
export function getStoredUserId(): string {

View File

@@ -1,5 +1,24 @@
import type { ChatMessage } from "@/components/chat/ChatMessageCard";
function matchesPendingServer(server: ChatMessage, pending: ChatMessage): boolean {
if (String(server.senderId) !== String(pending.senderId)) return false;
if (pending.fileType !== server.fileType) return false;
if (pending.fileType === "location" || (!pending.fileType && !pending.file)) {
return server.content === pending.content;
}
return Boolean(pending.fileType && server.fileType === pending.fileType);
}
export function filterResolvedPendingMessages(
server: ChatMessage[],
pending: ChatMessage[]
): ChatMessage[] {
return pending.filter(
(p) =>
!server.some((s) => s._id === p._id || matchesPendingServer(s, p))
);
}
export function dedupeChatMessages(messages: ChatMessage[]): ChatMessage[] {
const seen = new Set<string>();
const result: ChatMessage[] = [];

View File

@@ -8,8 +8,8 @@ export type MessageGroup =
| { type: "date"; label: string; key: string }
| { type: "message"; message: ChatMessage; key: string };
function dayKey(createdAt?: string | null): string {
const d = parseMessageDate(createdAt);
function dayKey(msg: ChatMessage): string {
const d = parseMessageDate(msg.createdAtIso ?? msg.createdAt);
if (d) return d.toDateString();
return "unknown-day";
}
@@ -21,12 +21,12 @@ export function groupMessagesByDate(messages: ChatMessage[]): MessageGroup[] {
messages.forEach((msg, i) => {
if (!msg) return;
const dk = dayKey(msg.createdAt);
const dk = dayKey(msg);
if (dk !== lastDay) {
lastDay = dk;
groups.push({
type: "date",
label: formatChatDateLabel(msg.createdAt),
label: formatChatDateLabel(msg.createdAtIso ?? msg.createdAt),
key: `date-${dk}`,
});
}

View File

@@ -3,8 +3,8 @@ import { parseMessageDate } from "@/lib/chat/formatMessageTime";
export type BubbleGroupPosition = "single" | "first" | "middle" | "last";
function dayKey(createdAt?: string | null): string {
const d = parseMessageDate(createdAt);
function dayKey(msg: ChatMessage): string {
const d = parseMessageDate(msg.createdAtIso ?? msg.createdAt);
if (d) return d.toDateString();
return "unknown-day";
}
@@ -14,7 +14,7 @@ export function sameSender(a: ChatMessage, b: ChatMessage): boolean {
}
function sameVisualGroup(a: ChatMessage, b: ChatMessage): boolean {
return sameSender(a, b) && dayKey(a.createdAt) === dayKey(b.createdAt);
return sameSender(a, b) && dayKey(a) === dayKey(b);
}
export { sameVisualGroup };

View File

@@ -0,0 +1,9 @@
export function formatFullName(
firstName?: string | null,
lastName?: string | null
): string {
return [firstName, lastName]
.map((part) => (part == null ? "" : String(part).trim()))
.filter((part) => part.length > 0 && part.toLowerCase() !== "null")
.join(" ");
}

View File

@@ -10,6 +10,8 @@ export interface IVerifyOtp {
step: string;
page: string;
id: string;
auth_provider?: "mobile" | "google";
email?: string | null;
}
export interface Academy {
academy_image:string,
@@ -191,6 +193,8 @@ export interface User {
allProjectsCount?: number | undefined;
successfulProjectsCount?: number | undefined;
mobile?: string;
email?: string;
auth_provider?: "mobile" | "google";
isRegister?: boolean;
city?: ICity;
province?: IProvince;