Initial commit
This commit is contained in:
@@ -41,12 +41,16 @@ export async function fetchBillboards(
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[fetchBillboards] fetch failed:", apiUrl, error);
|
||||
return { advertisings: [], totalItems: 0, totalPages: 0 };
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
console.error("[fetchBillboards] bad response:", response.status, apiUrl);
|
||||
return { advertisings: [], totalItems: 0, totalPages: 0 };
|
||||
}
|
||||
|
||||
return response.json();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { normalizeUserLevel } from "@/lib/userLevel";
|
||||
|
||||
export async function fetchPosts(
|
||||
page: number,
|
||||
@@ -14,7 +15,12 @@ export async function fetchPosts(
|
||||
},
|
||||
token: string
|
||||
) {
|
||||
const validFilters = Object.entries(filters || {})
|
||||
const normalizedFilters = { ...filters };
|
||||
if (normalizedFilters.userLevel) {
|
||||
normalizedFilters.userLevel = normalizeUserLevel(normalizedFilters.userLevel);
|
||||
}
|
||||
|
||||
const validFilters = Object.entries(normalizedFilters || {})
|
||||
.filter(([value]) => value !== undefined && value !== "")
|
||||
.reduce((acc, [key, value]) => {
|
||||
acc[key] = value;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import AuthButton from "@/components/auth/AuthButton";
|
||||
import AuthHead from "@/components/auth/AuthHead";
|
||||
import AuthInput from "@/components/auth/AuthInput";
|
||||
import AuthPasswordInput from "@/components/auth/AuthPasswordInput";
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthRules from "@/components/auth/AuthRules";
|
||||
import Link from "next/link";
|
||||
@@ -14,7 +14,7 @@ import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { IVerifyOtp } from "@/types/types";
|
||||
|
||||
// Validation schema using Yup //
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object().shape({
|
||||
password: yup
|
||||
.string()
|
||||
@@ -71,11 +71,11 @@ function ChangePassword() {
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthInput
|
||||
<AuthPasswordInput
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="کلمه عبور جدید"
|
||||
className={`border mt-4 ${
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.password && formik.errors.password
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
@@ -90,11 +90,11 @@ function ChangePassword() {
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthInput
|
||||
<AuthPasswordInput
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
placeholder="تکرار کلمه عبور"
|
||||
className={`border mt-4 ${
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.confirmPassword && formik.errors.confirmPassword
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
@@ -109,8 +109,8 @@ function ChangePassword() {
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthButton type="submit" className="mt-5" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "تغییر کلمه عبور"}
|
||||
<AuthButton type="submit" className="mt-5" loading={loading} disabled={loading}>
|
||||
تغییر کلمه عبور
|
||||
</AuthButton>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -7,13 +7,15 @@ import Container from "@/components/elements/Container";
|
||||
import AuthRules from "@/components/auth/AuthRules";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
import OtpInput from "react-otp-input";
|
||||
import AuthOtpInput from "@/components/auth/AuthOtpInput";
|
||||
import AuthOtpStatus from "@/components/auth/AuthOtpStatus";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { IVerifyOtp } from "@/types/types";
|
||||
import Cookies from "js-cookie";
|
||||
import { setAuthSession } from "@/lib/auth/session";
|
||||
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
@@ -26,6 +28,10 @@ const schema = yup.object({
|
||||
function ForgetPasswordOtp() {
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const [otpExpired, setOtpExpired] = useState(false);
|
||||
const [resendLoading, setResendLoading] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const [otpError, setOtpError] = useState(false);
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
@@ -47,15 +53,14 @@ function ForgetPasswordOtp() {
|
||||
mobile,
|
||||
otp: values.otp.trim(),
|
||||
})) as IVerifyOtp;
|
||||
document.cookie = `token=${response.token}; path=/; Secure`;
|
||||
Cookies.set("token", response.token, {
|
||||
secure: false,
|
||||
path: "/",
|
||||
expires: 365
|
||||
});
|
||||
await setAuthSession(response.token);
|
||||
await localStorage.setItem("otp", values.otp);
|
||||
setIsSuccess(true);
|
||||
await pause(AUTH_SUCCESS_DELAY_MS);
|
||||
router.push("/change-passowrd");
|
||||
} catch (err: any) {
|
||||
setOtpError(true);
|
||||
setIsSuccess(false);
|
||||
console.log("Unhandled error:", err?.message);
|
||||
}
|
||||
},
|
||||
@@ -65,31 +70,39 @@ function ForgetPasswordOtp() {
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="فراموش کردن کلمه عبور" />
|
||||
<small className="mb-10">کد 6 رقمی به شماره {mobile} ارسال شد</small>
|
||||
<AuthOtpStatus
|
||||
mobile={mobile}
|
||||
resendLoading={resendLoading}
|
||||
onExpiredChange={setOtpExpired}
|
||||
onResend={async () => {
|
||||
if (!mobile) return;
|
||||
setResendLoading(true);
|
||||
try {
|
||||
await request("POST", "/login", { mobile });
|
||||
formik.setFieldValue("otp", "");
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<OtpInput
|
||||
<AuthOtpInput
|
||||
className="mt-3"
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => {
|
||||
if (formik.values.otp !== otp) {
|
||||
formik.setFieldValue("otp", otp);
|
||||
if (otp.length === 6) {
|
||||
setTimeout(() => {
|
||||
formik.submitForm();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
formik.setFieldValue("otp", otp);
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
}}
|
||||
numInputs={6}
|
||||
containerStyle="mt-3 dir-ltr flex gap-x-4 justify-center"
|
||||
inputStyle={`text-center min-w-[30px] border bg-secondary-light dark:bg-secondary-dark rounded-2xl py-3 ${
|
||||
formik.touched.otp && formik.errors.otp
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-border-primary-light dark:border-border-primary-dark"
|
||||
} focus:outline-none`}
|
||||
renderInput={(props) => <input {...props} />}
|
||||
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
|
||||
error={otpError || Boolean(formik.touched.otp && formik.errors.otp)}
|
||||
success={isSuccess}
|
||||
disabled={loading || otpExpired || isSuccess}
|
||||
/>
|
||||
{formik.touched.otp && formik.errors.otp && (
|
||||
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
|
||||
@@ -97,9 +110,9 @@ function ForgetPasswordOtp() {
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
disabled={loading || formik.values.otp.length !== 6}
|
||||
loading={loading} disabled={loading || otpExpired || isSuccess || formik.values.otp.length !== 6}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "تایید کد"}
|
||||
تایید کد
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href={"/login-with-username"}>
|
||||
|
||||
@@ -6,10 +6,11 @@ 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 useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { markOtpSent } from "@/lib/auth/otpTimer";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
@@ -32,6 +33,7 @@ function ForgetPassword() {
|
||||
try {
|
||||
await request("POST", "/login", { mobile: values.mobile });
|
||||
await localStorage.setItem("mobile", values.mobile);
|
||||
markOtpSent();
|
||||
router.push("/forget-password-otp");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
@@ -51,6 +53,7 @@ function ForgetPassword() {
|
||||
name="mobile"
|
||||
maxLength={11}
|
||||
type="text"
|
||||
dir="ltr"
|
||||
placeholder="09 _ _ _ _ _ _ _ _ _"
|
||||
className={`border ${
|
||||
formik.touched.mobile && formik.errors.mobile
|
||||
@@ -66,8 +69,8 @@ function ForgetPassword() {
|
||||
{formik.errors.mobile}
|
||||
</small>
|
||||
)}
|
||||
<AuthButton type="submit" className="mt-5" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ارسال کد"}
|
||||
<AuthButton type="submit" className="mt-5" loading={loading} disabled={loading}>
|
||||
ارسال کد
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href={"/register"}>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import AuthButton from "@/components/auth/AuthButton";
|
||||
import AuthHead from "@/components/auth/AuthHead";
|
||||
import AuthInput from "@/components/auth/AuthInput";
|
||||
import AuthPasswordInput from "@/components/auth/AuthPasswordInput";
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthRules from "@/components/auth/AuthRules";
|
||||
import Link from "next/link";
|
||||
@@ -13,7 +14,8 @@ import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { IVerifyOtp } from "@/types/types";
|
||||
import Cookies from "js-cookie";
|
||||
import { setAuthSession } from "@/lib/auth/session";
|
||||
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object().shape({
|
||||
@@ -31,6 +33,8 @@ const schema = yup.object().shape({
|
||||
function LoginWithUsername() {
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const [authError, setAuthError] = useState(false);
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
@@ -50,33 +54,31 @@ function LoginWithUsername() {
|
||||
})) as IVerifyOtp;
|
||||
switch (response?.page) {
|
||||
case "home":
|
||||
document.cookie = `token=${response.token}; path=/; Secure`;
|
||||
Cookies.set("token", response.token, {
|
||||
secure: false,
|
||||
path: "/",
|
||||
expires: 365,
|
||||
await setAuthSession(response.token, {
|
||||
id: response.id,
|
||||
user_type: response.user_type,
|
||||
step: response.step,
|
||||
});
|
||||
|
||||
await localStorage.setItem("usertype", response.user_type);
|
||||
await localStorage.setItem("id", response.id);
|
||||
await localStorage.setItem("step", response.step);
|
||||
setIsSuccess(true);
|
||||
await pause(AUTH_SUCCESS_DELAY_MS);
|
||||
router.refresh();
|
||||
router.push("/");
|
||||
break;
|
||||
case "auth-page":
|
||||
Cookies.set("token", response.token, {
|
||||
secure: false,
|
||||
path: "/",
|
||||
expires: 365,
|
||||
await setAuthSession(response.token, {
|
||||
id: response.id,
|
||||
step: response.step,
|
||||
});
|
||||
// await localStorage.setItem("usertype", response.user_type);
|
||||
await localStorage.setItem("id", response.id);
|
||||
await localStorage.setItem("step", response.step);
|
||||
setIsSuccess(true);
|
||||
await pause(AUTH_SUCCESS_DELAY_MS);
|
||||
router.push("/auth");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (err: any) {
|
||||
setAuthError(true);
|
||||
setIsSuccess(false);
|
||||
console.log("Unhandled error:", err?.message);
|
||||
}
|
||||
},
|
||||
@@ -94,40 +96,44 @@ function LoginWithUsername() {
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="نام کاربری"
|
||||
className={`border ${
|
||||
formik.touched.username && formik.errors.username
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
success={isSuccess}
|
||||
error={authError || Boolean(formik.touched.username && formik.errors.username)}
|
||||
value={formik.values.username}
|
||||
onChange={formik.handleChange}
|
||||
onChange={(e) => {
|
||||
setAuthError(false);
|
||||
setIsSuccess(false);
|
||||
formik.handleChange(e);
|
||||
}}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={loading || isSuccess}
|
||||
/>
|
||||
{formik.touched.username && formik.errors.username && (
|
||||
{formik.touched.username && formik.errors.username && !authError && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.username}
|
||||
</small>
|
||||
)}
|
||||
<AuthInput
|
||||
<AuthPasswordInput
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="کلمه عبور"
|
||||
className={`border mt-4 ${
|
||||
formik.touched.password && formik.errors.password
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
wrapperClassName="mt-4"
|
||||
success={isSuccess}
|
||||
error={authError || Boolean(formik.touched.password && formik.errors.password)}
|
||||
value={formik.values.password}
|
||||
onChange={formik.handleChange}
|
||||
onChange={(e) => {
|
||||
setAuthError(false);
|
||||
setIsSuccess(false);
|
||||
formik.handleChange(e);
|
||||
}}
|
||||
onBlur={formik.handleBlur}
|
||||
disabled={loading || isSuccess}
|
||||
/>
|
||||
{formik.touched.password && formik.errors.password && (
|
||||
{formik.touched.password && formik.errors.password && !authError && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
{formik.errors.password}
|
||||
</small>
|
||||
)}
|
||||
<AuthButton type="submit" className="mt-5" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ورود"}
|
||||
<AuthButton type="submit" className="mt-5" loading={loading} disabled={loading || isSuccess}>
|
||||
ورود
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href={"/forget-password"}>
|
||||
|
||||
@@ -11,6 +11,7 @@ import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { markOtpSent } from "@/lib/auth/otpTimer";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
@@ -34,6 +35,7 @@ function Login() {
|
||||
try {
|
||||
await request("POST", "/login", { mobile: values.mobile });
|
||||
await localStorage.setItem("mobile", values.mobile);
|
||||
markOtpSent();
|
||||
router.push("/verify-otp");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
@@ -53,7 +55,8 @@ function Login() {
|
||||
name="mobile"
|
||||
maxLength={11}
|
||||
type="tel"
|
||||
pattern="[0-9]*"
|
||||
pattern="[0-9]*"
|
||||
dir="ltr"
|
||||
placeholder="09 _ _ _ _ _ _ _ _ _"
|
||||
className={`border ${
|
||||
formik.touched.mobile && formik.errors.mobile
|
||||
@@ -69,8 +72,8 @@ function Login() {
|
||||
{formik.errors.mobile}
|
||||
</small>
|
||||
)}
|
||||
<AuthButton type="submit" className="mt-5" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ارسال کد"}
|
||||
<AuthButton type="submit" className="mt-5" loading={loading} disabled={loading}>
|
||||
ارسال کد
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href={"/login-with-username"}>
|
||||
|
||||
@@ -7,13 +7,15 @@ import Container from "@/components/elements/Container";
|
||||
import AuthRules from "@/components/auth/AuthRules";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
import OtpInput from "react-otp-input";
|
||||
import AuthOtpInput from "@/components/auth/AuthOtpInput";
|
||||
import AuthOtpStatus from "@/components/auth/AuthOtpStatus";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { IVerifyOtp } from "@/types/types";
|
||||
import Cookies from "js-cookie";
|
||||
import { setAuthSession } from "@/lib/auth/session";
|
||||
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
@@ -26,6 +28,10 @@ const schema = yup.object({
|
||||
function VerifyOtp() {
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const [otpExpired, setOtpExpired] = useState(false);
|
||||
const [resendLoading, setResendLoading] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const [otpError, setOtpError] = useState(false);
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
@@ -50,25 +56,23 @@ function VerifyOtp() {
|
||||
|
||||
switch (response?.page) {
|
||||
case "home":
|
||||
Cookies.set("token", response.token, {
|
||||
secure: false,
|
||||
path: "/",
|
||||
expires: 365,
|
||||
await setAuthSession(response.token, {
|
||||
id: response.id,
|
||||
user_type: response.user_type,
|
||||
step: response.step,
|
||||
});
|
||||
await localStorage.setItem("usertype", response.user_type);
|
||||
await localStorage.setItem("id", response.id);
|
||||
await localStorage.setItem("step", response.step);
|
||||
setIsSuccess(true);
|
||||
await pause(AUTH_SUCCESS_DELAY_MS);
|
||||
router.refresh();
|
||||
router.push("/");
|
||||
break;
|
||||
case "auth-page":
|
||||
Cookies.set("token", response.token, {
|
||||
secure: false,
|
||||
path: "/",
|
||||
expires: 365,
|
||||
await setAuthSession(response.token, {
|
||||
id: response.id,
|
||||
step: response.step,
|
||||
});
|
||||
// await localStorage.setItem("usertype", response.user_type);
|
||||
await localStorage.setItem("id", response.id);
|
||||
await localStorage.setItem("step", response.step);
|
||||
setIsSuccess(true);
|
||||
await pause(AUTH_SUCCESS_DELAY_MS);
|
||||
|
||||
switch (response.step) {
|
||||
case "user_name":
|
||||
@@ -93,6 +97,8 @@ function VerifyOtp() {
|
||||
}
|
||||
// router.push("/dashboard");
|
||||
} catch (err: any) {
|
||||
setOtpError(true);
|
||||
setIsSuccess(false);
|
||||
console.log("Unhandled error:", err?.message);
|
||||
}
|
||||
},
|
||||
@@ -102,34 +108,39 @@ function VerifyOtp() {
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="ورود" />
|
||||
<small className="mb-10">کد 6 رقمی به شماره {mobile} ارسال شد</small>
|
||||
<AuthOtpStatus
|
||||
mobile={mobile}
|
||||
resendLoading={resendLoading}
|
||||
onExpiredChange={setOtpExpired}
|
||||
onResend={async () => {
|
||||
if (!mobile) return;
|
||||
setResendLoading(true);
|
||||
try {
|
||||
await request("POST", "/login", { mobile });
|
||||
formik.setFieldValue("otp", "");
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<OtpInput
|
||||
<AuthOtpInput
|
||||
className="mt-3"
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => {
|
||||
formik.setFieldValue("otp", otp);
|
||||
if (otp.length === 6) {
|
||||
setTimeout(() => formik.submitForm(), 500);
|
||||
}
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
}}
|
||||
numInputs={6}
|
||||
containerStyle="mt-3 dir-ltr flex gap-x-4 justify-center"
|
||||
inputStyle={`text-center min-w-[30px] border bg-secondary-light dark:bg-secondary-dark rounded-2xl py-3 ${
|
||||
formik.touched.otp && formik.errors.otp
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-border-primary-light "
|
||||
} focus:outline-none`}
|
||||
renderInput={(props) => (
|
||||
<input
|
||||
{...props}
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
/>
|
||||
)}
|
||||
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
|
||||
error={otpError || Boolean(formik.touched.otp && formik.errors.otp)}
|
||||
success={isSuccess}
|
||||
disabled={loading || otpExpired || isSuccess}
|
||||
/>
|
||||
|
||||
{formik.touched.otp && formik.errors.otp && (
|
||||
@@ -138,9 +149,9 @@ function VerifyOtp() {
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
disabled={loading || formik.values.otp.length !== 6}
|
||||
loading={loading} disabled={loading || otpExpired || isSuccess || formik.values.otp.length !== 6}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "تایید کد"}
|
||||
تایید کد
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href={"/login-with-username"}>
|
||||
|
||||
@@ -8,13 +8,15 @@ import Container from "@/components/elements/Container";
|
||||
import AuthRules from "@/components/auth/AuthRules";
|
||||
import Link from "next/link";
|
||||
import React, { useState } from "react";
|
||||
import OtpInput from "react-otp-input";
|
||||
import AuthOtpInput from "@/components/auth/AuthOtpInput";
|
||||
import AuthOtpStatus from "@/components/auth/AuthOtpStatus";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { IVerifyOtp } from "@/types/types";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Cookies from "js-cookie";
|
||||
import { setAuthSession } from "@/lib/auth/session";
|
||||
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
@@ -27,6 +29,10 @@ const schema = yup.object({
|
||||
function RegisterOtp() {
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const [otpExpired, setOtpExpired] = useState(false);
|
||||
const [resendLoading, setResendLoading] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const [otpError, setOtpError] = useState(false);
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
@@ -49,12 +55,9 @@ function RegisterOtp() {
|
||||
otp: values.otp.trim(),
|
||||
})) as IVerifyOtp;
|
||||
await localStorage.setItem("otp", values.otp);
|
||||
Cookies.set("token", response.token, {
|
||||
secure: false,
|
||||
path: "/",
|
||||
expires: 365
|
||||
});
|
||||
await localStorage.setItem("step", response.step);
|
||||
await setAuthSession(response.token, { step: response.step });
|
||||
setIsSuccess(true);
|
||||
await pause(AUTH_SUCCESS_DELAY_MS);
|
||||
response.step === "user_name"
|
||||
? router.push("/register/username")
|
||||
: response.step === "password"
|
||||
@@ -65,6 +68,8 @@ function RegisterOtp() {
|
||||
? router.push("/register/usertype")
|
||||
: router.push("/verify/avatar");
|
||||
} catch (err: any) {
|
||||
setOtpError(true);
|
||||
setIsSuccess(false);
|
||||
console.log("Unhandled error:", err.message);
|
||||
}
|
||||
},
|
||||
@@ -74,31 +79,39 @@ function RegisterOtp() {
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="ثبت نام" />
|
||||
<small className="mb-10">کد 6 رقمی به شماره {mobile} ارسال شد</small>
|
||||
<AuthOtpStatus
|
||||
mobile={mobile}
|
||||
resendLoading={resendLoading}
|
||||
onExpiredChange={setOtpExpired}
|
||||
onResend={async () => {
|
||||
if (!mobile) return;
|
||||
setResendLoading(true);
|
||||
try {
|
||||
await request("POST", "/register", { mobile });
|
||||
formik.setFieldValue("otp", "");
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<OtpInput
|
||||
<AuthOtpInput
|
||||
className="mt-3"
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => {
|
||||
if (formik.values.otp !== otp) {
|
||||
formik.setFieldValue("otp", otp);
|
||||
if (otp.length === 6) {
|
||||
setTimeout(() => {
|
||||
formik.submitForm();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
formik.setFieldValue("otp", otp);
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
}}
|
||||
numInputs={6}
|
||||
containerStyle="mt-3 dir-ltr flex gap-x-4 justify-center"
|
||||
inputStyle={`text-center min-w-[30px] border bg-secondary-light dark:bg-secondary-dark rounded-2xl py-3 ${
|
||||
formik.touched.otp && formik.errors.otp
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-border-primary-light dark:border-border-primary-dark"
|
||||
} focus:outline-none`}
|
||||
renderInput={(props) => <input {...props} />}
|
||||
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
|
||||
error={otpError || Boolean(formik.touched.otp && formik.errors.otp)}
|
||||
success={isSuccess}
|
||||
disabled={loading || otpExpired || isSuccess}
|
||||
/>
|
||||
{formik.touched.otp && formik.errors.otp && (
|
||||
<small className="text-red-500 mt-2">{formik.errors.otp}</small>
|
||||
@@ -106,9 +119,9 @@ function RegisterOtp() {
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
disabled={loading || formik.values.otp.length !== 6}
|
||||
loading={loading} disabled={loading || otpExpired || isSuccess || formik.values.otp.length !== 6}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "تایید کد"}
|
||||
تایید کد
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href={"/forget-password"}>
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
|
||||
import AuthHead from "@/components/auth/AuthHead";
|
||||
import AuthInput from "@/components/auth/AuthInput";
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import React from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
@@ -45,17 +48,19 @@ function FullNamePage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="نام و نام خانوادگی" />
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<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">
|
||||
<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"
|
||||
@@ -66,7 +71,7 @@ function FullNamePage() {
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.name && formik.errors.name && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.name}
|
||||
</small>
|
||||
)}
|
||||
@@ -75,6 +80,7 @@ function FullNamePage() {
|
||||
name="family"
|
||||
type="text"
|
||||
placeholder="نام خانوادگی"
|
||||
dir="rtl"
|
||||
className={`border mt-4 ${
|
||||
formik.touched.family && formik.errors.family
|
||||
? "border-red-500 dark:border-red-500"
|
||||
@@ -85,17 +91,20 @@ function FullNamePage() {
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.family && formik.errors.family && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{formik.errors.family}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
|
||||
<AuthFormFooter>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { markOtpSent } from "@/lib/auth/otpTimer";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
@@ -33,6 +34,7 @@ function Register() {
|
||||
try {
|
||||
await request("POST", "/register", { mobile: values.mobile });
|
||||
await localStorage.setItem("mobile", values.mobile);
|
||||
markOtpSent();
|
||||
router.push("/register-otp");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
@@ -52,6 +54,7 @@ function Register() {
|
||||
name="mobile"
|
||||
maxLength={11}
|
||||
type="text"
|
||||
dir="ltr"
|
||||
placeholder="09 _ _ _ _ _ _ _ _ _"
|
||||
className={`border ${
|
||||
formik.touched.mobile && formik.errors.mobile
|
||||
@@ -67,8 +70,8 @@ function Register() {
|
||||
{formik.errors.mobile}
|
||||
</small>
|
||||
)}
|
||||
<AuthButton type="submit" className="mt-5" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ارسال کد"}
|
||||
<AuthButton type="submit" className="mt-5" loading={loading} disabled={loading}>
|
||||
ارسال کد
|
||||
</AuthButton>
|
||||
</form>
|
||||
<button onClick={() => setModalOpen(true)} className="mb-2">
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
"use client";
|
||||
|
||||
import AuthHead from "@/components/auth/AuthHead";
|
||||
import AuthInput from "@/components/auth/AuthInput";
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthPasswordInput from "@/components/auth/AuthPasswordInput";
|
||||
import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import React from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
@@ -61,18 +64,19 @@ function PasswordPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="کلمه عبور" />
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthInput
|
||||
<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">
|
||||
<AuthPasswordInput
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="کلمه عبور"
|
||||
className={`border mt-4 ${
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.password && formik.errors.password
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
@@ -87,11 +91,11 @@ function PasswordPage() {
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthInput
|
||||
<AuthPasswordInput
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
placeholder="تکرار کلمه عبور"
|
||||
className={`border mt-4 ${
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.confirmPassword && formik.errors.confirmPassword
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
@@ -105,13 +109,16 @@ function PasswordPage() {
|
||||
{formik.errors.confirmPassword}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "تایید کلمه عبور"}
|
||||
<AuthFormFooter>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
تایید کلمه عبور
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,28 +3,25 @@
|
||||
|
||||
import AuthHead from "@/components/auth/AuthHead";
|
||||
import AuthInput from "@/components/auth/AuthInput";
|
||||
import Container from "@/components/elements/Container";
|
||||
import React from "react";
|
||||
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 { useRouter } from "next/navigation";
|
||||
import { IVerifyOtp } from "@/types/types";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
// ✅ فقط چک میکنیم مقدار وارد شده باشه
|
||||
const schema = yup.object().shape({
|
||||
username: yup
|
||||
.string()
|
||||
.required("نام کاربری الزامی است")
|
||||
.min(1, "نام کاربری باید حداقل ۱ کاراکتر باشد")
|
||||
.max(100, "نام کاربری نمیتواند بیشتر از ۱۰۰ کاراکتر باشد"),
|
||||
});
|
||||
import UsernameSuggestions from "@/components/auth/UsernameSuggestions";
|
||||
import { usernameFormSchema } from "@/lib/validation/usernameSchema";
|
||||
import { sanitizeUsernameInput, USERNAME_MIN_LENGTH } from "@/lib/validation/username";
|
||||
|
||||
function UsernamePage() {
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const [isDuplicate, setIsDuplicate] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
|
||||
@@ -32,60 +29,100 @@ function UsernamePage() {
|
||||
initialValues: {
|
||||
username: "",
|
||||
},
|
||||
validationSchema: schema,
|
||||
validationSchema: usernameFormSchema,
|
||||
onSubmit: async (values) => {
|
||||
setIsDuplicate(false);
|
||||
setSuggestions([]);
|
||||
|
||||
try {
|
||||
await request("POST", "/register/username", {
|
||||
mobile,
|
||||
user_name: values.username,
|
||||
user_name: values.username.trim().toLowerCase(),
|
||||
}) as IVerifyOtp;
|
||||
|
||||
localStorage.setItem("username", values.username);
|
||||
localStorage.setItem("username", values.username.trim().toLowerCase());
|
||||
router.push("/register/password");
|
||||
} catch (err: any) {
|
||||
console.log("Unhandled error:", err?.message);
|
||||
if (err?.response?.data?.message) {
|
||||
toast.error(err.response.data.message);
|
||||
} else {
|
||||
toast.error("خطایی رخ داد، دوباره تلاش کنید");
|
||||
const data = err?.response?.data;
|
||||
if (data?.message?.includes("تکراری")) {
|
||||
setIsDuplicate(true);
|
||||
setSuggestions(Array.isArray(data.suggestions) ? data.suggestions : []);
|
||||
} else if (data?.message) {
|
||||
formik.setFieldError("username", data.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleUsernameChange = (value: string) => {
|
||||
setIsDuplicate(false);
|
||||
setSuggestions([]);
|
||||
formik.setFieldValue("username", sanitizeUsernameInput(value));
|
||||
};
|
||||
|
||||
const handleSuggestionSelect = (username: string) => {
|
||||
formik.setFieldValue("username", username);
|
||||
formik.setFieldTouched("username", true, false);
|
||||
setIsDuplicate(false);
|
||||
setSuggestions([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthHead title="نام کاربری" />
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<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">
|
||||
<AuthInput
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="نام کاربری خود را وارد کنید"
|
||||
dir="auto" // ✅ اجازه ورود همه کاراکترها (فارسی، انگلیسی، ایموجی...)
|
||||
placeholder="نام کاربری"
|
||||
dir="ltr"
|
||||
autoComplete="username"
|
||||
spellCheck={false}
|
||||
className={`border ${
|
||||
formik.touched.username && formik.errors.username
|
||||
(formik.touched.username && formik.errors.username) || isDuplicate
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.username}
|
||||
onChange={formik.handleChange}
|
||||
onChange={(e) => handleUsernameChange(e.target.value)}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.username && formik.errors.username && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
|
||||
{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>
|
||||
)}
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
|
||||
|
||||
<UsernameSuggestions
|
||||
suggestions={suggestions}
|
||||
onSelect={handleSuggestionSelect}
|
||||
/>
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter>
|
||||
<AuthNextButton
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={loading || formik.values.username.length < USERNAME_MIN_LENGTH}
|
||||
>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
"use client";
|
||||
|
||||
import AuthHead from "@/components/auth/AuthHead";
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import React, { useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
@@ -58,14 +61,14 @@ function UsertypePage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
{/* <AuthHead title="نوع کاربری" /> */}
|
||||
<AuthHead title="" />
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<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"
|
||||
@@ -90,35 +93,36 @@ function UsertypePage() {
|
||||
کارفرما
|
||||
</AuthNextButton> */}
|
||||
|
||||
{/* Error Message */}
|
||||
{formik.errors.selectedButton && formik.touched.selectedButton && (
|
||||
<div className="text-red-500 mt-2 text-sm">
|
||||
{formik.errors.selectedButton}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
{/* Submit Buttons */}
|
||||
<AuthFormFooter>
|
||||
<div className="flex gap-3">
|
||||
<AuthNextButton
|
||||
type="submit"
|
||||
onClick={() => setSubmitAction("register")}
|
||||
className="mt-20 !border-[#0C8002] !text-[#0C8002]"
|
||||
className="!border-[#0C8002] !text-[#0C8002]"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ادامه ثبت نام"}
|
||||
ادامه ثبت نام
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="submit"
|
||||
onClick={() => setSubmitAction("login")}
|
||||
className="mt-20 !border-[#FF0000] !text-[#FF0000]"
|
||||
className="!border-[#FF0000] !text-[#FF0000]"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ورود به برنامه"}
|
||||
ورود به برنامه
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import React from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
@@ -69,17 +72,18 @@ function AuthPage() {
|
||||
console.log(formik.values);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center auth-page">
|
||||
<span className="text-xl font-bold">احراز هویت</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
<AuthPageLayout className="auth-page">
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">احراز هویت</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<small className="font-bold mt-10 mb-4"> جهت احراز هویت</small>
|
||||
<div className="flex w-full gap-2 max-w-[300px]">
|
||||
<div>
|
||||
@@ -165,19 +169,16 @@ function AuthPage() {
|
||||
<small className="font-bold mt-10">
|
||||
شماره شبا باید به نام خود شخص باشد
|
||||
</small>
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/location")}>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
<button
|
||||
className="text-sm text-gray-500 mt-5"
|
||||
onClick={() => router.push("/verify/location")}
|
||||
type="button"
|
||||
>
|
||||
<span>رد کن</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
@@ -20,7 +23,6 @@ function AvatarPage() {
|
||||
const { request, loading } = useAxios();
|
||||
const [userDetail, setUserDetail] = useState<IProfileData | null>(null);
|
||||
const [avatar, setAvatar] = useState<string | null>(null);
|
||||
const [originalImage, setOriginalImage] = useState<string | null>(null); // تصویر اصلی (سایز کامل)
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [loadingUpload, setLoadingUpload] = useState(false);
|
||||
@@ -50,8 +52,7 @@ function AvatarPage() {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const imageDataUrl = e.target?.result as string;
|
||||
setAvatar(imageDataUrl); // برای کراپر
|
||||
setOriginalImage(imageDataUrl); // تصویر اصلی (سایز کامل)
|
||||
setAvatar(imageDataUrl);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
@@ -105,7 +106,6 @@ function AvatarPage() {
|
||||
} catch (err) {
|
||||
console.log("Upload error:", err);
|
||||
toast.error("خطا در ارسال تصویر!");
|
||||
} finally {
|
||||
setLoadingUpload(false);
|
||||
}
|
||||
};
|
||||
@@ -120,28 +120,11 @@ function AvatarPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
{(loading || loadingUpload) && <p>Loading...</p>}
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold mb-4">تصویر پروفایل</span>
|
||||
|
||||
{!loading && !loadingUpload && (
|
||||
<>
|
||||
<span className="text-xl font-bold mb-4">تصویر پروفایل</span>
|
||||
|
||||
{/* نمایش تصویر اصلی (سایز کامل) */}
|
||||
{originalImage && (
|
||||
<div className="relative w-[300px] h-[400px] bg-gray-200 rounded-2xl mb-4 overflow-hidden">
|
||||
<Image
|
||||
src={originalImage}
|
||||
alt="Original image"
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* کراپر برای برش تصویر به مربع 1:1 */}
|
||||
<div className="relative w-[250px] h-[250px] bg-gray-200 rounded-3xl overflow-hidden mb-4">
|
||||
<div className="relative aspect-square w-[250px] bg-gray-200 rounded-3xl overflow-hidden mb-4">
|
||||
{avatar ? (
|
||||
<Cropper
|
||||
image={avatar.startsWith("data:") ? avatar : IMAGE_BASE_URL + avatar}
|
||||
@@ -178,31 +161,26 @@ function AvatarPage() {
|
||||
onChange={selectImage}
|
||||
/>
|
||||
|
||||
<AuthNextButton className="mt-4">
|
||||
<label className="cursor-pointer w-full h-full" htmlFor="fileInput">
|
||||
انتخاب/ویرایش تصویر
|
||||
</label>
|
||||
</AuthNextButton>
|
||||
<AuthNextButton className="mt-4">
|
||||
<label className="cursor-pointer w-full h-full" htmlFor="fileInput">
|
||||
انتخاب/ویرایش تصویر
|
||||
</label>
|
||||
</AuthNextButton>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthNextButton
|
||||
onClick={uploadImage}
|
||||
className="mt-10"
|
||||
disabled={loadingUpload}
|
||||
>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
|
||||
<button
|
||||
className="text-sm text-gray-500 mt-5"
|
||||
onClick={navigateHandler}
|
||||
type="button"
|
||||
>
|
||||
رد کن
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
<AuthFormFooter
|
||||
onSkip={navigateHandler}
|
||||
skipDisabled={loadingUpload}
|
||||
>
|
||||
<AuthNextButton
|
||||
onClick={uploadImage}
|
||||
loading={loadingUpload}
|
||||
disabled={loadingUpload}
|
||||
>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import React, { useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import * as yup from "yup";
|
||||
@@ -88,8 +91,8 @@ function Colors() {
|
||||
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">سایز</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
@@ -163,24 +166,19 @@ function Colors() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/public-relations")}>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={handleCheck}
|
||||
className="mt-10"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
<button
|
||||
className="text-sm text-gray-500 mt-5"
|
||||
onClick={() => router.push("/verify/public-relations")}
|
||||
type="button"
|
||||
>
|
||||
<span>رد کن</span>
|
||||
</button>
|
||||
</div>
|
||||
</Container>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,26 +2,48 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import Modal from "@/components/elements/Modal";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import VerificationBadge from "@/components/main/VerificationBadge";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
|
||||
function Confirm() {
|
||||
|
||||
const user = useUser();
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
const [showModal, setShowModal] = useState<boolean>(false);
|
||||
const [navigating, setNavigating] = useState(false);
|
||||
const userType: string | null =
|
||||
typeof window !== "undefined" ? localStorage.getItem("usertype") : null;
|
||||
|
||||
const [verifiedStatus, setVerifiedStatus] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const markComplete = async () => {
|
||||
try {
|
||||
const response = await request<{ is_verified?: string }>(
|
||||
"POST",
|
||||
"/verify/complete",
|
||||
{}
|
||||
);
|
||||
setVerifiedStatus(response?.is_verified ?? "pending");
|
||||
} catch {
|
||||
setVerifiedStatus(user?.is_verified ?? "pending");
|
||||
}
|
||||
};
|
||||
markComplete();
|
||||
}, []);
|
||||
|
||||
const confirmHandler = () => {
|
||||
setNavigating(true);
|
||||
if (userType !== "employer") {
|
||||
setShowModal(true);
|
||||
setNavigating(false);
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
@@ -36,38 +58,23 @@ function Confirm() {
|
||||
ورود شما به خانواده مدستاگرام را تبریک می گوییم{" "}
|
||||
</small>
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<Image
|
||||
className="rounded-2xl object-cover"
|
||||
width={280}
|
||||
height={280}
|
||||
<ProfileAvatar
|
||||
src={user?.profile_image}
|
||||
alt={user?.user_name || "user profile"}
|
||||
src={buildStorageUrl(user?.profile_image)}
|
||||
unoptimized={true}
|
||||
size="xl"
|
||||
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>
|
||||
<div className="flex flex-col justify-center items-center gap-1 mt-1">
|
||||
{user?.user_name}
|
||||
{user?.is_verified === "verified" ? (
|
||||
<Image
|
||||
width={70}
|
||||
height={70}
|
||||
alt={"verify icon"}
|
||||
src={`/images/icons/verify.svg`}
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{user?.user_name}
|
||||
<VerificationBadge
|
||||
isVerified={verifiedStatus ?? user?.is_verified ?? "pending"}
|
||||
/>
|
||||
) : user?.is_verified === "pending" ? (
|
||||
<Image
|
||||
width={70}
|
||||
height={70}
|
||||
alt={"not-verify icon"}
|
||||
src={`/images/icons/not-verify.svg`}
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,7 +124,7 @@ function Confirm() {
|
||||
</div>
|
||||
</div>
|
||||
{/* */}
|
||||
<AuthNextButton onClick={confirmHandler} className="mt-20">
|
||||
<AuthNextButton onClick={confirmHandler} className="mt-20" loading={navigating} disabled={navigating}>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
<small className="font-bold mt-10 mb-1 text-center">
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import React from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
@@ -44,20 +47,22 @@ function CooperationType() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold">نوع همکاری</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">نوع همکاری</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<p className="my-10 text-sm font-bold">
|
||||
آیا مایل به همکاری خارج از محل سکونت خود هستید؟
|
||||
</p>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<p className="my-10 text-sm font-bold">
|
||||
آیا مایل به همکاری خارج از محل سکونت خود هستید؟
|
||||
</p>
|
||||
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<div className="flex w-full gap-2">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
@@ -83,26 +88,21 @@ function CooperationType() {
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{formik.errors.selectedButton && formik.touched.selectedButton && (
|
||||
<div className="text-red-500 mt-2 text-sm">
|
||||
{formik.errors.selectedButton}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/public-relations")}>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
<button
|
||||
className="text-sm text-gray-500 mt-5"
|
||||
onClick={() => router.push("/verify/public-relations")}
|
||||
type="button"
|
||||
>
|
||||
<span>رد کن</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import * as yup from "yup";
|
||||
@@ -89,8 +92,8 @@ function Expertise() {
|
||||
);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">تخصص</span>
|
||||
|
||||
<AuthUserDetails />
|
||||
@@ -126,25 +129,19 @@ function Expertise() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/services")}>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={handleCheck}
|
||||
className="mt-20"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
|
||||
<button
|
||||
className="text-sm text-gray-500 mt-5"
|
||||
onClick={() => router.push("/verify/services")}
|
||||
type="button"
|
||||
>
|
||||
<span>رد کن</span>
|
||||
</button>
|
||||
</div>
|
||||
</Container>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import React from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
@@ -50,16 +53,18 @@ function Gender() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold">جنسیت</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">جنسیت</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<div className="flex w-full gap-4">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
@@ -97,26 +102,21 @@ function Gender() {
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{formik.errors.selectedButton && formik.touched.selectedButton && (
|
||||
<div className="text-red-500 mt-2 text-sm">
|
||||
{formik.errors.selectedButton}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/sizes")}>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
<button
|
||||
className="text-sm text-gray-500 mt-5"
|
||||
onClick={() => router.push("/verify/sizes")}
|
||||
type="button"
|
||||
>
|
||||
<span>رد کن</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
@@ -88,8 +91,8 @@ function LocationPage() {
|
||||
address: values.address,
|
||||
province_id: values.stateId,
|
||||
city_id: values.cityId,
|
||||
lat: values.markerCoordinate[0],
|
||||
lng: values.markerCoordinate[1],
|
||||
lat: values.markerCoordinate[1],
|
||||
lng: values.markerCoordinate[0],
|
||||
show_location: isCheckedOne,
|
||||
});
|
||||
router.push("/verify/national-cart");
|
||||
@@ -118,20 +121,22 @@ function LocationPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center address-page ">
|
||||
<span className="text-xl font-bold">لوکیشن</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
<AuthPageLayout className="address-page">
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">لوکیشن</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<small className="font-bold mt-10 mb-4">
|
||||
مشخص کنید در کدام شهر قادر به انجام فعالیت هستید
|
||||
</small>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<small className="font-bold mt-10 mb-4">
|
||||
مشخص کنید در کدام شهر قادر به انجام فعالیت هستید
|
||||
</small>
|
||||
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<SelectBox
|
||||
className={`mt-4 ${
|
||||
formik.touched.stateId && formik.errors.stateId
|
||||
@@ -225,25 +230,26 @@ function LocationPage() {
|
||||
)}
|
||||
</Map>
|
||||
<label className="flex items-center gap-2">
|
||||
<input className="scale-125" type="checkbox" onChange={toggleCheckBox} />
|
||||
<input
|
||||
className="scale-125"
|
||||
type="checkbox"
|
||||
checked={isCheckedOne}
|
||||
onChange={toggleCheckBox}
|
||||
/>
|
||||
<span className="text-xs font-bold">
|
||||
اطلاعات لوکیشن شما برای همه قابل نمایش باشد
|
||||
</span>
|
||||
</label>
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<button
|
||||
className="text-sm text-gray-500 mt-5"
|
||||
onClick={() => router.push("/verify/national-cart")}
|
||||
type="button"
|
||||
>
|
||||
<span>رد کن</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/national-cart")}>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import React, { useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -69,8 +72,8 @@ function NationalCart() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center address-page">
|
||||
<AuthPageLayout className="address-page">
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">احراز هویت</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
@@ -146,25 +149,19 @@ function NationalCart() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/confirm")}>
|
||||
<AuthNextButton
|
||||
onClick={uploadImage}
|
||||
className="mt-20"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
|
||||
<button
|
||||
className="text-sm text-gray-500 mt-5"
|
||||
onClick={() => router.push("/verify/confirm")}
|
||||
type="button"
|
||||
>
|
||||
رد کن
|
||||
</button>
|
||||
|
||||
<AuthRules isModalOpen={isModalOpen} setModalOpen={setModalOpen} />
|
||||
</div>
|
||||
</Container>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import React from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useFormik } from "formik";
|
||||
@@ -46,18 +49,18 @@ function PublicRelations() {
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold">روابط عمومی</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
|
||||
<AuthPageLayout>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="flex w-full flex-1 flex-col items-center"
|
||||
>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">روابط عمومی</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<small className="font-bold mt-10"> در مورد خودتان چیزی بگویید</small>
|
||||
<textarea
|
||||
name="bio"
|
||||
@@ -85,20 +88,16 @@ function PublicRelations() {
|
||||
{formik.errors.bio}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/location")}>
|
||||
<AuthNextButton type="submit" loading={loading} disabled={loading}>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
<button
|
||||
className="text-sm text-gray-500 mt-5"
|
||||
onClick={() => router.push("/verify/location")}
|
||||
type="button"
|
||||
>
|
||||
<span>رد کن</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</Container>
|
||||
</AuthFormFooter>
|
||||
</form>
|
||||
</AuthPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ import { Service } from "@/types/types";
|
||||
import ServiceItem from "@/components/main/Services/ServiceItem";
|
||||
import AddServiceModal from "@/components/main/Services/AddServiceModal";
|
||||
import AuthUserDetails from "@/components/auth/AuthUserDetails";
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -80,8 +83,8 @@ const ServicesPage: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center w-full">
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent className="w-full">
|
||||
<span className="text-xl font-bold">خدمات</span>
|
||||
|
||||
<div className="mt-5 w-full max-w-md">
|
||||
@@ -98,23 +101,6 @@ const ServicesPage: React.FC = () => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mt-6">
|
||||
<AuthNextButton
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="mb-4 w-32"
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
|
||||
</AuthNextButton>
|
||||
|
||||
<AuthNextButton
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="mb-4 !text-[#0066FF] !border-[#0066FF] w-32"
|
||||
>
|
||||
افزودن
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<AddServiceModal
|
||||
onClose={() => setModalOpen(false)}
|
||||
@@ -122,15 +108,28 @@ const ServicesPage: React.FC = () => {
|
||||
isModalOpen={isModalOpen}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className="text-sm text-gray-500 mt-5"
|
||||
onClick={Reject}
|
||||
type="button"
|
||||
>
|
||||
<span>رد کن</span>
|
||||
</button>
|
||||
</div>
|
||||
</Container>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={Reject}>
|
||||
<div className="flex gap-4">
|
||||
<AuthNextButton
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
className="w-32"
|
||||
>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
|
||||
<AuthNextButton
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="!text-[#0066FF] !border-[#0066FF] w-32"
|
||||
>
|
||||
افزودن
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import React, { useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import * as yup from "yup";
|
||||
@@ -73,8 +76,8 @@ function Sizes() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<AuthPageLayout>
|
||||
<AuthPageContent>
|
||||
<span className="text-xl font-bold">سایز</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
@@ -175,24 +178,19 @@ function Sizes() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter onSkip={() => router.push("/verify/colors")}>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={handleCheck}
|
||||
className="mt-10"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
<button
|
||||
className="text-sm text-gray-500 mt-5"
|
||||
onClick={() => router.push("/verify/colors")}
|
||||
type="button"
|
||||
>
|
||||
<span>رد کن</span>
|
||||
</button>
|
||||
</div>
|
||||
</Container>
|
||||
</AuthFormFooter>
|
||||
</AuthPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ import { Course } from "@/types/types";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { Comments } from "@/components/academy/CommentsModal";
|
||||
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import VerificationBadge from "@/components/main/VerificationBadge";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
@@ -662,39 +664,24 @@ useEffect(() => {
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
{/* آواتار کاربر */}
|
||||
<div className="flex-shrink-0">
|
||||
<Image
|
||||
className="rounded-full object-cover"
|
||||
width={40}
|
||||
height={40}
|
||||
<div className="shrink-0">
|
||||
<ProfileAvatar
|
||||
src={item?.user_id?.profile_image}
|
||||
alt={item?.user_id?.user_name || "کاربر"}
|
||||
src={
|
||||
item?.user_id?.profile_image
|
||||
? buildStorageUrl(item.user_id.profile_image)
|
||||
: "/images/default-avatar.png"
|
||||
}
|
||||
priority={true}
|
||||
unoptimized={true}
|
||||
size="chat"
|
||||
rounded="full"
|
||||
fallback="/images/default-avatar.png"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
{/* اطلاعات کاربر و امتیاز */}
|
||||
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||
<span className="font-semibold text-sm dark:text-white">
|
||||
<span className="font-semibold text-sm dark:text-white inline-flex items-center gap-1">
|
||||
{item?.user_id?.user_name || "کاربر ناشناس"}
|
||||
<VerificationBadge isVerified={item?.user_id?.is_verified} />
|
||||
</span>
|
||||
|
||||
{item?.user_id?.is_verified === "verified" && (
|
||||
<Image
|
||||
width={16}
|
||||
height={16}
|
||||
alt="verify"
|
||||
src="/images/icons/verify.svg"
|
||||
className="inline"
|
||||
/>
|
||||
)}
|
||||
|
||||
{item?.rate > 0 && (
|
||||
<div className="mr-2">
|
||||
{renderSmallStars(item.rate)}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Metadata } from "next";
|
||||
// درونریزی فایلهای دیتا برای استخراج نام شهر و استان
|
||||
import provinces from "@/data/provinces.json";
|
||||
import cities from "@/data/cities.json";
|
||||
import { normalizeUserLevel, parseUserLevelFromSlug } from "@/lib/userLevel";
|
||||
|
||||
interface IModelsProps {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
@@ -89,7 +90,7 @@ export default async function Models({ params, searchParams }: IModelsProps) {
|
||||
// ۱. مقادیر پایه (اولویت با کوئری پارامتر برای دقت در فیلتر)
|
||||
let provinceId = filters.province || "";
|
||||
let cityId = filters.city || "";
|
||||
let userLevel = filters.userLevel || "";
|
||||
let userLevel = normalizeUserLevel(filters.userLevel || "");
|
||||
let expertise = filters.expertise || "";
|
||||
|
||||
// ۲. استخراج و همگامسازی اطلاعات از URL فارسی (Slug)
|
||||
@@ -102,11 +103,8 @@ export default async function Models({ params, searchParams }: IModelsProps) {
|
||||
else if (fullText.includes("آرایشگر")) expertise = "آرایشگر";
|
||||
else if (fullText.includes("متخصصین")) expertise = ""; // حالت بدون تخصص
|
||||
|
||||
// استخراج سطح
|
||||
if (fullText.includes("تازه وارد")) userLevel = "تازه وارد";
|
||||
else if (fullText.includes("استاندارد")) userLevel = "استاندارد";
|
||||
else if (fullText.includes("حرفه ای")) userLevel = "حرفه ای";
|
||||
else if (fullText.includes("استاد")) userLevel = "استاد";
|
||||
const parsedLevel = parseUserLevelFromSlug(fullText);
|
||||
if (parsedLevel) userLevel = parsedLevel;
|
||||
|
||||
// استخراج مکان (IDها)
|
||||
if (fullText.includes("در شهر")) {
|
||||
@@ -153,7 +151,7 @@ export default async function Models({ params, searchParams }: IModelsProps) {
|
||||
hashtag: filters.hashtag, // این خط را اضافه کن
|
||||
province: provinceId,
|
||||
city: cityId,
|
||||
userLevel: userLevel || filters.userLevel,
|
||||
userLevel: userLevel || normalizeUserLevel(filters.userLevel),
|
||||
expertise: expertise || filters.expertise
|
||||
}}
|
||||
token={token}
|
||||
|
||||
40
src/app/api/auth/session/route.ts
Normal file
40
src/app/api/auth/session/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const TOKEN_KEY = "token";
|
||||
const MAX_AGE = 365 * 24 * 60 * 60;
|
||||
|
||||
function cookieOptions() {
|
||||
return {
|
||||
path: "/",
|
||||
maxAge: MAX_AGE,
|
||||
sameSite: "lax" as const,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
httpOnly: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const token = typeof body?.token === "string" ? body.token.trim() : "";
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "token required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(TOKEN_KEY, token, cookieOptions());
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "invalid request" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete({ name: TOKEN_KEY, path: "/" });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import BillboardDetails from "@/components/billboards/BillboardPage/BillboardDet
|
||||
import { Metadata } from "next";
|
||||
|
||||
import { generatePageMetadata } from "@/utils/generatePageMetadata";
|
||||
import SEO from "@/config/SEO";
|
||||
|
||||
interface IBillboardProps {
|
||||
params: Promise<{ id: string; title: string }>;
|
||||
@@ -97,8 +96,6 @@ async function BillboardPage({ params }: IBillboardProps) {
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<SEO type="store" data={billboard} />
|
||||
|
||||
<a
|
||||
href={`/billboards/profile/${billboard?.creatorId}/${encodeURIComponent(billboard?.title || "")}`}
|
||||
>
|
||||
|
||||
@@ -4,7 +4,7 @@ import Container from "@/components/elements/Container";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import ExploreGrid from "@/components/explore/ExploreGrid";
|
||||
import Link from "next/link";
|
||||
import { FiChevronRight } from "react-icons/fi";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
|
||||
export default function ExplorePage() {
|
||||
return (
|
||||
@@ -16,7 +16,7 @@ export default function ExplorePage() {
|
||||
className="gentle-transition flex h-9 w-9 items-center justify-center rounded-full text-[#0095f6] active:scale-90"
|
||||
aria-label="بازگشت"
|
||||
>
|
||||
<FiChevronRight size={22} />
|
||||
<BoldIcon name="arrow-right-2" size={22} tinted className="text-[#0095f6]" />
|
||||
</Link>
|
||||
<h1 className="text-lg font-bold">اکسپلور</h1>
|
||||
</header>
|
||||
|
||||
@@ -286,26 +286,117 @@ select {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.45);
|
||||
border: none;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.dark .glass-panel {
|
||||
background: rgba(28, 28, 30, 0.78);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.chat-header-glass {
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: none;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.dark .chat-header-glass {
|
||||
background: rgba(28, 28, 30, 0.72);
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.chat-header-status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
.chat-header-status-dot--online {
|
||||
background: #22c55e;
|
||||
}
|
||||
.chat-header-status-dot--offline {
|
||||
background: #9ca3af;
|
||||
}
|
||||
.dark .chat-header-status-dot--offline {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
.chat-timer-glass {
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
border: none;
|
||||
box-shadow: 0 2px 14px rgba(36, 140, 200, 0.12);
|
||||
}
|
||||
.dark .chat-timer-glass {
|
||||
background: rgba(24, 37, 51, 0.72);
|
||||
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.chat-timer-glass--active {
|
||||
background: rgba(42, 171, 238, 0.18);
|
||||
color: #248cc8;
|
||||
box-shadow: 0 2px 14px rgba(42, 171, 238, 0.2);
|
||||
}
|
||||
.dark .chat-timer-glass--active {
|
||||
background: rgba(42, 171, 238, 0.22);
|
||||
color: #2aabee;
|
||||
}
|
||||
|
||||
.chat-timer-badge-glass {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.42);
|
||||
backdrop-filter: blur(10px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(160%);
|
||||
border: none;
|
||||
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.08);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.chat-bubble-out .chat-timer-badge-glass {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
color: inherit;
|
||||
}
|
||||
.chat-bubble-in .chat-timer-badge-glass {
|
||||
color: #248cc8;
|
||||
}
|
||||
.dark .chat-timer-badge-glass {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.dark .chat-bubble-in .chat-timer-badge-glass {
|
||||
color: #2aabee;
|
||||
}
|
||||
|
||||
.chat-message-highlight {
|
||||
animation: message-highlight-flash 1.6s ease-out;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
@keyframes message-highlight-flash {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(42, 171, 238, 0);
|
||||
}
|
||||
25%,
|
||||
65% {
|
||||
box-shadow: 0 0 0 3px rgba(42, 171, 238, 0.55);
|
||||
}
|
||||
}
|
||||
|
||||
.glass-chat-input {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
backdrop-filter: blur(24px) saturate(200%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(200%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08), 0 1px 0 rgba(255, 255, 255, 0.6) inset;
|
||||
border: none;
|
||||
box-shadow: 0 4px 20px rgba(15, 45, 70, 0.08);
|
||||
}
|
||||
.dark .glass-chat-input {
|
||||
background: rgba(44, 44, 46, 0.88);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
background: rgba(24, 37, 51, 0.78);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* ─── Gentle transitions ─── */
|
||||
@@ -330,38 +421,90 @@ select {
|
||||
max-width: min(100% - 2rem, 22rem);
|
||||
}
|
||||
|
||||
/* ─── Instagram DM chat theme ─── */
|
||||
.chat-page-bg {
|
||||
background: #ffffff;
|
||||
}
|
||||
.dark .chat-page-bg {
|
||||
background: #000000;
|
||||
/* ─── Chat theme — Modstagram: modeling · makeup · beauty · photography ─── */
|
||||
:root {
|
||||
--chat-accent: #2aabee;
|
||||
/* Studio light: warm key + soft fill */
|
||||
--chat-bg: #f3ece6;
|
||||
--chat-bg-pattern: url("/images/chat/wallpaper-light.svg"),
|
||||
radial-gradient(ellipse 90% 70% at 8% 12%, rgba(214, 164, 176, 0.32) 0%, transparent 58%),
|
||||
radial-gradient(ellipse 85% 65% at 92% 88%, rgba(141, 175, 205, 0.26) 0%, transparent 55%),
|
||||
radial-gradient(ellipse 60% 50% at 50% 50%, rgba(255, 248, 242, 0.5) 0%, transparent 70%),
|
||||
linear-gradient(168deg, #faf4ef 0%, #f0e8f0 38%, #e8eef4 100%);
|
||||
--chat-bg-pattern-size: 480px 480px, 100% 100%, 100% 100%, 100% 100%, 100% 100%;
|
||||
--chat-bubble-out: #248cc8;
|
||||
--chat-bubble-out-fg: #ffffff;
|
||||
--chat-bubble-out-meta: rgba(255, 255, 255, 0.78);
|
||||
--chat-bubble-in: #fffdfb;
|
||||
--chat-bubble-in-fg: #1c1520;
|
||||
--chat-bubble-in-meta: rgba(28, 21, 32, 0.46);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--chat-accent: #2aabee;
|
||||
/* Dark studio: tungsten key + cool rim */
|
||||
--chat-bg: #0e1014;
|
||||
--chat-bg-pattern: url("/images/chat/wallpaper-dark.svg"),
|
||||
radial-gradient(ellipse 80% 55% at 10% 15%, rgba(196, 149, 127, 0.14) 0%, transparent 58%),
|
||||
radial-gradient(ellipse 75% 50% at 88% 82%, rgba(42, 171, 238, 0.11) 0%, transparent 52%),
|
||||
radial-gradient(ellipse 50% 40% at 50% 45%, rgba(180, 140, 170, 0.06) 0%, transparent 65%),
|
||||
linear-gradient(168deg, #121018 0%, #0e1014 45%, #0a0c12 100%);
|
||||
--chat-bg-pattern-size: 480px 480px, 100% 100%, 100% 100%, 100% 100%, 100% 100%;
|
||||
--chat-bubble-out: #2aabee;
|
||||
--chat-bubble-out-fg: #ffffff;
|
||||
--chat-bubble-out-meta: rgba(255, 255, 255, 0.76);
|
||||
--chat-bubble-in: #1a2029;
|
||||
--chat-bubble-in-fg: #eceef3;
|
||||
--chat-bubble-in-meta: rgba(236, 238, 243, 0.55);
|
||||
}
|
||||
|
||||
.chat-page-bg {
|
||||
position: relative;
|
||||
background: transparent;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.chat-page-bg::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background-color: var(--chat-bg);
|
||||
background-image: var(--chat-bg-pattern);
|
||||
background-size: var(--chat-bg-pattern-size);
|
||||
}
|
||||
|
||||
/* فقط لیست پیامها — بدون position:relative تا fixed هدر/فوتر خراب نشود */
|
||||
.chat-page-bg > .chat-thread-messages {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Outgoing — Instagram DM (RTL: tail bottom-left / outer edge) */
|
||||
.chat-bubble-out,
|
||||
.ig-dm-out {
|
||||
background: linear-gradient(135deg, #5b51d8 0%, #c13584 45%, #e1306c 100%);
|
||||
color: #fff;
|
||||
background: var(--chat-bubble-out);
|
||||
color: var(--chat-bubble-out-fg);
|
||||
}
|
||||
.bubble-tail-out .chat-bubble-out,
|
||||
.bubble-tail-out .chat-text-bubble.chat-bubble-out {
|
||||
border-radius: 18px 18px 18px 4px;
|
||||
.chat-bubble-out .message-time-inline,
|
||||
.chat-text-bubble.chat-bubble-out .message-time-inline,
|
||||
.chat-bubble-out .message-time-below {
|
||||
color: var(--chat-bubble-out-meta);
|
||||
}
|
||||
/* Incoming — tail bottom-right */
|
||||
|
||||
.chat-bubble-in,
|
||||
.ig-dm-in {
|
||||
background: #efefef;
|
||||
color: #000;
|
||||
}
|
||||
.bubble-tail-in .chat-bubble-in,
|
||||
.bubble-tail-in .chat-text-bubble.chat-bubble-in {
|
||||
border-radius: 18px 18px 4px 18px;
|
||||
background: var(--chat-bubble-in);
|
||||
color: var(--chat-bubble-in-fg);
|
||||
box-shadow: 0 1px 2px rgba(26, 21, 32, 0.08);
|
||||
}
|
||||
.dark .chat-bubble-in,
|
||||
.dark .ig-dm-in {
|
||||
background: #262626;
|
||||
color: #f5f5f5;
|
||||
box-shadow: none;
|
||||
}
|
||||
.chat-bubble-in .message-time-inline,
|
||||
.chat-text-bubble.chat-bubble-in .message-time-inline,
|
||||
.chat-bubble-in .message-time-below {
|
||||
color: var(--chat-bubble-in-meta);
|
||||
}
|
||||
|
||||
.chat-text-bubble {
|
||||
@@ -384,12 +527,15 @@ select {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bubble-tail-out .message-time-inline {
|
||||
.chat-bubble-out .message-time-inline,
|
||||
.chat-text-bubble.chat-bubble-out .message-time-inline {
|
||||
left: auto;
|
||||
right: 8px;
|
||||
}
|
||||
.bubble-tail-in .message-time-inline {
|
||||
.chat-bubble-in .message-time-inline,
|
||||
.chat-text-bubble.chat-bubble-in .message-time-inline {
|
||||
left: 8px;
|
||||
right: auto;
|
||||
}
|
||||
@@ -416,6 +562,17 @@ select {
|
||||
box-shadow: 0 4px 14px rgba(0, 122, 255, 0.35);
|
||||
}
|
||||
|
||||
.chat-forward-btn {
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 14px rgba(124, 58, 237, 0.35);
|
||||
}
|
||||
|
||||
.dark .chat-forward-btn {
|
||||
background: linear-gradient(135deg, #a78bfa 0%, #8b5cf6 100%);
|
||||
box-shadow: 0 4px 14px rgba(139, 92, 246, 0.4);
|
||||
}
|
||||
|
||||
.header-offline {
|
||||
background: linear-gradient(180deg, rgba(220, 38, 38, 0.92) 0%, rgba(185, 28, 28, 0.88) 100%) !important;
|
||||
backdrop-filter: blur(12px);
|
||||
@@ -448,7 +605,8 @@ select {
|
||||
}
|
||||
|
||||
.ig-dm-send-btn {
|
||||
background: linear-gradient(135deg, #5b51d8 0%, #c13584 50%, #e1306c 100%);
|
||||
background: var(--chat-bubble-out);
|
||||
color: var(--chat-bubble-out-fg);
|
||||
}
|
||||
|
||||
/* ─── Instagram-style typing dots ─── */
|
||||
@@ -472,14 +630,7 @@ select {
|
||||
animation: check-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
/* ─── Message enter animation ─── */
|
||||
@keyframes message-in {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
.message-enter {
|
||||
animation: message-in 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
|
||||
}
|
||||
/* Message enter — handled by framer-motion in ChatMessageCard */
|
||||
|
||||
/* ─── Shrink header ─── */
|
||||
.header-shrink {
|
||||
@@ -502,7 +653,7 @@ select {
|
||||
filter: invert(1) hue-rotate(180deg) brightness(1.2);
|
||||
}
|
||||
|
||||
/* حذف پسزمینه سفید اضافی در برخی مرورگرها */
|
||||
.custom-audio-player::-webkit-media-controls-panel {
|
||||
background-color: transparent;
|
||||
}
|
||||
.profile-avatar {
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
}
|
||||
@@ -10,8 +10,15 @@ const iranSansFont = localFont({
|
||||
src: "./../../public/fonts/IRANSansX-Regular.woff",
|
||||
});
|
||||
|
||||
const defaultTitle =
|
||||
typeof defaultSEOConfig.title === "string"
|
||||
? defaultSEOConfig.title
|
||||
: "مدستاگرام";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: defaultSEOConfig.title,
|
||||
title: {
|
||||
default: defaultTitle,
|
||||
},
|
||||
description: defaultSEOConfig.description,
|
||||
metadataBase: new URL("https://modstagram.com"),
|
||||
keywords: [
|
||||
@@ -26,8 +33,21 @@ export const metadata: Metadata = {
|
||||
alternates: {
|
||||
canonical: "/",
|
||||
},
|
||||
manifest: "/manifest.json",
|
||||
icons: {
|
||||
icon: [{ url: "/favicon.ico", type: "image/x-icon" }],
|
||||
shortcut: ["/favicon.ico"],
|
||||
apple: [
|
||||
{ url: "/images/icons/apple-touch-icon.png" },
|
||||
{ url: "/images/icons/192x192.png" },
|
||||
],
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "black",
|
||||
},
|
||||
openGraph: {
|
||||
title: defaultSEOConfig.openGraph?.title || "",
|
||||
title: defaultSEOConfig.openGraph?.title || defaultTitle,
|
||||
description: defaultSEOConfig.openGraph?.description || "",
|
||||
url: defaultSEOConfig.openGraph?.url || "",
|
||||
siteName: defaultSEOConfig.openGraph?.site_name || "",
|
||||
@@ -37,7 +57,7 @@ export const metadata: Metadata = {
|
||||
robots: "index, follow",
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: defaultSEOConfig.title || "",
|
||||
title: defaultTitle,
|
||||
description: defaultSEOConfig.description || "",
|
||||
creator: "@modstagram",
|
||||
images: defaultSEOConfig.openGraph?.images?.map((img) => img.url) || [],
|
||||
@@ -45,13 +65,11 @@ export const metadata: Metadata = {
|
||||
verification: {
|
||||
google: "yVjB5yKPchPUtxl33GWVyMvjH6wCEInqEvaH7ZsXXMo",
|
||||
},
|
||||
};
|
||||
|
||||
const getInitialTheme = (): string => {
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem("theme") || "light";
|
||||
}
|
||||
return "light";
|
||||
other: {
|
||||
"theme-color": "#ffffff",
|
||||
"msapplication-TileColor": "#0072BC",
|
||||
samandehi: "522190795",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -59,30 +77,9 @@ export default function RootLayout({
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const initialTheme = getInitialTheme();
|
||||
|
||||
return (
|
||||
<html lang="fa" dir="rtl" className={initialTheme}>
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<link rel="apple-touch-icon" href="/images/icons/192x192.png" />
|
||||
|
||||
<head>
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
href="/images/icons/apple-touch-icon.png"
|
||||
/>
|
||||
<meta name="msapplication-TileColor" content="#0072BC" />
|
||||
<meta name="msapplication-TileImage" content="/path/to/tileImage.png" />
|
||||
<meta name="samandehi" content="522190795" />
|
||||
</head>
|
||||
<body
|
||||
className={`${iranSansFont.className} antialiased bg-white dark:bg-neutral-950 text-text-primary-light dark:text-primary-light`}
|
||||
>
|
||||
<html lang="fa" dir="rtl" suppressHydrationWarning>
|
||||
<body className={`${iranSansFont.className} antialiased`}>
|
||||
<RegisterSW />
|
||||
<Layout>{children}</Layout>
|
||||
</body>
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
"use client";
|
||||
import { Separator } from "@/Components/ui/separator";
|
||||
import React from "react";
|
||||
import { IconArrowBack } from "@tabler/icons-react";
|
||||
import { GrTasks } from "react-icons/gr";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Image from "next/image";
|
||||
import { FaFileInvoice } from "react-icons/fa";
|
||||
import { IoIosArrowForward } from "react-icons/io";
|
||||
import { FcRating } from "react-icons/fc";
|
||||
|
||||
|
||||
interface OrderItem {
|
||||
@@ -31,14 +27,14 @@ export default function OrdersCart({ order }: { order: OrderItem }) {
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex justify-center items-center gap-2.5">
|
||||
<span className="">
|
||||
<GrTasks className="size-7 text-cyan-600 dark:text-neutral-600" />
|
||||
<BoldIcon name="task" size={28} tinted className="text-cyan-600 dark:text-neutral-600" />
|
||||
</span>
|
||||
<span className="font-bold text-neutral-600 dark:text-neutral-300">
|
||||
{order.statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<span className="ltr:rotate-180">
|
||||
<IconArrowBack className="text-neutral-600 dark:text-neutral-300" />
|
||||
<BoldIcon name="arrow-right-2" size={24} tinted className="text-neutral-600 dark:text-neutral-300" />
|
||||
</span>
|
||||
</div>
|
||||
<div className=" text-sm max-sm:text-xs w-full flex gap-3.5">
|
||||
@@ -89,15 +85,15 @@ export default function OrdersCart({ order }: { order: OrderItem }) {
|
||||
{t("orderCart.Your_rating_for_this_order")}:{" "}
|
||||
</span>
|
||||
<span className="font-bold flex justify-center items-center gap-0.5 flex-row-reverse text-neutral-600 dark:text-neutral-300">
|
||||
{order.rating} <FcRating className="size-5"/>
|
||||
{order.rating} <BoldIcon name="star" size={20} tinted className="text-amber-400" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex cursor-pointer justify-center items-center gap-2.5">
|
||||
<span className="">
|
||||
<FaFileInvoice className="size-5 text-cyan-800 dark:text-neutral-200" />
|
||||
<BoldIcon name="document-text" size={20} tinted className="text-cyan-800 dark:text-neutral-200" />
|
||||
</span>
|
||||
<span className="font-bold flex justify-center items-center text-cyan-800 dark:text-neutral-200">
|
||||
{t("orderCart.View_invoice")} <IoIosArrowForward className="rtl:rotate-180" />
|
||||
{t("orderCart.View_invoice")} <BoldIcon name="arrow-right-2" size={20} tinted className="text-cyan-800 dark:text-neutral-200 rtl:rotate-180" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SOCKET_URL } from "@/components/main/BaseUrl";
|
||||
import MessageInput from "@/components/chat/MessageInput";
|
||||
import MultiImageModal from "@/components/chat/MultiImageModal";
|
||||
import ChatActionBar from "@/components/chat/ChatActionBar";
|
||||
import ChatDeleteBar from "@/components/chat/ChatDeleteBar";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import ChatMessageList from "@/components/chat/ChatMessageList";
|
||||
import ChatHeader from "@/components/chat/ChatHeader";
|
||||
@@ -19,7 +20,13 @@ import toast from "react-hot-toast";
|
||||
import { optimizeMediaFile } from "@/lib/media";
|
||||
import ForwardMessageModal from "@/components/chat/ForwardMessageModal";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { formatBubbleTime } from "@/lib/chat/formatMessageTime";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { chatThreadQueryKey } from "@/lib/chat/queryKeys";
|
||||
import { appendMessageToThreadCache, normalizeThreadId } from "@/lib/chat/threadCache";
|
||||
import { useStableMessageKeys } from "@/hooks/useStableMessageKeys";
|
||||
import { getStoredUserId } from "@/lib/auth/session";
|
||||
import { buildExpiresAtIso } from "@/lib/chat/timedMessages";
|
||||
import { isViewOnceMediaType } from "@/lib/chat/viewOnce";
|
||||
|
||||
interface ITicketChatProps {
|
||||
params: Promise<{ id: string; username: string }>;
|
||||
@@ -29,7 +36,6 @@ function normalizeMessage(raw: ChatMessage & { data?: ChatMessage }): ChatMessag
|
||||
const m = (raw as { data?: ChatMessage }).data ?? raw;
|
||||
return {
|
||||
...m,
|
||||
createdAt: formatBubbleTime(m.createdAt),
|
||||
replyTo: m.replyTo ?? undefined,
|
||||
};
|
||||
}
|
||||
@@ -37,7 +43,7 @@ function normalizeMessage(raw: ChatMessage & { data?: ChatMessage }): ChatMessag
|
||||
function TicketChat({ params }: ITicketChatProps) {
|
||||
const resolvedParams = React.use(params);
|
||||
const { request } = useAxios();
|
||||
const { username } = resolvedParams;
|
||||
const { username, id: chatPartnerId } = resolvedParams;
|
||||
const [newMessage, setNewMessage] = useState("");
|
||||
const [userTwoDetail, setUserTwoDetail] = useState<User>();
|
||||
const [pendingImages, setPendingImages] = useState<File[]>([]);
|
||||
@@ -47,9 +53,18 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
const [forwardMessage, setForwardMessage] = useState<ChatMessage | null>(null);
|
||||
const [selectedMessage, setSelectedMessage] = useState<ChatMessage | null>(null);
|
||||
const [actionMode, setActionMode] = useState(false);
|
||||
const [deleteMode, setDeleteMode] = useState(false);
|
||||
const [selectedDeleteIds, setSelectedDeleteIds] = useState<string[]>([]);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selfDestructSeconds, setSelfDestructSeconds] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
const [viewOnceMedia, setViewOnceMedia] = useState(false);
|
||||
const user = useUser();
|
||||
const [receiverId, setReceiverId] = useState("");
|
||||
const queryClient = useQueryClient();
|
||||
const { linkIds, getStableKey } = useStableMessageKeys();
|
||||
const [receiverId, setReceiverId] = useState(chatPartnerId);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const socketRef = useRef<ReturnType<typeof io> | null>(null);
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -98,10 +113,10 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
}, [username, request]);
|
||||
|
||||
useEffect(() => {
|
||||
setReceiverId(userTwoDetail?._id || "");
|
||||
}, [user, userTwoDetail]);
|
||||
setReceiverId(userTwoDetail?._id || chatPartnerId);
|
||||
}, [user, userTwoDetail, chatPartnerId]);
|
||||
|
||||
const handleImageSelection = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleFileSelection = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const picked = Array.from(e.target.files || []);
|
||||
if (!picked.length) return;
|
||||
const MAX_SIZE = 200 * 1024 * 1024;
|
||||
@@ -113,8 +128,27 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
return true;
|
||||
});
|
||||
if (!valid.length) return;
|
||||
setPendingImages((prev) => [...prev, ...valid].slice(0, 10));
|
||||
setShowMultiModal(true);
|
||||
|
||||
const videos = valid.filter((f) => f.type.startsWith("video/"));
|
||||
const images = valid.filter((f) => f.type.startsWith("image/"));
|
||||
const others = valid.filter(
|
||||
(f) => !f.type.startsWith("video/") && !f.type.startsWith("image/")
|
||||
);
|
||||
|
||||
if (videos.length === 1 && images.length === 0 && others.length === 0) {
|
||||
void processSendMessage(videos[0], "video");
|
||||
return;
|
||||
}
|
||||
|
||||
if (others.length === 1 && images.length === 0 && videos.length === 0) {
|
||||
void processSendMessage(others[0], "file");
|
||||
return;
|
||||
}
|
||||
|
||||
if (images.length) {
|
||||
setPendingImages((prev) => [...prev, ...images].slice(0, 10));
|
||||
setShowMultiModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVoiceUpload = (blob: Blob) => {
|
||||
@@ -183,15 +217,26 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
const tempId = `temp-${Date.now()}-${Math.random()}`;
|
||||
const replyPayload = buildReplyPayload(replySource);
|
||||
|
||||
const currentUserId = normalizeThreadId(user?._id ?? getStoredUserId());
|
||||
const targetReceiverId = normalizeThreadId(
|
||||
userTwoDetail?._id ?? receiverId ?? chatPartnerId
|
||||
);
|
||||
|
||||
const applyViewOnce =
|
||||
viewOnceMedia && fileType && isViewOnceMediaType(fileType);
|
||||
|
||||
const optimisticMsg: ChatMessage = {
|
||||
_id: tempId,
|
||||
content: textContent,
|
||||
senderId: user?._id || "",
|
||||
createdAt: formatBubbleTime(new Date().toISOString()),
|
||||
senderId: currentUserId,
|
||||
receiverId: targetReceiverId,
|
||||
createdAt: new Date().toISOString(),
|
||||
expiresAt: buildExpiresAtIso(selfDestructSeconds),
|
||||
status: "pending",
|
||||
file: fileToUpload ? URL.createObjectURL(fileToUpload) : "",
|
||||
fileType: fileType,
|
||||
replyTo: replyPayload,
|
||||
viewOnce: applyViewOnce || undefined,
|
||||
};
|
||||
|
||||
setPendingMessages((prev) => [...prev, optimisticMsg]);
|
||||
@@ -210,8 +255,8 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("content", textContent);
|
||||
formData.append("receiverId", userTwoDetail?._id || "");
|
||||
formData.append("senderId", user?._id || "");
|
||||
formData.append("receiverId", targetReceiverId);
|
||||
formData.append("senderId", currentUserId);
|
||||
if (replySource?._id && !replySource._id.startsWith("temp")) {
|
||||
formData.append("replyToId", replySource._id);
|
||||
}
|
||||
@@ -219,6 +264,12 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
formData.append("file", fileForUpload);
|
||||
if (fileType) formData.append("fileType", fileType);
|
||||
}
|
||||
if (selfDestructSeconds) {
|
||||
formData.append("selfDestructSeconds", String(selfDestructSeconds));
|
||||
}
|
||||
if (applyViewOnce) {
|
||||
formData.append("viewOnce", "true");
|
||||
}
|
||||
|
||||
try {
|
||||
const endpoint =
|
||||
@@ -232,20 +283,26 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
);
|
||||
|
||||
const serverMsg = normalizeMessage(response as ChatMessage);
|
||||
setPendingMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m._id === tempId
|
||||
? {
|
||||
...serverMsg,
|
||||
status: "sent",
|
||||
replyTo: serverMsg.replyTo ?? replyPayload,
|
||||
}
|
||||
: m
|
||||
)
|
||||
);
|
||||
const sentMsg: ChatMessage = {
|
||||
...serverMsg,
|
||||
status: "sent",
|
||||
replyTo: serverMsg.replyTo ?? replyPayload,
|
||||
};
|
||||
|
||||
if (currentUserId && chatPartnerId) {
|
||||
linkIds(tempId, sentMsg._id);
|
||||
queryClient.setQueryData(
|
||||
chatThreadQueryKey(currentUserId, chatPartnerId),
|
||||
(oldData) => appendMessageToThreadCache(oldData, sentMsg)
|
||||
);
|
||||
}
|
||||
|
||||
setPendingMessages((prev) => prev.filter((m) => m._id !== tempId));
|
||||
|
||||
setReplyingTo(null);
|
||||
activeReplyRef.current = null;
|
||||
setSelfDestructSeconds(null);
|
||||
if (applyViewOnce) setViewOnceMedia(false);
|
||||
} catch (error) {
|
||||
console.error("Error sending message:", error);
|
||||
setPendingMessages((prev) => prev.filter((m) => m._id !== tempId));
|
||||
@@ -253,6 +310,10 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExpirePending = useCallback((ids: string[]) => {
|
||||
setPendingMessages((prev) => prev.filter((m) => !ids.includes(m._id)));
|
||||
}, []);
|
||||
|
||||
const openActionFor = (msg: ChatMessage) => {
|
||||
setSelectedMessage(msg);
|
||||
setActionMode(true);
|
||||
@@ -263,11 +324,15 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
setSelectedMessage(null);
|
||||
};
|
||||
|
||||
const startReplyFor = useCallback((msg: ChatMessage) => {
|
||||
setReplyingTo(msg);
|
||||
activeReplyRef.current = msg;
|
||||
closeActionMode();
|
||||
}, []);
|
||||
|
||||
const startReply = () => {
|
||||
if (!selectedMessage) return;
|
||||
setReplyingTo(selectedMessage);
|
||||
activeReplyRef.current = selectedMessage;
|
||||
closeActionMode();
|
||||
startReplyFor(selectedMessage);
|
||||
};
|
||||
|
||||
const startForward = () => {
|
||||
@@ -276,23 +341,89 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
closeActionMode();
|
||||
};
|
||||
|
||||
const handleBlockChange = () => {
|
||||
const handleBlockChange = (blocked?: boolean) => {
|
||||
if (typeof blocked === "boolean") {
|
||||
setUserTwoDetail((prev) =>
|
||||
prev ? { ...prev, is_blocked: blocked } : prev
|
||||
);
|
||||
}
|
||||
void request<{ user: User }>(
|
||||
"GET",
|
||||
`/users/get/web?user_name=${username}&_t=${Date.now()}`
|
||||
).then((res) => setUserTwoDetail(res?.user));
|
||||
).then((res) => {
|
||||
if (res?.user) setUserTwoDetail(res.user);
|
||||
});
|
||||
};
|
||||
|
||||
const startDeleteMode = () => {
|
||||
closeActionMode();
|
||||
setSearchQuery("");
|
||||
setDeleteMode(true);
|
||||
setSelectedDeleteIds([]);
|
||||
};
|
||||
|
||||
const cancelDeleteMode = () => {
|
||||
setDeleteMode(false);
|
||||
setSelectedDeleteIds([]);
|
||||
};
|
||||
|
||||
const toggleDeleteSelect = (msg: ChatMessage) => {
|
||||
setSelectedDeleteIds((prev) =>
|
||||
prev.includes(msg._id)
|
||||
? prev.filter((id) => id !== msg._id)
|
||||
: [...prev, msg._id]
|
||||
);
|
||||
};
|
||||
|
||||
const confirmDeleteMessages = async () => {
|
||||
if (!selectedDeleteIds.length || !user?._id) return;
|
||||
setDeleteLoading(true);
|
||||
try {
|
||||
const response = await request<{ deletedIds: string[]; message?: string }>(
|
||||
"DELETE",
|
||||
"/chat",
|
||||
{ messageIds: selectedDeleteIds }
|
||||
);
|
||||
const deleted = new Set(response?.deletedIds || []);
|
||||
setPendingMessages((prev) => prev.filter((m) => !deleted.has(m._id)));
|
||||
queryClient.setQueryData(
|
||||
chatThreadQueryKey(
|
||||
normalizeThreadId(user._id),
|
||||
normalizeThreadId(chatPartnerId)
|
||||
),
|
||||
(oldData: { pages: { messages: ChatMessage[] }[] } | undefined) => {
|
||||
if (!oldData) return oldData;
|
||||
return {
|
||||
...oldData,
|
||||
pages: oldData.pages.map((page) => ({
|
||||
...page,
|
||||
messages: page.messages.filter((m) => !deleted.has(m._id)),
|
||||
})),
|
||||
};
|
||||
}
|
||||
);
|
||||
toast.success(response?.message || "پیامها حذف شدند");
|
||||
cancelDeleteMode();
|
||||
} catch {
|
||||
toast.error("حذف پیامها ناموفق بود");
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container className="chat-page-bg flex h-[100dvh] max-h-[100dvh] flex-col overflow-hidden !px-0">
|
||||
<div className="flex min-h-0 flex-1 flex-col px-2 sm:px-4">
|
||||
<ChatHeader
|
||||
user={userTwoDetail}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onBlockChange={handleBlockChange}
|
||||
/>
|
||||
<ChatMessageList
|
||||
<ChatHeader
|
||||
user={userTwoDetail}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onBlockChange={handleBlockChange}
|
||||
onStartDeleteMode={startDeleteMode}
|
||||
deleteMode={deleteMode}
|
||||
/>
|
||||
<ChatMessageList
|
||||
chatPartnerId={chatPartnerId}
|
||||
getStableKey={getStableKey}
|
||||
pendingMessages={pendingMessages}
|
||||
userDetail={user as User}
|
||||
userTwoDetail={userTwoDetail}
|
||||
@@ -301,11 +432,30 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
searchQuery={searchQuery}
|
||||
selectedMessageId={selectedMessage?._id}
|
||||
onBubbleClick={openActionFor}
|
||||
/>
|
||||
</div>
|
||||
onSwipeReply={startReplyFor}
|
||||
actionMode={actionMode}
|
||||
onDismissAction={closeActionMode}
|
||||
deleteMode={deleteMode}
|
||||
selectedDeleteIds={selectedDeleteIds}
|
||||
onToggleDeleteSelect={toggleDeleteSelect}
|
||||
onExpirePending={handleExpirePending}
|
||||
extraBottomRem={
|
||||
(replyingTo ? 2.75 : 0) +
|
||||
(selfDestructSeconds != null ? 2 : 0) +
|
||||
(viewOnceMedia ? 2 : 0)
|
||||
}
|
||||
/>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{actionMode ? (
|
||||
{deleteMode ? (
|
||||
<ChatDeleteBar
|
||||
key="delete"
|
||||
selectedCount={selectedDeleteIds.length}
|
||||
onDelete={confirmDeleteMessages}
|
||||
onCancel={cancelDeleteMode}
|
||||
loading={deleteLoading}
|
||||
/>
|
||||
) : actionMode ? (
|
||||
<ChatActionBar
|
||||
key="actions"
|
||||
onReply={startReply}
|
||||
@@ -318,7 +468,7 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
newMessage={newMessage}
|
||||
setNewMessage={setNewMessage}
|
||||
sendMessage={sendMessage}
|
||||
handleFileSelection={handleImageSelection}
|
||||
handleFileSelection={handleFileSelection}
|
||||
handleVoiceUpload={handleVoiceUpload}
|
||||
onLocationShare={handleLocationShare}
|
||||
onTyping={handleTyping}
|
||||
@@ -334,6 +484,10 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
? replyingTo.content?.slice(0, 80) || "پیام"
|
||||
: undefined
|
||||
}
|
||||
selfDestructSeconds={selfDestructSeconds}
|
||||
onSelfDestructChange={setSelfDestructSeconds}
|
||||
viewOnceMedia={viewOnceMedia}
|
||||
onViewOnceChange={setViewOnceMedia}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -353,6 +507,8 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
setShowMultiModal(false);
|
||||
}}
|
||||
onConfirm={sendAllImages}
|
||||
viewOnceMedia={viewOnceMedia}
|
||||
onViewOnceChange={setViewOnceMedia}
|
||||
/>
|
||||
{forwardMessage && user?._id && (
|
||||
<ForwardMessageModal
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import { IMAGE_BASE_URL, SOCKET_URL, buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import VerificationBadge from "@/components/main/VerificationBadge";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import UserDetails from "@/components/settings/UserDetails";
|
||||
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
|
||||
@@ -126,15 +127,10 @@ function Chats() {
|
||||
{item.first_name} {item.last_name}
|
||||
</span>
|
||||
<div className="mt-1 flex items-center gap-1 text-neutral-500">
|
||||
@{item?.user_name}
|
||||
{item?.is_verified === "verified" && (
|
||||
<Image
|
||||
width={18}
|
||||
height={18}
|
||||
alt="تایید"
|
||||
src="/images/icons/verify.svg"
|
||||
/>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
@{item?.user_name}
|
||||
<VerificationBadge isVerified={item?.is_verified} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,7 +71,7 @@ function NationalCart() {
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center address-page">
|
||||
<span className="text-xl font-bold">احراز هویت</span>
|
||||
<span className="text-xl font-bold text-foreground">احراز هویت</span>
|
||||
<div className="mt-5">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
@@ -149,9 +149,9 @@ function NationalCart() {
|
||||
<AuthNextButton
|
||||
onClick={uploadImage}
|
||||
className="mt-20"
|
||||
disabled={loading}
|
||||
loading={loading} disabled={loading}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ثبت و ادامه"}
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
|
||||
<button
|
||||
|
||||
@@ -6,6 +6,7 @@ import React, { useState, useEffect } from "react";
|
||||
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import Image from "next/image";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { BASE_URL, IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
@@ -178,17 +179,13 @@ function LicensePage() {
|
||||
<span className="text-xl mb-9 font-bold">بارگذاری مجوز</span>
|
||||
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<Image
|
||||
className="rounded-2xl object-cover"
|
||||
width={130}
|
||||
height={130}
|
||||
<ProfileAvatar
|
||||
src={user?.profile_image}
|
||||
alt={user?.user_name || "user profile"}
|
||||
src={
|
||||
user?.profile_image
|
||||
? buildStorageUrl(user.profile_image)
|
||||
: "/images/placeholder.png"
|
||||
}
|
||||
unoptimized={true}
|
||||
size="md"
|
||||
rounded="2xl"
|
||||
className="h-[130px] w-[130px]"
|
||||
fallback="/images/placeholder.png"
|
||||
/>
|
||||
<div className="flex flex-col justify-center items-center gap-1 mt-1">
|
||||
<span>
|
||||
@@ -286,7 +283,7 @@ function LicensePage() {
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<AuthNextButton onClick={upload} className="mt-20">
|
||||
<AuthNextButton onClick={upload} className="mt-20" loading={loadingUpload} disabled={loadingUpload}>
|
||||
ثبت و ادامه
|
||||
</AuthNextButton>
|
||||
<small className="font-bold mt-10 mb-1 text-center">
|
||||
|
||||
@@ -58,7 +58,6 @@ function AvatarPage() {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
setLoadingUpload(false);
|
||||
router.push("/settings/edit");
|
||||
} else {
|
||||
setLoadingUpload(false);
|
||||
@@ -80,13 +79,9 @@ function AvatarPage() {
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
{loading || loadingUpload ? (
|
||||
<p>Loading...</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col items-center mb-4">
|
||||
<span className="text-xl font-bold">تصویر پروفایل</span>
|
||||
<div className="relative w-[250px] h-[250px] flex items-center justify-center text-white rounded-3xl border border-[#676767] overflow-hidden mt-10">
|
||||
<div className="flex flex-col items-center mb-4">
|
||||
<span className="text-xl font-bold text-foreground">تصویر پروفایل</span>
|
||||
<div className="relative aspect-square w-[250px] flex items-center justify-center text-white rounded-3xl border border-[#676767] overflow-hidden mt-10">
|
||||
{avatar ? (
|
||||
<>
|
||||
<img
|
||||
@@ -96,7 +91,7 @@ function AvatarPage() {
|
||||
: IMAGE_BASE_URL + avatar
|
||||
}
|
||||
alt="Avatar"
|
||||
className="w-full h-full object-cover"
|
||||
className="w-full h-full aspect-square object-cover"
|
||||
/>
|
||||
<button
|
||||
onClick={removeImage}
|
||||
@@ -141,15 +136,14 @@ function AvatarPage() {
|
||||
</label>
|
||||
</AuthNextButton>
|
||||
|
||||
<AuthNextButton
|
||||
onClick={uploadImage}
|
||||
className="mt-20"
|
||||
disabled={loading}
|
||||
>
|
||||
ویرایش
|
||||
</AuthNextButton>
|
||||
</>
|
||||
)}
|
||||
<AuthNextButton
|
||||
onClick={uploadImage}
|
||||
className="mt-20"
|
||||
loading={loadingUpload}
|
||||
disabled={loadingUpload || loading}
|
||||
>
|
||||
ویرایش
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -50,7 +50,7 @@ function PublicRelations() {
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold">بیو</span>
|
||||
<span className="text-xl font-bold text-foreground">بیو</span>
|
||||
<div className="mt-5"></div>
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
@@ -75,8 +75,8 @@ function PublicRelations() {
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ویرایش"}
|
||||
<AuthNextButton type="submit" className="mt-20" loading={loading} disabled={loading}>
|
||||
ویرایش
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -84,7 +84,7 @@ function Colors() {
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold">سایز</span>
|
||||
<span className="text-xl font-bold text-foreground">سایز</span>
|
||||
|
||||
<p className="mt-8 text-center text-sm font-bold">مشخصات ظاهری</p>
|
||||
|
||||
@@ -159,9 +159,9 @@ function Colors() {
|
||||
type="button"
|
||||
onClick={handleCheck}
|
||||
className="mt-10"
|
||||
disabled={loading}
|
||||
loading={loading} disabled={loading}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ویرایش"}
|
||||
ویرایش
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -48,7 +48,7 @@ function CooperationType() {
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold">نوع همکاری</span>
|
||||
<span className="text-xl font-bold text-foreground">نوع همکاری</span>
|
||||
|
||||
<p className="my-10 text-sm font-bold">
|
||||
آیا مایل به همکاری خارج از محل سکونت خود هستید؟
|
||||
@@ -89,8 +89,8 @@ function CooperationType() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ویرایش"}
|
||||
<AuthNextButton type="submit" className="mt-20" loading={loading} disabled={loading}>
|
||||
ویرایش
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -96,7 +96,7 @@ function Expertise() {
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold">تخصص</span>
|
||||
<span className="text-xl font-bold text-foreground">تخصص</span>
|
||||
|
||||
<p className="mt-8 text-center">در چه زمینه ای تخصص دارید؟</p>
|
||||
|
||||
@@ -134,9 +134,9 @@ function Expertise() {
|
||||
type="button"
|
||||
onClick={handleCheck}
|
||||
className="mt-20"
|
||||
disabled={loading}
|
||||
loading={loading} disabled={loading}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ویرایش"}
|
||||
ویرایش
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -89,8 +89,8 @@ function LocationPage() {
|
||||
address: values.address,
|
||||
province_id: values.stateId,
|
||||
city_id: values.cityId,
|
||||
lat: values.markerCoordinate[0],
|
||||
lng: values.markerCoordinate[1],
|
||||
lat: values.markerCoordinate[1],
|
||||
lng: values.markerCoordinate[0],
|
||||
show_location: isCheckedOne,
|
||||
});
|
||||
router.push("/settings/edit");
|
||||
@@ -121,7 +121,9 @@ function LocationPage() {
|
||||
if (user) {
|
||||
formik.setValues({
|
||||
markerCoordinate:
|
||||
user.lat && user.lng ? [Number(user.lat), Number(user.lng)] : [],
|
||||
user.lat && user.lng
|
||||
? [Number(user.lng), Number(user.lat)]
|
||||
: [],
|
||||
address: user.address || "",
|
||||
cityId: user?.city?.id ? String(user.city.id) : "",
|
||||
stateId: user?.province?.id ? String(user.province.id) : "",
|
||||
@@ -129,8 +131,8 @@ function LocationPage() {
|
||||
|
||||
if (user.lat && user.lng) {
|
||||
setSelectedLocation({
|
||||
lat: Number(user?.lng),
|
||||
lng: Number(user?.lat),
|
||||
lat: Number(user.lat),
|
||||
lng: Number(user.lng),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -146,7 +148,7 @@ function LocationPage() {
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center justify-center address-page ">
|
||||
<span className="text-xl font-bold">لوکیشن</span>
|
||||
<span className="text-xl font-bold text-foreground">لوکیشن</span>
|
||||
<div className="mt-5"></div>
|
||||
|
||||
<small className="font-bold mt-10 mb-4">
|
||||
@@ -224,8 +226,8 @@ function LocationPage() {
|
||||
<Map
|
||||
style={{ height: "calc(100vh - 260px)" }}
|
||||
initialViewState={{
|
||||
longitude: Number(user?.lat) || 51.375433528216654,
|
||||
latitude: Number(user?.lng) || 35.73356434056531,
|
||||
longitude: Number(user?.lng) || 51.375433528216654,
|
||||
latitude: Number(user?.lat) || 35.73356434056531,
|
||||
zoom: 11,
|
||||
}}
|
||||
mapboxAccessToken="pk.eyJ1IjoiYWxkZjkyIiwiYSI6ImNscHh5ZG56dTByMXAycnJ2cDNmdDQ5M3YifQ.a_sUt1rLFMfA0jHrETcM7A"
|
||||
@@ -259,8 +261,8 @@ function LocationPage() {
|
||||
اطلاعات لوکیشن شما برای همه قابل نمایش باشد
|
||||
</span>
|
||||
</label>
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ویرایش"}
|
||||
<AuthNextButton type="submit" className="mt-20" loading={loading} disabled={loading}>
|
||||
ویرایش
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"use client";
|
||||
|
||||
import AuthHead from "@/components/auth/AuthHead";
|
||||
import AuthInput from "@/components/auth/AuthInput";
|
||||
import AuthPasswordInput from "@/components/auth/AuthPasswordInput";
|
||||
import Container from "@/components/elements/Container";
|
||||
import React from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
@@ -64,11 +64,11 @@ function PasswordPage() {
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center"
|
||||
>
|
||||
<AuthInput
|
||||
<AuthPasswordInput
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="کلمه عبور"
|
||||
className={`border mt-4 ${
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.password && formik.errors.password
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
@@ -83,11 +83,11 @@ function PasswordPage() {
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthInput
|
||||
<AuthPasswordInput
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
placeholder="تکرار کلمه عبور"
|
||||
className={`border mt-4 ${
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.confirmPassword && formik.errors.confirmPassword
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
@@ -102,8 +102,8 @@ function PasswordPage() {
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "تایید کلمه عبور"}
|
||||
<AuthNextButton type="submit" className="mt-20" loading={loading} disabled={loading}>
|
||||
تایید کلمه عبور
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,7 @@ function PublicRelations() {
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold">روابط عمومی</span>
|
||||
<span className="text-xl font-bold text-foreground">روابط عمومی</span>
|
||||
<div className="mt-5"></div>
|
||||
|
||||
{/* <p className="my-10 text-sm font-bold">
|
||||
@@ -123,8 +123,8 @@ function PublicRelations() {
|
||||
</small>
|
||||
)}
|
||||
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ویرایش"}
|
||||
<AuthNextButton type="submit" className="mt-20" loading={loading} disabled={loading}>
|
||||
ویرایش
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react";
|
||||
import { Service, User } from "@/types/types";
|
||||
import ServiceItem from "@/components/main/Services/ServiceItem";
|
||||
import AddServiceModal from "@/components/main/Services/AddServiceModal";
|
||||
import AuthUserDetails from "@/components/auth/AuthUserDetails";
|
||||
import Container from "@/components/elements/Container";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
@@ -91,7 +92,11 @@ const ServicesPage: React.FC = () => {
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold">خدمات</span>
|
||||
<span className="text-xl font-bold text-foreground">خدمات</span>
|
||||
|
||||
<div className="mt-5 w-full max-w-md">
|
||||
<AuthUserDetails />
|
||||
</div>
|
||||
|
||||
<div className="gap-4 min-h-96">
|
||||
{services.map((service) => (
|
||||
@@ -107,10 +112,10 @@ const ServicesPage: React.FC = () => {
|
||||
<div className="flex gap-4">
|
||||
<AuthNextButton
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
loading={loading} disabled={loading}
|
||||
className="mb-4 w-32"
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ویرایش"}
|
||||
ویرایش
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
onClick={() => setModalOpen(true)}
|
||||
|
||||
@@ -53,7 +53,7 @@ function AuthPage() {
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center auth-page">
|
||||
<span className="text-xl font-bold">شماره شبا</span>
|
||||
<span className="text-xl font-bold text-foreground">شماره شبا</span>
|
||||
<div className="mt-5"></div>
|
||||
|
||||
<form
|
||||
@@ -87,8 +87,8 @@ function AuthPage() {
|
||||
<small className="font-bold mt-10">
|
||||
شماره شبا باید به نام خود شخص باشد
|
||||
</small>
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ویرایش"}
|
||||
<AuthNextButton type="submit" className="mt-20" loading={loading} disabled={loading}>
|
||||
ویرایش
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -75,7 +75,7 @@ function Sizes() {
|
||||
return (
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<span className="text-xl font-bold">سایز</span>
|
||||
<span className="text-xl font-bold text-foreground">سایز</span>
|
||||
<p className="mt-8 text-center text-sm font-bold mb-4">مشخصات ظاهری</p>
|
||||
{/* Buttons for categories */}
|
||||
<div className="flex gap-2 mt-4 w-full max-w-[320px]">
|
||||
@@ -175,9 +175,9 @@ function Sizes() {
|
||||
type="button"
|
||||
onClick={handleCheck}
|
||||
className="mt-10"
|
||||
disabled={loading}
|
||||
loading={loading} disabled={loading}
|
||||
>
|
||||
{loading ? "در حال ارسال..." : "ویرایش"}
|
||||
ویرایش
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
@@ -10,41 +9,57 @@ import AuthHead from "@/components/auth/AuthHead";
|
||||
import AuthInput from "@/components/auth/AuthInput";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import Container from "@/components/elements/Container";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object().shape({
|
||||
username: yup.string().required("نام کاربری الزامی است"),
|
||||
});
|
||||
import UsernameSuggestions from "@/components/auth/UsernameSuggestions";
|
||||
import { usernameFormSchema } from "@/lib/validation/usernameSchema";
|
||||
import { sanitizeUsernameInput, USERNAME_MIN_LENGTH } from "@/lib/validation/username";
|
||||
|
||||
function UsernamePage() {
|
||||
const router = useRouter();
|
||||
const user = useUser();
|
||||
const { request, loading } = useAxios();
|
||||
const [isDuplicate, setIsDuplicate] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
username: user?.user_name || "",
|
||||
},
|
||||
validationSchema: schema,
|
||||
validationSchema: usernameFormSchema,
|
||||
enableReinitialize: true,
|
||||
onSubmit: async (values) => {
|
||||
setIsDuplicate(false);
|
||||
setSuggestions([]);
|
||||
|
||||
try {
|
||||
await request("PATCH", "/register/username", {
|
||||
user_name: values.username,
|
||||
user_name: values.username.trim().toLowerCase(),
|
||||
});
|
||||
localStorage.setItem("username", values.username);
|
||||
localStorage.setItem("username", values.username.trim().toLowerCase());
|
||||
router.push("/settings/edit");
|
||||
} catch (err: any) {
|
||||
console.log("Unhandled error:", err?.message);
|
||||
const data = err?.response?.data;
|
||||
if (data?.message?.includes("تکراری")) {
|
||||
setIsDuplicate(true);
|
||||
setSuggestions(Array.isArray(data.suggestions) ? data.suggestions : []);
|
||||
} else if (data?.message) {
|
||||
formik.setFieldError("username", data.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// آپدیت مقدار username در صورتی که user.user_name مقدار داشته باشد
|
||||
useEffect(() => {
|
||||
if (user?.user_name) {
|
||||
formik.setValues({ username: user.user_name });
|
||||
}
|
||||
}, [user?.user_name]);
|
||||
const handleUsernameChange = (value: string) => {
|
||||
setIsDuplicate(false);
|
||||
setSuggestions([]);
|
||||
formik.setFieldValue("username", sanitizeUsernameInput(value));
|
||||
};
|
||||
|
||||
const handleSuggestionSelect = (username: string) => {
|
||||
formik.setFieldValue("username", username);
|
||||
formik.setFieldTouched("username", true, false);
|
||||
setIsDuplicate(false);
|
||||
setSuggestions([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
@@ -57,23 +72,43 @@ function UsernamePage() {
|
||||
<AuthInput
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="نام کاربری خود را وارد کنید"
|
||||
placeholder="نام کاربری"
|
||||
dir="ltr"
|
||||
autoComplete="username"
|
||||
spellCheck={false}
|
||||
className={`border ${
|
||||
formik.touched.username && formik.errors.username
|
||||
(formik.touched.username && formik.errors.username) || isDuplicate
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.username}
|
||||
onChange={formik.handleChange}
|
||||
onChange={(e) => handleUsernameChange(e.target.value)}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.username && formik.errors.username && (
|
||||
<small className="text-red-500 mt-2 block">
|
||||
|
||||
{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>
|
||||
)}
|
||||
<AuthNextButton type="submit" className="mt-20" disabled={loading}>
|
||||
{loading ? "در حال ارسال..." : "ویرایش"}
|
||||
|
||||
<UsernameSuggestions
|
||||
suggestions={suggestions}
|
||||
onSelect={handleSuggestionSelect}
|
||||
/>
|
||||
|
||||
<AuthNextButton
|
||||
type="submit"
|
||||
className="mt-20"
|
||||
loading={loading} disabled={loading || formik.values.username.length < USERNAME_MIN_LENGTH}
|
||||
>
|
||||
ویرایش
|
||||
</AuthNextButton>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import SettingsShell from "@/components/settings/SettingsShell";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "تنظیمات",
|
||||
template: "%s | مدستاگرام",
|
||||
},
|
||||
};
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return <SettingsShell>{children}</SettingsShell>;
|
||||
|
||||
@@ -57,6 +57,15 @@ function Notifications() {
|
||||
item?.title?.split("/").pop() || ""
|
||||
)}/${item?.project_post_id}`,
|
||||
new_offer: "/settings/offers",
|
||||
post_like: `/posts/${item?.project_post_id}`,
|
||||
academy_like: `/academy/${item?.project_post_id}/course`,
|
||||
billboard_like: `/settings/my-billboards/${item?.project_post_id}/b`,
|
||||
post_comment: `/posts/${item?.project_post_id}`,
|
||||
profile_comment: "/settings/profile",
|
||||
user_comment: "/settings/profile",
|
||||
academy_comment: `/academy/${item?.project_post_id}/course`,
|
||||
billboard_comment: `/settings/my-billboards/${item?.project_post_id}/b`,
|
||||
billboard_rating: `/settings/my-billboards/${item?.project_post_id}/b`,
|
||||
"reject-user": `/tickets/new/${
|
||||
item?.project_post_id
|
||||
}/${encodeURIComponent(item?.title?.split("/").pop() || "")}`,
|
||||
|
||||
@@ -15,19 +15,9 @@ function Profile() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const { request, loading } = useAxios();
|
||||
|
||||
// گرفتن userId از localStorage فقط در مرورگر
|
||||
const getUserId = () =>
|
||||
typeof window !== "undefined" ? localStorage.getItem("id") : null;
|
||||
|
||||
const fetchUser = async () => {
|
||||
const userId = getUserId();
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
const response = await request<{ user: User }>(
|
||||
"GET",
|
||||
`/users/get/web?user_id=${userId}`
|
||||
);
|
||||
const response = await request<{ user: User }>("GET", "/profile");
|
||||
setUser(response?.user ?? null);
|
||||
} catch (err) {
|
||||
console.error("خطا در دریافت اطلاعات کاربر:", err);
|
||||
|
||||
@@ -13,9 +13,13 @@ interface IUserProps {
|
||||
|
||||
export async function generateMetadata({ params }: IUserProps): Promise<Metadata> {
|
||||
const { username } = await params;
|
||||
const token = (await cookies()).get("token")?.value || "";
|
||||
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}/users/get/web?user_name=${username}`, { cache: 'no-store' });
|
||||
const res = await fetch(`${BASE_URL}/users/get/web?user_name=${username}`, {
|
||||
cache: "no-store",
|
||||
headers: { Authorization: token ? `Bearer ${token}` : "" },
|
||||
});
|
||||
const data = await res.json();
|
||||
const user = data?.user as User;
|
||||
|
||||
@@ -23,6 +27,13 @@ export async function generateMetadata({ params }: IUserProps): Promise<Metadata
|
||||
return { title: `${username} | مدستاگرام`, robots: "noindex" };
|
||||
}
|
||||
|
||||
if (user.blocked_you) {
|
||||
return {
|
||||
title: "پروفایل در دسترس نیست | مدستاگرام",
|
||||
robots: "noindex, nofollow",
|
||||
};
|
||||
}
|
||||
|
||||
const fullName = `${user.first_name || ""} ${user.last_name || ""}`.trim();
|
||||
const city = user.city?.name || "ایران";
|
||||
const expertise = user.expertise || "متخصص";
|
||||
@@ -97,6 +108,22 @@ async function UserPage({ params }: IUserProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (user.blocked_you) {
|
||||
return (
|
||||
<Container>
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center px-6 py-20 text-center">
|
||||
<div className="mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-neutral-200 dark:bg-neutral-800">
|
||||
<span className="text-3xl text-neutral-400">🚫</span>
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-foreground">این پروفایل در دسترس نیست</h2>
|
||||
<p className="mt-2 max-w-sm text-sm text-neutral-500 dark:text-neutral-400">
|
||||
شما توسط این کاربر مسدود شدهاید و امکان مشاهده پروفایل و پستهای او وجود ندارد.
|
||||
</p>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const fullName = `${user.first_name || ""} ${user.last_name || ""}`.trim();
|
||||
|
||||
// داده ساختاریافته (Schema) برای درک بهتر گوگل از ماهیت پروفایل
|
||||
|
||||
@@ -2,16 +2,23 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { isAuthenticated } from "@/lib/auth/session";
|
||||
|
||||
const ROUTES = ["/academy", "/billboards", "/settings", "/explore"];
|
||||
const PUBLIC_ROUTES = ["/academy", "/billboards", "/explore"];
|
||||
|
||||
export default function BackgroundPrefetch() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => {
|
||||
ROUTES.forEach((route) => router.prefetch(route));
|
||||
PUBLIC_ROUTES.forEach((route) => router.prefetch(route));
|
||||
|
||||
// settings فقط وقتی لاگین است prefetch شود
|
||||
if (isAuthenticated()) {
|
||||
router.prefetch("/settings");
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
return () => window.clearTimeout(t);
|
||||
}, [router]);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
import { ReactQueryProvider } from "@/providers/ReactQueryProvider";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import BackgroundPrefetch from "@/components/BackgroundPrefetch";
|
||||
import TitleGuardian from "@/components/TitleGuardian";
|
||||
|
||||
interface ILayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -12,6 +13,7 @@ function Layout({ children }: ILayoutProps) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<ReactQueryProvider>
|
||||
<TitleGuardian />
|
||||
<Toaster />
|
||||
<BackgroundPrefetch />
|
||||
{children}
|
||||
|
||||
@@ -3,68 +3,16 @@
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
href: "/",
|
||||
icon: (color: string) => (
|
||||
<svg width="28" height="28" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M27.5 13.125C27.5 11.6125 26.4875 9.67496 25.25 8.81246L17.525 3.39996C15.775 2.17496 12.9625 2.23746 11.275 3.54996L4.5375 8.79996C3.4125 9.67496 2.5 11.5375 2.5 12.95V22.2125C2.5 25.1125 4.8625 27.4875 7.7625 27.4875H22.2375C25.1375 27.4875 27.5 25.1125 27.5 22.225V18.35"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
label: "خانه",
|
||||
},
|
||||
{
|
||||
href: "/academy",
|
||||
icon: (color: string) => (
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke={color} d="M5.99953 5.17031L4.02953 6.46031C2.09953 7.72031 2.09953 10.5403 4.02953 11.8003L10.0495 15.7303C11.1295 16.4403 12.9095 16.4403 13.9895 15.7303L19.9795 11.8003C21.8995 10.5403 21.8995 7.73031 19.9795 6.47031L13.9895 2.54031C12.9095 1.83031 11.1295 1.83031 10.0495 2.54031" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path stroke={color} d="M5.63012 13.0801L5.62012 17.7701C5.62012 19.0401 6.60012 20.4001 7.80012 20.8001L10.9901 21.8601C11.5401 22.0401 12.4501 22.0401 13.0101 21.8601L16.2001 20.8001C17.4001 20.4001 18.3801 19.0401 18.3801 17.7701V13.1301" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path stroke={color} d="M21.4004 15V9" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
),
|
||||
label: "آموزشگاه",
|
||||
},
|
||||
{
|
||||
href: "/new-post",
|
||||
icon: (color: string) => (
|
||||
<svg width="28" height="28" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.7375 15H20" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M10 15H14.7625" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M15 20V10" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M2.5 16.3V18.75C2.5 25 5 27.5 11.25 27.5H18.75C25 27.5 27.5 25 27.5 18.75V11.25C27.5 5 25 2.5 18.75 2.5H11.25C5 2.5 2.5 5 2.5 11.25" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
),
|
||||
label: "پست ها",
|
||||
},
|
||||
{
|
||||
href: "/billboards",
|
||||
icon: (color: string) => (
|
||||
<svg width="28" height="28" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.85 15.9001H13.025V20.9626C13.025 21.7126 13.95 22.0626 14.45 21.5001L19.775 15.4501C20.2375 14.9251 19.8625 14.1001 19.1625 14.1001H16.9875V9.0376C16.9875 8.2876 16.0625 7.9376 15.5625 8.5001L10.2375 14.5501C9.775 15.0751 10.15 15.9001 10.85 15.9001Z" stroke={color} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M5 7.5C3.4375 9.5875 2.5 12.1875 2.5 15C2.5 21.9 8.1 27.5 15 27.5C21.9 27.5 27.5 21.9 27.5 15C27.5 8.1 21.9 2.5 15 2.5C13.2125 2.5 11.5 2.875 9.9625 3.5625" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
),
|
||||
label: "بیلبورد",
|
||||
},
|
||||
{
|
||||
href: "/settings",
|
||||
icon: (color: string) => (
|
||||
<svg width="28" height="28" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3.75 16.35V18.6C3.75 21.25 3.75 21.25 6.25 22.9375L13.125 26.9125C14.1625 27.5125 15.85 27.5125 16.875 26.9125L23.75 22.9375C26.25 21.25 26.25 21.25 26.25 18.6125V11.3875C26.25 8.74995 26.25 8.74995 23.75 7.06245L16.875 3.08745C15.85 2.48745 14.1625 2.48745 13.125 3.08745L6.25 7.06245C3.75 8.74995 3.75 8.74995 3.75 11.3875" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M18.75 15C18.75 12.925 17.075 11.25 15 11.25C12.925 11.25 11.25 12.925 11.25 15C11.25 17.075 12.925 18.75 15 18.75C15.5125 18.75 16.0125 18.65 16.4625 18.45" stroke={color} strokeWidth="1.5" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
),
|
||||
label: "تنظیمات",
|
||||
},
|
||||
];
|
||||
{ href: "/", icon: "home-2", label: "خانه" },
|
||||
{ href: "/academy", icon: "teacher", label: "آموزشگاه" },
|
||||
{ href: "/new-post", icon: "add-square", label: "پست ها" },
|
||||
{ href: "/billboards", icon: "flash-circle", label: "بیلبورد" },
|
||||
{ href: "/settings", icon: "setting", label: "تنظیمات" },
|
||||
] as const;
|
||||
|
||||
type TabNavigationProps = {
|
||||
currentPage: string;
|
||||
@@ -108,9 +56,6 @@ export default function TabNavigation({ currentPage }: TabNavigationProps) {
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const defaultColor = isDark ? "#a1a1aa" : "#52525b";
|
||||
const activeColor = "#ff107d";
|
||||
|
||||
return (
|
||||
<nav className="fixed bottom-2 left-1/2 z-50 w-full max-w-xl -translate-x-1/2 px-4 pb-2">
|
||||
<div className="glass-panel gentle-transition flex justify-evenly rounded-full border border-white/40 p-2.5 shadow-lg dark:border-white/10">
|
||||
@@ -119,7 +64,6 @@ export default function TabNavigation({ currentPage }: TabNavigationProps) {
|
||||
const isActive =
|
||||
currentPage === href ||
|
||||
(href === "/settings" && currentPage.startsWith("/settings"));
|
||||
const color = isActive ? activeColor : defaultColor;
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -137,7 +81,18 @@ export default function TabNavigation({ currentPage }: TabNavigationProps) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tab.icon(color)}
|
||||
<BoldIcon
|
||||
name={tab.icon}
|
||||
size={28}
|
||||
tinted
|
||||
className={cn(
|
||||
isActive
|
||||
? "text-[#ff107d]"
|
||||
: isDark
|
||||
? "text-zinc-400"
|
||||
: "text-zinc-600"
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-semibold gentle-transition",
|
||||
|
||||
@@ -1,29 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { defaultSEOConfig } from "@/config/seoConfig";
|
||||
|
||||
const DEFAULT_TITLE =
|
||||
typeof defaultSEOConfig.title === "string"
|
||||
? defaultSEOConfig.title
|
||||
: "مدستاگرام";
|
||||
|
||||
export default function TitleGuardian() {
|
||||
const pathname = usePathname();
|
||||
const lastValidTitle = useRef(DEFAULT_TITLE);
|
||||
|
||||
useEffect(() => {
|
||||
// جلوگیری از تغییرات ناگهانی توسط اسکریپتهای چت و اینماد
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === "childList" || mutation.type === "characterData") {
|
||||
const newTitle = document.title;
|
||||
// اگر تایتل خالی شد یا به localhost تغییر کرد یا حاوی کلمات سیستمی بود، دخالت نکن
|
||||
// اما اگر اسکریپت خارجی مثل "گفتینو" تایتل را عوض کرد، اینجا میتوانید منطق بازگشت بنویسید
|
||||
}
|
||||
});
|
||||
if (document.title.trim()) {
|
||||
lastValidTitle.current = document.title;
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const currentTitle = document.title.trim();
|
||||
if (currentTitle) {
|
||||
lastValidTitle.current = currentTitle;
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastValidTitle.current.trim()) {
|
||||
document.title = lastValidTitle.current;
|
||||
}
|
||||
});
|
||||
|
||||
const titleElement = document.querySelector("title");
|
||||
if (titleElement) {
|
||||
observer.observe(titleElement, {
|
||||
childList: true,
|
||||
characterData: true,
|
||||
subtree: true
|
||||
childList: true,
|
||||
characterData: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,4 +43,4 @@ export default function TitleGuardian() {
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { motion } from "framer-motion";
|
||||
import { Search, Filter, X } from "lucide-react";
|
||||
import { Filter, X } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
|
||||
interface AcademyFilterProps {
|
||||
@@ -222,7 +223,13 @@ export default function AcademyFilter({ onFilterChange }: AcademyFilterProps) {
|
||||
placeholder="جستجوی دوره، مدرس، ..."
|
||||
className="w-full px-4 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-transparent"
|
||||
/>
|
||||
<Search className="absolute left-3 top-2.5 w-5 h-5 text-gray-400" />
|
||||
<Image
|
||||
src="/images/icons/search-normal.svg"
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
className="absolute left-3 top-2.5 opacity-60 dark:invert"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import Modal from "../elements/Modal";
|
||||
import Image from "next/image";
|
||||
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import VerificationBadge from "@/components/main/VerificationBadge";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -362,39 +363,24 @@ function CommentsModal({
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
{/* آواتار کاربر */}
|
||||
<div className="flex-shrink-0">
|
||||
<Image
|
||||
className="rounded-full object-cover"
|
||||
width={40}
|
||||
height={40}
|
||||
<div className="shrink-0">
|
||||
<ProfileAvatar
|
||||
src={item?.user_id?.profile_image}
|
||||
alt={item?.user_id?.user_name || "کاربر"}
|
||||
src={
|
||||
item?.user_id?.profile_image
|
||||
? buildStorageUrl(item.user_id.profile_image)
|
||||
: "/images/default-avatar.png"
|
||||
}
|
||||
priority={true}
|
||||
unoptimized={true}
|
||||
size="chat"
|
||||
rounded="full"
|
||||
fallback="/images/default-avatar.png"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
{/* اطلاعات کاربر و امتیاز */}
|
||||
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||
<span className="font-semibold text-sm dark:text-white">
|
||||
<span className="font-semibold text-sm dark:text-white inline-flex items-center gap-1">
|
||||
{item?.user_id?.user_name || "کاربر ناشناس"}
|
||||
<VerificationBadge isVerified={item?.user_id?.is_verified} />
|
||||
</span>
|
||||
|
||||
{item?.user_id?.is_verified === "verified" && (
|
||||
<Image
|
||||
width={16}
|
||||
height={16}
|
||||
alt="verify"
|
||||
src="/images/icons/verify.svg"
|
||||
className="inline"
|
||||
/>
|
||||
)}
|
||||
|
||||
{item?.rate > 0 && (
|
||||
<div className="mr-2">
|
||||
{renderStars(item.rate)}
|
||||
|
||||
@@ -13,6 +13,7 @@ import Image from "next/image";
|
||||
import MainModelCardActions from "./MainModelCardActions";
|
||||
import Link from "next/link";
|
||||
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import VerificationBadge from "@/components/main/VerificationBadge";
|
||||
import { useUserById } from "@/hooks/getUserById";
|
||||
import "slick-carousel/slick/slick.css";
|
||||
import "slick-carousel/slick/slick-theme.css";
|
||||
@@ -425,31 +426,10 @@ function MainModelCard({ postData }: { postData: Course }) {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<h2 className="max-sm:text-xs">{cuorse_name}</h2>
|
||||
{is_verified === "verified" ? (
|
||||
<Image
|
||||
width={22}
|
||||
height={22}
|
||||
alt={"verify icon"}
|
||||
src={`/images/icons/verify.svg`}
|
||||
/>
|
||||
) : is_verified === "pending" ? (
|
||||
<Image
|
||||
width={25}
|
||||
height={25}
|
||||
alt={"not-verify icon"}
|
||||
src={`/images/icons/not-verify.svg`}
|
||||
/>
|
||||
) : is_verified === "true" ? (
|
||||
<Image
|
||||
width={22}
|
||||
height={22}
|
||||
alt="verify icon"
|
||||
src="/images/icons/verify2.svg"
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<h2 className="max-sm:text-xs inline-flex items-center gap-1">
|
||||
{cuorse_name}
|
||||
<VerificationBadge isVerified={is_verified} />
|
||||
</h2>
|
||||
<div className="flex items-center gap-0.5 justify-center">
|
||||
<span className="mr-2">{newScore || 0}</span>
|
||||
<Image
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"use client"
|
||||
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import { User } from "@/types/types";
|
||||
import Image from "next/image";
|
||||
import React, { useState } from "react";
|
||||
import ModelHeadRowTwo from "../models/ModelPage/ModelHeadRowTwo";
|
||||
import ModelHeadRowThree from "../models/ModelPage/ModelHeadRowThree";
|
||||
@@ -31,7 +29,8 @@ function ModelHead({ user, item2 }: ModelHeadProps) {
|
||||
user_level,
|
||||
bio,
|
||||
user_type,
|
||||
_id
|
||||
_id,
|
||||
is_Register,
|
||||
} = user;
|
||||
|
||||
|
||||
@@ -108,30 +107,19 @@ function ModelHead({ user, item2 }: ModelHeadProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{profile_image ? (
|
||||
<Image
|
||||
className="rounded-xl object-cover h-[72px] w-[72px] md:h-[120px] md:w-[120px]"
|
||||
width={120}
|
||||
height={120}
|
||||
alt={user_name}
|
||||
src={buildStorageUrl(profile_image)}
|
||||
unoptimized={true}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
className="rounded-xl object-cover h-[72px] w-[72px] md:h-[120px] md:w-[120px]"
|
||||
width={120}
|
||||
height={120}
|
||||
alt={user_name}
|
||||
src={`/images/fake-avatar.png`}
|
||||
unoptimized={true}
|
||||
/>
|
||||
)}
|
||||
<ProfileAvatar
|
||||
src={profile_image}
|
||||
alt={user_name}
|
||||
size="md"
|
||||
rounded="xl"
|
||||
className="md:h-[120px] md:w-[120px]"
|
||||
/>
|
||||
</div>
|
||||
<ModelHeadRowTwo
|
||||
first_name={first_name}
|
||||
last_name={last_name}
|
||||
is_verified={is_verified}
|
||||
is_Register={is_Register}
|
||||
user_name={user_name}
|
||||
user_score={user_score}
|
||||
rate={rate}
|
||||
|
||||
@@ -1,13 +1,42 @@
|
||||
import React, { ComponentProps } from "react";
|
||||
type TButton = ComponentProps<"button"> & {};
|
||||
function AuthButton({ children, style, className, ...rest }: TButton) {
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TButton = ComponentProps<"button"> & {
|
||||
loading?: boolean;
|
||||
loadingText?: string;
|
||||
};
|
||||
|
||||
function AuthButton({
|
||||
children,
|
||||
style,
|
||||
className,
|
||||
loading = false,
|
||||
loadingText = "در حال ارسال...",
|
||||
disabled,
|
||||
...rest
|
||||
}: TButton) {
|
||||
return (
|
||||
<button
|
||||
className={`w-full dir-ltr max-w-[290px] p-3 text-center text-white rounded-2xl bg-[#FC8EAC] hover:bg-[#f85e87] font-bold duration-300 ${className}`}
|
||||
style={{ ...style }}
|
||||
className={cn(
|
||||
"w-full dir-ltr max-w-[290px] p-3 text-center text-white rounded-2xl bg-[#FC8EAC] hover:bg-[#f85e87] font-bold duration-300",
|
||||
loading && "cursor-wait opacity-80",
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
disabled={disabled || loading}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center justify-center gap-2">
|
||||
<span
|
||||
className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
|
||||
aria-hidden
|
||||
/>
|
||||
<span>{loadingText}</span>
|
||||
</span>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
import { usePageTitle } from "@/hooks/usePageTitle";
|
||||
|
||||
interface IAuthHeadProps {
|
||||
title: string;
|
||||
}
|
||||
|
||||
function AuthHead({ title }: IAuthHeadProps) {
|
||||
usePageTitle(title);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl font-bold">{title}</span>
|
||||
{title ? (
|
||||
<span className="text-2xl font-bold text-foreground">{title}</span>
|
||||
) : null}
|
||||
<Image
|
||||
width={0}
|
||||
height={0}
|
||||
className="w-auto my-20 min-w-[300px]"
|
||||
className="my-20 w-auto min-w-[300px]"
|
||||
src={"/images/icons/logo.svg"}
|
||||
alt="logo"
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import React, { ComponentProps } from "react";
|
||||
type TInput = ComponentProps<"input"> & {};
|
||||
function AuthInput({ style, className, ...rest }: TInput) {
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TInput = ComponentProps<"input"> & {
|
||||
error?: boolean;
|
||||
success?: boolean;
|
||||
};
|
||||
|
||||
function AuthInput({ style, className, error, success, ...rest }: TInput) {
|
||||
return (
|
||||
<input
|
||||
className={`w-full dir-ltr max-w-[290px] p-3 text-center rounded-2xl border border-border-secondary-light dark:border-border-secondary-dark bg-secondary-light dark:bg-secondary-dark font-medium ${className}`}
|
||||
className={cn(
|
||||
"w-full max-w-[290px] p-3.5 text-center text-lg font-semibold rounded-2xl",
|
||||
"border-2 bg-white text-gray-900 placeholder:text-gray-400",
|
||||
"dark:bg-white dark:text-gray-900 dark:placeholder:text-gray-500",
|
||||
"shadow-sm transition-[border-color,box-shadow] duration-200",
|
||||
success
|
||||
? "border-green-500 ring-2 ring-green-500/30 focus:border-green-500 focus:ring-green-500/30"
|
||||
: error
|
||||
? "border-red-500 ring-2 ring-red-500/20 focus:border-red-500 focus:ring-red-500/30"
|
||||
: "border-gray-200 dark:border-gray-300 focus:outline-none focus:border-[#387E65] focus:ring-2 focus:ring-[#387E65]/30",
|
||||
className
|
||||
)}
|
||||
style={{ ...style }}
|
||||
{...rest}
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,42 @@
|
||||
import React, { ComponentProps } from "react";
|
||||
type TButton = ComponentProps<"button"> & {};
|
||||
function AuthNextButton({ children, style, className, ...rest }: TButton) {
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TButton = ComponentProps<"button"> & {
|
||||
loading?: boolean;
|
||||
loadingText?: string;
|
||||
};
|
||||
|
||||
function AuthNextButton({
|
||||
children,
|
||||
style,
|
||||
className,
|
||||
loading = false,
|
||||
loadingText = "در حال ارسال...",
|
||||
disabled,
|
||||
...rest
|
||||
}: TButton) {
|
||||
return (
|
||||
<button
|
||||
className={`border dir-ltr p-7 py-2 text-center text-border-secondary-light dark:text-border-secondary-dark border-border-secondary-light dark:border-border-secondary-dark rounded-3xl bg-tertiary-light dark:bg-tertiary-dark font-medium duration-300 hover:opacity-70 text-sm ${className}`}
|
||||
style={{ ...style }}
|
||||
className={cn(
|
||||
"border dir-ltr p-7 py-2 text-center text-border-secondary-light dark:text-border-secondary-dark border-border-secondary-light dark:border-border-secondary-dark rounded-3xl bg-tertiary-light dark:bg-tertiary-dark font-medium duration-300 hover:opacity-70 text-sm",
|
||||
loading && "cursor-wait opacity-70",
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
disabled={disabled || loading}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center justify-center gap-2">
|
||||
<span
|
||||
className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
|
||||
aria-hidden
|
||||
/>
|
||||
<span>{loadingText}</span>
|
||||
</span>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
89
src/components/auth/AuthOtpInput.tsx
Normal file
89
src/components/auth/AuthOtpInput.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import OtpInput from "react-otp-input";
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AuthOtpInputProps {
|
||||
value: string;
|
||||
onChange: (otp: string) => void;
|
||||
onComplete?: (otp: string) => void;
|
||||
numInputs?: number;
|
||||
error?: boolean;
|
||||
success?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const cellBase =
|
||||
"w-11 h-14 sm:w-12 sm:h-14 min-w-[44px] text-center text-2xl font-bold tabular-nums rounded-2xl border-[2.5px] bg-white text-gray-900 shadow-md outline-none caret-[#387E65] selection:bg-sky-200 dark:bg-white dark:text-gray-900 dark:caret-[#387E65]";
|
||||
|
||||
export default function AuthOtpInput({
|
||||
value,
|
||||
onChange,
|
||||
onComplete,
|
||||
numInputs = 6,
|
||||
error = false,
|
||||
success = false,
|
||||
disabled = false,
|
||||
className,
|
||||
}: AuthOtpInputProps) {
|
||||
return (
|
||||
<div className={cn("w-full flex justify-center", className)}>
|
||||
<OtpInput
|
||||
value={value}
|
||||
onChange={(otp) => {
|
||||
onChange(otp);
|
||||
if (otp.length === numInputs) {
|
||||
onComplete?.(otp);
|
||||
}
|
||||
}}
|
||||
numInputs={numInputs}
|
||||
inputType="tel"
|
||||
shouldAutoFocus
|
||||
skipDefaultStyles
|
||||
containerStyle={{
|
||||
display: "flex",
|
||||
direction: "ltr",
|
||||
gap: "0.5rem",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
}}
|
||||
renderInput={(inputProps, index) => {
|
||||
const filled = Boolean(inputProps.value);
|
||||
|
||||
return (
|
||||
<motion.input
|
||||
{...inputProps}
|
||||
disabled={disabled}
|
||||
animate={
|
||||
filled
|
||||
? { scale: [0.88, 1.1, 1], y: [6, -2, 0], opacity: 1 }
|
||||
: { scale: 1, y: 0, opacity: 1 }
|
||||
}
|
||||
transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
|
||||
className={cn(
|
||||
cellBase,
|
||||
success
|
||||
? "border-green-500 shadow-lg ring-2 ring-green-500/35"
|
||||
: error
|
||||
? "border-red-500 ring-red-500/30"
|
||||
: filled
|
||||
? "border-[#387E65] shadow-lg ring-2 ring-[#387E65]/25"
|
||||
: "border-gray-500 dark:border-gray-400 shadow-md",
|
||||
!success &&
|
||||
!error &&
|
||||
"focus:border-[#387E65] focus:ring-2 focus:ring-[#387E65]/40 focus:shadow-lg",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autoComplete={index === 0 ? "one-time-code" : "off"}
|
||||
aria-label={`رقم ${index + 1} از ${numInputs}`}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
src/components/auth/AuthOtpStatus.tsx
Normal file
87
src/components/auth/AuthOtpStatus.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
formatOtpCountdown,
|
||||
getOtpRemainingSeconds,
|
||||
markOtpSent,
|
||||
OTP_VALID_SECONDS,
|
||||
} from "@/lib/auth/otpTimer";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AuthOtpStatusProps {
|
||||
mobile: string | null;
|
||||
onResend: () => Promise<void>;
|
||||
resendLoading?: boolean;
|
||||
className?: string;
|
||||
onExpiredChange?: (expired: boolean) => void;
|
||||
}
|
||||
|
||||
export default function AuthOtpStatus({
|
||||
mobile,
|
||||
onResend,
|
||||
resendLoading = false,
|
||||
className,
|
||||
onExpiredChange,
|
||||
}: AuthOtpStatusProps) {
|
||||
const [remaining, setRemaining] = useState(OTP_VALID_SECONDS);
|
||||
const expired = remaining <= 0;
|
||||
|
||||
useEffect(() => {
|
||||
setRemaining(getOtpRemainingSeconds());
|
||||
const interval = window.setInterval(() => {
|
||||
setRemaining(getOtpRemainingSeconds());
|
||||
}, 1000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onExpiredChange?.(expired);
|
||||
}, [expired, onExpiredChange]);
|
||||
|
||||
const handleResend = async () => {
|
||||
await onResend();
|
||||
markOtpSent();
|
||||
setRemaining(OTP_VALID_SECONDS);
|
||||
};
|
||||
|
||||
if (!mobile) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("w-full max-w-sm text-center mb-6 space-y-2", className)}>
|
||||
<p className="text-sm sm:text-base font-medium text-gray-800 dark:text-gray-100 leading-relaxed">
|
||||
کد ۶ رقمی به شماره{" "}
|
||||
<span dir="ltr" className="font-bold text-[#387E65] inline-block">
|
||||
{mobile}
|
||||
</span>{" "}
|
||||
ارسال شد
|
||||
</p>
|
||||
|
||||
{!expired ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
اعتبار کد:{" "}
|
||||
<span
|
||||
dir="ltr"
|
||||
className="font-bold tabular-nums text-[#FF5C00] text-base"
|
||||
>
|
||||
{formatOtpCountdown(remaining)}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-red-600 dark:text-red-400">
|
||||
کد منقضی شده است. لطفاً دوباره درخواست ارسال کد دهید.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResend}
|
||||
disabled={resendLoading}
|
||||
className="text-sm font-bold text-[#667AC1] hover:text-[#0033EA] disabled:opacity-50"
|
||||
>
|
||||
{resendLoading ? "در حال ارسال..." : "ارسال مجدد کد"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
src/components/auth/AuthPageLayout.tsx
Normal file
79
src/components/auth/AuthPageLayout.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import Container from "@/components/elements/Container";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AuthPageLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AuthPageContent({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-1 flex-col items-center justify-center",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type AuthFormFooterProps = {
|
||||
children: React.ReactNode;
|
||||
onSkip?: () => void;
|
||||
skipDisabled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AuthFormFooter({
|
||||
children,
|
||||
onSkip,
|
||||
skipDisabled,
|
||||
className,
|
||||
}: AuthFormFooterProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-auto flex w-full flex-col items-center gap-3 pt-6 pb-[max(2rem,env(safe-area-inset-bottom))]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{onSkip && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip}
|
||||
disabled={skipDisabled}
|
||||
className="text-sm text-gray-500 disabled:opacity-50"
|
||||
>
|
||||
رد کن
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthPageLayout({ children, className }: AuthPageLayoutProps) {
|
||||
return (
|
||||
<Container>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-screen flex-col items-center p-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthPageLayout;
|
||||
62
src/components/auth/AuthPasswordInput.tsx
Normal file
62
src/components/auth/AuthPasswordInput.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import React, { ComponentProps, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AuthPasswordInputProps = Omit<ComponentProps<"input">, "type"> & {
|
||||
wrapperClassName?: string;
|
||||
error?: boolean;
|
||||
success?: boolean;
|
||||
};
|
||||
|
||||
function AuthPasswordInput({
|
||||
style,
|
||||
className,
|
||||
wrapperClassName,
|
||||
error,
|
||||
success,
|
||||
...rest
|
||||
}: AuthPasswordInputProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={cn("relative w-full max-w-[290px]", wrapperClassName)}>
|
||||
<input
|
||||
type={visible ? "text" : "password"}
|
||||
className={cn(
|
||||
"w-full dir-ltr p-3.5 px-12 text-center text-lg font-semibold rounded-2xl",
|
||||
"border-2 bg-white text-gray-900 placeholder:text-gray-400",
|
||||
"dark:bg-white dark:text-gray-900 dark:placeholder:text-gray-500",
|
||||
"shadow-sm transition-[border-color,box-shadow] duration-200",
|
||||
success
|
||||
? "border-green-500 ring-2 ring-green-500/30 focus:border-green-500 focus:ring-green-500/30"
|
||||
: error
|
||||
? "border-red-500 ring-2 ring-red-500/20 focus:border-red-500 focus:ring-red-500/30"
|
||||
: "border-gray-200 dark:border-gray-300 focus:outline-none focus:border-[#387E65] focus:ring-2 focus:ring-[#387E65]/30",
|
||||
className
|
||||
)}
|
||||
style={{ ...style }}
|
||||
{...rest}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible((prev) => !prev)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 m-0 flex size-[22px] items-center justify-center rounded p-0 hover:opacity-70 transition-opacity"
|
||||
aria-label={visible ? "مخفی کردن کلمه عبور" : "نمایش کلمه عبور"}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={visible ? "/images/icons/eye-slash.svg" : "/images/icons/eye-open.svg"}
|
||||
alt=""
|
||||
width={22}
|
||||
height={22}
|
||||
className="block size-[22px] object-contain object-center opacity-80 dark:invert"
|
||||
draggable={false}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthPasswordInput;
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { IProfileData } from "@/types/types";
|
||||
import React, { useEffect, useState } from "react";
|
||||
@@ -6,7 +7,7 @@ import React, { useEffect, useState } from "react";
|
||||
function AuthUserDetails() {
|
||||
const [userDetail, setUserDetail] = useState<IProfileData | null>(null);
|
||||
const { request } = useAxios();
|
||||
// Fetch user data
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await request<{ user: IProfileData }>("GET", "/profile");
|
||||
@@ -15,17 +16,25 @@ function AuthUserDetails() {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-5 flex w-full max-w-md flex-col items-center text-center">
|
||||
<ProfileAvatar
|
||||
src={userDetail?.profile_image}
|
||||
alt={userDetail?.user_name || "profile"}
|
||||
size="md"
|
||||
rounded="xl"
|
||||
className="mb-3"
|
||||
/>
|
||||
<span className="text-lg font-bold">
|
||||
{userDetail?.first_name} {userDetail?.last_name}
|
||||
</span>
|
||||
<p className="text-sm text-gray-500">{userDetail?.user_name}</p>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
30
src/components/auth/UsernameSuggestions.tsx
Normal file
30
src/components/auth/UsernameSuggestions.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
interface UsernameSuggestionsProps {
|
||||
suggestions: string[];
|
||||
onSelect: (username: string) => void;
|
||||
}
|
||||
|
||||
export default function UsernameSuggestions({
|
||||
suggestions,
|
||||
onSelect,
|
||||
}: UsernameSuggestionsProps) {
|
||||
if (!suggestions.length) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap justify-center gap-2 mt-3 w-full max-w-[290px]">
|
||||
{suggestions.map((username) => (
|
||||
<button
|
||||
key={username}
|
||||
type="button"
|
||||
onClick={() => onSelect(username)}
|
||||
className="px-3 py-1.5 text-sm font-medium rounded-full border border-[#387E65] text-[#387E65] bg-white hover:bg-[#387E65]/10 transition-colors dir-ltr"
|
||||
>
|
||||
{username}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import React, { useState } from "react";
|
||||
import Modal from "../../elements/Modal";
|
||||
import Image from "next/image";
|
||||
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
|
||||
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import VerificationBadge from "@/components/main/VerificationBadge";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
|
||||
@@ -76,35 +77,19 @@ function BillboardCommentsModal({
|
||||
{page?.comments?.map((item: Comments) => (
|
||||
<div key={item?._id} className="mb-4">
|
||||
<div className="flex">
|
||||
<Image
|
||||
className="rounded-full ml-2"
|
||||
width={50}
|
||||
height={50}
|
||||
alt={item?.userId?.user_name}
|
||||
src={buildStorageUrl(item?.userId?.profile_image)}
|
||||
priority={true}
|
||||
unoptimized={true}
|
||||
<ProfileAvatar
|
||||
src={item?.userId?.profile_image}
|
||||
alt={item?.userId?.user_name || "کاربر"}
|
||||
size="chat"
|
||||
rounded="full"
|
||||
className="ml-2"
|
||||
/>
|
||||
<div className="text-sm">
|
||||
<div className="flex items-center gap-1 mt-1 font-semibold">
|
||||
<span>{item?.userId?.user_name}</span>
|
||||
{item?.userId?.is_verified === "verified" ? (
|
||||
<Image
|
||||
width={22}
|
||||
height={22}
|
||||
alt={"verify icon"}
|
||||
src={`/images/icons/verify.svg`}
|
||||
/>
|
||||
) : item?.userId?.is_verified === "pending" ? (
|
||||
<Image
|
||||
width={25}
|
||||
height={25}
|
||||
alt={"not-verify icon"}
|
||||
src={`/images/icons/not-verify.svg`}
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{item?.userId?.user_name}
|
||||
<VerificationBadge isVerified={item?.userId?.is_verified} />
|
||||
</span>
|
||||
</div>
|
||||
<p>{item.text}</p>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import MainBillboardCardActions from "./MainBillboardCardActions";
|
||||
import SEO from "@/config/SEO";
|
||||
|
||||
function MainBillboardCard({
|
||||
billboard,
|
||||
@@ -30,8 +29,6 @@ function MainBillboardCard({
|
||||
: "bg-tertiary-light dark:bg-tertiary-dark border-border-secondary-dark dark:border-border-secondary-dark hover:shadow-md transition-shadow"}
|
||||
`}
|
||||
>
|
||||
<SEO type="billboardSingle" data={billboard} />
|
||||
|
||||
{/* نوع تبلیغ */}
|
||||
{billboard?.type === "special" && (
|
||||
<RoundedDiv className="bg-[#FFBDBD] dark:bg-[#794a4a] !border-[#FFBDBD] h-7 w-28 absolute right-4 -top-3">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
"use client";
|
||||
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import { IAdvertisingProfile } from "@/types/types";
|
||||
import Image from "next/image";
|
||||
import React, { useState } from "react";
|
||||
@@ -40,13 +41,12 @@ function AdProfileHead({ profile, id }: AdProfileHeadProps) {
|
||||
</div>
|
||||
</div>
|
||||
{profile_image ? (
|
||||
<Image
|
||||
className="rounded-xl object-cover h-[72px] w-[72px] md:h-[120px] md:w-[120px]"
|
||||
width={120}
|
||||
height={120}
|
||||
<ProfileAvatar
|
||||
src={profile_image}
|
||||
alt={profile?.vitrine_name || "Name"}
|
||||
src={buildStorageUrl(profile_image)}
|
||||
unoptimized={true}
|
||||
size="md"
|
||||
rounded="xl"
|
||||
className="md:h-[120px] md:w-[120px]"
|
||||
/>
|
||||
) : (
|
||||
<div className="border dark:border-border-primary-dark rounded-3xl p-2">
|
||||
@@ -92,13 +92,12 @@ function AdProfileHead({ profile, id }: AdProfileHeadProps) {
|
||||
</div>
|
||||
</div>
|
||||
{profile_image ? (
|
||||
<Image
|
||||
className="rounded-xl object-cover h-[72px] w-[72px] md:h-[120px] md:w-[120px]"
|
||||
width={120}
|
||||
height={120}
|
||||
<ProfileAvatar
|
||||
src={profile_image}
|
||||
alt={profile?.vitrine_name || "Name"}
|
||||
src={buildStorageUrl(profile_image)}
|
||||
unoptimized={true}
|
||||
size="md"
|
||||
rounded="xl"
|
||||
className="md:h-[120px] md:w-[120px]"
|
||||
/>
|
||||
) : (
|
||||
<div className="border dark:border-border-primary-dark rounded-3xl p-2">
|
||||
|
||||
@@ -2,13 +2,7 @@
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
FiCamera,
|
||||
FiFile,
|
||||
FiImage,
|
||||
FiMapPin,
|
||||
FiVideo,
|
||||
} from "react-icons/fi";
|
||||
import ChatBoldIcon, { type ChatBoldIconName } from "./ChatBoldIcon";
|
||||
|
||||
export type AttachmentType = "image" | "video" | "file" | "location" | "camera";
|
||||
|
||||
@@ -19,12 +13,17 @@ interface AttachmentMenuProps {
|
||||
anchorRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
const items: { type: AttachmentType; label: string; icon: React.ReactNode; color: string }[] = [
|
||||
{ type: "camera", label: "دوربین", icon: <FiCamera size={22} />, color: "bg-red-500" },
|
||||
{ type: "image", label: "گالری", icon: <FiImage size={22} />, color: "bg-purple-500" },
|
||||
{ type: "video", label: "ویدیو", icon: <FiVideo size={22} />, color: "bg-blue-500" },
|
||||
{ type: "file", label: "فایل", icon: <FiFile size={22} />, color: "bg-orange-500" },
|
||||
{ type: "location", label: "موقعیت", icon: <FiMapPin size={22} />, color: "bg-green-500" },
|
||||
const items: {
|
||||
type: AttachmentType;
|
||||
label: string;
|
||||
icon: ChatBoldIconName;
|
||||
color: string;
|
||||
}[] = [
|
||||
{ type: "camera", label: "دوربین", icon: "camera", color: "bg-red-500" },
|
||||
{ type: "image", label: "گالری", icon: "gallery", color: "bg-purple-500" },
|
||||
{ type: "video", label: "ویدیو", icon: "video", color: "bg-blue-500" },
|
||||
{ type: "file", label: "فایل", icon: "file", color: "bg-orange-500" },
|
||||
{ type: "location", label: "موقعیت", icon: "location", color: "bg-green-500" },
|
||||
];
|
||||
|
||||
export default function AttachmentMenu({
|
||||
@@ -78,7 +77,7 @@ export default function AttachmentMenu({
|
||||
<span
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-full text-white ${item.color}`}
|
||||
>
|
||||
{item.icon}
|
||||
<ChatBoldIcon name={item.icon} size={22} className="text-white" />
|
||||
</span>
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
</motion.button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { FiCornerUpLeft, FiShare2 } from "react-icons/fi";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
|
||||
interface ChatActionBarProps {
|
||||
onReply: () => void;
|
||||
@@ -35,15 +35,15 @@ export default function ChatActionBar({
|
||||
onClick={onReply}
|
||||
className="chat-action-btn gentle-transition flex flex-1 items-center justify-center gap-2 rounded-full py-3 text-sm font-semibold text-white active:scale-[0.98]"
|
||||
>
|
||||
<FiCornerUpLeft size={18} />
|
||||
<BoldIcon name="direct-right" size={18} tinted className="text-white" />
|
||||
پاسخ
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onForward}
|
||||
className="ig-dm-send-btn gentle-transition flex flex-1 items-center justify-center gap-2 rounded-full py-3 text-sm font-semibold text-white active:scale-[0.98]"
|
||||
className="chat-forward-btn gentle-transition flex flex-1 items-center justify-center gap-2 rounded-full py-3 text-sm font-semibold text-white active:scale-[0.98]"
|
||||
>
|
||||
<FiShare2 size={18} />
|
||||
<BoldIcon name="share" size={18} tinted className="text-white" />
|
||||
فوروارد
|
||||
</button>
|
||||
</div>
|
||||
|
||||
87
src/components/chat/ChatBoldIcon.tsx
Normal file
87
src/components/chat/ChatBoldIcon.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
|
||||
export type ChatBoldIconName =
|
||||
| "timer"
|
||||
| "eye"
|
||||
| "attachment"
|
||||
| "microphone"
|
||||
| "camera"
|
||||
| "gallery"
|
||||
| "video"
|
||||
| "file"
|
||||
| "location"
|
||||
| "send"
|
||||
| "search"
|
||||
| "delete"
|
||||
| "back";
|
||||
|
||||
/** Chat-specific SVG assets (custom uploads) */
|
||||
const CHAT_ICON_SRC: Partial<Record<ChatBoldIconName, string>> = {
|
||||
timer: "/images/icons/chat/timer.svg",
|
||||
search: "/images/icons/chat/search-normal.svg",
|
||||
back: "/images/icons/chat/back.svg",
|
||||
};
|
||||
|
||||
const ICON_NAME: Record<ChatBoldIconName, string> = {
|
||||
timer: "timer",
|
||||
eye: "eye",
|
||||
attachment: "link",
|
||||
microphone: "microphone-2",
|
||||
camera: "camera",
|
||||
gallery: "gallery",
|
||||
video: "video",
|
||||
file: "folder-2",
|
||||
location: "location",
|
||||
send: "send-2",
|
||||
search: "search-normal",
|
||||
delete: "trash",
|
||||
back: "arrow-circle-right",
|
||||
};
|
||||
|
||||
interface ChatBoldIconProps {
|
||||
name: ChatBoldIconName;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function ChatBoldIcon({
|
||||
name,
|
||||
size = 20,
|
||||
className,
|
||||
}: ChatBoldIconProps) {
|
||||
const customSrc = CHAT_ICON_SRC[name];
|
||||
|
||||
if (customSrc) {
|
||||
return (
|
||||
<span
|
||||
role="img"
|
||||
aria-hidden
|
||||
className={cn("inline-block shrink-0 bg-current", className)}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
WebkitMaskImage: `url(${customSrc})`,
|
||||
maskImage: `url(${customSrc})`,
|
||||
WebkitMaskRepeat: "no-repeat",
|
||||
maskRepeat: "no-repeat",
|
||||
WebkitMaskPosition: "center",
|
||||
maskPosition: "center",
|
||||
WebkitMaskSize: "contain",
|
||||
maskSize: "contain",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BoldIcon
|
||||
name={ICON_NAME[name]}
|
||||
size={size}
|
||||
className={className}
|
||||
tinted
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
/** Telegram-style centered date capsule */
|
||||
export default function ChatDateSeparator({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="my-3 flex w-full justify-center">
|
||||
<span className="glass-panel rounded-full px-3 py-1 text-[11px] font-medium text-neutral-600 dark:text-neutral-300">
|
||||
<div className="pointer-events-none my-2 flex w-full justify-center">
|
||||
<span className="rounded-full bg-black/35 px-3 py-1 text-[12px] font-medium text-white/95 shadow-sm backdrop-blur-sm dark:bg-black/45 dark:text-white/90">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
48
src/components/chat/ChatDeleteBar.tsx
Normal file
48
src/components/chat/ChatDeleteBar.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
|
||||
interface ChatDeleteBarProps {
|
||||
selectedCount: number;
|
||||
onDelete: () => void;
|
||||
onCancel: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function ChatDeleteBar({
|
||||
selectedCount,
|
||||
onDelete,
|
||||
onCancel,
|
||||
loading = false,
|
||||
}: ChatDeleteBarProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ y: 24, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 24, opacity: 0 }}
|
||||
transition={{ duration: 0.28, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
className="pointer-events-none fixed bottom-0 left-0 right-0 z-[60] flex justify-center px-3 chat-input-area--thread"
|
||||
>
|
||||
<div className="pointer-events-auto mb-2 flex w-full max-w-lg gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
className="glass-panel gentle-transition flex-1 rounded-full py-3 text-sm font-medium active:scale-[0.98] disabled:opacity-50"
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
disabled={loading || selectedCount === 0}
|
||||
className="gentle-transition flex flex-[2] items-center justify-center gap-2 rounded-full bg-red-500 py-3 text-sm font-semibold text-white active:scale-[0.98] disabled:opacity-50"
|
||||
>
|
||||
<BoldIcon name="trash" size={18} tinted className="text-white" />
|
||||
{selectedCount > 0 ? `حذف (${selectedCount})` : "حذف"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { User } from "@/types/types";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { FiChevronRight } from "react-icons/fi";
|
||||
import { IMAGE_BASE_URL, buildStorageUrl } from "../main/BaseUrl";
|
||||
import ChatBoldIcon from "./ChatBoldIcon";
|
||||
import ProfileAvatar from "../main/ProfileAvatar";
|
||||
import { useRouter } from "next/navigation";
|
||||
import ChatHeaderMenu from "./ChatHeaderMenu";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const chatHeaderCircleBtn = cn(
|
||||
"chat-header-glass gentle-transition flex h-11 w-11 shrink-0 items-center justify-center rounded-full",
|
||||
"text-neutral-800 active:scale-90",
|
||||
"dark:text-neutral-100"
|
||||
);
|
||||
|
||||
export const chatHeaderInfoPill = cn(
|
||||
"chat-header-glass gentle-transition flex h-11 min-w-0 flex-1 items-center gap-2 rounded-full py-[5px] pr-[5px] pl-3",
|
||||
"active:scale-[0.98]"
|
||||
);
|
||||
|
||||
function ChatHeader({
|
||||
user,
|
||||
searchQuery = "",
|
||||
onSearchChange,
|
||||
onBlockChange,
|
||||
onStartDeleteMode,
|
||||
deleteMode = false,
|
||||
}: {
|
||||
user: User | undefined;
|
||||
searchQuery?: string;
|
||||
onSearchChange?: (q: string) => void;
|
||||
onBlockChange?: () => void;
|
||||
onBlockChange?: (blocked: boolean) => void;
|
||||
onStartDeleteMode?: () => void;
|
||||
deleteMode?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
|
||||
const isOnline = user?.last_online === "آنلاین";
|
||||
const displayName =
|
||||
[user?.first_name, user?.last_name].filter(Boolean).join(" ").trim() ||
|
||||
user?.user_name ||
|
||||
"";
|
||||
|
||||
return (
|
||||
<header className="glass-panel gentle-transition sticky top-0 z-40 shrink-0 border-b border-white/20 px-2 py-2 sm:px-3 dark:border-white/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<header className="pointer-events-none fixed left-0 right-0 top-0 z-[100] bg-transparent px-2 pb-2 pt-[max(0.5rem,env(safe-area-inset-top))] sm:px-3">
|
||||
<div
|
||||
dir="rtl"
|
||||
className="pointer-events-auto mx-auto flex max-w-lg items-center gap-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="gentle-transition flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[#0095f6] active:scale-90"
|
||||
className={chatHeaderCircleBtn}
|
||||
aria-label="بازگشت"
|
||||
>
|
||||
<FiChevronRight size={24} />
|
||||
<ChatBoldIcon name="back" size={22} className="text-current" />
|
||||
</button>
|
||||
|
||||
<Link
|
||||
href={`/users/${user?.user_name}`}
|
||||
className="gentle-transition flex min-w-0 flex-1 items-center gap-2 active:opacity-80"
|
||||
dir="ltr"
|
||||
className={chatHeaderInfoPill}
|
||||
>
|
||||
{user?.profile_image && !user?.blocked_you ? (
|
||||
<Image
|
||||
width={40}
|
||||
height={40}
|
||||
alt=""
|
||||
src={buildStorageUrl(user.profile_image)}
|
||||
className="h-10 w-10 shrink-0 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-10 w-10 shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-700" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1 text-right">
|
||||
<span className="block truncate text-[15px] font-semibold">
|
||||
{user?.first_name} {user?.last_name}
|
||||
<span
|
||||
className={cn(
|
||||
"chat-header-status-dot shrink-0",
|
||||
isOnline
|
||||
? "chat-header-status-dot--online"
|
||||
: "chat-header-status-dot--offline"
|
||||
)}
|
||||
title={isOnline ? "آنلاین" : "آفلاین"}
|
||||
aria-hidden
|
||||
/>
|
||||
<div dir="rtl" className="min-w-0 flex-1 text-right">
|
||||
<span className="block truncate text-[15px] font-semibold leading-tight text-neutral-900 dark:text-neutral-50">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="truncate text-xs text-neutral-500">
|
||||
<span className="block truncate text-xs leading-tight text-neutral-500 dark:text-neutral-400">
|
||||
{isOnline ? (
|
||||
<span className="text-[#0095f6]">آنلاین</span>
|
||||
<span className="text-[#22c55e]">آنلاین</span>
|
||||
) : (
|
||||
user?.last_online || user?.user_name
|
||||
user?.last_online || user?.user_name || "آفلاین"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{user?.profile_image && !user?.blocked_you ? (
|
||||
<ProfileAvatar
|
||||
src={user.profile_image}
|
||||
alt={user.user_name || ""}
|
||||
size="xs"
|
||||
rounded="full"
|
||||
className="h-[34px] w-[34px] shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-[34px] w-[34px] shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-600" />
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<ChatHeaderMenu
|
||||
@@ -71,16 +105,24 @@ function ChatHeader({
|
||||
searchOpen={searchOpen}
|
||||
onSearchToggle={setSearchOpen}
|
||||
onBlockChange={onBlockChange}
|
||||
onStartDeleteMode={onStartDeleteMode}
|
||||
buttonClassName={chatHeaderCircleBtn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{deleteMode && (
|
||||
<p className="pointer-events-auto mx-auto mt-2 max-w-lg text-center text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||
پیامهای خود را برای حذف انتخاب کنید
|
||||
</p>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{searchOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
className="pointer-events-auto mx-auto max-w-lg overflow-hidden"
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
@@ -96,7 +138,4 @@ function ChatHeader({
|
||||
);
|
||||
}
|
||||
|
||||
// framer-motion for search expand
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
export default ChatHeader;
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FiMoreVertical, FiSearch } from "react-icons/fi";
|
||||
import { useState, useEffect } from "react";
|
||||
import { FiMoreVertical } from "react-icons/fi";
|
||||
import ChatBoldIcon from "./ChatBoldIcon";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { User } from "@/types/types";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ChatHeaderMenuProps {
|
||||
user: User | undefined;
|
||||
onSearchToggle: (open: boolean) => void;
|
||||
searchOpen: boolean;
|
||||
onBlockChange?: (blocked: boolean) => void;
|
||||
buttonClassName?: string;
|
||||
onStartDeleteMode?: () => void;
|
||||
}
|
||||
|
||||
export default function ChatHeaderMenu({
|
||||
@@ -18,11 +22,17 @@ export default function ChatHeaderMenu({
|
||||
onSearchToggle,
|
||||
searchOpen,
|
||||
onBlockChange,
|
||||
buttonClassName,
|
||||
onStartDeleteMode,
|
||||
}: ChatHeaderMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isBlocked, setIsBlocked] = useState(user?.is_blocked);
|
||||
const [isBlocked, setIsBlocked] = useState(Boolean(user?.is_blocked));
|
||||
const { request } = useAxios();
|
||||
|
||||
useEffect(() => {
|
||||
setIsBlocked(Boolean(user?.is_blocked));
|
||||
}, [user?.is_blocked, user?._id]);
|
||||
|
||||
const block = async () => {
|
||||
await request("POST", "/users/block", { user_to_block: user?._id });
|
||||
setIsBlocked(true);
|
||||
@@ -38,24 +48,28 @@ export default function ChatHeaderMenu({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0">
|
||||
<div className={cn("relative shrink-0", open && "z-[110]")}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="gentle-transition flex h-9 w-9 items-center justify-center rounded-full text-neutral-600 active:scale-90 dark:text-neutral-300"
|
||||
className={cn(buttonClassName, "relative")}
|
||||
aria-label="منو"
|
||||
>
|
||||
<FiMoreVertical size={20} />
|
||||
<FiMoreVertical size={20} strokeWidth={2.5} />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-30" onClick={() => setOpen(false)} />
|
||||
<div
|
||||
className="fixed inset-0 z-[105]"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-hidden
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: -4 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="glass-panel absolute left-0 top-full z-40 mt-1 min-w-[160px] overflow-hidden rounded-xl py-1 shadow-xl"
|
||||
className="glass-panel absolute left-0 top-full z-[110] mt-1 min-w-[160px] overflow-hidden rounded-2xl py-1 shadow-xl"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -65,9 +79,20 @@ export default function ChatHeaderMenu({
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<FiSearch size={16} />
|
||||
<ChatBoldIcon name="search" size={16} />
|
||||
جستجو در چت
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-4 py-2.5 text-right text-sm text-red-500 hover:bg-black/5 dark:hover:bg-white/10"
|
||||
onClick={() => {
|
||||
onStartDeleteMode?.();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<ChatBoldIcon name="delete" size={16} className="text-red-500" />
|
||||
حذف
|
||||
</button>
|
||||
{isBlocked ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { User } from "@/types/types";
|
||||
@@ -15,6 +15,57 @@ import LocationMessageBubble, {
|
||||
} from "./LocationMessageBubble";
|
||||
import { detectChatFileType } from "@/lib/chat/detectFileType";
|
||||
import { formatBubbleTime } from "@/lib/chat/formatMessageTime";
|
||||
import { useLongPress } from "@/hooks/useLongPress";
|
||||
import { useSwipeToReply } from "@/hooks/useSwipeToReply";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import ChatBoldIcon from "./ChatBoldIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
formatCountdown,
|
||||
getRemainingSeconds,
|
||||
} from "@/lib/chat/timedMessages";
|
||||
import { useViewOnceMedia } from "@/hooks/useViewOnceMedia";
|
||||
import ViewOnceMediaBubble from "./ViewOnceMediaBubble";
|
||||
import { isViewOnceMediaType } from "@/lib/chat/viewOnce";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
type BubbleGroupPosition,
|
||||
getBubbleRadius,
|
||||
getMessageRowSpacing,
|
||||
} from "@/lib/chat/messageGrouping";
|
||||
|
||||
function ViewOnceBadge() {
|
||||
return (
|
||||
<span className="absolute left-2 top-2 z-[2] flex h-5 min-w-[20px] items-center justify-center rounded-full bg-[#ff9500] px-1.5 text-[10px] font-bold text-white shadow">
|
||||
۱
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TimedBadge({ expiresAt }: { expiresAt: string }) {
|
||||
const [remaining, setRemaining] = useState(() =>
|
||||
getRemainingSeconds(expiresAt)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const tick = () => setRemaining(getRemainingSeconds(expiresAt));
|
||||
tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [expiresAt]);
|
||||
|
||||
if (remaining == null || remaining <= 0) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
className="chat-timer-badge-glass mr-1"
|
||||
title="پیام زماندار"
|
||||
>
|
||||
<ChatBoldIcon name="timer" size={10} className="inline-block align-middle" />
|
||||
{formatCountdown(remaining)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
_id: string;
|
||||
@@ -22,6 +73,11 @@ export interface ChatMessage {
|
||||
senderId: string;
|
||||
receiverId?: string;
|
||||
createdAt: string;
|
||||
createdAtIso?: string;
|
||||
expiresAt?: string;
|
||||
viewOnce?: boolean;
|
||||
viewOnceLocked?: boolean;
|
||||
viewOnceExpired?: boolean;
|
||||
file?: string;
|
||||
fileType?: "image" | "video" | "file" | "voice" | "location";
|
||||
status?: "pending" | "sent" | "seen";
|
||||
@@ -40,10 +96,24 @@ export interface ChatMessage {
|
||||
interface ChatMessageCardProps {
|
||||
message: ChatMessage;
|
||||
userDetail: User | undefined;
|
||||
groupPosition?: BubbleGroupPosition;
|
||||
isGroupedWithPrev?: boolean;
|
||||
showAvatar?: boolean;
|
||||
partnerAvatar?: string;
|
||||
isNew?: boolean;
|
||||
selected?: boolean;
|
||||
onBubbleClick?: (message: ChatMessage) => void;
|
||||
onSwipeReply?: (message: ChatMessage) => void;
|
||||
deleteMode?: boolean;
|
||||
selectedForDelete?: boolean;
|
||||
onToggleDeleteSelect?: (message: ChatMessage) => void;
|
||||
onScrollToReply?: (messageId: string) => void;
|
||||
highlighted?: boolean;
|
||||
registerRef?: (id: string, el: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
const LONG_PRESS_MS = 2000;
|
||||
|
||||
function parseForwarded(content: string): ChatMessage["forwardedFrom"] | null {
|
||||
try {
|
||||
const line = content.split("\n")[0];
|
||||
@@ -54,21 +124,103 @@ function parseForwarded(content: string): ChatMessage["forwardedFrom"] | null {
|
||||
}
|
||||
}
|
||||
|
||||
function isEmojiOnly(text: string): boolean {
|
||||
const stripped = text.trim();
|
||||
if (!stripped) return false;
|
||||
return !/[\p{L}\p{N}]/u.test(stripped);
|
||||
}
|
||||
|
||||
const ChatMessageCard = ({
|
||||
message,
|
||||
userDetail,
|
||||
groupPosition = "single",
|
||||
isGroupedWithPrev = false,
|
||||
showAvatar = false,
|
||||
partnerAvatar,
|
||||
isNew = false,
|
||||
selected = false,
|
||||
onBubbleClick,
|
||||
onSwipeReply,
|
||||
deleteMode = false,
|
||||
selectedForDelete = false,
|
||||
onToggleDeleteSelect,
|
||||
onScrollToReply,
|
||||
highlighted = false,
|
||||
registerRef,
|
||||
}: ChatMessageCardProps) => {
|
||||
const isSender = !!(userDetail && message.senderId === userDetail._id);
|
||||
const currentUserId = String(userDetail?._id ?? "");
|
||||
const isSender =
|
||||
!!currentUserId && String(message.senderId) === currentUserId;
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const [viewOncePreview, setViewOncePreview] = useState<{
|
||||
url: string;
|
||||
type: "image" | "video";
|
||||
} | null>(null);
|
||||
const [viewOnceVoiceUrl, setViewOnceVoiceUrl] = useState<string | null>(null);
|
||||
const { openViewOnce, completeViewOnce } = useViewOnceMedia();
|
||||
const canSelectForDelete = deleteMode && isSender && !message._id.startsWith("temp-");
|
||||
const swipeEnabled = !deleteMode && !!onSwipeReply;
|
||||
|
||||
const handleOpenActions = useCallback(() => {
|
||||
onBubbleClick?.(message);
|
||||
}, [message, onBubbleClick]);
|
||||
|
||||
const handleSwipeReply = useCallback(() => {
|
||||
onSwipeReply?.(message);
|
||||
}, [message, onSwipeReply]);
|
||||
|
||||
const { pressing, shouldBlockClick, cancelPress, handlers: longPressHandlers } =
|
||||
useLongPress(handleOpenActions, { delay: LONG_PRESS_MS });
|
||||
|
||||
const { dragProps, replyOpacity, replyScale } = useSwipeToReply(
|
||||
handleSwipeReply,
|
||||
swipeEnabled
|
||||
);
|
||||
|
||||
const swipeDragStart =
|
||||
swipeEnabled && "onDragStart" in dragProps ? dragProps.onDragStart : undefined;
|
||||
const restDragProps = swipeEnabled
|
||||
? (() => {
|
||||
const { onDragStart: _, ...rest } = dragProps;
|
||||
return rest;
|
||||
})()
|
||||
: {};
|
||||
|
||||
const toggleDeleteSelection = () => {
|
||||
if (canSelectForDelete) onToggleDeleteSelect?.(message);
|
||||
};
|
||||
|
||||
const openPreview = (url: string) => {
|
||||
if (deleteMode) {
|
||||
toggleDeleteSelection();
|
||||
return;
|
||||
}
|
||||
if (shouldBlockClick()) return;
|
||||
setPreview(url);
|
||||
};
|
||||
|
||||
const bubbleHandlers = deleteMode
|
||||
? { onClick: toggleDeleteSelection }
|
||||
: longPressHandlers;
|
||||
|
||||
const handleDragStart = () => {
|
||||
cancelPress();
|
||||
swipeDragStart?.();
|
||||
};
|
||||
|
||||
const forwarded =
|
||||
message.forwardedFrom || parseForwarded(message.content || "");
|
||||
const locationData = parseLocationContent(message.content);
|
||||
const hasLocation = message.fileType === "location" || !!locationData;
|
||||
|
||||
if (!message.content && !message.file && !hasLocation) return null;
|
||||
if (
|
||||
!message.content &&
|
||||
!message.file &&
|
||||
!hasLocation &&
|
||||
!(message.viewOnce && isViewOnceMediaType(message.fileType))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileUrl = message.file?.startsWith("blob:")
|
||||
? message.file
|
||||
@@ -93,14 +245,51 @@ const ChatMessageCard = ({
|
||||
fileType !== "location" &&
|
||||
(!message.file || (fileType === "image" && textContent));
|
||||
|
||||
const tailClass = isSender ? "bubble-tail-out" : "bubble-tail-in";
|
||||
const emojiOnly = showTextBubble && !message.file && isEmojiOnly(textContent);
|
||||
const borderRadius = getBubbleRadius(isSender, groupPosition);
|
||||
const rowSpacing = getMessageRowSpacing(groupPosition, isGroupedWithPrev);
|
||||
const timeLabel = formatBubbleTime(message.createdAt);
|
||||
const isPending = message.status === "pending";
|
||||
|
||||
const isViewOnce =
|
||||
Boolean(message.viewOnce) && isViewOnceMediaType(fileType);
|
||||
const showViewOnceLocked =
|
||||
isViewOnce &&
|
||||
!isSender &&
|
||||
(message.viewOnceLocked || message.viewOnceExpired) &&
|
||||
!viewOnceVoiceUrl;
|
||||
|
||||
const finishViewOnce = useCallback(async () => {
|
||||
setViewOncePreview(null);
|
||||
setViewOnceVoiceUrl(null);
|
||||
if (message._id.startsWith("temp")) return;
|
||||
try {
|
||||
await completeViewOnce(message._id);
|
||||
} catch {
|
||||
toast.error("حذف پیام یکبارمصرف ناموفق بود");
|
||||
}
|
||||
}, [completeViewOnce, message._id]);
|
||||
|
||||
const handleViewOnceOpen = useCallback(async () => {
|
||||
if (message._id.startsWith("temp")) return;
|
||||
try {
|
||||
const { url, fileType: openedType } = await openViewOnce(message._id);
|
||||
if (openedType === "voice") {
|
||||
setViewOnceVoiceUrl(url);
|
||||
} else if (openedType === "video") {
|
||||
setViewOncePreview({ url, type: "video" });
|
||||
} else {
|
||||
setViewOncePreview({ url, type: "image" });
|
||||
}
|
||||
} catch {
|
||||
toast.error("باز کردن پیام یکبارمصرف ممکن نیست");
|
||||
}
|
||||
}, [message._id, openViewOnce]);
|
||||
|
||||
const TimeInline = () => (
|
||||
<span
|
||||
className={`message-time-inline ${isSender ? "text-white/70" : "text-neutral-400"}`}
|
||||
>
|
||||
<span className="message-time-inline">
|
||||
{timeLabel}
|
||||
{message.expiresAt && <TimedBadge expiresAt={message.expiresAt} />}
|
||||
{isSender && (
|
||||
<span className="mr-1 inline-flex align-middle">
|
||||
<ReadReceipt status={message.status} />
|
||||
@@ -111,9 +300,13 @@ const ChatMessageCard = ({
|
||||
|
||||
const TimeBelow = () => (
|
||||
<span
|
||||
className={`message-time-below mt-0.5 block text-[10px] tabular-nums text-neutral-400 ${isSender ? "text-left" : "text-right"}`}
|
||||
className={cn(
|
||||
"message-time-below mt-0.5 block text-[10px] tabular-nums",
|
||||
isSender ? "text-left" : "text-right"
|
||||
)}
|
||||
>
|
||||
{timeLabel}
|
||||
{message.expiresAt && <TimedBadge expiresAt={message.expiresAt} />}
|
||||
{isSender && (
|
||||
<span className="mr-1 inline-flex">
|
||||
<ReadReceipt status={message.status} />
|
||||
@@ -122,22 +315,63 @@ const ChatMessageCard = ({
|
||||
</span>
|
||||
);
|
||||
|
||||
const handleBubbleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onBubbleClick?.(message);
|
||||
const bubbleStyle = {
|
||||
borderRadius,
|
||||
...(isSender
|
||||
? {}
|
||||
: {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`message-enter relative my-0.5 flex w-full ${isSender ? "justify-end" : "justify-start"}`}
|
||||
ref={(el) => registerRef?.(message._id, el)}
|
||||
data-message-id={message._id}
|
||||
initial={
|
||||
isNew
|
||||
? { opacity: 0, y: 8, scale: 0.98 }
|
||||
: false
|
||||
}
|
||||
animate={{
|
||||
opacity: isPending ? 0.82 : 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
}}
|
||||
transition={{
|
||||
duration: 0.24,
|
||||
ease: [0.25, 0.46, 0.45, 0.94],
|
||||
}}
|
||||
className={cn(
|
||||
"relative flex w-full gap-2",
|
||||
isSender ? "justify-end pl-10" : "justify-start pr-10",
|
||||
rowSpacing,
|
||||
deleteMode && !isSender && "opacity-60",
|
||||
selected && "relative z-[2]",
|
||||
highlighted && "chat-message-highlight relative z-[2]"
|
||||
)}
|
||||
dir="rtl"
|
||||
>
|
||||
{canSelectForDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleDeleteSelection}
|
||||
className={cn(
|
||||
"gentle-transition order-first flex h-6 w-6 shrink-0 items-center justify-center self-end rounded-full border-2",
|
||||
selectedForDelete
|
||||
? "border-[#0095f6] bg-[#0095f6] text-white"
|
||||
: "border-neutral-400 bg-transparent"
|
||||
)}
|
||||
aria-label={selectedForDelete ? "لغو انتخاب" : "انتخاب پیام"}
|
||||
>
|
||||
{selectedForDelete && <BoldIcon name="tick-circle" size={14} tinted className="text-white" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`relative flex w-full max-w-[min(88%,340px)] flex-col sm:max-w-[85%] ${isSender ? "items-end" : "items-start"}`}
|
||||
className={cn(
|
||||
"relative flex max-w-[min(78%,280px)] flex-col sm:max-w-[min(75%,320px)]",
|
||||
isSender ? "items-end" : "items-start"
|
||||
)}
|
||||
>
|
||||
{forwarded && (
|
||||
<Link
|
||||
@@ -150,56 +384,157 @@ const ChatMessageCard = ({
|
||||
)}
|
||||
|
||||
{message.replyTo && (
|
||||
<div className="mb-1 max-w-full rounded-lg border-r-2 border-[#0095f6] bg-black/5 px-2 py-1 text-[11px] opacity-90 dark:bg-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (message.replyTo?._id) onScrollToReply?.(message.replyTo._id);
|
||||
}}
|
||||
className="gentle-transition mb-1 max-w-full cursor-pointer rounded-lg border-r-2 border-[#0095f6] bg-black/5 px-2 py-1 text-right text-[11px] opacity-90 hover:bg-[#0095f6]/10 active:scale-[0.98] dark:bg-white/10"
|
||||
>
|
||||
<span className="font-semibold text-[#0095f6]">
|
||||
{message.replyTo.senderName}
|
||||
</span>
|
||||
<p className="truncate">{message.replyTo.content}</p>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBubbleClick}
|
||||
className={`gentle-transition relative text-right outline-none ${tailClass} ${
|
||||
selected ? "ring-2 ring-[#0095f6]/50 rounded-2xl" : ""
|
||||
}`}
|
||||
>
|
||||
{fileType === "image" && fileUrl && (
|
||||
<div
|
||||
className="block overflow-hidden rounded-2xl"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreview(fileUrl);
|
||||
}}
|
||||
<div className="relative w-full overflow-hidden">
|
||||
{swipeEnabled && (
|
||||
<motion.div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-y-0 flex w-11 items-center justify-center text-[#0095f6]",
|
||||
isSender ? "right-0" : "right-0"
|
||||
)}
|
||||
style={{ opacity: replyOpacity, scale: replyScale }}
|
||||
>
|
||||
<div className="relative aspect-[3/4] w-44 sm:w-52">
|
||||
<Image fill src={fileUrl} alt="" className="object-cover" sizes="208px" />
|
||||
<BoldIcon name="direct-right" size={20} tinted className="text-current" />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
{...(swipeEnabled ? restDragProps : {})}
|
||||
onDragStart={swipeEnabled ? handleDragStart : undefined}
|
||||
className="relative z-[1] w-full"
|
||||
>
|
||||
<div
|
||||
data-chat-bubble
|
||||
{...bubbleHandlers}
|
||||
className={cn(
|
||||
"relative select-none text-right outline-none touch-manipulation",
|
||||
canSelectForDelete && "cursor-pointer",
|
||||
(selected || selectedForDelete) &&
|
||||
"rounded-2xl ring-2 ring-[#0095f6]/50",
|
||||
!deleteMode && pressing && "scale-[0.98] opacity-90"
|
||||
)}
|
||||
>
|
||||
{fileType === "image" &&
|
||||
(fileUrl || (isViewOnce && !isSender)) &&
|
||||
(showViewOnceLocked ? (
|
||||
<div style={{ borderRadius }}>
|
||||
<ViewOnceMediaBubble
|
||||
fileType="image"
|
||||
isOutgoing={isSender}
|
||||
expired={Boolean(message.viewOnceExpired)}
|
||||
borderRadius={borderRadius}
|
||||
onOpen={handleViewOnceOpen}
|
||||
/>
|
||||
<TimeBelow />
|
||||
</div>
|
||||
<TimeBelow />
|
||||
</div>
|
||||
)}
|
||||
) : fileUrl ? (
|
||||
<div
|
||||
className="relative block overflow-hidden"
|
||||
style={{ borderRadius }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openPreview(fileUrl);
|
||||
}}
|
||||
>
|
||||
{isViewOnce && isSender && <ViewOnceBadge />}
|
||||
<div className="relative aspect-[3/4] w-44 sm:w-52">
|
||||
<Image
|
||||
fill
|
||||
src={fileUrl}
|
||||
alt=""
|
||||
className="object-cover"
|
||||
sizes="208px"
|
||||
/>
|
||||
</div>
|
||||
<TimeBelow />
|
||||
</div>
|
||||
) : null)}
|
||||
|
||||
{fileType === "video" && fileUrl && (
|
||||
<div>
|
||||
<VideoMessageBubble
|
||||
src={fileUrl}
|
||||
isOutgoing={isSender}
|
||||
onOpen={() => setPreview(fileUrl)}
|
||||
/>
|
||||
<TimeBelow />
|
||||
</div>
|
||||
)}
|
||||
{fileType === "video" &&
|
||||
(fileUrl || (isViewOnce && !isSender)) &&
|
||||
(showViewOnceLocked ? (
|
||||
<div style={{ borderRadius }}>
|
||||
<ViewOnceMediaBubble
|
||||
fileType="video"
|
||||
isOutgoing={isSender}
|
||||
expired={Boolean(message.viewOnceExpired)}
|
||||
borderRadius={borderRadius}
|
||||
onOpen={handleViewOnceOpen}
|
||||
/>
|
||||
<TimeBelow />
|
||||
</div>
|
||||
) : fileUrl ? (
|
||||
<div
|
||||
className="relative cursor-pointer overflow-hidden"
|
||||
style={{ borderRadius }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openPreview(fileUrl);
|
||||
}}
|
||||
>
|
||||
{isViewOnce && isSender && <ViewOnceBadge />}
|
||||
<VideoMessageBubble src={fileUrl} isOutgoing={isSender} />
|
||||
<TimeBelow />
|
||||
</div>
|
||||
) : null)}
|
||||
|
||||
{fileType === "voice" && fileUrl && (
|
||||
<div className={isSender ? "chat-bubble-out rounded-2xl p-1" : "chat-bubble-in rounded-2xl p-1"}>
|
||||
<VoiceMessagePlayer src={fileUrl} isOutgoing={isSender} />
|
||||
<TimeBelow />
|
||||
</div>
|
||||
)}
|
||||
{fileType === "voice" &&
|
||||
(fileUrl || viewOnceVoiceUrl || (isViewOnce && !isSender)) &&
|
||||
(showViewOnceLocked ? (
|
||||
<div style={bubbleStyle}>
|
||||
<ViewOnceMediaBubble
|
||||
fileType="voice"
|
||||
isOutgoing={isSender}
|
||||
expired={Boolean(message.viewOnceExpired)}
|
||||
borderRadius={borderRadius}
|
||||
onOpen={handleViewOnceOpen}
|
||||
/>
|
||||
<TimeBelow />
|
||||
</div>
|
||||
) : viewOnceVoiceUrl ? (
|
||||
<div
|
||||
className={isSender ? "chat-bubble-out p-1" : "chat-bubble-in p-1"}
|
||||
style={bubbleStyle}
|
||||
>
|
||||
<VoiceMessagePlayer
|
||||
src={viewOnceVoiceUrl}
|
||||
isOutgoing={isSender}
|
||||
autoPlay
|
||||
singlePlay
|
||||
onPlaybackComplete={finishViewOnce}
|
||||
/>
|
||||
<TimeBelow />
|
||||
</div>
|
||||
) : fileUrl ? (
|
||||
<div
|
||||
className={cn(
|
||||
isSender ? "chat-bubble-out p-1" : "chat-bubble-in p-1",
|
||||
isViewOnce && isSender && "relative"
|
||||
)}
|
||||
style={bubbleStyle}
|
||||
>
|
||||
{isViewOnce && isSender && <ViewOnceBadge />}
|
||||
<VoiceMessagePlayer src={fileUrl} isOutgoing={isSender} />
|
||||
<TimeBelow />
|
||||
</div>
|
||||
) : null)}
|
||||
|
||||
{fileType === "file" && fileUrl && (
|
||||
<div>
|
||||
<div style={bubbleStyle} className="overflow-hidden">
|
||||
<FileMessageBubble
|
||||
url={fileUrl}
|
||||
fileName={textContent || "فایل"}
|
||||
@@ -210,25 +545,85 @@ const ChatMessageCard = ({
|
||||
)}
|
||||
|
||||
{fileType === "location" && locationData && (
|
||||
<div>
|
||||
<div style={bubbleStyle} className="overflow-hidden">
|
||||
<LocationMessageBubble location={locationData} isOutgoing={isSender} />
|
||||
<TimeBelow />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showTextBubble && !message.file && (
|
||||
{showTextBubble && !message.file && emojiOnly && (
|
||||
<p className="px-1 py-0.5 text-[2.5rem] leading-none">{textContent}</p>
|
||||
)}
|
||||
|
||||
{showTextBubble && !message.file && !emojiOnly && (
|
||||
<div
|
||||
className={`chat-text-bubble ${isSender ? "chat-bubble-out" : "chat-bubble-in"} px-3 py-2 text-[15px]`}
|
||||
className={cn(
|
||||
"chat-text-bubble px-3 py-2 text-[15px] leading-snug",
|
||||
isSender ? "chat-bubble-out" : "chat-bubble-in"
|
||||
)}
|
||||
style={bubbleStyle}
|
||||
>
|
||||
<p className="chat-message-text">{textContent}</p>
|
||||
<TimeInline />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isSender && (
|
||||
<div className="flex w-7 shrink-0 items-end self-end">
|
||||
{showAvatar && partnerAvatar ? (
|
||||
<div className="relative h-7 w-7 overflow-hidden rounded-full bg-neutral-700">
|
||||
<Image
|
||||
src={buildStorageUrl(partnerAvatar)}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="28px"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-7 w-7" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
{viewOncePreview && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/95"
|
||||
onClick={() => void finishViewOnce()}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="max-h-[92vh] max-w-[95vw] p-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{viewOncePreview.type === "video" ? (
|
||||
<video
|
||||
src={viewOncePreview.url}
|
||||
controls
|
||||
autoPlay
|
||||
className="max-h-[88vh] rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={viewOncePreview.url}
|
||||
alt=""
|
||||
className="max-h-[88vh] object-contain"
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
{preview && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useRef, useMemo } from "react";
|
||||
import { BASE_URL, SOCKET_URL } from "../main/BaseUrl";
|
||||
import axios from "axios";
|
||||
import React, { useEffect, useState, useRef, useMemo, useCallback } from "react";
|
||||
import { SOCKET_URL } from "../main/BaseUrl";
|
||||
import { apiClient } from "@/hooks/useAxios";
|
||||
import { chatThreadQueryKey } from "@/lib/chat/queryKeys";
|
||||
import {
|
||||
appendMessageToThreadCache,
|
||||
normalizeThreadId,
|
||||
} from "@/lib/chat/threadCache";
|
||||
import ChatMessageCard, { ChatMessage } from "./ChatMessageCard";
|
||||
import ChatDateSeparator from "./ChatDateSeparator";
|
||||
import { groupMessagesByDate } from "@/lib/chat/groupMessagesByDate";
|
||||
import { dedupeChatMessages } from "@/lib/chat/dedupeMessages";
|
||||
import { formatBubbleTime } from "@/lib/chat/formatMessageTime";
|
||||
import { io } from "socket.io-client";
|
||||
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { User } from "@/types/types";
|
||||
import { ChatMessagesSkeleton } from "@/components/ui/ChatSkeletons";
|
||||
import TypingIndicator from "./TypingIndicator";
|
||||
import IOSSpinner from "@/components/ui/IOSSpinner";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { getStoredUserId } from "@/lib/auth/session";
|
||||
import { useTimedMessageExpiry } from "@/hooks/useTimedMessageExpiry";
|
||||
import {
|
||||
getBubbleGroupPosition,
|
||||
sameVisualGroup,
|
||||
} from "@/lib/chat/messageGrouping";
|
||||
import { cn } from "@/lib/utils";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
function belongsToThread(
|
||||
msg: ChatMessage,
|
||||
@@ -31,77 +43,118 @@ function belongsToThread(
|
||||
interface ChatMessageListProps {
|
||||
userDetail: User | undefined;
|
||||
userTwoDetail: User | undefined;
|
||||
chatPartnerId: string;
|
||||
pendingMessages: ChatMessage[];
|
||||
isTyping?: boolean;
|
||||
onTypingChange?: (typing: boolean) => void;
|
||||
searchQuery?: string;
|
||||
selectedMessageId?: string;
|
||||
onBubbleClick?: (message: ChatMessage) => void;
|
||||
onSwipeReply?: (message: ChatMessage) => void;
|
||||
actionMode?: boolean;
|
||||
onDismissAction?: () => void;
|
||||
deleteMode?: boolean;
|
||||
selectedDeleteIds?: string[];
|
||||
onToggleDeleteSelect?: (message: ChatMessage) => void;
|
||||
getStableKey?: (id: string) => string;
|
||||
onExpirePending?: (ids: string[]) => void;
|
||||
/** فضای اضافه وقتی بنر پاسخ/زماندار بالای input است */
|
||||
extraBottomRem?: number;
|
||||
}
|
||||
|
||||
const ChatMessageList = ({
|
||||
userDetail,
|
||||
userTwoDetail,
|
||||
chatPartnerId,
|
||||
pendingMessages,
|
||||
isTyping = false,
|
||||
onTypingChange,
|
||||
searchQuery = "",
|
||||
selectedMessageId,
|
||||
onBubbleClick,
|
||||
onSwipeReply,
|
||||
actionMode = false,
|
||||
onDismissAction,
|
||||
deleteMode = false,
|
||||
selectedDeleteIds = [],
|
||||
onToggleDeleteSelect,
|
||||
getStableKey = (id) => id,
|
||||
onExpirePending,
|
||||
extraBottomRem = 0,
|
||||
}: ChatMessageListProps) => {
|
||||
const [receiverId, setReceiverId] = useState("");
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const prevScrollHeight = useRef(0);
|
||||
const prevPendingCountRef = useRef(0);
|
||||
const [toEnd, setToEnd] = useState(true);
|
||||
const queryClient = useQueryClient();
|
||||
const hasInitializedRef = useRef(false);
|
||||
const seenMessageKeysRef = useRef(new Set<string>());
|
||||
const prevMessageCountRef = useRef(0);
|
||||
const messageRefs = useRef(new Map<string, HTMLDivElement>());
|
||||
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [highlightedMessageId, setHighlightedMessageId] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setReceiverId(userTwoDetail?._id || "");
|
||||
}, [userDetail, userTwoDetail]);
|
||||
const senderId = normalizeThreadId(userDetail?._id ?? getStoredUserId());
|
||||
const receiverId = normalizeThreadId(chatPartnerId);
|
||||
const threadReady = Boolean(senderId && receiverId);
|
||||
const queryKey = chatThreadQueryKey(senderId, receiverId);
|
||||
|
||||
const fetchMessages = async ({ pageParam = 1 }) => {
|
||||
if (!userDetail?._id || !receiverId)
|
||||
return { messages: [], nextPage: undefined };
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useInfiniteQuery({
|
||||
queryKey,
|
||||
queryFn: async ({ pageParam = 1, queryKey: key }) => {
|
||||
const [, sid, rid] = key as ReturnType<typeof chatThreadQueryKey>;
|
||||
if (!sid || !rid) {
|
||||
return { messages: [], nextPage: undefined };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${BASE_URL}/chat?senderId=${userDetail?._id}&receiverId=${receiverId}&page=${pageParam}&limit=50&_t=${Date.now()}`
|
||||
);
|
||||
const msgs = (response.data.messages || []).map((m: ChatMessage) => ({
|
||||
...m,
|
||||
createdAt: formatBubbleTime(m.createdAt),
|
||||
}));
|
||||
return {
|
||||
messages: msgs,
|
||||
nextPage:
|
||||
pageParam < response.data.totalPages ? pageParam + 1 : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching messages:", error);
|
||||
return { messages: [], nextPage: undefined };
|
||||
}
|
||||
};
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["messages", userDetail?._id, receiverId],
|
||||
queryFn: fetchMessages,
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/chat?senderId=${sid}&receiverId=${rid}&page=${pageParam}&limit=50`
|
||||
);
|
||||
const msgs = (response.data.messages || []) as ChatMessage[];
|
||||
return {
|
||||
messages: msgs,
|
||||
nextPage:
|
||||
pageParam < response.data.totalPages ? pageParam + 1 : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching messages:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
enabled: threadReady,
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage) => lastPage.nextPage,
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
staleTime: 60_000,
|
||||
gcTime: 10 * 60 * 1000,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (toEnd && scrollRef.current) {
|
||||
setTimeout(() => {
|
||||
scrollRef.current?.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
}, [data, toEnd, pendingMessages, isTyping]);
|
||||
const hasCachedMessages = Boolean(
|
||||
data?.pages?.some((page) => page.messages.length > 0)
|
||||
);
|
||||
const showSkeleton =
|
||||
!threadReady ||
|
||||
(isLoading && !hasCachedMessages && pendingMessages.length === 0);
|
||||
|
||||
const scrollToBottom = useCallback((behavior: ScrollBehavior = "auto") => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const run = () => {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior });
|
||||
};
|
||||
requestAnimationFrame(() => requestAnimationFrame(run));
|
||||
}, []);
|
||||
|
||||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
if (!scrollRef.current) return;
|
||||
@@ -127,39 +180,22 @@ const ChatMessageList = ({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!userDetail?._id || !receiverId) return;
|
||||
if (!threadReady) return;
|
||||
const socket = io(SOCKET_URL);
|
||||
|
||||
socket.emit("joinChat", { userId: userDetail._id, receiverId });
|
||||
socket.emit("joinChat", { userId: senderId, receiverId });
|
||||
|
||||
const belongs = (msg: ChatMessage) =>
|
||||
belongsToThread(msg, userDetail._id, receiverId) ||
|
||||
(msg.senderId === userDetail._id && msg.receiverId === receiverId) ||
|
||||
(msg.senderId === receiverId && msg.receiverId === userDetail._id);
|
||||
belongsToThread(msg, senderId, receiverId) ||
|
||||
(msg.senderId === senderId && msg.receiverId === receiverId) ||
|
||||
(msg.senderId === receiverId && msg.receiverId === senderId);
|
||||
|
||||
socket.on("newMessage", (message: ChatMessage) => {
|
||||
if (!belongs(message)) return;
|
||||
|
||||
const formatted = {
|
||||
...message,
|
||||
createdAt: formatBubbleTime(message.createdAt),
|
||||
};
|
||||
|
||||
queryClient.setQueryData(
|
||||
["messages", userDetail._id, receiverId],
|
||||
(oldData: { pages: { messages: ChatMessage[] }[] } | undefined) => {
|
||||
if (!oldData?.pages?.length) return oldData;
|
||||
const exists = oldData.pages.some((p) =>
|
||||
p.messages.some((m) => m._id === formatted._id)
|
||||
);
|
||||
if (exists) return oldData;
|
||||
const newPages = [...oldData.pages];
|
||||
newPages[0] = {
|
||||
...newPages[0],
|
||||
messages: [formatted, ...newPages[0].messages],
|
||||
};
|
||||
return { ...oldData, pages: newPages };
|
||||
}
|
||||
chatThreadQueryKey(senderId, receiverId),
|
||||
(oldData) => appendMessageToThreadCache(oldData, message)
|
||||
);
|
||||
setToEnd(true);
|
||||
|
||||
@@ -167,7 +203,7 @@ const ChatMessageList = ({
|
||||
socket.emit("messageSeen", {
|
||||
messageId: message._id,
|
||||
senderId: message.senderId,
|
||||
receiverId: userDetail._id,
|
||||
receiverId: senderId,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -176,7 +212,7 @@ const ChatMessageList = ({
|
||||
"messageStatusUpdate",
|
||||
({ messageId, status }: { messageId: string; status: ChatMessage["status"] }) => {
|
||||
queryClient.setQueryData(
|
||||
["messages", userDetail._id, receiverId],
|
||||
chatThreadQueryKey(senderId, receiverId),
|
||||
(oldData: { pages: { messages: ChatMessage[] }[] } | undefined) => {
|
||||
if (!oldData) return oldData;
|
||||
return {
|
||||
@@ -200,17 +236,39 @@ const ChatMessageList = ({
|
||||
}
|
||||
});
|
||||
|
||||
socket.on(
|
||||
"messagesDeleted",
|
||||
({ messageIds }: { messageIds: string[] }) => {
|
||||
if (!messageIds?.length) return;
|
||||
const idSet = new Set(messageIds);
|
||||
queryClient.setQueryData(
|
||||
chatThreadQueryKey(senderId, receiverId),
|
||||
(oldData: { pages: { messages: ChatMessage[] }[] } | undefined) => {
|
||||
if (!oldData) return oldData;
|
||||
return {
|
||||
...oldData,
|
||||
pages: oldData.pages.map((page) => ({
|
||||
...page,
|
||||
messages: page.messages.filter((m) => !idSet.has(m._id)),
|
||||
})),
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
socket.off("newMessage");
|
||||
socket.off("messageStatusUpdate");
|
||||
socket.off("messagesDeleted");
|
||||
socket.off("userTyping");
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [userDetail, receiverId, queryClient, onTypingChange]);
|
||||
}, [threadReady, senderId, receiverId, queryClient, onTypingChange]);
|
||||
|
||||
const allMessages = useMemo(() => {
|
||||
const server =
|
||||
data?.pages.flatMap((page) => page.messages).reverse() || [];
|
||||
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 merged = dedupeChatMessages([...server, ...pendingOnly]);
|
||||
@@ -226,41 +284,251 @@ const ChatMessageList = ({
|
||||
[allMessages]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toEnd) return;
|
||||
scrollToBottom("auto");
|
||||
}, [
|
||||
data,
|
||||
toEnd,
|
||||
pendingMessages,
|
||||
isTyping,
|
||||
allMessages.length,
|
||||
extraBottomRem,
|
||||
scrollToBottom,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingMessages.length > prevPendingCountRef.current) {
|
||||
setToEnd(true);
|
||||
scrollToBottom("smooth");
|
||||
const retry = setTimeout(() => scrollToBottom("auto"), 180);
|
||||
prevPendingCountRef.current = pendingMessages.length;
|
||||
return () => clearTimeout(retry);
|
||||
}
|
||||
prevPendingCountRef.current = pendingMessages.length;
|
||||
}, [pendingMessages, scrollToBottom]);
|
||||
|
||||
useTimedMessageExpiry(allMessages, queryKey, onExpirePending);
|
||||
|
||||
useEffect(() => {
|
||||
if (allMessages.length === 0) return;
|
||||
if (!hasInitializedRef.current) {
|
||||
allMessages.forEach((m) =>
|
||||
seenMessageKeysRef.current.add(getStableKey(m._id))
|
||||
);
|
||||
hasInitializedRef.current = true;
|
||||
prevMessageCountRef.current = allMessages.length;
|
||||
}
|
||||
}, [allMessages, getStableKey]);
|
||||
|
||||
const messageIndexMap = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
allMessages.forEach((m, i) => map.set(m._id, i));
|
||||
return map;
|
||||
}, [allMessages]);
|
||||
|
||||
const registerMessageRef = useCallback(
|
||||
(id: string, el: HTMLDivElement | null) => {
|
||||
if (el) messageRefs.current.set(id, el);
|
||||
else messageRefs.current.delete(id);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const scrollToMessage = useCallback(
|
||||
async (targetId: string) => {
|
||||
if (!targetId) return;
|
||||
setToEnd(false);
|
||||
|
||||
const scrollToTarget = (): boolean => {
|
||||
const container = scrollRef.current;
|
||||
const el = messageRefs.current.get(targetId);
|
||||
if (!container || !el) return false;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const offset =
|
||||
elRect.top -
|
||||
containerRect.top -
|
||||
container.clientHeight / 2 +
|
||||
elRect.height / 2;
|
||||
|
||||
container.scrollTo({
|
||||
top: container.scrollTop + offset,
|
||||
behavior: "smooth",
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const flashHighlight = () => {
|
||||
setHighlightedMessageId(targetId);
|
||||
if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
|
||||
highlightTimerRef.current = setTimeout(
|
||||
() => setHighlightedMessageId(null),
|
||||
1800
|
||||
);
|
||||
};
|
||||
|
||||
const waitForPaint = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
});
|
||||
|
||||
if (scrollToTarget()) {
|
||||
flashHighlight();
|
||||
return;
|
||||
}
|
||||
|
||||
const isLoaded = (id: string) => allMessages.some((m) => m._id === id);
|
||||
|
||||
if (isLoaded(targetId)) {
|
||||
await waitForPaint();
|
||||
if (scrollToTarget()) {
|
||||
flashHighlight();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let pagesLoaded = 0;
|
||||
let hasMore = hasNextPage;
|
||||
while (hasMore && pagesLoaded < 15 && !isLoaded(targetId)) {
|
||||
const result = await fetchNextPage();
|
||||
pagesLoaded += 1;
|
||||
hasMore = result.hasNextPage ?? false;
|
||||
await waitForPaint();
|
||||
}
|
||||
|
||||
await waitForPaint();
|
||||
if (scrollToTarget()) {
|
||||
flashHighlight();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error("پیام اصلی یافت نشد");
|
||||
},
|
||||
[allMessages, fetchNextPage, hasNextPage]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
dir="rtl"
|
||||
className="custom-scrollbar chat-page-bg flex min-h-0 flex-1 flex-col overflow-y-auto px-2 pb-[calc(5.5rem+env(safe-area-inset-bottom))] pt-2 sm:px-3"
|
||||
className={cn(
|
||||
"chat-thread-messages custom-scrollbar relative flex min-h-0 flex-1 flex-col overflow-y-auto bg-transparent px-2 pt-[calc(3.75rem+env(safe-area-inset-top))] sm:px-3"
|
||||
)}
|
||||
style={{
|
||||
paddingBottom: `calc(${5.5 + extraBottomRem}rem + env(safe-area-inset-bottom, 0px))`,
|
||||
}}
|
||||
onScroll={handleScroll}
|
||||
onClick={(e) => {
|
||||
if (!actionMode) return;
|
||||
if (!(e.target as Element).closest("[data-chat-bubble]")) {
|
||||
onDismissAction?.();
|
||||
}
|
||||
}}
|
||||
ref={scrollRef}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{actionMode && (
|
||||
<motion.div
|
||||
key="action-dim"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="pointer-events-none absolute inset-0 z-[5] bg-black/15 dark:bg-black/35"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex justify-center py-2">
|
||||
<IOSSpinner size={18} color="#8e8e93" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
{showSkeleton ? (
|
||||
<ChatMessagesSkeleton />
|
||||
) : isError ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 py-12 text-center">
|
||||
<p className="text-sm text-neutral-500">بارگذاری پیامها ناموفق بود.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refetch()}
|
||||
className="rounded-full bg-[#2aabee] px-4 py-2 text-sm text-white"
|
||||
>
|
||||
تلاش مجدد
|
||||
</button>
|
||||
</div>
|
||||
) : grouped.length === 0 ? (
|
||||
<p className="py-12 text-center text-sm text-neutral-500">
|
||||
هنوز پیامی رد و بدل نشده است.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{grouped.map((item, index) =>
|
||||
item.type === "date" ? (
|
||||
<ChatDateSeparator key={`date-${item.label}-${index}`} label={item.label} />
|
||||
) : (
|
||||
<div className="flex flex-col pb-1">
|
||||
{grouped.map((item, index) => {
|
||||
if (item.type === "date") {
|
||||
return (
|
||||
<ChatDateSeparator
|
||||
key={`date-${item.label}-${index}`}
|
||||
label={item.label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const msg = item.message;
|
||||
const msgIndex = messageIndexMap.get(msg._id) ?? 0;
|
||||
const groupPosition = getBubbleGroupPosition(allMessages, msgIndex);
|
||||
const prevMsg = allMessages[msgIndex - 1];
|
||||
const isGroupedWithPrev =
|
||||
!!prevMsg && sameVisualGroup(prevMsg, msg);
|
||||
const isIncoming =
|
||||
String(msg.senderId) !==
|
||||
String(userDetail?._id ?? getStoredUserId());
|
||||
const stableKey = getStableKey(msg._id);
|
||||
const isNew =
|
||||
hasInitializedRef.current &&
|
||||
!seenMessageKeysRef.current.has(stableKey);
|
||||
|
||||
if (isNew) {
|
||||
seenMessageKeysRef.current.add(stableKey);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatMessageCard
|
||||
key={item.message._id || `msg-${index}`}
|
||||
message={item.message}
|
||||
key={stableKey}
|
||||
message={msg}
|
||||
userDetail={userDetail}
|
||||
selected={selectedMessageId === item.message._id}
|
||||
onBubbleClick={onBubbleClick}
|
||||
groupPosition={groupPosition}
|
||||
isGroupedWithPrev={isGroupedWithPrev}
|
||||
showAvatar={
|
||||
isIncoming &&
|
||||
(groupPosition === "last" || groupPosition === "single")
|
||||
}
|
||||
partnerAvatar={userTwoDetail?.profile_image ?? undefined}
|
||||
isNew={isNew}
|
||||
selected={selectedMessageId === msg._id}
|
||||
onBubbleClick={deleteMode ? undefined : onBubbleClick}
|
||||
onSwipeReply={deleteMode ? undefined : onSwipeReply}
|
||||
deleteMode={deleteMode}
|
||||
selectedForDelete={selectedDeleteIds.includes(msg._id)}
|
||||
onToggleDeleteSelect={onToggleDeleteSelect}
|
||||
onScrollToReply={scrollToMessage}
|
||||
highlighted={highlightedMessageId === msg._id}
|
||||
registerRef={registerMessageRef}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{isTyping && !isLoading && (
|
||||
{isTyping && !showSkeleton && (
|
||||
<TypingIndicator userName={userTwoDetail?.first_name} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FiDownload, FiFile } from "react-icons/fi";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import IOSSpinner from "@/components/ui/IOSSpinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function FileMessageBubble({
|
||||
{isPending || downloading ? (
|
||||
<IOSSpinner size={22} color={isOutgoing ? "#2aabee" : "#007aff"} />
|
||||
) : (
|
||||
<FiFile size={22} />
|
||||
<BoldIcon name="document" size={22} tinted className="text-current" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -97,7 +97,7 @@ export default function FileMessageBubble({
|
||||
className="gentle-transition rounded-full p-2 text-neutral-500 hover:bg-neutral-200/80 active:scale-90 dark:hover:bg-neutral-700"
|
||||
aria-label="دانلود"
|
||||
>
|
||||
<FiDownload size={20} />
|
||||
<BoldIcon name="document-download" size={20} tinted className="text-current" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FiMapPin, FiNavigation } from "react-icons/fi";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
|
||||
export interface LocationData {
|
||||
lat: number;
|
||||
@@ -46,7 +46,7 @@ export default function LocationMessageBubble({
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<FiMapPin className="text-3xl text-red-500 drop-shadow-md" />
|
||||
<BoldIcon name="location" size={30} tinted className="text-red-500 drop-shadow-md" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 bg-white/90 px-3 py-2 dark:bg-neutral-900/90">
|
||||
@@ -59,7 +59,7 @@ export default function LocationMessageBubble({
|
||||
rel="noopener noreferrer"
|
||||
className="gentle-transition flex items-center gap-1 rounded-full bg-[#2aabee] px-2.5 py-1 text-[10px] text-white active:scale-95"
|
||||
>
|
||||
<FiNavigation size={12} />
|
||||
<BoldIcon name="routing" size={12} tinted className="text-white" />
|
||||
مسیریابی
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Image from "next/image";
|
||||
import { User } from "@/types/types";
|
||||
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { FiCheck, FiCheckCircle } from "react-icons/fi"; // اضافه کردن آیکونها
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
|
||||
interface Message {
|
||||
_id: string;
|
||||
@@ -9,7 +9,7 @@ interface Message {
|
||||
senderId: string;
|
||||
createdAt: string;
|
||||
file: string;
|
||||
status: 'sent' | 'delivered' | 'seen'; // اضافه شده برای مدیریت وضعیت
|
||||
status: 'sent' | 'delivered' | 'seen';
|
||||
}
|
||||
|
||||
interface MessageCardProps {
|
||||
@@ -20,25 +20,24 @@ interface MessageCardProps {
|
||||
const MessageCard = ({ message, userDetail }: MessageCardProps) => {
|
||||
const isSender = userDetail && message.senderId === userDetail._id;
|
||||
|
||||
// تابع تعیین آیکون بر اساس وضعیت
|
||||
const renderStatus = () => {
|
||||
if (!isSender) return null; // وضعیت فقط برای پیامهای ارسالی نمایش داده شود
|
||||
if (!isSender) return null;
|
||||
|
||||
switch (message.status) {
|
||||
case 'sent':
|
||||
return <FiCheck className="text-gray-400" size={14} />;
|
||||
return <BoldIcon name="tick-circle" size={14} tinted className="text-gray-400" />;
|
||||
case 'delivered':
|
||||
return (
|
||||
<div className="flex -space-x-2">
|
||||
<FiCheck className="text-gray-400" size={14} />
|
||||
<FiCheck className="text-gray-400" size={14} />
|
||||
<BoldIcon name="tick-circle" size={14} tinted className="text-gray-400" />
|
||||
<BoldIcon name="tick-circle" size={14} tinted className="text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
case 'seen':
|
||||
return (
|
||||
<div className="flex -space-x-2 text-[#2196EB]">
|
||||
<FiCheck size={14} />
|
||||
<FiCheck size={14} />
|
||||
<BoldIcon name="tick-circle" size={14} tinted className="text-[#2196EB]" />
|
||||
<BoldIcon name="tick-circle" size={14} tinted className="text-[#2196EB]" />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
@@ -61,7 +60,6 @@ const MessageCard = ({ message, userDetail }: MessageCardProps) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* نمایش وضعیت تیکها */}
|
||||
<div className="px-1">{renderStatus()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { FiMic, FiPaperclip, FiSend, FiSquare } from "react-icons/fi";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import AttachmentMenu, { AttachmentType } from "./AttachmentMenu";
|
||||
import TimedMessagePicker from "./TimedMessagePicker";
|
||||
import ViewOnceToggle from "./ViewOnceToggle";
|
||||
import ChatBoldIcon from "./ChatBoldIcon";
|
||||
import { formatTimedLabel } from "@/lib/chat/timedMessages";
|
||||
import { viewOnceLabel } from "@/lib/chat/viewOnce";
|
||||
|
||||
interface MessageInputProps {
|
||||
newMessage: string;
|
||||
@@ -19,6 +24,10 @@ interface MessageInputProps {
|
||||
replyingTo?: unknown;
|
||||
onCancelReply?: () => void;
|
||||
replyLabel?: string;
|
||||
selfDestructSeconds?: number | null;
|
||||
onSelfDestructChange?: (seconds: number | null) => void;
|
||||
viewOnceMedia?: boolean;
|
||||
onViewOnceChange?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
@@ -39,6 +48,10 @@ const MessageInput = ({
|
||||
blocked_you,
|
||||
onCancelReply,
|
||||
replyLabel,
|
||||
selfDestructSeconds = null,
|
||||
onSelfDestructChange,
|
||||
viewOnceMedia = false,
|
||||
onViewOnceChange,
|
||||
}: MessageInputProps) => {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [recordingTime, setRecordingTime] = useState(0);
|
||||
@@ -155,38 +168,75 @@ const MessageInput = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-none fixed left-0 right-0 z-50 flex flex-col items-center px-3 sm:px-4 ${
|
||||
className={`pointer-events-none fixed left-0 right-0 z-50 flex flex-col items-center bg-transparent px-3 sm:px-4 ${
|
||||
isChatThread ? "bottom-0 chat-input-area--thread" : "bottom-[5.5rem] chat-input-area"
|
||||
}`}
|
||||
>
|
||||
{replyLabel && (
|
||||
<div className="pointer-events-auto mb-2 flex w-full max-w-lg items-center gap-2 rounded-2xl border-r-2 border-[#0095f6] bg-white/80 px-3 py-2 text-xs backdrop-blur-md dark:bg-neutral-900/80">
|
||||
<span className="flex-1 truncate text-neutral-600 dark:text-neutral-300">
|
||||
{replyLabel}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancelReply}
|
||||
className="shrink-0 text-[#0095f6]"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{(replyLabel ||
|
||||
(selfDestructSeconds != null && isChatThread) ||
|
||||
(viewOnceMedia && isChatThread)) && (
|
||||
<div className="pointer-events-auto relative z-[2] mb-2 flex w-full max-w-lg flex-col gap-2">
|
||||
{replyLabel && (
|
||||
<div className="glass-panel flex items-center gap-2 rounded-2xl px-3 py-2.5 text-xs shadow-lg">
|
||||
<span className="flex-1 truncate text-neutral-700 dark:text-neutral-200">
|
||||
{replyLabel}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancelReply}
|
||||
className="shrink-0 text-[#0095f6]"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{selfDestructSeconds != null && isChatThread && (
|
||||
<div className="chat-timer-glass chat-timer-glass--active flex items-center gap-2 rounded-2xl px-3 py-1.5 text-[11px]">
|
||||
<ChatBoldIcon name="timer" size={14} className="shrink-0" />
|
||||
<span>
|
||||
پیام زماندار — حذف خودکار بعد از{" "}
|
||||
{formatTimedLabel(selfDestructSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{viewOnceMedia && isChatThread && (
|
||||
<div className="chat-timer-glass chat-timer-glass--active flex items-center gap-2 rounded-2xl px-3 py-1.5 text-[11px] text-[#ff9500]">
|
||||
<span className="font-semibold">۱</span>
|
||||
<span>
|
||||
{viewOnceLabel("image")} — گیرنده فقط یکبار میتواند ببیند
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
dir="ltr"
|
||||
className="pointer-events-auto relative flex w-full max-w-lg items-end gap-2 sm:gap-3"
|
||||
className="pointer-events-auto relative z-[1] flex w-full max-w-lg items-end gap-2 sm:gap-2.5"
|
||||
>
|
||||
<div className="relative mb-1">
|
||||
<div className="relative flex shrink-0 items-center gap-1.5">
|
||||
{isChatThread && onSelfDestructChange && (
|
||||
<TimedMessagePicker
|
||||
value={selfDestructSeconds}
|
||||
onChange={onSelfDestructChange}
|
||||
disabled={blocked_you || isRecording}
|
||||
/>
|
||||
)}
|
||||
{isChatThread && onViewOnceChange && (
|
||||
<ViewOnceToggle
|
||||
value={viewOnceMedia}
|
||||
onChange={onViewOnceChange}
|
||||
disabled={blocked_you}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
ref={attachBtnRef}
|
||||
type="button"
|
||||
disabled={blocked_you || isRecording}
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
className="chat-action-btn gentle-transition flex h-10 w-10 shrink-0 items-center justify-center rounded-full active:scale-90 disabled:opacity-40 sm:h-11 sm:w-11"
|
||||
className="chat-action-btn gentle-transition flex h-11 w-11 shrink-0 items-center justify-center rounded-full active:scale-90 disabled:opacity-40"
|
||||
aria-label="پیوست"
|
||||
>
|
||||
<FiPaperclip size={22} className="rotate-[-45deg]" />
|
||||
<ChatBoldIcon name="attachment" size={22} />
|
||||
</button>
|
||||
<AttachmentMenu
|
||||
open={menuOpen}
|
||||
@@ -197,7 +247,7 @@ const MessageInput = ({
|
||||
</div>
|
||||
|
||||
{/* Glass input box */}
|
||||
<div className="glass-chat-input gentle-transition flex min-h-[44px] flex-1 items-center rounded-[22px] px-4 py-2">
|
||||
<div className="glass-chat-input gentle-transition flex min-h-11 flex-1 items-center rounded-[22px] px-4 py-2">
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
@@ -239,7 +289,7 @@ const MessageInput = ({
|
||||
onClick={() => stopRecording(false)}
|
||||
className="rounded-full p-1"
|
||||
>
|
||||
<FiSquare size={18} />
|
||||
<BoldIcon name="stop" size={18} tinted className="text-current" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -277,10 +327,10 @@ const MessageInput = ({
|
||||
type="button"
|
||||
disabled={blocked_you}
|
||||
onClick={sendMessage}
|
||||
className="ig-dm-send-btn gentle-transition mb-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-white shadow-lg active:scale-90 sm:h-11 sm:w-11"
|
||||
className="ig-dm-send-btn gentle-transition flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-white shadow-lg active:scale-90"
|
||||
aria-label="ارسال"
|
||||
>
|
||||
<FiSend size={20} className="-mr-0.5" />
|
||||
<ChatBoldIcon name="send" size={20} className="text-white -mr-0.5" />
|
||||
</motion.button>
|
||||
) : (
|
||||
<motion.button
|
||||
@@ -294,14 +344,14 @@ const MessageInput = ({
|
||||
onPointerDown={handleMicPointerDown}
|
||||
onPointerUp={handleMicPointerUp}
|
||||
onPointerLeave={() => isRecording && stopRecording(true)}
|
||||
className={`gentle-transition mb-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-full sm:h-11 sm:w-11 ${
|
||||
className={`gentle-transition flex h-11 w-11 shrink-0 items-center justify-center rounded-full ${
|
||||
isRecording
|
||||
? "scale-110 bg-red-500 text-white shadow-inner"
|
||||
: "ig-dm-send-btn text-white shadow-lg"
|
||||
} active:scale-90`}
|
||||
aria-label="ضبط ویس"
|
||||
>
|
||||
{isRecording ? <FiSquare size={20} /> : <FiMic size={22} />}
|
||||
{isRecording ? <BoldIcon name="stop" size={20} tinted className="text-white" /> : <ChatBoldIcon name="microphone" size={22} className="text-white" />}
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -4,12 +4,16 @@ import Image from "next/image";
|
||||
import { motion } from "framer-motion";
|
||||
import Modal from "../elements/Modal";
|
||||
|
||||
import ViewOnceToggle from "./ViewOnceToggle";
|
||||
|
||||
interface MultiImageModalProps {
|
||||
isOpen: boolean;
|
||||
files: File[];
|
||||
onRemove: (index: number) => void;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
viewOnceMedia?: boolean;
|
||||
onViewOnceChange?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export default function MultiImageModal({
|
||||
@@ -18,6 +22,8 @@ export default function MultiImageModal({
|
||||
onRemove,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
viewOnceMedia = false,
|
||||
onViewOnceChange,
|
||||
}: MultiImageModalProps) {
|
||||
if (!isOpen || files.length === 0) return null;
|
||||
|
||||
@@ -51,7 +57,17 @@ export default function MultiImageModal({
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-center gap-4">
|
||||
<div className="mt-4 flex flex-col items-center gap-3">
|
||||
{onViewOnceChange && (
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-600 dark:text-neutral-300">
|
||||
<ViewOnceToggle
|
||||
value={viewOnceMedia}
|
||||
onChange={onViewOnceChange}
|
||||
/>
|
||||
<span>ارسال بهصورت یکبار مصرف</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
@@ -66,6 +82,7 @@ export default function MultiImageModal({
|
||||
>
|
||||
لغو
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
95
src/components/chat/TimedMessagePicker.tsx
Normal file
95
src/components/chat/TimedMessagePicker.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import ChatBoldIcon from "./ChatBoldIcon";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
TIMED_MESSAGE_OPTIONS,
|
||||
formatTimedLabel,
|
||||
} from "@/lib/chat/timedMessages";
|
||||
|
||||
interface TimedMessagePickerProps {
|
||||
value: number | null;
|
||||
onChange: (seconds: number | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function TimedMessagePicker({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: TimedMessagePickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", close);
|
||||
return () => document.removeEventListener("mousedown", close);
|
||||
}, [open]);
|
||||
|
||||
const active = value != null;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className={cn(
|
||||
"chat-timer-glass gentle-transition flex h-11 w-11 items-center justify-center rounded-full",
|
||||
active && "chat-timer-glass--active",
|
||||
!active && "text-neutral-500 dark:text-neutral-400",
|
||||
disabled && "opacity-40"
|
||||
)}
|
||||
aria-label="پیام زماندار"
|
||||
title={active ? formatTimedLabel(value) : "پیام زماندار"}
|
||||
>
|
||||
<ChatBoldIcon name="timer" size={20} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 8, scale: 0.96 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="glass-panel chat-timer-glass absolute bottom-full left-0 z-50 mb-2 min-w-[148px] overflow-hidden rounded-2xl p-1.5 shadow-xl"
|
||||
>
|
||||
<p className="px-2.5 py-1.5 text-[11px] font-medium text-neutral-500 dark:text-neutral-400">
|
||||
حذف خودکار بعد از
|
||||
</p>
|
||||
{TIMED_MESSAGE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(opt.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-xl px-2.5 py-2 text-right text-sm transition-colors",
|
||||
value === opt.value
|
||||
? "bg-[#2aabee]/12 font-semibold text-[#248cc8] dark:text-[#2aabee]"
|
||||
: "text-foreground hover:bg-black/5 dark:hover:bg-white/8"
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
{value === opt.value && (
|
||||
<span className="text-[#2aabee]">✓</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { FiPlay } from "react-icons/fi";
|
||||
|
||||
interface VideoMessageBubbleProps {
|
||||
src: string;
|
||||
isOutgoing?: boolean;
|
||||
onOpen: () => void;
|
||||
}
|
||||
|
||||
/** Full video preview — not compressed file card */
|
||||
export default function VideoMessageBubble({
|
||||
src,
|
||||
isOutgoing,
|
||||
onOpen,
|
||||
}: VideoMessageBubbleProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className={`gentle-transition relative max-w-[min(280px,85vw)] overflow-hidden rounded-2xl active:scale-[0.98] ${
|
||||
isOutgoing ? "ring-1 ring-white/20" : "shadow-md"
|
||||
}`}
|
||||
>
|
||||
<video
|
||||
src={src}
|
||||
className="max-h-72 w-full min-w-[200px] object-contain bg-black/90"
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm">
|
||||
<FiPlay className="ml-0.5 text-2xl text-white" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
|
||||
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
|
||||
|
||||
|
||||
interface VideoMessageBubbleProps {
|
||||
|
||||
src: string;
|
||||
|
||||
isOutgoing?: boolean;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Full video preview — not compressed file card */
|
||||
|
||||
export default function VideoMessageBubble({
|
||||
|
||||
src,
|
||||
|
||||
isOutgoing,
|
||||
|
||||
}: VideoMessageBubbleProps) {
|
||||
|
||||
return (
|
||||
|
||||
<div
|
||||
|
||||
className={`relative max-w-[min(280px,85vw)] overflow-hidden rounded-2xl ${
|
||||
|
||||
isOutgoing ? "ring-1 ring-white/20" : "shadow-md"
|
||||
|
||||
68
src/components/chat/ViewOnceMediaBubble.tsx
Normal file
68
src/components/chat/ViewOnceMediaBubble.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import { viewOnceLabel } from "@/lib/chat/viewOnce";
|
||||
import ChatBoldIcon from "./ChatBoldIcon";
|
||||
|
||||
interface ViewOnceMediaBubbleProps {
|
||||
fileType: "image" | "video" | "voice";
|
||||
isOutgoing?: boolean;
|
||||
expired?: boolean;
|
||||
onOpen?: () => void;
|
||||
borderRadius?: string;
|
||||
}
|
||||
|
||||
export default function ViewOnceMediaBubble({
|
||||
fileType,
|
||||
isOutgoing = false,
|
||||
expired = false,
|
||||
onOpen,
|
||||
borderRadius = "18px",
|
||||
}: ViewOnceMediaBubbleProps) {
|
||||
const mediaIcon =
|
||||
fileType === "voice" ? (
|
||||
<ChatBoldIcon name="microphone" size={28} className="text-[#ff9500]" />
|
||||
) : fileType === "video" ? (
|
||||
<BoldIcon name="video" size={28} tinted className="text-current" />
|
||||
) : (
|
||||
<BoldIcon name="gallery" size={28} tinted className="text-current" />
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={expired}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!expired) onOpen?.();
|
||||
}}
|
||||
className={cn(
|
||||
"gentle-transition flex w-44 flex-col items-center justify-center gap-2 px-4 py-6 sm:w-52",
|
||||
isOutgoing ? "chat-bubble-out" : "chat-bubble-in",
|
||||
expired ? "cursor-default opacity-60" : "cursor-pointer active:scale-[0.98]"
|
||||
)}
|
||||
style={{ borderRadius }}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-14 w-14 items-center justify-center rounded-full",
|
||||
expired
|
||||
? "bg-neutral-300/40 dark:bg-neutral-600/40"
|
||||
: "bg-[#ff9500]/15 text-[#ff9500]"
|
||||
)}
|
||||
>
|
||||
{mediaIcon}
|
||||
</div>
|
||||
<span className="text-center text-xs font-medium leading-snug">
|
||||
{expired ? "پیام منقضی شد" : viewOnceLabel(fileType)}
|
||||
</span>
|
||||
{!expired && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-[#ff9500]">
|
||||
<ChatBoldIcon name="eye" size={12} className="text-[#ff9500]" />
|
||||
برای مشاهده بزنید
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
45
src/components/chat/ViewOnceToggle.tsx
Normal file
45
src/components/chat/ViewOnceToggle.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import ChatBoldIcon from "./ChatBoldIcon";
|
||||
|
||||
interface ViewOnceToggleProps {
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function ViewOnceToggle({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: ViewOnceToggleProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!value)}
|
||||
className={cn(
|
||||
"chat-timer-glass gentle-transition flex h-11 w-11 shrink-0 items-center justify-center rounded-full",
|
||||
value && "chat-timer-glass--active bg-[#ff9500]/20 text-[#ff9500]",
|
||||
!value && "text-neutral-500 dark:text-neutral-400",
|
||||
disabled && "opacity-40"
|
||||
)}
|
||||
aria-label="یکبار مصرف"
|
||||
title={value ? "یکبار مصرف فعال" : "یکبار مصرف"}
|
||||
>
|
||||
<span className="relative flex items-center justify-center">
|
||||
<ChatBoldIcon
|
||||
name="eye"
|
||||
size={20}
|
||||
className={value ? "text-[#ff9500]" : undefined}
|
||||
/>
|
||||
{value && (
|
||||
<span className="absolute -right-1 -top-1 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-[#ff9500] text-[8px] font-bold text-white">
|
||||
۱
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FiDownload, FiPause, FiPlay } from "react-icons/fi";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import IOSSpinner from "@/components/ui/IOSSpinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface VoiceMessagePlayerProps {
|
||||
src: string;
|
||||
isOutgoing?: boolean;
|
||||
autoPlay?: boolean;
|
||||
singlePlay?: boolean;
|
||||
onPlaybackComplete?: () => void;
|
||||
}
|
||||
|
||||
function formatDuration(sec: number) {
|
||||
@@ -20,6 +23,9 @@ function formatDuration(sec: number) {
|
||||
export default function VoiceMessagePlayer({
|
||||
src,
|
||||
isOutgoing = false,
|
||||
autoPlay = false,
|
||||
singlePlay = false,
|
||||
onPlaybackComplete,
|
||||
}: VoiceMessagePlayerProps) {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [downloaded, setDownloaded] = useState(false);
|
||||
@@ -28,8 +34,13 @@ export default function VoiceMessagePlayer({
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [waveform, setWaveform] = useState<number[]>([]);
|
||||
const [playedOnce, setPlayedOnce] = useState(false);
|
||||
const isBlob = src.startsWith("blob:");
|
||||
|
||||
useEffect(() => {
|
||||
setPlayedOnce(false);
|
||||
}, [src]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isBlob) {
|
||||
setDownloaded(true);
|
||||
@@ -87,6 +98,8 @@ export default function VoiceMessagePlayer({
|
||||
const onEnd = () => {
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setPlayedOnce(true);
|
||||
onPlaybackComplete?.();
|
||||
};
|
||||
|
||||
audio.addEventListener("loadedmetadata", onMeta);
|
||||
@@ -97,11 +110,18 @@ export default function VoiceMessagePlayer({
|
||||
audio.removeEventListener("timeupdate", onTime);
|
||||
audio.removeEventListener("ended", onEnd);
|
||||
};
|
||||
}, [downloaded]);
|
||||
}, [downloaded, onPlaybackComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !downloaded || !autoPlay || playedOnce) return;
|
||||
audio.play().then(() => setIsPlaying(true)).catch(() => {});
|
||||
}, [autoPlay, downloaded, playedOnce, src]);
|
||||
|
||||
const togglePlay = () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (singlePlay && playedOnce) return;
|
||||
if (isPlaying) {
|
||||
audio.pause();
|
||||
setIsPlaying(false);
|
||||
@@ -143,7 +163,7 @@ export default function VoiceMessagePlayer({
|
||||
{downloadProgress > 0 && downloadProgress < 100 ? (
|
||||
<IOSSpinner size={18} color="#fff" />
|
||||
) : (
|
||||
<FiDownload size={18} />
|
||||
<BoldIcon name="document-download" size={18} tinted className="text-current" />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
@@ -172,10 +192,14 @@ export default function VoiceMessagePlayer({
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePlay}
|
||||
className="gentle-transition flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#2aabee] text-white active:scale-95"
|
||||
disabled={singlePlay && playedOnce}
|
||||
className={cn(
|
||||
"gentle-transition flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#2aabee] text-white active:scale-95",
|
||||
singlePlay && playedOnce && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
aria-label={isPlaying ? "توقف" : "پخش"}
|
||||
>
|
||||
{isPlaying ? <FiPause size={18} /> : <FiPlay size={18} className="mr-[-2px]" />}
|
||||
{isPlaying ? <BoldIcon name="pause" size={18} tinted className="text-current mr-[-2px]" /> : <BoldIcon name="play" size={18} tinted className="text-current mr-[-2px]" />}
|
||||
</button>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
// لوکال: NEXT_PUBLIC_BASE_URL=http://localhost:3002/api/v1
|
||||
// export const SOCKET_URL = "http://localhost:8000";
|
||||
// export const BASE_URL = "http://localhost:8000/api/v1";
|
||||
// export const IMAGE_BASE_URL = "http://localhost:8000/storage";
|
||||
|
||||
export const SOCKET_URL = "https://api.modstagram.com";
|
||||
export const BASE_URL = "https://api.modstagram.com/api/v1";
|
||||
export const IMAGE_BASE_URL = "https://api.modstagram.com/storage";
|
||||
/** app.modstagram.ir — SSL معتبر برای fetch سمت سرور Next.js */
|
||||
export const SOCKET_URL =
|
||||
process.env.NEXT_PUBLIC_SOCKET_URL ?? "https://app.modstagram.ir";
|
||||
export const BASE_URL =
|
||||
process.env.NEXT_PUBLIC_BASE_URL ?? "https://app.modstagram.ir/api/v1";
|
||||
export const IMAGE_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_IMAGE_BASE_URL ?? "https://app.modstagram.ir/storage";
|
||||
|
||||
/** ساخت URL کامل فایل storage — مسیرهای DB را یکسان میکند */
|
||||
export function buildStorageUrl(path: string | null | undefined): string {
|
||||
|
||||
@@ -74,10 +74,10 @@ function Header() {
|
||||
) : null;
|
||||
|
||||
const headerBarClass = cn(
|
||||
"gentle-transition mx-auto flex max-w-lg items-center justify-center px-4",
|
||||
"gentle-transition mx-auto flex items-center",
|
||||
compact
|
||||
? "glass-panel safari-header-pill gap-3 rounded-full px-4 py-2 shadow-lg"
|
||||
: "glass-panel w-full max-w-none rounded-none border-b border-white/30 py-1 dark:border-white/5",
|
||||
? "glass-panel safari-header-pill w-full max-w-lg justify-between rounded-full px-2 py-2 shadow-lg"
|
||||
: "glass-panel w-full max-w-none justify-center rounded-none border-b border-white/30 px-4 py-1 dark:border-white/5",
|
||||
isOffline && "header-offline",
|
||||
isSyncing && "header-syncing"
|
||||
);
|
||||
@@ -94,11 +94,11 @@ function Header() {
|
||||
>
|
||||
<Image
|
||||
alt="مادستاگرام"
|
||||
width={compact ? 72 : 130}
|
||||
height={compact ? 24 : 36}
|
||||
width={compact ? 124 : 130}
|
||||
height={compact ? 34 : 36}
|
||||
className={cn(
|
||||
"h-auto w-auto dark:invert",
|
||||
compact ? "h-6 max-w-[72px]" : "h-8 max-w-[130px]"
|
||||
"h-auto w-auto dark:invert gentle-transition",
|
||||
compact ? "h-[30px] max-w-[124px]" : "h-8 max-w-[130px]"
|
||||
)}
|
||||
src="/images/icons/logo.svg"
|
||||
priority
|
||||
@@ -127,7 +127,11 @@ function Header() {
|
||||
<div className={headerBarClass}>
|
||||
{compact ? (
|
||||
<>
|
||||
<Link href="/settings/chats" className={iconBtn} aria-label="پیامها">
|
||||
<Link
|
||||
href="/settings/chats"
|
||||
className={cn(iconBtn, "-mr-0.5")}
|
||||
aria-label="پیامها"
|
||||
>
|
||||
{badge(unreadMessages)}
|
||||
<Image
|
||||
alt=""
|
||||
@@ -140,7 +144,7 @@ function Header() {
|
||||
<LogoBlock />
|
||||
<Link
|
||||
href="/settings/notifications"
|
||||
className={iconBtn}
|
||||
className={cn(iconBtn, "-ml-0.5")}
|
||||
aria-label="اعلانها"
|
||||
>
|
||||
{badge(unreadNotification)}
|
||||
|
||||
57
src/components/main/ProfileAvatar.tsx
Normal file
57
src/components/main/ProfileAvatar.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buildStorageUrl } from "./BaseUrl";
|
||||
|
||||
const sizeMap = {
|
||||
xs: "h-8 w-8",
|
||||
sm: "h-10 w-10",
|
||||
chat: "h-14 w-14",
|
||||
md: "h-[72px] w-[72px]",
|
||||
lg: "h-[120px] w-[120px]",
|
||||
xl: "h-[280px] w-[280px]",
|
||||
};
|
||||
|
||||
type ProfileAvatarProps = {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
size?: keyof typeof sizeMap;
|
||||
rounded?: "2xl" | "xl" | "full";
|
||||
className?: string;
|
||||
fallback?: string;
|
||||
};
|
||||
|
||||
function ProfileAvatar({
|
||||
src,
|
||||
alt = "",
|
||||
size = "md",
|
||||
rounded = "2xl",
|
||||
className,
|
||||
fallback = "/images/fake-avatar.png",
|
||||
}: ProfileAvatarProps) {
|
||||
const roundedClass =
|
||||
rounded === "full"
|
||||
? "rounded-full"
|
||||
: rounded === "xl"
|
||||
? "rounded-xl"
|
||||
: "rounded-2xl";
|
||||
|
||||
const imageSrc = src ? buildStorageUrl(src) : fallback;
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={imageSrc}
|
||||
alt={alt}
|
||||
width={280}
|
||||
height={280}
|
||||
unoptimized
|
||||
className={cn(
|
||||
"profile-avatar aspect-square object-cover shrink-0",
|
||||
sizeMap[size],
|
||||
roundedClass,
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ProfileAvatar;
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { FiCopy, FiShare2, FiX } from "react-icons/fi";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface SharePostModalProps {
|
||||
@@ -59,7 +59,7 @@ export default function SharePostModal({
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="font-bold">اشتراکگذاری</h3>
|
||||
<button type="button" onClick={onClose} className="rounded-full p-2">
|
||||
<FiX size={20} />
|
||||
<BoldIcon name="close-circle" size={20} tinted className="text-current" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="glass-chat-input mb-4 flex items-center gap-2 rounded-2xl px-3 py-2.5">
|
||||
@@ -72,7 +72,7 @@ export default function SharePostModal({
|
||||
className="chat-action-btn gentle-transition flex h-9 w-9 shrink-0 items-center justify-center rounded-full active:scale-90"
|
||||
aria-label="کپی"
|
||||
>
|
||||
<FiCopy size={16} />
|
||||
<BoldIcon name="copy" size={16} tinted className="text-white" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@@ -80,7 +80,7 @@ export default function SharePostModal({
|
||||
onClick={nativeShare}
|
||||
className="ig-dm-send-btn gentle-transition flex w-full items-center justify-center gap-2 rounded-full py-3 text-sm font-semibold text-white active:scale-[0.98]"
|
||||
>
|
||||
<FiShare2 size={18} />
|
||||
<BoldIcon name="share" size={18} tinted className="text-white" />
|
||||
اشتراکگذاری سیستمی
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
@@ -17,8 +17,8 @@ function ShowMap({
|
||||
<Map
|
||||
style={{ height: height }}
|
||||
initialViewState={{
|
||||
longitude: Number(lat),
|
||||
latitude: Number(lng),
|
||||
longitude: Number(lng),
|
||||
latitude: Number(lat),
|
||||
zoom: 12,
|
||||
}}
|
||||
mapboxAccessToken="pk.eyJ1IjoiYWxkZjkyIiwiYSI6ImNscHh5ZG56dTByMXAycnJ2cDNmdDQ5M3YifQ.a_sUt1rLFMfA0jHrETcM7A"
|
||||
@@ -26,7 +26,7 @@ function ShowMap({
|
||||
>
|
||||
<GeolocateControl />
|
||||
{lat && lng && (
|
||||
<Marker latitude={Number(lng)} longitude={Number(lat)}>
|
||||
<Marker latitude={Number(lat)} longitude={Number(lng)}>
|
||||
<Image
|
||||
alt="location icon"
|
||||
className="-mt-5"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
import { IMAGE_BASE_URL, buildStorageUrl } from "./BaseUrl";
|
||||
import Link from "next/link";
|
||||
import ProfileAvatar from "./ProfileAvatar";
|
||||
import VerificationBadge from "./VerificationBadge";
|
||||
|
||||
interface IUserInfoProps {
|
||||
profile_image: string | null | undefined;
|
||||
@@ -22,50 +23,22 @@ function UserInfo({
|
||||
is_verified,
|
||||
noLink,
|
||||
}: IUserInfoProps) {
|
||||
const avatar = (
|
||||
<ProfileAvatar
|
||||
src={profile_image}
|
||||
alt={user_name || ""}
|
||||
size="md"
|
||||
rounded="2xl"
|
||||
className="ml-3"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center text-xs max-sm:text-[10px] md:text-sm font-semibold">
|
||||
{noLink ? (
|
||||
profile_image ? (
|
||||
<Image
|
||||
className="rounded-2xl object-cover h-[72px] w-[72px] ml-3"
|
||||
width={75}
|
||||
height={75}
|
||||
alt={user_name || ""}
|
||||
src={buildStorageUrl(profile_image)}
|
||||
unoptimized={true}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
className="rounded-2xl object-cover h-[72px] w-[72px] ml-3"
|
||||
width={75}
|
||||
height={75}
|
||||
alt={user_name || ""}
|
||||
src={`/images/fake-avatar.png`}
|
||||
unoptimized={true}
|
||||
/>
|
||||
)
|
||||
avatar
|
||||
) : (
|
||||
<Link href={`/users/${user_name}`}>
|
||||
{profile_image ? (
|
||||
<Image
|
||||
className="rounded-2xl object-cover h-[72px] w-[72px] ml-3"
|
||||
width={75}
|
||||
height={75}
|
||||
alt={user_name || ""}
|
||||
src={buildStorageUrl(profile_image)}
|
||||
unoptimized={true}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
className="rounded-2xl object-cover h-[72px] w-[72px] ml-3"
|
||||
width={75}
|
||||
height={75}
|
||||
alt={user_name || ""}
|
||||
src={`/images/fake-avatar.png`}
|
||||
unoptimized={true}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
<Link href={`/users/${user_name}`}>{avatar}</Link>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[#387E65]">
|
||||
@@ -73,29 +46,10 @@ function UserInfo({
|
||||
</span>
|
||||
<h1>{first_name && first_name + " " + last_name}</h1>
|
||||
<h2 className="flex items-center gap-1 mt-1">
|
||||
{user_name}
|
||||
{is_verified === "verified" ? (
|
||||
<Image
|
||||
width={22}
|
||||
height={22}
|
||||
alt={"verify icon"}
|
||||
src={`/images/icons/verify.svg`}
|
||||
/>
|
||||
) : is_verified === "pending" ? (
|
||||
<Image
|
||||
width={25}
|
||||
height={25}
|
||||
alt={"not-verify icon"}
|
||||
src={`/images/icons/not-verify.svg`}
|
||||
/>
|
||||
) :is_verified === "true" ? (
|
||||
<Image
|
||||
width={22}
|
||||
height={22}
|
||||
alt="verify icon"
|
||||
src="/images/icons/verify2.svg"
|
||||
/>
|
||||
): ("")}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{user_name}
|
||||
<VerificationBadge isVerified={is_verified} />
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user