From 901ce944434a93b99883ff83c7e1eadc05f1687c Mon Sep 17 00:00:00 2001
From: payacom <59262811+payacom@users.noreply.github.com>
Date: Thu, 10 Sep 2026 16:51:26 +0330
Subject: [PATCH] secret chat
---
.claude/launch.json | 11 +
src/api/fetchStories.ts | 97 +-
src/app/(auth)/verify/avatar/page.tsx | 4 +-
src/app/(auth)/verify/confirm/page.tsx | 2 -
.../academy/[id]/[title]/CourseClient.tsx | 10 +-
.../(projects)/projects/specialists/page.tsx | 152 +-
src/app/new-post/page.tsx | 35 +-
src/app/posts/[id]/[[...slug]]/page.tsx | 1 -
src/app/settings/academy/my-courses/page.tsx | 5 +-
.../settings/academy/storeProfile/page.tsx | 7 +-
src/app/settings/academy/wallet/page.tsx | 2 +-
src/app/settings/chats/find-partner/page.tsx | 99 +-
src/app/settings/chats/nearby/page.tsx | 93 +-
src/app/settings/edit/birthday/page.tsx | 108 +
src/app/settings/edit/gender/page.tsx | 105 +
src/app/settings/edit/link-device/page.tsx | 2 -
.../settings/edit/personal-details/page.tsx | 332 ++-
src/components/academy/InfinitePakage.tsx | 6 +-
src/components/academy/MainModelCard.tsx | 8 +-
.../academy/MainModelCardActions.tsx | 8 +-
.../BillboardPage/BillboardImageSlider.tsx | 2 +-
.../Profile/AdProfileContentPosts.tsx | 2 +-
src/components/chat/E2eeIdentityGate.tsx | 152 +-
src/components/chat/ForwardMessageModal.tsx | 2 +-
.../chats/FindPartnerFilterModal.tsx | 187 +-
src/components/course/CourseForm.tsx | 58 +-
src/components/course/CourseManager.tsx | 33 +-
src/components/course/CustomDialog.tsx | 22 +-
src/components/main/UserInfo.tsx | 1 -
.../models/MainModelCard/MainModelCard.tsx | 35 +-
.../models/ModelPage/MainModelCardPost.tsx | 2 +-
src/components/nav-user.tsx | 16 -
.../posts/FollowingSuggestionsCarousel.tsx | 8 +-
src/components/posts/ReelsPostCard.tsx | 28 +-
.../projects/SpecialistCarousel.tsx | 19 +-
.../my-billboards/BillboardLocationLabels.tsx | 2 -
src/components/stories/StoriesBar.tsx | 5 +-
src/components/stories/StoryCameraCapture.tsx | 133 +
src/components/stories/StoryEditor.tsx | 410 ++-
src/components/stories/StoryMentionPicker.tsx | 116 +
src/components/stories/StoryViewer.tsx | 168 +-
src/components/ui/AppSidebar.tsx | 1 -
src/components/ui/MultiSelectDropdown.tsx | 101 +
src/components/ui/RoundedSelect.tsx | 43 +
src/components/ui/SearchProductCart.tsx | 2 -
src/components/ui/tabs.tsx | 2 -
src/data/geo/cities.json | 2478 +++++++++++------
src/hooks/usePostSocialActions.tsx | 2 +-
src/lib/buildAcademyListSeo.ts | 4 -
src/lib/e2ee/dm.ts | 15 +-
src/lib/e2ee/keys.ts | 73 +
src/lib/e2ee/room.ts | 284 +-
src/lib/i18n/resolveLanguage.ts | 1 -
src/lib/personalDetailsFieldOptions.ts | 226 ++
src/lib/storyFonts.ts | 25 +
src/locales/en/common.json | 52 +-
src/locales/fa/common.json | 181 +-
src/types/types.ts | 67 +-
src/utils/generatePageMetadata.ts | 4 -
59 files changed, 4742 insertions(+), 1307 deletions(-)
create mode 100644 .claude/launch.json
create mode 100644 src/app/settings/edit/birthday/page.tsx
create mode 100644 src/app/settings/edit/gender/page.tsx
create mode 100644 src/components/stories/StoryCameraCapture.tsx
create mode 100644 src/components/stories/StoryMentionPicker.tsx
create mode 100644 src/components/ui/MultiSelectDropdown.tsx
create mode 100644 src/components/ui/RoundedSelect.tsx
create mode 100644 src/lib/personalDetailsFieldOptions.ts
create mode 100644 src/lib/storyFonts.ts
diff --git a/.claude/launch.json b/.claude/launch.json
new file mode 100644
index 0000000..9ddb903
--- /dev/null
+++ b/.claude/launch.json
@@ -0,0 +1,11 @@
+{
+ "version": "0.0.1",
+ "configurations": [
+ {
+ "name": "modstagram-next-dev",
+ "runtimeExecutable": "npm",
+ "runtimeArgs": ["run", "dev"],
+ "port": 3000
+ }
+ ]
+}
diff --git a/src/api/fetchStories.ts b/src/api/fetchStories.ts
index 346eb93..91a1ccd 100644
--- a/src/api/fetchStories.ts
+++ b/src/api/fetchStories.ts
@@ -1,12 +1,24 @@
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import type { PostLinkKind } from "@/lib/postLinkCaption";
+// اپ فعلاً فقط یه خانوادهفونت (IRANSansX) با وزنهای مختلف داره، نه چند تایپفیسِ واقعاً
+// جدا مثل اینستاگرام — پس «تغییر فونت» همون وزنهاست، تنها گزینهی واقعاً موجود (عین RN)
+export const STORY_FONT_FAMILIES = [
+ { value: "light", label: "نازک" },
+ { value: "regular", label: "معمولی" },
+ { value: "medium", label: "متوسط" },
+ { value: "bold", label: "ضخیم" },
+ { value: "black", label: "خیلی ضخیم" },
+] as const;
+export type StoryFontFamily = (typeof STORY_FONT_FAMILIES)[number]["value"];
+
export type StoryTextOverlay = {
type?: "text";
text: string;
x: number;
y: number;
font_size: number;
+ font_family?: StoryFontFamily;
color: string;
background?: string;
rotation?: number;
@@ -24,7 +36,90 @@ export type StoryLinkOverlay = {
rotation?: number;
};
-export type StoryOverlay = StoryTextOverlay | StoryLinkOverlay;
+export type StoryMentionOverlay = {
+ type: "mention";
+ mention_user_id: string;
+ mention_username: string;
+ x: number;
+ y: number;
+ rotation?: number;
+ font_size: number;
+};
+
+export type StoryHashtagOverlay = {
+ type: "hashtag";
+ text: string; // بدون #
+ x: number;
+ y: number;
+ rotation?: number;
+ font_size: number;
+};
+
+export type StoryLocationOverlay = {
+ type: "location";
+ text: string; // نام آزاد مکان
+ x: number;
+ y: number;
+ rotation?: number;
+ font_size: number;
+};
+
+export type StoryStickerOverlay = {
+ type: "sticker";
+ text: string; // خودِ ایموجی
+ x: number;
+ y: number;
+ rotation?: number;
+ font_size: number;
+};
+
+export type StoryOverlay =
+ | StoryTextOverlay
+ | StoryLinkOverlay
+ | StoryMentionOverlay
+ | StoryHashtagOverlay
+ | StoryLocationOverlay
+ | StoryStickerOverlay;
+
+export const DEFAULT_STICKER_FONT_SIZE = 0.045;
+
+function clampNum(value: number, min: number, max: number): number {
+ return Math.min(max, Math.max(min, value));
+}
+
+// سقفِ ایمنِ کلی — اگه یه استوریِ قدیمی (مثلاً قبل از رفعِ باگِ ناهمخوانیِ نسبتِ
+// تصویرِ ادیتور/نمایشگر) مقدارِ font_size خیلی بزرگی ذخیره کرده باشه، رندرِ اون مستقیم
+// توی ادیتور میتونه بدشکل/بیاندازه بشه — قبل از باز کردنِ ادیتور برای ویرایش، همهی
+// این مقادیر یهبار سالمسازی میشن (لینک font_size ندارد، دستنخورده میماند)
+const SAFE_FONT_SIZE_MIN = 0.02;
+const SAFE_FONT_SIZE_MAX = 0.25;
+
+export function sanitizeOverlaysForEditing(overlays: StoryOverlay[]): StoryOverlay[] {
+ return overlays.map((o) => {
+ const clamped = {
+ ...o,
+ x: clampNum(Number.isFinite(o.x) ? o.x : 0.5, 0, 1),
+ y: clampNum(Number.isFinite(o.y) ? o.y : 0.5, 0, 1),
+ };
+ if (o.type === "link") return clamped;
+ return {
+ ...clamped,
+ font_size: clampNum(
+ Number.isFinite(o.font_size) ? o.font_size : DEFAULT_STICKER_FONT_SIZE,
+ SAFE_FONT_SIZE_MIN,
+ SAFE_FONT_SIZE_MAX,
+ ),
+ };
+ });
+}
+export const DEFAULT_EMOJI_FONT_SIZE = 0.09;
+
+// عین RN — ست کوچیک و پرکاربرد، نه کل کیبورد ایموجی
+export const STORY_EMOJI_PRESETS = [
+ "❤️", "🔥", "😂", "😍", "😮", "😢", "👏", "🎉",
+ "✨", "💯", "😎", "🙏", "👍", "🥳", "😘", "😭",
+ "🤔", "😅", "💔", "⭐", "🌹", "🎶", "☀️", "🌙",
+];
export type StoryItem = {
_id: string;
diff --git a/src/app/(auth)/verify/avatar/page.tsx b/src/app/(auth)/verify/avatar/page.tsx
index a56f073..682456d 100644
--- a/src/app/(auth)/verify/avatar/page.tsx
+++ b/src/app/(auth)/verify/avatar/page.tsx
@@ -21,8 +21,7 @@ import LocalePageShell from "@/components/i18n/LocalePageShell";
function AvatarPage() {
const { t } = useTranslation("common");
const router = useRouter();
- const { request, loading } = useAxios();
- const [userDetail, setUserDetail] = useState(null);
+ const { request } = useAxios();
const [avatar, setAvatar] = useState(null);
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
@@ -35,7 +34,6 @@ function AvatarPage() {
const fetchData = async () => {
try {
const response = await request<{ user: IProfileData }>("GET", "/profile");
- setUserDetail(response?.user || null);
setAvatar(response?.user?.profile_image || null);
} catch (err) {
console.log(err);
diff --git a/src/app/(auth)/verify/confirm/page.tsx b/src/app/(auth)/verify/confirm/page.tsx
index 2b606cd..47d8d17 100644
--- a/src/app/(auth)/verify/confirm/page.tsx
+++ b/src/app/(auth)/verify/confirm/page.tsx
@@ -3,7 +3,6 @@
import Container from "@/components/elements/Container";
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";
@@ -19,7 +18,6 @@ function Confirm() {
const { t } = useTranslation("common");
const user = useUser();
const { request } = useAxios();
- const router = useRouter();
const [showModal, setShowModal] = useState(false);
const [navigating, setNavigating] = useState(false);
const [verifiedStatus, setVerifiedStatus] = useState(null);
diff --git a/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx b/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx
index 2eddca2..9f91497 100644
--- a/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx
+++ b/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx
@@ -18,7 +18,7 @@ import {
Star,
Phone,
} from "lucide-react";
-import { btnPrimary, btnDefault } from "@/lib/ui/buttonStyles";
+import { btnPrimary } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
import { Course } from "@/types/types";
import useAxios from "@/hooks/useAxios";
@@ -97,7 +97,6 @@ interface MediaFile {
}
export default function CourseDetail({
- courseId,
initialIsPurchased = false,
}: CourseDetailProps) {
const { t } = useTranslation("common");
@@ -105,7 +104,6 @@ export default function CourseDetail({
const [isPurchased, setIsPurchased] = useState(initialIsPurchased);
const [purchasing, setPurchasing] = useState(false);
const [showContactInfoModal, setShowContactInfoModal] = useState(false);
- const [videos] = useState(MOCK_VIDEOS);
const [course] = useState(MOCK_COURSE);
const pathname = usePathname();
@@ -130,7 +128,6 @@ export default function CourseDetail({
const [usersCache, setUsersCache] = useState>({});
const [videoFields, setVideoFields] = useState([]);
- const [newScore, setNewScore] = useState(0);
const [rating, setRating] = useState(0);
const containerRef = useRef(null);
@@ -240,11 +237,9 @@ export default function CourseDetail({
});
const average = sum / count;
- const averagesum = sum *1;
const roundedRating = Math.round(average * 10) / 10
setRating(roundedRating);
- setNewScore(averagesum);
if (response?.success) {
let newComments = response.data.comments;
@@ -605,9 +600,6 @@ export default function CourseDetail({
courseContactInfo?.instagramLink
);
- const freeVideosCount = videos.filter((v) => v.is_free).length;
- const paidVideosCount = videos.filter((v) => !v.is_free).length;
-
// کامپوننت نمایش ستارههای کوچک برای کامنتها
const renderSmallStars = (rate: number) => {
return (
diff --git a/src/app/(projects)/projects/specialists/page.tsx b/src/app/(projects)/projects/specialists/page.tsx
index c7b388d..12eacb9 100644
--- a/src/app/(projects)/projects/specialists/page.tsx
+++ b/src/app/(projects)/projects/specialists/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import React, { useCallback, useRef, useState } from "react";
+import React, { useCallback, useEffect, useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -17,10 +17,16 @@ import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import type { SuggestedFollowUser } from "@/components/posts/FollowingSuggestionsCarousel";
import { Post } from "@/types/types";
+import { getPostMedia } from "@/lib/explore/postMedia";
import { validateMinMaxPairs } from "@/lib/rangeValidation";
import toast from "react-hot-toast";
const SWIPE_THRESHOLD = 72;
+// عین PAGE_LIMIT/PREFETCH_THRESHOLD توی SpecialistsScreen.tsx اندروید — هر صفحه
+// PAGE_LIMIT «پست» میگیره (نه کاربر یکتا)، و وقتی به PREFETCH_THRESHOLD کارت مونده
+// به آخرِ لیستِ لودشده برسیم، صفحهی بعد از پیش لود میشه
+const PAGE_LIMIT = 30;
+const PREFETCH_THRESHOLD = 5;
const slideVariants = {
enter: (direction: number) => ({
@@ -58,8 +64,12 @@ function SpecialistCard({
viewProfileLabel: string;
}) {
const displayName = getDisplayName(user, userFallback);
- const previewPath = user.previewPost?.files?.[0]?.path;
- const previewUrl = previewPath ? buildStorageUrl(previewPath) : null;
+ // بدون فالبک به post_images/post_video، پستِ آخرِ بعضی کاربرها که فیلد files
+ // خالی داشتن اصلاً پیشنمایشی نداشت (کارت کاملاً خالی میموند) — عین getPostMedia
+ // که خودِ فید اصلی برای تشخیص عکس/ویدئو استفاده میکنه
+ const media = user.previewPost
+ ? getPostMedia(user.previewPost as Post)
+ : null;
const profileUrl = user.profile_image
? buildStorageUrl(user.profile_image)
: null;
@@ -68,10 +78,11 @@ function SpecialistCard({
return (
- {previewUrl ? (
- user.previewPost?.type === "video" ? (
+ {media ? (
+ media.kind === "video" ? (
) : (
([]);
const [loading, setLoading] = useState(false);
+ const [loadingMore, setLoadingMore] = useState(false);
const [[index, direction], setSlide] = useState<[number, number]>([0, 0]);
const applyingFilterRef = useRef(false);
+ // صفحهبندی بینهایته و تا totalPages ادامه پیدا میکنه — اینها فقط اندازهی هر
+ // درخواست و شمارهی صفحهی بعدی رو نگه میدارن، نه سقف کلی روی نتایج
+ const pageRef = useRef(1);
+ const totalPagesRef = useRef(1);
+ const knownUserIdsRef = useRef>(new Set());
const [stateId, setStateId] = useState("");
const [cityId, setCityId] = useState("");
@@ -211,10 +228,12 @@ export default function SpecialistsPage() {
setEyeColor("");
};
- const fetchUsers = useCallback(async () => {
- setLoading(true);
- try {
- const qs = new URLSearchParams({ page: "1", limit: "20" });
+ const buildFilterParams = useCallback(
+ (pageNum: number) => {
+ const qs = new URLSearchParams({
+ page: String(pageNum),
+ limit: String(PAGE_LIMIT),
+ });
if (selectedExpertise) qs.set("expertise", selectedExpertise);
if (stateId) qs.set("province", stateId);
if (cityId) qs.set("city", cityId);
@@ -228,21 +247,52 @@ export default function SpecialistsPage() {
if (sizeMax) qs.set("sizeMax", sizeMax);
if (hairColor) qs.set("hair_color", hairColor);
if (eyeColor) qs.set("eye_color", eyeColor);
+ return qs;
+ },
+ [
+ selectedExpertise,
+ stateId,
+ cityId,
+ userLevel,
+ rateFilter,
+ heightMin,
+ heightMax,
+ weightMin,
+ weightMax,
+ sizeMin,
+ sizeMax,
+ hairColor,
+ eyeColor,
+ ],
+ );
+ const fetchUsers = useCallback(async () => {
+ setLoading(true);
+ pageRef.current = 1;
+ totalPagesRef.current = 1;
+ knownUserIdsRef.current = new Set();
+ try {
+ const qs = buildFilterParams(1);
const response = await request<{
posts?: Post[];
+ totalPages?: number;
feedMeta?: { suggestedUsers?: SuggestedFollowUser[] };
}>("GET", `/users/web?${qs.toString()}`, null, { noToast: true });
const suggested = response?.feedMeta?.suggestedUsers;
if (Array.isArray(suggested) && suggested.length) {
+ // این لیست از قبل کیوریتشدهست، نه بر پایهی صفحهبندیِ پستها — صفحهبندی
+ // نمیخواد (عین RN که اصلاً این مسیر رو نداره و فقط posts رو صفحهبندی میکنه)
+ totalPagesRef.current = 1;
setUsers(suggested);
} else {
+ totalPagesRef.current = response?.totalPages ?? 1;
const posts = response?.posts || [];
const map = new Map();
posts.forEach((post) => {
const id = String(post.userId || post.user_id || "");
if (!id || map.has(id)) return;
+ knownUserIdsRef.current.add(id);
map.set(id, {
_id: id,
user_name: post.user_name,
@@ -253,6 +303,8 @@ export default function SpecialistsPage() {
previewPost: {
_id: post._id,
type: post.type,
+ post_images: post.post_images,
+ post_video: post.post_video,
files: post.files,
caption: post.caption,
},
@@ -267,21 +319,71 @@ export default function SpecialistsPage() {
} finally {
setLoading(false);
}
+ }, [request, buildFilterParams]);
+
+ // چون هر صفحه «پست» میگیره نه «کاربر یکتا»، ممکنه یک صفحه هیچ کاربر تازهای اضافه
+ // نکنه — با هر بار موفقیت یه fetch، خودِ افکتِ پایین دوباره چک میکنه و لازم شد صفحهی
+ // بعد رو هم میگیره، عین SpecialistsScreen.tsx اندروید
+ const fetchNextPage = useCallback(async () => {
+ if (loadingMore || pageRef.current >= totalPagesRef.current) return;
+ setLoadingMore(true);
+ try {
+ const nextPage = pageRef.current + 1;
+ const qs = buildFilterParams(nextPage);
+ const response = await request<{ posts?: Post[]; totalPages?: number }>(
+ "GET",
+ `/users/web?${qs.toString()}`,
+ null,
+ { noToast: true },
+ );
+ pageRef.current = nextPage;
+ totalPagesRef.current = response?.totalPages ?? totalPagesRef.current;
+ const posts = response?.posts || [];
+ const additions: SuggestedFollowUser[] = [];
+ posts.forEach((post) => {
+ const id = String(post.userId || post.user_id || "");
+ if (!id || knownUserIdsRef.current.has(id)) return;
+ knownUserIdsRef.current.add(id);
+ additions.push({
+ _id: id,
+ user_name: post.user_name,
+ first_name: post.first_name,
+ last_name: post.last_name,
+ expertise: post.expertise,
+ profile_image: post.profile_image,
+ previewPost: {
+ _id: post._id,
+ type: post.type,
+ post_images: post.post_images,
+ post_video: post.post_video,
+ files: post.files,
+ caption: post.caption,
+ },
+ is_following: post.is_following,
+ });
+ });
+ if (additions.length) {
+ setUsers((prev) => [...prev, ...additions]);
+ }
+ } catch {
+ // خطای صفحهی بعدی نباید نتایجِ از قبل لودشده رو پاک کنه
+ } finally {
+ setLoadingMore(false);
+ }
+ }, [request, buildFilterParams, loadingMore]);
+
+ // با نزدیک شدن به انتهای لیستِ لودشده (حین سوایپ)، صفحهی بعد رو پیشلود کن
+ useEffect(() => {
+ if (phase !== "results") return;
+ if (pageRef.current >= totalPagesRef.current) return;
+ if (users.length - index <= PREFETCH_THRESHOLD) {
+ void fetchNextPage();
+ }
}, [
- request,
- selectedExpertise,
- stateId,
- cityId,
- userLevel,
- rateFilter,
- heightMin,
- heightMax,
- weightMin,
- weightMax,
- sizeMin,
- sizeMax,
- hairColor,
- eyeColor,
+ phase,
+ index,
+ users.length,
+ fetchNextPage,
]);
const handleFilterChange = () => {
diff --git a/src/app/new-post/page.tsx b/src/app/new-post/page.tsx
index 5ed0c7f..090be08 100644
--- a/src/app/new-post/page.tsx
+++ b/src/app/new-post/page.tsx
@@ -6,6 +6,7 @@ import useAxios from "@/hooks/useAxios";
import { optimizeImageToWebP, optimizeVideoToMp4, isImageAspectRatioAllowed } from "@/lib/media";
import { createStoryBase64, StoryOverlay } from "@/api/fetchStories";
import StoryEditor from "@/components/stories/StoryEditor";
+import StoryCameraCapture from "@/components/stories/StoryCameraCapture";
import TagUsersPicker, { TaggedUser } from "@/components/posts/TagUsersPicker";
import PostLinkPicker from "@/components/posts/PostLinkPicker";
import {
@@ -67,12 +68,13 @@ export default function NewPostPage() {
const editPostId = searchParams.get("edit") || "";
const isEditMode = Boolean(editPostId);
- const initialMode = (searchParams.get("type") as CreateMode) || "image";
+ const initialMode = (searchParams.get("type") as CreateMode) || "video";
const [mode, setMode] = useState(
- ["image", "video", "story"].includes(initialMode) ? initialMode : "image"
+ ["image", "video", "story"].includes(initialMode) ? initialMode : "video"
);
const [file, setFile] = useState(null);
const [preview, setPreview] = useState("");
+ const [cameraOpen, setCameraOpen] = useState(false);
const [gallery, setGallery] = useState([]);
const [description, setDescription] = useState("");
const [taggedUsers, setTaggedUsers] = useState([]);
@@ -249,6 +251,16 @@ export default function NewPostPage() {
if (mode === "video") setVideoCover(null);
};
+ // نتیجهی دوربین (فیلترِ رنگونور روی مرورگر با CSS filter اعمال شده، از قبل روی
+ // canvas پخته شده) دقیقاً مثل یه فایلِ انتخابشده از گالری با همون state هاست میشه
+ const onCameraCapture = (captured: File) => {
+ if (preview?.startsWith("blob:")) URL.revokeObjectURL(preview);
+ setFile(captured);
+ setPreview(URL.createObjectURL(captured));
+ setStoryOverlays([]);
+ setCameraOpen(false);
+ };
+
const returnPath = useMemo(() => {
const params = new URLSearchParams();
if (mode !== "image") params.set("type", mode);
@@ -562,7 +574,7 @@ export default function NewPostPage() {
{/* Mode tabs */}
{!isEditMode ? (
- {(["image", "video", "story"] as CreateMode[]).map((m) => (
+ {(["video", "image", "story"] as CreateMode[]).map((m) => (
)}
+ {!preview && isStory ? (
+
+ ) : null}
{preview && (
);
diff --git a/src/app/posts/[id]/[[...slug]]/page.tsx b/src/app/posts/[id]/[[...slug]]/page.tsx
index 770ebf0..402bbed 100644
--- a/src/app/posts/[id]/[[...slug]]/page.tsx
+++ b/src/app/posts/[id]/[[...slug]]/page.tsx
@@ -5,7 +5,6 @@ import { buildStorageUrl } from "@/components/main/BaseUrl";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import {
buildPostPath,
- buildPostPublicUrl,
buildPostSeoDescription,
buildPostSeoTitle,
} from "@/lib/postSlug";
diff --git a/src/app/settings/academy/my-courses/page.tsx b/src/app/settings/academy/my-courses/page.tsx
index 06d2842..0146daf 100644
--- a/src/app/settings/academy/my-courses/page.tsx
+++ b/src/app/settings/academy/my-courses/page.tsx
@@ -12,6 +12,7 @@ import toast from "react-hot-toast";
import { motion, AnimatePresence } from "framer-motion";
import { useTranslation } from "react-i18next";
import BackButton from "@/components/ui/BackButton";
+import Link from "next/link";
interface PaginationData {
page: number;
@@ -172,7 +173,7 @@ export default function PurchasedCoursesPage() {
{t("settings.academy.myCourses.emptyDescription")}
-
@@ -190,7 +191,7 @@ export default function PurchasedCoursesPage() {
/>
{t("settings.academy.myCourses.browseCourses")}
-
+
) : (
<>
diff --git a/src/app/settings/academy/storeProfile/page.tsx b/src/app/settings/academy/storeProfile/page.tsx
index 365a4a8..97084be 100644
--- a/src/app/settings/academy/storeProfile/page.tsx
+++ b/src/app/settings/academy/storeProfile/page.tsx
@@ -28,7 +28,7 @@ import "leaflet/dist/leaflet.css";
import { useRouter } from "next/navigation";
import useAxios from "@/hooks/useAxios";
import { Academy } from "@/types/types";
-import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
+import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import { AcademyProfileHeadSkeleton } from "@/components/academy/AcademySkeletons";
import { Skeleton } from "@/components/ui/skeleton";
import {
@@ -77,13 +77,12 @@ interface AcademyResponse {
type TagType = { value: string };
export default function StoreSettings() {
- const { request, loading, error } = useAxios();
+ const { request } = useAxios();
const router = useRouter();
const { t } = useTranslation("common");
const storeSchema = useMemo(() => buildStoreSchema(t), [t]);
const tagPlaceholder = t("settings.academy.storeProfile.tagPlaceholder");
const [previewProfile, setPreviewProfile] = useState("");
- const [avatar, setAvatar] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [isFetching, setIsFetching] = useState(true); // برای نمایش لودینگ اولیه
@@ -153,7 +152,6 @@ export default function StoreSettings() {
// تنظیم پیشنمایش عکس
if (response.academy.academy_image) {
setPreviewProfile(response.academy.academy_image);
- setAvatar(response.academy.academy_image);
}
}
} catch (error) {
@@ -181,7 +179,6 @@ export default function StoreSettings() {
const file = event.target.files[0];
const previewUrl = URL.createObjectURL(file);
setPreviewProfile(previewUrl);
- setAvatar(previewUrl);
}
};
diff --git a/src/app/settings/academy/wallet/page.tsx b/src/app/settings/academy/wallet/page.tsx
index b1c3b9d..863ffa1 100644
--- a/src/app/settings/academy/wallet/page.tsx
+++ b/src/app/settings/academy/wallet/page.tsx
@@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect } from "react";
-import { ThemeProvider, useTheme } from "next-themes";
+import { ThemeProvider } from "next-themes";
import { Skeleton } from "@/components/ui/skeleton";
import {
diff --git a/src/app/settings/chats/find-partner/page.tsx b/src/app/settings/chats/find-partner/page.tsx
index 5bba24b..bc74b1f 100644
--- a/src/app/settings/chats/find-partner/page.tsx
+++ b/src/app/settings/chats/find-partner/page.tsx
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
+import { useRouter } from "next/navigation";
import Container from "@/components/elements/Container";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import VerificationBadge from "@/components/main/VerificationBadge";
@@ -9,14 +10,23 @@ import LocalePageShell from "@/components/i18n/LocalePageShell";
import RoundedButton from "@/components/elements/RoundedButton";
import PageTitle from "@/components/settings/PageTitle";
import IOSSpinner from "@/components/ui/IOSSpinner";
+import ModelDetailModal from "@/components/models/ModelDetailModal";
+import ModelServiceModal from "@/components/models/ModelServiceModal";
import useAxios from "@/hooks/useAxios";
import { useTranslation } from "react-i18next";
import { formatFullName } from "@/lib/formatFullName";
+import { isModelExpertise } from "@/lib/isModelExpertise";
+import type { Service } from "@/types/types";
import toast from "react-hot-toast";
import FindPartnerFilterModal, {
EMPTY_PARTNER_FILTERS,
PartnerFilters,
} from "@/components/chats/FindPartnerFilterModal";
+import {
+ DESIRED_AGE_RANGE_TO_MIN_MAX,
+ joinFilterValues,
+ type DesiredAgeRange,
+} from "@/lib/personalDetailsFieldOptions";
type PartnerUser = {
_id: string;
@@ -36,6 +46,19 @@ type PartnerUser = {
type PartnerAlert = { _id: string } | null;
+// عین فلوی ModelContent.tsx (دکمهی «درخواست همکاری» → ModelDetailModal/ModelServiceModal
+// → دکمهی داخلِ مودال میره /offer/[id]): برای تصمیمِ مدل/غیرمدل و رندرِ مودالِ درست
+type CollabUser = {
+ _id: string;
+ expertise?: string;
+ hair_color?: string | null;
+ eye_color?: string | null;
+ height?: string | null;
+ size?: string | null;
+ weight?: string | null;
+ services?: Service[];
+};
+
function ageFromBirthday(birthday?: string | null): number | null {
if (!birthday) return null;
const diffMs = Date.now() - new Date(birthday).getTime();
@@ -49,8 +72,16 @@ function filtersToParams(filters: PartnerFilters): Record {
if (value) params[key] = value;
};
put("gender", filters.gender);
- put("ageMin", filters.ageMin);
- put("ageMax", filters.ageMax);
+
+ // بازهی سنی: ورودی عددی دستی (ageMin/ageMax) اگر پر شده باشد اولویت دارد؛
+ // در غیر این صورت از دستهی «بازه سنی مورد نظر» (desired_age_range) به
+ // ageMin/ageMax عددی که سرچ سرور میفهمد ترجمه میشود.
+ const bucket = filters.desiredAgeRange
+ ? DESIRED_AGE_RANGE_TO_MIN_MAX[filters.desiredAgeRange as DesiredAgeRange]
+ : undefined;
+ put("ageMin", filters.ageMin || (bucket?.min != null ? String(bucket.min) : undefined));
+ put("ageMax", filters.ageMax || (bucket?.max != null ? String(bucket.max) : undefined));
+
put("heightMin", filters.heightMin);
put("heightMax", filters.heightMax);
put("weightMin", filters.weightMin);
@@ -65,6 +96,16 @@ function filtersToParams(filters: PartnerFilters): Record {
put("personal_style", filters.personalStyle);
put("income_range", filters.incomeRange);
put("job", filters.job);
+ put("relationship_goal", joinFilterValues(filters.relationshipGoal));
+ put("seeking_gender", filters.seekingGender);
+ put("personality_traits", joinFilterValues(filters.personalityTraits));
+ put("lifestyle", joinFilterValues(filters.lifestyle));
+ put("interests", joinFilterValues(filters.interests));
+ put("values", joinFilterValues(filters.values));
+ put("relationship_view", filters.relationshipView);
+ put("geo_preference", filters.geoPreference);
+ put("children_status", filters.childrenStatus);
+ put("smoking_status", filters.smokingStatus);
if (filters.geo.countryId) params.country = String(filters.geo.countryId);
if (filters.geo.provinceId) params.province = String(filters.geo.provinceId);
if (filters.geo.cityId) params.city = String(filters.geo.cityId);
@@ -74,6 +115,7 @@ function filtersToParams(filters: PartnerFilters): Record {
export default function FindPartnerPage() {
const { t } = useTranslation("common");
const { request } = useAxios();
+ const router = useRouter();
const [filters, setFilters] = useState(EMPTY_PARTNER_FILTERS);
const [showFilterModal, setShowFilterModal] = useState(false);
@@ -81,6 +123,33 @@ export default function FindPartnerPage() {
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
const [alert, setAlert] = useState(null);
const [savingAlert, setSavingAlert] = useState(false);
+ const [collabUser, setCollabUser] = useState(null);
+
+ // عین sendMsgHandler توی ModelContent.tsx: قبل از رفتن به چت، وضعیت درخواست
+ // همکاری (offer_status) رو تازه میگیریم — بدون درخواست ثبتشده، اول باید همون
+ // مودالِ واقعیِ مشخصاتظاهری/لیستخدمات باز بشه (نه مستقیم صفحهی ثبت درخواست)
+ const openUser = useCallback(
+ async (item: PartnerUser) => {
+ try {
+ const res = await request<{ user?: CollabUser & { offer_status?: boolean } }>(
+ "GET",
+ `/users/get/web?user_id=${item._id}&_t=${Date.now()}`,
+ null,
+ { noToast: true }
+ );
+ if (res?.user?.offer_status) {
+ router.push(`/settings/chats/${item.user_name}/${item._id}`);
+ return;
+ }
+ setCollabUser(res?.user ? { ...res.user, _id: item._id } : { _id: item._id });
+ return;
+ } catch {
+ // در صورت خطا هم بریم صفحهی ثبت درخواست، نه مستقیم چت
+ }
+ router.push(`/offer/${item._id}`);
+ },
+ [request, router]
+ );
const runSearch = useCallback(
async (activeFilters: PartnerFilters) => {
@@ -207,6 +276,10 @@ export default function FindPartnerPage() {
{
+ e.preventDefault();
+ void openUser(item);
+ }}
className="flex items-center gap-3 py-3"
>
+
+ {collabUser &&
+ (isModelExpertise(collabUser.expertise) ? (
+ setCollabUser(null)}
+ userId={collabUser._id}
+ hairColor={collabUser.hair_color}
+ eyeColor={collabUser.eye_color}
+ height={collabUser.height}
+ size={collabUser.size}
+ weight={collabUser.weight}
+ isMain
+ />
+ ) : (
+ setCollabUser(null)}
+ userId={collabUser._id}
+ services={collabUser.services}
+ />
+ ))}
);
}
diff --git a/src/app/settings/chats/nearby/page.tsx b/src/app/settings/chats/nearby/page.tsx
index 4a6822c..e26bb46 100644
--- a/src/app/settings/chats/nearby/page.tsx
+++ b/src/app/settings/chats/nearby/page.tsx
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
+import { useRouter } from "next/navigation";
import Map, { Marker } from "react-map-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import Container from "@/components/elements/Container";
@@ -12,11 +13,15 @@ import RoundedButton from "@/components/elements/RoundedButton";
import PageTitle from "@/components/settings/PageTitle";
import IOSSpinner from "@/components/ui/IOSSpinner";
import BoldIcon from "@/components/ui/BoldIcon";
+import ModelDetailModal from "@/components/models/ModelDetailModal";
+import ModelServiceModal from "@/components/models/ModelServiceModal";
import useAxios from "@/hooks/useAxios";
import { useUser } from "@/hooks/useUser";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { formatFullName } from "@/lib/formatFullName";
+import { isModelExpertise } from "@/lib/isModelExpertise";
+import type { Service } from "@/types/types";
const MAPBOX_ACCESS_TOKEN =
"pk.eyJ1IjoiYWxkZjkyIiwiYSI6ImNscHh5ZG56dTByMXAycnJ2cDNmdDQ5M3YifQ.a_sUt1rLFMfA0jHrETcM7A";
@@ -51,9 +56,25 @@ type NearbyUser = {
is_verified?: string;
verify_badge?: string;
is_Register?: string | boolean;
+ /** فقط با این تیک («نمایش لوکیشن به دیگران») باید روی خودِ نقشه قرار بگیره — بدونش
+ * فقط توی رادار (نمایش تقریبی، بدون نقشهی واقعی) نشون داده میشه */
+ show_location?: boolean;
distanceMeters: number;
};
+// عین فلوی ModelContent.tsx (دکمهی «درخواست همکاری» → ModelDetailModal/ModelServiceModal
+// → دکمهی داخلِ مودال میره /offer/[id]): برای تصمیمِ مدل/غیرمدل و رندرِ مودالِ درست
+type CollabUser = {
+ _id: string;
+ expertise?: string;
+ hair_color?: string | null;
+ eye_color?: string | null;
+ height?: string | null;
+ size?: string | null;
+ weight?: string | null;
+ services?: Service[];
+};
+
type NearbyGate =
| "checking"
| "location_disabled"
@@ -93,6 +114,7 @@ export default function NearbyFriendsPage() {
const { t } = useTranslation("common");
const user = useUser();
const { request } = useAxios();
+ const router = useRouter();
const [gate, setGate] = useState("checking");
const [users, setUsers] = useState([]);
@@ -102,8 +124,15 @@ export default function NearbyFriendsPage() {
);
const [showMap, setShowMap] = useState(false);
const [radiusKm, setRadiusKm] = useState(DEFAULT_RADIUS_METERS / 1000);
+ const [collabUser, setCollabUser] = useState(null);
const radarUsers = useMemo(() => users.slice(0, 12), [users]);
+ // فقط کاربرهایی که «نمایش لوکیشن به دیگران» رو فعال کردن باید روی خودِ نقشه (که
+ // موقعیتِ واقعیترشون رو نشون میده) بیان — رادار بالای صفحه برای همه (radarUsers) باقی میمونه
+ const mapEligibleUsers = useMemo(
+ () => users.filter((u) => u.show_location),
+ [users]
+ );
const radarActive =
gate === "locating" || gate === "checking" || gate === "ready";
const radiusMeters = radiusKm * 1000;
@@ -188,6 +217,32 @@ export default function NearbyFriendsPage() {
[coords, loadNearby]
);
+ // عین sendMsgHandler توی ModelContent.tsx: قبل از رفتن به چت، وضعیت درخواست
+ // همکاری (offer_status) رو تازه میگیریم — بدون درخواست ثبتشده، اول باید همون
+ // مودالِ واقعیِ مشخصاتظاهری/لیستخدمات باز بشه (نه مستقیم صفحهی ثبت درخواست)
+ const openUser = useCallback(
+ async (item: NearbyUser) => {
+ try {
+ const res = await request<{ user?: CollabUser & { offer_status?: boolean } }>(
+ "GET",
+ `/users/get/web?user_id=${item._id}&_t=${Date.now()}`,
+ null,
+ { noToast: true }
+ );
+ if (res?.user?.offer_status) {
+ router.push(`/settings/chats/${item.user_name}/${item._id}`);
+ return;
+ }
+ setCollabUser(res?.user ? { ...res.user, _id: item._id } : { _id: item._id });
+ return;
+ } catch {
+ // در صورت خطا هم بریم صفحهی ثبت درخواست، نه مستقیم چت
+ }
+ router.push(`/offer/${item._id}`);
+ },
+ [request, router]
+ );
+
useEffect(() => {
if (!user) return;
@@ -299,6 +354,10 @@ export default function NearbyFriendsPage() {
{
+ e.preventDefault();
+ void openUser(item);
+ }}
className="absolute z-10 -translate-x-1/2 -translate-y-1/2 transition-transform active:scale-95"
style={pos}
title={item.user_name}
@@ -428,6 +487,10 @@ export default function NearbyFriendsPage() {
{
+ e.preventDefault();
+ void openUser(item);
+ }}
className="flex items-center gap-3 py-3"
>
- {users.map((item, index) => {
+ {mapEligibleUsers.map((item, index) => {
const [lng, lat] = approximateCoordinate(
coords.lat,
coords.lng,
item.distanceMeters,
index,
- users.length
+ mapEligibleUsers.length
);
return (
{
+ e.preventDefault();
+ void openUser(item);
+ }}
className="flex flex-col items-center"
title={item.user_name}
>
@@ -560,6 +627,28 @@ export default function NearbyFriendsPage() {
) : null}
+
+ {collabUser &&
+ (isModelExpertise(collabUser.expertise) ? (
+ setCollabUser(null)}
+ userId={collabUser._id}
+ hairColor={collabUser.hair_color}
+ eyeColor={collabUser.eye_color}
+ height={collabUser.height}
+ size={collabUser.size}
+ weight={collabUser.weight}
+ isMain
+ />
+ ) : (
+ setCollabUser(null)}
+ userId={collabUser._id}
+ services={collabUser.services}
+ />
+ ))}
);
}
diff --git a/src/app/settings/edit/birthday/page.tsx b/src/app/settings/edit/birthday/page.tsx
new file mode 100644
index 0000000..f8045da
--- /dev/null
+++ b/src/app/settings/edit/birthday/page.tsx
@@ -0,0 +1,108 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+"use client";
+
+import Container from "@/components/elements/Container";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import AuthNextButton from "@/components/auth/AuthNextButton";
+import EditPageHeader from "@/components/settings/EditPageHeader";
+import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons";
+import useAxios from "@/hooks/useAxios";
+import { useUser } from "@/hooks/useUser";
+import { useRouter } from "next/navigation";
+import { useEffect, useState } from "react";
+import toast from "react-hot-toast";
+import { useTranslation } from "react-i18next";
+import DatePicker from "react-multi-date-picker";
+import persian from "react-date-object/calendars/persian";
+import persian_fa from "react-date-object/locales/persian_fa";
+import Image from "next/image";
+import moment from "jalali-moment";
+
+const p2e = (es: string): string =>
+ es.replace(/[۰-۹]/g, (d) => String.fromCharCode("۰۱۲۳۴۵۶۷۸۹".indexOf(d) + 48));
+
+// نسخهی مستقل و سبک برای ثبتِ فقط تاریخ تولد، بدون شبا/کد ملیِ الزامیِ
+// POST /verify/auth (نگاه کنید settings/edit/shaba/page.tsx) — عیناً همون
+// مکانیزمِ انتخابگرِ تاریخِ آن صفحه، ذخیره از طریق PATCH /verify/personal_details
+function EditBirthdayPage() {
+ const { t } = useTranslation("common");
+ const router = useRouter();
+ const { request, loading } = useAxios();
+ const user = useUser();
+ const [birthDate, setBirthDate] = useState("");
+
+ useEffect(() => {
+ if (user?.birthday) {
+ setBirthDate(moment(user.birthday, "YYYY/MM/DD").format("jYYYY/jMM/jDD"));
+ }
+ }, [user?.birthday]);
+
+ const submit = async () => {
+ if (!birthDate) {
+ toast.error(t("auth.birthDateRequired"));
+ return;
+ }
+ try {
+ await request("PATCH", "/verify/personal_details", {
+ birthday: moment(p2e(birthDate), "jYYYY/jMM/jDD").format("YYYY/MM/DD"),
+ });
+ toast.success(t("settings.edit.personalDetails.saveSuccess"));
+ router.push("/settings/edit/personal-details");
+ } catch (err: any) {
+ toast.error(err?.response?.data?.message || t("settings.edit.unknownError"));
+ }
+ };
+
+ const canSave = Boolean(birthDate) && !loading;
+
+ return (
+
+
+
+
+ {!user ? (
+
+ ) : (
+
+
+
+ setBirthDate(value?.format("YYYY/MM/DD") || "")
+ }
+ calendarPosition="bottom-center"
+ placeholder={t("auth.birthDatePlaceholder")}
+ inputClass="w-full dir-ltr max-w-[290px] p-3 !pr-6 text-center rounded-2xl border border-gray-300 bg-secondary-light dark:bg-secondary-dark dark:border-neutral-600 font-medium h-[44px]"
+ />
+
+
+
+
+ {t("settings.edit.save")}
+
+
+ )}
+
+
+
+ );
+}
+
+export default EditBirthdayPage;
diff --git a/src/app/settings/edit/gender/page.tsx b/src/app/settings/edit/gender/page.tsx
new file mode 100644
index 0000000..655cd54
--- /dev/null
+++ b/src/app/settings/edit/gender/page.tsx
@@ -0,0 +1,105 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+"use client";
+
+import Container from "@/components/elements/Container";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import AuthNextButton from "@/components/auth/AuthNextButton";
+import EditPageHeader from "@/components/settings/EditPageHeader";
+import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons";
+import useAxios from "@/hooks/useAxios";
+import { useUser } from "@/hooks/useUser";
+import { useRouter } from "next/navigation";
+import { useState } from "react";
+import toast from "react-hot-toast";
+import { useTranslation } from "react-i18next";
+import Image from "next/image";
+
+// نسخهی مستقل از (auth)/verify/gender/page.tsx: بهجای پیشروی توی ویزارد ثبتنام،
+// فقط ذخیره میکنه و به «مشخصات فردی» برمیگرده — برای تکمیل جنسیتِ ثبتنشده از
+// همون صفحه (نگاه کنید src/app/settings/edit/personal-details/page.tsx)
+function EditGenderPage() {
+ const { t } = useTranslation("common");
+ const router = useRouter();
+ const { request, loading } = useAxios();
+ const user = useUser();
+ const [gender, setGender] = useState<"male" | "female" | "">(
+ user?.gender === "male" || user?.gender === "female" ? user.gender : ""
+ );
+
+ const submit = async () => {
+ if (!gender) {
+ toast.error(t("auth.genderRequired"));
+ return;
+ }
+ try {
+ const mobile =
+ typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
+ await request("POST", "/verify/gender", { mobile, gender });
+ toast.success(t("settings.edit.personalDetails.saveSuccess"));
+ router.push("/settings/edit/personal-details");
+ } catch (err: any) {
+ toast.error(err?.response?.data?.message || t("auth.unknownError"));
+ }
+ };
+
+ return (
+
+
+
+
+ {!user ? (
+
+ ) : (
+
+
+
setGender("female")}
+ className={`w-full max-w-[250px] !border-[#FC8EAC] flex items-center justify-center flex-row-reverse gap-2 ${
+ gender === "female" ? "btn-modern--selected text-white" : ""
+ }`}
+ >
+
+ {t("auth.female")}
+
+
setGender("male")}
+ className={`w-full max-w-[250px] !border-[#3E8BFF] flex items-center justify-center flex-row-reverse gap-2 ${
+ gender === "male" ? "!bg-[#3E8BFF] !text-white" : ""
+ }`}
+ >
+
+ {t("auth.male")}
+
+
+
+
+ {t("settings.edit.save")}
+
+
+ )}
+
+
+
+ );
+}
+
+export default EditGenderPage;
diff --git a/src/app/settings/edit/link-device/page.tsx b/src/app/settings/edit/link-device/page.tsx
index 9aabd6d..00895d1 100644
--- a/src/app/settings/edit/link-device/page.tsx
+++ b/src/app/settings/edit/link-device/page.tsx
@@ -24,7 +24,6 @@ export default function LinkDevicePage() {
const { t } = useTranslation("common");
const router = useRouter();
const [phase, setPhase] = useState("loading");
- const [linkId, setLinkId] = useState(null);
const [approveUrl, setApproveUrl] = useState(null);
const ephemeralPrivateKeyRef = useRef(null);
@@ -44,7 +43,6 @@ export default function LinkDevicePage() {
try {
const { linkId: id, ephemeralPrivateKey } = await startDeviceLinkSession();
ephemeralPrivateKeyRef.current = ephemeralPrivateKey;
- setLinkId(id);
const url = `${window.location.origin}/settings/edit/link-device/approve?id=${id}`;
setApproveUrl(url);
diff --git a/src/app/settings/edit/personal-details/page.tsx b/src/app/settings/edit/personal-details/page.tsx
index fcefe19..4835391 100644
--- a/src/app/settings/edit/personal-details/page.tsx
+++ b/src/app/settings/edit/personal-details/page.tsx
@@ -13,12 +13,96 @@ import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
+import RoundedSelect from "@/components/ui/RoundedSelect";
+import MultiSelectDropdown from "@/components/ui/MultiSelectDropdown";
+import Link from "next/link";
+import {
+ buildRelationshipGoalOptions,
+ buildMaritalStatusNewOptions,
+ buildIncomeRangeOptions,
+ buildPersonalityTraitsOptions,
+ buildLifestyleOptions,
+ buildInterestsOptions,
+ buildValuesOptions,
+ buildRelationshipViewOptions,
+ buildGeoPreferenceOptions,
+ buildChildrenStatusOptions,
+ buildSmokingStatusOptions,
+ RELATIONSHIP_GOAL_MAX,
+ PERSONALITY_TRAITS_MAX,
+ LIFESTYLE_MAX,
+ INTERESTS_MAX,
+ VALUES_MAX,
+ type RelationshipView,
+ type GeoPreference,
+ type ChildrenStatus,
+ type SmokingStatus,
+} from "@/lib/personalDetailsFieldOptions";
-type MaritalStatus = "single" | "divorced" | null;
+type MaritalStatus =
+ | "single"
+ | "divorced"
+ | "widowed"
+ | "not_in_relationship"
+ | "prefer_not_to_say"
+ | null;
type ResidenceType = "owner" | "tenant" | null;
type VehicleType = "none" | "car" | "motorcycle" | null;
type PersonalStyle = "formal" | "sporty" | null;
-type IncomeRange = "under_5m" | "5m_10m" | "10m_20m" | "over_20m" | null;
+type IncomeRange =
+ | "under_40m"
+ | "40m_50m"
+ | "50m_60m"
+ | "60m_70m"
+ | "70m_80m"
+ | "80m_90m"
+ | "90m_100m"
+ | "100m_150m"
+ | "150m_200m"
+ | "over_200m"
+ | null;
+
+// عین ageFromBirthday توی settings/chats/find-partner/page.tsx — برای نمایشِ
+// فقطخواندنیِ سنِ واقعیِ کاربر (نه ترجیح جستجو) توی همین صفحه
+function ageFromBirthday(birthday?: string | null): number | null {
+ if (!birthday) return null;
+ const diffMs = Date.now() - new Date(birthday).getTime();
+ if (Number.isNaN(diffMs)) return null;
+ return Math.floor(diffMs / (365.25 * 24 * 60 * 60 * 1000));
+}
+
+// ردیف فقطخواندنی برای جنسیت/سنِ واقعیِ کاربر (از پروفایل، نه ترجیح جستجو) —
+// طبق درخواست کاربر seeking_gender/desired_age_range فقط باید توی فیلتر «یافتن
+// همکار» (FindPartnerFilterModal.tsx) بمونن، نه توی ویرایش مشخصات فردی؛ اگه
+// مقدار واقعی هنوز ثبت نشده، بهجاش یه لینک «افزودن» نشون داده میشه
+function ReadOnlyValueRow({
+ label,
+ value,
+ href,
+}: {
+ label: string;
+ value: string | null;
+ href: string;
+}) {
+ return (
+
+
{label}
+ {value ? (
+
+ {value}
+
+ ) : (
+
+ افزودن
+
+
+
+ )}
+
+ );
+}
function PersonalDetailsPage() {
const { t } = useTranslation("common");
@@ -36,6 +120,30 @@ function PersonalDetailsPage() {
const [incomeRange, setIncomeRange] = useState(null);
const [moralTraits, setMoralTraits] = useState("");
+ const [relationshipGoal, setRelationshipGoal] = useState([]);
+ const [personalityTraits, setPersonalityTraits] = useState([]);
+ const [lifestyle, setLifestyle] = useState([]);
+ const [interests, setInterests] = useState([]);
+ const [values, setValues] = useState([]);
+ const [relationshipView, setRelationshipView] = useState("");
+ const [geoPreference, setGeoPreference] = useState("");
+ const [childrenStatus, setChildrenStatus] = useState("");
+ const [smokingStatus, setSmokingStatus] = useState("");
+
+ const maritalStatusNewOptions = buildMaritalStatusNewOptions(t);
+ const incomeRangeOptions = buildIncomeRangeOptions(t);
+ const relationshipGoalOptions = buildRelationshipGoalOptions(t);
+ const personalityTraitsOptions = buildPersonalityTraitsOptions(t);
+ const lifestyleOptions = buildLifestyleOptions(t);
+ const interestsOptions = buildInterestsOptions(t);
+ const valuesOptions = buildValuesOptions(t);
+ const relationshipViewOptions = buildRelationshipViewOptions(t);
+ const geoPreferenceOptions = buildGeoPreferenceOptions(t);
+ const childrenStatusOptions = buildChildrenStatusOptions(t);
+ const smokingStatusOptions = buildSmokingStatusOptions(t);
+ const selectedCountLabel = (count: number) =>
+ t("settings.edit.personalDetails.multiSelectSelectedCount", { count });
+
useEffect(() => {
if (!user) return;
setMaritalStatus(user.marital_status ?? null);
@@ -47,6 +155,15 @@ function PersonalDetailsPage() {
setJob(user.job ?? "");
setIncomeRange(user.income_range ?? null);
setMoralTraits(user.moral_traits ?? "");
+ setRelationshipGoal(user.relationship_goal ?? []);
+ setPersonalityTraits(user.personality_traits ?? []);
+ setLifestyle(user.lifestyle ?? []);
+ setInterests(user.interests ?? []);
+ setValues(user.values ?? []);
+ setRelationshipView(user.relationship_view ?? "");
+ setGeoPreference(user.geo_preference ?? "");
+ setChildrenStatus(user.children_status ?? "");
+ setSmokingStatus(user.smoking_status ?? "");
}, [user]);
const handleSave = async () => {
@@ -61,6 +178,15 @@ function PersonalDetailsPage() {
job: job || null,
income_range: incomeRange,
moral_traits: moralTraits || null,
+ relationship_goal: relationshipGoal.length ? relationshipGoal : null,
+ personality_traits: personalityTraits.length ? personalityTraits : null,
+ lifestyle: lifestyle.length ? lifestyle : null,
+ interests: interests.length ? interests : null,
+ values: values.length ? values : null,
+ relationship_view: relationshipView || null,
+ geo_preference: geoPreference || null,
+ children_status: childrenStatus || null,
+ smoking_status: smokingStatus || null,
});
toast.success(t("settings.edit.personalDetails.saveSuccess"));
router.push("/settings/edit");
@@ -90,21 +216,31 @@ function PersonalDetailsPage() {
{t("settings.edit.personalDetails.maritalStatus")}
-
+
+ {maritalStatusNewOptions.map((opt) => (
+
+ ))}
@@ -220,41 +356,14 @@ function PersonalDetailsPage() {
{/* درآمد */}
-
-
- {t("settings.edit.personalDetails.incomeRange")}
-
-
-
-
-
-
-
-
+ setIncomeRange((v || null) as IncomeRange)}
+ className="mx-auto"
+ />
{/* خصوصیات اخلاقی */}
@@ -270,6 +379,149 @@ function PersonalDetailsPage() {
className="w-full rounded-3xl border border-neutral-950 bg-white px-4 py-3 font-medium text-neutral-900 dark:border-neutral-400 dark:bg-neutral-950 dark:text-neutral-50"
/>
+
+
+
+ {/* هدف از رابطه */}
+
+
+ {/* جنسیت (فقطخواندنی، از ثبتنام) */}
+
+
+ {/* سن (فقطخواندنی، از تاریخ تولد ثبتشده) */}
+
+
+ {/* ویژگیهای شخصیتی */}
+
+
+ {/* سبک زندگی */}
+
+
+ {/* علاقهمندیها */}
+
+
+ {/* ارزشها */}
+
+
+ {/* دیدگاه نسبت به رابطه */}
+ setRelationshipView(v as RelationshipView)}
+ className="mx-auto"
+ />
+
+ {/* محدوده جغرافیایی مورد نظر */}
+ setGeoPreference(v as GeoPreference)}
+ className="mx-auto"
+ />
+
+ {/* وضعیت فرزند */}
+ setChildrenStatus(v as ChildrenStatus)}
+ className="mx-auto"
+ />
+
+ {/* وضعیت مصرف دخانیات */}
+ setSmokingStatus(v as SmokingStatus)}
+ className="mx-auto"
+ />
([]);
const [coursesPage, setCoursesPage] = useState(1);
diff --git a/src/components/academy/MainModelCard.tsx b/src/components/academy/MainModelCard.tsx
index a945580..82fc94b 100644
--- a/src/components/academy/MainModelCard.tsx
+++ b/src/components/academy/MainModelCard.tsx
@@ -27,7 +27,6 @@ import "slick-carousel/slick/slick-theme.css";
import useAxios from "@/hooks/useAxios";
import { useRouter } from "next/navigation";
import { navigateWithSavedScroll } from "@/lib/listScrollRestoration";
-import { forEach } from "lodash";
import { formatCoursePriceLabel } from "@/lib/formatCoursePriceLabel";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -58,23 +57,18 @@ function MainModelCard({
user_id,
caption,
type,
- price,
cuorse_name,
category,
- offer,
academyId,
number_of_course_content,
course_image,
} = postData;
// console.log(postData._id);
-
+
const user = useUserById(user_id);
- const profile_image = user?.profile_image;
const verify_badge = user?.verify_badge;
- const user_score = user?.user_score;
- const rate = user?.rate;
const { request } = useAxios();
const router = useRouter()
diff --git a/src/components/academy/MainModelCardActions.tsx b/src/components/academy/MainModelCardActions.tsx
index f77ad13..bbf5a5d 100644
--- a/src/components/academy/MainModelCardActions.tsx
+++ b/src/components/academy/MainModelCardActions.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Course, Post } from "@/types/types";
+import { Course } from "@/types/types";
import Image from "next/image";
import React, { useEffect, useState } from "react";
import ReportModal from "../main/ReportModal";
@@ -10,8 +10,6 @@ import toast from "react-hot-toast";
import ModelDetailModal from "../models/ModelDetailModal";
import ModelServiceModal from "../models/ModelServiceModal";
import { useUserById } from "@/hooks/getUserById";
-import { AxiosError } from "axios";
-import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { useRouter } from "next/navigation";
import { useRequireAuth } from "@/lib/auth/useRequireAuth";
import { SocialIconButton, SocialStatButton } from "@/components/ui/SocialStatButton";
@@ -67,7 +65,7 @@ function MainModelCardActions({ postData }: { postData: Course }) {
if (liked === false) {
try {
- const response = await request("POST", `/academy/academy/course/like`, {
+ await request("POST", `/academy/academy/course/like`, {
courseId: _id,
});
@@ -79,7 +77,7 @@ function MainModelCardActions({ postData }: { postData: Course }) {
}
if (liked === true) {
try {
- const response = await request(
+ await request(
"POST",
`/academy/academy/course/dislike`,
{
diff --git a/src/components/billboards/BillboardPage/BillboardImageSlider.tsx b/src/components/billboards/BillboardPage/BillboardImageSlider.tsx
index 0f21644..b1e7a29 100644
--- a/src/components/billboards/BillboardPage/BillboardImageSlider.tsx
+++ b/src/components/billboards/BillboardPage/BillboardImageSlider.tsx
@@ -5,7 +5,7 @@ import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import { Pagination } from "swiper/modules";
import Image from "next/image";
-import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
+import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
interface BillboardImageSliderProps {
images: string[];
diff --git a/src/components/billboards/Profile/AdProfileContentPosts.tsx b/src/components/billboards/Profile/AdProfileContentPosts.tsx
index 6e83d22..57b2c7d 100644
--- a/src/components/billboards/Profile/AdProfileContentPosts.tsx
+++ b/src/components/billboards/Profile/AdProfileContentPosts.tsx
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
-import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
+import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import React, { useState } from "react";
import Modal from "react-modal";
import { Swiper, SwiperSlide } from "swiper/react";
diff --git a/src/components/chat/E2eeIdentityGate.tsx b/src/components/chat/E2eeIdentityGate.tsx
index 2e34aee..e37dd25 100644
--- a/src/components/chat/E2eeIdentityGate.tsx
+++ b/src/components/chat/E2eeIdentityGate.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import Modal from "@/components/elements/Modal";
import RoundedButton from "@/components/elements/RoundedButton";
@@ -8,24 +8,21 @@ import {
IdentityRecoveryNeededError,
createFreshIdentity,
ensureIdentityKeys,
- fetchMyKeyBackup,
recoverIdentityFromBackup,
- saveKeyBackup,
} from "@/lib/e2ee";
-const SETUP_DISMISS_KEY = "e2ee_backup_setup_dismissed";
-
-type GateState =
- | { kind: "idle" }
- | { kind: "recovery"; hasBackup: boolean }
- | { kind: "setupPrompt" };
+type GateState = { kind: "idle" } | { kind: "recovery"; hasBackup: boolean };
/**
* توی ChatSocketProvider (سطحِ لایوت /settings/chats) mount میشه — جایی که
- * ensureIdentityKeys برای اولینبار صدا زده میشه. اگه یه هویتِ قبلی روی سرور باشه
- * که این دستگاه بهش دسترسی نداره، بهجای اینکه کدِ صداکننده (ChatSocketProvider) بیصدا
- * fail کنه، این کامپوننت رو مسدود میکنه تا کاربر یا با PIN بازیابی کنه یا صراحتاً
- * بپذیره هویتِ تازه بسازه (پیامهای قدیمی برای همیشه قفل میمونن)
+ * ensureIdentityKeys برای اولینبار صدا زده میشه.
+ *
+ * بکآپِ کلید حالا کاملاً خودکاره (با رمزِ per-account که سرور خودش میسازه، نه PIN
+ * کاربر) — پس دیگه هیچ پیشنهادِ «رمز بازیابی بساز»ی لازم نیست، و لاگین دوباره/دستگاهِ
+ * جدید هم خودش را قبل از رسیدن به اینجا با همون رمزِ خودکار بازیابی میکنه
+ * (lib/e2ee/keys.ts: tryAutoRecoverIdentity). این کارت فقط برای حالتِ نادری ظاهر
+ * میشه که حتی اون بازیابیِ خودکار هم شکست بخوره (مثلاً بکآپِ سرور با یه PIN دستیِ
+ * قدیمی — ازقبلازاینتغییر — رمزنگاری شده، یا واقعاً هیچ بکآپی نیست)
*/
export default function E2eeIdentityGate({ userId }: { userId: string }) {
const { t } = useTranslation("common");
@@ -35,29 +32,39 @@ export default function E2eeIdentityGate({ userId }: { userId: string }) {
const [busy, setBusy] = useState(false);
const [confirmFresh, setConfirmFresh] = useState(false);
- useEffect(() => {
- if (!userId) return;
- let cancelled = false;
-
- ensureIdentityKeys(userId)
- .then(async () => {
- if (cancelled) return;
- const dismissed = localStorage.getItem(SETUP_DISMISS_KEY) === "1";
- if (dismissed) return;
- const backup = await fetchMyKeyBackup();
- if (!cancelled && !backup) setState({ kind: "setupPrompt" });
- })
- .catch((err) => {
- if (cancelled) return;
+ const runEnsure = useCallback(
+ async (cancelledRef?: { current: boolean }) => {
+ try {
+ await ensureIdentityKeys(userId);
+ if (!cancelledRef?.current) setState({ kind: "idle" });
+ } catch (err) {
+ if (cancelledRef?.current) return;
if (err instanceof IdentityRecoveryNeededError) {
setState({ kind: "recovery", hasBackup: err.hasBackup });
}
- });
+ }
+ },
+ [userId]
+ );
+ useEffect(() => {
+ if (!userId) return;
+ const cancelledRef = { current: false };
+ void runEnsure(cancelledRef);
return () => {
- cancelled = true;
+ cancelledRef.current = true;
};
- }, [userId]);
+ }, [userId, runEnsure]);
+
+ const handleRetryAuto = async () => {
+ setError(null);
+ setBusy(true);
+ try {
+ await runEnsure();
+ } finally {
+ setBusy(false);
+ }
+ };
const handleRecover = async () => {
setError(null);
@@ -77,37 +84,13 @@ export default function E2eeIdentityGate({ userId }: { userId: string }) {
setBusy(true);
try {
await createFreshIdentity(userId);
- setState({ kind: "setupPrompt" });
+ setState({ kind: "idle" });
setConfirmFresh(false);
} finally {
setBusy(false);
}
};
- const handleSaveBackup = async () => {
- if (pin.trim().length < 4) {
- setError(t("chats.e2ee.pinTooShort"));
- return;
- }
- setError(null);
- setBusy(true);
- try {
- await saveKeyBackup(userId, pin);
- localStorage.setItem(SETUP_DISMISS_KEY, "1");
- setState({ kind: "idle" });
- setPin("");
- } catch {
- setError(t("chats.e2ee.backupSaveFailed"));
- } finally {
- setBusy(false);
- }
- };
-
- const dismissSetupPrompt = () => {
- localStorage.setItem(SETUP_DISMISS_KEY, "1");
- setState({ kind: "idle" });
- };
-
if (state.kind === "recovery") {
return (
{}} height="fit" elevated>
@@ -119,6 +102,15 @@ export default function E2eeIdentityGate({ userId }: { userId: string }) {
: t("chats.e2ee.recoveryNoBackupDesc")}
+ void handleRetryAuto()}
+ >
+ {busy ? "…" : t("chats.e2ee.retryButton")}
+
+
{state.hasBackup ? (
<>
- {error && {error}
}
void handleRecover()}
@@ -141,6 +131,8 @@ export default function E2eeIdentityGate({ userId }: { userId: string }) {
>
) : null}
+ {error && {error}
}
+
{confirmFresh ? (
@@ -178,51 +170,5 @@ export default function E2eeIdentityGate({ userId }: { userId: string }) {
);
}
- if (state.kind === "setupPrompt") {
- // برخلافِ حالتِ recovery (که واقعاً باید کاربر رو مسدود کنه)، این یه پیشنهادِ
- // اختیاریه — نباید با یه بکدراپِ تمامصفحه بقیهی صفحه (مثلاً دکمهی برگشتِ
- // هدر) رو زیرِ خودش قایم کنه. قبلاً از Modal (بکدراپِ fixed inset-0) استفاده
- // میکرد — لمسِ هر جای صفحه، حتی دکمهی برگشت، بهجای رسیدن به عنصرِ زیرش،
- // بکدراپ رو میگرفت و میبست؛ کاربر با «برگشت نمیکنه» تجربهش میکرد
- return (
-
-
-
{t("chats.e2ee.setupTitle")}
-
- {t("chats.e2ee.setupDesc")}
-
-
setPin(e.target.value)}
- placeholder={t("chats.e2ee.pinPlaceholder")}
- className="w-full rounded-xl border border-neutral-300 bg-transparent px-4 py-2.5 text-center text-lg tracking-widest outline-none dark:border-neutral-600"
- />
- {error &&
{error}
}
-
-
- {t("chats.e2ee.setupLater")}
-
- void handleSaveBackup()}
- >
- {busy ? "…" : t("chats.e2ee.setupSave")}
-
-
-
-
- );
- }
-
return null;
}
diff --git a/src/components/chat/ForwardMessageModal.tsx b/src/components/chat/ForwardMessageModal.tsx
index 56b55c4..e843c43 100644
--- a/src/components/chat/ForwardMessageModal.tsx
+++ b/src/components/chat/ForwardMessageModal.tsx
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import Image from "next/image";
-import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
+import { buildStorageUrl } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import IOSSpinner from "@/components/ui/IOSSpinner";
diff --git a/src/components/chats/FindPartnerFilterModal.tsx b/src/components/chats/FindPartnerFilterModal.tsx
index 01d62b6..6c8df26 100644
--- a/src/components/chats/FindPartnerFilterModal.tsx
+++ b/src/components/chats/FindPartnerFilterModal.tsx
@@ -6,8 +6,30 @@ import RoundedInput from "../elements/RoundedInput";
import RoundedButton from "../elements/RoundedButton";
import AppearanceColorSelect from "../models/AppearanceColorSelect";
import CountryProvinceCitySelect, { GeoSelection } from "../ui/CountryProvinceCitySelect";
+import RoundedSelect from "../ui/RoundedSelect";
+import MultiSelectDropdown from "../ui/MultiSelectDropdown";
import { EYE_COLOR_OPTIONS, HAIR_COLOR_OPTIONS } from "@/lib/appearanceOptions";
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
+import {
+ buildRelationshipGoalOptions,
+ buildMaritalStatusNewOptions,
+ buildSeekingGenderOptions,
+ buildDesiredAgeRangeOptions,
+ buildPersonalityTraitsOptions,
+ buildLifestyleOptions,
+ buildInterestsOptions,
+ buildValuesOptions,
+ buildRelationshipViewOptions,
+ buildGeoPreferenceOptions,
+ buildChildrenStatusOptions,
+ buildSmokingStatusOptions,
+ buildIncomeRangeOptions,
+ RELATIONSHIP_GOAL_MAX,
+ PERSONALITY_TRAITS_MAX,
+ LIFESTYLE_MAX,
+ INTERESTS_MAX,
+ VALUES_MAX,
+} from "@/lib/personalDetailsFieldOptions";
export type PartnerFilters = {
gender: string;
@@ -28,6 +50,17 @@ export type PartnerFilters = {
job: string;
incomeRange: string;
geo: GeoSelection;
+ relationshipGoal: string[];
+ seekingGender: string;
+ desiredAgeRange: string;
+ personalityTraits: string[];
+ lifestyle: string[];
+ interests: string[];
+ values: string[];
+ relationshipView: string;
+ geoPreference: string;
+ childrenStatus: string;
+ smokingStatus: string;
};
export const EMPTY_PARTNER_FILTERS: PartnerFilters = {
@@ -49,6 +82,17 @@ export const EMPTY_PARTNER_FILTERS: PartnerFilters = {
job: "",
incomeRange: "",
geo: { countryId: null, provinceId: null, cityId: null },
+ relationshipGoal: [],
+ seekingGender: "",
+ desiredAgeRange: "",
+ personalityTraits: [],
+ lifestyle: [],
+ interests: [],
+ values: [],
+ relationshipView: "",
+ geoPreference: "",
+ childrenStatus: "",
+ smokingStatus: "",
};
function RangeField({
@@ -145,6 +189,22 @@ export default function FindPartnerFilterModal({
const set =
(key: K, value: PartnerFilters[K]) =>
onChange({ ...filters, [key]: value });
+ const maritalStatusNewOptions = buildMaritalStatusNewOptions(t);
+ const relationshipGoalOptions = buildRelationshipGoalOptions(t);
+ const seekingGenderOptions = buildSeekingGenderOptions(t);
+ const desiredAgeRangeOptions = buildDesiredAgeRangeOptions(t);
+ const personalityTraitsOptions = buildPersonalityTraitsOptions(t);
+ const lifestyleOptions = buildLifestyleOptions(t);
+ const interestsOptions = buildInterestsOptions(t);
+ const valuesOptions = buildValuesOptions(t);
+ const relationshipViewOptions = buildRelationshipViewOptions(t);
+ const geoPreferenceOptions = buildGeoPreferenceOptions(t);
+ const childrenStatusOptions = buildChildrenStatusOptions(t);
+ const smokingStatusOptions = buildSmokingStatusOptions(t);
+ const incomeRangeOptions = buildIncomeRangeOptions(t);
+ const selectedCountLabel = (count: number) =>
+ t("settings.edit.personalDetails.multiSelectSelectedCount", { count });
+
return (
@@ -278,16 +339,128 @@ export default function FindPartnerFilterModal({
/>
- set("incomeRange", v)}
- options={[
- { value: "under_5m", label: t("settings.edit.personalDetails.incomeUnder5m") },
- { value: "5m_10m", label: t("settings.edit.personalDetails.income5m10m") },
- { value: "10m_20m", label: t("settings.edit.personalDetails.income10m20m") },
- { value: "over_20m", label: t("settings.edit.personalDetails.incomeOver20m") },
- ]}
+ className="mx-auto"
+ />
+
+
+ set("relationshipGoal", v)}
+ max={RELATIONSHIP_GOAL_MAX}
+ maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", {
+ max: RELATIONSHIP_GOAL_MAX,
+ })}
+ formatSelectedSummary={selectedCountLabel}
+ />
+
+
+ set("seekingGender", v)}
+ />
+
+ set("desiredAgeRange", v)}
+ />
+
+ set("personalityTraits", v)}
+ max={PERSONALITY_TRAITS_MAX}
+ maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", {
+ max: PERSONALITY_TRAITS_MAX,
+ })}
+ formatSelectedSummary={selectedCountLabel}
+ />
+
+ set("lifestyle", v)}
+ max={LIFESTYLE_MAX}
+ maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", {
+ max: LIFESTYLE_MAX,
+ })}
+ formatSelectedSummary={selectedCountLabel}
+ />
+
+ set("interests", v)}
+ max={INTERESTS_MAX}
+ maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", {
+ max: INTERESTS_MAX,
+ })}
+ formatSelectedSummary={selectedCountLabel}
+ />
+
+ set("values", v)}
+ max={VALUES_MAX}
+ maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", {
+ max: VALUES_MAX,
+ })}
+ formatSelectedSummary={selectedCountLabel}
+ />
+
+ set("relationshipView", v)}
+ />
+
+ set("geoPreference", v)}
+ />
+
+ set("childrenStatus", v)}
+ />
+
+ set("smokingStatus", v)}
/>
diff --git a/src/components/course/CourseForm.tsx b/src/components/course/CourseForm.tsx
index 4b47b55..3925814 100644
--- a/src/components/course/CourseForm.tsx
+++ b/src/components/course/CourseForm.tsx
@@ -1,13 +1,11 @@
"use client";
-import { useState, useRef, useEffect, useMemo } from "react";
-import { useForm, useFieldArray } from "react-hook-form";
+import { useState, useEffect, useMemo } from "react";
+import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { motion } from "framer-motion";
import { toast } from "react-hot-toast";
-import { ImageIcon } from "lucide-react";
-import { Image } from "lucide-react";
import {
Form,
@@ -30,9 +28,9 @@ import {
import { Checkbox } from "@/components/ui/checkbox";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { Trash2, Video, Plus, Upload, FileVideo, Play } from "lucide-react";
+import { Trash2, Video, Plus, FileVideo, Play } from "lucide-react";
import useAxios from "@/hooks/useAxios";
-import { BASE_URL, IMAGE_BASE_URL, buildStorageUrl } from "../main/BaseUrl";
+import { buildStorageUrl } from "../main/BaseUrl";
import { useTranslation } from "react-i18next";
import { useAppLanguage } from "@/contexts/LanguageProvider";
@@ -110,10 +108,6 @@ export function CourseForm({
const [previewImage, setPreviewImage] = useState
("");
const [previewProfile, setPreviewProfile] = useState("");
- const [avatar, setAvatar] = useState(null);
- // State برای عکس پکیج
- const [packageImageFile, setPackageImageFile] = useState(null);
- const [packageImagePreview, setPackageImagePreview] = useState("");
// State برای عکسها
const [imageFields, setImageFields] = useState([]);
@@ -130,9 +124,6 @@ export function CourseForm({
const [videoFile, setVideoFile] = useState(null);
const [videoPreview, setVideoPreview] = useState("");
const [isFreeVideo, setIsFreeVideo] = useState(false);
- const [videoPreviewUrls, setVideoPreviewUrls] = useState<{
- [key: number]: string;
- }>({});
// ==========================================
// تغییر: استفاده از دستهبندی آکادمی به جای تبلیغات
@@ -144,7 +135,7 @@ export function CourseForm({
const [uploadProgress, setUploadProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false);
- const { request, loading, error } = useAxios();
+ const { request } = useAxios();
// ==========================================
// دریافت دستهبندیهای آکادمی از API جدید
@@ -319,15 +310,6 @@ export function CourseForm({
}
}, [initialData, request, form]);
- const {
- // fields: videoFields,
- append: appendVideo,
- remove: removeVideo,
- } = useFieldArray({
- control: form.control,
- name: "videos",
- });
-
// تابع برای انتخاب فایل ویدیو
const handleVideoFileSelect = (e: React.ChangeEvent) => {
const file = e.target.files?.[0];
@@ -350,17 +332,7 @@ export function CourseForm({
}
};
- const fileToBase64 = (file: File): Promise => {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.readAsDataURL(file);
- reader.onload = () => resolve(reader.result as string);
- reader.onerror = (error) => reject(error);
- });
- };
-
// اضافه کردن state های جدید
- const [uploadChunks, setUploadChunks] = useState([]);
const [currentChunk, setCurrentChunk] = useState(0);
const [totalChunks, setTotalChunks] = useState(0);
@@ -600,26 +572,9 @@ export function CourseForm({
}
};
- const fileToBase64WithProgress = (file, onProgress) => {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
-
- reader.onprogress = (event) => {
- if (event.lengthComputable && onProgress) {
- const percentComplete = Math.round((event.loaded / event.total) * 50); // 50% برای تبدیل
- onProgress(percentComplete);
- }
- };
-
- reader.onload = () => resolve(reader.result);
- reader.onerror = (error) => reject(error);
- reader.readAsDataURL(file);
- });
- };
-
const handleRemoveVideo = async (index: number, field) => {
console.log(field.courseId);
- const deleteCourseContent = await request(
+ await request(
"DELETE",
`/academy/academy/course/content/video`,
{
@@ -641,7 +596,6 @@ export function CourseForm({
form.setValue("packageImage", url);
}
setPreviewProfile(previewUrl);
- setAvatar(previewUrl);
}
};
diff --git a/src/components/course/CourseManager.tsx b/src/components/course/CourseManager.tsx
index f1a284c..554af46 100644
--- a/src/components/course/CourseManager.tsx
+++ b/src/components/course/CourseManager.tsx
@@ -11,7 +11,6 @@ import {
DialogContent,
DialogHeader,
DialogTitle,
- DialogTrigger,
} from "@/components/ui/dialog";
import { Course } from "@/types/types";
import { toast } from "react-hot-toast";
@@ -37,26 +36,6 @@ function appendCourseFormFields(formData: FormData, data: any) {
formData.append("contact_instagram", data.contact_instagram || "");
}
-// دیتای موقت برای نمایش
-const mockCourses: Course[] = [
- {
- id: "1",
- name: "دوره جامع React",
- price: 1200000,
- category: "programming",
- discount: 20,
- caption: "آموزش کامل React از مقدماتی تا پیشرفته",
- duration: "15 ساعت",
- videoCount: 25,
- instructor: "علی رضایی",
- packageImage: "/courses/react.jpg",
- videos: [
- { title: "مقدمه", url: "video1.mp4", isFree: true },
- { title: "نصب و راه اندازی", url: "video2.mp4", isFree: false },
- ],
- },
-];
-
export function CourseManager() {
const { t } = useTranslation("common");
const subscriptionPlans = useMemo(
@@ -98,7 +77,7 @@ export function CourseManager() {
const [totalCourses, setTotalCourses] = useState(0);
const itemsPerPage = 6;
- const { request, loading, error } = useAxios();
+ const { request } = useAxios();
// تابع دریافت دورهها با صفحهبندی
const fetchCourses = async (page = 1) => {
@@ -149,12 +128,6 @@ export function CourseManager() {
try {
// اینجا درخواست به API بزن
- const newCourse = {
- ...data,
- id: Date.now().toString(),
- createdAt: new Date(),
- };
-
const fileInput = document.getElementById(
"fileInput"
) as HTMLInputElement;
@@ -223,7 +196,7 @@ export function CourseManager() {
} finally {
setIsLoading(false);
}
- } catch (error) {
+ } catch {
toast.error(t("academyCourse.createError"));
} finally {
setIsLoading(false);
@@ -251,7 +224,7 @@ export function CourseManager() {
}
// اگر عکس جدیدی انتخاب نشده، فیلد course_image رو ارسال نکن
- const response = await request(
+ await request(
"PATCH",
"/academy/academy/update/course",
formData,
diff --git a/src/components/course/CustomDialog.tsx b/src/components/course/CustomDialog.tsx
index e3624b1..77b0664 100644
--- a/src/components/course/CustomDialog.tsx
+++ b/src/components/course/CustomDialog.tsx
@@ -4,6 +4,7 @@ import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import i18n from "@/lib/i18n";
+import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
@@ -16,7 +17,10 @@ const DialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
));
@@ -30,7 +34,10 @@ const DialogContent = React.forwardRef<
{children}
@@ -48,7 +55,7 @@ const DialogHeader = ({
...props
}: React.HTMLAttributes) => (
);
@@ -59,7 +66,10 @@ const DialogFooter = ({
...props
}: React.HTMLAttributes) => (
);
@@ -71,7 +81,7 @@ const DialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
));
@@ -83,7 +93,7 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
));
diff --git a/src/components/main/UserInfo.tsx b/src/components/main/UserInfo.tsx
index a94685e..57cb160 100644
--- a/src/components/main/UserInfo.tsx
+++ b/src/components/main/UserInfo.tsx
@@ -1,4 +1,3 @@
-import Image from "next/image";
import React from "react";
import Link from "next/link";
import ProfileAvatar from "./ProfileAvatar";
diff --git a/src/components/models/MainModelCard/MainModelCard.tsx b/src/components/models/MainModelCard/MainModelCard.tsx
index c2571b8..3f64c60 100644
--- a/src/components/models/MainModelCard/MainModelCard.tsx
+++ b/src/components/models/MainModelCard/MainModelCard.tsx
@@ -11,7 +11,7 @@ import { Post } from "@/types/types";
import Image from "next/image";
import MainModelCardActions from "./MainModelCardActions";
import Link from "next/link";
-import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
+import { buildStorageUrl } from "@/components/main/BaseUrl";
import VerificationBadge from "@/components/main/VerificationBadge";
import ScoreRateStats from "@/components/main/ScoreRateStats";
import { useUserById } from "@/hooks/getUserById";
@@ -27,7 +27,6 @@ import {
reorderCaptionLinksToEnd,
splitCaptionBodyAndLinks,
} from "@/lib/postLinkCaption";
-import { cn } from "@/lib/utils";
import { getPostSubExpertiseLabels } from "@/lib/subExpertiseDisplay";
import { localizedGeoName } from "@/utils/geoName";
import { useTranslation } from "react-i18next";
@@ -233,19 +232,23 @@ function MainModelCard({
-
+
+
+
{user_level || t("models.newcomer")}
+
+ {city?.name ? {localizedGeoName(city)} : null}
+ {expertise ? {expertise} : null}
+ {displaySubExpertise ? {displaySubExpertise} : null}
+
+
+
+
{user_name}
-
-
-
-
- {first_name} {last_name}
-
{subExpertise && matchedExpertise2.length > 0 && (
@@ -355,15 +358,11 @@ function MainModelCard({
-
-
-
{user_level || t("models.newcomer")}
-
- {city?.name ? {localizedGeoName(city)} : null}
- {expertise ? {expertise} : null}
- {displaySubExpertise ? {displaySubExpertise} : null}
-
+
+
+ {first_name} {last_name}
+
diff --git a/src/components/models/ModelPage/MainModelCardPost.tsx b/src/components/models/ModelPage/MainModelCardPost.tsx
index f162722..6e783b0 100644
--- a/src/components/models/ModelPage/MainModelCardPost.tsx
+++ b/src/components/models/ModelPage/MainModelCardPost.tsx
@@ -6,7 +6,7 @@ import Image from "next/image";
import MainModelCardActions from "../MainModelCard/MainModelCardActions";
import Link from "next/link";
import CaptionWithMentions from "@/components/posts/CaptionWithMentions";
-import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
+import { buildStorageUrl } from "@/components/main/BaseUrl";
import VerificationBadge from "@/components/main/VerificationBadge";
import ScoreRateStats from "@/components/main/ScoreRateStats";
import { useUserById } from "@/hooks/getUserById";
diff --git a/src/components/nav-user.tsx b/src/components/nav-user.tsx
index 678fdb1..b3407a2 100644
--- a/src/components/nav-user.tsx
+++ b/src/components/nav-user.tsx
@@ -1,28 +1,14 @@
"use client";
-import {
- IconCreditCard,
- IconDotsVertical,
- IconLogout,
- IconNotification,
- IconUserCircle,
-} from "@tabler/icons-react";
-
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
- DropdownMenuContent,
- DropdownMenuGroup,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
- useSidebar,
} from "@/components/ui/sidebar";
interface UserDataItem {
@@ -41,8 +27,6 @@ export function NavUser({
data: UserDataItem;
};
}) {
- const { isMobile } = useSidebar();
-
const firstData = user.data;
return (
diff --git a/src/components/posts/FollowingSuggestionsCarousel.tsx b/src/components/posts/FollowingSuggestionsCarousel.tsx
index 5bac754..5b786d4 100644
--- a/src/components/posts/FollowingSuggestionsCarousel.tsx
+++ b/src/components/posts/FollowingSuggestionsCarousel.tsx
@@ -12,10 +12,16 @@ export type SuggestedFollowUser = {
last_name?: string;
expertise?: string;
profile_image?: string;
+ // مدل واقعی بکاند مدیا رو با post_images (آرایه)/post_video برمیگردونه، نه فقط
+ // files — بعضی مسیرها (مثلاً /users/web) فیلد files رو خالی میفرستن؛ بدون این دو
+ // فیلد، getPostMedia (lib/explore/postMedia.ts) هیچ مدیایی پیدا نمیکرد و کارت
+ // کاملاً خالی میموند
previewPost?: {
_id: string;
type?: string;
- files?: { path: string }[];
+ files?: { path: string; type?: string }[];
+ post_images?: string[];
+ post_video?: string | null;
caption?: string;
};
is_following?: boolean;
diff --git a/src/components/posts/ReelsPostCard.tsx b/src/components/posts/ReelsPostCard.tsx
index 3b34a0f..19c444c 100644
--- a/src/components/posts/ReelsPostCard.tsx
+++ b/src/components/posts/ReelsPostCard.tsx
@@ -633,22 +633,24 @@ export default function ReelsPostCard({
))}
-
+
{first_name} {last_name}
- {userRate != null && (
-
-
- {userRate.toFixed(1)}
-
- )}
- {userScore != null && (
-
-
- {userScore}
-
- )}
+
+ {userRate != null && (
+
+
+ {userRate.toFixed(1)}
+
+ )}
+ {userScore != null && (
+
+
+ {userScore}
+
+ )}
+
diff --git a/src/components/projects/SpecialistCarousel.tsx b/src/components/projects/SpecialistCarousel.tsx
index e867bc3..d0dd41e 100644
--- a/src/components/projects/SpecialistCarousel.tsx
+++ b/src/components/projects/SpecialistCarousel.tsx
@@ -17,6 +17,8 @@ import { buildStorageUrl } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import { cn } from "@/lib/utils";
import type { SuggestedFollowUser } from "@/components/posts/FollowingSuggestionsCarousel";
+import { getPostMedia } from "@/lib/explore/postMedia";
+import type { Post } from "@/types/types";
export type SpecialistCarouselItem = SuggestedFollowUser;
@@ -68,9 +70,11 @@ function SpecialistCard({
userFallback: string;
}) {
const displayName = getDisplayName(user, userFallback);
- const previewFile = user.previewPost?.files?.[0]?.path;
- const previewUrl = previewFile ? buildStorageUrl(previewFile) : null;
- const isVideo = user.previewPost?.type === "video";
+ // بدون فالبک به post_images/post_video، پستِ آخرِ بعضی کاربرها که فیلد files
+ // خالی داشتن اصلاً پیشنمایشی نداشت — عین getPostMedia که خودِ فید اصلی استفاده میکنه
+ const media = user.previewPost
+ ? getPostMedia(user.previewPost as Post)
+ : null;
const profileUrl = user.profile_image ? buildStorageUrl(user.profile_image) : null;
const profileHref = user.user_name ? `/users/${user.user_name}` : "#";
@@ -83,10 +87,11 @@ function SpecialistCard({
isActive ? "scale-100 opacity-100" : "scale-[0.94] opacity-70"
)}
>
- {previewUrl ? (
- isVideo ? (
+ {media ? (
+ media.kind === "video" ? (
) : (
void;
+ onClose: () => void;
+};
+
+// عین فیلترهای رنگونورِ سادهی اینستاگرام — با CSS filter (هم روی پیشنمایشِ زنده هم موقعِ
+// عکسگرفتن روی canvas اعمال میشه، بدون نیاز به سرور چون مرورگر خودش پردازش میکنه).
+// اعمال تشخیصِ چهره روی این پیشنمایش عمداً اضافه نشده — کتابخونهی رایگانِ واقعیش
+// (MediaPipe) بهتنهایی کارِ بزرگیه که باید جدا از این مرحله انجام بشه
+const FILTERS: { value: string; label: string; css: string }[] = [
+ { value: "normal", label: "عادی", css: "none" },
+ { value: "warm", label: "گرم", css: "brightness(1.08) saturate(1.2) sepia(0.2)" },
+ { value: "cool", label: "سرد", css: "brightness(1.02) saturate(1.1) contrast(1.05) hue-rotate(-8deg)" },
+ { value: "bw", label: "سیاهوسفید", css: "grayscale(1) brightness(1.05)" },
+ { value: "vivid", label: "زنده", css: "saturate(1.6) brightness(1.05) contrast(1.1)" },
+ { value: "fade", label: "محو", css: "saturate(0.7) brightness(1.15) contrast(0.9)" },
+ { value: "vintage", label: "قدیمی", css: "saturate(0.8) sepia(0.35) contrast(0.95)" },
+];
+
+export default function StoryCameraCapture({ onCapture, onClose }: Props) {
+ const { t } = useTranslation("common");
+ const videoRef = useRef(null);
+ const streamRef = useRef(null);
+ const [selected, setSelected] = useState("normal");
+ const [error, setError] = useState("");
+ const selectedFilter = FILTERS.find((f) => f.value === selected) ?? FILTERS[0];
+
+ useEffect(() => {
+ let cancelled = false;
+ navigator.mediaDevices
+ .getUserMedia({ video: { facingMode: "user" }, audio: false })
+ .then((stream) => {
+ if (cancelled) {
+ stream.getTracks().forEach((tr) => tr.stop());
+ return;
+ }
+ streamRef.current = stream;
+ if (videoRef.current) videoRef.current.srcObject = stream;
+ })
+ .catch(() => setError(t("stories.cameraAccessDenied")));
+ return () => {
+ cancelled = true;
+ streamRef.current?.getTracks().forEach((tr) => tr.stop());
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const close = () => {
+ streamRef.current?.getTracks().forEach((tr) => tr.stop());
+ onClose();
+ };
+
+ const capture = () => {
+ const video = videoRef.current;
+ if (!video || !video.videoWidth) return;
+ const canvas = document.createElement("canvas");
+ canvas.width = video.videoWidth;
+ canvas.height = video.videoHeight;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+ ctx.filter = selectedFilter.css;
+ // آینهای — چون پیشنمایش هم آینهایه (حس دوربینِ سلفی)، عکسِ نهایی باید همون چیزی
+ // باشه که کاربر روی صفحه دید
+ ctx.translate(canvas.width, 0);
+ ctx.scale(-1, 1);
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
+ canvas.toBlob(
+ (blob) => {
+ if (!blob) return;
+ streamRef.current?.getTracks().forEach((tr) => tr.stop());
+ onCapture(new File([blob], "story.jpg", { type: "image/jpeg" }));
+ },
+ "image/jpeg",
+ 0.9
+ );
+ };
+
+ return (
+
+
+
+
+
+
+ {error ? (
+
{error}
+ ) : (
+
+ )}
+
+
+
+ {FILTERS.map((f) => (
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/stories/StoryEditor.tsx b/src/components/stories/StoryEditor.tsx
index b4b4cd8..33e7cfb 100644
--- a/src/components/stories/StoryEditor.tsx
+++ b/src/components/stories/StoryEditor.tsx
@@ -1,14 +1,23 @@
"use client";
-import React, {
- useCallback,
- useLayoutEffect,
- useRef,
- useState,
-} from "react";
-import { StoryOverlay, StoryTextOverlay, StoryLinkOverlay } from "@/api/fetchStories";
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import {
+ StoryOverlay,
+ StoryTextOverlay,
+ StoryLinkOverlay,
+ StoryMentionOverlay,
+ StoryHashtagOverlay,
+ StoryLocationOverlay,
+ StoryStickerOverlay,
+ STORY_FONT_FAMILIES,
+ STORY_EMOJI_PRESETS,
+ DEFAULT_STICKER_FONT_SIZE,
+ DEFAULT_EMOJI_FONT_SIZE,
+} from "@/api/fetchStories";
+import { storyFont, STORY_FONT_WEIGHTS } from "@/lib/storyFonts";
import BoldIcon from "@/components/ui/BoldIcon";
import StoryLinkPicker, { PickedStoryLink } from "@/components/stories/StoryLinkPicker";
+import StoryMentionPicker, { MentionUser } from "@/components/stories/StoryMentionPicker";
import { useTranslation } from "react-i18next";
const LINK_KIND_ICONS: Record = {
@@ -49,35 +58,66 @@ export default function StoryEditor({
}: StoryEditorProps) {
const { t } = useTranslation("common");
const containerRef = useRef(null);
- const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
+ // اندازهی واقعیِ بوم — قبلاً یه کادرِ ثابتِ ۹:۱۶ بود (فارغ از نسبتِ واقعیِ عکس)، پس
+ // x/y/font_size نسبیای که اینجا ذخیره میشدن با کادرِ تمامصفحهی نمایشِ استوری
+ // (StoryViewer.tsx) که به نسبتِ واقعیِ عکس/صفحهنمایش دیگهای letterbox میشه، همخوانی
+ // نداشتن — جا/اندازهی اورلیها موقع نمایش عوض بهنظر میرسید. حالا بوم دقیقاً به نسبتِ
+ // واقعیِ خودِ عکس/ویدیو اندازه میشه (contain-fit داخل یه سقفِ عرض/ارتفاع)
+ const MAX_BOX_WIDTH = 340;
+ const MAX_BOX_HEIGHT = 600;
+ const [boxSize, setBoxSize] = useState({ width: MAX_BOX_WIDTH, height: (MAX_BOX_WIDTH * 16) / 9 });
+
+ const applyAspect = useCallback((heightOverWidth: number) => {
+ let width = MAX_BOX_WIDTH;
+ let height = width * heightOverWidth;
+ if (height > MAX_BOX_HEIGHT) {
+ height = MAX_BOX_HEIGHT;
+ width = height / heightOverWidth;
+ }
+ setBoxSize({ width, height });
+ }, []);
+
+ useEffect(() => {
+ if (mediaType === "video") return; // ابعادِ ویدیو از خودِ