+
+ {isChatThread && onSelfDestructChange && (
+
+ )}
+ {isChatThread && onViewOnceChange && (
+
+ )}
{/* Glass input box */}
-
+
stopRecording(false)}
className="rounded-full p-1"
>
-
+
) : (
@@ -277,10 +327,10 @@ const MessageInput = ({
type="button"
disabled={blocked_you}
onClick={sendMessage}
- className="ig-dm-send-btn gentle-transition mb-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-white shadow-lg active:scale-90 sm:h-11 sm:w-11"
+ className="ig-dm-send-btn gentle-transition flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-white shadow-lg active:scale-90"
aria-label="ارسال"
>
-
+
) : (
isRecording && stopRecording(true)}
- className={`gentle-transition mb-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-full sm:h-11 sm:w-11 ${
+ className={`gentle-transition flex h-11 w-11 shrink-0 items-center justify-center rounded-full ${
isRecording
? "scale-110 bg-red-500 text-white shadow-inner"
: "ig-dm-send-btn text-white shadow-lg"
} active:scale-90`}
aria-label="ضبط ویس"
>
- {isRecording ? : }
+ {isRecording ? : }
)}
diff --git a/src/components/chat/MultiImageModal.tsx b/src/components/chat/MultiImageModal.tsx
index 5ee9d21..c191410 100644
--- a/src/components/chat/MultiImageModal.tsx
+++ b/src/components/chat/MultiImageModal.tsx
@@ -4,12 +4,16 @@ import Image from "next/image";
import { motion } from "framer-motion";
import Modal from "../elements/Modal";
+import ViewOnceToggle from "./ViewOnceToggle";
+
interface MultiImageModalProps {
isOpen: boolean;
files: File[];
onRemove: (index: number) => void;
onCancel: () => void;
onConfirm: () => void;
+ viewOnceMedia?: boolean;
+ onViewOnceChange?: (value: boolean) => void;
}
export default function MultiImageModal({
@@ -18,6 +22,8 @@ export default function MultiImageModal({
onRemove,
onCancel,
onConfirm,
+ viewOnceMedia = false,
+ onViewOnceChange,
}: MultiImageModalProps) {
if (!isOpen || files.length === 0) return null;
@@ -51,7 +57,17 @@ export default function MultiImageModal({
))}
-
+
+ {onViewOnceChange && (
+
+
+ ارسال بهصورت یکبار مصرف
+
+ )}
+
+
diff --git a/src/components/chat/TimedMessagePicker.tsx b/src/components/chat/TimedMessagePicker.tsx
new file mode 100644
index 0000000..66bc338
--- /dev/null
+++ b/src/components/chat/TimedMessagePicker.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import ChatBoldIcon from "./ChatBoldIcon";
+import { AnimatePresence, motion } from "framer-motion";
+import { cn } from "@/lib/utils";
+import {
+ TIMED_MESSAGE_OPTIONS,
+ formatTimedLabel,
+} from "@/lib/chat/timedMessages";
+
+interface TimedMessagePickerProps {
+ value: number | null;
+ onChange: (seconds: number | null) => void;
+ disabled?: boolean;
+}
+
+export default function TimedMessagePicker({
+ value,
+ onChange,
+ disabled,
+}: TimedMessagePickerProps) {
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+
+ useEffect(() => {
+ if (!open) return;
+ const close = (e: MouseEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node)) {
+ setOpen(false);
+ }
+ };
+ document.addEventListener("mousedown", close);
+ return () => document.removeEventListener("mousedown", close);
+ }, [open]);
+
+ const active = value != null;
+
+ return (
+
+
+
+
+ {open && (
+
+
+ حذف خودکار بعد از
+
+ {TIMED_MESSAGE_OPTIONS.map((opt) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/chat/VideoMessageBubble.tsx b/src/components/chat/VideoMessageBubble.tsx
index 73341e9..1021847 100644
--- a/src/components/chat/VideoMessageBubble.tsx
+++ b/src/components/chat/VideoMessageBubble.tsx
@@ -1,39 +1,35 @@
-"use client";
-
-import { FiPlay } from "react-icons/fi";
-
-interface VideoMessageBubbleProps {
- src: string;
- isOutgoing?: boolean;
- onOpen: () => void;
-}
-
-/** Full video preview — not compressed file card */
-export default function VideoMessageBubble({
- src,
- isOutgoing,
- onOpen,
-}: VideoMessageBubbleProps) {
- return (
-
- );
-}
+"use client";
+
+import BoldIcon from "@/components/ui/BoldIcon";
+
+interface VideoMessageBubbleProps {
+ src: string;
+ isOutgoing?: boolean;
+}
+
+/** Full video preview — not compressed file card */
+export default function VideoMessageBubble({
+ src,
+ isOutgoing,
+}: VideoMessageBubbleProps) {
+ return (
+
+ );
+}
diff --git a/src/components/chat/ViewOnceMediaBubble.tsx b/src/components/chat/ViewOnceMediaBubble.tsx
new file mode 100644
index 0000000..3079b0c
--- /dev/null
+++ b/src/components/chat/ViewOnceMediaBubble.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import BoldIcon from "@/components/ui/BoldIcon";
+import { viewOnceLabel } from "@/lib/chat/viewOnce";
+import ChatBoldIcon from "./ChatBoldIcon";
+
+interface ViewOnceMediaBubbleProps {
+ fileType: "image" | "video" | "voice";
+ isOutgoing?: boolean;
+ expired?: boolean;
+ onOpen?: () => void;
+ borderRadius?: string;
+}
+
+export default function ViewOnceMediaBubble({
+ fileType,
+ isOutgoing = false,
+ expired = false,
+ onOpen,
+ borderRadius = "18px",
+}: ViewOnceMediaBubbleProps) {
+ const mediaIcon =
+ fileType === "voice" ? (
+
+ ) : fileType === "video" ? (
+
+ ) : (
+
+ );
+
+ return (
+
+ );
+}
diff --git a/src/components/chat/ViewOnceToggle.tsx b/src/components/chat/ViewOnceToggle.tsx
new file mode 100644
index 0000000..9fcf52a
--- /dev/null
+++ b/src/components/chat/ViewOnceToggle.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import ChatBoldIcon from "./ChatBoldIcon";
+
+interface ViewOnceToggleProps {
+ value: boolean;
+ onChange: (value: boolean) => void;
+ disabled?: boolean;
+}
+
+export default function ViewOnceToggle({
+ value,
+ onChange,
+ disabled,
+}: ViewOnceToggleProps) {
+ return (
+
+ );
+}
diff --git a/src/components/chat/VoiceMessagePlayer.tsx b/src/components/chat/VoiceMessagePlayer.tsx
index 6fb7e80..f5e0e6a 100644
--- a/src/components/chat/VoiceMessagePlayer.tsx
+++ b/src/components/chat/VoiceMessagePlayer.tsx
@@ -1,13 +1,16 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
-import { FiDownload, FiPause, FiPlay } from "react-icons/fi";
+import BoldIcon from "@/components/ui/BoldIcon";
import IOSSpinner from "@/components/ui/IOSSpinner";
import { cn } from "@/lib/utils";
interface VoiceMessagePlayerProps {
src: string;
isOutgoing?: boolean;
+ autoPlay?: boolean;
+ singlePlay?: boolean;
+ onPlaybackComplete?: () => void;
}
function formatDuration(sec: number) {
@@ -20,6 +23,9 @@ function formatDuration(sec: number) {
export default function VoiceMessagePlayer({
src,
isOutgoing = false,
+ autoPlay = false,
+ singlePlay = false,
+ onPlaybackComplete,
}: VoiceMessagePlayerProps) {
const audioRef = useRef(null);
const [downloaded, setDownloaded] = useState(false);
@@ -28,8 +34,13 @@ export default function VoiceMessagePlayer({
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [waveform, setWaveform] = useState([]);
+ const [playedOnce, setPlayedOnce] = useState(false);
const isBlob = src.startsWith("blob:");
+ useEffect(() => {
+ setPlayedOnce(false);
+ }, [src]);
+
useEffect(() => {
if (isBlob) {
setDownloaded(true);
@@ -87,6 +98,8 @@ export default function VoiceMessagePlayer({
const onEnd = () => {
setIsPlaying(false);
setCurrentTime(0);
+ setPlayedOnce(true);
+ onPlaybackComplete?.();
};
audio.addEventListener("loadedmetadata", onMeta);
@@ -97,11 +110,18 @@ export default function VoiceMessagePlayer({
audio.removeEventListener("timeupdate", onTime);
audio.removeEventListener("ended", onEnd);
};
- }, [downloaded]);
+ }, [downloaded, onPlaybackComplete]);
+
+ useEffect(() => {
+ const audio = audioRef.current;
+ if (!audio || !downloaded || !autoPlay || playedOnce) return;
+ audio.play().then(() => setIsPlaying(true)).catch(() => {});
+ }, [autoPlay, downloaded, playedOnce, src]);
const togglePlay = () => {
const audio = audioRef.current;
if (!audio) return;
+ if (singlePlay && playedOnce) return;
if (isPlaying) {
audio.pause();
setIsPlaying(false);
@@ -143,7 +163,7 @@ export default function VoiceMessagePlayer({
{downloadProgress > 0 && downloadProgress < 100 ? (
) : (
-
+
)}
@@ -172,10 +192,14 @@ export default function VoiceMessagePlayer({
{compact ? (
<>
-
+
{badge(unreadMessages)}
{badge(unreadNotification)}
diff --git a/src/components/main/ProfileAvatar.tsx b/src/components/main/ProfileAvatar.tsx
new file mode 100644
index 0000000..be1dae6
--- /dev/null
+++ b/src/components/main/ProfileAvatar.tsx
@@ -0,0 +1,57 @@
+import Image from "next/image";
+import { cn } from "@/lib/utils";
+import { buildStorageUrl } from "./BaseUrl";
+
+const sizeMap = {
+ xs: "h-8 w-8",
+ sm: "h-10 w-10",
+ chat: "h-14 w-14",
+ md: "h-[72px] w-[72px]",
+ lg: "h-[120px] w-[120px]",
+ xl: "h-[280px] w-[280px]",
+};
+
+type ProfileAvatarProps = {
+ src?: string | null;
+ alt?: string;
+ size?: keyof typeof sizeMap;
+ rounded?: "2xl" | "xl" | "full";
+ className?: string;
+ fallback?: string;
+};
+
+function ProfileAvatar({
+ src,
+ alt = "",
+ size = "md",
+ rounded = "2xl",
+ className,
+ fallback = "/images/fake-avatar.png",
+}: ProfileAvatarProps) {
+ const roundedClass =
+ rounded === "full"
+ ? "rounded-full"
+ : rounded === "xl"
+ ? "rounded-xl"
+ : "rounded-2xl";
+
+ const imageSrc = src ? buildStorageUrl(src) : fallback;
+
+ return (
+
+ );
+}
+
+export default ProfileAvatar;
diff --git a/src/components/main/SharePostModal.tsx b/src/components/main/SharePostModal.tsx
index 0bfad22..c11420c 100644
--- a/src/components/main/SharePostModal.tsx
+++ b/src/components/main/SharePostModal.tsx
@@ -1,7 +1,7 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
-import { FiCopy, FiShare2, FiX } from "react-icons/fi";
+import BoldIcon from "@/components/ui/BoldIcon";
import toast from "react-hot-toast";
interface SharePostModalProps {
@@ -59,7 +59,7 @@ export default function SharePostModal({
اشتراکگذاری
@@ -72,7 +72,7 @@ export default function SharePostModal({
className="chat-action-btn gentle-transition flex h-9 w-9 shrink-0 items-center justify-center rounded-full active:scale-90"
aria-label="کپی"
>
-
+
diff --git a/src/components/main/ShowMap.tsx b/src/components/main/ShowMap.tsx
index 09e5208..0ad6f13 100644
--- a/src/components/main/ShowMap.tsx
+++ b/src/components/main/ShowMap.tsx
@@ -17,8 +17,8 @@ function ShowMap({
@@ -54,33 +57,12 @@ function ModelHeadRowTwo({
-
+
{user_name}
-
- {is_verified === "verified" ? (
-
- ) : is_verified === "pending" ? (
-
- ) : is_verified === "true" ? (
-
- ) : (
- ""
- )}
+
diff --git a/src/components/models/ModelsFilter.tsx b/src/components/models/ModelsFilter.tsx
index 28c86fa..cd760ea 100644
--- a/src/components/models/ModelsFilter.tsx
+++ b/src/components/models/ModelsFilter.tsx
@@ -108,18 +108,30 @@ function ModelsFilter({ expertise }: ModelsFilterProps) {
className="dark:invert"
/>
+
diff --git a/src/components/posts/PostFeedView.tsx b/src/components/posts/PostFeedView.tsx
index 6d94211..594c445 100644
--- a/src/components/posts/PostFeedView.tsx
+++ b/src/components/posts/PostFeedView.tsx
@@ -11,7 +11,7 @@ import { MutedProvider } from "@/components/models/ModelPage/MainModelCardPost";
import PageLoader from "@/components/ui/PageLoader";
import { Skeleton } from "@/components/ui/skeleton";
import Cookies from "js-cookie";
-import { FiX } from "react-icons/fi";
+import BoldIcon from "@/components/ui/BoldIcon";
interface PostFeedViewProps {
initialPostId: string;
@@ -109,7 +109,7 @@ export default function PostFeedView({
className="gentle-transition fixed left-4 top-[calc(1rem+env(safe-area-inset-top))] z-[100] flex h-10 w-10 items-center justify-center rounded-full bg-black/40 text-white backdrop-blur-md active:scale-90"
aria-label="بستن"
>
-
+
)}
setOpen((o) => !o)}
className="glass-chat-input gentle-transition flex w-full items-center justify-center gap-2 rounded-full py-2.5 text-sm font-medium active:scale-[0.98]"
>
-
+
تگ کردن افراد ({selected.length}/{max})
@@ -86,7 +86,7 @@ export default function TagUsersPicker({
>
@{u.user_name}
))}
diff --git a/src/components/projects/ProjectPage/ProjectCreator.tsx b/src/components/projects/ProjectPage/ProjectCreator.tsx
index b2967ba..d6b23e1 100644
--- a/src/components/projects/ProjectPage/ProjectCreator.tsx
+++ b/src/components/projects/ProjectPage/ProjectCreator.tsx
@@ -1,7 +1,6 @@
import { IProjectCreator } from "@/types/types";
import React from "react";
-import Image from "next/image";
-import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
+import ProfileAvatar from "@/components/main/ProfileAvatar";import VerificationBadge from "@/components/main/VerificationBadge";
import Link from "next/link";
function ProjectCreator({ creator }: { creator: IProjectCreator }) {
@@ -11,13 +10,12 @@ function ProjectCreator({ creator }: { creator: IProjectCreator }) {
className="w-full flex items-center my-3"
>
{creator?.profile_image && (
-
)}
@@ -31,24 +29,10 @@ function ProjectCreator({ creator }: { creator: IProjectCreator }) {
creator?.first_name + " " + creator?.last_name}
- {creator?.user_name}
- {creator?.is_verified === "verified" ? (
-
- ) : creator?.is_verified === "pending" ? (
-
- ) : (
- ""
- )}
+
+ {creator?.user_name}
+
+
diff --git a/src/components/projects/Workroom/ProjectWorkroomRate.tsx b/src/components/projects/Workroom/ProjectWorkroomRate.tsx
index 9acd904..e5edafa 100644
--- a/src/components/projects/Workroom/ProjectWorkroomRate.tsx
+++ b/src/components/projects/Workroom/ProjectWorkroomRate.tsx
@@ -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
(0);
const [commentText, setCommentText] = useState("");
+ 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 = {
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 = {
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 (
@@ -54,11 +53,11 @@ export default function CategoryBox(props: CategoryBox) {
props.ClassificationView === true ? "" : "hidden"
} flex items-center gap-2`}
>
-
+
{t("home.Classification")}: {props.Classification}
-
+
{t("home.product")}: {props.product}
diff --git a/src/components/ui/ImageCarousel.tsx b/src/components/ui/ImageCarousel.tsx
index 06cb2bd..6c9b435 100644
--- a/src/components/ui/ImageCarousel.tsx
+++ b/src/components/ui/ImageCarousel.tsx
@@ -1,7 +1,7 @@
"use client";
import React, { useState } from "react";
-import { FaHeart, FaShareAlt } from "react-icons/fa";
+import BoldIcon from "@/components/ui/BoldIcon";
interface ProductGalleryProps {
images: string[];
@@ -13,14 +13,12 @@ export default function ImageCarousel({ images }: ProductGalleryProps) {
return (
- {/* عکس بزرگ */}

- {/* دکمههای لایک و شیر */}
- {/* تصاویر کوچک */}
{images.map((img, idx) => (
![]()
-
+
{current.flag} {current.label}
diff --git a/src/components/ui/ProductCart.tsx b/src/components/ui/ProductCart.tsx
index f1ea296..5a9b098 100644
--- a/src/components/ui/ProductCart.tsx
+++ b/src/components/ui/ProductCart.tsx
@@ -2,8 +2,7 @@
import React from "react";
import { useTranslation } from "react-i18next";
-import { FaStar, FaHeart } from "react-icons/fa";
-import { IoMdShare } from "react-icons/io";
+import BoldIcon from "@/components/ui/BoldIcon";
import {
Card,
CardContent,
@@ -63,14 +62,14 @@ export default function ProductCart(props: Cart) {
variant="secondary"
className="rounded-full bg-white/80 dark:bg-neutral-800/80 backdrop-blur hover:bg-red-500 hover:text-white"
>
-
+
@@ -105,7 +104,7 @@ export default function ProductCart(props: Cart) {
{/* Rating */}
-
+
{props.score.toFixed(1)}
diff --git a/src/components/ui/SearchInp.tsx b/src/components/ui/SearchInp.tsx
index 7e86f36..639c440 100644
--- a/src/components/ui/SearchInp.tsx
+++ b/src/components/ui/SearchInp.tsx
@@ -1,6 +1,5 @@
import React from "react";
-import { Search } from "lucide-react";
-import { HiChevronRight } from "react-icons/hi2";
+import BoldIcon from "@/components/ui/BoldIcon";
interface SearchInpValue {
Placeholder: string;
@@ -19,9 +18,9 @@ interface SearchInpValue {
className="w-full max-tablet-l:h-9 max-desktop-s:text-sm h-16 max-desktop-s:h-12 flex justify-center items-center ltr:pl-12 ltr:pr-4 rtl:pr-12 rtl:pl-4 py-2 rounded-full shadow-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-neutral-800 text-gray-800 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all duration-300"
/>
-
+
-
+
);
}
diff --git a/src/components/ui/SearchProductCart.tsx b/src/components/ui/SearchProductCart.tsx
index 34c19d4..f95b68d 100644
--- a/src/components/ui/SearchProductCart.tsx
+++ b/src/components/ui/SearchProductCart.tsx
@@ -1,9 +1,7 @@
"use client";
import React from "react";
import { useTranslation } from "react-i18next";
-import { FaStar } from "react-icons/fa";
-import { FaHeart } from "react-icons/fa";
-import { IoMdShare } from "react-icons/io";
+import BoldIcon from "@/components/ui/BoldIcon";
interface Cart {
image: string;
@@ -72,15 +70,15 @@ export default function ProductCart(props: Cart) {
{props.score}
-
+
@@ -134,15 +132,15 @@ export default function ProductCart(props: Cart) {
{props.score}
-
+
diff --git a/src/components/ui/SellersCart.tsx b/src/components/ui/SellersCart.tsx
index 90c5d22..97920cd 100644
--- a/src/components/ui/SellersCart.tsx
+++ b/src/components/ui/SellersCart.tsx
@@ -1,8 +1,7 @@
"use client";
import React from "react";
import { useTranslation } from "react-i18next";
-import { IoMdStar } from "react-icons/io";
-import { IoStorefrontSharp } from "react-icons/io5";
+import BoldIcon from "@/components/ui/BoldIcon";
import IdeaComponent from "./IdeaComponent";
interface SampleSellerCart {
@@ -58,7 +57,7 @@ export default function SellersCart(props: SampleSellerCart) {
{t("home.sellerCartPoints")}:
- {props.point}
+ {props.point}
@@ -86,7 +85,7 @@ export default function SellersCart(props: SampleSellerCart) {
}`}
>
{t("home.sellerCartStoreViewTheStore")}
-
+
diff --git a/src/constants/index.ts b/src/constants/index.ts
index 9e640a6..cd27c2a 100644
--- a/src/constants/index.ts
+++ b/src/constants/index.ts
@@ -51,7 +51,7 @@ export const editUserNavLinks = [
{
title: "نام کاربری",
href: "/username",
- icon: "edit.svg",
+ icon: "profile-2user.svg",
},
{
title: "رمز عبور",
@@ -66,7 +66,7 @@ export const editUserNavLinks = [
{
title: "احراز هویت",
href: "/Authentication",
- icon: "user-tick.svg",
+ icon: "user-square.svg",
},
{
title: "تخصص",
@@ -96,7 +96,7 @@ export const editUserNavLinks = [
{
title: "نوع همکاری",
href: "/cooperation-type",
- icon: "celo-(celo).svg",
+ icon: "link-square.svg",
},
{
title: "روابط عمومی",
@@ -119,7 +119,7 @@ export const editEmployerNavLinks = [
{
title: "نام کاربری",
href: "/username",
- icon: "edit.svg",
+ icon: "profile-2user.svg",
},
{
title: "رمز عبور",
diff --git a/src/hooks/useAxios.tsx b/src/hooks/useAxios.tsx
index 16c5dc0..4e42564 100644
--- a/src/hooks/useAxios.tsx
+++ b/src/hooks/useAxios.tsx
@@ -11,6 +11,7 @@ import axios, {
import toast from "react-hot-toast";
import { BASE_URL as base } from "@/components/main/BaseUrl";
import Cookies from "js-cookie";
+import { clearAuthSession } from "@/lib/auth/session";
const BASE_URL = base;
const axiosInstance = axios.create({
@@ -56,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) {
@@ -72,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.");
@@ -88,9 +100,14 @@ axiosInstance.interceptors.response.use(
);
const handleUnauthorized = (): void => {
- if (typeof window !== "undefined") {
- // toast.error("برای انجام عملیات لطفا وارد حساب کاربری خود شوید");
- }
+ if (typeof window === "undefined") return;
+
+ const path = window.location.pathname;
+ if (path.startsWith("/login") || path.startsWith("/register")) return;
+
+ clearAuthSession().finally(() => {
+ window.location.href = "/login";
+ });
};
// تایپ اصلاحشده برای درخواستها
@@ -147,4 +164,5 @@ const useAxios = () => {
};
};
+export { axiosInstance as apiClient };
export default useAxios;
diff --git a/src/hooks/useLongPress.ts b/src/hooks/useLongPress.ts
new file mode 100644
index 0000000..71d5487
--- /dev/null
+++ b/src/hooks/useLongPress.ts
@@ -0,0 +1,72 @@
+"use client";
+
+import { useCallback, useRef, useState } from "react";
+
+type LongPressOptions = {
+ delay?: number;
+};
+
+export function useLongPress(
+ onLongPress: () => void,
+ { delay = 2000 }: LongPressOptions = {}
+) {
+ const timerRef = useRef | 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;
+ }, []);
+
+ const cancelPress = useCallback(() => {
+ clear();
+ setPressing(false);
+ }, [clear]);
+
+ return {
+ pressing,
+ shouldBlockClick,
+ cancelPress,
+ handlers: {
+ onPointerDown,
+ onPointerUp: endPress,
+ onPointerLeave: endPress,
+ onPointerCancel: endPress,
+ onContextMenu: (e: React.MouseEvent) => e.preventDefault(),
+ },
+ };
+}
diff --git a/src/hooks/usePageTitle.ts b/src/hooks/usePageTitle.ts
new file mode 100644
index 0000000..220b012
--- /dev/null
+++ b/src/hooks/usePageTitle.ts
@@ -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]);
+}
diff --git a/src/hooks/useStableMessageKeys.ts b/src/hooks/useStableMessageKeys.ts
new file mode 100644
index 0000000..248f71b
--- /dev/null
+++ b/src/hooks/useStableMessageKeys.ts
@@ -0,0 +1,17 @@
+import { useCallback, useRef } from "react";
+
+/** Keeps React keys stable when optimistic temp ids become server ids */
+export function useStableMessageKeys() {
+ const aliasRef = useRef(new Map());
+
+ const linkIds = useCallback((tempId: string, realId: string) => {
+ aliasRef.current.set(realId, tempId);
+ aliasRef.current.set(tempId, tempId);
+ }, []);
+
+ const getStableKey = useCallback((id: string) => {
+ return aliasRef.current.get(id) ?? id;
+ }, []);
+
+ return { linkIds, getStableKey };
+}
diff --git a/src/hooks/useSwipeToReply.ts b/src/hooks/useSwipeToReply.ts
new file mode 100644
index 0000000..fc7ef3f
--- /dev/null
+++ b/src/hooks/useSwipeToReply.ts
@@ -0,0 +1,68 @@
+"use client";
+
+import { useCallback, useState } from "react";
+import {
+ animate,
+ useMotionValue,
+ useTransform,
+ type PanInfo,
+} from "framer-motion";
+
+const SWIPE_THRESHOLD = 52;
+const MAX_DRAG = 72;
+
+export function useSwipeToReply(onReply: () => void, enabled = true) {
+ const x = useMotionValue(0);
+ const [dragging, setDragging] = useState(false);
+ const replyOpacity = useTransform(x, [-10, -40], [0, 1]);
+ const replyScale = useTransform(x, [-10, -40], [0.6, 1]);
+
+ const resetPosition = useCallback(() => {
+ void animate(x, 0, {
+ type: "spring",
+ stiffness: 520,
+ damping: 38,
+ mass: 0.85,
+ });
+ }, [x]);
+
+ const onDragStart = useCallback(() => {
+ setDragging(true);
+ }, []);
+
+ const onDragEnd = useCallback(
+ (_: unknown, info: PanInfo) => {
+ setDragging(false);
+ if (info.offset.x <= -SWIPE_THRESHOLD) {
+ if (typeof navigator !== "undefined" && navigator.vibrate) {
+ navigator.vibrate(12);
+ }
+ onReply();
+ }
+ resetPosition();
+ },
+ [onReply, resetPosition]
+ );
+
+ const dragProps = enabled
+ ? {
+ drag: "x" as const,
+ dragConstraints: { left: -MAX_DRAG, right: 0 },
+ dragElastic: { left: 0.15, right: 0 },
+ dragMomentum: false,
+ dragSnapToOrigin: true,
+ style: { x, touchAction: "pan-y" as const },
+ onDragStart,
+ onDragEnd,
+ }
+ : {};
+
+ return {
+ dragProps,
+ dragging,
+ replyOpacity,
+ replyScale,
+ x,
+ resetPosition,
+ };
+}
diff --git a/src/hooks/useTimedMessageExpiry.ts b/src/hooks/useTimedMessageExpiry.ts
new file mode 100644
index 0000000..e9ecaf1
--- /dev/null
+++ b/src/hooks/useTimedMessageExpiry.ts
@@ -0,0 +1,52 @@
+"use client";
+
+import { useEffect } from "react";
+import type { ChatMessage } from "@/components/chat/ChatMessageCard";
+import type { QueryKey } from "@tanstack/react-query";
+import { useQueryClient } from "@tanstack/react-query";
+
+type ThreadCache = {
+ pages: { messages: ChatMessage[] }[];
+};
+
+export function useTimedMessageExpiry(
+ messages: ChatMessage[],
+ queryKey: QueryKey,
+ onExpirePending?: (ids: string[]) => void
+) {
+ const queryClient = useQueryClient();
+
+ useEffect(() => {
+ const purge = () => {
+ const now = Date.now();
+ const expired = messages.filter((m) => {
+ if (!m.expiresAt) return false;
+ const t = new Date(m.expiresAt).getTime();
+ return !Number.isNaN(t) && t <= now;
+ });
+ if (!expired.length) return;
+
+ const idSet = new Set(expired.map((m) => m._id));
+
+ queryClient.setQueryData(queryKey, (oldData: ThreadCache | undefined) => {
+ if (!oldData?.pages?.length) return oldData;
+ return {
+ ...oldData,
+ pages: oldData.pages.map((page) => ({
+ ...page,
+ messages: page.messages.filter((m) => !idSet.has(m._id)),
+ })),
+ };
+ });
+
+ const pendingIds = expired
+ .filter((m) => m._id.startsWith("temp-"))
+ .map((m) => m._id);
+ if (pendingIds.length) onExpirePending?.(pendingIds);
+ };
+
+ purge();
+ const timer = setInterval(purge, 1000);
+ return () => clearInterval(timer);
+ }, [messages, queryKey, queryClient, onExpirePending]);
+}
diff --git a/src/hooks/useUser.tsx b/src/hooks/useUser.tsx
index d721dbe..9dbb9ae 100644
--- a/src/hooks/useUser.tsx
+++ b/src/hooks/useUser.tsx
@@ -3,9 +3,16 @@
import { useState, useEffect } from "react";
import { User } from "@/types/types";
import useAxios from "./useAxios";
+import { getStoredUserId } from "@/lib/auth/session";
+
+function readStoredUser(): User | undefined {
+ const id = getStoredUserId();
+ if (!id) return undefined;
+ return { _id: id } as User;
+}
export const useUser = () => {
- const [user, setUser] = useState();
+ const [user, setUser] = useState(readStoredUser);
const { request } = useAxios();
const fetchUser = async () => {
try {
diff --git a/src/hooks/useViewOnceMedia.ts b/src/hooks/useViewOnceMedia.ts
new file mode 100644
index 0000000..95fdabc
--- /dev/null
+++ b/src/hooks/useViewOnceMedia.ts
@@ -0,0 +1,42 @@
+"use client";
+
+import { useCallback } from "react";
+import useAxios from "@/hooks/useAxios";
+import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
+
+export function useViewOnceMedia() {
+ const { request } = useAxios();
+
+ const openViewOnce = useCallback(
+ async (messageId: string) => {
+ const res = await request<{
+ file: string;
+ fileType: string;
+ messageId: string;
+ }>("POST", "/chat/view-once/open", { messageId });
+
+ const path = res?.file || "";
+ const url = path.startsWith("blob:")
+ ? path
+ : path.startsWith("http")
+ ? path
+ : IMAGE_BASE_URL + path;
+
+ return { url, fileType: res?.fileType as string };
+ },
+ [request]
+ );
+
+ const completeViewOnce = useCallback(
+ async (messageId: string) => {
+ await request<{ deletedIds: string[] }>(
+ "POST",
+ "/chat/view-once/complete",
+ { messageId }
+ );
+ },
+ [request]
+ );
+
+ return { openViewOnce, completeViewOnce };
+}
diff --git a/src/lib/auth/feedback.ts b/src/lib/auth/feedback.ts
new file mode 100644
index 0000000..6a808e6
--- /dev/null
+++ b/src/lib/auth/feedback.ts
@@ -0,0 +1,5 @@
+export const AUTH_SUCCESS_DELAY_MS = 600;
+
+export function pause(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
diff --git a/src/lib/auth/otpTimer.ts b/src/lib/auth/otpTimer.ts
new file mode 100644
index 0000000..5ac7d1b
--- /dev/null
+++ b/src/lib/auth/otpTimer.ts
@@ -0,0 +1,23 @@
+export const OTP_VALID_SECONDS = 3 * 60;
+const OTP_SENT_AT_KEY = "otp_sent_at";
+
+export function markOtpSent(): void {
+ if (typeof window === "undefined") return;
+ sessionStorage.setItem(OTP_SENT_AT_KEY, Date.now().toString());
+}
+
+export function getOtpRemainingSeconds(): number {
+ if (typeof window === "undefined") return OTP_VALID_SECONDS;
+
+ const sent = sessionStorage.getItem(OTP_SENT_AT_KEY);
+ if (!sent) return OTP_VALID_SECONDS;
+
+ const elapsed = Math.floor((Date.now() - Number(sent)) / 1000);
+ return Math.max(0, OTP_VALID_SECONDS - elapsed);
+}
+
+export function formatOtpCountdown(totalSeconds: number): string {
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+ return `${minutes}:${seconds.toString().padStart(2, "0")}`;
+}
diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts
new file mode 100644
index 0000000..306d85c
--- /dev/null
+++ b/src/lib/auth/session.ts
@@ -0,0 +1,75 @@
+import Cookies from "js-cookie";
+
+const TOKEN_KEY = "token";
+
+export type AuthUserMeta = {
+ id?: string;
+ user_type?: string;
+ step?: string;
+};
+
+const cookieOptions = (): Cookies.CookieAttributes => ({
+ path: "/",
+ expires: 365,
+ sameSite: "Lax",
+ secure: typeof window !== "undefined" && window.location.protocol === "https:",
+});
+
+/** ذخیره توکن در کوکی مرورگر + Route Handler سرور (برای middleware) */
+export async function setAuthSession(
+ token: string,
+ meta?: AuthUserMeta
+): Promise {
+ if (!token) return;
+
+ Cookies.set(TOKEN_KEY, token, cookieOptions());
+
+ await fetch("/api/auth/session", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token }),
+ credentials: "same-origin",
+ });
+
+ if (meta?.id) localStorage.setItem("id", meta.id);
+ if (meta?.user_type) localStorage.setItem("usertype", meta.user_type);
+ if (meta?.step) localStorage.setItem("step", meta.step);
+}
+
+/** پاک کردن کامل session */
+export async function clearAuthSession(): Promise {
+ Cookies.remove(TOKEN_KEY, { path: "/" });
+
+ try {
+ await fetch("/api/auth/session", {
+ method: "DELETE",
+ credentials: "same-origin",
+ });
+ } catch {
+ // ignore
+ }
+
+ localStorage.removeItem("id");
+ localStorage.removeItem("mobile");
+ localStorage.removeItem("otp");
+ localStorage.removeItem("token");
+ localStorage.removeItem("usertype");
+ localStorage.removeItem("first_name");
+ localStorage.removeItem("last_name");
+ localStorage.removeItem("username");
+ localStorage.removeItem("step");
+}
+
+export function getStoredUserId(): string {
+ if (typeof window === "undefined") return "";
+ return localStorage.getItem("id") ?? "";
+}
+
+export function getAuthToken(): string | undefined {
+ if (typeof window === "undefined") return undefined;
+ return Cookies.get(TOKEN_KEY);
+}
+
+export function isAuthenticated(): boolean {
+ return Boolean(getAuthToken());
+}
diff --git a/src/lib/chat/formatMessageTime.ts b/src/lib/chat/formatMessageTime.ts
index 541aa35..96c8caa 100644
--- a/src/lib/chat/formatMessageTime.ts
+++ b/src/lib/chat/formatMessageTime.ts
@@ -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) {
diff --git a/src/lib/chat/groupMessagesByDate.ts b/src/lib/chat/groupMessagesByDate.ts
index cb69418..658f196 100644
--- a/src/lib/chat/groupMessagesByDate.ts
+++ b/src/lib/chat/groupMessagesByDate.ts
@@ -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}`,
});
}
diff --git a/src/lib/chat/messageGrouping.ts b/src/lib/chat/messageGrouping.ts
new file mode 100644
index 0000000..329d824
--- /dev/null
+++ b/src/lib/chat/messageGrouping.ts
@@ -0,0 +1,80 @@
+import type { ChatMessage } from "@/components/chat/ChatMessageCard";
+import { parseMessageDate } from "@/lib/chat/formatMessageTime";
+
+export type BubbleGroupPosition = "single" | "first" | "middle" | "last";
+
+function dayKey(createdAt?: string | null): string {
+ const d = parseMessageDate(createdAt);
+ if (d) return d.toDateString();
+ return "unknown-day";
+}
+
+export function sameSender(a: ChatMessage, b: ChatMessage): boolean {
+ return String(a.senderId) === String(b.senderId);
+}
+
+function sameVisualGroup(a: ChatMessage, b: ChatMessage): boolean {
+ return sameSender(a, b) && dayKey(a.createdAt) === dayKey(b.createdAt);
+}
+
+export { sameVisualGroup };
+
+export function getBubbleGroupPosition(
+ messages: ChatMessage[],
+ index: number
+): BubbleGroupPosition {
+ const current = messages[index];
+ if (!current) return "single";
+
+ const sameAsPrev =
+ index > 0 && sameVisualGroup(messages[index - 1], current);
+ const sameAsNext =
+ index < messages.length - 1 &&
+ sameVisualGroup(messages[index + 1], current);
+
+ if (!sameAsPrev && !sameAsNext) return "single";
+ if (!sameAsPrev && sameAsNext) return "first";
+ if (sameAsPrev && sameAsNext) return "middle";
+ return "last";
+}
+
+/** Instagram-style corner radii (TL TR BR BL) */
+export function getBubbleRadius(
+ isOutgoing: boolean,
+ position: BubbleGroupPosition
+): string {
+ const r = 18;
+ const s = 4;
+
+ if (isOutgoing) {
+ switch (position) {
+ case "single":
+ return `${r}px ${r}px ${r}px ${s}px`;
+ case "first":
+ return `${r}px ${r}px ${r}px ${s}px`;
+ case "middle":
+ return `${s}px ${r}px ${r}px ${s}px`;
+ case "last":
+ return `${s}px ${r}px ${r}px ${r}px`;
+ }
+ }
+
+ switch (position) {
+ case "single":
+ return `${r}px ${r}px ${s}px ${r}px`;
+ case "first":
+ return `${r}px ${r}px ${s}px ${r}px`;
+ case "middle":
+ return `${r}px ${s}px ${s}px ${r}px`;
+ case "last":
+ return `${r}px ${s}px ${r}px ${r}px`;
+ }
+}
+
+export function getMessageRowSpacing(
+ position: BubbleGroupPosition,
+ isGroupedWithPrev: boolean
+): string {
+ if (isGroupedWithPrev) return "mt-[2px]";
+ return "mt-3";
+}
diff --git a/src/lib/chat/queryKeys.ts b/src/lib/chat/queryKeys.ts
new file mode 100644
index 0000000..1892f7d
--- /dev/null
+++ b/src/lib/chat/queryKeys.ts
@@ -0,0 +1,3 @@
+export function chatThreadQueryKey(senderId: string, receiverId: string) {
+ return ["chatThread", senderId, receiverId] as const;
+}
diff --git a/src/lib/chat/threadCache.ts b/src/lib/chat/threadCache.ts
new file mode 100644
index 0000000..3ae6850
--- /dev/null
+++ b/src/lib/chat/threadCache.ts
@@ -0,0 +1,36 @@
+import type { InfiniteData } from "@tanstack/react-query";
+import type { ChatMessage } from "@/components/chat/ChatMessageCard";
+
+export type ChatThreadPage = { messages: ChatMessage[]; nextPage?: number };
+export type ChatThreadData = InfiniteData;
+
+export function normalizeThreadId(id: unknown): string {
+ if (!id) return "";
+ return String(id);
+}
+
+export function appendMessageToThreadCache(
+ oldData: ChatThreadData | undefined,
+ message: ChatMessage
+): ChatThreadData {
+ if (!oldData?.pages?.length) {
+ return {
+ pages: [{ messages: [message], nextPage: undefined }],
+ pageParams: [1],
+ };
+ }
+
+ const exists = oldData.pages.some((p) =>
+ p.messages.some((m) => m._id === message._id)
+ );
+ if (exists) return oldData;
+
+ const newPages = [...oldData.pages];
+ const firstPage = newPages[0];
+ newPages[0] = {
+ ...firstPage,
+ messages: [...firstPage.messages, message],
+ };
+
+ return { ...oldData, pages: newPages };
+}
diff --git a/src/lib/chat/timedMessages.ts b/src/lib/chat/timedMessages.ts
new file mode 100644
index 0000000..c664807
--- /dev/null
+++ b/src/lib/chat/timedMessages.ts
@@ -0,0 +1,36 @@
+export const TIMED_MESSAGE_OPTIONS = [
+ { label: "خاموش", value: null as number | null },
+ { label: "۱۰ ثانیه", value: 10 },
+ { label: "۳۰ ثانیه", value: 30 },
+ { label: "۱ دقیقه", value: 60 },
+ { label: "۵ دقیقه", value: 300 },
+ { label: "۱ ساعت", value: 3600 },
+] as const;
+
+export function formatTimedLabel(seconds: number | null): string {
+ const opt = TIMED_MESSAGE_OPTIONS.find((o) => o.value === seconds);
+ return opt?.label ?? "خاموش";
+}
+
+export function getRemainingSeconds(expiresAt?: string | null): number | null {
+ if (!expiresAt) return null;
+ const ms = new Date(expiresAt).getTime() - Date.now();
+ if (Number.isNaN(ms) || ms <= 0) return 0;
+ return Math.ceil(ms / 1000);
+}
+
+export function formatCountdown(totalSeconds: number): string {
+ if (totalSeconds >= 3600) {
+ const h = Math.floor(totalSeconds / 3600);
+ const m = Math.floor((totalSeconds % 3600) / 60);
+ return `${h}:${String(m).padStart(2, "0")}`;
+ }
+ const m = Math.floor(totalSeconds / 60);
+ const s = totalSeconds % 60;
+ return `${m}:${String(s).padStart(2, "0")}`;
+}
+
+export function buildExpiresAtIso(seconds: number | null): string | undefined {
+ if (!seconds) return undefined;
+ return new Date(Date.now() + seconds * 1000).toISOString();
+}
diff --git a/src/lib/chat/viewOnce.ts b/src/lib/chat/viewOnce.ts
new file mode 100644
index 0000000..c01d4de
--- /dev/null
+++ b/src/lib/chat/viewOnce.ts
@@ -0,0 +1,22 @@
+export const VIEW_ONCE_MEDIA_TYPES = ["image", "video", "voice"] as const;
+
+export type ViewOnceMediaType = (typeof VIEW_ONCE_MEDIA_TYPES)[number];
+
+export function isViewOnceMediaType(
+ fileType?: string | null
+): fileType is ViewOnceMediaType {
+ return VIEW_ONCE_MEDIA_TYPES.includes(fileType as ViewOnceMediaType);
+}
+
+export function viewOnceLabel(fileType?: string): string {
+ switch (fileType) {
+ case "image":
+ return "عکس یکبار مصرف";
+ case "video":
+ return "فیلم یکبار مصرف";
+ case "voice":
+ return "ویس یکبار مصرف";
+ default:
+ return "پیام یکبار مصرف";
+ }
+}
diff --git a/src/lib/userLevel.ts b/src/lib/userLevel.ts
new file mode 100644
index 0000000..21c642c
--- /dev/null
+++ b/src/lib/userLevel.ts
@@ -0,0 +1,34 @@
+export const USER_LEVELS = {
+ NEW: "تازه وارد",
+ STANDARD: "استاندارد",
+ PRO: "حرفهای",
+ MASTER: "استاد",
+} as const;
+
+/** Normalize level from URL slug or query (handles ZWNJ / spacing variants). */
+export function normalizeUserLevel(raw?: string | null): string {
+ if (!raw) return "";
+
+ const text = raw.trim().replace(/\u200c/g, "").replace(/\s+/g, " ");
+
+ if (text === USER_LEVELS.NEW) return USER_LEVELS.NEW;
+ if (text === USER_LEVELS.STANDARD) return USER_LEVELS.STANDARD;
+ if (text === USER_LEVELS.MASTER) return USER_LEVELS.MASTER;
+ if (text === USER_LEVELS.PRO || text === "حرفه ای") return USER_LEVELS.PRO;
+
+ return raw.trim();
+}
+
+/** Extract user level token from Persian SEO slug text. */
+export function parseUserLevelFromSlug(fullText: string): string {
+ const text = fullText.replace(/\u200c/g, " ");
+
+ if (text.includes(USER_LEVELS.NEW)) return USER_LEVELS.NEW;
+ if (text.includes(USER_LEVELS.STANDARD)) return USER_LEVELS.STANDARD;
+ if (text.includes(USER_LEVELS.MASTER)) return USER_LEVELS.MASTER;
+ if (/حرفه[\s\u200c]*ای/.test(text) || text.includes(USER_LEVELS.PRO)) {
+ return USER_LEVELS.PRO;
+ }
+
+ return "";
+}
diff --git a/src/lib/validation/username.ts b/src/lib/validation/username.ts
new file mode 100644
index 0000000..c4087c2
--- /dev/null
+++ b/src/lib/validation/username.ts
@@ -0,0 +1,21 @@
+/** حروف انگلیسی، اعداد و . _ - — بدون فارسی و کاراکترهای غیرمعمول */
+export const USERNAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._-]{5,99}$/;
+
+export const USERNAME_MIN_LENGTH = 6;
+export const USERNAME_MAX_LENGTH = 100;
+
+export const USERNAME_ALLOWED_CHARS = /[^a-zA-Z0-9._-]/g;
+
+export const USERNAME_VALIDATION_MESSAGE =
+ "نام کاربری فقط میتواند شامل حروف انگلیسی، اعداد و کاراکترهای . _ - باشد";
+
+export const USERNAME_START_MESSAGE =
+ "نام کاربری باید با حرف یا عدد انگلیسی شروع شود";
+
+export function sanitizeUsernameInput(value: string): string {
+ return value.replace(USERNAME_ALLOWED_CHARS, "");
+}
+
+export function isValidUsername(value: string): boolean {
+ return USERNAME_REGEX.test(value);
+}
diff --git a/src/lib/validation/usernameSchema.ts b/src/lib/validation/usernameSchema.ts
new file mode 100644
index 0000000..b75591a
--- /dev/null
+++ b/src/lib/validation/usernameSchema.ts
@@ -0,0 +1,20 @@
+import * as yup from "yup";
+import {
+ USERNAME_MAX_LENGTH,
+ USERNAME_MIN_LENGTH,
+ USERNAME_REGEX,
+ USERNAME_START_MESSAGE,
+ USERNAME_VALIDATION_MESSAGE,
+} from "./username";
+
+export const usernameSchema = yup
+ .string()
+ .required("نام کاربری الزامی است")
+ .min(USERNAME_MIN_LENGTH, `نام کاربری باید حداقل ${USERNAME_MIN_LENGTH} کاراکتر باشد`)
+ .max(USERNAME_MAX_LENGTH, `نام کاربری نمیتواند بیشتر از ${USERNAME_MAX_LENGTH} کاراکتر باشد`)
+ .matches(/^[a-zA-Z0-9]/, USERNAME_START_MESSAGE)
+ .matches(USERNAME_REGEX, USERNAME_VALIDATION_MESSAGE);
+
+export const usernameFormSchema = yup.object({
+ username: usernameSchema,
+});
diff --git a/src/middleware.ts b/src/middleware.ts
index 0255750..72ac8bc 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -1,17 +1,27 @@
-import { NextRequest, NextResponse } from 'next/server';
+import { NextRequest, NextResponse } from "next/server";
export function middleware(req: NextRequest) {
- const token = req.cookies.get('token'); // دریافت توکن از کوکی
+ const isPrefetch =
+ req.headers.get("next-router-prefetch") === "1" ||
+ req.headers.get("purpose") === "prefetch";
- // بررسی اینکه مسیر داخل settings هست
- if (req.nextUrl.pathname.startsWith('/settings') && !token) {
- return NextResponse.redirect(new URL('/login', req.url)); // انتقال به صفحه لاگین
+ // prefetch بدون کوکی نباید redirect به /login کش شود
+ if (isPrefetch) {
+ return NextResponse.next();
}
- return NextResponse.next(); // ادامه پردازش عادی
-}
+ const token = req.cookies.get("token")?.value?.trim();
-// تعیین مسیرهایی که این middleware روی آنها اعمال میشود
+ if (req.nextUrl.pathname.startsWith("/settings") && !token) {
+ const loginUrl = new URL("/login", req.url);
+ loginUrl.searchParams.set("redirect", req.nextUrl.pathname);
+ return NextResponse.redirect(loginUrl);
+ }
+
+ return NextResponse.next();
+}
+//
export const config = {
- matcher: ['/settings/:path*'], // فقط روی مسیرهای settings اعمال شود
+ matcher: ["/settings/:path*"],
};
+
\ No newline at end of file
diff --git a/src/utils/generatePageMetadata.ts b/src/utils/generatePageMetadata.ts
index 072bf3a..45bdfd9 100644
--- a/src/utils/generatePageMetadata.ts
+++ b/src/utils/generatePageMetadata.ts
@@ -33,7 +33,9 @@ export function generatePageMetadata(options: PageMetadataOptions): Metadata {
: defaultSEOConfig.openGraph?.images || []; // اگر تصویر خاصی نیست، از تصاویر پیشفرض استفاده کند
return {
- title: finalTitle,
+ title: {
+ absolute: finalTitle,
+ },
description: finalDescription,
alternates: {
canonical: fullUrl,