Initial commit
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
|
||||
<url><loc>https://modstagram.com/robots.txt</loc><lastmod>2026-07-07T08:04:14.348Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://modstagram.com/sitemap.xml</loc><lastmod>2026-07-07T08:04:14.349Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://modstagram.com/explore</loc><lastmod>2026-07-07T08:04:14.350Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://modstagram.com/academy/payment/failed</loc><lastmod>2026-07-07T08:04:14.350Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://modstagram.com/academy/payment/success</loc><lastmod>2026-07-07T08:04:14.350Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://modstagram.com/about-us</loc><lastmod>2026-07-07T08:04:14.350Z</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>
|
||||
<url><loc>https://modstagram.com/robots.txt</loc><lastmod>2026-07-07T14:45:45.374Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://modstagram.com/sitemap.xml</loc><lastmod>2026-07-07T14:45:45.376Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://modstagram.com/explore</loc><lastmod>2026-07-07T14:45:45.376Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://modstagram.com/academy/payment/success</loc><lastmod>2026-07-07T14:45:45.376Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://modstagram.com/academy/payment/failed</loc><lastmod>2026-07-07T14:45:45.376Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://modstagram.com/about-us</loc><lastmod>2026-07-07T14:45:45.376Z</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>
|
||||
<url><loc>https://modstagram.com</loc><changefreq>daily</changefreq><priority>1</priority></url>
|
||||
<url><loc>https://modstagram.com/projects</loc><changefreq>daily</changefreq><priority>0.9</priority></url>
|
||||
<url><loc>https://modstagram.com/billboards</loc><changefreq>daily</changefreq><priority>0.9</priority></url>
|
||||
|
||||
@@ -15,6 +15,7 @@ import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { IVerifyOtp } from "@/types/types";
|
||||
import { setAuthSession } from "@/lib/auth/session";
|
||||
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
@@ -29,6 +30,8 @@ function ForgetPasswordOtp() {
|
||||
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;
|
||||
@@ -52,8 +55,12 @@ function ForgetPasswordOtp() {
|
||||
})) as IVerifyOtp;
|
||||
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);
|
||||
}
|
||||
},
|
||||
@@ -73,6 +80,8 @@ function ForgetPasswordOtp() {
|
||||
try {
|
||||
await request("POST", "/login", { mobile });
|
||||
formik.setFieldValue("otp", "");
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
@@ -85,10 +94,15 @@ function ForgetPasswordOtp() {
|
||||
<AuthOtpInput
|
||||
className="mt-3"
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => formik.setFieldValue("otp", otp)}
|
||||
onChange={(otp) => {
|
||||
formik.setFieldValue("otp", otp);
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
}}
|
||||
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
|
||||
error={Boolean(formik.touched.otp && formik.errors.otp)}
|
||||
disabled={loading || otpExpired}
|
||||
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>
|
||||
@@ -96,7 +110,7 @@ function ForgetPasswordOtp() {
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading} disabled={loading || otpExpired || formik.values.otp.length !== 6}
|
||||
loading={loading} disabled={loading || otpExpired || isSuccess || formik.values.otp.length !== 6}
|
||||
>
|
||||
تایید کد
|
||||
</AuthButton>
|
||||
|
||||
@@ -15,6 +15,7 @@ import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { IVerifyOtp } from "@/types/types";
|
||||
import { setAuthSession } from "@/lib/auth/session";
|
||||
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object().shape({
|
||||
@@ -32,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;
|
||||
@@ -56,6 +59,8 @@ function LoginWithUsername() {
|
||||
user_type: response.user_type,
|
||||
step: response.step,
|
||||
});
|
||||
setIsSuccess(true);
|
||||
await pause(AUTH_SUCCESS_DELAY_MS);
|
||||
router.refresh();
|
||||
router.push("/");
|
||||
break;
|
||||
@@ -64,12 +69,16 @@ function LoginWithUsername() {
|
||||
id: response.id,
|
||||
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);
|
||||
}
|
||||
},
|
||||
@@ -87,16 +96,18 @@ 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>
|
||||
@@ -105,21 +116,23 @@ function LoginWithUsername() {
|
||||
name="password"
|
||||
placeholder="کلمه عبور"
|
||||
wrapperClassName="mt-4"
|
||||
className={`border ${
|
||||
formik.touched.password && formik.errors.password
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
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" loading={loading} disabled={loading}>
|
||||
<AuthButton type="submit" className="mt-5" loading={loading} disabled={loading || isSuccess}>
|
||||
ورود
|
||||
</AuthButton>
|
||||
</form>
|
||||
|
||||
@@ -15,6 +15,7 @@ import * as yup from "yup";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { IVerifyOtp } from "@/types/types";
|
||||
import { setAuthSession } from "@/lib/auth/session";
|
||||
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
@@ -29,6 +30,8 @@ function VerifyOtp() {
|
||||
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;
|
||||
@@ -58,6 +61,8 @@ function VerifyOtp() {
|
||||
user_type: response.user_type,
|
||||
step: response.step,
|
||||
});
|
||||
setIsSuccess(true);
|
||||
await pause(AUTH_SUCCESS_DELAY_MS);
|
||||
router.refresh();
|
||||
router.push("/");
|
||||
break;
|
||||
@@ -66,6 +71,8 @@ function VerifyOtp() {
|
||||
id: response.id,
|
||||
step: response.step,
|
||||
});
|
||||
setIsSuccess(true);
|
||||
await pause(AUTH_SUCCESS_DELAY_MS);
|
||||
|
||||
switch (response.step) {
|
||||
case "user_name":
|
||||
@@ -90,6 +97,8 @@ function VerifyOtp() {
|
||||
}
|
||||
// router.push("/dashboard");
|
||||
} catch (err: any) {
|
||||
setOtpError(true);
|
||||
setIsSuccess(false);
|
||||
console.log("Unhandled error:", err?.message);
|
||||
}
|
||||
},
|
||||
@@ -109,6 +118,8 @@ function VerifyOtp() {
|
||||
try {
|
||||
await request("POST", "/login", { mobile });
|
||||
formik.setFieldValue("otp", "");
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
@@ -121,10 +132,15 @@ function VerifyOtp() {
|
||||
<AuthOtpInput
|
||||
className="mt-3"
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => formik.setFieldValue("otp", otp)}
|
||||
onChange={(otp) => {
|
||||
formik.setFieldValue("otp", otp);
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
}}
|
||||
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
|
||||
error={Boolean(formik.touched.otp && formik.errors.otp)}
|
||||
disabled={loading || otpExpired}
|
||||
error={otpError || Boolean(formik.touched.otp && formik.errors.otp)}
|
||||
success={isSuccess}
|
||||
disabled={loading || otpExpired || isSuccess}
|
||||
/>
|
||||
|
||||
{formik.touched.otp && formik.errors.otp && (
|
||||
@@ -133,7 +149,7 @@ function VerifyOtp() {
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading} disabled={loading || otpExpired || formik.values.otp.length !== 6}
|
||||
loading={loading} disabled={loading || otpExpired || isSuccess || formik.values.otp.length !== 6}
|
||||
>
|
||||
تایید کد
|
||||
</AuthButton>
|
||||
|
||||
@@ -16,6 +16,7 @@ import * as yup from "yup";
|
||||
import { IVerifyOtp } from "@/types/types";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { setAuthSession } from "@/lib/auth/session";
|
||||
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object({
|
||||
@@ -30,6 +31,8 @@ function RegisterOtp() {
|
||||
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;
|
||||
@@ -53,6 +56,8 @@ function RegisterOtp() {
|
||||
})) as IVerifyOtp;
|
||||
await localStorage.setItem("otp", values.otp);
|
||||
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"
|
||||
@@ -63,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);
|
||||
}
|
||||
},
|
||||
@@ -82,6 +89,8 @@ function RegisterOtp() {
|
||||
try {
|
||||
await request("POST", "/register", { mobile });
|
||||
formik.setFieldValue("otp", "");
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
@@ -94,10 +103,15 @@ function RegisterOtp() {
|
||||
<AuthOtpInput
|
||||
className="mt-3"
|
||||
value={formik.values.otp}
|
||||
onChange={(otp) => formik.setFieldValue("otp", otp)}
|
||||
onChange={(otp) => {
|
||||
formik.setFieldValue("otp", otp);
|
||||
setOtpError(false);
|
||||
setIsSuccess(false);
|
||||
}}
|
||||
onComplete={() => setTimeout(() => formik.submitForm(), 400)}
|
||||
error={Boolean(formik.touched.otp && formik.errors.otp)}
|
||||
disabled={loading || otpExpired}
|
||||
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>
|
||||
@@ -105,7 +119,7 @@ function RegisterOtp() {
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading} disabled={loading || otpExpired || formik.values.otp.length !== 6}
|
||||
loading={loading} disabled={loading || otpExpired || isSuccess || formik.values.otp.length !== 6}
|
||||
>
|
||||
تایید کد
|
||||
</AuthButton>
|
||||
|
||||
@@ -2,24 +2,43 @@
|
||||
"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 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") {
|
||||
@@ -49,26 +68,11 @@ function Confirm() {
|
||||
<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`}
|
||||
/>
|
||||
) : user?.is_verified === "pending" ? (
|
||||
<Image
|
||||
width={70}
|
||||
height={70}
|
||||
alt={"not-verify icon"}
|
||||
src={`/images/icons/not-verify.svg`}
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span>{user?.user_name}</span>
|
||||
<VerificationBadge
|
||||
isVerified={verifiedStatus ?? user?.is_verified ?? "pending"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -91,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");
|
||||
@@ -230,7 +230,12 @@ 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>
|
||||
|
||||
@@ -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,19 +664,13 @@ 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>
|
||||
|
||||
@@ -685,15 +681,7 @@ useEffect(() => {
|
||||
{item?.user_id?.user_name || "کاربر ناشناس"}
|
||||
</span>
|
||||
|
||||
{item?.user_id?.is_verified === "verified" && (
|
||||
<Image
|
||||
width={16}
|
||||
height={16}
|
||||
alt="verify"
|
||||
src="/images/icons/verify.svg"
|
||||
className="inline"
|
||||
/>
|
||||
)}
|
||||
<VerificationBadge isVerified={item?.user_id?.is_verified} />
|
||||
|
||||
{item?.rate > 0 && (
|
||||
<div className="mr-2">
|
||||
|
||||
@@ -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 || "")}`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,7 @@ 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";
|
||||
|
||||
interface ITicketChatProps {
|
||||
params: Promise<{ id: string; username: string }>;
|
||||
@@ -29,7 +30,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,
|
||||
};
|
||||
}
|
||||
@@ -47,8 +47,12 @@ 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 user = useUser();
|
||||
const queryClient = useQueryClient();
|
||||
const [receiverId, setReceiverId] = useState("");
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const socketRef = useRef<ReturnType<typeof io> | null>(null);
|
||||
@@ -187,7 +191,7 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
_id: tempId,
|
||||
content: textContent,
|
||||
senderId: user?._id || "",
|
||||
createdAt: formatBubbleTime(new Date().toISOString()),
|
||||
createdAt: new Date().toISOString(),
|
||||
status: "pending",
|
||||
file: fileToUpload ? URL.createObjectURL(fileToUpload) : "",
|
||||
fileType: fileType,
|
||||
@@ -283,6 +287,59 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
).then((res) => 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(
|
||||
["messages", user._id, receiverId],
|
||||
(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">
|
||||
@@ -291,6 +348,8 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onBlockChange={handleBlockChange}
|
||||
onStartDeleteMode={startDeleteMode}
|
||||
deleteMode={deleteMode}
|
||||
/>
|
||||
<ChatMessageList
|
||||
pendingMessages={pendingMessages}
|
||||
@@ -301,11 +360,22 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
searchQuery={searchQuery}
|
||||
selectedMessageId={selectedMessage?._id}
|
||||
onBubbleClick={openActionFor}
|
||||
deleteMode={deleteMode}
|
||||
selectedDeleteIds={selectedDeleteIds}
|
||||
onToggleDeleteSelect={toggleDeleteSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{actionMode ? (
|
||||
{deleteMode ? (
|
||||
<ChatDeleteBar
|
||||
key="delete"
|
||||
selectedCount={selectedDeleteIds.length}
|
||||
onDelete={confirmDeleteMessages}
|
||||
onCancel={cancelDeleteMode}
|
||||
loading={deleteLoading}
|
||||
/>
|
||||
) : actionMode ? (
|
||||
<ChatActionBar
|
||||
key="actions"
|
||||
onReply={startReply}
|
||||
|
||||
@@ -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";
|
||||
@@ -127,14 +128,7 @@ function Chats() {
|
||||
</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"
|
||||
/>
|
||||
)}
|
||||
<VerificationBadge isVerified={item?.is_verified} />
|
||||
</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>
|
||||
|
||||
@@ -80,7 +80,7 @@ function AvatarPage() {
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center h-screen justify-center">
|
||||
<div className="flex flex-col items-center mb-4">
|
||||
<span className="text-xl font-bold">تصویر پروفایل</span>
|
||||
<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 ? (
|
||||
<>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
آیا مایل به همکاری خارج از محل سکونت خود هستید؟
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -92,7 +92,7 @@ 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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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) برای درک بهتر گوگل از ماهیت پروفایل
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,19 +363,13 @@ 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>
|
||||
|
||||
@@ -385,15 +380,7 @@ function CommentsModal({
|
||||
{item?.user_id?.user_name || "کاربر ناشناس"}
|
||||
</span>
|
||||
|
||||
{item?.user_id?.is_verified === "verified" && (
|
||||
<Image
|
||||
width={16}
|
||||
height={16}
|
||||
alt="verify"
|
||||
src="/images/icons/verify.svg"
|
||||
className="inline"
|
||||
/>
|
||||
)}
|
||||
<VerificationBadge isVerified={item?.user_id?.is_verified} />
|
||||
|
||||
{item?.rate > 0 && (
|
||||
<div className="mr-2">
|
||||
|
||||
@@ -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";
|
||||
@@ -426,30 +427,7 @@ function MainModelCard({ postData }: { postData: Course }) {
|
||||
</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"
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<VerificationBadge isVerified={is_verified} />
|
||||
<div className="flex items-center gap-0.5 justify-center">
|
||||
<span className="mr-2">{newScore || 0}</span>
|
||||
<Image
|
||||
|
||||
@@ -29,7 +29,8 @@ function ModelHead({ user, item2 }: ModelHeadProps) {
|
||||
user_level,
|
||||
bio,
|
||||
user_type,
|
||||
_id
|
||||
_id,
|
||||
is_Register,
|
||||
} = user;
|
||||
|
||||
|
||||
@@ -118,6 +119,7 @@ function ModelHead({ user, item2 }: ModelHeadProps) {
|
||||
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,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,18 +1,24 @@
|
||||
import React, { ComponentProps } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TInput = ComponentProps<"input"> & {};
|
||||
type TInput = ComponentProps<"input"> & {
|
||||
error?: boolean;
|
||||
success?: boolean;
|
||||
};
|
||||
|
||||
function AuthInput({ style, className, ...rest }: TInput) {
|
||||
function AuthInput({ style, className, error, success, ...rest }: TInput) {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
"w-full max-w-[290px] p-3.5 text-center text-lg font-semibold rounded-2xl",
|
||||
"border-2 border-gray-200 dark:border-gray-300",
|
||||
"bg-white text-gray-900 placeholder:text-gray-400",
|
||||
"border-2 bg-white text-gray-900 placeholder:text-gray-400",
|
||||
"dark:bg-white dark:text-gray-900 dark:placeholder:text-gray-500",
|
||||
"focus:outline-none focus:border-[#387E65] focus:ring-2 focus:ring-[#387E65]/30",
|
||||
"shadow-sm transition-[border-color,box-shadow] duration-200",
|
||||
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 }}
|
||||
|
||||
@@ -10,6 +10,7 @@ interface AuthOtpInputProps {
|
||||
onComplete?: (otp: string) => void;
|
||||
numInputs?: number;
|
||||
error?: boolean;
|
||||
success?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
@@ -23,6 +24,7 @@ export default function AuthOtpInput({
|
||||
onComplete,
|
||||
numInputs = 6,
|
||||
error = false,
|
||||
success = false,
|
||||
disabled = false,
|
||||
className,
|
||||
}: AuthOtpInputProps) {
|
||||
@@ -62,11 +64,16 @@ export default function AuthOtpInput({
|
||||
transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
|
||||
className={cn(
|
||||
cellBase,
|
||||
filled
|
||||
? "border-[#387E65] shadow-lg ring-2 ring-[#387E65]/25"
|
||||
: "border-gray-500 dark:border-gray-400 shadow-md",
|
||||
error && "border-red-500 ring-red-500/30",
|
||||
"focus:border-[#387E65] focus:ring-2 focus:ring-[#387E65]/40 focus:shadow-lg",
|
||||
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"
|
||||
|
||||
@@ -5,12 +5,16 @@ 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);
|
||||
@@ -21,11 +25,14 @@ function AuthPasswordInput({
|
||||
type={visible ? "text" : "password"}
|
||||
className={cn(
|
||||
"w-full dir-ltr p-3.5 px-12 text-center text-lg font-semibold rounded-2xl",
|
||||
"border-2 border-gray-200 dark:border-gray-300",
|
||||
"bg-white text-gray-900 placeholder:text-gray-400",
|
||||
"border-2 bg-white text-gray-900 placeholder:text-gray-400",
|
||||
"dark:bg-white dark:text-gray-900 dark:placeholder:text-gray-500",
|
||||
"focus:outline-none focus:border-[#387E65] focus:ring-2 focus:ring-[#387E65]/30",
|
||||
"shadow-sm transition-[border-color,box-shadow] duration-200",
|
||||
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 }}
|
||||
|
||||
@@ -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,17 @@ 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`}
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<VerificationBadge isVerified={item?.userId?.is_verified} />
|
||||
</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,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import { IAdvertisingProfile } from "@/types/types";
|
||||
import Image from "next/image";
|
||||
|
||||
@@ -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/40 px-3 py-1 text-[12px] font-medium text-white/95 shadow-sm backdrop-blur-sm dark:bg-white/15 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 { FiTrash2 } from "react-icons/fi";
|
||||
|
||||
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"
|
||||
>
|
||||
<FiTrash2 size={18} />
|
||||
{selectedCount > 0 ? `حذف (${selectedCount})` : "حذف"}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +1,89 @@
|
||||
"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 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(
|
||||
"gentle-transition flex h-11 w-11 shrink-0 items-center justify-center rounded-full",
|
||||
"bg-white shadow-[0_2px_8px_rgba(0,0,0,0.1)]",
|
||||
"text-neutral-800 active:scale-90",
|
||||
"dark:bg-[#2c2c2e] dark:text-neutral-100 dark:shadow-[0_2px_8px_rgba(0,0,0,0.4)]"
|
||||
);
|
||||
|
||||
export const chatHeaderInfoPill = cn(
|
||||
"gentle-transition flex h-11 min-w-0 flex-1 items-center gap-2 rounded-full py-[5px] pr-[5px] pl-3",
|
||||
"bg-white shadow-[0_2px_8px_rgba(0,0,0,0.1)]",
|
||||
"active:scale-[0.98]",
|
||||
"dark:bg-[#2c2c2e] dark:shadow-[0_2px_8px_rgba(0,0,0,0.4)]"
|
||||
);
|
||||
|
||||
function ChatHeader({
|
||||
user,
|
||||
searchQuery = "",
|
||||
onSearchChange,
|
||||
onBlockChange,
|
||||
onStartDeleteMode,
|
||||
deleteMode = false,
|
||||
}: {
|
||||
user: User | undefined;
|
||||
searchQuery?: string;
|
||||
onSearchChange?: (q: string) => void;
|
||||
onBlockChange?: () => 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="gentle-transition sticky top-0 z-40 shrink-0 px-2 pb-2 pt-[max(0.5rem,env(safe-area-inset-top))] sm:px-3">
|
||||
<div className="flex items-center justify-between 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} />
|
||||
<FiChevronRight size={22} strokeWidth={2.5} />
|
||||
</button>
|
||||
|
||||
<Link
|
||||
href={`/users/${user?.user_name}`}
|
||||
className="gentle-transition flex min-w-0 flex-1 items-center gap-2 active:opacity-80"
|
||||
className={chatHeaderInfoPill}
|
||||
>
|
||||
{user?.profile_image && !user?.blocked_you ? (
|
||||
<ProfileAvatar
|
||||
src={user.profile_image}
|
||||
alt={user.user_name || ""}
|
||||
size="sm"
|
||||
size="xs"
|
||||
rounded="full"
|
||||
className="h-[34px] w-[34px] shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-10 w-10 shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-700" />
|
||||
<div className="h-[34px] w-[34px] shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-600" />
|
||||
)}
|
||||
<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="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>
|
||||
) : (
|
||||
user?.last_online || user?.user_name
|
||||
user?.user_name || user?.last_online || ""
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -70,9 +94,17 @@ function ChatHeader({
|
||||
searchOpen={searchOpen}
|
||||
onSearchToggle={setSearchOpen}
|
||||
onBlockChange={onBlockChange}
|
||||
onStartDeleteMode={onStartDeleteMode}
|
||||
buttonClassName={chatHeaderCircleBtn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{deleteMode && (
|
||||
<p className="mt-2 text-center text-xs font-medium text-neutral-500 dark:text-neutral-400">
|
||||
پیامهای خود را برای حذف انتخاب کنید
|
||||
</p>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{searchOpen && (
|
||||
<motion.div
|
||||
@@ -95,7 +127,4 @@ function ChatHeader({
|
||||
);
|
||||
}
|
||||
|
||||
// framer-motion for search expand
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
export default ChatHeader;
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FiMoreVertical, FiSearch } from "react-icons/fi";
|
||||
import { FiMoreVertical, FiSearch, FiTrash2 } from "react-icons/fi";
|
||||
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,6 +21,8 @@ export default function ChatHeaderMenu({
|
||||
onSearchToggle,
|
||||
searchOpen,
|
||||
onBlockChange,
|
||||
buttonClassName,
|
||||
onStartDeleteMode,
|
||||
}: ChatHeaderMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isBlocked, setIsBlocked] = useState(user?.is_blocked);
|
||||
@@ -42,10 +47,10 @@ export default function ChatHeaderMenu({
|
||||
<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 && (
|
||||
@@ -68,6 +73,17 @@ export default function ChatHeaderMenu({
|
||||
<FiSearch 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);
|
||||
}}
|
||||
>
|
||||
<FiTrash2 size={16} />
|
||||
حذف
|
||||
</button>
|
||||
{isBlocked ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -15,6 +15,9 @@ import LocationMessageBubble, {
|
||||
} from "./LocationMessageBubble";
|
||||
import { detectChatFileType } from "@/lib/chat/detectFileType";
|
||||
import { formatBubbleTime } from "@/lib/chat/formatMessageTime";
|
||||
import { useLongPress } from "@/hooks/useLongPress";
|
||||
import { FiCheck } from "react-icons/fi";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ChatMessage {
|
||||
_id: string;
|
||||
@@ -42,8 +45,13 @@ interface ChatMessageCardProps {
|
||||
userDetail: User | undefined;
|
||||
selected?: boolean;
|
||||
onBubbleClick?: (message: ChatMessage) => void;
|
||||
deleteMode?: boolean;
|
||||
selectedForDelete?: boolean;
|
||||
onToggleDeleteSelect?: (message: ChatMessage) => void;
|
||||
}
|
||||
|
||||
const LONG_PRESS_MS = 2000;
|
||||
|
||||
function parseForwarded(content: string): ChatMessage["forwardedFrom"] | null {
|
||||
try {
|
||||
const line = content.split("\n")[0];
|
||||
@@ -59,9 +67,33 @@ const ChatMessageCard = ({
|
||||
userDetail,
|
||||
selected = false,
|
||||
onBubbleClick,
|
||||
deleteMode = false,
|
||||
selectedForDelete = false,
|
||||
onToggleDeleteSelect,
|
||||
}: ChatMessageCardProps) => {
|
||||
const isSender = !!(userDetail && message.senderId === userDetail._id);
|
||||
const [preview, setPreview] = useState<string | null>(null);
|
||||
const canSelectForDelete = deleteMode && isSender && !message._id.startsWith("temp-");
|
||||
|
||||
const { pressing, shouldBlockClick, handlers: longPressHandlers } =
|
||||
useLongPress(() => onBubbleClick?.(message), { delay: LONG_PRESS_MS });
|
||||
|
||||
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 forwarded =
|
||||
message.forwardedFrom || parseForwarded(message.content || "");
|
||||
@@ -122,22 +154,24 @@ const ChatMessageCard = ({
|
||||
</span>
|
||||
);
|
||||
|
||||
const handleBubbleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onBubbleClick?.(message);
|
||||
};
|
||||
|
||||
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"}`}
|
||||
className={cn(
|
||||
"message-enter relative my-0.5 flex w-full gap-2",
|
||||
isSender ? "justify-end" : "justify-start",
|
||||
deleteMode && !isSender && "opacity-60"
|
||||
)}
|
||||
dir="rtl"
|
||||
>
|
||||
<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(88%,340px)] flex-col sm:max-w-[85%]",
|
||||
isSender ? "items-end" : "items-start"
|
||||
)}
|
||||
>
|
||||
{forwarded && (
|
||||
<Link
|
||||
@@ -158,19 +192,22 @@ const ChatMessageCard = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBubbleClick}
|
||||
className={`gentle-transition relative text-right outline-none ${tailClass} ${
|
||||
selected ? "ring-2 ring-[#0095f6]/50 rounded-2xl" : ""
|
||||
}`}
|
||||
<div
|
||||
{...bubbleHandlers}
|
||||
className={cn(
|
||||
"gentle-transition relative select-none text-right outline-none touch-manipulation",
|
||||
tailClass,
|
||||
canSelectForDelete && "cursor-pointer",
|
||||
(selected || selectedForDelete) && "rounded-2xl ring-2 ring-[#0095f6]/50",
|
||||
!deleteMode && pressing && "scale-[0.98] opacity-90"
|
||||
)}
|
||||
>
|
||||
{fileType === "image" && fileUrl && (
|
||||
<div
|
||||
className="block overflow-hidden rounded-2xl"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreview(fileUrl);
|
||||
openPreview(fileUrl);
|
||||
}}
|
||||
>
|
||||
<div className="relative aspect-[3/4] w-44 sm:w-52">
|
||||
@@ -181,12 +218,14 @@ const ChatMessageCard = ({
|
||||
)}
|
||||
|
||||
{fileType === "video" && fileUrl && (
|
||||
<div>
|
||||
<VideoMessageBubble
|
||||
src={fileUrl}
|
||||
isOutgoing={isSender}
|
||||
onOpen={() => setPreview(fileUrl)}
|
||||
/>
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openPreview(fileUrl);
|
||||
}}
|
||||
>
|
||||
<VideoMessageBubble src={fileUrl} isOutgoing={isSender} />
|
||||
<TimeBelow />
|
||||
</div>
|
||||
)}
|
||||
@@ -224,8 +263,24 @@ const ChatMessageCard = ({
|
||||
<TimeInline />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canSelectForDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleDeleteSelection}
|
||||
className={cn(
|
||||
"gentle-transition mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-full border-2",
|
||||
selectedForDelete
|
||||
? "border-[#0095f6] bg-[#0095f6] text-white"
|
||||
: "border-neutral-400 bg-transparent"
|
||||
)}
|
||||
aria-label={selectedForDelete ? "لغو انتخاب" : "انتخاب پیام"}
|
||||
>
|
||||
{selectedForDelete && <FiCheck size={14} strokeWidth={3} />}
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<AnimatePresence>
|
||||
|
||||
@@ -7,7 +7,6 @@ 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";
|
||||
@@ -37,6 +36,9 @@ interface ChatMessageListProps {
|
||||
searchQuery?: string;
|
||||
selectedMessageId?: string;
|
||||
onBubbleClick?: (message: ChatMessage) => void;
|
||||
deleteMode?: boolean;
|
||||
selectedDeleteIds?: string[];
|
||||
onToggleDeleteSelect?: (message: ChatMessage) => void;
|
||||
}
|
||||
|
||||
const ChatMessageList = ({
|
||||
@@ -48,6 +50,9 @@ const ChatMessageList = ({
|
||||
searchQuery = "",
|
||||
selectedMessageId,
|
||||
onBubbleClick,
|
||||
deleteMode = false,
|
||||
selectedDeleteIds = [],
|
||||
onToggleDeleteSelect,
|
||||
}: ChatMessageListProps) => {
|
||||
const [receiverId, setReceiverId] = useState("");
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -67,10 +72,7 @@ const ChatMessageList = ({
|
||||
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),
|
||||
}));
|
||||
const msgs = (response.data.messages || []) as ChatMessage[];
|
||||
return {
|
||||
messages: msgs,
|
||||
nextPage:
|
||||
@@ -140,10 +142,7 @@ const ChatMessageList = ({
|
||||
socket.on("newMessage", (message: ChatMessage) => {
|
||||
if (!belongs(message)) return;
|
||||
|
||||
const formatted = {
|
||||
...message,
|
||||
createdAt: formatBubbleTime(message.createdAt),
|
||||
};
|
||||
const formatted = { ...message };
|
||||
|
||||
queryClient.setQueryData(
|
||||
["messages", userDetail._id, receiverId],
|
||||
@@ -200,9 +199,31 @@ const ChatMessageList = ({
|
||||
}
|
||||
});
|
||||
|
||||
socket.on(
|
||||
"messagesDeleted",
|
||||
({ messageIds }: { messageIds: string[] }) => {
|
||||
if (!messageIds?.length) return;
|
||||
const idSet = new Set(messageIds);
|
||||
queryClient.setQueryData(
|
||||
["messages", userDetail._id, 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();
|
||||
};
|
||||
@@ -252,7 +273,10 @@ const ChatMessageList = ({
|
||||
message={item.message}
|
||||
userDetail={userDetail}
|
||||
selected={selectedMessageId === item.message._id}
|
||||
onBubbleClick={onBubbleClick}
|
||||
onBubbleClick={deleteMode ? undefined : onBubbleClick}
|
||||
deleteMode={deleteMode}
|
||||
selectedForDelete={selectedDeleteIds.includes(item.message._id)}
|
||||
onToggleDeleteSelect={onToggleDeleteSelect}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -5,20 +5,16 @@ 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] ${
|
||||
<div
|
||||
className={`relative max-w-[min(280px,85vw)] overflow-hidden rounded-2xl ${
|
||||
isOutgoing ? "ring-1 ring-white/20" : "shadow-md"
|
||||
}`}
|
||||
>
|
||||
@@ -34,6 +30,6 @@ export default function VideoMessageBubble({
|
||||
<FiPlay className="ml-0.5 text-2xl text-white" />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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]",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -2,6 +2,7 @@ import Image from "next/image";
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import ProfileAvatar from "./ProfileAvatar";
|
||||
import VerificationBadge from "./VerificationBadge";
|
||||
|
||||
interface IUserInfoProps {
|
||||
profile_image: string | null | undefined;
|
||||
@@ -46,30 +47,7 @@ function UserInfo({
|
||||
<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"
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<VerificationBadge isVerified={is_verified} />
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
68
src/components/main/VerificationBadge.tsx
Normal file
68
src/components/main/VerificationBadge.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const VERIFICATION_BADGE_SIZE = 22;
|
||||
|
||||
type VerificationBadgeProps = {
|
||||
isVerified?: string | null;
|
||||
isRegister?: string | boolean | null;
|
||||
className?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export function resolveVerificationStatus(
|
||||
isVerified?: string | null,
|
||||
isRegister?: string | boolean | null
|
||||
): string | null {
|
||||
if (isVerified && isVerified !== "none") {
|
||||
return isVerified;
|
||||
}
|
||||
|
||||
if (isRegister === true || isRegister === "true") {
|
||||
return "pending";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getVerificationIcon(
|
||||
isVerified?: string | null,
|
||||
isRegister?: string | boolean | null
|
||||
): { src: string; alt: string } | null {
|
||||
const status = resolveVerificationStatus(isVerified, isRegister);
|
||||
if (!status) return null;
|
||||
|
||||
if (status === "true") {
|
||||
return { src: "/images/icons/verify2.svg", alt: "تیک مجوز" };
|
||||
}
|
||||
if (status === "verified") {
|
||||
return { src: "/images/icons/verify.svg", alt: "تیک تایید" };
|
||||
}
|
||||
if (status === "pending") {
|
||||
return { src: "/images/icons/not-verify.svg", alt: "در انتظار تایید" };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function VerificationBadge({
|
||||
isVerified,
|
||||
isRegister,
|
||||
className,
|
||||
size = VERIFICATION_BADGE_SIZE,
|
||||
}: VerificationBadgeProps) {
|
||||
const icon = getVerificationIcon(isVerified, isRegister);
|
||||
if (!icon) return null;
|
||||
|
||||
return (
|
||||
<Image
|
||||
width={size}
|
||||
height={size}
|
||||
alt={icon.alt}
|
||||
src={icon.src}
|
||||
className={cn("shrink-0", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default VerificationBadge;
|
||||
@@ -1,15 +1,13 @@
|
||||
import React, { useState, useEffect } 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 RoundedInput from "@/components/elements/RoundedInput";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import toast from "react-hot-toast";
|
||||
import Rate from "rc-rate";
|
||||
import "rc-rate/assets/index.css";
|
||||
import { FiSend } from "react-icons/fi";
|
||||
import Cookies from "js-cookie";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
|
||||
export interface Comments {
|
||||
_id: string;
|
||||
@@ -37,10 +35,6 @@ type CommentsModalProps = {
|
||||
postId?: string;
|
||||
};
|
||||
|
||||
function ratingStorageKey(profileUserId: string) {
|
||||
return `profile_star_rating_${profileUserId}`;
|
||||
}
|
||||
|
||||
function CommentsModal({ isOpen, onClose, userId, postId }: CommentsModalProps) {
|
||||
const [hasRatedProfile, setHasRatedProfile] = useState(false);
|
||||
const [newMessage, setNewMessage] = useState("");
|
||||
@@ -56,9 +50,11 @@ function CommentsModal({ isOpen, onClose, userId, postId }: CommentsModalProps)
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
setHasRatedProfile(!!localStorage.getItem(ratingStorageKey(userId)));
|
||||
}, [userId, isOpen]);
|
||||
const hasRated = data?.pages?.[0]?.has_rated;
|
||||
if (typeof hasRated === "boolean") {
|
||||
setHasRatedProfile(hasRated);
|
||||
}
|
||||
}, [data, isOpen, userId]);
|
||||
|
||||
const allComments =
|
||||
data?.pages?.flatMap((page) => page?.comments || []) || [];
|
||||
@@ -93,7 +89,6 @@ function CommentsModal({ isOpen, onClose, userId, postId }: CommentsModalProps)
|
||||
|
||||
await request("POST", "/users/comments", body);
|
||||
if (!hasRatedProfile && rating) {
|
||||
localStorage.setItem(ratingStorageKey(userId), String(rating));
|
||||
setHasRatedProfile(true);
|
||||
}
|
||||
setNewMessage("");
|
||||
@@ -119,17 +114,16 @@ function CommentsModal({ isOpen, onClose, userId, postId }: CommentsModalProps)
|
||||
key={item?._id}
|
||||
className="mb-4 flex flex-row border-b border-neutral-100 pb-2 dark:border-neutral-800"
|
||||
>
|
||||
<Image
|
||||
className="ml-2 aspect-square min-h-16 max-h-16 min-w-16 max-w-16 rounded-xl object-cover"
|
||||
width={50}
|
||||
height={50}
|
||||
alt={item?.user?.user_name}
|
||||
src={buildStorageUrl(item?.user?.profile_image)}
|
||||
unoptimized
|
||||
<ProfileAvatar
|
||||
src={item?.user?.profile_image}
|
||||
alt={item?.user?.user_name || "کاربر"}
|
||||
size="chat"
|
||||
rounded="full"
|
||||
className="ml-2"
|
||||
/>
|
||||
<div className="text-sm">
|
||||
<div className="font-semibold">{item?.user?.user_name}</div>
|
||||
{item?.rating != null && (
|
||||
{item?.rating != null && item.rating > 0 && (
|
||||
<span className="text-amber-500">★ {item.rating}</span>
|
||||
)}
|
||||
<p className="mt-1">{item.comment}</p>
|
||||
|
||||
@@ -12,6 +12,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";
|
||||
@@ -183,30 +184,7 @@ function MainModelCard({ postData }: { postData: Post }) {
|
||||
<div className="font-semibold flex gap-2 flex-col-reverse ml-2 w-full">
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<h2 className="max-sm:text-xs">{user_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"
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<VerificationBadge isVerified={is_verified} />
|
||||
<div className="flex items-center gap-0.5 justify-center">
|
||||
<span className="mr-2">{user_score || 0}</span>
|
||||
<Image
|
||||
|
||||
@@ -6,6 +6,7 @@ import Image from "next/image";
|
||||
import MainModelCardActions from "../MainModelCard/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 Slider from "react-slick";
|
||||
import "slick-carousel/slick/slick.css";
|
||||
@@ -181,14 +182,7 @@ function MainModelCard({ postData }: { postData: Post }) {
|
||||
<div className="font-semibold flex gap-2 flex-col-reverse ml-2 w-full">
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className="max-sm:text-xs">{user_name}</span>
|
||||
{is_verified === "verified" && (
|
||||
<Image
|
||||
width={22}
|
||||
height={22}
|
||||
alt="verify icon"
|
||||
src="/images/icons/verify.svg"
|
||||
/>
|
||||
)}
|
||||
<VerificationBadge isVerified={is_verified} />
|
||||
<div className="flex items-center gap-0.5 justify-center">
|
||||
<span className="mr-2">{user_score || 0}</span>
|
||||
<Image
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import { User } from "@/types/types";
|
||||
import React, { useState } from "react";
|
||||
@@ -29,7 +31,8 @@ function ModelHead({ user, item2 }: ModelHeadProps) {
|
||||
user_level,
|
||||
bio,
|
||||
user_type,
|
||||
_id
|
||||
_id,
|
||||
is_Register,
|
||||
} = user;
|
||||
|
||||
|
||||
@@ -118,6 +121,7 @@ function ModelHead({ user, item2 }: ModelHeadProps) {
|
||||
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,4 +1,5 @@
|
||||
import { useUserById } from "@/hooks/getUserById";
|
||||
import VerificationBadge from "@/components/main/VerificationBadge";
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
|
||||
@@ -6,10 +7,11 @@ interface ProfileHeadRowTwoProps {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
is_verified: string;
|
||||
is_Register?: string | boolean | null;
|
||||
user_name: string;
|
||||
user_score?: string;
|
||||
rate?: string;
|
||||
user_id:string
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
function ModelHeadRowTwo({
|
||||
@@ -18,12 +20,13 @@ function ModelHeadRowTwo({
|
||||
user_name,
|
||||
user_score,
|
||||
rate,
|
||||
user_id
|
||||
user_id,
|
||||
is_verified: isVerifiedProp,
|
||||
is_Register: isRegisterProp,
|
||||
}: ProfileHeadRowTwoProps) {
|
||||
|
||||
|
||||
const user = useUserById( user_id);
|
||||
const is_verified = user?.is_verified;
|
||||
const user = useUserById(user_id);
|
||||
const is_verified = isVerifiedProp ?? user?.is_verified;
|
||||
const is_Register = isRegisterProp ?? user?.is_Register;
|
||||
return (
|
||||
<div className="px-4 py-2 grid w-full grid-cols-3 items-end text-xs md:text-sm font-semibold">
|
||||
<div className="flex items-center">
|
||||
@@ -54,33 +57,12 @@ function ModelHeadRowTwo({
|
||||
</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
<h3 className="flex gap-1 flex-row-reverse">
|
||||
<h3 className="flex items-center gap-1 flex-row-reverse">
|
||||
{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"
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<VerificationBadge
|
||||
isVerified={is_verified}
|
||||
isRegister={is_Register}
|
||||
/>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { IProjectCreator } from "@/types/types";
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";import VerificationBadge from "@/components/main/VerificationBadge";
|
||||
import Link from "next/link";
|
||||
|
||||
function ProjectCreator({ creator }: { creator: IProjectCreator }) {
|
||||
@@ -31,23 +30,7 @@ function ProjectCreator({ creator }: { creator: IProjectCreator }) {
|
||||
</span>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
{creator?.user_name}
|
||||
{creator?.is_verified === "verified" ? (
|
||||
<Image
|
||||
width={22}
|
||||
height={22}
|
||||
alt={"verify icon"}
|
||||
src={`/images/icons/verify.svg`}
|
||||
/>
|
||||
) : creator?.is_verified === "pending" ? (
|
||||
<Image
|
||||
width={25}
|
||||
height={25}
|
||||
alt={"not-verify icon"}
|
||||
src={`/images/icons/not-verify.svg`}
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<VerificationBadge isVerified={creator?.is_verified} />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import Rate from "rc-rate";
|
||||
import "rc-rate/assets/index.css";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
@@ -20,48 +20,77 @@ function ProjectWorkroomRate({
|
||||
const router = useRouter();
|
||||
const [rating, setRating] = useState<number>(0);
|
||||
const [commentText, setCommentText] = useState<string>("");
|
||||
const [hasRatedUser, setHasRatedUser] = useState(false);
|
||||
|
||||
const targetUserId =
|
||||
typeof selected_user === "string" ? selected_user : selected_user?._id;
|
||||
|
||||
useEffect(() => {
|
||||
if (!targetUserId) return;
|
||||
|
||||
const fetchRatingStatus = async () => {
|
||||
try {
|
||||
const response = (await request(
|
||||
"GET",
|
||||
`/users/comments?user_id=${targetUserId}&limit=1`
|
||||
)) as { has_rated?: boolean };
|
||||
setHasRatedUser(!!response?.has_rated);
|
||||
} catch {
|
||||
setHasRatedUser(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRatingStatus();
|
||||
}, [targetUserId, request]);
|
||||
|
||||
const doneHandler = async () => {
|
||||
if (!commentText) {
|
||||
toast.error("ثبت نظر الزامی است.");
|
||||
return;
|
||||
}
|
||||
if (!rating) {
|
||||
if (!hasRatedUser && !rating) {
|
||||
toast.error("ثبت امتیاز الزامی است.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await request("POST", "/projects/done/web", {
|
||||
const body: Record<string, unknown> = {
|
||||
project_id: projectId,
|
||||
comment: commentText,
|
||||
rate: rating,
|
||||
user_id: selected_user,
|
||||
});
|
||||
user_id: targetUserId,
|
||||
};
|
||||
if (!hasRatedUser && rating) body.rate = rating;
|
||||
|
||||
await request("POST", "/projects/done/web", body);
|
||||
router.push("/settings/workroom");
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
const cancleHandler = async () => {
|
||||
if (!commentText) {
|
||||
toast.error("ثبت نظر الزامی است.");
|
||||
return;
|
||||
}
|
||||
if (!rating) {
|
||||
if (!hasRatedUser && !rating) {
|
||||
toast.error("ثبت امتیاز الزامی است.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await request("POST", "/projects/cancle", {
|
||||
const body: Record<string, unknown> = {
|
||||
project_id: projectId,
|
||||
comment: commentText,
|
||||
rate: rating,
|
||||
user_id: selected_user,
|
||||
});
|
||||
user_id: targetUserId,
|
||||
};
|
||||
if (!hasRatedUser && rating) body.rate = rating;
|
||||
|
||||
await request("POST", "/projects/cancle", body);
|
||||
router.push("/settings/workroom");
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 w-full my-7">
|
||||
<textarea
|
||||
@@ -70,12 +99,14 @@ function ProjectWorkroomRate({
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
className={`w-full p-4 h-20 rounded-3xl border border-border-secondary-light dark:border-border-secondary-dark bg-secondary-light dark:bg-secondary-dark font-medium `}
|
||||
/>
|
||||
<Rate
|
||||
value={rating}
|
||||
onChange={(value) => setRating(value)}
|
||||
count={5}
|
||||
style={{ fontSize: "28px" }}
|
||||
/>
|
||||
{!hasRatedUser && (
|
||||
<Rate
|
||||
value={rating}
|
||||
onChange={(value) => setRating(value)}
|
||||
count={5}
|
||||
style={{ fontSize: "28px" }}
|
||||
/>
|
||||
)}
|
||||
<div className="grid grid-cols-2 w-full max-w-sm gap-4 text-sm">
|
||||
<RoundedButton
|
||||
onClick={doneHandler}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { usePageTitle } from "@/hooks/usePageTitle";
|
||||
|
||||
type PageTitleProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const PageTitle: React.FC<PageTitleProps> = ({ children }) => {
|
||||
const titleText = typeof children === "string" ? children : null;
|
||||
usePageTitle(titleText);
|
||||
|
||||
return (
|
||||
<h6 className="text-title-light dark:text-text-title-dark font-bold text-center text-2xl my-7">
|
||||
<h1 className="my-7 text-center text-2xl font-bold text-foreground">
|
||||
{children}
|
||||
</h6>
|
||||
</h1>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import ProfileAvatar from "../main/ProfileAvatar";
|
||||
import VerificationBadge from "../main/VerificationBadge";
|
||||
import CompleteRegister from "./settings/CompleteRegister";
|
||||
import Link from "next/link";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
@@ -38,30 +38,10 @@ function UserDetails() {
|
||||
</span>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
{user?.user_name}
|
||||
{isRegister === "verified" ? (
|
||||
<Image
|
||||
width={22}
|
||||
height={22}
|
||||
alt={"verify icon"}
|
||||
src={`/images/icons/verify.svg`}
|
||||
/>
|
||||
) : isRegister === "pending" ? (
|
||||
<Image
|
||||
width={25}
|
||||
height={25}
|
||||
alt={"not-verify icon"}
|
||||
src={`/images/icons/not-verify.svg`}
|
||||
/>
|
||||
) : isRegister === "true" ? (
|
||||
<Image
|
||||
width={22}
|
||||
height={22}
|
||||
alt="verify icon"
|
||||
src="/images/icons/verify2.svg"
|
||||
/>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<VerificationBadge
|
||||
isVerified={user?.is_verified}
|
||||
isRegister={user?.is_Register}
|
||||
/>
|
||||
<span className="mr-4">{user?.mobile}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import Rate from "rc-rate";
|
||||
import "rc-rate/assets/index.css";
|
||||
import Modal from "@/components/elements/Modal";
|
||||
@@ -17,36 +17,64 @@ function CommentModal({ isOpen, onClose, item }: CommentModalProps) {
|
||||
const { request } = useAxios();
|
||||
const [rating, setRating] = useState<number>(0);
|
||||
const [commentText, setCommentText] = useState<string>("");
|
||||
const [hasRatedUser, setHasRatedUser] = useState(false);
|
||||
|
||||
const receiverId = item?.receiver?._id;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !receiverId) return;
|
||||
|
||||
const fetchRatingStatus = async () => {
|
||||
try {
|
||||
const response = (await request(
|
||||
"GET",
|
||||
`/users/comments?user_id=${receiverId}&limit=1`
|
||||
)) as { has_rated?: boolean };
|
||||
setHasRatedUser(!!response?.has_rated);
|
||||
} catch {
|
||||
setHasRatedUser(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchRatingStatus();
|
||||
}, [isOpen, receiverId, request]);
|
||||
|
||||
const submitComment = async () => {
|
||||
if (!commentText) {
|
||||
toast.error("متن نظر الزامی است.");
|
||||
return;
|
||||
}
|
||||
if (!rating) {
|
||||
if (!hasRatedUser && !rating) {
|
||||
toast.error("امتیاز الزامی است.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await request("POST", "/offers/comment", {
|
||||
const body: Record<string, unknown> = {
|
||||
offerId: item ? item?._id : "",
|
||||
comment: commentText,
|
||||
rate: rating,
|
||||
user_id: item ? item?.receiver?._id : "",
|
||||
});
|
||||
user_id: receiverId,
|
||||
};
|
||||
if (!hasRatedUser && rating) body.rate = rating;
|
||||
|
||||
await request("POST", "/offers/comment", body);
|
||||
onClose();
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal height="440px" isOpen={isOpen} onClose={onClose}>
|
||||
<div className="flex flex-col items-center gap-6 mt-5">
|
||||
<span>ثبت نظر</span>
|
||||
<Rate
|
||||
value={rating}
|
||||
onChange={(value) => setRating(value)}
|
||||
count={5}
|
||||
style={{ fontSize: "28px" }}
|
||||
/>
|
||||
{!hasRatedUser && (
|
||||
<Rate
|
||||
value={rating}
|
||||
onChange={(value) => setRating(value)}
|
||||
count={5}
|
||||
style={{ fontSize: "28px" }}
|
||||
/>
|
||||
)}
|
||||
<textarea
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
|
||||
@@ -60,7 +60,9 @@ export function SiteHeader() {
|
||||
orientation="vertical"
|
||||
className="mx-2 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<h1 className="text-base font-medium">{academy?.academy_name}</h1>
|
||||
<h1 className="text-base font-medium text-foreground">
|
||||
{academy?.academy_name || "پنل آکادمی"}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -57,6 +57,10 @@ axiosInstance.interceptors.response.use(
|
||||
|
||||
if (status === 401 && !noToast) {
|
||||
handleUnauthorized();
|
||||
} else if (status === 403 && (data as { type?: string })?.type === "block") {
|
||||
if (!noToast && (data as { message?: string })?.message) {
|
||||
toast.error((data as { message: string }).message);
|
||||
}
|
||||
} else if (status === 422) {
|
||||
const errors = (data as { [key: string]: string[] }).errors || {};
|
||||
if (!noToast) {
|
||||
@@ -73,6 +77,13 @@ axiosInstance.interceptors.response.use(
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (status === 404 && !noToast) {
|
||||
if (
|
||||
typeof data === "object" &&
|
||||
(data as { message?: string }).message
|
||||
) {
|
||||
toast.error((data as { message: string }).message);
|
||||
}
|
||||
} else if ([429, 500, 503].includes(status)) {
|
||||
if (!noToast) {
|
||||
// toast.error("An unexpected error occurred. Please try again later.");
|
||||
|
||||
66
src/hooks/useLongPress.ts
Normal file
66
src/hooks/useLongPress.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
type LongPressOptions = {
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
export function useLongPress(
|
||||
onLongPress: () => void,
|
||||
{ delay = 2000 }: LongPressOptions = {}
|
||||
) {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const blockClickRef = useRef(false);
|
||||
const [pressing, setPressing] = useState(false);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
blockClickRef.current = false;
|
||||
setPressing(true);
|
||||
clear();
|
||||
timerRef.current = setTimeout(() => {
|
||||
blockClickRef.current = true;
|
||||
setPressing(false);
|
||||
if (typeof navigator !== "undefined" && navigator.vibrate) {
|
||||
navigator.vibrate(15);
|
||||
}
|
||||
onLongPress();
|
||||
}, delay);
|
||||
},
|
||||
[clear, delay, onLongPress]
|
||||
);
|
||||
|
||||
const endPress = useCallback(() => {
|
||||
clear();
|
||||
setPressing(false);
|
||||
}, [clear]);
|
||||
|
||||
const shouldBlockClick = useCallback(() => {
|
||||
if (blockClickRef.current) {
|
||||
blockClickRef.current = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
pressing,
|
||||
shouldBlockClick,
|
||||
handlers: {
|
||||
onPointerDown,
|
||||
onPointerUp: endPress,
|
||||
onPointerLeave: endPress,
|
||||
onPointerCancel: endPress,
|
||||
onContextMenu: (e: React.MouseEvent) => e.preventDefault(),
|
||||
},
|
||||
};
|
||||
}
|
||||
23
src/hooks/usePageTitle.ts
Normal file
23
src/hooks/usePageTitle.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { defaultSEOConfig } from "@/config/seoConfig";
|
||||
|
||||
const DEFAULT_TITLE =
|
||||
typeof defaultSEOConfig.title === "string"
|
||||
? defaultSEOConfig.title
|
||||
: "مدستاگرام";
|
||||
|
||||
export function formatPageTitle(title: string) {
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) return DEFAULT_TITLE;
|
||||
if (trimmed.includes("مدستاگرام")) return trimmed;
|
||||
return `${trimmed} | مدستاگرام`;
|
||||
}
|
||||
|
||||
export function usePageTitle(title?: string | null) {
|
||||
useEffect(() => {
|
||||
if (!title?.trim()) return;
|
||||
document.title = formatPageTitle(title);
|
||||
}, [title]);
|
||||
}
|
||||
5
src/lib/auth/feedback.ts
Normal file
5
src/lib/auth/feedback.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const AUTH_SUCCESS_DELAY_MS = 600;
|
||||
|
||||
export function pause(ms: number) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -1,3 +1,37 @@
|
||||
/** Parse message timestamp (ISO or datetime string) for grouping */
|
||||
export function parseMessageDate(createdAt?: string | null): Date | null {
|
||||
if (!createdAt) return null;
|
||||
const raw = String(createdAt).trim();
|
||||
const d = new Date(raw);
|
||||
if (!Number.isNaN(d.getTime())) return d;
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSameDay(a: Date, b: Date): boolean {
|
||||
return a.toDateString() === b.toDateString();
|
||||
}
|
||||
|
||||
/** Telegram-style date label: امروز، دیروز، or calendar date */
|
||||
export function formatChatDateLabel(createdAt?: string | null): string {
|
||||
const d = parseMessageDate(createdAt) ?? new Date();
|
||||
const today = new Date();
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
|
||||
if (isSameDay(d, today)) return "امروز";
|
||||
if (isSameDay(d, yesterday)) return "دیروز";
|
||||
|
||||
if (d.getFullYear() === today.getFullYear()) {
|
||||
return d.toLocaleDateString("fa-IR", { month: "long", day: "numeric" });
|
||||
}
|
||||
|
||||
return d.toLocaleDateString("fa-IR", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/** Bubble time: hours, minutes, seconds only (no date) */
|
||||
export function formatBubbleTime(createdAt?: string | null): string {
|
||||
if (!createdAt) {
|
||||
|
||||
@@ -1,43 +1,17 @@
|
||||
import type { ChatMessage } from "@/components/chat/ChatMessageCard";
|
||||
import {
|
||||
formatChatDateLabel,
|
||||
parseMessageDate,
|
||||
} from "@/lib/chat/formatMessageTime";
|
||||
|
||||
export type MessageGroup =
|
||||
| { type: "date"; label: string; key: string }
|
||||
| { type: "message"; message: ChatMessage; key: string };
|
||||
|
||||
function normalizeDateInput(isoOrTime?: string | null): string {
|
||||
if (isoOrTime == null || String(isoOrTime).trim() === "") {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
return String(isoOrTime);
|
||||
}
|
||||
|
||||
function formatDateLabel(isoOrTime?: string | null): string {
|
||||
const raw = normalizeDateInput(isoOrTime);
|
||||
const d = new Date(raw);
|
||||
if (!Number.isNaN(d.getTime())) {
|
||||
const today = new Date();
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
if (d.toDateString() === today.toDateString()) return "امروز";
|
||||
if (d.toDateString() === yesterday.toDateString()) return "دیروز";
|
||||
return d.toLocaleDateString("fa-IR", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
const fallback = raw.split(" ")[0];
|
||||
return fallback || "امروز";
|
||||
}
|
||||
|
||||
function dayKey(isoOrTime?: string | null): string {
|
||||
const raw = normalizeDateInput(isoOrTime);
|
||||
const d = new Date(raw);
|
||||
if (!Number.isNaN(d.getTime())) {
|
||||
return d.toDateString();
|
||||
}
|
||||
return raw.split(" ")[0] || "unknown-day";
|
||||
function dayKey(createdAt?: string | null): string {
|
||||
const d = parseMessageDate(createdAt);
|
||||
if (d) return d.toDateString();
|
||||
return "unknown-day";
|
||||
}
|
||||
|
||||
export function groupMessagesByDate(messages: ChatMessage[]): MessageGroup[] {
|
||||
@@ -52,7 +26,7 @@ export function groupMessagesByDate(messages: ChatMessage[]): MessageGroup[] {
|
||||
lastDay = dk;
|
||||
groups.push({
|
||||
type: "date",
|
||||
label: formatDateLabel(msg.createdAt),
|
||||
label: formatChatDateLabel(msg.createdAt),
|
||||
key: `date-${dk}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,7 +33,9 @@ export function generatePageMetadata(options: PageMetadataOptions): Metadata {
|
||||
: defaultSEOConfig.openGraph?.images || []; // اگر تصویر خاصی نیست، از تصاویر پیشفرض استفاده کند
|
||||
|
||||
return {
|
||||
title: finalTitle,
|
||||
title: {
|
||||
absolute: finalTitle,
|
||||
},
|
||||
description: finalDescription,
|
||||
alternates: {
|
||||
canonical: fullUrl,
|
||||
|
||||
Reference in New Issue
Block a user