From 58314d0ab5b82b969774e72e741257316dcf542b Mon Sep 17 00:00:00 2001 From: payacom <59262811+payacom@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:08:49 +0330 Subject: [PATCH] login --- package-lock.json | 11 ++ package.json | 1 + .../(auth)/(login)/change-passowrd/page.tsx | 108 ++++++++--- .../(auth)/(login)/forget-password/page.tsx | 100 +++++++--- src/app/(auth)/(login)/login/page.tsx | 8 +- src/app/(auth)/(login)/verify-otp/page.tsx | 22 +-- .../(auth)/(register)/register-otp/page.tsx | 25 ++- .../(register)/register/complete/page.tsx | 79 ++++++++ .../(register)/register/fullname/page.tsx | 179 ++++++++++++------ src/app/(auth)/(register)/register/page.tsx | 5 +- .../(register)/register/password/page.tsx | 91 +++++---- .../(register)/register/username/page.tsx | 82 ++++---- .../(register)/register/usertype/page.tsx | 128 +------------ src/app/(auth)/AuthProviders.tsx | 11 ++ src/app/(auth)/layout.tsx | 3 +- src/app/(auth)/verify/confirm/page.tsx | 5 +- src/app/(auth)/verify/expertise/page.tsx | 66 ++----- .../settings/chats/[username]/[id]/page.tsx | 77 ++++---- src/app/settings/edit/License/page.tsx | 5 +- src/app/settings/edit/expertise/page.tsx | 73 +++---- src/app/settings/edit/password/page.tsx | 22 ++- src/components/auth/AuthOtpInput.tsx | 2 +- src/components/auth/AuthUserDetails.tsx | 3 +- src/components/auth/ExpertisePicker.tsx | 99 ++++++++++ src/components/auth/GoogleSignInButton.tsx | 144 ++++++++++++++ src/components/chat/ChatMessageList.tsx | 11 +- src/components/main/UserInfo.tsx | 3 +- src/components/settings/UserDetails.tsx | 5 +- .../settings/settings/CompleteRegister.tsx | 23 ++- src/hooks/useAxios.tsx | 15 +- src/lib/api/upstreamProxy.ts | 20 +- src/lib/auth/inputProps.ts | 6 + src/lib/auth/postLogin.ts | 21 +- src/lib/auth/registrationRoutes.ts | 16 ++ src/lib/auth/session.ts | 6 + src/lib/chat/dedupeMessages.ts | 19 ++ src/lib/chat/groupMessagesByDate.ts | 8 +- src/lib/chat/messageGrouping.ts | 6 +- src/lib/formatFullName.ts | 9 + src/types/types.ts | 4 + 40 files changed, 986 insertions(+), 535 deletions(-) create mode 100644 src/app/(auth)/(register)/register/complete/page.tsx create mode 100644 src/app/(auth)/AuthProviders.tsx create mode 100644 src/components/auth/ExpertisePicker.tsx create mode 100644 src/components/auth/GoogleSignInButton.tsx create mode 100644 src/lib/auth/inputProps.ts create mode 100644 src/lib/auth/registrationRoutes.ts create mode 100644 src/lib/formatFullName.ts diff --git a/package-lock.json b/package-lock.json index f9e5c97..8bcfe0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 178101c..19f266b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(auth)/(login)/change-passowrd/page.tsx b/src/app/(auth)/(login)/change-passowrd/page.tsx index 6bae497..9a38a24 100644 --- a/src/app/(auth)/(login)/change-passowrd/page.tsx +++ b/src/app/(auth)/(login)/change-passowrd/page.tsx @@ -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() {
+

+ {isEmailReset + ? "کد ارسال‌شده به ایمیل و کلمه عبور جدید را وارد کنید." + : "کد ارسال‌شده به موبایل و کلمه عبور جدید را وارد کنید."} +

+ { + formik.setFieldValue("otp", otp); + setOtpError(false); + }} + error={ + otpError || Boolean(formik.touched.otp && formik.errors.otp) + } + disabled={loading} + /> + {formik.touched.otp && formik.errors.otp && ( + {formik.errors.otp} + )} + )} - + تغییر کلمه عبور - - - حساب کاربری ندارید؟ ثبت نام + + + ارسال مجدد کد + + + + + بازگشت به ورود -
); diff --git a/src/app/(auth)/(login)/forget-password/page.tsx b/src/app/(auth)/(login)/forget-password/page.tsx index c008d53..4a1c14b 100644 --- a/src/app/(auth)/(login)/forget-password/page.tsx +++ b/src/app/(auth)/(login)/forget-password/page.tsx @@ -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 (
- + +

+ ایمیل (برای حساب گوگل) یا شماره موبایل خود را وارد کنید تا کد بازیابی + ارسال شود. +

- {formik.touched.mobile && formik.errors.mobile && ( + {formik.touched.identifier && formik.errors.identifier && ( - {formik.errors.mobile} + {formik.errors.identifier} )} - - ارسال کد + {destination && ( + + کد به {destination} ارسال شد + + )} + + ارسال کد بازیابی - - - حساب کاربری ندارید؟ ثبت نام + + + بازگشت به ورود
@@ -83,4 +121,4 @@ function ForgetPassword() { ); } -export default ForgetPassword; +export default ForgetPasswordPage; diff --git a/src/app/(auth)/(login)/login/page.tsx b/src/app/(auth)/(login)/login/page.tsx index d629a96..499469f 100644 --- a/src/app/(auth)/(login)/login/page.tsx +++ b/src/app/(auth)/(login)/login/page.tsx @@ -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() { + با نام کاربری و کلمه عبور خود وارد شوید diff --git a/src/app/(auth)/(login)/verify-otp/page.tsx b/src/app/(auth)/(login)/verify-otp/page.tsx index 4871a52..e33e103 100644 --- a/src/app/(auth)/(login)/verify-otp/page.tsx +++ b/src/app/(auth)/(login)/verify-otp/page.tsx @@ -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; diff --git a/src/app/(auth)/(register)/register-otp/page.tsx b/src/app/(auth)/(register)/register-otp/page.tsx index e53f0fb..3be873e 100644 --- a/src/app/(auth)/(register)/register-otp/page.tsx +++ b/src/app/(auth)/(register)/register-otp/page.tsx @@ -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); diff --git a/src/app/(auth)/(register)/register/complete/page.tsx b/src/app/(auth)/(register)/register/complete/page.tsx new file mode 100644 index 0000000..cd9cfe1 --- /dev/null +++ b/src/app/(auth)/(register)/register/complete/page.tsx @@ -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 ( + +
+ + +

+ می‌توانید وارد برنامه شوید یا ثبت‌نام کامل (تایید هویت و پروفایل) + را ادامه دهید. +

+
+ + +
+ finishRegistration("continue")} + className="!border-[#0C8002] !text-[#0C8002] w-full sm:w-auto" + disabled={loading} + loading={loading && action === "continue"} + > + ادامه ثبت نام + + finishRegistration("app")} + className="!border-[#FF0000] !text-[#FF0000] w-full sm:w-auto" + disabled={loading} + loading={loading && action === "app"} + > + ورود به برنامه + +
+
+
+
+ ); +} + +export default RegisterCompletePage; diff --git a/src/app/(auth)/(register)/register/fullname/page.tsx b/src/app/(auth)/(register)/register/fullname/page.tsx index 1879ee9..1cdd88d 100644 --- a/src/app/(auth)/(register)/register/fullname/page.tsx +++ b/src/app/(auth)/(register)/register/fullname/page.tsx @@ -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 (
- - {formik.touched.name && formik.errors.name && ( - - {formik.errors.name} - - )} + + {formik.touched.name && formik.errors.name && ( + + {formik.errors.name} + + )} - - {formik.touched.family && formik.errors.family && ( - - {formik.errors.family} - - )} + + {formik.touched.family && formik.errors.family && ( + + {formik.errors.family} + + )} + + {nameSaved && ( +

+ نام شما ثبت شد. یکی از گزینه‌های زیر را انتخاب کنید. +

+ )}
- - ثبت و ادامه - + {!nameSaved ? ( + + ثبت و ادامه + + ) : ( +
+ finishRegistration("continue")} + className="!border-[#0C8002] !text-[#0C8002] w-full sm:w-auto" + disabled={Boolean(choiceLoading)} + loading={choiceLoading === "continue"} + > + ادامه ثبت نام + + finishRegistration("app")} + className="!border-[#FF0000] !text-[#FF0000] w-full sm:w-auto" + disabled={Boolean(choiceLoading)} + loading={choiceLoading === "app"} + > + ورود به برنامه + +
+ )}
diff --git a/src/app/(auth)/(register)/register/page.tsx b/src/app/(auth)/(register)/register/page.tsx index b1c56aa..d0fed7f 100644 --- a/src/app/(auth)/(register)/register/page.tsx +++ b/src/app/(auth)/(register)/register/page.tsx @@ -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() { + - ))} - - {selectedExpertise?.sub_expertise.map((item) => ( - - ))} - + { + setExpertise(value); + setSubExpertise([]); + }} + onSubExpertiseToggle={(value) => { + setSubExpertise((prev) => + prev.includes(value) + ? prev.filter((item) => item !== value) + : [...prev, value] + ); + }} + /> router.push("/verify/services")}> diff --git a/src/app/settings/chats/[username]/[id]/page.tsx b/src/app/settings/chats/[username]/[id]/page.tsx index 0004570..9af916d 100644 --- a/src/app/settings/chats/[username]/[id]/page.tsx +++ b/src/app/settings/chats/[username]/[id]/page.tsx @@ -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 = { 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( + response = await request( "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( + "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) + diff --git a/src/app/settings/edit/License/page.tsx b/src/app/settings/edit/License/page.tsx index 78a64b8..4011cd7 100644 --- a/src/app/settings/edit/License/page.tsx +++ b/src/app/settings/edit/License/page.tsx @@ -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" />
- - {user?.first_name && user?.first_name + " " + user?.last_name} - + {formatFullName(user?.first_name, user?.last_name)}
{user?.user_name} diff --git a/src/app/settings/edit/expertise/page.tsx b/src/app/settings/edit/expertise/page.tsx index 933d87b..95aa21a 100644 --- a/src/app/settings/edit/expertise/page.tsx +++ b/src/app/settings/edit/expertise/page.tsx @@ -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(null); const [subExpertise, setSubExpertise] = useState([]); 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 ( -
+
تخصص

در چه زمینه ای تخصص دارید؟

-
- {expertiseList?.map((item) => ( - - ))} - - {selectedExpertise?.sub_expertise.map((item) => ( - - ))} -
+ { + setExpertise(value); + setSubExpertise([]); + }} + onSubExpertiseToggle={(value) => { + setSubExpertise((prev) => + prev.includes(value) + ? prev.filter((item) => item !== value) + : [...prev, value] + ); + }} + /> ویرایش diff --git a/src/app/settings/edit/password/page.tsx b/src/app/settings/edit/password/page.tsx index 83a4802..f95a9d8 100644 --- a/src/app/settings/edit/password/page.tsx +++ b/src/app/settings/edit/password/page.tsx @@ -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 (
- + + {isGoogleUser && ( +

+ حساب شما با گوگل ساخته شده است. در صورت تمایل می‌توانید کلمه عبور + جداگانه تنظیم کنید. +

+ )}
- {userDetail?.first_name} {userDetail?.last_name} + {formatFullName(userDetail?.first_name, userDetail?.last_name)}

{userDetail?.user_name}

diff --git a/src/components/auth/ExpertisePicker.tsx b/src/components/auth/ExpertisePicker.tsx new file mode 100644 index 0000000..912a95b --- /dev/null +++ b/src/components/auth/ExpertisePicker.tsx @@ -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 ( +

+ در حال بارگذاری تخصص‌ها... +

+ ); + } + + return ( +
+
+ {expertiseList?.map((item) => ( + + ))} +
+ + {selectedExpertise && subCount > 0 && ( +
+

+ زیرمجموعه {selectedExpertise.expertise} +

+
+ {selectedExpertise.sub_expertise.map((item) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/src/components/auth/GoogleSignInButton.tsx b/src/components/auth/GoogleSignInButton.tsx new file mode 100644 index 0000000..4a8ad82 --- /dev/null +++ b/src/components/auth/GoogleSignInButton.tsx @@ -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 ( + + ); +} + +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 ( +
+
+ + + {mode === "register" ? "یا ثبت‌نام با" : "یا ورود با"} + +
+ +
+
+ + {label} +
+ +
+ 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" }, + }} + /> +
+
+
+ ); +} + +export default function GoogleSignInButton(props: GoogleSignInButtonProps) { + if (!GOOGLE_CLIENT_ID) return null; + return ; +} + +export function GoogleAuthRootProvider({ + children, +}: { + children: React.ReactNode; +}) { + if (!GOOGLE_CLIENT_ID) return <>{children}; + + return ( + + {children} + + ); +} diff --git a/src/components/chat/ChatMessageList.tsx b/src/components/chat/ChatMessageList.tsx index 0216104..99c2a84 100644 --- a/src/components/chat/ChatMessageList.tsx +++ b/src/components/chat/ChatMessageList.tsx @@ -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(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; diff --git a/src/components/main/UserInfo.tsx b/src/components/main/UserInfo.tsx index d18e5d8..2cf8d45 100644 --- a/src/components/main/UserInfo.tsx +++ b/src/components/main/UserInfo.tsx @@ -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({ سطح: {user_level ? user_level : "تازه وارد"} -

{first_name && first_name + " " + last_name}

+

{formatFullName(first_name, last_name)}

{user_name} diff --git a/src/components/settings/UserDetails.tsx b/src/components/settings/UserDetails.tsx index ab0ec82..1c57500 100644 --- a/src/components/settings/UserDetails.tsx +++ b/src/components/settings/UserDetails.tsx @@ -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("false"); @@ -33,9 +34,7 @@ function UserDetails() { سطح: {user?.user_level ? user?.user_level : "تازه وارد"} - - {user?.first_name && user?.first_name + " " + user?.last_name} - + {formatFullName(user?.first_name, user?.last_name)}
{user?.user_name} diff --git a/src/components/settings/settings/CompleteRegister.tsx b/src/components/settings/settings/CompleteRegister.tsx index e5bafc7..cb6486b 100644 --- a/src/components/settings/settings/CompleteRegister.tsx +++ b/src/components/settings/settings/CompleteRegister.tsx @@ -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 (