Compare commits
2 Commits
bf41ad4e0f
...
901ce94443
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
901ce94443 | ||
|
|
2d5505065b |
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "modstagram-next-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3000
|
||||
}
|
||||
]
|
||||
}
|
||||
50
public/offline.html
Normal file
50
public/offline.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fa" dir="rtl">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>مدستاگرام</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Tahoma, sans-serif;
|
||||
background: #ffffff;
|
||||
color: #111111;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #0e0e0e; color: #f5f5f5; }
|
||||
}
|
||||
img { width: 88px; height: 88px; border-radius: 22px; }
|
||||
h1 { margin: 0; font-size: 18px; font-weight: 700; }
|
||||
p { margin: 0; font-size: 14px; opacity: 0.65; max-width: 320px; line-height: 1.7; }
|
||||
button {
|
||||
margin-top: 8px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 12px 32px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
background: #FF107D;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:active { opacity: 0.85; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="/images/icons/192x192.png" alt="مدستاگرام" />
|
||||
<h1>اتصال برقرار نمیباشد</h1>
|
||||
<p>به نظر میرسد به اینترنت وصل نیستید. اتصال خود را بررسی کنید و دوباره تلاش کنید.</p>
|
||||
<button onclick="location.reload()">تلاش دوباره</button>
|
||||
</body>
|
||||
</html>
|
||||
@@ -43,10 +43,11 @@ self.addEventListener("notificationclick", (event) => {
|
||||
);
|
||||
});
|
||||
|
||||
const CACHE_NAME = "pwa-cache-v11";
|
||||
const CACHE_NAME = "pwa-cache-v12";
|
||||
|
||||
const PRECACHE_URLS = [
|
||||
"/",
|
||||
"/offline.html",
|
||||
"/manifest.webmanifest",
|
||||
"/manifest.json",
|
||||
"/images/icons/192x192.png",
|
||||
@@ -82,7 +83,9 @@ self.addEventListener("fetch", (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
if (event.request.mode === "navigate") {
|
||||
event.respondWith(fetch(event.request));
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() => caches.match("/offline.html"))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<IProfileData | null>(null);
|
||||
const { request } = useAxios();
|
||||
const [avatar, setAvatar] = useState<string | null>(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);
|
||||
|
||||
@@ -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<boolean>(false);
|
||||
const [navigating, setNavigating] = useState(false);
|
||||
const [verifiedStatus, setVerifiedStatus] = useState<string | null>(null);
|
||||
|
||||
@@ -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<Record<string, any>>({});
|
||||
const [videoFields, setVideoFields] = useState<MediaFile[]>([]);
|
||||
|
||||
const [newScore, setNewScore] = useState<number>(0);
|
||||
const [rating, setRating] = useState<number>(0);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(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 (
|
||||
@@ -655,6 +647,7 @@ useEffect(() => {
|
||||
key={selectedVideo?._id || ""}
|
||||
className="w-full aspect-video"
|
||||
controls
|
||||
playsInline
|
||||
controlsList="nodownload noplaybackrate"
|
||||
onContextMenu={preventDownload}
|
||||
onKeyDown={preventDownload}
|
||||
|
||||
@@ -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 (
|
||||
<div className="relative h-full w-full overflow-hidden rounded-3xl bg-neutral-900 shadow-2xl">
|
||||
{previewUrl ? (
|
||||
user.previewPost?.type === "video" ? (
|
||||
{media ? (
|
||||
media.kind === "video" ? (
|
||||
<video
|
||||
src={previewUrl}
|
||||
src={media.src}
|
||||
poster={media.poster}
|
||||
className="h-full w-full object-cover"
|
||||
muted
|
||||
playsInline
|
||||
@@ -81,7 +92,7 @@ function SpecialistCard({
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={previewUrl}
|
||||
src={media.src}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
@@ -178,8 +189,14 @@ export default function SpecialistsPage() {
|
||||
const [showFilter, setShowFilter] = useState(true);
|
||||
const [users, setUsers] = useState<SuggestedFollowUser[]>([]);
|
||||
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<Set<string>>(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<string, SuggestedFollowUser>();
|
||||
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 = () => {
|
||||
|
||||
@@ -5,9 +5,17 @@ import RoundedDiv from "@/components/elements/RoundedDiv";
|
||||
import EditPageHeader from "@/components/settings/EditPageHeader";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isAndroidDevice } from "@/lib/pwa/install";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { ANDROID_APP_VERSION } from "@/lib/pwaConfig";
|
||||
|
||||
// عین PwaInstallPrompt.tsx: مسیر مستقیمِ فایل APK که بکاند بدون روتِ جدید سرو میکنه
|
||||
const ANDROID_APK_URL = buildStorageUrl("/downloads/modstagram.apk");
|
||||
|
||||
function AboutUs() {
|
||||
const { t } = useTranslation("common");
|
||||
// فقط اندروید — طبق خواستهی صریح کاربر توی iOS این دکمه نمایش داده نشه
|
||||
const isAndroid = isAndroidDevice();
|
||||
|
||||
return (
|
||||
<Container>
|
||||
@@ -23,12 +31,26 @@ function AboutUs() {
|
||||
<span>02167586</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex justify-center items-center">
|
||||
<Link href={"/settings/tickets/new"} className="flex flex-col mt-6 gap-1">
|
||||
<RoundedDiv className="w-28 py-1">
|
||||
<span>{t("aboutPage.sendTicket")}</span>
|
||||
</RoundedDiv>
|
||||
</Link>
|
||||
<div className="w-full flex flex-col items-center gap-4">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Link href={"/settings/tickets/new"} className="flex flex-col mt-6 gap-1">
|
||||
<RoundedDiv className="w-28 py-1">
|
||||
<span>{t("aboutPage.sendTicket")}</span>
|
||||
</RoundedDiv>
|
||||
</Link>
|
||||
{isAndroid ? (
|
||||
<a href={ANDROID_APK_URL} className="flex flex-col mt-6 gap-1">
|
||||
<RoundedDiv className="w-28 py-1">
|
||||
<span>{t("aboutPage.downloadApp")}</span>
|
||||
</RoundedDiv>
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
{isAndroid ? (
|
||||
<span className="text-xs text-neutral-500">
|
||||
{t("aboutPage.appVersion", { version: ANDROID_APP_VERSION })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -618,6 +618,7 @@ select {
|
||||
.header-offline {
|
||||
background: linear-gradient(180deg, rgba(220, 38, 38, 0.92) 0%, rgba(185, 28, 28, 0.88) 100%) !important;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
.header-syncing .brand-logo {
|
||||
opacity: 0;
|
||||
@@ -631,6 +632,7 @@ select {
|
||||
.tab-active-glass {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
authors: [{ name: "Modstagram", url: "https://modstagram.com" }],
|
||||
creator: "Modstagram",
|
||||
manifest: "/manifest.webmanifest",
|
||||
applicationName: site.name,
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.png", type: "image/png", sizes: "32x32" },
|
||||
@@ -67,8 +68,6 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
google: "yVjB5yKPchPUtxl33GWVyMvjH6wCEInqEvaH7ZsXXMo",
|
||||
},
|
||||
other: {
|
||||
"theme-color": "#FF107D",
|
||||
"mobile-web-app-capable": "yes",
|
||||
"msapplication-TileColor": "#FF107D",
|
||||
samandehi: "522190795",
|
||||
},
|
||||
|
||||
@@ -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<CreateMode>(
|
||||
["image", "video", "story"].includes(initialMode) ? initialMode : "image"
|
||||
["image", "video", "story"].includes(initialMode) ? initialMode : "video"
|
||||
);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<string>("");
|
||||
const [cameraOpen, setCameraOpen] = useState(false);
|
||||
const [gallery, setGallery] = useState<PostGalleryItem[]>([]);
|
||||
const [description, setDescription] = useState("");
|
||||
const [taggedUsers, setTaggedUsers] = useState<TaggedUser[]>([]);
|
||||
@@ -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 ? (
|
||||
<div className="flex border-b border-neutral-200 dark:border-neutral-800">
|
||||
{(["image", "video", "story"] as CreateMode[]).map((m) => (
|
||||
{(["video", "image", "story"] as CreateMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
@@ -650,6 +662,16 @@ export default function NewPostPage() {
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{!preview && isStory ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCameraOpen(true)}
|
||||
className="mb-6 flex items-center gap-2 rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 dark:border-neutral-700 dark:text-neutral-200"
|
||||
>
|
||||
<BoldIcon name="camera" size={18} className="dark:invert" />
|
||||
{t("stories.camera")}
|
||||
</button>
|
||||
) : null}
|
||||
{preview && (
|
||||
<label className="absolute bottom-4 rounded-lg bg-black/60 px-4 py-2 text-xs text-white">
|
||||
{t("posts.changeFile")}
|
||||
@@ -743,6 +765,13 @@ export default function NewPostPage() {
|
||||
onClose={() => setExpertiseModalOpen(false)}
|
||||
returnPath={returnPath}
|
||||
/>
|
||||
|
||||
{cameraOpen && (
|
||||
<StoryCameraCapture
|
||||
onCapture={onCameraCapture}
|
||||
onClose={() => setCameraOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</LocalePageShell>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { generatePageMetadata } from "@/utils/generatePageMetadata";
|
||||
import {
|
||||
buildPostPath,
|
||||
buildPostPublicUrl,
|
||||
buildPostSeoDescription,
|
||||
buildPostSeoTitle,
|
||||
} from "@/lib/postSlug";
|
||||
|
||||
@@ -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() {
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
||||
{t("settings.academy.myCourses.emptyDescription")}
|
||||
</p>
|
||||
<a
|
||||
<Link
|
||||
href="/academy"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-2xl hover:shadow-lg transition-all duration-300 transform hover:scale-105"
|
||||
>
|
||||
@@ -190,7 +191,7 @@ export default function PurchasedCoursesPage() {
|
||||
/>
|
||||
</svg>
|
||||
{t("settings.academy.myCourses.browseCourses")}
|
||||
</a>
|
||||
</Link>
|
||||
</motion.div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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<string>("");
|
||||
const [avatar, setAvatar] = useState<string | null>(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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, string> {
|
||||
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<string, string> {
|
||||
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<string, string> {
|
||||
export default function FindPartnerPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
|
||||
const [filters, setFilters] = useState<PartnerFilters>(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<PartnerAlert>(null);
|
||||
const [savingAlert, setSavingAlert] = useState(false);
|
||||
const [collabUser, setCollabUser] = useState<CollabUser | null>(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() {
|
||||
<Link
|
||||
key={item._id}
|
||||
href={`/offer/${item._id}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void openUser(item);
|
||||
}}
|
||||
className="flex items-center gap-3 py-3"
|
||||
>
|
||||
<ProfileAvatar
|
||||
@@ -278,6 +351,28 @@ export default function FindPartnerPage() {
|
||||
onApply={handleApplyFilters}
|
||||
onClear={handleClearFilters}
|
||||
/>
|
||||
|
||||
{collabUser &&
|
||||
(isModelExpertise(collabUser.expertise) ? (
|
||||
<ModelDetailModal
|
||||
isOpen
|
||||
onClose={() => setCollabUser(null)}
|
||||
userId={collabUser._id}
|
||||
hairColor={collabUser.hair_color}
|
||||
eyeColor={collabUser.eye_color}
|
||||
height={collabUser.height}
|
||||
size={collabUser.size}
|
||||
weight={collabUser.weight}
|
||||
isMain
|
||||
/>
|
||||
) : (
|
||||
<ModelServiceModal
|
||||
isOpen
|
||||
onClose={() => setCollabUser(null)}
|
||||
userId={collabUser._id}
|
||||
services={collabUser.services}
|
||||
/>
|
||||
))}
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<NearbyGate>("checking");
|
||||
const [users, setUsers] = useState<NearbyUser[]>([]);
|
||||
@@ -102,8 +124,15 @@ export default function NearbyFriendsPage() {
|
||||
);
|
||||
const [showMap, setShowMap] = useState(false);
|
||||
const [radiusKm, setRadiusKm] = useState<number>(DEFAULT_RADIUS_METERS / 1000);
|
||||
const [collabUser, setCollabUser] = useState<CollabUser | null>(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() {
|
||||
<Link
|
||||
key={item._id}
|
||||
href={`/offer/${item._id}`}
|
||||
onClick={(e) => {
|
||||
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() {
|
||||
<Link
|
||||
key={`list-${item._id}`}
|
||||
href={`/offer/${item._id}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void openUser(item);
|
||||
}}
|
||||
className="flex items-center gap-3 py-3"
|
||||
>
|
||||
<ProfileAvatar
|
||||
@@ -519,18 +582,22 @@ export default function NearbyFriendsPage() {
|
||||
</div>
|
||||
</Marker>
|
||||
|
||||
{users.map((item, index) => {
|
||||
{mapEligibleUsers.map((item, index) => {
|
||||
const [lng, lat] = approximateCoordinate(
|
||||
coords.lat,
|
||||
coords.lng,
|
||||
item.distanceMeters,
|
||||
index,
|
||||
users.length
|
||||
mapEligibleUsers.length
|
||||
);
|
||||
return (
|
||||
<Marker key={item._id} longitude={lng} latitude={lat}>
|
||||
<Link
|
||||
href={`/offer/${item._id}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void openUser(item);
|
||||
}}
|
||||
className="flex flex-col items-center"
|
||||
title={item.user_name}
|
||||
>
|
||||
@@ -560,6 +627,28 @@ export default function NearbyFriendsPage() {
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{collabUser &&
|
||||
(isModelExpertise(collabUser.expertise) ? (
|
||||
<ModelDetailModal
|
||||
isOpen
|
||||
onClose={() => setCollabUser(null)}
|
||||
userId={collabUser._id}
|
||||
hairColor={collabUser.hair_color}
|
||||
eyeColor={collabUser.eye_color}
|
||||
height={collabUser.height}
|
||||
size={collabUser.size}
|
||||
weight={collabUser.weight}
|
||||
isMain
|
||||
/>
|
||||
) : (
|
||||
<ModelServiceModal
|
||||
isOpen
|
||||
onClose={() => setCollabUser(null)}
|
||||
userId={collabUser._id}
|
||||
services={collabUser.services}
|
||||
/>
|
||||
))}
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import Container from "@/components/elements/Container";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import ChatBoldIcon from "@/components/chat/ChatBoldIcon";
|
||||
import { chatHeaderCircleBtn, chatHeaderInfoPill, chatHeaderGlassStyle } from "@/components/chat/ChatHeader";
|
||||
import VideoMessageBubble from "@/components/chat/VideoMessageBubble";
|
||||
import BackButton from "@/components/ui/BackButton";
|
||||
import IOSSpinner from "@/components/ui/IOSSpinner";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { glassBackdropStyle } from "@/lib/glassBackdrop";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { formatBubbleTime } from "@/lib/chat/formatMessageTime";
|
||||
import {
|
||||
@@ -58,6 +58,7 @@ export default function ShopChatPage() {
|
||||
const params = useParams<{ shopId: string; buyerId: string }>();
|
||||
const { shopId, buyerId } = params;
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
|
||||
const [messages, setMessages] = useState<ShopChatMessage[] | null>(null);
|
||||
const [counterpart, setCounterpart] = useState<Counterpart | null>(null);
|
||||
@@ -162,12 +163,15 @@ export default function ShopChatPage() {
|
||||
dir="rtl"
|
||||
className="pointer-events-auto mx-auto flex max-w-lg items-center gap-2"
|
||||
>
|
||||
<BackButton
|
||||
tinted
|
||||
iconSize={22}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className={chatHeaderCircleBtn}
|
||||
style={chatHeaderGlassStyle}
|
||||
/>
|
||||
aria-label={t("chats.actions.back")}
|
||||
>
|
||||
<ChatBoldIcon name="back" size={22} className="text-current" />
|
||||
</button>
|
||||
|
||||
<div dir="ltr" className={chatHeaderInfoPill} style={chatHeaderGlassStyle}>
|
||||
<div dir="rtl" className="min-w-0 flex-1 text-right">
|
||||
@@ -291,7 +295,10 @@ export default function ShopChatPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="glass-chat-input gentle-transition flex min-h-11 flex-1 items-center rounded-[22px] px-4 py-2">
|
||||
<div
|
||||
className="glass-chat-input gentle-transition flex min-h-11 flex-1 items-center rounded-[22px] px-4 py-2"
|
||||
style={glassBackdropStyle("glass-chat-input")}
|
||||
>
|
||||
<textarea
|
||||
rows={1}
|
||||
value={text}
|
||||
|
||||
108
src/app/settings/edit/birthday/page.tsx
Normal file
108
src/app/settings/edit/birthday/page.tsx
Normal file
@@ -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 (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center min-h-dvh justify-center auth-page">
|
||||
<EditPageHeader title={t("auth.birthDatePlaceholder")} />
|
||||
{!user ? (
|
||||
<EditFormSkeleton fields={2} />
|
||||
) : (
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<div className="relative mt-5">
|
||||
<DatePicker
|
||||
id="birthDate"
|
||||
calendar={persian}
|
||||
locale={persian_fa}
|
||||
value={birthDate}
|
||||
onChange={(value) =>
|
||||
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]"
|
||||
/>
|
||||
<label htmlFor="birthDate" className="absolute right-4 top-3">
|
||||
<Image
|
||||
width={24}
|
||||
height={24}
|
||||
alt="calendar icon"
|
||||
src="/images/icons/calendar.svg"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="mt-8"
|
||||
loading={loading}
|
||||
disabled={!canSave}
|
||||
onClick={submit}
|
||||
>
|
||||
{t("settings.edit.save")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditBirthdayPage;
|
||||
105
src/app/settings/edit/gender/page.tsx
Normal file
105
src/app/settings/edit/gender/page.tsx
Normal file
@@ -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 (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center min-h-dvh justify-center auth-page">
|
||||
<EditPageHeader title={t("auth.genderTitle")} />
|
||||
{!user ? (
|
||||
<EditFormSkeleton fields={2} />
|
||||
) : (
|
||||
<div className="w-full max-w-sm flex flex-col items-center">
|
||||
<div className="mt-5 flex w-full gap-4">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => 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" : ""
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/woman.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
alt="gender icon"
|
||||
/>
|
||||
{t("auth.female")}
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => 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" : ""
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={"/images/icons/man.svg"}
|
||||
width={25}
|
||||
height={25}
|
||||
alt="gender icon"
|
||||
/>
|
||||
{t("auth.male")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="mt-8"
|
||||
loading={loading}
|
||||
disabled={loading || !gender}
|
||||
onClick={submit}
|
||||
>
|
||||
{t("settings.edit.save")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditGenderPage;
|
||||
@@ -24,7 +24,6 @@ export default function LinkDevicePage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const [phase, setPhase] = useState<Phase>("loading");
|
||||
const [linkId, setLinkId] = useState<string | null>(null);
|
||||
const [approveUrl, setApproveUrl] = useState<string | null>(null);
|
||||
|
||||
const ephemeralPrivateKeyRef = useRef<CryptoKey | null>(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);
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold">{label}</p>
|
||||
{value ? (
|
||||
<div className="flex min-h-[42px] w-full items-center rounded-2xl border border-[#C3C3C3] bg-white/80 px-4 py-2.5 text-sm dark:border-neutral-600 dark:bg-neutral-900/80">
|
||||
{value}
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
href={href}
|
||||
className="flex min-h-[42px] w-full items-center justify-between rounded-2xl border border-neutral-950 px-4 py-2.5 text-sm font-medium text-neutral-950 dark:border-neutral-400 dark:text-neutral-50"
|
||||
>
|
||||
افزودن
|
||||
<span aria-hidden>+</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonalDetailsPage() {
|
||||
const { t } = useTranslation("common");
|
||||
@@ -36,6 +120,30 @@ function PersonalDetailsPage() {
|
||||
const [incomeRange, setIncomeRange] = useState<IncomeRange>(null);
|
||||
const [moralTraits, setMoralTraits] = useState("");
|
||||
|
||||
const [relationshipGoal, setRelationshipGoal] = useState<string[]>([]);
|
||||
const [personalityTraits, setPersonalityTraits] = useState<string[]>([]);
|
||||
const [lifestyle, setLifestyle] = useState<string[]>([]);
|
||||
const [interests, setInterests] = useState<string[]>([]);
|
||||
const [values, setValues] = useState<string[]>([]);
|
||||
const [relationshipView, setRelationshipView] = useState<RelationshipView | "">("");
|
||||
const [geoPreference, setGeoPreference] = useState<GeoPreference | "">("");
|
||||
const [childrenStatus, setChildrenStatus] = useState<ChildrenStatus | "">("");
|
||||
const [smokingStatus, setSmokingStatus] = useState<SmokingStatus | "">("");
|
||||
|
||||
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() {
|
||||
<p className="mb-2 text-sm font-semibold">
|
||||
{t("settings.edit.personalDetails.maritalStatus")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(maritalStatus === "single", "h-10 flex-1")}
|
||||
className={toggleBtnClass(maritalStatus === "single", "h-10 text-xs")}
|
||||
onClick={() => setMaritalStatus("single")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.maritalStatusSingle")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(maritalStatus === "divorced", "h-10 flex-1")}
|
||||
className={toggleBtnClass(maritalStatus === "divorced", "h-10 text-xs")}
|
||||
onClick={() => setMaritalStatus("divorced")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.maritalStatusDivorced")}
|
||||
</button>
|
||||
{maritalStatusNewOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={toggleBtnClass(maritalStatus === opt.value, "h-10 text-xs")}
|
||||
onClick={() => setMaritalStatus(opt.value as MaritalStatus)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -220,41 +356,14 @@ function PersonalDetailsPage() {
|
||||
</div>
|
||||
|
||||
{/* درآمد */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold">
|
||||
{t("settings.edit.personalDetails.incomeRange")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(incomeRange === "under_5m", "h-10 text-xs")}
|
||||
onClick={() => setIncomeRange("under_5m")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.incomeUnder5m")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(incomeRange === "5m_10m", "h-10 text-xs")}
|
||||
onClick={() => setIncomeRange("5m_10m")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.income5m10m")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(incomeRange === "10m_20m", "h-10 text-xs")}
|
||||
onClick={() => setIncomeRange("10m_20m")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.income10m20m")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(incomeRange === "over_20m", "h-10 text-xs")}
|
||||
onClick={() => setIncomeRange("over_20m")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.incomeOver20m")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<RoundedSelect
|
||||
label={t("settings.edit.personalDetails.incomeRange")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={incomeRange ?? ""}
|
||||
options={incomeRangeOptions}
|
||||
onChange={(v) => setIncomeRange((v || null) as IncomeRange)}
|
||||
className="mx-auto"
|
||||
/>
|
||||
|
||||
{/* خصوصیات اخلاقی */}
|
||||
<div>
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-neutral-200 pt-6 dark:border-neutral-700" />
|
||||
|
||||
{/* هدف از رابطه */}
|
||||
<MultiSelectDropdown
|
||||
label={t("settings.edit.personalDetails.relationshipGoal")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={relationshipGoal}
|
||||
options={relationshipGoalOptions}
|
||||
onChange={setRelationshipGoal}
|
||||
max={RELATIONSHIP_GOAL_MAX}
|
||||
maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", {
|
||||
max: RELATIONSHIP_GOAL_MAX,
|
||||
})}
|
||||
formatSelectedSummary={selectedCountLabel}
|
||||
className="mx-auto"
|
||||
/>
|
||||
|
||||
{/* جنسیت (فقطخواندنی، از ثبتنام) */}
|
||||
<ReadOnlyValueRow
|
||||
label={t("auth.genderTitle")}
|
||||
value={
|
||||
user?.gender === "female"
|
||||
? t("auth.female")
|
||||
: user?.gender === "male"
|
||||
? t("auth.male")
|
||||
: null
|
||||
}
|
||||
href="/settings/edit/gender"
|
||||
/>
|
||||
|
||||
{/* سن (فقطخواندنی، از تاریخ تولد ثبتشده) */}
|
||||
<ReadOnlyValueRow
|
||||
label={t("settings.edit.personalDetails.age")}
|
||||
value={
|
||||
ageFromBirthday(user?.birthday) != null
|
||||
? t("settings.edit.personalDetails.ageYears", {
|
||||
age: ageFromBirthday(user?.birthday),
|
||||
})
|
||||
: null
|
||||
}
|
||||
href="/settings/edit/birthday"
|
||||
/>
|
||||
|
||||
{/* ویژگیهای شخصیتی */}
|
||||
<MultiSelectDropdown
|
||||
label={t("settings.edit.personalDetails.personalityTraits")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={personalityTraits}
|
||||
options={personalityTraitsOptions}
|
||||
onChange={setPersonalityTraits}
|
||||
max={PERSONALITY_TRAITS_MAX}
|
||||
maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", {
|
||||
max: PERSONALITY_TRAITS_MAX,
|
||||
})}
|
||||
formatSelectedSummary={selectedCountLabel}
|
||||
className="mx-auto"
|
||||
/>
|
||||
|
||||
{/* سبک زندگی */}
|
||||
<MultiSelectDropdown
|
||||
label={t("settings.edit.personalDetails.lifestyle")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={lifestyle}
|
||||
options={lifestyleOptions}
|
||||
onChange={setLifestyle}
|
||||
max={LIFESTYLE_MAX}
|
||||
maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", {
|
||||
max: LIFESTYLE_MAX,
|
||||
})}
|
||||
formatSelectedSummary={selectedCountLabel}
|
||||
className="mx-auto"
|
||||
/>
|
||||
|
||||
{/* علاقهمندیها */}
|
||||
<MultiSelectDropdown
|
||||
label={t("settings.edit.personalDetails.interests")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={interests}
|
||||
options={interestsOptions}
|
||||
onChange={setInterests}
|
||||
max={INTERESTS_MAX}
|
||||
maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", {
|
||||
max: INTERESTS_MAX,
|
||||
})}
|
||||
formatSelectedSummary={selectedCountLabel}
|
||||
className="mx-auto"
|
||||
/>
|
||||
|
||||
{/* ارزشها */}
|
||||
<MultiSelectDropdown
|
||||
label={t("settings.edit.personalDetails.values")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={values}
|
||||
options={valuesOptions}
|
||||
onChange={setValues}
|
||||
max={VALUES_MAX}
|
||||
maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", {
|
||||
max: VALUES_MAX,
|
||||
})}
|
||||
formatSelectedSummary={selectedCountLabel}
|
||||
className="mx-auto"
|
||||
/>
|
||||
|
||||
{/* دیدگاه نسبت به رابطه */}
|
||||
<RoundedSelect
|
||||
label={t("settings.edit.personalDetails.relationshipView")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={relationshipView}
|
||||
options={relationshipViewOptions}
|
||||
onChange={(v) => setRelationshipView(v as RelationshipView)}
|
||||
className="mx-auto"
|
||||
/>
|
||||
|
||||
{/* محدوده جغرافیایی مورد نظر */}
|
||||
<RoundedSelect
|
||||
label={t("settings.edit.personalDetails.geoPreference")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={geoPreference}
|
||||
options={geoPreferenceOptions}
|
||||
onChange={(v) => setGeoPreference(v as GeoPreference)}
|
||||
className="mx-auto"
|
||||
/>
|
||||
|
||||
{/* وضعیت فرزند */}
|
||||
<RoundedSelect
|
||||
label={t("settings.edit.personalDetails.childrenStatus")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={childrenStatus}
|
||||
options={childrenStatusOptions}
|
||||
onChange={(v) => setChildrenStatus(v as ChildrenStatus)}
|
||||
className="mx-auto"
|
||||
/>
|
||||
|
||||
{/* وضعیت مصرف دخانیات */}
|
||||
<RoundedSelect
|
||||
label={t("settings.edit.personalDetails.smokingStatus")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={smokingStatus}
|
||||
options={smokingStatusOptions}
|
||||
onChange={(v) => setSmokingStatus(v as SmokingStatus)}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AuthNextButton
|
||||
|
||||
@@ -407,6 +407,7 @@ export default function UserSettingsPage() {
|
||||
isOpen={resetConfirmOpen}
|
||||
onClose={() => setResetConfirmOpen(false)}
|
||||
height="fit"
|
||||
elevated
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4 py-2 text-center">
|
||||
<p className="font-bold">{t("settings.resetAlgorithm")}</p>
|
||||
|
||||
166
src/components/AnalyticsTracker.tsx
Normal file
166
src/components/AnalyticsTracker.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { parseUserAgent } from "@/lib/analytics/parseUserAgent";
|
||||
import { getApiBaseUrl } from "@/components/main/BaseUrl";
|
||||
|
||||
const SESSION_KEY = "modstagram_analytics_session_id";
|
||||
const FLUSH_INTERVAL_MS = 8000;
|
||||
|
||||
type AnalyticsEvent = {
|
||||
sessionId: string;
|
||||
type: "session_start" | "page_view" | "click";
|
||||
path?: string | null;
|
||||
durationMs?: number | null;
|
||||
label?: string | null;
|
||||
platform: "web";
|
||||
device: string;
|
||||
browser: string;
|
||||
os: string;
|
||||
};
|
||||
|
||||
function getSessionId(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
try {
|
||||
let id = window.sessionStorage.getItem(SESSION_KEY);
|
||||
if (!id) {
|
||||
id =
|
||||
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
window.sessionStorage.setItem(SESSION_KEY, id);
|
||||
}
|
||||
return id;
|
||||
} catch {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// نزدیکترین عنصر قابلکلیکِ معنادار (دکمه/لینک) از روی target — تا کلیکهای بیربط
|
||||
// (مثلاً یه <div> بینام) ثبت نشه و لیبل خوانا داشته باشیم
|
||||
function findClickLabel(target: EventTarget | null): string | null {
|
||||
let el = target as HTMLElement | null;
|
||||
let depth = 0;
|
||||
while (el && depth < 6) {
|
||||
if (
|
||||
el.tagName === "BUTTON" ||
|
||||
el.tagName === "A" ||
|
||||
el.getAttribute("role") === "button"
|
||||
) {
|
||||
const explicit = el.getAttribute("data-analytics-id");
|
||||
if (explicit) return explicit;
|
||||
const aria = el.getAttribute("aria-label");
|
||||
if (aria) return aria;
|
||||
const text = el.textContent?.trim().slice(0, 60);
|
||||
if (text) return text;
|
||||
if (el.tagName === "A") {
|
||||
const href = el.getAttribute("href");
|
||||
if (href) return `link:${href.slice(0, 60)}`;
|
||||
}
|
||||
return el.tagName.toLowerCase();
|
||||
}
|
||||
el = el.parentElement;
|
||||
depth += 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function AnalyticsTracker() {
|
||||
const pathname = usePathname();
|
||||
const sessionIdRef = useRef<string>("");
|
||||
const uaRef = useRef({ device: "Desktop", browser: "Other", os: "Other" });
|
||||
const queueRef = useRef<AnalyticsEvent[]>([]);
|
||||
const currentPathRef = useRef<string | null>(null);
|
||||
const pageEnteredAtRef = useRef<number>(0);
|
||||
|
||||
const flush = (useBeacon = false) => {
|
||||
if (!queueRef.current.length) return;
|
||||
const events = queueRef.current;
|
||||
queueRef.current = [];
|
||||
const url = `${getApiBaseUrl()}/analytics/track`;
|
||||
const body = JSON.stringify({ events });
|
||||
|
||||
if (useBeacon && typeof navigator !== "undefined" && navigator.sendBeacon) {
|
||||
const blob = new Blob([body], { type: "application/json" });
|
||||
navigator.sendBeacon(url, blob);
|
||||
return;
|
||||
}
|
||||
|
||||
const token =
|
||||
typeof document !== "undefined"
|
||||
? document.cookie
|
||||
.split("; ")
|
||||
.find((c) => c.startsWith("token="))
|
||||
?.split("=")[1]
|
||||
: null;
|
||||
|
||||
fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body,
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const enqueue = (event: Omit<AnalyticsEvent, "sessionId" | "platform" | "device" | "browser" | "os">) => {
|
||||
queueRef.current.push({
|
||||
...event,
|
||||
sessionId: sessionIdRef.current,
|
||||
platform: "web",
|
||||
...uaRef.current,
|
||||
});
|
||||
};
|
||||
|
||||
const closeCurrentPage = () => {
|
||||
if (!currentPathRef.current) return;
|
||||
const durationMs = Date.now() - pageEnteredAtRef.current;
|
||||
enqueue({ type: "page_view", path: currentPathRef.current, durationMs });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
sessionIdRef.current = getSessionId();
|
||||
uaRef.current = parseUserAgent(navigator.userAgent);
|
||||
enqueue({ type: "session_start" });
|
||||
|
||||
const interval = window.setInterval(() => flush(false), FLUSH_INTERVAL_MS);
|
||||
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
closeCurrentPage();
|
||||
currentPathRef.current = null;
|
||||
flush(true);
|
||||
}
|
||||
};
|
||||
const onClick = (e: MouseEvent) => {
|
||||
const label = findClickLabel(e.target);
|
||||
if (label) enqueue({ type: "click", path: location.pathname, label });
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
document.addEventListener("click", onClick, { capture: true });
|
||||
window.addEventListener("pagehide", () => {
|
||||
closeCurrentPage();
|
||||
flush(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
document.removeEventListener("click", onClick, { capture: true });
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
closeCurrentPage();
|
||||
currentPathRef.current = pathname;
|
||||
pageEnteredAtRef.current = Date.now();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import PwaInstallPrompt from "@/components/pwa/PwaInstallPrompt";
|
||||
import PwaBootSplash from "@/components/pwa/PwaBootSplash";
|
||||
import PwaHead from "@/components/PwaHead";
|
||||
import NotificationBridge from "@/components/notifications/NotificationBridge";
|
||||
import AnalyticsTracker from "@/components/AnalyticsTracker";
|
||||
|
||||
interface ILayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -32,6 +33,7 @@ function Layout({ children, initialLanguage }: ILayoutProps) {
|
||||
<PwaInstallPrompt />
|
||||
<BackgroundPrefetch />
|
||||
<NotificationBridge />
|
||||
<AnalyticsTracker />
|
||||
{children}
|
||||
</ReactQueryProvider>
|
||||
</LanguageProvider>
|
||||
|
||||
@@ -8,24 +8,31 @@ import {
|
||||
toPwaLang,
|
||||
} from "@/lib/pwaConfig";
|
||||
|
||||
function upsertLink(rel: string, href: string) {
|
||||
let link = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`);
|
||||
if (!link) {
|
||||
link = document.createElement("link");
|
||||
link.rel = rel;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.href = href;
|
||||
// این تگها (manifest/apple-touch-icon/apple-mobile-web-app-title/application-name)
|
||||
// از قبل توسط generateMetadata (layout.tsx) بهصورت سرور-ساید توسط خودِ ریاکت رندر
|
||||
// و مدیریت میشن. اینجا فقط برای هماهنگکردنِ manifest/برچسبها با زبانی که کاربر
|
||||
// سمتِ کلاینت (بدون رفرشِ صفحه) عوض میکنه، مقدارِ href/content رو روی همون نودِ
|
||||
// موجود عوض میکنیم — بدون remove()/appendChild و بدون MutationObserver.
|
||||
//
|
||||
// نسخهی قبلی این فایل با querySelectorAll + remove() تگهای «تکراری» رو پاک
|
||||
// میکرد و توی یه MutationObserver هم دوباره صدا زده میشد؛ چون این نودها همونایی
|
||||
// هستن که ریاکتِ خودِ Next.js (متادیتای App Router) داره مدیریت میکنه، حذف/
|
||||
// جابهجاییِ ساختاریِ بیرون از ریاکت باعث میشد فایبرِ ریاکت روی ریرندرِ بعدی
|
||||
// (مثلاً هر ناوبری بینِ صفحات که متادیتا رو دوباره reconcile میکنه) به یه نودِ
|
||||
// جداشده removeChild بزنه و کرش کنه — که این کرشِ مکرر باعث میشد کامیتِ ریاکت
|
||||
// برای کلیکِ اول ناقص بمونه و کل سایت («تنظیمات»، دکمهی برگشتِ چت و...) نیاز به
|
||||
// کلیکِ دوم پیدا کنه. تغییرِ فقط یه attribute (نه ساختار) روی نودِ از قبل موجود،
|
||||
// فایبرِ ریاکت رو دستنخورده نگه میداره و این تداخل رو کاملاً حذف میکنه.
|
||||
function setLinkHref(rel: string, href: string) {
|
||||
const link = document.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`);
|
||||
if (link) link.href = href;
|
||||
}
|
||||
|
||||
function upsertMeta(name: string, content: string) {
|
||||
let meta = document.querySelector<HTMLMetaElement>(`meta[name="${name}"]`);
|
||||
if (!meta) {
|
||||
meta = document.createElement("meta");
|
||||
meta.name = name;
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
meta.content = content;
|
||||
function setMetaContent(name: string, content: string) {
|
||||
const meta = document.querySelector<HTMLMetaElement>(
|
||||
`meta[name="${name}"]`
|
||||
);
|
||||
if (meta) meta.content = content;
|
||||
}
|
||||
|
||||
export default function PwaHead() {
|
||||
@@ -37,13 +44,12 @@ export default function PwaHead() {
|
||||
const pwaLang = toPwaLang(language);
|
||||
const labels = getPwaLabels(pwaLang);
|
||||
|
||||
upsertLink(
|
||||
setLinkHref(
|
||||
"manifest",
|
||||
`/manifest.webmanifest?lang=${pwaLang}&v=${PWA_VERSION}`
|
||||
);
|
||||
upsertLink("apple-touch-icon", "/images/icons/apple-touch-icon.png");
|
||||
upsertMeta("apple-mobile-web-app-title", labels.short_name);
|
||||
upsertMeta("application-name", labels.short_name);
|
||||
setMetaContent("apple-mobile-web-app-title", labels.short_name);
|
||||
setMetaContent("application-name", labels.short_name);
|
||||
}, [language, ready]);
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useCallback, useState, useRef, useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useEffect, useState, useRef, useMemo } from "react";
|
||||
import { useListScrollRestoration } from "@/hooks/useListScrollRestoration";
|
||||
import MainModelCard from "./MainModelCard";
|
||||
import { Course } from "@/types/types";
|
||||
import Image from "next/image";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import toast from "react-hot-toast";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
@@ -38,7 +36,7 @@ interface InfinitePostsProps {
|
||||
typeFilter?: "image" | "video";
|
||||
}
|
||||
|
||||
export default function InfinitePosts({ filters, token }: InfinitePostsProps) {
|
||||
export default function InfinitePosts({ filters }: InfinitePostsProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [coursesPage, setCoursesPage] = useState(1);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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`,
|
||||
{
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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";
|
||||
|
||||
174
src/components/chat/E2eeIdentityGate.tsx
Normal file
174
src/components/chat/E2eeIdentityGate.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Modal from "@/components/elements/Modal";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import {
|
||||
IdentityRecoveryNeededError,
|
||||
createFreshIdentity,
|
||||
ensureIdentityKeys,
|
||||
recoverIdentityFromBackup,
|
||||
} from "@/lib/e2ee";
|
||||
|
||||
type GateState = { kind: "idle" } | { kind: "recovery"; hasBackup: boolean };
|
||||
|
||||
/**
|
||||
* توی ChatSocketProvider (سطحِ لایوت /settings/chats) mount میشه — جایی که
|
||||
* ensureIdentityKeys برای اولینبار صدا زده میشه.
|
||||
*
|
||||
* بکآپِ کلید حالا کاملاً خودکاره (با رمزِ per-account که سرور خودش میسازه، نه PIN
|
||||
* کاربر) — پس دیگه هیچ پیشنهادِ «رمز بازیابی بساز»ی لازم نیست، و لاگین دوباره/دستگاهِ
|
||||
* جدید هم خودش را قبل از رسیدن به اینجا با همون رمزِ خودکار بازیابی میکنه
|
||||
* (lib/e2ee/keys.ts: tryAutoRecoverIdentity). این کارت فقط برای حالتِ نادری ظاهر
|
||||
* میشه که حتی اون بازیابیِ خودکار هم شکست بخوره (مثلاً بکآپِ سرور با یه PIN دستیِ
|
||||
* قدیمی — ازقبلازاینتغییر — رمزنگاری شده، یا واقعاً هیچ بکآپی نیست)
|
||||
*/
|
||||
export default function E2eeIdentityGate({ userId }: { userId: string }) {
|
||||
const { t } = useTranslation("common");
|
||||
const [state, setState] = useState<GateState>({ kind: "idle" });
|
||||
const [pin, setPin] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmFresh, setConfirmFresh] = useState(false);
|
||||
|
||||
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 () => {
|
||||
cancelledRef.current = true;
|
||||
};
|
||||
}, [userId, runEnsure]);
|
||||
|
||||
const handleRetryAuto = async () => {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await runEnsure();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecover = async () => {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await recoverIdentityFromBackup(userId, pin);
|
||||
setState({ kind: "idle" });
|
||||
setPin("");
|
||||
} catch {
|
||||
setError(t("chats.e2ee.wrongPin"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartFresh = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await createFreshIdentity(userId);
|
||||
setState({ kind: "idle" });
|
||||
setConfirmFresh(false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (state.kind === "recovery") {
|
||||
return (
|
||||
<Modal isOpen onClose={() => {}} height="fit" elevated>
|
||||
<div className="flex flex-col gap-3 py-2 text-center">
|
||||
<p className="font-bold">{t("chats.e2ee.recoveryTitle")}</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{state.hasBackup
|
||||
? t("chats.e2ee.recoveryHasBackupDesc")
|
||||
: t("chats.e2ee.recoveryNoBackupDesc")}
|
||||
</p>
|
||||
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 w-full max-w-[220px] mx-auto"
|
||||
disabled={busy}
|
||||
onClick={() => void handleRetryAuto()}
|
||||
>
|
||||
{busy ? "…" : t("chats.e2ee.retryButton")}
|
||||
</RoundedButton>
|
||||
|
||||
{state.hasBackup ? (
|
||||
<>
|
||||
<input
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
value={pin}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<RoundedButton
|
||||
className="h-10 w-full max-w-[220px] mx-auto"
|
||||
disabled={busy || !pin.trim()}
|
||||
onClick={() => void handleRecover()}
|
||||
>
|
||||
{busy ? "…" : t("chats.e2ee.recoverButton")}
|
||||
</RoundedButton>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{confirmFresh ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs font-semibold text-red-500">
|
||||
{t("chats.e2ee.confirmFreshWarning")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<RoundedButton
|
||||
className="h-10 w-full"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirmFresh(false)}
|
||||
>
|
||||
{t("common.no")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 w-full"
|
||||
disabled={busy}
|
||||
onClick={() => void handleStartFresh()}
|
||||
>
|
||||
{busy ? "…" : t("common.yes")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmFresh(true)}
|
||||
className="text-xs text-neutral-500 underline"
|
||||
>
|
||||
{t("chats.e2ee.startFreshLink")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = <K extends keyof PartnerFilters>(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 (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
@@ -232,6 +292,7 @@ export default function FindPartnerFilterModal({
|
||||
options={[
|
||||
{ value: "single", label: t("settings.edit.personalDetails.maritalStatusSingle") },
|
||||
{ value: "divorced", label: t("settings.edit.personalDetails.maritalStatusDivorced") },
|
||||
...maritalStatusNewOptions,
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -278,16 +339,128 @@ export default function FindPartnerFilterModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ToggleRow
|
||||
<RoundedSelect
|
||||
label={t("settings.edit.personalDetails.incomeRange")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={filters.incomeRange}
|
||||
options={incomeRangeOptions}
|
||||
onChange={(v) => 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"
|
||||
/>
|
||||
|
||||
<div className="w-full max-w-sm border-t border-neutral-200 pt-4 dark:border-neutral-700">
|
||||
<MultiSelectDropdown
|
||||
label={t("settings.edit.personalDetails.relationshipGoal")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={filters.relationshipGoal}
|
||||
options={relationshipGoalOptions}
|
||||
onChange={(v) => set("relationshipGoal", v)}
|
||||
max={RELATIONSHIP_GOAL_MAX}
|
||||
maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", {
|
||||
max: RELATIONSHIP_GOAL_MAX,
|
||||
})}
|
||||
formatSelectedSummary={selectedCountLabel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RoundedSelect
|
||||
label={t("settings.edit.personalDetails.seekingGender")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={filters.seekingGender}
|
||||
options={seekingGenderOptions}
|
||||
onChange={(v) => set("seekingGender", v)}
|
||||
/>
|
||||
|
||||
<RoundedSelect
|
||||
label={t("settings.edit.personalDetails.desiredAgeRange")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={filters.desiredAgeRange}
|
||||
options={desiredAgeRangeOptions}
|
||||
onChange={(v) => set("desiredAgeRange", v)}
|
||||
/>
|
||||
|
||||
<MultiSelectDropdown
|
||||
label={t("settings.edit.personalDetails.personalityTraits")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={filters.personalityTraits}
|
||||
options={personalityTraitsOptions}
|
||||
onChange={(v) => set("personalityTraits", v)}
|
||||
max={PERSONALITY_TRAITS_MAX}
|
||||
maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", {
|
||||
max: PERSONALITY_TRAITS_MAX,
|
||||
})}
|
||||
formatSelectedSummary={selectedCountLabel}
|
||||
/>
|
||||
|
||||
<MultiSelectDropdown
|
||||
label={t("settings.edit.personalDetails.lifestyle")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={filters.lifestyle}
|
||||
options={lifestyleOptions}
|
||||
onChange={(v) => set("lifestyle", v)}
|
||||
max={LIFESTYLE_MAX}
|
||||
maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", {
|
||||
max: LIFESTYLE_MAX,
|
||||
})}
|
||||
formatSelectedSummary={selectedCountLabel}
|
||||
/>
|
||||
|
||||
<MultiSelectDropdown
|
||||
label={t("settings.edit.personalDetails.interests")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={filters.interests}
|
||||
options={interestsOptions}
|
||||
onChange={(v) => set("interests", v)}
|
||||
max={INTERESTS_MAX}
|
||||
maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", {
|
||||
max: INTERESTS_MAX,
|
||||
})}
|
||||
formatSelectedSummary={selectedCountLabel}
|
||||
/>
|
||||
|
||||
<MultiSelectDropdown
|
||||
label={t("settings.edit.personalDetails.values")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={filters.values}
|
||||
options={valuesOptions}
|
||||
onChange={(v) => set("values", v)}
|
||||
max={VALUES_MAX}
|
||||
maxHint={t("settings.edit.personalDetails.multiSelectMaxHint", {
|
||||
max: VALUES_MAX,
|
||||
})}
|
||||
formatSelectedSummary={selectedCountLabel}
|
||||
/>
|
||||
|
||||
<RoundedSelect
|
||||
label={t("settings.edit.personalDetails.relationshipView")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={filters.relationshipView}
|
||||
options={relationshipViewOptions}
|
||||
onChange={(v) => set("relationshipView", v)}
|
||||
/>
|
||||
|
||||
<RoundedSelect
|
||||
label={t("settings.edit.personalDetails.geoPreference")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={filters.geoPreference}
|
||||
options={geoPreferenceOptions}
|
||||
onChange={(v) => set("geoPreference", v)}
|
||||
/>
|
||||
|
||||
<RoundedSelect
|
||||
label={t("settings.edit.personalDetails.childrenStatus")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={filters.childrenStatus}
|
||||
options={childrenStatusOptions}
|
||||
onChange={(v) => set("childrenStatus", v)}
|
||||
/>
|
||||
|
||||
<RoundedSelect
|
||||
label={t("settings.edit.personalDetails.smokingStatus")}
|
||||
placeholder={t("settings.edit.personalDetails.selectPlaceholder")}
|
||||
value={filters.smokingStatus}
|
||||
options={smokingStatusOptions}
|
||||
onChange={(v) => set("smokingStatus", v)}
|
||||
/>
|
||||
|
||||
<div className="w-full max-w-sm border-t border-neutral-200 pt-4 dark:border-neutral-700">
|
||||
|
||||
@@ -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<string>("");
|
||||
|
||||
const [previewProfile, setPreviewProfile] = useState<string>("");
|
||||
const [avatar, setAvatar] = useState<string | null>(null);
|
||||
// State برای عکس پکیج
|
||||
const [packageImageFile, setPackageImageFile] = useState<File | null>(null);
|
||||
const [packageImagePreview, setPackageImagePreview] = useState<string>("");
|
||||
|
||||
// State برای عکسها
|
||||
const [imageFields, setImageFields] = useState<MediaFile[]>([]);
|
||||
@@ -130,9 +124,6 @@ export function CourseForm({
|
||||
const [videoFile, setVideoFile] = useState<File | null>(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<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -350,17 +332,7 @@ export function CourseForm({
|
||||
}
|
||||
};
|
||||
|
||||
const fileToBase64 = (file: File): Promise<string> => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1049,6 +1003,7 @@ export function CourseForm({
|
||||
<video
|
||||
src={videoPreview}
|
||||
controls
|
||||
playsInline
|
||||
className="w-full max-h-48 rounded-lg border"
|
||||
controlsList="nodownload"
|
||||
/>
|
||||
@@ -1152,6 +1107,7 @@ export function CourseForm({
|
||||
<video
|
||||
src={buildStorageUrl(field.course_video)}
|
||||
controls
|
||||
playsInline
|
||||
className="w-full max-h-64 rounded-lg"
|
||||
controlsList="nodownload"
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className="glass-modal-overlay fixed inset-0 z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
className={cn(
|
||||
"glass-modal-overlay fixed inset-0 z-50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -30,7 +34,10 @@ const DialogContent = React.forwardRef<
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className="glass-modal-panel glass-modal-panel--center fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 p-6 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]"
|
||||
className={cn(
|
||||
"glass-modal-panel glass-modal-panel--center fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 p-6 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -48,7 +55,7 @@ const DialogHeader = ({
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className="flex flex-col space-y-1.5 text-center sm:text-left"
|
||||
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -59,7 +66,10 @@ const DialogFooter = ({
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className="flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2"
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -71,7 +81,7 @@ const DialogTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className="text-lg font-semibold leading-none tracking-tight"
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -83,7 +93,7 @@ const DialogDescription = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className="text-sm text-muted-foreground"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import ProfileAvatar from "./ProfileAvatar";
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
<Link
|
||||
href={`/users/${user_name}`}
|
||||
className="font-semibold flex gap-2 flex-col-reverse ml-2 w-full min-w-0"
|
||||
className="font-semibold flex gap-2 flex-col ml-2 w-full min-w-0"
|
||||
>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<div className="max-sm:text-xs flex w-full flex-col gap-0.5 text-[#387E65]">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span>{user_level || t("models.newcomer")}</span>
|
||||
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-0.5 pt-1 font-semibold">
|
||||
{city?.name ? <span>{localizedGeoName(city)}</span> : null}
|
||||
{expertise ? <span>{expertise}</span> : null}
|
||||
{displaySubExpertise ? <span>{displaySubExpertise}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-1 mt-1">
|
||||
<h2 className="max-sm:text-xs inline-flex items-center gap-1">
|
||||
{user_name}
|
||||
<VerificationBadge verifyBadge={verify_badge} />
|
||||
</h2>
|
||||
<ScoreRateStats score={user_score} rate={rate} />
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="max-sm:text-xs">
|
||||
{first_name} {last_name}
|
||||
</div>
|
||||
<div className="">
|
||||
<div className="text-[#15a9d6] flex gap-5 font-semibold">
|
||||
{subExpertise && matchedExpertise2.length > 0 && (
|
||||
@@ -355,15 +358,11 @@ function MainModelCard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-sm:text-xs flex w-full flex-col gap-0.5 text-[#387E65]">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span>{user_level || t("models.newcomer")}</span>
|
||||
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-0.5 pt-1 font-semibold">
|
||||
{city?.name ? <span>{localizedGeoName(city)}</span> : null}
|
||||
{expertise ? <span>{expertise}</span> : null}
|
||||
{displaySubExpertise ? <span>{displaySubExpertise}</span> : null}
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="max-sm:text-xs">
|
||||
{first_name} {last_name}
|
||||
</div>
|
||||
<ScoreRateStats score={user_score} rate={rate} />
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -106,6 +106,7 @@ const SwipeImageSlider: React.FC<SwipeImageSliderProps> = ({
|
||||
<video
|
||||
src={file.src}
|
||||
controls
|
||||
playsInline
|
||||
className="max-h-[60vh] w-auto rounded-2xl shadow-lg"
|
||||
onLoadedData={() => setImageLoading?.(false)}
|
||||
/>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -199,14 +199,16 @@ function ModelsFilter({ expertise }: ModelsFilterProps) {
|
||||
>
|
||||
<BoldIcon name="grid-3" size={TOOLBAR_ICON_SIZE} className="block" />
|
||||
</button>
|
||||
<button
|
||||
{/* طبق خواستهی صریح کاربر: فعلاً که تعداد فروشگاهها کمه این آیکون مخفیه؛
|
||||
هر وقت تعداد فروشگاهها زیاد شد از کامنت درش بیارید */}
|
||||
{/* <button
|
||||
type="button"
|
||||
onClick={() => router.push("/shops/discover")}
|
||||
aria-label={t("filters.toolbar.shop")}
|
||||
className={toolbarIconButtonClass}
|
||||
>
|
||||
<BoldIcon name="shop" size={TOOLBAR_ICON_SIZE} className="block" />
|
||||
</button>
|
||||
</button> */}
|
||||
</div>
|
||||
{showFilterModal && (
|
||||
<FilterModal
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -633,22 +633,24 @@ export default function ReelsPostCard({
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2 text-xs text-white/80">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 text-xs text-white/80">
|
||||
<span className="truncate">
|
||||
{first_name} {last_name}
|
||||
</span>
|
||||
{userRate != null && (
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5">
|
||||
<BoldIcon name="star-1" size={12} className="text-yellow-400" tinted />
|
||||
{userRate.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
{userScore != null && (
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5">
|
||||
<BoldIcon name="medal-star" size={12} className="text-yellow-400" tinted />
|
||||
{userScore}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{userRate != null && (
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5">
|
||||
<BoldIcon name="star-1" size={12} className="text-yellow-400" tinted />
|
||||
{userRate.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
{userScore != null && (
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5">
|
||||
<BoldIcon name="medal-star" size={12} className="text-yellow-400" tinted />
|
||||
{userScore}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -148,7 +148,7 @@ function ProjectRequestForm({
|
||||
className="relative h-20 w-20 overflow-hidden rounded-lg border"
|
||||
>
|
||||
{item.type === "video" ? (
|
||||
<video src={item.data} className="h-full w-full object-cover" />
|
||||
<video src={item.data} playsInline muted className="h-full w-full object-cover" />
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
|
||||
@@ -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" ? (
|
||||
<video
|
||||
src={previewUrl}
|
||||
src={media.src}
|
||||
poster={media.poster}
|
||||
className="h-full w-full object-cover"
|
||||
muted
|
||||
playsInline
|
||||
@@ -96,7 +101,7 @@ function SpecialistCard({
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={previewUrl}
|
||||
src={media.src}
|
||||
alt=""
|
||||
fill
|
||||
loading="lazy"
|
||||
|
||||
@@ -166,6 +166,7 @@ function ProjectRequestsAction({
|
||||
src={url}
|
||||
className="h-full w-full object-cover"
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { usePwaInstall } from "@/hooks/usePwaInstall";
|
||||
import { btnPrimary, btnDefault } from "@/lib/ui/buttonStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -25,22 +26,32 @@ export default function PwaInstallPrompt() {
|
||||
const { language } = useAppLanguage();
|
||||
const labels = getPwaLabels(toPwaLang(language));
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="glass-modal-overlay fixed inset-0 z-[300]"
|
||||
aria-hidden
|
||||
onClick={() => close(true)}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-labelledby="pwa-install-title"
|
||||
aria-describedby="pwa-install-desc"
|
||||
className="glass-modal-panel glass-modal-panel--sheet fixed bottom-0 left-0 right-0 z-[301] mx-auto max-w-xl p-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] shadow-2xl"
|
||||
dir={labels.dir}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{visible ? (
|
||||
<>
|
||||
<motion.div
|
||||
key="pwa-install-overlay"
|
||||
className="glass-modal-overlay fixed inset-0 z-[300]"
|
||||
aria-hidden
|
||||
onClick={() => close(true)}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
/>
|
||||
<motion.div
|
||||
key="pwa-install-sheet"
|
||||
role="dialog"
|
||||
aria-labelledby="pwa-install-title"
|
||||
aria-describedby="pwa-install-desc"
|
||||
className="glass-modal-panel glass-modal-panel--sheet fixed bottom-0 left-0 right-0 z-[301] mx-auto max-w-xl p-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] shadow-2xl"
|
||||
dir={labels.dir}
|
||||
initial={{ y: "100%", opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: "100%", opacity: 0 }}
|
||||
transition={{ type: "spring", damping: 28, stiffness: 320 }}
|
||||
>
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<div className="relative h-14 w-14 shrink-0 overflow-hidden rounded-2xl bg-white shadow-md">
|
||||
<Image
|
||||
@@ -140,7 +151,9 @@ export default function PwaInstallPrompt() {
|
||||
{labels.later}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</motion.div>
|
||||
</>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -5,10 +5,7 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Cookies from "js-cookie";
|
||||
import {
|
||||
fetchStoriesFeed,
|
||||
StoryFeedUser,
|
||||
} from "@/api/fetchStories";
|
||||
import { fetchStoriesFeed } from "@/api/fetchStories";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import StoryViewer from "@/components/stories/StoryViewer";
|
||||
import StoryRing, {
|
||||
|
||||
133
src/components/stories/StoryCameraCapture.tsx
Normal file
133
src/components/stories/StoryCameraCapture.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
|
||||
type Props = {
|
||||
onCapture: (file: File) => 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<HTMLVideoElement>(null);
|
||||
const streamRef = useRef<MediaStream | null>(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 (
|
||||
<div className="fixed inset-0 z-[70] flex flex-col bg-black">
|
||||
<div className="flex items-center justify-between px-4 py-4">
|
||||
<button type="button" onClick={close} aria-label={t("stories.closeAria")}>
|
||||
<BoldIcon name="close-circle" size={26} tinted className="text-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-1 items-center justify-center overflow-hidden">
|
||||
{error ? (
|
||||
<p className="px-8 text-center text-sm text-white">{error}</p>
|
||||
) : (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
className="h-full w-full object-cover"
|
||||
style={{ filter: selectedFilter.css, transform: "scaleX(-1)" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 overflow-x-auto px-4 py-3">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
type="button"
|
||||
onClick={() => setSelected(f.value)}
|
||||
className={`shrink-0 rounded-2xl border px-3.5 py-2 text-xs font-semibold text-white ${
|
||||
selected === f.value ? "border-[#0095f6] bg-[#0095f6]/20" : "border-white/30"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center pb-8 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={capture}
|
||||
disabled={!!error}
|
||||
aria-label={t("stories.capturePhoto")}
|
||||
className="h-16 w-16 rounded-full border-4 border-white bg-white/20 disabled:opacity-40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<PickedStoryLink["link_kind"], string> = {
|
||||
@@ -49,35 +58,66 @@ export default function StoryEditor({
|
||||
}: StoryEditorProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const containerRef = useRef<HTMLDivElement>(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; // ابعادِ ویدیو از خودِ <video onLoadedMetadata> میاد
|
||||
const img = new window.Image();
|
||||
img.onload = () => {
|
||||
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||||
applyAspect(img.naturalHeight / img.naturalWidth);
|
||||
}
|
||||
};
|
||||
img.src = mediaUrl;
|
||||
}, [mediaUrl, mediaType, applyAspect]);
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
const [textModalOpen, setTextModalOpen] = useState(false);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [draftText, setDraftText] = useState("");
|
||||
const [linkPickerOpen, setLinkPickerOpen] = useState(false);
|
||||
const [mentionPickerOpen, setMentionPickerOpen] = useState(false);
|
||||
const [stickerModal, setStickerModal] = useState<"hashtag" | "location" | null>(
|
||||
null
|
||||
);
|
||||
const [stickerDraft, setStickerDraft] = useState("");
|
||||
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
|
||||
|
||||
const dragState = useRef<{
|
||||
index: number;
|
||||
moved: boolean;
|
||||
} | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const update = () =>
|
||||
setContainerSize({ width: el.clientWidth, height: el.clientHeight });
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const updateOverlay = useCallback(
|
||||
(index: number, patch: Partial<StoryTextOverlay>) => {
|
||||
(
|
||||
index: number,
|
||||
patch:
|
||||
| Partial<StoryTextOverlay>
|
||||
| Partial<StoryMentionOverlay>
|
||||
| Partial<StoryHashtagOverlay>
|
||||
| Partial<StoryLocationOverlay>
|
||||
| Partial<StoryStickerOverlay>
|
||||
) => {
|
||||
onChange(
|
||||
overlays.map((o, i) => {
|
||||
if (i !== index || o.type === "link") return o;
|
||||
return { ...o, ...patch };
|
||||
return { ...o, ...patch } as StoryOverlay;
|
||||
})
|
||||
);
|
||||
},
|
||||
@@ -105,7 +145,9 @@ export default function StoryEditor({
|
||||
setDraftText("");
|
||||
} else {
|
||||
const overlay = overlays[index];
|
||||
setDraftText(overlay && overlay.type !== "link" ? overlay.text : "");
|
||||
setDraftText(
|
||||
overlay && (!overlay.type || overlay.type === "text") ? overlay.text : ""
|
||||
);
|
||||
}
|
||||
setEditingIndex(index);
|
||||
setTextModalOpen(true);
|
||||
@@ -126,6 +168,7 @@ export default function StoryEditor({
|
||||
x: 0.5,
|
||||
y: 0.4,
|
||||
font_size: 0.07,
|
||||
font_family: "bold",
|
||||
color: "#ffffff",
|
||||
background: "",
|
||||
rotation: 0,
|
||||
@@ -181,15 +224,68 @@ export default function StoryEditor({
|
||||
setLinkPickerOpen(false);
|
||||
};
|
||||
|
||||
const addMentionOverlay = (user: MentionUser) => {
|
||||
const next: StoryMentionOverlay = {
|
||||
type: "mention",
|
||||
mention_user_id: user._id,
|
||||
mention_username: user.user_name,
|
||||
x: 0.5,
|
||||
y: 0.6,
|
||||
rotation: 0,
|
||||
font_size: DEFAULT_STICKER_FONT_SIZE,
|
||||
};
|
||||
onChange([...overlays, next]);
|
||||
setSelected(overlays.length);
|
||||
setMentionPickerOpen(false);
|
||||
};
|
||||
|
||||
const openStickerModal = (kind: "hashtag" | "location") => {
|
||||
setStickerDraft("");
|
||||
setStickerModal(kind);
|
||||
};
|
||||
|
||||
const commitSticker = () => {
|
||||
const value = stickerDraft.trim().replace(/^#/, "");
|
||||
if (value && stickerModal) {
|
||||
const next: StoryHashtagOverlay | StoryLocationOverlay = {
|
||||
type: stickerModal,
|
||||
text: value,
|
||||
x: 0.5,
|
||||
y: 0.6,
|
||||
rotation: 0,
|
||||
font_size: DEFAULT_STICKER_FONT_SIZE,
|
||||
};
|
||||
onChange([...overlays, next]);
|
||||
setSelected(overlays.length);
|
||||
}
|
||||
setStickerModal(null);
|
||||
setStickerDraft("");
|
||||
};
|
||||
|
||||
const addEmojiOverlay = (emoji: string) => {
|
||||
const next: StoryStickerOverlay = {
|
||||
type: "sticker",
|
||||
text: emoji,
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
rotation: 0,
|
||||
font_size: DEFAULT_EMOJI_FONT_SIZE,
|
||||
};
|
||||
onChange([...overlays, next]);
|
||||
setSelected(overlays.length);
|
||||
setEmojiPickerOpen(false);
|
||||
};
|
||||
|
||||
const selectedOverlay =
|
||||
selected !== null ? overlays[selected] : null;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
{/* Canvas frame 9:16 */}
|
||||
{/* Canvas frame — به نسبتِ واقعیِ خودِ عکس/ویدیو، نه ۹:۱۶ ثابت */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative mx-auto aspect-[9/16] w-full max-w-[340px] overflow-hidden rounded-2xl bg-black"
|
||||
className="relative mx-auto overflow-hidden rounded-2xl bg-black"
|
||||
style={{ width: boxSize.width, height: boxSize.height }}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onClick={() => setSelected(null)}
|
||||
@@ -198,6 +294,12 @@ export default function StoryEditor({
|
||||
<video
|
||||
src={mediaUrl}
|
||||
className="absolute inset-0 h-full w-full object-contain"
|
||||
onLoadedMetadata={(e) => {
|
||||
const v = e.currentTarget;
|
||||
if (v.videoWidth > 0 && v.videoHeight > 0) {
|
||||
applyAspect(v.videoHeight / v.videoWidth);
|
||||
}
|
||||
}}
|
||||
playsInline
|
||||
muted
|
||||
loop
|
||||
@@ -251,9 +353,79 @@ export default function StoryEditor({
|
||||
);
|
||||
}
|
||||
|
||||
if (o.type === "mention" || o.type === "hashtag" || o.type === "location") {
|
||||
const iconName =
|
||||
o.type === "mention" ? "tag-user" : o.type === "hashtag" ? "hashtag" : "location";
|
||||
const label =
|
||||
o.type === "mention" ? `@${o.mention_username}` : o.type === "hashtag" ? `#${o.text}` : o.text;
|
||||
const fontPx = Math.max(10, o.font_size * boxSize.height);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => handlePointerDown(e, index)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${o.x * 100}%`,
|
||||
top: `${o.y * 100}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${o.rotation ?? 0}deg)`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: `${fontPx * 0.3}px ${fontPx * 0.55}px`,
|
||||
borderRadius: 999,
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
color: "#fff",
|
||||
fontSize: fontPx * 0.8,
|
||||
fontWeight: 700,
|
||||
maxWidth: "80%",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
cursor: "move",
|
||||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
outline:
|
||||
selected === index ? "2px dashed rgba(255,255,255,0.7)" : "none",
|
||||
outlineOffset: 4,
|
||||
}}
|
||||
>
|
||||
<BoldIcon name={iconName} size={fontPx * 0.7} tinted className="text-white" />
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (o.type === "sticker") {
|
||||
const emojiPx = Math.max(20, o.font_size * boxSize.height);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => handlePointerDown(e, index)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${o.x * 100}%`,
|
||||
top: `${o.y * 100}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${o.rotation ?? 0}deg)`,
|
||||
fontSize: `${emojiPx}px`,
|
||||
lineHeight: 1,
|
||||
cursor: "move",
|
||||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
outline:
|
||||
selected === index ? "2px dashed rgba(255,255,255,0.7)" : "none",
|
||||
outlineOffset: 4,
|
||||
}}
|
||||
>
|
||||
{o.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fontPx = Math.max(
|
||||
10,
|
||||
o.font_size * (containerSize.width || 300)
|
||||
o.font_size * boxSize.height
|
||||
);
|
||||
return (
|
||||
<div
|
||||
@@ -269,7 +441,8 @@ export default function StoryEditor({
|
||||
color: o.color,
|
||||
fontSize: `${fontPx}px`,
|
||||
lineHeight: 1.2,
|
||||
fontWeight: 700,
|
||||
fontFamily: storyFont.style.fontFamily,
|
||||
fontWeight: STORY_FONT_WEIGHTS[o.font_family ?? "bold"],
|
||||
textAlign: (o.align ?? "center") as React.CSSProperties["textAlign"],
|
||||
background: o.background || "transparent",
|
||||
padding: o.background ? "0.15em 0.35em" : 0,
|
||||
@@ -317,6 +490,50 @@ export default function StoryEditor({
|
||||
<BoldIcon name="link" size={16} tinted className="text-white" />
|
||||
{t("stories.attachLink")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMentionPickerOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1 rounded-full bg-black/55 px-3 py-1.5 text-xs font-semibold text-white backdrop-blur"
|
||||
>
|
||||
<BoldIcon name="tag-user" size={16} tinted className="text-white" />
|
||||
{t("stories.mention")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openStickerModal("hashtag");
|
||||
}}
|
||||
className="flex items-center gap-1 rounded-full bg-black/55 px-3 py-1.5 text-xs font-semibold text-white backdrop-blur"
|
||||
>
|
||||
<BoldIcon name="hashtag" size={16} tinted className="text-white" />
|
||||
{t("stories.hashtag")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openStickerModal("location");
|
||||
}}
|
||||
className="flex items-center gap-1 rounded-full bg-black/55 px-3 py-1.5 text-xs font-semibold text-white backdrop-blur"
|
||||
>
|
||||
<BoldIcon name="location" size={16} tinted className="text-white" />
|
||||
{t("stories.location")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEmojiPickerOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1 rounded-full bg-black/55 px-3 py-1.5 text-xs font-semibold text-white backdrop-blur"
|
||||
>
|
||||
<BoldIcon name="emoji-happy" size={16} tinted className="text-white" />
|
||||
{t("stories.sticker")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -324,6 +541,76 @@ export default function StoryEditor({
|
||||
<StoryLinkPicker onSelect={addLinkOverlay} onClose={() => setLinkPickerOpen(false)} />
|
||||
)}
|
||||
|
||||
{mentionPickerOpen && (
|
||||
<StoryMentionPicker
|
||||
onSelect={addMentionOverlay}
|
||||
onClose={() => setMentionPickerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{stickerModal && (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-end justify-center bg-black/60"
|
||||
onClick={() => setStickerModal(null)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-t-3xl bg-white p-4 dark:bg-neutral-900"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="mb-3 text-center text-sm font-semibold text-neutral-700 dark:text-neutral-200">
|
||||
{stickerModal === "hashtag" ? t("stories.addHashtag") : t("stories.addLocation")}
|
||||
</p>
|
||||
<input
|
||||
value={stickerDraft}
|
||||
onChange={(e) => setStickerDraft(e.target.value)}
|
||||
placeholder={
|
||||
stickerModal === "hashtag"
|
||||
? t("stories.hashtagPlaceholder")
|
||||
: t("stories.locationPlaceholder")
|
||||
}
|
||||
maxLength={stickerModal === "hashtag" ? 60 : 100}
|
||||
className="w-full rounded-full bg-neutral-100 px-4 py-2.5 text-sm outline-none dark:bg-neutral-800"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={commitSticker}
|
||||
className="mt-3 w-full rounded-full bg-[#0095f6] py-2.5 text-sm font-semibold text-white"
|
||||
>
|
||||
{t("stories.confirm")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{emojiPickerOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-end justify-center bg-black/60"
|
||||
onClick={() => setEmojiPickerOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-t-3xl bg-white p-4 dark:bg-neutral-900"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="mb-3 text-center text-sm font-semibold text-neutral-700 dark:text-neutral-200">
|
||||
{t("stories.addSticker")}
|
||||
</p>
|
||||
<div className="grid grid-cols-8 gap-2">
|
||||
{STORY_EMOJI_PRESETS.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
onClick={() => addEmojiOverlay(emoji)}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl bg-neutral-100 text-2xl dark:bg-neutral-800"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toolbar for selected overlay */}
|
||||
{selectedOverlay && selectedOverlay.type === "link" && (
|
||||
<div className="mx-auto flex w-full max-w-[340px] items-center justify-between rounded-2xl border border-neutral-200 p-3 dark:border-neutral-800">
|
||||
@@ -340,7 +627,47 @@ export default function StoryEditor({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedOverlay && selectedOverlay.type !== "link" && (
|
||||
{selectedOverlay &&
|
||||
(selectedOverlay.type === "mention" ||
|
||||
selectedOverlay.type === "hashtag" ||
|
||||
selectedOverlay.type === "location" ||
|
||||
selectedOverlay.type === "sticker") && (
|
||||
<div className="mx-auto w-full max-w-[340px] space-y-3 rounded-2xl border border-neutral-200 p-3 dark:border-neutral-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-neutral-500">
|
||||
{selectedOverlay.type === "mention"
|
||||
? `@${selectedOverlay.mention_username}`
|
||||
: selectedOverlay.type === "hashtag"
|
||||
? `#${selectedOverlay.text}`
|
||||
: selectedOverlay.text}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selected !== null && removeOverlay(selected)}
|
||||
className="rounded-md bg-red-50 px-2 py-1 text-xs text-red-600 dark:bg-red-950/40"
|
||||
>
|
||||
{t("stories.remove")}
|
||||
</button>
|
||||
</div>
|
||||
<label className="flex items-center gap-3 text-xs text-neutral-500">
|
||||
{t("stories.fontSize")}
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_FONT}
|
||||
max={MAX_FONT}
|
||||
step={0.005}
|
||||
value={selectedOverlay.font_size}
|
||||
onChange={(e) =>
|
||||
selected !== null &&
|
||||
updateOverlay(selected, { font_size: parseFloat(e.target.value) })
|
||||
}
|
||||
className="flex-1 accent-[#0095f6]"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedOverlay && (!selectedOverlay.type || selectedOverlay.type === "text") && (
|
||||
<div className="mx-auto w-full max-w-[340px] space-y-3 rounded-2xl border border-neutral-200 p-3 dark:border-neutral-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-neutral-500">
|
||||
@@ -416,6 +743,35 @@ export default function StoryEditor({
|
||||
className="flex-1 accent-[#0095f6]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Font family (weight) */}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-500">
|
||||
<span>{t("stories.fontFamily")}</span>
|
||||
{STORY_FONT_FAMILIES.map((f) => {
|
||||
const active = (selectedOverlay.font_family ?? "bold") === f.value;
|
||||
return (
|
||||
<button
|
||||
key={f.value}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
selected !== null &&
|
||||
updateOverlay(selected, { font_family: f.value })
|
||||
}
|
||||
className={`rounded-md border px-2.5 py-1 ${
|
||||
active
|
||||
? "border-[#0095f6] bg-[#0095f6]/10 text-[#0095f6]"
|
||||
: "border-neutral-300 dark:border-neutral-600"
|
||||
}`}
|
||||
style={{
|
||||
fontFamily: storyFont.style.fontFamily,
|
||||
fontWeight: STORY_FONT_WEIGHTS[f.value],
|
||||
}}
|
||||
>
|
||||
{t(`stories.fontFamilyOptions.${f.value}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
116
src/components/stories/StoryMentionPicker.tsx
Normal file
116
src/components/stories/StoryMentionPicker.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export type MentionUser = {
|
||||
_id: string;
|
||||
user_name: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
profile_image?: string;
|
||||
};
|
||||
|
||||
function avatarSrc(profileImage?: string) {
|
||||
if (!profileImage) return null;
|
||||
return profileImage.startsWith("http")
|
||||
? profileImage
|
||||
: buildStorageUrl(profileImage);
|
||||
}
|
||||
|
||||
function displayName(u: MentionUser) {
|
||||
return [u.first_name, u.last_name].filter(Boolean).join(" ") || u.user_name;
|
||||
}
|
||||
|
||||
// عین TagUsersPicker.tsx (همون endpoint /search-web) ولی تکانتخابی — برای انداختن یه
|
||||
// منشن بهعنوان استیکر روی استوری، نه تگکردن چندنفرهی پست
|
||||
export default function StoryMentionPicker({
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
onSelect: (user: MentionUser) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<MentionUser[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (query.trim().length < 2) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(async () => {
|
||||
setSearching(true);
|
||||
try {
|
||||
const res = await request<{ result?: MentionUser[] }>(
|
||||
"GET",
|
||||
`/search-web?search=${encodeURIComponent(query.trim())}&type=user&limit=20`,
|
||||
null,
|
||||
{ noToast: true }
|
||||
);
|
||||
setResults(res?.result || []);
|
||||
} catch {
|
||||
setResults([]);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query, request]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-end justify-center bg-black/60" onClick={onClose}>
|
||||
<div
|
||||
className="max-h-[70vh] w-full max-w-md overflow-y-auto rounded-t-3xl bg-white p-4 dark:bg-neutral-900"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="mb-3 text-center text-sm font-semibold text-neutral-700 dark:text-neutral-200">
|
||||
{t("stories.mentionSomeone")}
|
||||
</p>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("posts.searchUsersPlaceholder")}
|
||||
className="w-full rounded-full bg-neutral-100 px-4 py-2.5 text-sm outline-none dark:bg-neutral-800"
|
||||
autoFocus
|
||||
/>
|
||||
{searching && (
|
||||
<p className="mt-2 text-center text-xs text-neutral-400">{t("posts.searching")}</p>
|
||||
)}
|
||||
{!searching && query.trim().length >= 2 && results.length === 0 && (
|
||||
<p className="py-6 text-center text-xs text-neutral-400">{t("posts.noUsersFound")}</p>
|
||||
)}
|
||||
<ul className="mt-2 space-y-1">
|
||||
{results.map((u) => {
|
||||
const src = avatarSrc(u.profile_image);
|
||||
return (
|
||||
<li key={u._id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(u)}
|
||||
className="flex w-full items-center gap-3 rounded-2xl px-2 py-2 text-right transition hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-full bg-neutral-200">
|
||||
{src ? (
|
||||
<Image src={src} alt="" fill className="object-cover" unoptimized />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-right">
|
||||
<p className="truncate text-sm font-medium">{displayName(u)}</p>
|
||||
<p className="truncate text-xs text-neutral-500">@{u.user_name}</p>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,13 @@ import React, {
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import toast from "react-hot-toast";
|
||||
import { StoryFeedUser, StoryOverlay, StoryViewerUser } from "@/api/fetchStories";
|
||||
import {
|
||||
sanitizeOverlaysForEditing,
|
||||
StoryFeedUser,
|
||||
StoryOverlay,
|
||||
StoryViewerUser,
|
||||
} from "@/api/fetchStories";
|
||||
import { storyFont, STORY_FONT_WEIGHTS } from "@/lib/storyFonts";
|
||||
import { buildPostLinkPath, PostLinkKind } from "@/lib/postLinkCaption";
|
||||
|
||||
const LINK_KIND_ICONS: Record<PostLinkKind, string> = {
|
||||
@@ -74,7 +80,13 @@ export default function StoryViewer({
|
||||
const [loadingViewers, setLoadingViewers] = useState(false);
|
||||
const [followingMap, setFollowingMap] = useState<Record<string, boolean>>({});
|
||||
const [followLoading, setFollowLoading] = useState(false);
|
||||
const [mediaWidth, setMediaWidth] = useState(0);
|
||||
// اندازهی کادرِ در دسترس (خودِ mediaBoxRef، که تمامِ فضای موجود رو میگیره)
|
||||
const [availableSize, setAvailableSize] = useState({ width: 0, height: 0 });
|
||||
// نسبت واقعیِ ارتفاع/عرض خودِ عکس/ویدیو — چون <Image fill object-contain> داخلِ
|
||||
// availableSize letterbox میشه، مختصاتِ نسبیِ اورلیها (که توی StoryEditor.tsx هم به
|
||||
// همین نسبتِ واقعی نرمالایز شدن) باید نسبت به تصویرِ واقعاً نمایشدادهشده حساب شن، نه
|
||||
// کل کادرِ letterboxشده — وگرنه جا/اندازهشون با ادیتور فرق میکرد (گزارش کاربر)
|
||||
const [mediaAspect, setMediaAspect] = useState(16 / 9);
|
||||
const [sessionViewedIds, setSessionViewedIds] = useState<Set<string>>(
|
||||
() => new Set()
|
||||
);
|
||||
@@ -149,13 +161,41 @@ export default function StoryViewer({
|
||||
useLayoutEffect(() => {
|
||||
const el = mediaBoxRef.current;
|
||||
if (!el) return;
|
||||
const update = () => setMediaWidth(el.clientWidth);
|
||||
const update = () =>
|
||||
setAvailableSize({ width: el.clientWidth, height: el.clientHeight });
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// نسبتِ واقعیِ عکس/ویدیوِ استوریِ جاری رو تا نگرفتیم، فرض نکنیم قبلی هنوز درسته — یه
|
||||
// پیشفرضِ خنثی (۹:۱۶) بگیریم تا بارگذاریِ رسانهی جدید مقدارِ استوریِ قبلی رو نگه نداره
|
||||
useEffect(() => {
|
||||
setMediaAspect(16 / 9);
|
||||
}, [currentStory?._id]);
|
||||
|
||||
// بوم واقعیِ نمایشدادهشده: contain-fit نسبتِ واقعیِ عکس داخلِ فضای در دسترس — دقیقاً
|
||||
// همون منطقِ StoryOverlayEditor.tsx (که اینبار نسبتها ازش میان) و mediaBox توی
|
||||
// اندروید (StoryViewerModal.tsx)
|
||||
const mediaBox = (() => {
|
||||
const availW = availableSize.width;
|
||||
const availH = availableSize.height;
|
||||
if (!availW || !availH) return { width: 0, height: 0, offsetX: 0, offsetY: 0 };
|
||||
let boxW = availW;
|
||||
let boxH = boxW * mediaAspect;
|
||||
if (boxH > availH) {
|
||||
boxH = availH;
|
||||
boxW = boxH / mediaAspect;
|
||||
}
|
||||
return {
|
||||
width: boxW,
|
||||
height: boxH,
|
||||
offsetX: (availW - boxW) / 2,
|
||||
offsetY: (availH - boxH) / 2,
|
||||
};
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentStory || paused || editing) return;
|
||||
|
||||
@@ -325,7 +365,7 @@ export default function StoryViewer({
|
||||
};
|
||||
|
||||
const openEditor = () => {
|
||||
setDraftOverlays(currentOverlays.map((o) => ({ ...o })));
|
||||
setDraftOverlays(sanitizeOverlaysForEditing(currentOverlays));
|
||||
setEditing(true);
|
||||
setPaused(true);
|
||||
if (videoRef.current) videoRef.current.pause();
|
||||
@@ -462,6 +502,12 @@ export default function StoryViewer({
|
||||
className="h-full w-full object-contain"
|
||||
playsInline
|
||||
muted={false}
|
||||
onLoadedMetadata={(e) => {
|
||||
const v = e.currentTarget;
|
||||
if (v.videoWidth > 0 && v.videoHeight > 0) {
|
||||
setMediaAspect(v.videoHeight / v.videoWidth);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
@@ -471,6 +517,12 @@ export default function StoryViewer({
|
||||
className="object-contain"
|
||||
priority
|
||||
unoptimized
|
||||
onLoad={(e) => {
|
||||
const img = e.currentTarget;
|
||||
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||||
setMediaAspect(img.naturalHeight / img.naturalWidth);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -488,8 +540,8 @@ export default function StoryViewer({
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${o.x * 100}%`,
|
||||
top: `${o.y * 100}%`,
|
||||
left: mediaBox.offsetX + o.x * mediaBox.width,
|
||||
top: mediaBox.offsetY + o.y * mediaBox.height,
|
||||
transform: `translate(-50%, -50%) rotate(${o.rotation ?? 0}deg)`,
|
||||
zIndex: 20,
|
||||
pointerEvents: "auto",
|
||||
@@ -513,18 +565,114 @@ export default function StoryViewer({
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (o.type === "mention" || o.type === "hashtag") {
|
||||
const href =
|
||||
o.type === "mention"
|
||||
? `/users/${encodeURIComponent(o.mention_username)}`
|
||||
: `/?hashtag=${encodeURIComponent(o.text)}`;
|
||||
const label = o.type === "mention" ? `@${o.mention_username}` : `#${o.text}`;
|
||||
const iconName = o.type === "mention" ? "tag-user" : "hashtag";
|
||||
const fontPx = Math.max(10, o.font_size * (mediaBox.height || 700));
|
||||
return (
|
||||
<Link
|
||||
key={idx}
|
||||
href={href}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: mediaBox.offsetX + o.x * mediaBox.width,
|
||||
top: mediaBox.offsetY + o.y * mediaBox.height,
|
||||
transform: `translate(-50%, -50%) rotate(${o.rotation ?? 0}deg)`,
|
||||
zIndex: 20,
|
||||
pointerEvents: "auto",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: `${fontPx * 0.3}px ${fontPx * 0.55}px`,
|
||||
borderRadius: 999,
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
color: "#fff",
|
||||
fontSize: fontPx * 0.8,
|
||||
fontWeight: 700,
|
||||
maxWidth: "80%",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
<BoldIcon name={iconName} size={fontPx * 0.7} tinted className="text-white" />
|
||||
<span className="truncate">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (o.type === "location") {
|
||||
const fontPx = Math.max(10, o.font_size * (mediaBox.height || 700));
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: mediaBox.offsetX + o.x * mediaBox.width,
|
||||
top: mediaBox.offsetY + o.y * mediaBox.height,
|
||||
transform: `translate(-50%, -50%) rotate(${o.rotation ?? 0}deg)`,
|
||||
pointerEvents: "none",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: `${fontPx * 0.3}px ${fontPx * 0.55}px`,
|
||||
borderRadius: 999,
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
color: "#fff",
|
||||
fontSize: fontPx * 0.8,
|
||||
fontWeight: 700,
|
||||
maxWidth: "80%",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
<BoldIcon name="location" size={fontPx * 0.7} tinted className="text-white" />
|
||||
<span className="truncate">{o.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (o.type === "sticker") {
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: mediaBox.offsetX + o.x * mediaBox.width,
|
||||
top: mediaBox.offsetY + o.y * mediaBox.height,
|
||||
transform: `translate(-50%, -50%) rotate(${o.rotation ?? 0}deg)`,
|
||||
fontSize: `${Math.max(20, o.font_size * (mediaBox.height || 700))}px`,
|
||||
lineHeight: 1,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{o.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${o.x * 100}%`,
|
||||
top: `${o.y * 100}%`,
|
||||
left: mediaBox.offsetX + o.x * mediaBox.width,
|
||||
top: mediaBox.offsetY + o.y * mediaBox.height,
|
||||
transform: `translate(-50%, -50%) rotate(${o.rotation ?? 0}deg)`,
|
||||
color: o.color,
|
||||
fontSize: `${Math.max(10, o.font_size * (mediaWidth || 380))}px`,
|
||||
fontSize: `${Math.max(10, o.font_size * (mediaBox.height || 700))}px`,
|
||||
lineHeight: 1.2,
|
||||
fontWeight: 700,
|
||||
fontFamily: storyFont.style.fontFamily,
|
||||
fontWeight: STORY_FONT_WEIGHTS[o.font_family ?? "bold"],
|
||||
textAlign: (o.align ?? "center") as React.CSSProperties["textAlign"],
|
||||
background: o.background || "transparent",
|
||||
padding: o.background ? "0.15em 0.35em" : 0,
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarHeader,
|
||||
SidebarProvider,
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
|
||||
101
src/components/ui/MultiSelectDropdown.tsx
Normal file
101
src/components/ui/MultiSelectDropdown.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
|
||||
|
||||
export type MultiSelectOption = { value: string; label: string };
|
||||
|
||||
type MultiSelectDropdownProps = {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
value: string[];
|
||||
options: MultiSelectOption[];
|
||||
onChange: (value: string[]) => void;
|
||||
/** حداکثر تعداد قابل انتخاب — بعد از رسیدن به این عدد، انتخاب گزینهی جدید مسدود میشود. */
|
||||
max?: number;
|
||||
/** پیامی که وقتی به حداکثر تعداد رسیده باشد نمایش داده میشود، مثلاً «حداکثر ۲ مورد قابل انتخاب است». */
|
||||
maxHint?: string;
|
||||
/** برچسبی که وقتی حداقل یک مورد انتخاب شده باشد، بهجای placeholder نمایش داده میشود (شامل تعداد). */
|
||||
formatSelectedSummary?: (count: number) => string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** دراپداون/پاپآور چندانتخابی — دکمهای که با کلیک باز میشود و لیستی از چیپهای
|
||||
* قابلتاگل را نشان میدهد (با همون استایل toggleBtnClass که در بقیهی اپ برای
|
||||
* چندانتخابیها مثل زیرتخصص استفاده میشود)؛ وقتی بسته است، تعداد انتخابشدهها را نشان میدهد. */
|
||||
export default function MultiSelectDropdown({
|
||||
label,
|
||||
placeholder,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
max,
|
||||
maxHint,
|
||||
formatSelectedSummary,
|
||||
className,
|
||||
}: MultiSelectDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const limitReached = typeof max === "number" && value.length >= max;
|
||||
const summary =
|
||||
value.length > 0
|
||||
? formatSelectedSummary
|
||||
? formatSelectedSummary(value.length)
|
||||
: String(value.length)
|
||||
: placeholder;
|
||||
|
||||
const toggle = (optValue: string) => {
|
||||
const isSelected = value.includes(optValue);
|
||||
if (isSelected) {
|
||||
onChange(value.filter((v) => v !== optValue));
|
||||
return;
|
||||
}
|
||||
if (limitReached) return;
|
||||
onChange([...value, optValue]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("w-full max-w-sm", className)}>
|
||||
<p className="mb-1.5 text-center text-xs text-neutral-500">{label}</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
className="flex w-full items-center justify-between gap-2 rounded-2xl border border-[#C3C3C3] bg-white/80 px-4 py-2.5 text-sm dark:border-neutral-600 dark:bg-neutral-900/80"
|
||||
>
|
||||
<span className={cn("truncate", value.length === 0 && "text-neutral-400")}>
|
||||
{summary}
|
||||
</span>
|
||||
<span className="shrink-0 text-neutral-400">{open ? "▲" : "▼"}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="mt-2 max-h-64 overflow-y-auto overscroll-contain rounded-2xl border border-neutral-200 bg-white/90 p-3 dark:border-neutral-700 dark:bg-neutral-900/90">
|
||||
{limitReached && maxHint && (
|
||||
<p className="mb-2 text-center text-[11px] text-amber-500">{maxHint}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{options.map((opt) => {
|
||||
const isSelected = value.includes(opt.value);
|
||||
const disabled = !isSelected && limitReached;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
toggleBtnClass(isSelected, "h-9 text-xs"),
|
||||
disabled && "opacity-40"
|
||||
)}
|
||||
onClick={() => toggle(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
src/components/ui/RoundedSelect.tsx
Normal file
43
src/components/ui/RoundedSelect.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type RoundedSelectOption = { value: string; label: string };
|
||||
|
||||
type RoundedSelectProps = {
|
||||
label: string;
|
||||
placeholder: string;
|
||||
value: string;
|
||||
options: RoundedSelectOption[];
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** یک `<select>` بومی با ظاهر یکدست با بقیهی دراپداونهای اپ (مثل کشور/استان/شهر) —
|
||||
* برای فیلدهای تکانتخابی جدید مشخصات فردی و فیلتر یافتن همکار. */
|
||||
export default function RoundedSelect({
|
||||
label,
|
||||
placeholder,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
className,
|
||||
}: RoundedSelectProps) {
|
||||
return (
|
||||
<div className={cn("w-full max-w-sm", className)}>
|
||||
<p className="mb-1.5 text-center text-xs text-neutral-500">{label}</p>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full rounded-2xl border border-[#C3C3C3] bg-white/80 px-4 py-2.5 text-sm outline-none dark:border-neutral-600 dark:bg-neutral-900/80"
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
|
||||
interface Cart {
|
||||
@@ -15,7 +14,6 @@ interface Cart {
|
||||
}
|
||||
|
||||
export default function ProductCart(props: Cart) {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<div
|
||||
className={`${props.style} max-tablet-lg:justify-start max-tablet-lg:px-4 max-tablet-lg:py-2 max-tablet-lg:flex-row max-tablet-lg:w-full max-tablet-lg:max-w-full max-tablet-lg:h-auto w-[240px] max-laptop-xl:w-[210px] max-laptop-xl:h-[330px] cursor-pointer bg-white flex flex-col justify-between py-1 max-tablet-lg:border-b max-tablet-lg:shadow-none max-tablet-lg:border-0 items-center max-w-[310px] h-[370px] max-h-[459px] border-2 border-neutral-300`}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
const TabsList = TabsPrimitive.List;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -168,7 +168,7 @@ export function usePostSocialState(postData: Post) {
|
||||
is_saved: resolvedSaved,
|
||||
savesCount: response?.savesCount ?? newCount,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
} catch {
|
||||
toast.error(t("posts.bookmarkError"));
|
||||
setSaved(!newSaved);
|
||||
setSavesCount(savesCount);
|
||||
|
||||
34
src/lib/analytics/parseUserAgent.ts
Normal file
34
src/lib/analytics/parseUserAgent.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// پارسر سبک UA — بدون وابستگی خارجی؛ فقط چیزهایی که برای گزارش دستگاه/مرورگر/سیستمعامل
|
||||
// توی آنالیتیکس لازمه (نه یک UA-parser کامل)
|
||||
export type ParsedUserAgent = {
|
||||
device: string;
|
||||
browser: string;
|
||||
os: string;
|
||||
};
|
||||
|
||||
export function parseUserAgent(ua: string): ParsedUserAgent {
|
||||
const s = ua || "";
|
||||
|
||||
let os = "Other";
|
||||
if (/windows/i.test(s)) os = "Windows";
|
||||
else if (/iphone|ipad|ipod/i.test(s)) os = "iOS";
|
||||
else if (/android/i.test(s)) os = "Android";
|
||||
else if (/mac os x/i.test(s)) os = "macOS";
|
||||
else if (/linux/i.test(s)) os = "Linux";
|
||||
|
||||
let browser = "Other";
|
||||
if (/edg\//i.test(s)) browser = "Edge";
|
||||
else if (/samsungbrowser/i.test(s)) browser = "Samsung Internet";
|
||||
else if (/firefox\//i.test(s)) browser = "Firefox";
|
||||
else if (/crios\//i.test(s)) browser = "Chrome (iOS)";
|
||||
else if (/fxios\//i.test(s)) browser = "Firefox (iOS)";
|
||||
else if (/chrome\//i.test(s)) browser = "Chrome";
|
||||
else if (/version\/.+safari/i.test(s)) browser = "Safari";
|
||||
else if (/safari\//i.test(s)) browser = "Safari";
|
||||
|
||||
let device = "Desktop";
|
||||
if (/ipad|tablet/i.test(s)) device = "Tablet";
|
||||
else if (/mobile|iphone|android/i.test(s)) device = "Mobile";
|
||||
|
||||
return { device, browser, os };
|
||||
}
|
||||
@@ -83,10 +83,6 @@ export function buildAcademyListSeo(
|
||||
) {
|
||||
const academyPage = getSeoPage("academy", lang);
|
||||
const templates = getListSeoTemplates(lang).academy as Record<string, string>;
|
||||
const sortLabels = getListSeoTemplates(lang).academy.sort as Record<
|
||||
string,
|
||||
string
|
||||
>;
|
||||
|
||||
const hasFilters = Boolean(
|
||||
filters.category ||
|
||||
|
||||
@@ -218,6 +218,73 @@ export async function unwrapIdentityKeyForDevice(
|
||||
return JSON.parse(new TextDecoder().decode(pt)) as JsonWebKey;
|
||||
}
|
||||
|
||||
// بکآپِ کلیدِ خصوصی با PIN/رمزِ انتخابیِ کاربر — عین Signal: کلیدِ رمزنگاری از خودِ
|
||||
// PIN با PBKDF2 (تعداد تکرارِ بالا، چون PIN معمولاً کوتاهه و در برابر brute-force
|
||||
// نیاز به کندسازیِ عمدی داره) مشتق میشه؛ سرور فقط بلاکِ رمزنگاریشده رو میبینه
|
||||
const BACKUP_PBKDF2_ITERATIONS = 250000;
|
||||
|
||||
async function deriveBackupKey(
|
||||
pin: string,
|
||||
salt: Uint8Array
|
||||
): Promise<CryptoKey> {
|
||||
const pinKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(pin),
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveKey"]
|
||||
);
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt: toArrayBuffer(salt),
|
||||
iterations: BACKUP_PBKDF2_ITERATIONS,
|
||||
hash: "SHA-256",
|
||||
},
|
||||
pinKey,
|
||||
{ name: "AES-GCM", length: AES_BITS },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
}
|
||||
|
||||
export type KeyBackupPayload = {
|
||||
salt: string;
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
iterations: number;
|
||||
};
|
||||
|
||||
export async function encryptPrivateKeyForBackup(
|
||||
privateJwk: JsonWebKey,
|
||||
pin: string
|
||||
): Promise<KeyBackupPayload> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const key = await deriveBackupKey(pin, salt);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const plaintext = new TextEncoder().encode(JSON.stringify(privateJwk));
|
||||
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, plaintext);
|
||||
return {
|
||||
salt: bufToB64(salt),
|
||||
iv: bufToB64(iv),
|
||||
ciphertext: bufToB64(ct),
|
||||
iterations: BACKUP_PBKDF2_ITERATIONS,
|
||||
};
|
||||
}
|
||||
|
||||
/** PIN اشتباه → decrypt با AES-GCM شکست میخوره (تگِ احرازهویت جور درنمیاد) و throw میکنه */
|
||||
export async function decryptPrivateKeyFromBackup(
|
||||
backup: KeyBackupPayload,
|
||||
pin: string
|
||||
): Promise<JsonWebKey> {
|
||||
const salt = b64ToBuf(backup.salt);
|
||||
const key = await deriveBackupKey(pin, new Uint8Array(salt));
|
||||
const iv = b64ToBuf(backup.iv);
|
||||
const ct = b64ToBuf(backup.ciphertext);
|
||||
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct);
|
||||
return JSON.parse(new TextDecoder().decode(pt)) as JsonWebKey;
|
||||
}
|
||||
|
||||
export async function unwrapRoomKey(
|
||||
wrappedB64: string,
|
||||
myPrivateKey: CryptoKey
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { deriveDmAesKey, importPublicKeySpkiB64 } from "./crypto";
|
||||
import {
|
||||
decryptText,
|
||||
deriveDmAesKey,
|
||||
encryptText,
|
||||
importPublicKeySpkiB64,
|
||||
} from "./crypto";
|
||||
import { E2EE_LOCKED_PREVIEW, isE2eeEnvelope } from "./envelope";
|
||||
import { ensureIdentityKeys, fetchPeerPublicKey } from "./keys";
|
||||
|
||||
@@ -73,17 +78,24 @@ export function clearDmKeyCache(): void {
|
||||
dmKeyCache.clear();
|
||||
}
|
||||
|
||||
// E2EE موقتاً کاملاً غیرفعال شده (کلیدهای محلی که از بین میرفتن — پاکشدنِ
|
||||
// دیتای مرورگر، نصبِ مجددِ اپ، دستگاهِ جدید — رمزگشایی رو برای همیشه میشکوندن،
|
||||
// بدونِ راهِ بازیابی چون سرور خودِ متن رو نگه نمیداره). پیامهای جدید همیشه
|
||||
// متنِ ساده ارسال/دریافت میشن؛ توابعِ زیرِ کلید (getDmConversationKey و...)
|
||||
// دستنخورده میمونن چون جاهای دیگهای importشون میکنن، ولی دیگه از اینجا صدا زده نمیشن
|
||||
// دوباره فعال شده — حالا که بکآپِ کلید با PIN (keys.ts: saveKeyBackup/
|
||||
// recoverIdentityFromBackup) و چکِ ایمنِ «هویتِ قبلی روی سرور هست ولی این دستگاه
|
||||
// کلیدِ محلی نداره» (keys.ts: IdentityRecoveryNeededError) قبل از این وجود دارن، یه
|
||||
// کلیدِ محلی هیچوقت بیصدا جایگزینِ یه هویتِ قدیمی نمیشه. اگه کلیدِ طرفِ مقابل در
|
||||
// دسترس نباشه (fail شدنِ getDmConversationKey)، متنِ ساده ارسال میشه — بهتر از
|
||||
// شکستِ کاملِ ارسالِ پیام، ولی این حالت باید نادر باشه
|
||||
export async function encryptDmContent(
|
||||
myUserId: string,
|
||||
peerUserId: string,
|
||||
plaintext: string
|
||||
): Promise<string | null> {
|
||||
return plaintext;
|
||||
const key = await getDmConversationKey(myUserId, peerUserId);
|
||||
if (!key) return plaintext;
|
||||
try {
|
||||
return encryptText(key, plaintext);
|
||||
} catch {
|
||||
return plaintext;
|
||||
}
|
||||
}
|
||||
|
||||
export async function decryptDmContent(
|
||||
@@ -92,11 +104,24 @@ export async function decryptDmContent(
|
||||
content: string
|
||||
): Promise<string> {
|
||||
const raw = normalizeMessageContent(content);
|
||||
// فقط پیامِ قدیمیِ رمزنگاریشده (از قبلِ غیرفعالسازیِ E2EE) به اینجا میرسه —
|
||||
// چون کلیدِ لازم ممکنه دیگه در دسترس نباشه، بهجایِ تلاشِ بیفایده برای
|
||||
// رمزگشایی، همونجا پیامِ روشن نشون میدیم
|
||||
if (isE2eeEnvelope(raw)) return E2EE_LOCKED_PREVIEW;
|
||||
return raw;
|
||||
if (!isE2eeEnvelope(raw)) return raw;
|
||||
|
||||
const key = await getDmConversationKey(myUserId, peerUserId);
|
||||
if (key) {
|
||||
const plain = await decryptText(key, raw);
|
||||
if (plain != null) return plain;
|
||||
}
|
||||
|
||||
// شکستِ رمزگشایی با کلیدِ کششده (یا نبودِ کلید) میتونه یعنی طرفِ مقابل وسطِ سشن
|
||||
// هویتش رو عوض کرده (دستگاه جدید/بازیابی) و ما هنوز کلیدِ عمومیِ قدیمیشون رو کش
|
||||
// کرده بودیم — قبل از نمایشِ «پیام رمزنگاریشده»، یهبار با کلیدِ عمومیِ کاملاً
|
||||
// تازهواکشیشده دوباره امتحان کن (forceFresh)؛ این باعث میشه بعد از اینجور تغییرِ
|
||||
// کلید، چت خودش رو ترمیم کنه بهجای اینکه تا رفرشِ صفحه قفل بمونه
|
||||
const freshKey = await getDmConversationKey(myUserId, peerUserId, true);
|
||||
if (!freshKey) return E2EE_LOCKED_PREVIEW;
|
||||
|
||||
const plain = await decryptText(freshKey, raw);
|
||||
return plain ?? E2EE_LOCKED_PREVIEW;
|
||||
}
|
||||
|
||||
export async function decryptDmMessageList<
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
export { ensureIdentityKeys, fetchPeerPublicKey } from "./keys";
|
||||
export {
|
||||
ensureIdentityKeys,
|
||||
fetchPeerPublicKey,
|
||||
fetchMyKeyBackup,
|
||||
saveKeyBackup,
|
||||
recoverIdentityFromBackup,
|
||||
createFreshIdentity,
|
||||
IdentityRecoveryNeededError,
|
||||
} from "./keys";
|
||||
export {
|
||||
encryptDmContent,
|
||||
decryptDmContent,
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import Cookies from "js-cookie";
|
||||
import { getApiBaseUrl } from "@/components/main/BaseUrl";
|
||||
import {
|
||||
decryptPrivateKeyFromBackup,
|
||||
encryptPrivateKeyForBackup,
|
||||
exportPrivateKeyJwk,
|
||||
exportPublicKeySpkiB64,
|
||||
generateIdentityKeyPair,
|
||||
importPrivateKeyJwk,
|
||||
importPublicKeySpkiB64,
|
||||
type KeyBackupPayload,
|
||||
} from "./crypto";
|
||||
import { idbGet, idbSet } from "./storage";
|
||||
|
||||
@@ -47,6 +50,97 @@ async function publishPublicKey(publicKeyB64: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* رمزِ خودکارِ per-account (نه PIN دستیِ کاربر) — با یه درخواستِ احرازهویتشده به همین
|
||||
* اکانت از هر دستگاهی قابلدریافته (سرور اولینبار خودش میسازتش). بهعنوانِ رمزِ
|
||||
* رمزنگاریِ بکآپِ کلید استفاده میشه تا بازیابیِ هویت روی دستگاهِ جدید کاملاً خودکار
|
||||
* انجام بشه — بدونِ اینکه کاربر چیزی وارد کنه. توجه: این یعنی end-to-end واقعی دیگه
|
||||
* برقرار نیست (سرور میتونه این رمز رو به بکآپِ خودش بده و کلید خصوصی رو دربیاره) —
|
||||
* این تصمیمِ آگاهانهایه که به نفعِ هیچوقتگمنشدنِ پیامها گرفته شده
|
||||
*/
|
||||
async function fetchAutoBackupSecret(): Promise<string | null> {
|
||||
try {
|
||||
const base = getApiBaseUrl();
|
||||
const res = await fetch(`${base}/account/e2ee/auto-backup-secret`, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as { secret?: string | null };
|
||||
return data.secret || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** بکآپِ خودکار رو با رمزِ per-account (نه PIN) میسازه/بهروز میکنه — بیصدا و best-effort */
|
||||
async function saveAutoKeyBackup(privateJwk: JsonWebKey): Promise<void> {
|
||||
try {
|
||||
const autoSecret = await fetchAutoBackupSecret();
|
||||
if (!autoSecret) return;
|
||||
const payload = await encryptPrivateKeyForBackup(privateJwk, autoSecret);
|
||||
const base = getApiBaseUrl();
|
||||
await fetch(`${base}/account/e2ee/key-backup`, {
|
||||
method: "PUT",
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} catch {
|
||||
/* best-effort — اگه الان شکست بخوره، دفعهی بعدی که ensureIdentityKeys صدا زده بشه دوباره امتحان میشه */
|
||||
}
|
||||
}
|
||||
|
||||
/** تلاش برای بازیابیِ خودکارِ هویت با رمزِ per-account — بدون هیچ ورودیِ کاربر. اگه بکآپ نبود/باز نشد، null */
|
||||
async function tryAutoRecoverIdentity(
|
||||
uid: string,
|
||||
publishedPublicKeyB64: string
|
||||
): Promise<{ privateKey: CryptoKey; publicKeyB64: string } | null> {
|
||||
try {
|
||||
const [autoSecret, backup] = await Promise.all([
|
||||
fetchAutoBackupSecret(),
|
||||
fetchMyKeyBackup(),
|
||||
]);
|
||||
if (!autoSecret || !backup) return null;
|
||||
const privateJwk = await decryptPrivateKeyFromBackup(backup, autoSecret);
|
||||
const privateKey = await importPrivateKeyJwk(privateJwk);
|
||||
return await storeAndAdopt(uid, privateKey, privateJwk, publishedPublicKeyB64);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* پرتاب میشه وقتی روی سرور از قبل یه کلیدِ عمومی برای این کاربر ثبته، ولی این
|
||||
* دستگاه هیچ کلیدِ محلیای نداره — یعنی یه هویتِ قبلی وجود داره که این دستگاه بهش
|
||||
* دسترسی نداره. تولیدِ خودکارِ یه کلیدِ تازه در این حالت یعنی جایگزینیِ هویتِ قدیمی و
|
||||
* غیرقابلبازگشتشدنِ همیشگیِ پیامهای رمزنگاریشدهی قبلی — دقیقاً همون باگی که E2EE رو
|
||||
* موقتاً غیرفعال کرده بود. کدِ صداکننده باید این خطا رو بگیره و یا با
|
||||
* recoverIdentityFromBackup (اگه hasBackup) یا با تاییدِ صریحِ کاربر برای شروعِ یه
|
||||
* هویتِ تازه (createFreshIdentity) ادامه بده — هیچوقت خودکار
|
||||
*/
|
||||
export class IdentityRecoveryNeededError extends Error {
|
||||
hasBackup: boolean;
|
||||
constructor(hasBackup: boolean) {
|
||||
super("E2EE identity recovery needed");
|
||||
this.name = "IdentityRecoveryNeededError";
|
||||
this.hasBackup = hasBackup;
|
||||
}
|
||||
}
|
||||
|
||||
async function storeAndAdopt(
|
||||
uid: string,
|
||||
privateKey: CryptoKey,
|
||||
privateJwk: JsonWebKey,
|
||||
publicKeyB64: string
|
||||
): Promise<{ privateKey: CryptoKey; publicKeyB64: string }> {
|
||||
await idbSet(`identity:${uid}`, {
|
||||
userId: uid,
|
||||
privateJwk,
|
||||
publicKeyB64,
|
||||
} satisfies StoredIdentity);
|
||||
memoryPair = { userId: uid, privateKey, publicKeyB64 };
|
||||
return { privateKey, publicKeyB64 };
|
||||
}
|
||||
|
||||
async function loadOrCreateIdentityKeys(uid: string): Promise<{
|
||||
privateKey: CryptoKey;
|
||||
publicKeyB64: string;
|
||||
@@ -66,25 +160,39 @@ async function loadOrCreateIdentityKeys(uid: string): Promise<{
|
||||
} catch {
|
||||
/* publish best-effort */
|
||||
}
|
||||
// بکآپِ خودکار رو تازه نگه میداره — اگه قبلاً (مثلاً روی نسخهی قدیمیتر) هیچ
|
||||
// بکآپی ساخته نشده بود، همینجا برای اولینبار ساخته میشه، بدون نیاز به PIN کاربر
|
||||
void saveAutoKeyBackup(stored.privateJwk);
|
||||
return { privateKey, publicKeyB64: stored.publicKeyB64 };
|
||||
}
|
||||
|
||||
// هیچ کلیدِ محلیای نیست — قبل از ساختنِ یه هویتِ تازه، مطمئن شو که این واقعاً یه
|
||||
// کاربرِ تازهست، نه یه دستگاهِ جدید/دیتایپاکشده برای یه هویتِ از قبل موجود
|
||||
const existingPublicKey = await fetchPeerPublicKey(uid);
|
||||
if (existingPublicKey) {
|
||||
// قبل از throw کردن (که کاربر رو با کارتِ بازیابی مسدود میکنه)، یهبار خودکار
|
||||
// (با رمزِ per-account، نه PIN) امتحان کن — این دقیقاً همون چیزیه که باعث میشه
|
||||
// لاگین دوباره/دستگاهِ دوم هیچوقت نیازی به دخالتِ کاربر یا گمشدنِ پیام نداشته باشه
|
||||
const recovered = await tryAutoRecoverIdentity(uid, existingPublicKey);
|
||||
if (recovered) return recovered;
|
||||
|
||||
const backup = await fetchMyKeyBackup();
|
||||
throw new IdentityRecoveryNeededError(Boolean(backup));
|
||||
}
|
||||
|
||||
const pair = await generateIdentityKeyPair();
|
||||
const publicKeyB64 = await exportPublicKeySpkiB64(pair.publicKey);
|
||||
const privateJwk = await exportPrivateKeyJwk(pair.privateKey);
|
||||
await idbSet(storeKey, {
|
||||
userId: uid,
|
||||
privateJwk,
|
||||
publicKeyB64,
|
||||
} satisfies StoredIdentity);
|
||||
|
||||
memoryPair = { userId: uid, privateKey: pair.privateKey, publicKeyB64 };
|
||||
const result = await storeAndAdopt(uid, pair.privateKey, privateJwk, publicKeyB64);
|
||||
try {
|
||||
await publishPublicKey(publicKeyB64);
|
||||
} catch {
|
||||
/* publish best-effort */
|
||||
}
|
||||
return { privateKey: pair.privateKey, publicKeyB64 };
|
||||
// هویتِ تازه رو فوراً با رمزِ خودکار بکآپ میگیره — دستگاهِ بعدی که با همین اکانت
|
||||
// وارد بشه، دیگه هیچوقت به این نقطه (existingPublicKey ولی بدون بکآپ) نمیرسه
|
||||
await saveAutoKeyBackup(privateJwk);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function ensureIdentityKeys(userId: string): Promise<{
|
||||
@@ -109,6 +217,80 @@ export async function ensureIdentityKeys(userId: string): Promise<{
|
||||
return task;
|
||||
}
|
||||
|
||||
/** بکآپِ رمزنگاریشدهی این کاربر رو از سرور میگیره — سرور فقط ciphertext میبینه */
|
||||
export async function fetchMyKeyBackup(): Promise<KeyBackupPayload | null> {
|
||||
const base = getApiBaseUrl();
|
||||
const res = await fetch(`${base}/account/e2ee/key-backup`, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as { backup?: KeyBackupPayload | null };
|
||||
return data.backup || null;
|
||||
}
|
||||
|
||||
/** کلیدِ فعلیِ این دستگاه رو با یه PIN رمزنگاری میکنه و روی سرور بکآپ میگیره */
|
||||
export async function saveKeyBackup(userId: string, pin: string): Promise<void> {
|
||||
const uid = String(userId);
|
||||
const stored = await idbGet<StoredIdentity>(`identity:${uid}`);
|
||||
if (!stored?.privateJwk) throw new Error("No local identity key to back up");
|
||||
|
||||
const payload = await encryptPrivateKeyForBackup(stored.privateJwk, pin);
|
||||
const base = getApiBaseUrl();
|
||||
const res = await fetch(`${base}/account/e2ee/key-backup`, {
|
||||
method: "PUT",
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to save key backup");
|
||||
}
|
||||
|
||||
/**
|
||||
* بکآپِ سرور رو با PIN باز میکنه و بهعنوان کلیدِ این دستگاه میپذیره — همون هویتِ
|
||||
* قدیمی، پس پیامهای قدیمی دوباره قابلرمزگشایی میشن. PIN اشتباه → AES-GCM حین
|
||||
* decrypt (تگِ احرازهویت جور درنمیاد) throw میکنه، نه یه نتیجهی نادرستِ بیصدا
|
||||
*/
|
||||
export async function recoverIdentityFromBackup(
|
||||
userId: string,
|
||||
pin: string
|
||||
): Promise<void> {
|
||||
const uid = String(userId);
|
||||
const backup = await fetchMyKeyBackup();
|
||||
if (!backup) throw new Error("No backup found");
|
||||
|
||||
const privateJwk = await decryptPrivateKeyFromBackup(backup, pin);
|
||||
const privateKey = await importPrivateKeyJwk(privateJwk);
|
||||
const publicKeyB64 = await fetchPeerPublicKey(uid);
|
||||
if (!publicKeyB64) throw new Error("Could not fetch published public key");
|
||||
|
||||
await storeAndAdopt(uid, privateKey, privateJwk, publicKeyB64);
|
||||
}
|
||||
|
||||
/**
|
||||
* وقتی هیچ بکآپی نیست و هیچ دستگاهِ دیگهای هم برای لینککردن در دسترس نیست — کاربر
|
||||
* صراحتاً میپذیره که پیامهای قدیمیِ رمزنگاریشده برای همیشه غیرقابلبازگشت میمونن و
|
||||
* یه هویتِ کاملاً تازه میسازه. باید فقط بعدِ تاییدِ آگاهانهی کاربر صدا زده بشه، نه
|
||||
* خودکار
|
||||
*/
|
||||
export async function createFreshIdentity(userId: string): Promise<{
|
||||
privateKey: CryptoKey;
|
||||
publicKeyB64: string;
|
||||
}> {
|
||||
const uid = String(userId);
|
||||
const pair = await generateIdentityKeyPair();
|
||||
const publicKeyB64 = await exportPublicKeySpkiB64(pair.publicKey);
|
||||
const privateJwk = await exportPrivateKeyJwk(pair.privateKey);
|
||||
const result = await storeAndAdopt(uid, pair.privateKey, privateJwk, publicKeyB64);
|
||||
try {
|
||||
await publishPublicKey(publicKeyB64);
|
||||
} catch {
|
||||
/* publish best-effort */
|
||||
}
|
||||
// این هویتِ تازه رو هم فوراً با رمزِ خودکار بکآپ میگیره — وگرنه دستگاهِ بعدی که با
|
||||
// همین اکانت وارد بشه، دوباره به همین بنبست (بدون بکآپ) میرسید
|
||||
await saveAutoKeyBackup(privateJwk);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function fetchPeerPublicKey(
|
||||
peerUserId: string
|
||||
): Promise<string | null> {
|
||||
|
||||
@@ -1,54 +1,288 @@
|
||||
import { isE2eeEnvelope, E2EE_LOCKED_PREVIEW } from "./envelope";
|
||||
import Cookies from "js-cookie";
|
||||
import { getApiBaseUrl } from "@/components/main/BaseUrl";
|
||||
import {
|
||||
decryptText,
|
||||
encryptText,
|
||||
generateRoomKeyRaw,
|
||||
importPublicKeySpkiB64,
|
||||
importRoomAesKey,
|
||||
unwrapRoomKey,
|
||||
wrapRoomKeyForRecipient,
|
||||
} from "./crypto";
|
||||
import { E2EE_LOCKED_PREVIEW, isE2eeEnvelope } from "./envelope";
|
||||
import { ensureIdentityKeys } from "./keys";
|
||||
import { b64ToBuf, bufToB64, idbGet, idbSet } from "./storage";
|
||||
|
||||
// E2EE برای رومها هم موقتاً کاملاً غیرفعال شده. نکتهی مهم: ensureRoomAesKey
|
||||
// قبلاً اگه روم هنوز e2ee_enabled نبود، خودش یه کلیدِ تازه میساخت و با PUT به
|
||||
// سرور، رمزنگاری رو *خودکار* روی روم فعال میکرد — پس همینجا کاملاً no-op
|
||||
// شده تا هیچجایی (حتی موقعِ ساختنِ روم جدید) دیگه رمزنگاری رو روشن نکنه
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars -- امضا برای سازگاری با فراخوانهای موجود حفظ شده */
|
||||
export async function ensureRoomAesKey(
|
||||
myUserId: string,
|
||||
roomId: string
|
||||
): Promise<CryptoKey | null> {
|
||||
type RoomKeysResponse = {
|
||||
e2ee_enabled: boolean;
|
||||
myWrappedKey: string | null;
|
||||
memberPublicKeys: { userId: string; publicKey: string | null }[];
|
||||
missingWrapUserIds: string[];
|
||||
};
|
||||
|
||||
type PutRoomKeysConflict = { myWrappedKey: string | null } | null;
|
||||
|
||||
const roomAesCache = new Map<string, CryptoKey>();
|
||||
const roomRawCache = new Map<string, Uint8Array>();
|
||||
|
||||
// عین inFlight توی keys.ts: بدون این، چند فراخوانیِ همزمانِ همین تب (مثلاً mount شدنِ
|
||||
// صفحهی روم + رمزگشاییِ چند پیام همزمان) هرکدوم جداگانه ensureRoomAesKey رو صدا
|
||||
// میزدن؛ اگه روم هنوز کلید نداشت، هر کدوم یه کلیدِ تصادفیِ متفاوت میساختن. این فقط
|
||||
// رقابتِ داخلِ همین تب رو میبنده — رقابتِ بین چند عضوِ مختلف (چند دستگاه/تب) با
|
||||
// claimِ اتمیکِ سمتِ سرور (putRoomE2eeKeys) حل میشه
|
||||
const inFlight = new Map<string, Promise<CryptoKey | null>>();
|
||||
|
||||
function authHeaders(): HeadersInit {
|
||||
const token = Cookies.get("token");
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchRoomKeys(roomId: string): Promise<RoomKeysResponse | null> {
|
||||
const base = getApiBaseUrl();
|
||||
const res = await fetch(`${base}/chat-rooms/${roomId}/e2ee-keys`, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as RoomKeysResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* اگه سرور ۴۰۹ (E2EE_ALREADY_ENABLED) برگردونه یعنی عضو دیگهای همزمان برندهی
|
||||
* فعالسازیِ اولیهی روم شده — کلیدی که خودمون تازه ساختیم دیگه معتبر نیست؛ باید
|
||||
* wrappedKeyِ واقعی (متعلق به کلیدِ برنده) که سرور برگردونده رو برگردونیم، نه که
|
||||
* موفقیت رو فرض کنیم (باگِ اصلیای که قبلاً E2EE رومها رو دائم میشکست)
|
||||
*/
|
||||
async function putRoomKeys(
|
||||
roomId: string,
|
||||
body: {
|
||||
e2ee_enabled?: boolean;
|
||||
wrapped_keys?: { userId: string; wrappedKey: string }[];
|
||||
},
|
||||
): Promise<PutRoomKeysConflict> {
|
||||
const base = getApiBaseUrl();
|
||||
const res = await fetch(`${base}/chat-rooms/${roomId}/e2ee-keys`, {
|
||||
method: "PUT",
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.status === 409) {
|
||||
try {
|
||||
const data = (await res.json()) as {
|
||||
code?: string;
|
||||
myWrappedKey?: string | null;
|
||||
};
|
||||
if (data?.code === "E2EE_ALREADY_ENABLED") {
|
||||
return { myWrappedKey: data.myWrappedKey ?? null };
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
|
||||
async function loadCachedRoomRaw(
|
||||
myUserId: string,
|
||||
roomId: string,
|
||||
): Promise<Uint8Array | null> {
|
||||
const mem = roomRawCache.get(roomId);
|
||||
if (mem) return mem;
|
||||
const b64 = await idbGet<string>(`roomkey:${myUserId}:${roomId}`);
|
||||
if (!b64) return null;
|
||||
const raw = new Uint8Array(b64ToBuf(b64));
|
||||
roomRawCache.set(roomId, raw);
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function persistRoomRaw(
|
||||
myUserId: string,
|
||||
roomId: string,
|
||||
raw: Uint8Array,
|
||||
): Promise<CryptoKey> {
|
||||
roomRawCache.set(roomId, raw);
|
||||
await idbSet(`roomkey:${myUserId}:${roomId}`, bufToB64(raw));
|
||||
const aes = await importRoomAesKey(raw);
|
||||
roomAesCache.set(roomId, aes);
|
||||
return aes;
|
||||
}
|
||||
|
||||
async function doEnsureRoomAesKey(
|
||||
myUserId: string,
|
||||
roomId: string,
|
||||
forceFresh: boolean,
|
||||
): Promise<CryptoKey | null> {
|
||||
if (!forceFresh) {
|
||||
const cached = roomAesCache.get(roomId);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const { privateKey } = await ensureIdentityKeys(myUserId);
|
||||
const info = await fetchRoomKeys(roomId);
|
||||
if (!info) return null;
|
||||
|
||||
let raw = forceFresh ? null : await loadCachedRoomRaw(myUserId, roomId);
|
||||
|
||||
if (!raw && info.myWrappedKey) {
|
||||
try {
|
||||
raw = await unwrapRoomKey(info.myWrappedKey, privateKey);
|
||||
await persistRoomRaw(myUserId, roomId, raw);
|
||||
} catch {
|
||||
raw = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!raw && !info.e2ee_enabled) {
|
||||
raw = await generateRoomKeyRaw();
|
||||
const wraps: { userId: string; wrappedKey: string }[] = [];
|
||||
for (const m of info.memberPublicKeys) {
|
||||
if (!m.publicKey) continue;
|
||||
try {
|
||||
const pub = await importPublicKeySpkiB64(m.publicKey);
|
||||
wraps.push({
|
||||
userId: m.userId,
|
||||
wrappedKey: await wrapRoomKeyForRecipient(raw, pub),
|
||||
});
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
if (!wraps.some((w) => w.userId === myUserId)) {
|
||||
const myPub = info.memberPublicKeys.find((m) => m.userId === myUserId);
|
||||
if (myPub?.publicKey) {
|
||||
const pub = await importPublicKeySpkiB64(myPub.publicKey);
|
||||
wraps.push({
|
||||
userId: myUserId,
|
||||
wrappedKey: await wrapRoomKeyForRecipient(raw, pub),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const conflict = await putRoomKeys(roomId, {
|
||||
e2ee_enabled: true,
|
||||
wrapped_keys: wraps,
|
||||
});
|
||||
|
||||
if (conflict) {
|
||||
// عضو دیگهای همزمان برنده شد — کلیدِ خودمون رو دور میریزیم و کلیدِ واقعی
|
||||
// (متعلق به برنده) رو با wrappedKeyِ برگشتی از سرور باز میکنیم
|
||||
if (!conflict.myWrappedKey) return null;
|
||||
const winnerRaw = await unwrapRoomKey(conflict.myWrappedKey, privateKey);
|
||||
return persistRoomRaw(myUserId, roomId, winnerRaw);
|
||||
}
|
||||
|
||||
return persistRoomRaw(myUserId, roomId, raw);
|
||||
}
|
||||
|
||||
if (raw) {
|
||||
// Sync wraps for members missing keys
|
||||
if (info.missingWrapUserIds?.length) {
|
||||
const wraps: { userId: string; wrappedKey: string }[] = [];
|
||||
for (const uid of info.missingWrapUserIds) {
|
||||
const m = info.memberPublicKeys.find((x) => x.userId === uid);
|
||||
if (!m?.publicKey) continue;
|
||||
try {
|
||||
const pub = await importPublicKeySpkiB64(m.publicKey);
|
||||
wraps.push({
|
||||
userId: uid,
|
||||
wrappedKey: await wrapRoomKeyForRecipient(raw, pub),
|
||||
});
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
if (wraps.length) {
|
||||
await putRoomKeys(roomId, { wrapped_keys: wraps });
|
||||
}
|
||||
}
|
||||
return persistRoomRaw(myUserId, roomId, raw);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function ensureRoomAesKey(
|
||||
myUserId: string,
|
||||
roomId: string,
|
||||
/** Bypass the in-memory/IndexedDB cache and re-unwrap from the server's current wrapped key. */
|
||||
forceFresh = false,
|
||||
): Promise<CryptoKey | null> {
|
||||
if (!forceFresh) {
|
||||
const cached = roomAesCache.get(roomId);
|
||||
if (cached) return cached;
|
||||
|
||||
const existing = inFlight.get(roomId);
|
||||
if (existing) return existing;
|
||||
}
|
||||
|
||||
const task = doEnsureRoomAesKey(myUserId, roomId, forceFresh).finally(() => {
|
||||
inFlight.delete(roomId);
|
||||
});
|
||||
if (!forceFresh) inFlight.set(roomId, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
export async function encryptRoomContent(
|
||||
myUserId: string,
|
||||
roomId: string,
|
||||
plaintext: string
|
||||
plaintext: string,
|
||||
): Promise<string | null> {
|
||||
return plaintext;
|
||||
if (!plaintext) return plaintext;
|
||||
if (isE2eeEnvelope(plaintext)) return plaintext;
|
||||
const key = await ensureRoomAesKey(myUserId, roomId);
|
||||
if (!key) return null;
|
||||
return encryptText(key, plaintext);
|
||||
}
|
||||
|
||||
export async function decryptRoomContent(
|
||||
myUserId: string,
|
||||
roomId: string,
|
||||
content: string
|
||||
content: string,
|
||||
): Promise<string> {
|
||||
// فقط پیامِ قدیمیِ رمزنگاریشده (از قبلِ غیرفعالسازیِ E2EE) به اینجا میرسه
|
||||
if (isE2eeEnvelope(content)) return E2EE_LOCKED_PREVIEW;
|
||||
return content;
|
||||
if (!isE2eeEnvelope(content)) return content;
|
||||
|
||||
const key = await ensureRoomAesKey(myUserId, roomId);
|
||||
if (key) {
|
||||
const pt = await decryptText(key, content);
|
||||
if (pt != null) return pt;
|
||||
}
|
||||
|
||||
// کلید کششده ممکن است دیگر با wrapped key فعلی سرور همخوان نباشد — یک بار تازه امتحان کن
|
||||
const freshKey = await ensureRoomAesKey(myUserId, roomId, true);
|
||||
if (freshKey) {
|
||||
const pt = await decryptText(freshKey, content);
|
||||
if (pt != null) return pt;
|
||||
}
|
||||
|
||||
return E2EE_LOCKED_PREVIEW;
|
||||
}
|
||||
|
||||
export async function decryptRoomMessageList<
|
||||
T extends {
|
||||
content?: string;
|
||||
replyTo?: { content?: string; [k: string]: unknown } | null;
|
||||
}
|
||||
>(
|
||||
myUserId: string,
|
||||
roomId: string,
|
||||
messages: T[]
|
||||
): Promise<T[]> {
|
||||
},
|
||||
>(myUserId: string, roomId: string, messages: T[]): Promise<T[]> {
|
||||
await ensureRoomAesKey(myUserId, roomId);
|
||||
const out: T[] = [];
|
||||
for (const msg of messages) {
|
||||
let next = msg;
|
||||
if (msg.content && isE2eeEnvelope(msg.content)) {
|
||||
next = { ...next, content: E2EE_LOCKED_PREVIEW };
|
||||
const decrypted = await decryptRoomContent(myUserId, roomId, msg.content);
|
||||
next = { ...next, content: decrypted };
|
||||
}
|
||||
if (next.replyTo?.content && isE2eeEnvelope(next.replyTo.content)) {
|
||||
next = { ...next, replyTo: { ...next.replyTo, content: E2EE_LOCKED_PREVIEW } };
|
||||
const replyContent = await decryptRoomContent(
|
||||
myUserId,
|
||||
roomId,
|
||||
next.replyTo.content,
|
||||
);
|
||||
next = {
|
||||
...next,
|
||||
replyTo: { ...next.replyTo, content: replyContent },
|
||||
};
|
||||
}
|
||||
out.push(next);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
DEFAULT_LANGUAGE,
|
||||
ENABLED_LANGUAGES,
|
||||
getLanguageDefinition,
|
||||
isAppLanguage,
|
||||
|
||||
226
src/lib/personalDetailsFieldOptions.ts
Normal file
226
src/lib/personalDetailsFieldOptions.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
// مقادیر و برچسبهای فیلدهای جدید «مشخصات فردی» (هدف از رابطه، ویژگیهای
|
||||
// شخصیتی، سبک زندگی، علاقهمندیها و ...) که هم در صفحهی ویرایش مشخصات فردی
|
||||
// و هم در فیلترهای «یافتن همکار» استفاده میشوند — یکجا نگهداشته میشوند تا
|
||||
// مقادیر/ترتیب در هر دو صفحه همیشه یکسان بماند.
|
||||
|
||||
export const RELATIONSHIP_GOAL_VALUES = [
|
||||
"friendship",
|
||||
"serious_partner",
|
||||
"marriage_intro",
|
||||
"emotional_relationship",
|
||||
"travel_event_companion",
|
||||
"social_networking",
|
||||
"not_sure",
|
||||
] as const;
|
||||
export type RelationshipGoal = (typeof RELATIONSHIP_GOAL_VALUES)[number];
|
||||
export const RELATIONSHIP_GOAL_MAX = 2;
|
||||
|
||||
export const MARITAL_STATUS_NEW_VALUES = [
|
||||
"widowed",
|
||||
"not_in_relationship",
|
||||
"prefer_not_to_say",
|
||||
] as const;
|
||||
|
||||
export const SEEKING_GENDER_VALUES = ["female", "male", "both", "not_important"] as const;
|
||||
export type SeekingGender = (typeof SEEKING_GENDER_VALUES)[number];
|
||||
|
||||
export const DESIRED_AGE_RANGE_VALUES = [
|
||||
"18_24",
|
||||
"25_30",
|
||||
"31_35",
|
||||
"36_40",
|
||||
"41_50",
|
||||
"over_50",
|
||||
"not_important",
|
||||
] as const;
|
||||
export type DesiredAgeRange = (typeof DESIRED_AGE_RANGE_VALUES)[number];
|
||||
|
||||
/** تبدیل بازهی سنی انتخابی (bucket) به ageMin/ageMax عددی که سرچ سرور انتظار دارد. */
|
||||
export const DESIRED_AGE_RANGE_TO_MIN_MAX: Record<
|
||||
DesiredAgeRange,
|
||||
{ min?: number; max?: number }
|
||||
> = {
|
||||
"18_24": { min: 18, max: 24 },
|
||||
"25_30": { min: 25, max: 30 },
|
||||
"31_35": { min: 31, max: 35 },
|
||||
"36_40": { min: 36, max: 40 },
|
||||
"41_50": { min: 41, max: 50 },
|
||||
over_50: { min: 51 },
|
||||
not_important: {},
|
||||
};
|
||||
|
||||
export const PERSONALITY_TRAIT_VALUES = [
|
||||
"kind",
|
||||
"loyal",
|
||||
"funny",
|
||||
"calm",
|
||||
"energetic",
|
||||
"responsible",
|
||||
"ambitious",
|
||||
"emotional",
|
||||
"logical",
|
||||
"patient",
|
||||
"honest",
|
||||
"romantic",
|
||||
"independent",
|
||||
"family_oriented",
|
||||
"adventurous",
|
||||
"introvert",
|
||||
"extrovert",
|
||||
] as const;
|
||||
export type PersonalityTrait = (typeof PERSONALITY_TRAIT_VALUES)[number];
|
||||
export const PERSONALITY_TRAITS_MAX = 8;
|
||||
|
||||
export const LIFESTYLE_VALUES = [
|
||||
"active_healthy",
|
||||
"homebody_calm",
|
||||
"social_outgoing",
|
||||
"busy_career",
|
||||
"traveler",
|
||||
"night_owl",
|
||||
"early_riser",
|
||||
"minimalist",
|
||||
"luxury_lover",
|
||||
] as const;
|
||||
export type Lifestyle = (typeof LIFESTYLE_VALUES)[number];
|
||||
export const LIFESTYLE_MAX = 5;
|
||||
|
||||
export const INTEREST_VALUES = [
|
||||
"movies_series",
|
||||
"music",
|
||||
"books_reading",
|
||||
"sports",
|
||||
"cooking",
|
||||
"photography",
|
||||
"travel",
|
||||
"nature",
|
||||
"gaming",
|
||||
"art_painting",
|
||||
"fashion",
|
||||
"pets",
|
||||
"dancing",
|
||||
"language_learning",
|
||||
] as const;
|
||||
export type Interest = (typeof INTEREST_VALUES)[number];
|
||||
export const INTERESTS_MAX = 8;
|
||||
|
||||
export const VALUE_VALUES = [
|
||||
"family",
|
||||
"career_growth",
|
||||
"religion_spirituality",
|
||||
"personal_freedom",
|
||||
"financial_stability",
|
||||
"health_fitness",
|
||||
"personal_growth",
|
||||
"excitement_new_experiences",
|
||||
] as const;
|
||||
export type ValueItem = (typeof VALUE_VALUES)[number];
|
||||
export const VALUES_MAX = 5;
|
||||
|
||||
export const RELATIONSHIP_VIEW_VALUES = [
|
||||
"traditional",
|
||||
"modern",
|
||||
"long_term",
|
||||
"friendship_first",
|
||||
"marriage_soon",
|
||||
"friendship_only_for_now",
|
||||
] as const;
|
||||
export type RelationshipView = (typeof RELATIONSHIP_VIEW_VALUES)[number];
|
||||
|
||||
export const GEO_PREFERENCE_VALUES = [
|
||||
"same_city_only",
|
||||
"nearby_cities",
|
||||
"whole_province",
|
||||
"whole_iran",
|
||||
"even_abroad",
|
||||
"online_is_fine",
|
||||
] as const;
|
||||
export type GeoPreference = (typeof GEO_PREFERENCE_VALUES)[number];
|
||||
|
||||
export const CHILDREN_STATUS_VALUES = [
|
||||
"no_children",
|
||||
"has_children",
|
||||
"open_to_children",
|
||||
"not_now",
|
||||
"prefer_not_to_say",
|
||||
] as const;
|
||||
export type ChildrenStatus = (typeof CHILDREN_STATUS_VALUES)[number];
|
||||
|
||||
export const SMOKING_STATUS_VALUES = [
|
||||
"none",
|
||||
"sometimes",
|
||||
"yes",
|
||||
"doesnt_matter",
|
||||
"prefer_not_to_say",
|
||||
] as const;
|
||||
export type SmokingStatus = (typeof SMOKING_STATUS_VALUES)[number];
|
||||
|
||||
export const INCOME_RANGE_VALUES = [
|
||||
"under_40m",
|
||||
"40m_50m",
|
||||
"50m_60m",
|
||||
"60m_70m",
|
||||
"70m_80m",
|
||||
"80m_90m",
|
||||
"90m_100m",
|
||||
"100m_150m",
|
||||
"150m_200m",
|
||||
"over_200m",
|
||||
] as const;
|
||||
export type IncomeRange = (typeof INCOME_RANGE_VALUES)[number];
|
||||
|
||||
export type LabeledOption = { value: string; label: string };
|
||||
|
||||
type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
function buildOptions(t: TranslateFn, i18nKey: string, values: readonly string[]): LabeledOption[] {
|
||||
return values.map((value) => ({
|
||||
value,
|
||||
label: t(`settings.edit.personalDetails.${i18nKey}.${value}`),
|
||||
}));
|
||||
}
|
||||
|
||||
export const buildRelationshipGoalOptions = (t: TranslateFn) =>
|
||||
buildOptions(t, "relationshipGoalOptions", RELATIONSHIP_GOAL_VALUES);
|
||||
|
||||
export const buildMaritalStatusNewOptions = (t: TranslateFn) =>
|
||||
buildOptions(t, "maritalStatusOptions", MARITAL_STATUS_NEW_VALUES);
|
||||
|
||||
export const buildSeekingGenderOptions = (t: TranslateFn) =>
|
||||
buildOptions(t, "seekingGenderOptions", SEEKING_GENDER_VALUES);
|
||||
|
||||
export const buildDesiredAgeRangeOptions = (t: TranslateFn) =>
|
||||
buildOptions(t, "desiredAgeRangeOptions", DESIRED_AGE_RANGE_VALUES);
|
||||
|
||||
export const buildPersonalityTraitsOptions = (t: TranslateFn) =>
|
||||
buildOptions(t, "personalityTraitsOptions", PERSONALITY_TRAIT_VALUES);
|
||||
|
||||
export const buildLifestyleOptions = (t: TranslateFn) =>
|
||||
buildOptions(t, "lifestyleOptions", LIFESTYLE_VALUES);
|
||||
|
||||
export const buildInterestsOptions = (t: TranslateFn) =>
|
||||
buildOptions(t, "interestsOptions", INTEREST_VALUES);
|
||||
|
||||
export const buildValuesOptions = (t: TranslateFn) =>
|
||||
buildOptions(t, "valuesOptions", VALUE_VALUES);
|
||||
|
||||
export const buildRelationshipViewOptions = (t: TranslateFn) =>
|
||||
buildOptions(t, "relationshipViewOptions", RELATIONSHIP_VIEW_VALUES);
|
||||
|
||||
export const buildGeoPreferenceOptions = (t: TranslateFn) =>
|
||||
buildOptions(t, "geoPreferenceOptions", GEO_PREFERENCE_VALUES);
|
||||
|
||||
export const buildChildrenStatusOptions = (t: TranslateFn) =>
|
||||
buildOptions(t, "childrenStatusOptions", CHILDREN_STATUS_VALUES);
|
||||
|
||||
export const buildSmokingStatusOptions = (t: TranslateFn) =>
|
||||
buildOptions(t, "smokingStatusOptions", SMOKING_STATUS_VALUES);
|
||||
|
||||
export const buildIncomeRangeOptions = (t: TranslateFn) =>
|
||||
buildOptions(t, "incomeRangeOptions", INCOME_RANGE_VALUES);
|
||||
|
||||
/** برای درج n آیتم آرایهای در URLSearchParams طبق قرارداد بکاند (مقدار جدا شده با کاما). */
|
||||
export function joinFilterValues(values: string[] | undefined | null): string | undefined {
|
||||
if (!values || values.length === 0) return undefined;
|
||||
return values.join(",");
|
||||
}
|
||||
@@ -8,6 +8,10 @@ export const PWA_THEME_COLOR = "#FF107D";
|
||||
export const PWA_BACKGROUND_COLOR = "#FF107D";
|
||||
export const PWA_ID = "modstagram-pwa";
|
||||
export const PWA_VERSION = "12";
|
||||
// نسخهی فعلیِ APK اندروید — با هر انتشار نسخهی جدید (و آپلود APK جدید توی
|
||||
// storage/downloads/modstagram.apk) این مقدار هم دستی بهروز بشه؛ همون versionName
|
||||
// توی android/app/build.gradle
|
||||
export const ANDROID_APP_VERSION = "2.1.2";
|
||||
|
||||
export { resolvePwaLang, toPwaLang } from "@/lib/i18n/pwaLang";
|
||||
|
||||
|
||||
25
src/lib/storyFonts.ts
Normal file
25
src/lib/storyFonts.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import localFont from "next/font/local";
|
||||
import type { StoryFontFamily } from "@/api/fetchStories";
|
||||
|
||||
// همون خانوادهفونتِ IRANSansX که کل اپ استفاده میکنه (layout.tsx فقط وزن Regular رو لود
|
||||
// میکنه)، اینجا با همهی وزنهای لازم برای «تغییر فونت» استوری بار میشه — یه تعریفِ
|
||||
// next/font/local با چندین فایل وزن، نه چند فونت جدا
|
||||
export const storyFont = localFont({
|
||||
src: [
|
||||
{ path: "../../public/fonts/IRANSansX-Light.woff", weight: "300", style: "normal" },
|
||||
{ path: "../../public/fonts/IRANSansX-Regular.woff", weight: "400", style: "normal" },
|
||||
{ path: "../../public/fonts/IRANSansX-Medium.woff", weight: "500", style: "normal" },
|
||||
{ path: "../../public/fonts/IRANSansX-Bold.woff", weight: "700", style: "normal" },
|
||||
{ path: "../../public/fonts/IRANSansX-Black.woff", weight: "900", style: "normal" },
|
||||
],
|
||||
display: "swap",
|
||||
fallback: ["Tahoma", "Arial", "sans-serif"],
|
||||
});
|
||||
|
||||
export const STORY_FONT_WEIGHTS: Record<StoryFontFamily, number> = {
|
||||
light: 300,
|
||||
regular: 400,
|
||||
medium: 500,
|
||||
bold: 700,
|
||||
black: 900,
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
/** حروف انگلیسی، اعداد و . _ - — بدون فارسی و کاراکترهای غیرمعمول */
|
||||
export const USERNAME_MIN_LENGTH = 8;
|
||||
export const USERNAME_MIN_LENGTH = 6;
|
||||
export const USERNAME_MAX_LENGTH = 30;
|
||||
|
||||
export const USERNAME_REGEX = new RegExp(
|
||||
|
||||
@@ -662,6 +662,8 @@
|
||||
"personalDetails": {
|
||||
"title": "Personal Details",
|
||||
"hint": "This information is only shown to others in \"Find a Partner\" search results.",
|
||||
"age": "Age",
|
||||
"ageYears": "{{age}} years old",
|
||||
"maritalStatus": "Marital status",
|
||||
"maritalStatusSingle": "Single",
|
||||
"maritalStatusDivorced": "Divorced",
|
||||
@@ -685,6 +687,18 @@
|
||||
"income5m10m": "5M–10M Toman",
|
||||
"income10m20m": "10M–20M Toman",
|
||||
"incomeOver20m": "Over 20M Toman",
|
||||
"incomeRangeOptions": {
|
||||
"under_40m": "Under 40M Toman",
|
||||
"40m_50m": "40M–50M Toman",
|
||||
"50m_60m": "50M–60M Toman",
|
||||
"60m_70m": "60M–70M Toman",
|
||||
"70m_80m": "70M–80M Toman",
|
||||
"80m_90m": "80M–90M Toman",
|
||||
"90m_100m": "90M–100M Toman",
|
||||
"100m_150m": "100M–150M Toman",
|
||||
"150m_200m": "150M–200M Toman",
|
||||
"over_200m": "Over 200M Toman"
|
||||
},
|
||||
"moralTraits": "Personal qualities",
|
||||
"moralTraitsPlaceholder": "Describe your personal qualities",
|
||||
"saveSuccess": "Personal details saved successfully"
|
||||
@@ -1219,6 +1233,27 @@
|
||||
"removeTextBg": "Remove text background",
|
||||
"addTextBg": "Add text background",
|
||||
"fontSize": "Size",
|
||||
"fontFamily": "Font",
|
||||
"mentionSomeone": "Mention someone",
|
||||
"addHashtag": "Add hashtag",
|
||||
"addLocation": "Add location",
|
||||
"hashtagPlaceholder": "Without #, e.g. modstagram",
|
||||
"locationPlaceholder": "Location name...",
|
||||
"mention": "Mention",
|
||||
"hashtag": "Hashtag",
|
||||
"location": "Location",
|
||||
"sticker": "Sticker",
|
||||
"addSticker": "Add sticker",
|
||||
"camera": "Camera",
|
||||
"cameraAccessDenied": "Camera access was denied",
|
||||
"capturePhoto": "Take photo",
|
||||
"fontFamilyOptions": {
|
||||
"light": "Light",
|
||||
"regular": "Regular",
|
||||
"medium": "Medium",
|
||||
"bold": "Bold",
|
||||
"black": "Extra bold"
|
||||
},
|
||||
"storyTextPlaceholder": "Story text…",
|
||||
"confirm": "Confirm",
|
||||
"timeNow": "now",
|
||||
@@ -1289,7 +1324,9 @@
|
||||
"title": "Contact Modstagram",
|
||||
"emailLabel": "Email:",
|
||||
"phoneLabel": "Phone:",
|
||||
"sendTicket": "Send ticket"
|
||||
"sendTicket": "Send ticket",
|
||||
"downloadApp": "Download app",
|
||||
"appVersion": "Version {{version}}"
|
||||
},
|
||||
"offerPage": {
|
||||
"paymentTitle": "Payment",
|
||||
@@ -1680,6 +1717,17 @@
|
||||
"searchPlaceholder": "Search…",
|
||||
"chatRoom": "Chat rooms",
|
||||
"chatRoomDesc": "Group conversations with users",
|
||||
"e2ee": {
|
||||
"recoveryTitle": "Recover chat encryption",
|
||||
"recoveryHasBackupDesc": "This device's automatic recovery of your previous encryption key didn't work. Try again, or enter a manual recovery passphrase if you set one up before.",
|
||||
"recoveryNoBackupDesc": "This device doesn't have access to your previous encryption key, and no backup was saved. You can link this device from another device you're already signed in on, or continue with a fresh identity (old encrypted messages will be permanently unreadable).",
|
||||
"retryButton": "Try again",
|
||||
"pinPlaceholder": "Manual recovery passphrase (if any)",
|
||||
"recoverButton": "Recover with manual passphrase",
|
||||
"wrongPin": "Incorrect passphrase or recovery failed",
|
||||
"startFreshLink": "I don't have a backup, start fresh",
|
||||
"confirmFreshWarning": "This will permanently make your old encrypted messages unreadable. Are you sure?"
|
||||
},
|
||||
"nearby": {
|
||||
"title": "Find nearby friends",
|
||||
"desc": "People within about 5 km",
|
||||
|
||||
@@ -662,6 +662,8 @@
|
||||
"personalDetails": {
|
||||
"title": "مشخصات فردی",
|
||||
"hint": "این اطلاعات فقط در نتایج «یافتن همکار» به دیگران نمایش داده میشود.",
|
||||
"age": "سن",
|
||||
"ageYears": "{{age}} سال",
|
||||
"maritalStatus": "وضعیت تاهل",
|
||||
"maritalStatusSingle": "مجرد",
|
||||
"maritalStatusDivorced": "مطلقه",
|
||||
@@ -685,8 +687,149 @@
|
||||
"income5m10m": "۵ تا ۱۰ میلیون تومان",
|
||||
"income10m20m": "۱۰ تا ۲۰ میلیون تومان",
|
||||
"incomeOver20m": "بیشتر از ۲۰ میلیون تومان",
|
||||
"incomeRangeOptions": {
|
||||
"under_40m": "کمتر از ۴۰ میلیون تومان",
|
||||
"40m_50m": "۴۰ تا ۵۰ میلیون تومان",
|
||||
"50m_60m": "۵۰ تا ۶۰ میلیون تومان",
|
||||
"60m_70m": "۶۰ تا ۷۰ میلیون تومان",
|
||||
"70m_80m": "۷۰ تا ۸۰ میلیون تومان",
|
||||
"80m_90m": "۸۰ تا ۹۰ میلیون تومان",
|
||||
"90m_100m": "۹۰ تا ۱۰۰ میلیون تومان",
|
||||
"100m_150m": "۱۰۰ تا ۱۵۰ میلیون تومان",
|
||||
"150m_200m": "۱۵۰ تا ۲۰۰ میلیون تومان",
|
||||
"over_200m": "بیشتر از ۲۰۰ میلیون تومان"
|
||||
},
|
||||
"moralTraits": "خصوصیات اخلاقی",
|
||||
"moralTraitsPlaceholder": "خصوصیات اخلاقی خود را بنویسید",
|
||||
"maritalStatusOptions": {
|
||||
"widowed": "بیوه",
|
||||
"not_in_relationship": "در رابطه نیستم",
|
||||
"prefer_not_to_say": "ترجیح میدهم نگویم"
|
||||
},
|
||||
"relationshipGoal": "هدف از رابطه",
|
||||
"relationshipGoalOptions": {
|
||||
"friendship": "دوستی",
|
||||
"serious_partner": "پارتنریابی جدی",
|
||||
"marriage_intro": "آشنایی برای ازدواج",
|
||||
"emotional_relationship": "رابطه عاطفی",
|
||||
"travel_event_companion": "همراه برای سفر / رویداد",
|
||||
"social_networking": "شبکه اجتماعی و معاشرت",
|
||||
"not_sure": "هنوز مشخص نیست"
|
||||
},
|
||||
"seekingGender": "به دنبال",
|
||||
"seekingGenderOptions": {
|
||||
"female": "زن",
|
||||
"male": "مرد",
|
||||
"both": "هردو",
|
||||
"not_important": "مهم نیست"
|
||||
},
|
||||
"desiredAgeRange": "بازه سنی مورد نظر",
|
||||
"desiredAgeRangeOptions": {
|
||||
"18_24": "۱۸–۲۴",
|
||||
"25_30": "۲۵–۳۰",
|
||||
"31_35": "۳۱–۳۵",
|
||||
"36_40": "۳۶–۴۰",
|
||||
"41_50": "۴۱–۵۰",
|
||||
"over_50": "بالای ۵۰",
|
||||
"not_important": "مهم نیست"
|
||||
},
|
||||
"personalityTraits": "ویژگیهای شخصیتی",
|
||||
"personalityTraitsOptions": {
|
||||
"kind": "مهربان",
|
||||
"loyal": "وفادار",
|
||||
"funny": "شوخطبع",
|
||||
"calm": "آرام",
|
||||
"energetic": "پرانرژی",
|
||||
"responsible": "مسئولیتپذیر",
|
||||
"ambitious": "بلندپرواز",
|
||||
"emotional": "احساسی",
|
||||
"logical": "منطقی",
|
||||
"patient": "صبور",
|
||||
"honest": "صادق",
|
||||
"romantic": "رمانتیک",
|
||||
"independent": "مستقل",
|
||||
"family_oriented": "خانوادهدوست",
|
||||
"adventurous": "ماجراجو",
|
||||
"introvert": "درونگرا",
|
||||
"extrovert": "برونگرا"
|
||||
},
|
||||
"lifestyle": "سبک زندگی",
|
||||
"lifestyleOptions": {
|
||||
"active_healthy": "ورزشی و سالم",
|
||||
"homebody_calm": "خانهدار و آرام",
|
||||
"social_outgoing": "اجتماعی و اهل بیرون",
|
||||
"busy_career": "کاری و پرمشغله",
|
||||
"traveler": "اهل سفر",
|
||||
"night_owl": "شبزندهدار",
|
||||
"early_riser": "سحرخیز",
|
||||
"minimalist": "مینیمال",
|
||||
"luxury_lover": "لوکسپسند"
|
||||
},
|
||||
"interests": "علاقهمندیها",
|
||||
"interestsOptions": {
|
||||
"movies_series": "سینما و سریال",
|
||||
"music": "موسیقی",
|
||||
"books_reading": "کتاب و مطالعه",
|
||||
"sports": "ورزش",
|
||||
"cooking": "آشپزی",
|
||||
"photography": "عکاسی",
|
||||
"travel": "سفر",
|
||||
"nature": "طبیعتگردی",
|
||||
"gaming": "بازی و گیم",
|
||||
"art_painting": "هنر و نقاشی",
|
||||
"fashion": "مد و فشن",
|
||||
"pets": "حیوانات خانگی",
|
||||
"dancing": "رقص",
|
||||
"language_learning": "یادگیری زبان"
|
||||
},
|
||||
"values": "ارزشها",
|
||||
"valuesOptions": {
|
||||
"family": "خانواده",
|
||||
"career_growth": "شغل و پیشرفت",
|
||||
"religion_spirituality": "مذهب و معنویت",
|
||||
"personal_freedom": "آزادی فردی",
|
||||
"financial_stability": "ثبات مالی",
|
||||
"health_fitness": "سلامت و تناسب اندام",
|
||||
"personal_growth": "رشد شخصی",
|
||||
"excitement_new_experiences": "هیجان و تجربه جدید"
|
||||
},
|
||||
"relationshipView": "دیدگاه نسبت به رابطه",
|
||||
"relationshipViewOptions": {
|
||||
"traditional": "رابطه سنتی",
|
||||
"modern": "رابطه مدرن",
|
||||
"long_term": "رابطه بلندمدت",
|
||||
"friendship_first": "ابتدا دوستی بعد تصمیم",
|
||||
"marriage_soon": "ازدواج در آینده نزدیک",
|
||||
"friendship_only_for_now": "فعلاً فقط دوستی"
|
||||
},
|
||||
"geoPreference": "محدوده جغرافیایی مورد نظر",
|
||||
"geoPreferenceOptions": {
|
||||
"same_city_only": "فقط شهر خودم",
|
||||
"nearby_cities": "شهرهای نزدیک",
|
||||
"whole_province": "سراسر استان",
|
||||
"whole_iran": "سراسر ایران",
|
||||
"even_abroad": "حتی خارج از کشور",
|
||||
"online_is_fine": "آنلاین هم مشکلی نیست"
|
||||
},
|
||||
"childrenStatus": "وضعیت فرزند",
|
||||
"childrenStatusOptions": {
|
||||
"no_children": "فرزند ندارم",
|
||||
"has_children": "فرزند دارم",
|
||||
"open_to_children": "فرزند میپذیرم",
|
||||
"not_now": "فعلاً نمیخواهم فرزند",
|
||||
"prefer_not_to_say": "ترجیح میدهم نگویم"
|
||||
},
|
||||
"smokingStatus": "وضعیت مصرف دخانیات",
|
||||
"smokingStatusOptions": {
|
||||
"none": "مصرف نمیکنم",
|
||||
"sometimes": "گاهی",
|
||||
"yes": "مصرف میکنم",
|
||||
"doesnt_matter": "مهم نیست طرف مقابل چه باشد",
|
||||
"prefer_not_to_say": "ترجیح میدهم نگویم"
|
||||
},
|
||||
"selectPlaceholder": "انتخاب کنید",
|
||||
"multiSelectSelectedCount": "{{count}} مورد انتخاب شده",
|
||||
"multiSelectMaxHint": "حداکثر {{max}} مورد قابل انتخاب است",
|
||||
"saveSuccess": "مشخصات فردی با موفقیت ذخیره شد"
|
||||
},
|
||||
"avatar": {
|
||||
@@ -1219,6 +1362,27 @@
|
||||
"removeTextBg": "حذف پسزمینه متن",
|
||||
"addTextBg": "افزودن پسزمینه متن",
|
||||
"fontSize": "اندازه",
|
||||
"fontFamily": "فونت",
|
||||
"mentionSomeone": "منشن کردن فرد",
|
||||
"addHashtag": "افزودن هشتگ",
|
||||
"addLocation": "افزودن مکان",
|
||||
"hashtagPlaceholder": "بدون #، مثلاً مدستاگرام",
|
||||
"locationPlaceholder": "نام مکان...",
|
||||
"mention": "منشن",
|
||||
"hashtag": "هشتگ",
|
||||
"location": "مکان",
|
||||
"sticker": "استیکر",
|
||||
"addSticker": "افزودن استیکر",
|
||||
"camera": "دوربین",
|
||||
"cameraAccessDenied": "دسترسی به دوربین داده نشد",
|
||||
"capturePhoto": "گرفتن عکس",
|
||||
"fontFamilyOptions": {
|
||||
"light": "نازک",
|
||||
"regular": "معمولی",
|
||||
"medium": "متوسط",
|
||||
"bold": "ضخیم",
|
||||
"black": "خیلی ضخیم"
|
||||
},
|
||||
"storyTextPlaceholder": "متن استوری…",
|
||||
"confirm": "تأیید",
|
||||
"timeNow": "اکنون",
|
||||
@@ -1289,7 +1453,9 @@
|
||||
"title": "راه های ارتباطی با مجموعه مدستاگرام",
|
||||
"emailLabel": "ایمیل مجموعه :",
|
||||
"phoneLabel": "شماره تماس :",
|
||||
"sendTicket": "ارسال تیکت"
|
||||
"sendTicket": "ارسال تیکت",
|
||||
"downloadApp": "دانلود اپلیکیشن",
|
||||
"appVersion": "نسخه {{version}}"
|
||||
},
|
||||
"offerPage": {
|
||||
"paymentTitle": "پرداخت",
|
||||
@@ -1680,6 +1846,17 @@
|
||||
"searchPlaceholder": "جستجو…",
|
||||
"chatRoom": "چت روم",
|
||||
"chatRoomDesc": "گفتگوی گروهی با کاربران",
|
||||
"e2ee": {
|
||||
"recoveryTitle": "بازیابی رمزنگاری گفتگوها",
|
||||
"recoveryHasBackupDesc": "بازیابیِ خودکار این دستگاه به کلیدِ رمزنگاریِ قبلی موفق نشد. یهبار دیگه امتحان کن، یا اگه قبلاً رمزِ بازیابیِ دستی تنظیم کرده بودید همون رو وارد کنید.",
|
||||
"recoveryNoBackupDesc": "این دستگاه به کلید رمزنگاری قبلی دسترسی نداره و بکآپی هم ثبت نشده. میتونید از یه دستگاه دیگه که قبلاً واردش شدید لینک کنید، یا با هویت تازه ادامه بدید (پیامهای قدیمی رمزنگاریشده برای همیشه غیرقابلخوندن میمونن).",
|
||||
"retryButton": "تلاش دوباره",
|
||||
"pinPlaceholder": "رمز بازیابی دستی (در صورت وجود)",
|
||||
"recoverButton": "بازیابی با رمز دستی",
|
||||
"wrongPin": "رمز اشتباه است یا بازیابی ناموفق بود",
|
||||
"startFreshLink": "بکآپی ندارم، هویت تازه بساز",
|
||||
"confirmFreshWarning": "با این کار پیامهای رمزنگاریشدهی قبلی برای همیشه غیرقابلبازگشت میشن. مطمئنید؟"
|
||||
},
|
||||
"nearby": {
|
||||
"title": "یافتن دوستان نزدیک",
|
||||
"desc": "کاربران در شعاع حدود ۵ کیلومتر",
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
joinUserRoom,
|
||||
releaseChatSocket,
|
||||
} from "@/lib/chat/socketClient";
|
||||
import { ensureIdentityKeys } from "@/lib/e2ee";
|
||||
import E2eeIdentityGate from "@/components/chat/E2eeIdentityGate";
|
||||
|
||||
export default function ChatSocketProvider({
|
||||
children,
|
||||
@@ -15,14 +15,12 @@ export default function ChatSocketProvider({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const user = useUser();
|
||||
const uid = user?._id ? String(user._id) : "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?._id) return;
|
||||
if (!uid) return;
|
||||
|
||||
const socket = acquireChatSocket();
|
||||
const uid = String(user._id);
|
||||
|
||||
void ensureIdentityKeys(uid);
|
||||
|
||||
const join = () => joinUserRoom(uid);
|
||||
socket.on("connect", join);
|
||||
@@ -32,7 +30,15 @@ export default function ChatSocketProvider({
|
||||
socket.off("connect", join);
|
||||
releaseChatSocket();
|
||||
};
|
||||
}, [user?._id]);
|
||||
}, [uid]);
|
||||
|
||||
return children;
|
||||
return (
|
||||
<>
|
||||
{/* ensureIdentityKeys اینجا (تویِ خودِ گیت) صدا زده میشه، نه اینجا مستقیم —
|
||||
تا اگه هویتِ قبلی روی سرور هست و این دستگاه بهش دسترسی نداره، بهجای
|
||||
fail شدنِ بیصدا، از کاربر بازیابی/تاییدِ صریح خواسته بشه */}
|
||||
{uid ? <E2eeIdentityGate userId={uid} /> : null}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -286,15 +286,78 @@ export interface User {
|
||||
shaba?: string | null;
|
||||
national_code?: string | null;
|
||||
birthday?: string | null;
|
||||
marital_status?: "single" | "divorced" | null;
|
||||
gender?: "male" | "female" | string | null;
|
||||
marital_status?:
|
||||
| "single"
|
||||
| "divorced"
|
||||
| "widowed"
|
||||
| "not_in_relationship"
|
||||
| "prefer_not_to_say"
|
||||
| null;
|
||||
residence_type?: "owner" | "tenant" | null;
|
||||
residence_area?: string | null;
|
||||
vehicle_type?: "none" | "car" | "motorcycle" | null;
|
||||
vehicle_details?: string | null;
|
||||
personal_style?: "formal" | "sporty" | null;
|
||||
job?: string | null;
|
||||
income_range?: "under_5m" | "5m_10m" | "10m_20m" | "over_20m" | null;
|
||||
income_range?:
|
||||
| "under_40m"
|
||||
| "40m_50m"
|
||||
| "50m_60m"
|
||||
| "60m_70m"
|
||||
| "70m_80m"
|
||||
| "80m_90m"
|
||||
| "90m_100m"
|
||||
| "100m_150m"
|
||||
| "150m_200m"
|
||||
| "over_200m"
|
||||
| null;
|
||||
moral_traits?: string | null;
|
||||
relationship_goal?: string[] | null;
|
||||
seeking_gender?: "female" | "male" | "both" | "not_important" | null;
|
||||
desired_age_range?:
|
||||
| "18_24"
|
||||
| "25_30"
|
||||
| "31_35"
|
||||
| "36_40"
|
||||
| "41_50"
|
||||
| "over_50"
|
||||
| "not_important"
|
||||
| null;
|
||||
personality_traits?: string[] | null;
|
||||
lifestyle?: string[] | null;
|
||||
interests?: string[] | null;
|
||||
values?: string[] | null;
|
||||
relationship_view?:
|
||||
| "traditional"
|
||||
| "modern"
|
||||
| "long_term"
|
||||
| "friendship_first"
|
||||
| "marriage_soon"
|
||||
| "friendship_only_for_now"
|
||||
| null;
|
||||
geo_preference?:
|
||||
| "same_city_only"
|
||||
| "nearby_cities"
|
||||
| "whole_province"
|
||||
| "whole_iran"
|
||||
| "even_abroad"
|
||||
| "online_is_fine"
|
||||
| null;
|
||||
children_status?:
|
||||
| "no_children"
|
||||
| "has_children"
|
||||
| "open_to_children"
|
||||
| "not_now"
|
||||
| "prefer_not_to_say"
|
||||
| null;
|
||||
smoking_status?:
|
||||
| "none"
|
||||
| "sometimes"
|
||||
| "yes"
|
||||
| "doesnt_matter"
|
||||
| "prefer_not_to_say"
|
||||
| null;
|
||||
cooperation_abroad?: boolean | null | undefined;
|
||||
conversation_projects?: boolean | null | undefined;
|
||||
posts?: Post[];
|
||||
|
||||
@@ -11,10 +11,6 @@ const defaultOgImage = defaultSEOConfig.openGraph?.images?.[0];
|
||||
|
||||
export function generatePageMetadata(options: PageMetadataOptions): Metadata {
|
||||
const localized = buildLocalizedMetadataFields(options);
|
||||
const fullUrl =
|
||||
typeof localized.openGraph?.url === "string"
|
||||
? localized.openGraph.url
|
||||
: undefined;
|
||||
|
||||
const images = options.imageUrl
|
||||
? [
|
||||
|
||||
Reference in New Issue
Block a user