login
This commit is contained in:
11
package-lock.json
generated
11
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
با نام کاربری و کلمه عبور خود وارد شوید
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
79
src/app/(auth)/(register)/register/complete/page.tsx
Normal file
79
src/app/(auth)/(register)/register/complete/page.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
قوانین و مقررات
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
11
src/app/(auth)/AuthProviders.tsx
Normal file
11
src/app/(auth)/AuthProviders.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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) +
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function AuthOtpInput({
|
||||
}
|
||||
}}
|
||||
numInputs={numInputs}
|
||||
inputType="tel"
|
||||
inputType="number"
|
||||
shouldAutoFocus
|
||||
skipDefaultStyles
|
||||
containerStyle={{
|
||||
|
||||
@@ -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>
|
||||
|
||||
99
src/components/auth/ExpertisePicker.tsx
Normal file
99
src/components/auth/ExpertisePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
src/components/auth/GoogleSignInButton.tsx
Normal file
144
src/components/auth/GoogleSignInButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
6
src/lib/auth/inputProps.ts
Normal file
6
src/lib/auth/inputProps.ts
Normal 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,
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
16
src/lib/auth/registrationRoutes.ts
Normal file
16
src/lib/auth/registrationRoutes.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
9
src/lib/formatFullName.ts
Normal file
9
src/lib/formatFullName.ts
Normal 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(" ");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user