Initial commit

This commit is contained in:
payacom
2026-07-07 18:21:47 +03:30
parent 41b380452f
commit 2b96445a54
69 changed files with 1007 additions and 553 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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 || "")}`}
>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 ? (
<>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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">
آیا مایل به همکاری خارج از محل سکونت خود هستید؟

View File

@@ -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>

View File

@@ -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"

View File

@@ -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">

View File

@@ -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 />

View File

@@ -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

View File

@@ -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]">

View File

@@ -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>;

View File

@@ -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() || "")}`,

View File

@@ -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);

View File

@@ -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) برای درک بهتر گوگل از ماهیت پروفایل

View File

@@ -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}

View File

@@ -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;
}
}

View File

@@ -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">

View File

@@ -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

View File

@@ -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}

View File

@@ -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"
/>

View File

@@ -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 }}

View File

@@ -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"

View File

@@ -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 }}

View File

@@ -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>

View File

@@ -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">

View File

@@ -1,3 +1,5 @@
"use client";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import { IAdvertisingProfile } from "@/types/types";
import Image from "next/image";

View File

@@ -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>

View 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>
);
}

View File

@@ -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;

View File

@@ -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"

View File

@@ -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>

View File

@@ -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}
/>
)
)}

View File

@@ -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>
);
}

View File

@@ -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)}

View File

@@ -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]",

View File

@@ -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"

View File

@@ -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>

View 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;

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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)}

View File

@@ -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>

View File

@@ -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
View 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
View 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
View 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));
}

View File

@@ -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) {

View File

@@ -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}`,
});
}

View File

@@ -33,7 +33,9 @@ export function generatePageMetadata(options: PageMetadataOptions): Metadata {
: defaultSEOConfig.openGraph?.images || []; // اگر تصویر خاصی نیست، از تصاویر پیش‌فرض استفاده کند
return {
title: finalTitle,
title: {
absolute: finalTitle,
},
description: finalDescription,
alternates: {
canonical: fullUrl,