Compare commits

...

2 Commits

Author SHA1 Message Date
payacom
9fb454b0a7 reels 2026-10-01 21:11:37 +03:30
payacom
6e76e367f3 4.2.1 2026-09-25 13:35:34 +03:30
64 changed files with 2138 additions and 752 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "modstagram-next",
"version": "4.1.5",
"version": "4.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "modstagram-next",
"version": "4.1.5",
"version": "4.2.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "modstagram-next",
"version": "4.1.5",
"version": "4.2.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack --port 3004",

View File

@@ -22,6 +22,7 @@ import toast from "react-hot-toast";
import { useAuthSessionRedirect } from "@/hooks/useAuthSessionRedirect";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { establishIdentityAtLogin } from "@/lib/e2ee";
function LoginWithUsername() {
const { t, i18n } = useTranslation("common");
@@ -66,6 +67,12 @@ function LoginWithUsername() {
{ noToast: true }
)) as IVerifyOtp;
// قبل از این‌که password از scope خارج بشه، کلیدِ E2EE رو زیرِ همین رمز
// بک‌آپ/بازیابی کن — best-effort، نباید جلویِ خودِ لاگین رو بگیره
if (response.id) {
void establishIdentityAtLogin(response.id, values.password);
}
const loggedIn = await completeLogin(router, response, {
redirectTo: redirectPath,
});

View File

@@ -17,6 +17,8 @@ import AuthNextButton from "@/components/auth/AuthNextButton";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { getStoredUserId } from "@/lib/auth/session";
import { establishIdentityAtLogin } from "@/lib/e2ee";
function PasswordPage() {
const { t } = useTranslation("common");
@@ -53,6 +55,9 @@ function PasswordPage() {
(await request("POST", "/register/password", {
new_password: values.password,
})) as IVerifyOtp;
// اولین رمزِ عبورِ این کاربره — همین الان کلیدِ E2EE رو زیرش بک‌آپ بگیر
const userId = getStoredUserId();
if (userId) void establishIdentityAtLogin(userId, values.password);
router.push("/register/fullname");
} catch (err: any) {
const message =

View File

@@ -12,7 +12,7 @@ import Container from "@/components/elements/Container";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import SpecialistCarousel from "@/components/projects/SpecialistCarousel";
import useAxios from "@/hooks/useAxios";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import type { SuggestedFollowUser } from "@/components/posts/FollowingSuggestionsCarousel";
@@ -70,9 +70,6 @@ function SpecialistCard({
const media = user.previewPost
? getPostMedia(user.previewPost as Post)
: null;
const profileUrl = user.profile_image
? buildStorageUrl(user.profile_image)
: null;
const isMain = variant === "main";
const profileHref = user.user_name ? `/users/${user.user_name}` : "#";
@@ -130,25 +127,13 @@ function SpecialistCard({
isMain ? "px-4 pb-5" : "px-2 pb-3"
)}
>
<div
className={cn(
"relative mb-2 overflow-hidden rounded-full ring-2 ring-white/90",
isMain ? "h-16 w-16" : "h-9 w-9"
)}
>
{profileUrl ? (
<Image
src={profileUrl}
alt=""
fill
className="object-cover"
sizes={isMain ? "64px" : "36px"}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-neutral-700 text-white">
{displayName.charAt(0)}
</div>
)}
<div className="mb-2 overflow-hidden rounded-full ring-2 ring-white/90">
<ProfileAvatar
src={user.profile_image}
userId={user._id}
sizePx={isMain ? 64 : 36}
rounded="full"
/>
</div>
{isMain ? (

View File

@@ -712,6 +712,10 @@ select {
.story-ring-live {
animation: story-ring-spin 3s linear infinite;
}
/* حلقه‌ی «در حالِ آپلودِ استوری» — چرخشِ سریع‌تر، عینِ لودینگِ ثبتِ استوری اینستاگرام */
.story-ring-uploading {
animation: story-ring-spin 0.9s linear infinite;
}
/* Message enter — handled by framer-motion in ChatMessageCard */

View File

@@ -5,6 +5,8 @@
import useAxios from "@/hooks/useAxios";
import { optimizeImageToWebP, optimizeVideoToMp4, isImageAspectRatioAllowed } from "@/lib/media";
import { createStoryBase64, StoryOverlay } from "@/api/fetchStories";
import { useQueryClient } from "@tanstack/react-query";
import { setPendingStoryUpload } from "@/lib/stories/pendingStoryUpload";
import StoryEditor from "@/components/stories/StoryEditor";
import StoryCameraCapture from "@/components/stories/StoryCameraCapture";
import TagUsersPicker, { TaggedUser } from "@/components/posts/TagUsersPicker";
@@ -62,6 +64,7 @@ export default function NewPostPage() {
const { t, i18n } = useTranslation("common");
const captionLang = i18n.language === "en" ? "en" : "fa";
const router = useRouter();
const queryClient = useQueryClient();
const searchParams = useSearchParams();
const { request, loading } = useAxios();
const user = useUser();
@@ -303,39 +306,49 @@ export default function NewPostPage() {
toast.error(t("posts.storyMediaRequired"));
return;
}
try {
toast.loading(t("posts.publishingStory"), { id: "story-upload" });
let optimized: File;
if (file.type.startsWith("video/")) {
// اگر تبدیل ویدیو (ffmpeg) در دسترس نبود، فایل اصلی ارسال می‌شود
try {
optimized = await optimizeVideoToMp4(file);
} catch {
optimized = file;
}
} else {
try {
optimized = await optimizeImageToWebP(file);
} catch {
optimized = file;
// عین اینستاگرام: بلافاصله به فید برمی‌گردیم و آپلود پس‌زمینه ادامه پیدا می‌کنه —
// StoriesBar با خوندنِ همین پرچمِ pending، حلقه‌ی چرخانِ «در حال آپلود» رو نشون
// می‌ده تا وقتی createStoryBase64 واقعاً تموم بشه، بدون این‌که کاربر منتظر بمونه
const pendingFile = file;
const pendingOverlays = storyOverlays;
setPendingStoryUpload(queryClient, true);
router.push("/");
void (async () => {
try {
let optimized: File;
if (pendingFile.type.startsWith("video/")) {
// اگر تبدیل ویدیو (ffmpeg) در دسترس نبود، فایل اصلی ارسال می‌شود
try {
optimized = await optimizeVideoToMp4(pendingFile);
} catch {
optimized = pendingFile;
}
} else {
try {
optimized = await optimizeImageToWebP(pendingFile);
} catch {
optimized = pendingFile;
}
}
await createStoryBase64(
{
name: optimized.name,
type: optimized.type,
data: await fileToBase64(optimized),
},
token,
pendingOverlays
);
toast.success(t("posts.storyPublished"));
void queryClient.invalidateQueries({ queryKey: ["stories-feed"] });
} catch (err) {
toast.error(err instanceof Error ? err.message : t("posts.storyPublishError"));
} finally {
setPendingStoryUpload(queryClient, false);
}
await createStoryBase64(
{
name: optimized.name,
type: optimized.type,
data: await fileToBase64(optimized),
},
token,
storyOverlays
);
toast.success(t("posts.storyPublished"), { id: "story-upload" });
router.push("/");
} catch (err) {
toast.error(err instanceof Error ? err.message : t("posts.storyPublishError"), {
id: "story-upload",
});
}
})();
return;
}

View File

@@ -6,8 +6,11 @@ import RoundedButton from "@/components/elements/RoundedButton";
import RoundedDiv from "@/components/elements/RoundedDiv";
import { selectionCardClass } from "@/lib/ui/buttonStyles";
import { BASE_URL } from "@/components/main/BaseUrl";
import UserInfo from "@/components/main/UserInfo";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import VerificationBadge from "@/components/main/VerificationBadge";
import UserInfoClient from "@/components/main/UserInfoClient";
import ScoreRateStats from "@/components/main/ScoreRateStats";
import { formatFullName } from "@/lib/formatFullName";
import PageTitle from "@/components/settings/PageTitle";
import useAxios from "@/hooks/useAxios";
import { IOfferType, User } from "@/types/types";
@@ -21,6 +24,7 @@ import * as Yup from "yup";
import { useTranslation } from "react-i18next";
import { useUser } from "@/hooks/useUser";
import WalletUsagePrompt from "@/components/payment/WalletUsagePrompt";
import CompleteSocialSignupModal from "@/components/auth/CompleteSocialSignupModal";
type OfferTypeRow = IOfferType & {
disabled?: boolean;
unavailableReason?: string | null;
@@ -62,6 +66,8 @@ function TicketChat({ params }: ITicketChatProps) {
const [hasUsedFreeOffer, setHasUsedFreeOffer] = useState(false);
const [walletBalance, setWalletBalance] = useState(0);
const [showWalletPrompt, setShowWalletPrompt] = useState(false);
const [socialSignupOpen, setSocialSignupOpen] = useState(false);
const pendingPaymentRetryRef = React.useRef<(() => void) | null>(null);
useEffect(() => {
const fetchUser = async () => {
@@ -165,6 +171,14 @@ function TicketChat({ params }: ITicketChatProps) {
toast.error(t("offerPage.paymentInfoError"));
}
} catch (err: any) {
if (err?.response?.data?.type === "needs_username_password") {
pendingPaymentRetryRef.current = () => {
void startPayment(offerType, useWallet);
};
setSocialSignupOpen(true);
return;
}
console.error("Payment initiation error:", err);
toast.error(
err?.response?.data?.error ||
@@ -221,21 +235,34 @@ function TicketChat({ params }: ITicketChatProps) {
return t("offerPage.types.highlight");
};
// چیدمان این صفحه با UserInfo مشترک (استفاده‌شده توی نتایج جستجو/هدیه/...) فرق
// داره — عین اندروید (OfferScreen.tsx): ستون نام+یوزرنیم کنار آواتار، ستون
// سطح+ریت/امتیاز روبروش؛ به‌جای تغییر کامپوننت مشترک، چیدمان مخصوص همین صفحه‌ست
const userHeader = userDetail ? (
<div className="flex w-full items-end justify-between">
<UserInfo
first_name={userDetail?.first_name}
is_verified={userDetail?.verify_badge}
<div className="flex w-full items-center gap-3">
<ProfileAvatar
src={userDetail?.profile_image}
alt={userDetail?.user_name || ""}
size="md"
rounded="2xl"
userId={userDetail?._id}
profile_image={userDetail?.profile_image}
user_level={userDetail?.user_level}
last_name={userDetail?.last_name}
user_name={userDetail?.user_name}
noLink
trailingContent={
<ScoreRateStats score={userDetail?.user_score} rate={userDetail?.rate} />
}
verifyBadge={userDetail?.verify_badge}
/>
<div className="flex flex-1 items-start justify-between gap-2 text-xs max-sm:text-[10px] md:text-sm font-semibold">
<div className="flex flex-col gap-1">
<div className="font-semibold">
{formatFullName(userDetail?.first_name, userDetail?.last_name)}
</div>
<span className="inline-flex items-center gap-1">
{userDetail?.user_name}
<VerificationBadge verifyBadge={userDetail?.verify_badge} />
</span>
</div>
<div className="flex flex-col items-end gap-1">
<UserInfoClient user_level={userDetail?.user_level} />
<ScoreRateStats score={userDetail?.user_score} rate={userDetail?.rate} />
</div>
</div>
</div>
) : null;
@@ -308,6 +335,20 @@ function TicketChat({ params }: ITicketChatProps) {
void startPayment(selectedType, useWallet);
}}
/>
<CompleteSocialSignupModal
open={socialSignupOpen}
hasUsername={Boolean(me?.user_name)}
onClose={() => {
setSocialSignupOpen(false);
pendingPaymentRetryRef.current = null;
}}
onSuccess={() => {
setSocialSignupOpen(false);
pendingPaymentRetryRef.current?.();
pendingPaymentRetryRef.current = null;
}}
/>
</div>
</Container>
);

View File

@@ -11,6 +11,8 @@ import {
} from "@/lib/chat/socketClient";
import MessageInput from "@/components/chat/MessageInput";
import GiftInChatModal from "@/components/chat/GiftInChatModal";
import CompleteSocialSignupModal from "@/components/auth/CompleteSocialSignupModal";
import { AxiosError } from "axios";
import GiftPaymentResultModal from "@/components/chat/GiftPaymentResultModal";
import MultiImageModal from "@/components/chat/MultiImageModal";
import MediaPreviewModal from "@/components/chat/MediaPreviewModal";
@@ -90,6 +92,8 @@ function TicketChat({ params }: ITicketChatProps) {
null
);
const [viewOnceMedia, setViewOnceMedia] = useState(false);
const [socialSignupOpen, setSocialSignupOpen] = useState(false);
const pendingSendRetryRef = useRef<(() => void) | null>(null);
const user = useUser();
const queryClient = useQueryClient();
const { linkIds, getStableKey } = useStableMessageKeys();
@@ -359,18 +363,14 @@ function TicketChat({ params }: ITicketChatProps) {
const isFileUpload = fileForUpload && fileType !== "location";
try {
let sendContent = textContent;
try {
await ensureIdentityKeys(currentUserId);
const encrypted = await encryptDmContent(
currentUserId,
targetReceiverId,
textContent
);
if (encrypted) sendContent = encrypted;
} catch {
/* fallback plaintext if peer has no key yet */
}
await ensureIdentityKeys(currentUserId);
const encrypted = await encryptDmContent(
currentUserId,
targetReceiverId,
textContent
);
if (!encrypted) throw new Error("E2EE encryption failed");
const sendContent = encrypted;
let messagePayload: Record<string, unknown> = {
content: sendContent,
@@ -435,8 +435,19 @@ function TicketChat({ params }: ITicketChatProps) {
confirmSentMessage(tempId, serverMsg, replyPayload);
void queryClient.invalidateQueries({ queryKey: ["messages"] });
} catch (error) {
console.error("Error sending message:", error);
setPendingMessages((prev) => prev.filter((m) => m._id !== tempId));
const errType = (error as AxiosError<{ type?: string }>)?.response?.data
?.type;
if (errType === "needs_username_password") {
pendingSendRetryRef.current = () => {
void processSendMessage(fileToUpload, fileType, textContent);
};
setSocialSignupOpen(true);
return;
}
console.error("Error sending message:", error);
toast.error(t("chats.toast.sendFailedDot"));
return;
}
@@ -762,6 +773,20 @@ function TicketChat({ params }: ITicketChatProps) {
}}
/>
<CompleteSocialSignupModal
open={socialSignupOpen}
hasUsername={Boolean(user?.user_name)}
onClose={() => {
setSocialSignupOpen(false);
pendingSendRetryRef.current = null;
}}
onSuccess={() => {
setSocialSignupOpen(false);
pendingSendRetryRef.current?.();
pendingSendRetryRef.current = null;
}}
/>
<MultiImageModal
isOpen={showMultiModal}
files={pendingImages}

View File

@@ -43,12 +43,14 @@ import LocalePageShell from "@/components/i18n/LocalePageShell";
import { optimizeMediaFile } from "@/lib/media";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import {
E2EE_LOCKED_PREVIEW,
decryptRoomContent,
decryptRoomMessageList,
encryptRoomContent,
ensureIdentityKeys,
ensureRoomAesKey,
} from "@/lib/e2ee";
import CompleteSocialSignupModal from "@/components/auth/CompleteSocialSignupModal";
type RoomMember = {
_id: string;
@@ -164,6 +166,12 @@ function RoomMessageRow({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { onDragStart: _, ...restDragProps } = dragProps;
// طبقِ خواسته‌ی کاربر، E2EE باید کاملاً نامرئی باشه — پیامِ متنیِ خالصی که رمزگشایی
// نشده (هویتِ قدیمی ازدست‌رفته) اصلاً نمایش داده نمی‌شه، نه یه بابلِ خالی/قفل‌شده
const bubbleContent =
msg.content === E2EE_LOCKED_PREVIEW ? "" : msg.content;
if (!bubbleContent && !msg.file) return null;
return (
<div
ref={(el) => {
@@ -203,7 +211,11 @@ function RoomMessageRow({
<span className="font-semibold text-[#0095f6]">
{msg.replyTo.senderName}
</span>
<p className="truncate">{msg.replyTo.content}</p>
<p className="truncate">
{msg.replyTo.content === E2EE_LOCKED_PREVIEW
? ""
: msg.replyTo.content}
</p>
</button>
) : null}
<div className="relative w-full overflow-hidden">
@@ -288,9 +300,9 @@ function RoomMessageRow({
<span>File</span>
</a>
) : null}
{msg.content ? (
{bubbleContent ? (
<p className="chat-message-text select-text whitespace-pre-wrap break-words">
{msg.content}
{bubbleContent}
</p>
) : null}
<span className="mt-1 block text-[10px] opacity-70">
@@ -328,6 +340,8 @@ export default function ChatRoomThreadPage({
const { request } = useAxios();
const router = useRouter();
const user = useUser();
const [socialSignupOpen, setSocialSignupOpen] = useState(false);
const pendingSendRetryRef = useRef<(() => void) | null>(null);
const [room, setRoom] = useState<RoomDetail | null>(null);
const [messages, setMessages] = useState<RoomMessage[]>([]);
// این صفحه با تغییرِ فقط پارامترِ roomId دوباره mount نمی‌شه (رفتارِ App Router)،
@@ -548,9 +562,10 @@ export default function ChatRoomThreadPage({
const processSendMessage = async (
fileToUpload: File | null = null,
fileType?: RoomMessage["fileType"]
fileType?: RoomMessage["fileType"],
contentOverride?: string
) => {
const text = newMessage.trim();
const text = (contentOverride ?? newMessage).trim();
if ((!text && !fileToUpload) || sending) return;
const senderId = String(user?._id ?? getStoredUserId());
@@ -607,14 +622,10 @@ export default function ChatRoomThreadPage({
}
try {
let sendContent = text;
try {
await ensureIdentityKeys(senderId);
const encrypted = await encryptRoomContent(senderId, roomId, text);
if (encrypted) sendContent = encrypted;
} catch {
/* fallback plaintext */
}
await ensureIdentityKeys(senderId);
const encrypted = await encryptRoomContent(senderId, roomId, text);
if (!encrypted) throw new Error("E2EE encryption failed");
const sendContent = encrypted;
let res: { message?: RoomMessage } | null = null;
if (fileForUpload) {
@@ -663,6 +674,17 @@ export default function ChatRoomThreadPage({
if (replyPayload && savedReply) {
setReplyingTo(savedReply);
}
const errType = (err as { response?: { data?: { type?: string } } })
?.response?.data?.type;
if (errType === "needs_username_password") {
pendingSendRetryRef.current = () => {
void processSendMessage(fileToUpload, fileType, text);
};
setSocialSignupOpen(true);
return;
}
const message =
(err as { response?: { data?: { error?: string; message?: string } } })
?.response?.data?.error ||
@@ -957,6 +979,20 @@ export default function ChatRoomThreadPage({
existingMemberIds={room?.members?.map((member) => member._id) ?? []}
onAdded={() => void loadRoom()}
/>
<CompleteSocialSignupModal
open={socialSignupOpen}
hasUsername={Boolean(user?.user_name)}
onClose={() => {
setSocialSignupOpen(false);
pendingSendRetryRef.current = null;
}}
onSuccess={() => {
setSocialSignupOpen(false);
pendingSendRetryRef.current?.();
pendingSendRetryRef.current = null;
}}
/>
</Container>
</LocalePageShell>
);

View File

@@ -6,8 +6,7 @@ import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import { SettingsListSkeleton } from "@/components/settings/EditPageSkeletons";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import Image from "next/image";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import Link from "next/link";
import { useTranslation } from "react-i18next";
@@ -17,6 +16,7 @@ type BlockedUser = {
first_name?: string;
last_name?: string;
profile_image?: string;
verify_badge?: string;
};
function BlockedUsersPage() {
@@ -57,19 +57,13 @@ function BlockedUsersPage() {
href={`/users/${u.user_name}`}
className="flex items-center gap-3 rounded-2xl px-2 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-800"
>
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-full bg-neutral-200">
<Image
src={
u.profile_image
? buildStorageUrl(u.profile_image)
: "/images/fake-avatar.png"
}
alt=""
fill
sizes="44px"
className="object-cover"
/>
</div>
<ProfileAvatar
src={u.profile_image}
userId={u._id}
verifyBadge={u.verify_badge}
sizePx={44}
rounded="full"
/>
<div className="min-w-0">
<p className="truncate text-sm font-bold">{name}</p>
<p className="truncate text-xs text-neutral-500">

View File

@@ -16,6 +16,7 @@ const EDIT_NAV_KEY: Record<string, string> = {
"/username": "username",
"/password": "password",
"/google-account": "googleAccount",
"/register-mobile": "registerMobile",
"/two-factor": "twoFactor",
"/link-device": "linkDevice",
"/avatar": "avatar",
@@ -51,7 +52,8 @@ function EditPage() {
{editUserNavLinks?.map((item) => (
<Link
className={`flex items-center gap-2 ${
item.href === "/License" && isRegister === "false"
(item.href === "/License" && isRegister === "false") ||
(item.href === "/register-mobile" && Boolean(user?.mobile))
? "hidden"
: ""
}`}

View File

@@ -16,6 +16,7 @@ import BackButton from "@/components/ui/BackButton";
import { useUser } from "@/hooks/useUser";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { establishIdentityAtLogin } from "@/lib/e2ee";
function PasswordPage() {
const { t } = useTranslation("common");
@@ -60,6 +61,11 @@ function PasswordPage() {
...(user?.mobile ? { mobile: user.mobile } : {}),
new_password: values.password,
})) as IVerifyOtp;
// رمزِ عبور عوض شد (یا اولین‌بار تنظیم شد) — کلیدِ E2EEِ همین مرورگر
// (دست‌نخورده توی IndexedDB) رو زیرِ رمزِ جدید دوباره بک‌آپ بگیر
if (user?._id) {
void establishIdentityAtLogin(user._id, values.password);
}
toast.success(
isGoogleUser
? t("settings.edit.password.setSuccess")

View File

@@ -0,0 +1,132 @@
"use client";
import AuthOtpInput from "@/components/auth/AuthOtpInput";
import AuthOtpStatus from "@/components/auth/AuthOtpStatus";
import Container from "@/components/elements/Container";
import RoundedButton from "@/components/elements/RoundedButton";
import RoundedInput from "@/components/elements/RoundedInput";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import { markOtpSent } from "@/lib/auth/otpTimer";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
export default function RegisterMobilePage() {
const { t } = useTranslation("common");
const { request } = useAxios();
const [step, setStep] = useState<"mobile" | "otp">("mobile");
const [mobile, setMobile] = useState("");
const [otp, setOtp] = useState("");
const [otpError, setOtpError] = useState(false);
const [otpExpired, setOtpExpired] = useState(false);
const [sending, setSending] = useState(false);
const [verifying, setVerifying] = useState(false);
const [resendLoading, setResendLoading] = useState(false);
const sendOtp = async () => {
const cleaned = mobile.replace(/\D/g, "");
if (!/^09[0-9]{9}$/.test(cleaned)) {
toast.error(t("auth.invalidMobile"));
return;
}
setSending(true);
try {
await request("POST", "/account/mobile/request", { mobile: cleaned });
markOtpSent();
setStep("otp");
} catch {
// toast از interceptor
} finally {
setSending(false);
}
};
const verify = async () => {
if (otp.length !== 6) return;
setVerifying(true);
try {
await request("POST", "/account/mobile/verify", { otp });
toast.success(t("settings.edit.registerMobile.success"));
// نه router.push — صفحه‌ی edit با useUser کش‌شده هنوز mobile رو خالی نشون
// می‌داد؛ یه ناوبریِ کامل تضمین می‌کنه که آیتم از لیست درست حذف بشه
window.location.href = "/settings/edit";
} catch {
setOtpError(true);
} finally {
setVerifying(false);
}
};
return (
<LocalePageShell>
<Container>
<PageTitle>{t("settings.edit.nav.registerMobile")}</PageTitle>
<div className="mt-6 flex flex-col items-center gap-4 text-sm">
{step === "mobile" ? (
<>
<p className="text-center text-neutral-500">
{t("settings.edit.registerMobile.hint")}
</p>
<RoundedInput
value={mobile}
onChange={(e) => setMobile(e.target.value)}
placeholder="09xxxxxxxxx"
dir="ltr"
className="text-center"
maxLength={11}
/>
<RoundedButton
variant="primary"
className="h-11 w-full"
disabled={sending}
onClick={sendOtp}
>
{sending ? t("common.loading") : t("auth.sendCode")}
</RoundedButton>
</>
) : (
<>
<AuthOtpStatus
mobile={mobile.replace(/\D/g, "")}
resendLoading={resendLoading}
onExpiredChange={setOtpExpired}
onResend={async () => {
setResendLoading(true);
try {
await request("POST", "/account/mobile/request", {
mobile: mobile.replace(/\D/g, ""),
});
setOtp("");
setOtpError(false);
} finally {
setResendLoading(false);
}
}}
/>
<AuthOtpInput
value={otp}
onChange={(v) => {
setOtp(v);
setOtpError(false);
}}
onComplete={() => setTimeout(() => void verify(), 400)}
error={otpError}
disabled={verifying || otpExpired}
/>
<RoundedButton
variant="primary"
className="mt-3 h-11 w-full"
disabled={verifying || otp.length !== 6}
onClick={() => void verify()}
>
{verifying ? t("common.loading") : t("auth.verifyCode")}
</RoundedButton>
</>
)}
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -13,6 +13,7 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useAppLanguage } from "@/contexts/LanguageProvider";
import { isAppLanguage, type AppLanguage } from "@/lib/i18n/constants";
import { isSubUserSession } from "@/lib/auth/session";
// زبان‌هایی که آیتم‌های آنالیتیکس/کاربران مسدود/بایگانی/فعالیت شما فقط توی اونا نمایش داده می‌شن
const SETTINGS_ITEMS_VISIBLE_LANGUAGES: AppLanguage[] = ["fa", "ar", "en"];
@@ -215,6 +216,17 @@ export default function UserSettingsPage() {
<span className="text-neutral-400">›</span>
</Link>
{/* «مدیریت کاربران» فقط مالِ خودِ صاحبِ اکانته، نه کاربرِ زیرمجموعه */}
{!isSubUserSession() && (
<Link
href="/settings/sub-users"
className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<span className="font-semibold">{t("settings.nav.subUsers")}</span>
<span className="text-neutral-400">›</span>
</Link>
)}
<Link
href="/settings/booking"
className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"

View File

@@ -0,0 +1,291 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import RoundedButton from "@/components/elements/RoundedButton";
import RoundedInput from "@/components/elements/RoundedInput";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import { SUB_USER_PERMISSION_KEYS } from "@/constants/subUserPermissions";
import useAxios from "@/hooks/useAxios";
import Image from "next/image";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
type SubUser = {
_id: string;
first_name?: string;
last_name?: string;
user_name: string;
profile_image?: string | null;
sub_user_active: boolean;
sub_user_permissions: string[];
};
export default function SubUserDetailPage() {
const { t } = useTranslation("common");
const { request } = useAxios();
const params = useParams<{ id: string }>();
const id = params?.id;
// نه router.back()/Link — چون توی این صفحه یه غیرفعال/فعال‌سازی یا ویرایش ممکنه
// انجام شده باشه و لیستِ صفحه‌ی قبل (که فقط با useEffect یه‌بار دیتاش رو می‌گیره)
// بدونِ یه ناوبریِ کامل (نه soft) وضعیتِ تازه رو نشون نمی‌داد
const backToList = () => {
window.location.href = "/settings/sub-users";
};
const [subUser, setSubUser] = useState<SubUser | null>(null);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [toggling, setToggling] = useState(false);
// فرمِ ویرایش
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [userName, setUserName] = useState("");
const [password, setPassword] = useState("");
const [avatar, setAvatar] = useState<string | null>(null);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [permissions, setPermissions] = useState<string[]>([]);
const load = () => {
if (!id) return;
request<{ subUser?: SubUser }>("GET", `/sub-users/${id}`, null, { noToast: true })
.then((res) => setSubUser(res?.subUser ?? null))
.catch(() => setSubUser(null));
};
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
const startEditing = () => {
if (!subUser) return;
setFirstName(subUser.first_name || "");
setLastName(subUser.last_name || "");
setUserName(subUser.user_name || "");
setPassword("");
setAvatar(subUser.profile_image ? buildStorageUrl(subUser.profile_image) : null);
setAvatarFile(null);
setPermissions(subUser.sub_user_permissions || []);
setEditing(true);
};
const togglePermission = (key: string) => {
setPermissions((prev) =>
prev.includes(key) ? prev.filter((p) => p !== key) : [...prev, key]
);
};
const selectImage = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setAvatarFile(file);
const reader = new FileReader();
reader.onload = () => setAvatar(reader.result as string);
reader.readAsDataURL(file);
};
const save = async () => {
if (!id || !firstName.trim() || !userName.trim()) {
toast.error(t("auth.usernamePasswordRequired"));
return;
}
setSaving(true);
try {
const formData = new FormData();
formData.append("first_name", firstName.trim());
formData.append("last_name", lastName.trim());
formData.append("user_name", userName.trim());
if (password) formData.append("password", password);
formData.append("permissions", JSON.stringify(permissions));
if (avatarFile) formData.append("profile_image", avatarFile);
await request("PATCH", `/sub-users/${id}`, formData);
toast.success(t("subUsers.updateSuccess"));
setEditing(false);
load();
} catch {
// toast از interceptor
} finally {
setSaving(false);
}
};
const toggleActive = async () => {
if (!id || !subUser) return;
setToggling(true);
try {
await request("PATCH", `/sub-users/${id}/active`, {
active: !subUser.sub_user_active,
});
toast.success(
t(subUser.sub_user_active ? "subUsers.disableSuccess" : "subUsers.enableSuccess")
);
load();
} catch {
// toast از interceptor
} finally {
setToggling(false);
}
};
if (!subUser) {
return (
<LocalePageShell>
<Container>
<PageTitle onBack={backToList}>{t("subUsers.editTitle")}</PageTitle>
<p className="py-10 text-center text-gray-500">{t("common.loading")}</p>
</Container>
</LocalePageShell>
);
}
const name = [subUser.first_name, subUser.last_name].filter(Boolean).join(" ") || subUser.user_name;
if (editing) {
return (
<LocalePageShell>
<Container>
<PageTitle onBack={backToList}>{t("subUsers.editTitle")}</PageTitle>
<div className="mt-6 flex flex-col items-center gap-5 text-xs md:text-sm">
<div className="flex flex-col items-center gap-3">
<div className="relative flex h-[110px] w-[110px] items-center justify-center overflow-hidden rounded-full border border-[#676767]">
{avatar ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={avatar} alt="" className="h-full w-full object-cover" />
) : (
<label htmlFor="subUserAvatarEditInput" className="flex h-full w-full cursor-pointer items-center justify-center">
<Image src="/images/icons/gallery-add.svg" width={40} height={40} alt="" />
</label>
)}
</div>
<input
id="subUserAvatarEditInput"
type="file"
accept="image/*"
className="hidden"
onChange={selectImage}
/>
<label htmlFor="subUserAvatarEditInput" className="cursor-pointer text-neutral-500 underline">
{t("settings.edit.avatar.editImage")}
</label>
</div>
<div className="w-full space-y-3">
<div>
<p className="mb-2 font-semibold">{t("subUsers.nameLabel")}</p>
<RoundedInput value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</div>
<div>
<p className="mb-2 font-semibold">{t("subUsers.lastNameLabel")}</p>
<RoundedInput value={lastName} onChange={(e) => setLastName(e.target.value)} />
</div>
<div>
<p className="mb-2 font-semibold">{t("subUsers.usernameLabel")}</p>
<RoundedInput value={userName} onChange={(e) => setUserName(e.target.value)} dir="ltr" />
</div>
<div>
<p className="mb-2 font-semibold">{t("subUsers.passwordLabel")}</p>
<RoundedInput
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("subUsers.passwordEditHint")}
type="password"
dir="ltr"
/>
</div>
</div>
<div className="w-full">
<p className="mb-3 font-semibold">{t("subUsers.permissionsLabel")}</p>
<div className="space-y-2">
{SUB_USER_PERMISSION_KEYS.map((key) => (
<label
key={key}
className="flex items-center gap-3 rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<input
type="checkbox"
checked={permissions.includes(key)}
onChange={() => togglePermission(key)}
className="h-4 w-4"
/>
<span>{t(`subUsers.permissions.${key}`)}</span>
</label>
))}
</div>
</div>
<div className="flex w-full gap-3">
<RoundedButton className="h-11 flex-1" onClick={() => setEditing(false)} disabled={saving}>
{t("ads.backAction")}
</RoundedButton>
<RoundedButton variant="primary" className="h-11 flex-1" onClick={save} disabled={saving}>
{saving ? t("subUsers.saving") : t("subUsers.save")}
</RoundedButton>
</div>
</div>
</Container>
</LocalePageShell>
);
}
return (
<LocalePageShell>
<Container>
<PageTitle onBack={backToList}>{t("subUsers.editTitle")}</PageTitle>
<div className="mt-6 flex flex-col items-center gap-4 text-xs md:text-sm">
<ProfileAvatar src={subUser.profile_image} size="lg" rounded="full" />
<div className="text-center">
<p className="text-lg font-bold">{name}</p>
<p className="text-neutral-500">@{subUser.user_name}</p>
</div>
<div className="w-full">
<p className="mb-3 font-semibold">{t("subUsers.permissionsTitle")}</p>
<div className="space-y-3">
{SUB_USER_PERMISSION_KEYS.map((key) => {
const granted = subUser.sub_user_permissions?.includes(key);
return (
<div key={key}>
<p className="mb-2 text-sm font-semibold">{t(`subUsers.permissions.${key}`)}</p>
<div
className={
"flex min-h-[42px] w-full items-center rounded-2xl border px-4 py-2.5 text-sm " +
(granted
? "border-green-300 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950/30 dark:text-green-400"
: "border-[#C3C3C3] bg-white/80 text-neutral-500 dark:border-neutral-600 dark:bg-neutral-900/80")
}
>
{t(granted ? "settings.yes" : "settings.no")}
</div>
</div>
);
})}
</div>
</div>
<div className="flex w-full gap-3 pt-2">
<RoundedButton className="h-11 flex-1" onClick={startEditing}>
{t("subUsers.edit")}
</RoundedButton>
<RoundedButton
variant={subUser.sub_user_active ? "default" : "primary"}
className="h-11 flex-1"
onClick={toggleActive}
disabled={toggling}
>
{t(subUser.sub_user_active ? "subUsers.disable" : "subUsers.enable")}
</RoundedButton>
</div>
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,172 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import RoundedButton from "@/components/elements/RoundedButton";
import RoundedInput from "@/components/elements/RoundedInput";
import { SUB_USER_PERMISSION_KEYS } from "@/constants/subUserPermissions";
import useAxios from "@/hooks/useAxios";
import Image from "next/image";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
export default function NewSubUserPage() {
const { t } = useTranslation("common");
const { request } = useAxios();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [userName, setUserName] = useState("");
const [password, setPassword] = useState("");
const [avatar, setAvatar] = useState<string | null>(null);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [permissions, setPermissions] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
const togglePermission = (key: string) => {
setPermissions((prev) =>
prev.includes(key) ? prev.filter((p) => p !== key) : [...prev, key]
);
};
const selectImage = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setAvatarFile(file);
const reader = new FileReader();
reader.onload = () => setAvatar(reader.result as string);
reader.readAsDataURL(file);
};
const submit = async () => {
if (!firstName.trim() || !userName.trim() || !password) {
toast.error(t("auth.usernamePasswordRequired"));
return;
}
setSubmitting(true);
try {
const formData = new FormData();
formData.append("first_name", firstName.trim());
formData.append("last_name", lastName.trim());
formData.append("user_name", userName.trim());
formData.append("password", password);
formData.append("permissions", JSON.stringify(permissions));
if (avatarFile) formData.append("profile_image", avatarFile);
await request("POST", "/sub-users", formData);
toast.success(t("subUsers.createSuccess"));
// router.push ساده کافی نیست — صفحه‌ی لیست دیتاش رو فقط توی useEffect (یه بار
// موقع mount) می‌گیره؛ چون Next.js موقعِ برگشت این صفحه رو از کشِ خودش (نه
// واقعاً remount) نشون می‌ده، useEffect دوباره اجرا نمی‌شه و کاربرِ تازه‌ساخته
// دیده نمی‌شه تا یه رفرشِ دستی. navigation کامل (نه soft) این رو تضمین می‌کنه.
window.location.href = "/settings/sub-users";
} catch {
// خطا با toast interceptor خودِ useAxios نمایش داده می‌شه
} finally {
setSubmitting(false);
}
};
return (
<LocalePageShell>
<Container>
<PageTitle>{t("subUsers.createTitle")}</PageTitle>
<div className="mt-6 flex flex-col items-center gap-5 text-xs md:text-sm">
<div className="flex flex-col items-center gap-3">
<div className="relative flex h-[110px] w-[110px] items-center justify-center overflow-hidden rounded-full border border-[#676767]">
{avatar ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={avatar} alt="" className="h-full w-full object-cover" />
) : (
<label htmlFor="subUserAvatarInput" className="flex h-full w-full cursor-pointer items-center justify-center">
<Image src="/images/icons/gallery-add.svg" width={40} height={40} alt="" />
</label>
)}
</div>
<input
id="subUserAvatarInput"
type="file"
accept="image/*"
className="hidden"
onChange={selectImage}
/>
<label htmlFor="subUserAvatarInput" className="cursor-pointer text-neutral-500 underline">
{t("settings.edit.avatar.editImage")}
</label>
</div>
<div className="w-full space-y-3">
<div>
<p className="mb-2 font-semibold">{t("subUsers.nameLabel")}</p>
<RoundedInput
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder={t("subUsers.namePlaceholder")}
/>
</div>
<div>
<p className="mb-2 font-semibold">{t("subUsers.lastNameLabel")}</p>
<RoundedInput
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder={t("subUsers.lastNamePlaceholder")}
/>
</div>
<div>
<p className="mb-2 font-semibold">{t("subUsers.usernameLabel")}</p>
<RoundedInput
value={userName}
onChange={(e) => setUserName(e.target.value)}
placeholder={t("subUsers.usernamePlaceholder")}
autoCapitalize="none"
dir="ltr"
/>
</div>
<div>
<p className="mb-2 font-semibold">{t("subUsers.passwordLabel")}</p>
<RoundedInput
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("subUsers.passwordPlaceholder")}
type="password"
dir="ltr"
/>
</div>
</div>
<div className="w-full">
<p className="mb-3 font-semibold">{t("subUsers.permissionsLabel")}</p>
<div className="space-y-2">
{SUB_USER_PERMISSION_KEYS.map((key) => (
<label
key={key}
className="flex items-center gap-3 rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<input
type="checkbox"
checked={permissions.includes(key)}
onChange={() => togglePermission(key)}
className="h-4 w-4"
/>
<span>{t(`subUsers.permissions.${key}`)}</span>
</label>
))}
</div>
</div>
<RoundedButton
variant="primary"
className="mt-2 h-11 w-full"
disabled={submitting}
onClick={submit}
>
{submitting ? t("subUsers.saving") : t("subUsers.save")}
</RoundedButton>
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import EmptyState from "@/components/elements/EmptyState";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import { staticIconUrl } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
type SubUserRow = {
_id: string;
first_name?: string;
last_name?: string;
user_name: string;
profile_image?: string | null;
sub_user_active: boolean;
};
export default function SubUsersListPage() {
const { t } = useTranslation("common");
const { request } = useAxios();
const [subUsers, setSubUsers] = useState<SubUserRow[] | null>(null);
useEffect(() => {
request<{ subUsers?: SubUserRow[] }>("GET", "/sub-users", null, { noToast: true })
.then((res) => setSubUsers(res?.subUsers ?? []))
.catch(() => setSubUsers([]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<LocalePageShell>
<Container>
<PageTitle
action={
<Link href="/settings/sub-users/new" aria-label={t("subUsers.createTitle")} className="p-2">
<Image
width={24}
height={24}
alt=""
src={staticIconUrl("/images/icons/add.svg")}
className="dark:brightness-0 dark:invert"
/>
</Link>
}
>
{t("subUsers.listTitle")}
</PageTitle>
<div className="mt-6 space-y-3 text-xs md:text-sm">
{subUsers === null ? (
<p className="py-10 text-center text-gray-500">{t("common.loading")}</p>
) : subUsers.length === 0 ? (
<EmptyState
icon="profile-2user"
title={t("subUsers.emptyTitle")}
subtitle={t("subUsers.emptyHint")}
/>
) : (
subUsers.map((su) => {
const name = [su.first_name, su.last_name].filter(Boolean).join(" ") || su.user_name;
return (
<Link
key={su._id}
href={`/settings/sub-users/${su._id}`}
className="flex items-center gap-3 rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<ProfileAvatar src={su.profile_image} size="sm" rounded="full" />
<div className="min-w-0 flex-1">
<p className="truncate font-semibold">{name}</p>
<p className="truncate text-neutral-500">@{su.user_name}</p>
</div>
<span
className={
su.sub_user_active
? "shrink-0 text-green-600"
: "shrink-0 text-neutral-400"
}
>
{t(su.sub_user_active ? "subUsers.active" : "subUsers.inactive")}
</span>
</Link>
);
})
)}
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,228 @@
"use client";
import { useState } from "react";
import Modal from "@/components/elements/Modal";
import RoundedButton from "@/components/elements/RoundedButton";
import RoundedInput from "@/components/elements/RoundedInput";
import useAxios from "@/hooks/useAxios";
import { AxiosError } from "axios";
import { getStoredUserId } from "@/lib/auth/session";
import { establishIdentityAtLogin } from "@/lib/e2ee";
const USERNAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._-]{5,29}$/;
// باید دقیقا با STRONG_PASSWORD_REGEX سمت بک‌اند (utils/strongPasswordValidation.js) یکی باشد
const STRONG_PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,64}$/;
type CompleteSocialSignupResponse = {
message: string;
user_name: string;
};
type ErrorResponseData = {
error?: boolean;
field?: "user_name" | "password";
message?: string;
suggestions?: string[];
};
type CompleteSocialSignupModalProps = {
open: boolean;
onClose: () => void;
/** بعد از ثبت موفق نام‌کاربری+رمز عبور صدا زده می‌شود تا اکشن اصلی (ارسال پیام/ثبت درخواست) دوباره اجرا شود */
onSuccess: (userName: string) => void;
/** اگر کاربر از قبل نام‌کاربری دارد فقط مرحله‌ی رمز عبور نشان داده می‌شود */
hasUsername: boolean;
};
export default function CompleteSocialSignupModal({
open,
onClose,
onSuccess,
hasUsername,
}: CompleteSocialSignupModalProps) {
const { request } = useAxios();
const [step, setStep] = useState<"username" | "password">(
hasUsername ? "password" : "username"
);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [usernameError, setUsernameError] = useState<string | null>(null);
const [passwordError, setPasswordError] = useState<string | null>(null);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
const reset = () => {
setStep(hasUsername ? "password" : "username");
setUsername("");
setPassword("");
setConfirmPassword("");
setUsernameError(null);
setPasswordError(null);
setSuggestions([]);
setSubmitting(false);
};
const handleClose = () => {
reset();
onClose();
};
const handleUsernameNext = () => {
const trimmed = username.trim().toLowerCase();
if (!USERNAME_REGEX.test(trimmed)) {
setUsernameError(
"نام کاربری باید ۶ تا ۳۰ کاراکتر و شامل حروف انگلیسی، عدد و . _ - باشد"
);
return;
}
setUsernameError(null);
setUsername(trimmed);
setStep("password");
};
const handleSubmit = async () => {
if (!STRONG_PASSWORD_REGEX.test(password)) {
setPasswordError(
"رمز عبور باید حداقل ۸ کاراکتر و شامل حروف بزرگ، حروف کوچک، عدد و یک کاراکتر خاص باشد"
);
return;
}
if (password !== confirmPassword) {
setPasswordError("رمز عبور و تکرار آن یکسان نیستند");
return;
}
setPasswordError(null);
setSubmitting(true);
try {
const response = await request<CompleteSocialSignupResponse>(
"POST",
"/register/complete-social-signup",
hasUsername ? { password } : { user_name: username, password }
);
// اولین باریه این کاربر رمزِ عبور داره — همین الان کلیدِ E2EE رو زیرش بک‌آپ بگیر
const userId = getStoredUserId();
if (userId) void establishIdentityAtLogin(userId, password);
reset();
onSuccess(response.user_name);
} catch (err) {
const data = (err as AxiosError<ErrorResponseData>)?.response?.data;
if (data?.field === "user_name") {
setStep("username");
setUsernameError(data.message || "نام کاربری نامعتبر است");
setSuggestions(data.suggestions || []);
} else {
setPasswordError(data?.message || "خطایی رخ داد، دوباره تلاش کنید");
}
} finally {
setSubmitting(false);
}
};
return (
<Modal
isOpen={open}
onClose={handleClose}
height="fit"
elevated
panelClassName="!p-0 max-h-[85dvh] w-full overflow-y-auto"
>
<div className="px-4 pb-6 pt-5">
<h3 className="mb-2 text-center text-sm font-bold">
تکمیل حساب کاربری
</h3>
<p className="mb-4 text-center text-xs text-neutral-500 dark:text-neutral-400">
برای امنیت گفتگوهاتون، یک نام کاربری و رمز عبور تنظیم کنید
</p>
{step === "username" ? (
<div className="mx-auto w-full max-w-sm space-y-3">
<RoundedInput
type="text"
placeholder="نام کاربری"
dir="ltr"
value={username}
onChange={(e) => {
setUsername(e.target.value);
setUsernameError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleUsernameNext();
}}
/>
{usernameError ? (
<p className="text-center text-xs text-red-500">{usernameError}</p>
) : null}
{suggestions.length > 0 ? (
<div className="flex flex-wrap justify-center gap-2">
{suggestions.map((s) => (
<button
key={s}
type="button"
onClick={() => {
setUsername(s);
setUsernameError(null);
setSuggestions([]);
}}
className="rounded-full border border-neutral-300 px-3 py-1 text-xs dark:border-neutral-700"
>
{s}
</button>
))}
</div>
) : null}
<RoundedButton
type="button"
variant="primary"
className="h-10 w-full"
onClick={handleUsernameNext}
disabled={!username.trim()}
>
ثبت
</RoundedButton>
</div>
) : (
<div className="mx-auto w-full max-w-sm space-y-3">
<RoundedInput
type="password"
placeholder="رمز عبور"
dir="ltr"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setPasswordError(null);
}}
/>
<RoundedInput
type="password"
placeholder="تکرار رمز عبور"
dir="ltr"
value={confirmPassword}
onChange={(e) => {
setConfirmPassword(e.target.value);
setPasswordError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") void handleSubmit();
}}
/>
{passwordError ? (
<p className="text-center text-xs text-red-500">{passwordError}</p>
) : null}
<RoundedButton
type="button"
variant="primary"
className="h-10 w-full"
onClick={() => void handleSubmit()}
disabled={submitting || !password || !confirmPassword}
>
{submitting ? "در حال ثبت..." : "تایید و ادامه"}
</RoundedButton>
</div>
)}
</div>
</Modal>
);
}

View File

@@ -6,6 +6,7 @@ import { motion, AnimatePresence } from "framer-motion";
import Link from "next/link";
import { User } from "@/types/types";
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import ReadReceipt from "./ReadReceipt";
import VoiceMessagePlayer from "./VoiceMessagePlayer";
import FileMessageBubble from "./FileMessageBubble";
@@ -274,10 +275,13 @@ function parseForwarded(content: string): ChatMessage["forwardedFrom"] | null {
}
}
/** متن قابل نمایش — پاکت E2EE را با regex خراب نکن */
/** متن قابل نمایش — پاکت E2EE را با regex خراب نکن. طبقِ خواسته‌ی کاربر، E2EE باید کاملاً
* نامرئی باشه — یعنی حتی اگه یه پیامِ قدیمی (هویتِ ازدست‌رفته) رمزگشایی نشه، به‌جای
* نمایشِ متنِ «پیام رمزنگاری‌شده»، چیزی نشون داده نمی‌شه (رشته‌ی خالی؛ خودِ بابل هم
* پایین‌تر، در صورتِ نبودِ فایل/موقعیت/اشتراک‌گذاری، کلاً رندر نمی‌شه) */
function getBubbleTextContent(content: string | undefined): string {
if (!content) return "";
if (isE2eeEnvelope(content)) return E2EE_LOCKED_PREVIEW;
if (isE2eeEnvelope(content) || content === E2EE_LOCKED_PREVIEW) return "";
if (parseLocationContent(content)) return "";
const forwarded = parseForwarded(content);
@@ -421,15 +425,29 @@ const ChatMessageCard = ({
const hasLocation = message.fileType === "location" || !!locationData;
const reactionGroups = groupReactions(message.reactions);
if (
!message.content &&
// پیامِ متنیِ خالص که رمزگشایی نشده (هویتِ قدیمی ازدست‌رفته) — به‌جای یه بابلِ خالی یا
// متنِ «رمزنگاری‌شده»، کلاً نمایش داده نمی‌شه؛ طبقِ خواسته‌ی کاربر کاربر نباید هیچ اثری
// از مکانیزمِ E2EE رو ببینه
const isUndecryptedTextOnly =
(isE2eeEnvelope(message.content) ||
message.content === E2EE_LOCKED_PREVIEW) &&
!message.file &&
!hasLocation &&
!sharedPost &&
!sharedProduct &&
!sharedGift &&
!sharedStory &&
!(message.viewOnce && isViewOnceMediaType(message.fileType))
!sharedStory;
if (
(!message.content &&
!message.file &&
!hasLocation &&
!sharedPost &&
!sharedProduct &&
!sharedGift &&
!sharedStory &&
!(message.viewOnce && isViewOnceMediaType(message.fileType))) ||
isUndecryptedTextOnly
) {
return null;
}
@@ -596,7 +614,9 @@ const ChatMessageCard = ({
<span className="font-semibold text-[#0095f6]">
{message.replyTo.senderName}
</span>
<p className="truncate">{message.replyTo.content}</p>
<p className="truncate">
{getBubbleTextContent(message.replyTo.content)}
</p>
</button>
)}
@@ -877,15 +897,12 @@ const ChatMessageCard = ({
{!isSender && (
<div className="flex w-7 shrink-0 items-end self-end">
{showAvatar && partnerAvatar ? (
<div className="relative h-7 w-7 overflow-hidden rounded-full bg-neutral-700">
<Image
src={buildStorageUrl(partnerAvatar)}
alt=""
fill
className="object-cover"
sizes="28px"
/>
</div>
<ProfileAvatar
src={partnerAvatar}
userId={message.senderId}
sizePx={28}
rounded="full"
/>
) : (
<div className="h-7 w-7" aria-hidden />
)}

View File

@@ -1,9 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import useAxios from "@/hooks/useAxios";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import BoldIcon from "@/components/ui/BoldIcon";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -14,6 +13,7 @@ export type ChatRoomMember = {
first_name?: string;
last_name?: string;
profile_image?: string;
verify_badge?: string;
};
type Props = {
@@ -22,13 +22,6 @@ type Props = {
max?: number;
};
function avatarSrc(profileImage?: string) {
if (!profileImage) return null;
return profileImage.startsWith("http")
? profileImage
: buildStorageUrl(profileImage);
}
function displayName(user: ChatRoomMember) {
return (
[user.first_name, user.last_name].filter(Boolean).join(" ") || user.user_name
@@ -118,19 +111,13 @@ export default function ChatRoomMemberPicker({
onClick={() => addUser(user)}
className="flex w-full items-center gap-2 px-3 py-2 text-right hover:bg-neutral-50 dark:hover:bg-neutral-800"
>
{avatarSrc(user.profile_image) ? (
<Image
src={avatarSrc(user.profile_image)!}
alt=""
width={32}
height={32}
className="h-8 w-8 rounded-full object-cover"
/>
) : (
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-neutral-200 text-xs dark:bg-neutral-700">
{user.user_name?.[0]?.toUpperCase() || "?"}
</span>
)}
<ProfileAvatar
src={user.profile_image}
userId={user._id}
verifyBadge={user.verify_badge}
sizePx={32}
rounded="full"
/>
<span className="min-w-0 flex-1 truncate text-sm">
{displayName(user)}
<span className="block text-[11px] text-neutral-500" dir="ltr">

View File

@@ -1,176 +1,31 @@
"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 };
import { useEffect } from "react";
import { ensureIdentityKeys } from "@/lib/e2ee";
/**
* موقتاً کلاً غیرفعال — چون رمزنگاریِ خودِ پیام‌ها (encryptDmContent/encryptRoomContent)
* موقتاً خاموشه (به‌درخواستِ کاربر، چون بعضی وقت‌ها هنوز «پیام رمزنگاری‌شده» گیر
* می‌کرد)، دیگه نیازی نیست کاربر رو با کارتِ بازیابیِ هویت معطل کنیم. برای
* فعال‌سازیِ دوباره، فقط همین return null رو بردار.
* هیچ‌وقت چیزی نمایش نمی‌ده — طبقِ خواسته‌ی کاربر، E2EE باید کاملاً نامرئی باشه، نه
* رمز/کلیدی ازش بپرسه، نه با قفل‌شدنِ پیام‌ها نشونش بده. establishIdentityAtLogin
* (lib/e2ee/keys.ts) معمولاً همون لحظه‌ی لاگین این کار رو با رمزِ واردشده انجام می‌ده؛
* این کامپوننت فقط برای حالتِ نادرِ وسطِ سشنه (مثلاً IndexedDB بدونِ خروج از حساب پاک
* شده) — فقط یه‌بار سعی می‌کنه کلیدِ محلی رو گرم کنه.
*
* توی ChatSocketProvider (سطحِ لایوت /settings/chats) mount می‌شه — جایی که
* ensureIdentityKeys برای اولین‌بار صدا زده می‌شه.
* عمداً دیگه اینجا createFreshIdentity صدا زده نمی‌شه: این کامپوننت هیچ رمزی در
* اختیار نداره، پس اگه ensureIdentityKeys نیاز به بازیابی رو تشخیص بده، هیچ راهِ
* امنی برای تاییدِ «این هویتِ قدیمی واقعاً غیرقابل‌بازگشته» وجود نداره — ساختِ
* خودکارِ یه هویتِ تازه اینجا یعنی دورریختنِ بی‌صدای یه هویتِ کاملاً قابل‌بازیابی فقط
* چون این لحظه رمز در دسترس نبود (خیلی مهم‌تر از زیبایی تجربه‌ی کاربره که اطلاعاتِ
* چت کاربر گم نشه). اگه نیاز به بازیابی باشه، ارسالِ پیام تا لاگینِ بعدی
* (establishIdentityAtLogin، که رمز رو داره و تصمیمِ درست رو می‌گیره) موقتاً با خطا
* مواجه می‌شه — این خطا به‌مراتب بهتر از ازدست‌رفتنِ دائمیِ هویته
*/
export default function E2eeIdentityGate({ userId: _userId }: { userId: string }) {
return null;
}
function DisabledE2eeIdentityGate({ 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]
);
export default function E2eeIdentityGate({ userId }: { userId: string }) {
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>
);
}
void ensureIdentityKeys(userId).catch(() => {
/* بی‌خطر نادیده گرفته می‌شه — رجوع کن به کامنتِ بالا */
});
}, [userId]);
return null;
}

View File

@@ -2,8 +2,7 @@
import { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import Image from "next/image";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import IOSSpinner from "@/components/ui/IOSSpinner";
@@ -97,17 +96,13 @@ export default function ForwardMessageModal({
await ensureIdentityKeys(currentUserId);
await Promise.all(
targets.map(async (receiverId) => {
let sendContent = content;
try {
const encrypted = await encryptDmContent(
currentUserId,
receiverId,
content
);
if (encrypted) sendContent = encrypted;
} catch {
/* plaintext fallback */
}
const encrypted = await encryptDmContent(
currentUserId,
receiverId,
content
);
if (!encrypted) throw new Error("E2EE encryption failed");
const sendContent = encrypted;
return request("POST", "/chat", {
content: sendContent,
receiverId,
@@ -161,17 +156,12 @@ export default function ForwardMessageModal({
selected.has(u._id) ? "bg-[#0095f6]/15 ring-1 ring-[#0095f6]/40" : "hover:bg-black/5"
}`}
>
{u.profile_image ? (
<Image
src={buildStorageUrl(u.profile_image)}
width={40}
height={40}
alt=""
className="rounded-full"
/>
) : (
<div className="h-10 w-10 rounded-full bg-neutral-200" />
)}
<ProfileAvatar
src={u.profile_image}
userId={u._id}
sizePx={40}
rounded="full"
/>
<span className="text-sm">
{u.first_name} {u.last_name}
</span>

View File

@@ -2,7 +2,9 @@
import Image from "next/image";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import { fetchPostById } from "@/api/fetchPostById";
import { useTranslation } from "react-i18next";
export interface SharedPostPayload {
@@ -32,6 +34,24 @@ export default function SharedPostBubble({ data }: { data: SharedPostPayload })
const href = data.url || `/posts/${data.postId}`;
const preview = data.preview ? buildStorageUrl(data.preview) : null;
// اگه پست بعد از ارسال حذف شده باشه، این کارت (که فقط یه اسنپ‌شاتِ ذخیره‌شده در
// لحظه‌ی ارساله) باید بجای پیش‌نمایشِ کهنه، وضعیتِ «حذف‌شده» رو نشون بده
const { data: livePost, isLoading } = useQuery({
queryKey: ["post-exists", data.postId],
queryFn: () => fetchPostById(data.postId),
staleTime: 5 * 60_000,
enabled: Boolean(data.postId),
});
const isDeleted = !isLoading && livePost === null;
if (isDeleted) {
return (
<div className="flex min-w-[200px] max-w-[240px] items-center gap-2 rounded-2xl border border-white/20 bg-black/10 px-3 py-3 opacity-70">
<p className="text-xs">{t("posts.postWasDeleted")}</p>
</div>
);
}
return (
<Link
href={href}

View File

@@ -6,6 +6,7 @@ import Link from "next/link";
import { motion, AnimatePresence } from "framer-motion";
import { AcademyExploreItem } from "@/api/fetchAcademyExplore";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import BoldIcon from "@/components/ui/BoldIcon";
import { formatLikeDisplay } from "@/lib/formatLikeDisplay";
import SharePostModal from "@/components/main/SharePostModal";
@@ -315,16 +316,13 @@ export default function ReelsAcademyCard({
href={item.user_name ? `/users/${item.user_name}` : "#"}
className="flex min-w-0 flex-1 items-center gap-2.5"
>
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-full bg-neutral-700 ring-2 ring-white/30">
{item.profile_image ? (
<Image
src={buildStorageUrl(item.profile_image)}
alt=""
fill
sizes="40px"
className="object-cover"
/>
) : null}
<div className="shrink-0 overflow-hidden rounded-full ring-2 ring-white/30">
<ProfileAvatar
src={item.profile_image}
userId={item.user_id}
sizePx={40}
rounded="full"
/>
</div>
<div className="min-w-0 flex-1 text-white">
<div className="truncate text-sm font-semibold">

View File

@@ -3,7 +3,7 @@
import Image from "next/image";
import { cn } from "@/lib/utils";
import { buildStorageUrl } from "./BaseUrl";
import StoryRing, { verifyBadgeToTierColor, STORY_RING_PX } from "@/components/stories/StoryRing";
import StoryRing, { verifyBadgeToTierColor, STORY_RING_TOTAL_INSET_PX } from "@/components/stories/StoryRing";
import { useStoryMetaForUser } from "@/hooks/useStoryMetaForUser";
const sizeMap = {
@@ -65,22 +65,42 @@ function ProfileAvatar({
const { hasStory, hasUnviewed } = useStoryMetaForUser(userId);
// Keep the total rendered footprint equal to resolvedSizePx in both states.
// StoryRing only adds ring padding when hasStory is true, so when it *is* true we
// pre-shrink the photo by the ring's width; when it's false we pass the full size
// (StoryRing then renders it 1:1, matching a plain avatar exactly). Either way the
// outer box never grows/shrinks relative to a plain avatar, so wrapping a row's
// avatar with a ring never shifts that row's height or the next element's position.
const avatarSizeForRing =
userId && hasStory
? Math.max(0, resolvedSizePx - STORY_RING_TOTAL_INSET_PX * 2)
: resolvedSizePx;
const cornerRadius = rounded === "full" ? avatarSizeForRing / 2 : rounded === "xl" ? 12 : 16;
// وقتی داخل StoryRing قرار می‌گیره، اندازه‌ی واقعیِ خودِ عکس باید avatarSizeForRing
// باشه (نه resolvedSizePx خام) — وگرنه وقتی hasStory=true باکسِ حلقه کوچیک‌تر می‌شه
// ولی عکس همچنان با اندازه‌ی بزرگ‌ترِ قبلی رندر می‌شه و از باکسِ overflow-hidden
// بیرون می‌زنه (لبه‌ی راست زیرِ قاب می‌ره، گردیِ گوشه‌ها هم با کانتینر هم‌خون نمی‌شه)
const imageSizePx = userId ? avatarSizeForRing : resolvedSizePx;
// When wrapped in a StoryRing, `className` (margins, shrink, size overrides) must land
// on the ring's outer box, not this inner <Image> — the ring's middle layer is a fixed-size
// overflow-hidden box, so any margin applied here gets silently clipped instead of creating
// visible spacing (see the search-list "avatar stuck to text" regression). `sizePx` isn't
// affected by that — it always drives the actual pixel size via inline style below.
// visible spacing (see the search-list "avatar stuck to text" regression). The image's own
// rounding is skipped too when ring-wrapped — StoryRing's content box already clips to the
// correctly-computed radius (innerRadius), so applying a second, independent fixed radius
// here (roundedClass) would fight it and show a mismatched sliver at the corners.
const image = (
<Image
src={imageSrc}
alt={alt}
width={resolvedSizePx}
height={resolvedSizePx}
style={sizePx ? { width: sizePx, height: sizePx } : undefined}
width={imageSizePx}
height={imageSizePx}
style={sizePx || userId ? { width: imageSizePx, height: imageSizePx } : undefined}
className={cn(
"profile-avatar aspect-square object-cover shrink-0",
!sizePx && sizeMap[size],
roundedClass,
!sizePx && !userId && sizeMap[size],
!userId && roundedClass,
!userId && className
)}
/>
@@ -90,17 +110,6 @@ function ProfileAvatar({
return image;
}
// Keep the total rendered footprint equal to resolvedSizePx in both states.
// StoryRing only adds ring padding when hasStory is true, so when it *is* true we
// pre-shrink the photo by the ring's width; when it's false we pass the full size
// (StoryRing then renders it 1:1, matching a plain avatar exactly). Either way the
// outer box never grows/shrinks relative to a plain avatar, so wrapping a row's
// avatar with a ring never shifts that row's height or the next element's position.
const avatarSizeForRing = hasStory
? Math.max(0, resolvedSizePx - STORY_RING_PX * 2)
: resolvedSizePx;
const cornerRadius = rounded === "full" ? avatarSizeForRing / 2 : rounded === "xl" ? 12 : 16;
return (
<StoryRing
hasStory={hasStory}

View File

@@ -206,11 +206,17 @@ function MainModelCard({
tierColor={verifyBadgeToTierColor(verify_badge)}
rounded="xl"
animated={false}
avatarSize={storyMeta.hasStory ? 67 : 72}
// ۷۲ - ۲×(۳حلقه+۲فاصله) = ۶۲ — نه ۶۷ (که فوت‌پرینتِ کلِ حلقه رو از
// ۷۲ به ۷۷ می‌برد و از باکسِ ثابتِ ۷۲×۷۲ بیرون می‌زد)
avatarSize={storyMeta.hasStory ? 62 : 72}
avatarRadius={12}
>
{/* بدونِ rounded-2xl مستقل اینجا — والد (StoryRing > innerRounded) خودش
overflow-hidden + شعاعِ درست‌محاسبه‌شده (avatarRadius منهای padding)
داره؛ یه رادیوسِ ثابتِ دیگه‌ی این‌جا با اونش ناهم‌خون می‌شه و باعثِ
نازک/نامنظم دیده‌شدنِ گوشه‌ها می‌شه */}
<div
className={`relative overflow-hidden rounded-2xl ${
className={`relative ${
storyMeta.hasStory ? "h-full w-full" : "h-[72px] w-[72px]"
}`}
>
@@ -223,7 +229,7 @@ function MainModelCard({
alt={user_name}
fill
sizes="72px"
className="aspect-square rounded-2xl object-cover"
className="aspect-square object-cover"
priority
/>
</div>

View File

@@ -17,6 +17,9 @@ import { useTranslation } from "react-i18next";
import { getPostVideoSrc } from "@/lib/videoPreload";
import { getVideoPoster } from "@/lib/explore/postMedia";
import { resolvePostMediaType } from "@/lib/reelsMediaType";
import StoryRing, { verifyBadgeToTierColor } from "@/components/stories/StoryRing";
import StoryAvatarOpen from "@/components/stories/StoryAvatarOpen";
import { useStoryMetaForUser } from "@/hooks/useStoryMetaForUser";
// ایجاد Context برای به اشتراک گذاشتن وضعیت muted بین همه کارت‌ها
export const MutedContext = createContext<{
@@ -47,6 +50,7 @@ function MainModelCard({ postData }: { postData: Post }) {
const verify_badge = user?.verify_badge;
const user_score = user?.user_score;
const rate = user?.rate;
const storyMeta = useStoryMetaForUser(user_id);
// console.log(user);
const [showFullCaption, setShowFullCaption] = useState(false);
@@ -176,18 +180,39 @@ function MainModelCard({ postData }: { postData: Post }) {
className="flex w-full text-sm justify-between py-2"
>
<div className="flex items-center w-full">
<div className="relative max-w-[72px] min-w-[72px] max-h-[72px] min-h-[72px] ml-2">
<Image
src={
profile_image
? buildStorageUrl(profile_image)
: "/images/fake-avatar.png"
}
alt={user_name}
fill
className="rounded-2xl object-cover aspect-square"
priority
/>
<div className="relative ml-2 flex h-[72px] w-[72px] shrink-0 items-center justify-center">
<StoryRing
hasStory={storyMeta.hasStory}
hasUnviewed={storyMeta.hasUnviewed}
tierColor={verifyBadgeToTierColor(verify_badge)}
rounded="xl"
animated={false}
// ۷۲ - ۲×(۳حلقه+۲فاصله) = ۶۲ — نه ۶۷ (که فوت‌پرینتِ کلِ حلقه رو از ۷۲
// به ۷۷ می‌برد و از باکسِ ثابتِ ۷۲×۷۲ بیرون می‌زد)
avatarSize={storyMeta.hasStory ? 62 : 72}
avatarRadius={12}
>
{/* بدونِ rounded-2xl مستقل — والد (StoryRing) خودش شعاعِ درست‌محاسبه‌شده
رو کلیپ می‌کنه، وگرنه گوشه‌ها نازک/نامنظم دیده می‌شن */}
<div
className={`relative ${
storyMeta.hasStory ? "h-full w-full" : "h-[72px] w-[72px]"
}`}
>
<Image
src={
profile_image
? buildStorageUrl(profile_image)
: "/images/fake-avatar.png"
}
alt={user_name}
fill
sizes="72px"
className="aspect-square object-cover"
priority
/>
</div>
</StoryRing>
</div>
<div className="font-semibold flex gap-2 flex-col-reverse ml-2 w-full">
<div className="flex items-center gap-1 mt-1">

View File

@@ -127,14 +127,23 @@ function ModelContentPosts({
post.post_images?.[0] ||
mediaFiles.find((f) => f.type === "image")?.src;
const isPending = post.status === "pending";
return (
<button
type="button"
key={post._id}
className="relative aspect-square cursor-pointer overflow-hidden rounded-2xl group focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-500"
className={`relative aspect-square cursor-pointer overflow-hidden rounded-2xl group focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-500 ${
isPending ? "opacity-50" : ""
}`}
onClick={() => openPost(post)}
aria-label={t("models.viewPost")}
>
{isPending ? (
<span className="pointer-events-none absolute right-1.5 top-1.5 z-10 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-semibold text-white">
{t("models.postPending")}
</span>
) : null}
{post.type === "image" ? (
<Image
src={firstMedia}

View File

@@ -1,11 +1,10 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { motion, AnimatePresence } from "framer-motion";
import useAxios from "@/hooks/useAxios";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import BoldIcon from "@/components/ui/BoldIcon";
import { useTranslation } from "react-i18next";
@@ -102,19 +101,12 @@ export default function ProfileUserListModal({
className="flex items-center gap-3 rounded-2xl px-2 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-800"
onClick={onClose}
>
<div className="relative h-10 w-10 overflow-hidden rounded-full bg-neutral-200">
<Image
src={
u.profile_image
? buildStorageUrl(u.profile_image)
: "/images/fake-avatar.png"
}
alt=""
fill
sizes="40px"
className="object-cover"
/>
</div>
<ProfileAvatar
src={u.profile_image}
userId={u._id}
sizePx={40}
rounded="full"
/>
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{name}</p>
<p className="truncate text-xs text-neutral-500">

View File

@@ -20,6 +20,7 @@ export default function PostLocationNoticeModal({ isOpen, onNo, onYes }: Props)
isOpen={isOpen}
onClose={onNo}
height="fit"
elevated
panelClassName="!p-5 max-w-sm"
>
<p className="text-center text-sm font-bold leading-relaxed text-neutral-900 dark:text-white">

View File

@@ -159,6 +159,9 @@ export default function ReelsImageCarousel({
sizes="100vw"
className="object-contain select-none"
priority={priority}
// unoptimized: مثلِ ReelsPostCard — رد کردن از ریسایز/تبدیلِ next/image، دانلودِ
// مستقیم از CDN عینِ FastImage در اندروید، برای حذفِ تاخیر و افتِ کیفیتِ تبدیل
unoptimized
draggable={false}
onLoad={() => onLoadingComplete?.()}
/>
@@ -191,6 +194,8 @@ export default function ReelsImageCarousel({
loading={
priority && Math.abs(idx - index) <= 1 ? "eager" : "lazy"
}
// unoptimized: همون دلیلِ بالا — بدونِ هاپِ اضافه‌ی ریسایز سرور
unoptimized
draggable={false}
onLoad={() => {
if (idx === index) onLoadingComplete?.();

View File

@@ -13,6 +13,11 @@ import { useUserById } from "@/hooks/getUserById";
import SharePostModal from "@/components/main/SharePostModal";
import SendPostModal from "@/components/posts/SendPostModal";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import {
verifyBadgeToTierColor,
TIER_RING_COLORS,
} from "@/components/stories/StoryRing";
import { useStoryMetaForUser } from "@/hooks/useStoryMetaForUser";
import { SocialStatButton, SocialIconButton } from "@/components/ui/SocialStatButton";
import {
POST_SOCIAL_ICONS,
@@ -40,28 +45,74 @@ interface ReelsPostActionsProps {
}
function ReelsProfileFollowButton({
userId,
userName,
profileImage,
verifyBadge,
following,
isOwnPost,
onToggleFollow,
}: {
userId?: string;
userName: string;
profileImage?: string;
verifyBadge?: string | null;
following: boolean;
isOwnPost: boolean;
onToggleFollow: () => void;
}) {
const { t } = useTranslation("common");
// طبق خواسته‌ی کاربر: اندازه‌ی کلِ آواتار (۴۸px) و اندازه‌ی خودِ عکس هم برای کاربرِ
// استوری‌دار و هم بدونِ استوری باید دقیقاً یکی باشه — هم برای این‌که عکسِ کاربرِ
// بدونِ استوری بزرگ‌تر دیده نشه، هم برای این‌که فاصله‌ش تا لبه‌ی کادر (چون داخلِ
// یه ستونِ ثابت‌عرضِ items-center جا می‌گیره) با کاربرِ استوری‌دار یکی بمونه. به‌جای
// یه قابِ اضافه‌ی جداگانه برای حالتِ بدونِ استوری، همون لایه‌ی حلقه رو نگه می‌داریم
// ولی رنگش سفید می‌شه — پس هندسه (سایز کل/سایز عکس/فاصله از لبه) همیشه ثابته و
// فقط رنگِ حلقه فرق می‌کنه
const { hasStory, hasUnviewed } = useStoryMetaForUser(userId);
const RING_TOTAL = 48;
// عینِ اندروید (ReelsPostCard.tsx > FollowAvatarBadge: ringWidth={2}) — نه ۳پیکسلِ
// پیش‌فرضِ سراسریِ STORY_RING_PX، چون اونجا ضخامتِ ظریف‌تری برای این آواتارِ خاص خواسته شده.
// طبق خواسته‌ی کاربر: فاصله‌ی خالیِ شفاف بینِ حلقه و عکس فقط برای کاربرِ استوری‌دار
// می‌مونه؛ برای کاربرِ بدونِ استوری، قابِ سفید بدونِ فاصله‌ست و عکس مستقیم باهاش فیت می‌شه
const RING_W = 2;
const GAP_W = hasStory ? 2 : 0;
const PHOTO = RING_TOTAL - (RING_W + GAP_W) * 2;
const tierColor = verifyBadgeToTierColor(verifyBadge);
const isViewed = hasStory && !hasUnviewed;
const ringColor = !hasStory
? "#ffffff"
: isViewed
? "#a3a3a3"
: tierColor
? TIER_RING_COLORS[tierColor]
: "#FF107D";
const avatar = (
<div className="relative h-12 w-12 overflow-hidden rounded-full border-2 border-white shadow-lg">
<Image
src={profileImage ? buildStorageUrl(profileImage) : "/images/fake-avatar.png"}
alt={userName}
fill
sizes="48px"
className="rounded-full object-cover"
/>
// به‌جای یه دایره‌ی پُررنگِ زیرین + لایه‌های روش (که وسطش هرچی هم "شفاف" باشه بازم
// رنگِ لایه‌ی زیرین ازش رد می‌شه)، خودِ حلقه یه border ـه (نه fill) — پس فضای
// خالیِ داخلش واقعاً شفافه و پس‌زمینه‌ی واقعیِ پشتِ کامپوننت (نه رنگِ حلقه) دیده می‌شه
<div
className="relative shrink-0 rounded-full drop-shadow-lg"
style={{
width: RING_TOTAL,
height: RING_TOTAL,
boxSizing: "border-box",
border: `${RING_W}px solid ${ringColor}`,
}}
>
<div
className="absolute overflow-hidden rounded-full"
style={{ inset: GAP_W }}
>
<Image
src={profileImage ? buildStorageUrl(profileImage) : "/images/fake-avatar.png"}
alt={userName}
fill
sizes={`${PHOTO}px`}
className="rounded-full object-cover"
/>
</div>
</div>
);
@@ -132,7 +183,7 @@ function ReelsStoryShareButton({
name="story"
size={28}
tinted
className="text-white drop-shadow-lg"
className="text-white"
/>
</button>
);
@@ -190,7 +241,11 @@ export default function ReelsPostActions({
const postIconSize = "sm" as const;
const reelsIconSize = "md" as const;
const actionButtonClass = "text-white drop-shadow-md";
const reelsActionButtonClass = "text-white brightness-125 drop-shadow-lg";
// قبلاً drop-shadow-lg هم داشت — ولی چون این آیکون‌ها (برخلافِ actionButtonClass که
// افقی/جداگانه‌ست) توی ستونِ ریلز با gap-4 پشتِ سرِ هم تلنبار می‌شن، سایه‌ی هرکدوم با
// بغلی‌اش قاطی می‌شد و یه «هاله»ی محوِ پیوسته پشتِ کل ستون می‌ساخت — طبق خواسته‌ی
// کاربر کاملاً حذف شد (عینِ همین فیکس روی اندروید: ReelsPostCard.tsx > actionBtn)
const reelsActionButtonClass = "text-white brightness-125";
const collaborationModals = showServicesModal &&
(expertise === "مدلینگ" ? (
@@ -305,8 +360,10 @@ export default function ReelsPostActions({
/>
) : null}
<ReelsProfileFollowButton
userId={user_id}
userName={user_name}
profileImage={user?.profile_image}
verifyBadge={user?.verify_badge}
following={social.following}
isOwnPost={social.isOwnPost}
onToggleFollow={social.toggleFollow}
@@ -468,7 +525,7 @@ export default function ReelsPostActions({
<span
aria-hidden
className={cn(
"inline-block h-7 w-7 shrink-0 bg-white drop-shadow-lg",
"inline-block h-7 w-7 shrink-0 bg-white",
expanded && "opacity-80"
)}
style={{

View File

@@ -20,7 +20,7 @@ import ReelsImageCarousel from "./ReelsImageCarousel";
import PostCommentsContent from "./PostCommentsContent";
import CommentDrawer from "@/components/comments/CommentDrawer";
import ExpandableCaption from "./ExpandableCaption";
import StoryRing, { verifyBadgeToTierColor } from "@/components/stories/StoryRing";
import { verifyBadgeToTierColor, TIER_RING_COLORS } from "@/components/stories/StoryRing";
import StoryAvatarOpen from "@/components/stories/StoryAvatarOpen";
import { useStoryMetaForUser } from "@/hooks/useStoryMetaForUser";
import { MutedContext } from "@/components/models/ModelPage/MainModelCardPost";
@@ -40,6 +40,7 @@ import {
reorderCaptionLinksToEnd,
splitCaptionBodyAndLinks,
} from "@/lib/postLinkCaption";
import { REELS_BOTTOM_RESERVED } from "@/constants/reelsFeed";
const HOLD_MS = 220;
const LONG_VIDEO_SEC = 10;
@@ -473,7 +474,11 @@ export default function ReelsPostCard({
return (
<article
ref={containerRef}
className="relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-black"
// برخلافِ تلاشِ قبلی (نسبتِ ثابتِ ۹:۱۶ که روی گوشی‌های واقعی فضای خالیِ خیلی زیادی
// زیرش می‌ذاشت)، اینجا فقط دقیقاً به‌اندازه‌ی ارتفاعِ نوارِ ناوبریِ شناور (+ safe-area)
// از پایین کم می‌شه — تقریباً تمام‌صفحه، عینِ ریلزِ اندروید، نه letterbox
style={{ height: `calc(100% - ${REELS_BOTTOM_RESERVED})` }}
className="relative flex w-full flex-col overflow-hidden bg-black"
>
<motion.div
className={cn(
@@ -517,6 +522,12 @@ export default function ReelsPostCard({
// لنگرگاه نمایش داده بشه، وگرنه لحظه‌ی لود شدنِ ویدیو یه پرش دیده می‌شه
className="object-contain object-bottom"
priority={isActive || preloadPriority === "nearby"}
// unoptimized: این فقط یه پیش‌نمایشِ کوتاه‌مدته تا ویدیو آماده بشه — رد
// کردنش از مسیرِ بهینه‌سازیِ next/image (ریسایز+تبدیل به avif/webp روی
// سرور) دقیقاً همونیه که اندروید هم با FastImage انجام می‌ده: دانلودِ مستقیمِ
// فایلِ اصلی از CDN بدون یک هاپِ اضافه؛ همون هاپِ اضافه (و کیفیتِ کم‌شده‌ی
// خروجیِ تبدیل) عامل اصلیِ «تار دیده شدن و کند لود شدنِ» پوستر نسبت به اندروید بود
unoptimized
draggable={!allowSave ? false : undefined}
onContextMenu={
!allowSave ? (e) => e.preventDefault() : undefined
@@ -709,28 +720,57 @@ export default function ReelsPostCard({
)}
>
<div className="pointer-events-auto flex items-center gap-3">
{/* عینِ ReelsProfileFollowButton: به‌جای دو حالتِ مجزا (حلقه‌ی رنگی وقتی
استوری داره / بدونِ هیچ قاب‌ی وقتی نداره)، یه لایه‌ی حلقه‌ی همیشگی
با هندسه‌ی ثابت (اندازه‌ی کل ۴۸، عکس ۴۰، ضخامت ۲px) که فقط رنگش
عوض می‌شه: سفید برای بدونِ استوری، رنگِ تیک/برند برای استوری‌دار */}
<StoryAvatarOpen userId={user_id} className="shrink-0">
<StoryRing
hasStory={storyMeta.hasStory}
hasUnviewed={storyMeta.hasUnviewed}
tierColor={verifyBadgeToTierColor(verify_badge)}
rounded="full"
avatarSize={40}
>
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-full">
<Image
src={
profile_image
? buildStorageUrl(profile_image)
: "/images/fake-avatar.png"
}
alt={user_name}
fill
sizes="40px"
className="rounded-full object-cover"
/>
</div>
</StoryRing>
{(() => {
// فاصله‌ی خالیِ شفاف بینِ حلقه و عکس فقط برای کاربرِ استوری‌دار می‌مونه؛
// برای کاربرِ بدونِ استوری، قابِ سفید بدونِ فاصله‌ست و عکس مستقیم باهاش
// فیت می‌شه — ولی اندازه‌ی کلِ حلقه (TOTAL) هر دو حالت یکیه
const RING_W = 2;
const TOTAL = 48;
const GAP_W = storyMeta.hasStory ? 2 : 0;
const PHOTO = TOTAL - (RING_W + GAP_W) * 2;
const tierColor = verifyBadgeToTierColor(verify_badge);
const isViewed = storyMeta.hasStory && !storyMeta.hasUnviewed;
const ringColor = !storyMeta.hasStory
? "#ffffff"
: isViewed
? "#a3a3a3"
: tierColor
? TIER_RING_COLORS[tierColor]
: "#FF107D";
return (
<div
className="relative shrink-0 rounded-full"
style={{
width: TOTAL,
height: TOTAL,
boxSizing: "border-box",
border: `${RING_W}px solid ${ringColor}`,
}}
>
<div
className="absolute overflow-hidden rounded-full"
style={{ inset: GAP_W }}
>
<Image
src={
profile_image
? buildStorageUrl(profile_image)
: "/images/fake-avatar.png"
}
alt={user_name}
fill
sizes={`${PHOTO}px`}
className="rounded-full object-cover"
/>
</div>
</div>
);
})()}
</StoryAvatarOpen>
<Link
href={`/users/${user_name}`}

View File

@@ -54,6 +54,8 @@ export default function ReelsPostOptionsMenu({
const exploreUi = useExploreReelsUi();
const [open, setOpen] = useState(false);
const [reportOpen, setReportOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [analyticsOpen, setAnalyticsOpen] = useState(false);
const [reportReason, setReportReason] = useState("");
const [reportText, setReportText] = useState("");
@@ -139,6 +141,27 @@ export default function ReelsPostOptionsMenu({
}
};
const deletePost = async () => {
setDeleting(true);
try {
await request("DELETE", `/posts/${postData._id}`, null, { noToast: true });
toast.success(t("posts.postDeleted"));
setDeleteConfirmOpen(false);
setOpen(false);
void queryClient.invalidateQueries({ queryKey: ["posts"] });
void queryClient.invalidateQueries({ queryKey: ["archived-posts"] });
void queryClient.invalidateQueries({ queryKey: ["home-posts"] });
router.back();
} catch (err: unknown) {
const message = (
err as { response?: { data?: { message?: string } } }
)?.response?.data?.message;
toast.error(message || t("posts.deleteFailed"));
} finally {
setDeleting(false);
}
};
const toggleArchive = async () => {
try {
const res = await request<{ is_archived?: boolean; message?: string }>(
@@ -326,6 +349,16 @@ export default function ReelsPostOptionsMenu({
>
{isArchived ? t("posts.publishPost") : t("posts.archivePost")}
</button>
<button
type="button"
onClick={() => {
setOpen(false);
setDeleteConfirmOpen(true);
}}
className="px-5 py-3.5 text-right text-sm font-semibold text-red-500 active:bg-neutral-100 dark:active:bg-neutral-800"
>
{t("posts.deletePost")}
</button>
</>
) : null}
</div>
@@ -370,6 +403,40 @@ export default function ReelsPostOptionsMenu({
</RoundedButton>
</Modal>
<Modal
isOpen={deleteConfirmOpen}
onClose={() => setDeleteConfirmOpen(false)}
height="fit"
panelClassName="!p-5"
>
<p className="mb-2 text-center text-sm font-bold">
{t("posts.deletePostConfirmTitle")}
</p>
<p className="mb-5 text-center text-xs text-neutral-500">
{t("posts.deletePostConfirmBody")}
</p>
<div className="flex gap-3">
<RoundedButton
type="button"
variant="default"
disabled={deleting}
onClick={() => setDeleteConfirmOpen(false)}
className="h-10 flex-1 text-sm"
>
{t("posts.cancel")}
</RoundedButton>
<RoundedButton
type="button"
variant="primary"
disabled={deleting}
onClick={() => void deletePost()}
className="h-10 flex-1 !bg-red-500 text-sm"
>
{deleting ? t("posts.deleting") : t("posts.deleteConfirm")}
</RoundedButton>
</div>
</Modal>
{analyticsOpen ? (
<ReelsPostAnalyticsModal
postData={postData}

View File

@@ -7,6 +7,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 ProfileAvatar from "@/components/main/ProfileAvatar";
import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import IOSSpinner from "@/components/ui/IOSSpinner";
@@ -171,21 +172,12 @@ export default function SendPostModal({
checked ? "bg-pink-50 dark:bg-pink-950/30" : ""
}`}
>
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-full bg-neutral-200">
{u.profile_image ? (
<Image
src={
u.profile_image.startsWith("http")
? u.profile_image
: IMAGE_BASE_URL + u.profile_image
}
alt=""
fill
sizes="40px"
className="object-cover"
/>
) : null}
</div>
<ProfileAvatar
src={u.profile_image}
userId={u._id}
sizePx={40}
rounded="full"
/>
<span className="flex-1 text-sm font-medium">{name}</span>
<span
className={cn(

View File

@@ -1,9 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import useAxios from "@/hooks/useAxios";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import BoldIcon from "@/components/ui/BoldIcon";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -22,13 +21,6 @@ interface TagUsersPickerProps {
max?: number;
}
function avatarSrc(profileImage?: string) {
if (!profileImage) return null;
return profileImage.startsWith("http")
? profileImage
: buildStorageUrl(profileImage);
}
function displayName(u: TaggedUser) {
return (
[u.first_name, u.last_name].filter(Boolean).join(" ") || u.user_name
@@ -102,23 +94,17 @@ export default function TagUsersPicker({
{selected.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{selected.map((u) => {
const src = avatarSrc(u.profile_image);
return (
<span
key={u._id}
className="flex items-center gap-2 rounded-full bg-neutral-100 py-1 pe-2 ps-1 dark:bg-neutral-800"
>
<div className="relative h-7 w-7 shrink-0 overflow-hidden rounded-full bg-neutral-200">
{src ? (
<Image
src={src}
alt=""
fill
sizes="28px"
className="object-cover"
/>
) : null}
</div>
<ProfileAvatar
src={u.profile_image}
userId={u._id}
sizePx={28}
rounded="full"
/>
<span className="text-xs font-medium">
{displayName(u)}
<span className="mx-1 text-neutral-400">·</span>
@@ -163,7 +149,6 @@ export default function TagUsersPicker({
)}
<ul className="mt-2 max-h-52 space-y-1 overflow-y-auto">
{results.map((u) => {
const src = avatarSrc(u.profile_image);
const name = displayName(u);
return (
<li key={u._id}>
@@ -172,17 +157,12 @@ export default function TagUsersPicker({
onClick={() => addUser(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
sizes="40px"
className="object-cover"
/>
) : null}
</div>
<ProfileAvatar
src={u.profile_image}
userId={u._id}
sizePx={40}
rounded="full"
/>
<div className="min-w-0 flex-1 text-right">
<p className="truncate text-sm font-medium">{name}</p>
<p className="truncate text-xs text-neutral-500">

View File

@@ -13,7 +13,7 @@ import {
CarouselPrevious,
type CarouselApi,
} from "@/components/ui/carousel";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import useAxios from "@/hooks/useAxios";
import { cn } from "@/lib/utils";
import type { SuggestedFollowUser } from "@/components/posts/FollowingSuggestionsCarousel";
@@ -75,7 +75,6 @@ function SpecialistCard({
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}` : "#";
return (
@@ -116,21 +115,13 @@ function SpecialistCard({
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/25 to-black/10" />
<div className="absolute inset-x-0 bottom-0 flex flex-col items-center px-4 pb-4 pt-8">
<div className="relative mb-2 h-14 w-14 overflow-hidden rounded-full ring-2 ring-white/90">
{profileUrl ? (
<Image
src={profileUrl}
alt=""
fill
loading="lazy"
className="object-cover"
sizes="56px"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-neutral-700 text-white">
{displayName.charAt(0)}
</div>
)}
<div className="mb-2 overflow-hidden rounded-full ring-2 ring-white/90">
<ProfileAvatar
src={user.profile_image}
userId={user._id}
sizePx={56}
rounded="full"
/>
</div>
<p className="line-clamp-1 text-center text-sm font-bold text-white">

View File

@@ -9,6 +9,7 @@ import FilterModal from "@/components/models/FilterModal";
import BoldIcon from "@/components/ui/BoldIcon";
import useAxios from "@/hooks/useAxios";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import type { SuggestedFollowUser } from "@/components/posts/FollowingSuggestionsCarousel";
@@ -56,9 +57,6 @@ function SpecialistCard({
const displayName = getDisplayName(user, userFallback);
const previewPath = user.previewPost?.files?.[0]?.path;
const previewUrl = previewPath ? buildStorageUrl(previewPath) : null;
const profileUrl = user.profile_image
? buildStorageUrl(user.profile_image)
: null;
const isMain = variant === "main";
const profileHref = user.user_name ? `/users/${user.user_name}` : "#";
@@ -115,25 +113,13 @@ function SpecialistCard({
isMain ? "px-4 pb-5" : "px-2 pb-3"
)}
>
<div
className={cn(
"relative mb-2 overflow-hidden rounded-full ring-2 ring-white/90",
isMain ? "h-16 w-16" : "h-9 w-9"
)}
>
{profileUrl ? (
<Image
src={profileUrl}
alt=""
fill
className="object-cover"
sizes={isMain ? "64px" : "36px"}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-neutral-700 text-white">
{displayName.charAt(0)}
</div>
)}
<div className="mb-2 overflow-hidden rounded-full ring-2 ring-white/90">
<ProfileAvatar
src={user.profile_image}
userId={user._id}
sizePx={isMain ? 64 : 36}
rounded="full"
/>
</div>
{isMain ? (

View File

@@ -1,9 +1,8 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import BoldIcon from "@/components/ui/BoldIcon";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import {
getNotificationActionMeta,
getNotificationVisualKind,
@@ -37,10 +36,6 @@ export default function NotificationRow({
const visual = getNotificationVisualKind(item.type);
const actor = item.actor;
const avatarSrc = actor?.profile_image
? buildStorageUrl(actor.profile_image)
: "/images/fake-avatar.png";
return (
<Link
href={href}
@@ -49,17 +44,17 @@ export default function NotificationRow({
<div className="relative h-12 w-12 shrink-0">
<div
className={cn(
"relative h-12 w-12 overflow-hidden bg-neutral-200 dark:bg-neutral-800",
"h-12 w-12 overflow-hidden bg-neutral-200 dark:bg-neutral-800",
"rounded-full"
)}
>
{visual === "user" ? (
<Image
src={avatarSrc}
<ProfileAvatar
src={actor?.profile_image}
userId={actor?._id}
alt={actor?.user_name || t("common.user")}
fill
sizes="48px"
className="object-cover"
sizePx={48}
rounded="full"
/>
) : (
<div className="flex h-full w-full items-center justify-center">

View File

@@ -8,6 +8,9 @@ type PageTitleProps = {
/** Set false for root/bottom-tab pages that shouldn't offer a back action. */
showBack?: boolean;
backHref?: string;
/** جایگزینِ router.back() پیش‌فرض — برای صفحاتی که باید هدف‌مند (مثلاً reload
* کامل به‌جای soft nav) برگردن، نه صرفاً یه قدم توی تاریخچه عقب برن */
onBack?: () => void;
/**
* Optional right-side controls (e.g. a page-specific action icon). Pass content
* here instead of wrapping <PageTitle> in an outer flex row — an outer wrapper
@@ -26,6 +29,7 @@ const PageTitle: React.FC<PageTitleProps> = ({
children,
showBack = true,
backHref,
onBack,
action,
}) => {
const titleText = typeof children === "string" ? children : null;
@@ -34,7 +38,7 @@ const PageTitle: React.FC<PageTitleProps> = ({
return (
<div className="my-7 grid w-full grid-cols-[2.5rem_1fr_2.5rem] items-center gap-2">
{showBack ? (
<BackButton href={backHref} className="justify-self-start" />
<BackButton href={backHref} onClick={onBack} className="justify-self-start" />
) : (
<span />
)}

View File

@@ -10,17 +10,18 @@ import Suggestions from "@/components/settings/settings/Suggestions";
import UserDetails from "@/components/settings/UserDetails";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { staticIconUrl } from "@/components/main/BaseUrl";
import { canAccess } from "@/lib/auth/session";
import Image from "next/image";
import Link from "next/link";
import { useTranslation } from "react-i18next";
const SETTINGS_NAV = [
{ key: "wallet", href: "/wallet", icon: "card.svg" },
{ key: "shop", href: "/shop", icon: "shop-add.svg" },
{ key: "billboards", href: "/my-billboards", icon: "flash-circle.svg" },
{ key: "academy", href: "/academy/Dashboard", icon: "academy.svg" },
{ key: "shop", href: "/shop", icon: "shop-add.svg", permission: "create_shop" },
{ key: "billboards", href: "/my-billboards", icon: "flash-circle.svg", permission: "billboards" },
{ key: "academy", href: "/academy/Dashboard", icon: "academy.svg", permission: "academy_packages" },
{ key: "workroom", href: "/workroom", icon: "box.svg" },
{ key: "booking", href: "/booking", icon: "calendar.svg" },
{ key: "booking", href: "/booking", icon: "calendar.svg", permission: "bookings" },
{ key: "offers", href: "/offers", icon: "offer.svg" },
{ key: "favorites", href: "/favorites", icon: "bookmark-post.svg" },
{ key: "chats", href: "/chats", icon: "sms.svg" },
@@ -32,6 +33,12 @@ const SETTINGS_NAV = [
export default function SettingsClient() {
const { t } = useTranslation("common");
// برای کاربرِ اصلی همه‌چی نمایش داده می‌شه؛ برای کاربرِ زیرمجموعه فقط
// بخش‌هایی که براش پرمیژن داده شده
const visibleNav = SETTINGS_NAV.filter((item) => {
if ("permission" in item) return canAccess(item.permission);
return true;
});
return (
<LocalePageShell>
@@ -41,7 +48,7 @@ export default function SettingsClient() {
<UserDetails />
<div className="my-10 flex flex-col gap-5 text-sm font-semibold md:gap-8">
<ThemeSwitcher />
{SETTINGS_NAV.map((item) => (
{visibleNav.map((item) => (
<Link
className="flex items-center gap-2"
key={item.key}

View File

@@ -1,8 +1,7 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import { formatBubbleTime } from "@/lib/chat/formatMessageTime";
import { useTranslation } from "react-i18next";
@@ -22,26 +21,19 @@ type Props = {
export default function VisitorRow({ visitor }: Props) {
const { t } = useTranslation("common");
const avatarSrc = visitor.profile_image
? buildStorageUrl(visitor.profile_image)
: "/images/fake-avatar.png";
const visitedAt =
visitor.visited_at_time ||
(visitor.visited_at ? formatBubbleTime(visitor.visited_at) : "");
return (
<div className="mb-3 flex w-full items-start gap-3 border-b border-border-primary-light pb-3 last:border-0">
<Link
href={`/users/${visitor.user_name}`}
className="relative h-12 w-12 shrink-0 overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-800"
>
<Image
src={avatarSrc}
<Link href={`/users/${visitor.user_name}`} className="block shrink-0">
<ProfileAvatar
src={visitor.profile_image}
userId={visitor._id}
alt={visitor.user_name || t("common.user")}
fill
sizes="48px"
className="object-cover"
sizePx={48}
rounded="full"
/>
</Link>

View File

@@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import Image from "next/image";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import IOSSpinner from "@/components/ui/IOSSpinner";
@@ -159,21 +160,12 @@ export default function SendProductModal({
checked ? "bg-pink-50 dark:bg-pink-950/30" : ""
}`}
>
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-full bg-neutral-200">
{u.profile_image ? (
<Image
src={
u.profile_image.startsWith("http")
? u.profile_image
: IMAGE_BASE_URL + u.profile_image
}
alt=""
fill
sizes="40px"
className="object-cover"
/>
) : null}
</div>
<ProfileAvatar
src={u.profile_image}
userId={u._id}
sizePx={40}
rounded="full"
/>
<span className="flex-1 text-sm font-medium">{name}</span>
<span
className={cn(

View File

@@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import Image from "next/image";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import IOSSpinner from "@/components/ui/IOSSpinner";
@@ -154,21 +155,12 @@ export default function SendShopModal({
checked ? "bg-pink-50 dark:bg-pink-950/30" : ""
}`}
>
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-full bg-neutral-200">
{u.profile_image ? (
<Image
src={
u.profile_image.startsWith("http")
? u.profile_image
: IMAGE_BASE_URL + u.profile_image
}
alt=""
fill
sizes="40px"
className="object-cover"
/>
) : null}
</div>
<ProfileAvatar
src={u.profile_image}
userId={u._id}
sizePx={40}
rounded="full"
/>
<span className="flex-1 text-sm font-medium">{name}</span>
<span
className={cn(

View File

@@ -9,6 +9,7 @@ import { useCommentDrawer } from "@/components/comments/CommentDrawer";
import BoldIcon from "@/components/ui/BoldIcon";
import CommentsSkeleton from "@/components/ui/CommentsSkeleton";
import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import useAxios from "@/hooks/useAxios";
import { useRequireAuth } from "@/lib/auth/useRequireAuth";
import toast from "react-hot-toast";
@@ -139,17 +140,12 @@ export default function ShopProductCommentsModal({
<ul className="space-y-3">
{comments.map((c) => (
<li key={c._id} className="flex items-start gap-3">
<div className="relative h-9 w-9 shrink-0 overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-800">
{c.creator?.profile_image ? (
<Image
src={IMAGE_BASE_URL + c.creator.profile_image}
alt=""
fill
sizes="36px"
className="object-cover"
/>
) : null}
</div>
<ProfileAvatar
src={c.creator?.profile_image}
userId={c.creator?._id}
sizePx={36}
rounded="full"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<p className="text-xs font-semibold">{c.creator?.user_name}</p>

View File

@@ -120,6 +120,7 @@ export default function ShopProductGrid({
? listing.shop
: null;
const aspect = hashAspect(listing._id);
const isPending = listing.status !== "active";
return (
<article key={listing._id} className="mb-2 break-inside-avoid">
@@ -128,7 +129,9 @@ export default function ShopProductGrid({
onClick={() =>
navigateWithSavedScroll(router, scrollKey, getHref(listing))
}
className="gentle-transition block w-full text-right active:scale-[0.98]"
className={`gentle-transition block w-full text-right active:scale-[0.98] ${
isPending ? "opacity-50" : ""
}`}
>
<div
className={`relative w-full overflow-hidden rounded-2xl bg-neutral-200 dark:bg-neutral-900 ${aspect}`}

View File

@@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import Image from "next/image";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import IOSSpinner from "@/components/ui/IOSSpinner";
@@ -179,17 +180,12 @@ export default function SendStoryModal({
: "hover:bg-neutral-100 dark:hover:bg-neutral-800"
)}
>
<div className="relative h-10 w-10 overflow-hidden rounded-full bg-neutral-200">
{u.profile_image ? (
<Image
src={buildStorageUrl(u.profile_image)}
alt=""
fill
sizes="40px"
className="object-cover"
/>
) : null}
</div>
<ProfileAvatar
src={u.profile_image}
userId={u._id}
sizePx={40}
rounded="full"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">
{u.first_name} {u.last_name}

View File

@@ -15,6 +15,7 @@ import StoryRing, {
} from "@/components/stories/StoryRing";
import BoldIcon from "@/components/ui/BoldIcon";
import { useTranslation } from "react-i18next";
import { PENDING_STORY_UPLOAD_KEY } from "@/lib/stories/pendingStoryUpload";
function StoryAvatar({
profileImage,
@@ -74,6 +75,15 @@ export default function StoriesBar({ token: serverToken = "" }: StoriesBarProps)
const myStory = data?.my_story;
const hasMyStory = Boolean(myStory?.stories?.length);
// پرچمِ سراسریِ «استوری در حال آپلود» — new-post/page.tsx مستقیم بعد از زدنِ دکمه‌ی
// اشتراک‌گذاری این رو ست می‌کنه، بدون این‌که منتظرِ mount شدنِ این کامپوننت باشه
const { data: isUploadingMyStory } = useQuery<boolean>({
queryKey: PENDING_STORY_UPLOAD_KEY,
queryFn: () => false,
initialData: false,
staleTime: Infinity,
});
const openViewer = (index: number) => {
setViewerStartIndex(index);
setViewerOpen(true);
@@ -100,16 +110,24 @@ export default function StoriesBar({ token: serverToken = "" }: StoriesBarProps)
{/* Your story */}
<div className="flex w-[80px] shrink-0 flex-col items-center gap-1">
<div className="relative" style={{ width: STORY_RING_OUTER_SIZE, height: STORY_RING_OUTER_SIZE }}>
{showDynamicFeed && hasMyStory ? (
{showDynamicFeed && (hasMyStory || isUploadingMyStory) ? (
<button
type="button"
onClick={() => {
// حین آپلود هنوز استوریِ واقعی‌ای برای نمایش نیست — کلیک بی‌اثره
if (isUploadingMyStory && !hasMyStory) return;
const idx = feed.findIndex((f) => f.user._id === viewerId);
openViewer(idx >= 0 ? idx : 0);
}}
className="block"
>
<StoryRing hasStory forceGradient avatarSize={STORY_AVATAR_SIZE}>
<StoryRing
hasStory={hasMyStory}
forceGradient
uploading={isUploadingMyStory}
tierColor={verifyBadgeToTierColor(myStory?.user.verify_badge)}
avatarSize={STORY_AVATAR_SIZE}
>
<StoryAvatar
profileImage={myStory?.user.profile_image}
userName={myStory?.user.user_name}
@@ -130,7 +148,7 @@ export default function StoriesBar({ token: serverToken = "" }: StoriesBarProps)
)}
<Link
href="/new-post?type=story"
className="absolute -bottom-0.5 -right-0.5 z-20 flex h-5 w-5 items-center justify-center rounded-full border-2 border-background bg-[#0095f6] text-white shadow-sm"
className="absolute bottom-0 left-0 z-20 flex h-5 w-5 items-center justify-center rounded-full border-2 border-background bg-[#0095f6] text-white shadow-sm"
aria-label={t("stories.addStory")}
>
<BoldIcon name="add" size={14} tinted className="text-white" />

View File

@@ -1,9 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import useAxios from "@/hooks/useAxios";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import { useTranslation } from "react-i18next";
export type MentionUser = {
@@ -14,13 +13,6 @@ export type MentionUser = {
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;
}
@@ -88,7 +80,6 @@ export default function StoryMentionPicker({
)}
<ul className="mt-2 space-y-1">
{results.map((u) => {
const src = avatarSrc(u.profile_image);
return (
<li key={u._id}>
<button
@@ -96,11 +87,12 @@ export default function StoryMentionPicker({
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 sizes="40px" className="object-cover" />
) : null}
</div>
<ProfileAvatar
src={u.profile_image}
userId={u._id}
sizePx={40}
rounded="full"
/>
<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>

View File

@@ -3,9 +3,13 @@
import React from "react";
import { cn } from "@/lib/utils";
export const STORY_RING_PX = 2.5;
export const STORY_RING_PX = 3;
// فاصله‌ی خالی بین حلقه‌ی استوری و خودِ عکس پروفایل — عین حلقه‌ی استوری اینستاگرام
export const STORY_RING_GAP_PX = 2;
export const STORY_RING_TOTAL_INSET_PX = STORY_RING_PX + STORY_RING_GAP_PX;
export const STORY_AVATAR_SIZE = 56;
export const STORY_RING_OUTER_SIZE = STORY_AVATAR_SIZE + STORY_RING_PX * 2;
export const STORY_RING_OUTER_SIZE =
STORY_AVATAR_SIZE + STORY_RING_TOTAL_INSET_PX * 2;
export const TIER_RING_COLORS = {
blue: "#2196EB",
@@ -28,6 +32,9 @@ interface StoryRingProps {
forceGradient?: boolean;
/** When set (blue/green/gold), overrides the default gradient/gray ring with a flat tier color. Gray/none tiers should pass null/undefined to keep default behavior. */
tierColor?: StoryRingTierColor;
/** استوری در حال آپلود — عین انیمیشن حلقه‌ی ثبتِ استوری اینستاگرام: به‌جای رنگ ثابت، یک
* قوسِ رنگی می‌چرخه تا وقتی آپلود واقعاً تمام بشه (implies hasStory=true). */
uploading?: boolean;
rounded?: "full" | "xl";
avatarSize?: number;
avatarRadius?: number;
@@ -42,6 +49,7 @@ export default function StoryRing({
hasUnviewed = false,
forceGradient = false,
tierColor = null,
uploading = false,
rounded = "full",
avatarSize,
avatarRadius,
@@ -49,41 +57,74 @@ export default function StoryRing({
className,
children,
}: StoryRingProps) {
const showRing = hasStory || uploading;
const isXl = rounded === "xl";
const resolvedAvatarSize =
avatarSize ?? (isXl ? undefined : STORY_AVATAR_SIZE);
const cornerRadius =
avatarRadius ??
(isXl ? 12 : resolvedAvatarSize ? resolvedAvatarSize / 2 : undefined);
// برای دایره (rounded="full") همیشه از کلاس rounded-full استفاده می‌شه (نسبت به
// اندازه‌ی خودِ همون باکس ۵۰٪ گرد می‌کنه)، نه یک عددِ پیکسلیِ ثابت — وگرنه چون
// لایه‌ی میانی/داخلی سایزشون از باکسِ بیرونی کوچیک‌تره، همون شعاعِ باکسِ بیرونی
// منهای inset دیگه دقیقاً نصفِ اندازه‌ی خودشون نمی‌شه و حلقه به‌جای دایره، مربعِ
// گردشده (squircle) درمیاد. فقط برای rounded="xl" (مربعِ گردِ ثابت) تفریقِ عددی
// معنا داره، چون اونجا شعاع یک مقدارِ ثابتِ concentric-rect هست، نه نصفِ عرض.
const gapRadius =
isXl && cornerRadius != null
? Math.max(0, cornerRadius - (showRing ? STORY_RING_PX : 0))
: undefined;
const innerRadius =
cornerRadius != null
? Math.max(0, cornerRadius - (hasStory ? STORY_RING_PX : 0))
isXl && cornerRadius != null
? Math.max(0, cornerRadius - (showRing ? STORY_RING_TOTAL_INSET_PX : 0))
: undefined;
const outerRounded = isXl ? "rounded-2xl" : "rounded-full";
const innerRounded =
innerRadius != null
? undefined
: isXl
? "rounded-[calc(0.75rem-2.5px)]"
: "rounded-full";
// باگ: قبلاً این همیشه کلاسِ ثابتِ rounded-2xl (۱۶px) بود، صرف‌نظر از این‌که
// cornerRadius واقعی چقدره — وقتی avatarRadius غیرِ ۱۶ پاس داده می‌شد (مثلاً ۱۲،
// ProfileAvatar.tsx برای rounded="xl")، شعاعِ لایه‌ی بیرونیِ حلقه با شعاعِ
// محاسبه‌شده‌ی لایه‌های gap/inner (که از رویِ همون cornerRadius واقعی کم می‌کنن)
// هم‌خون نبود و گوشه‌ها نامنظم/نازک دیده می‌شدن
const outerRounded =
isXl && cornerRadius == null ? "rounded-2xl" : !isXl ? "rounded-full" : undefined;
const gapRounded = isXl
? gapRadius == null
? "rounded-[calc(0.75rem-3px)]"
: undefined
: "rounded-full";
const innerRounded = isXl
? innerRadius == null
? "rounded-[calc(0.75rem-5px)]"
: undefined
: "rounded-full";
// When there's no story, the ring layer isn't drawn — keep the total footprint
// exactly equal to resolvedAvatarSize (matching the old "render bare children"
// behavior) instead of the ring-inclusive size, so call sites whose surrounding
// layout was built around "no story = original size" don't grow unexpectedly.
const ringInset = hasStory ? STORY_RING_PX : 0;
const ringInset = showRing ? STORY_RING_PX : 0;
const gapInset = showRing ? STORY_RING_GAP_PX : 0;
const outerSize =
resolvedAvatarSize != null
? resolvedAvatarSize + (hasStory ? STORY_RING_PX * 2 : 0)
? resolvedAvatarSize + (showRing ? STORY_RING_TOTAL_INSET_PX * 2 : 0)
: undefined;
// رنگِ اصلیِ برند اپ (همون رنگِ آیکون قلبِ لایک‌شده در همه‌جای اپ) — قبلاً گرادیانِ
// رنگین‌کمانیِ اینستاگرام بود، طبق درخواست جایگزین شد. طبق خواسته‌ی بعدیِ کاربر،
// تمایزِ دیده‌شده/ندیده‌شده (که دیده‌شده رو خاکستری می‌کرد) هم حذف شد — حلقه‌ی
// «معمولی» (بدونِ تیکِ رنگی) همیشه هم‌رنگِ لوگوئه
// رنگین‌کمانیِ اینستاگرام بود، طبق درخواست جایگزین شد.
const live = hasUnviewed || forceGradient;
const ringBg = tierColor ? TIER_RING_COLORS[tierColor] : "#FF107D";
// طبق خواسته‌ی دوباره‌ی کاربر: استوریِ دیده‌شده (نه در حالِ pulse/forceGradient)
// حلقه‌ش خاکستری کمرنگ بشه — حتی اگه کاربر تیکِ رنگی (آبی/سبز/طلایی) هم داشته باشه،
// چون «دیده شدن» باید همیشه قابل‌تشخیص باشه
const isViewed = hasStory && !live && !uploading;
const ringBg = isViewed
? "#a3a3a3"
: tierColor
? TIER_RING_COLORS[tierColor]
: "#FF107D";
// حین آپلود، به‌جای پرکردنِ کاملِ حلقه با رنگ، فقط یه قوس نشون داده می‌شه که می‌چرخه —
// عین انیمیشنِ «در حال ثبت استوری»ی اینستاگرام (یه لودینگِ چرخان دورِ آواتار)
const ringBackground = uploading
? `conic-gradient(${ringBg} 0deg 100deg, transparent 100deg 360deg)`
: ringBg;
// Always render the same wrapper structure (same total size, same rounding)
// regardless of hasStory — only the ring-color layer is conditional. This keeps
@@ -98,35 +139,56 @@ export default function StoryRing({
: undefined
}
>
{hasStory && (
{showRing && (
<div
className={cn(
"absolute inset-0",
outerRounded,
!tierColor && live && animated && "story-ring-live"
uploading
? "story-ring-uploading"
: !tierColor && live && animated && "story-ring-live"
)}
style={{ background: ringBg }}
style={{
background: ringBackground,
...(isXl && cornerRadius != null ? { borderRadius: cornerRadius } : {}),
}}
aria-hidden
/>
)}
{/* فاصله‌ی خالیِ بین حلقه و عکس (عین حلقه‌ی استوری اینستاگرام) — پس‌زمینه‌ش هم‌رنگِ
صفحه‌ست تا حلقه‌ی رنگی فقط دورِ این لایه دیده بشه، نه چسبیده به خودِ عکس */}
<div
className={cn(
"relative z-[1] flex items-center justify-center overflow-hidden bg-white leading-none dark:bg-neutral-950",
innerRounded
)}
className={cn("relative z-[1] bg-white dark:bg-neutral-950", gapRounded)}
style={{
margin: ringInset,
...(innerRadius != null ? { borderRadius: innerRadius } : {}),
...(gapRadius != null ? { borderRadius: gapRadius } : {}),
...(resolvedAvatarSize != null
? {
width: resolvedAvatarSize,
height: resolvedAvatarSize,
width: resolvedAvatarSize + gapInset * 2,
height: resolvedAvatarSize + gapInset * 2,
}
: { width: "100%", height: "100%" }),
}}
>
<div className="h-full w-full [&_img]:h-full [&_img]:w-full [&_img]:object-cover [&>*]:h-full [&>*]:w-full">
{children}
<div
className={cn(
"relative flex h-full w-full items-center justify-center overflow-hidden leading-none",
innerRounded
)}
style={{
margin: gapInset,
...(innerRadius != null ? { borderRadius: innerRadius } : {}),
...(resolvedAvatarSize != null
? {
width: resolvedAvatarSize,
height: resolvedAvatarSize,
}
: { width: "100%", height: "100%" }),
}}
>
<div className="h-full w-full [&_img]:h-full [&_img]:w-full [&_img]:object-cover [&>*]:h-full [&>*]:w-full">
{children}
</div>
</div>
</div>
</div>

View File

@@ -68,6 +68,11 @@ export const editUserNavLinks = [
href: "/google-account",
icon: "google.png",
},
{
labelKey: "settings.edit.nav.registerMobile",
href: "/register-mobile",
icon: "phone.svg",
},
{
labelKey: "settings.edit.nav.twoFactor",
href: "/two-factor",

View File

@@ -7,9 +7,19 @@ export const REELS_PAGE_SIZE = 6;
/** فقط کارت‌های نزدیک پست فعال mount می‌شوند */
export const REELS_RENDER_WINDOW = 5;
/** کلاس یکسان هر اسلات ریلز — snap + ارتفاع ثابت */
/** فضای رزروشده‌ی پایینِ هر اسلات برای نوارِ ناوبریِ شناورِ پایینِ صفحه (TabNavigation.tsx)
* که برخلافِ اندروید (که تبِ پایین رو کاملاً مخفی می‌کنه)، توی ریلزِ وب همیشه روی صفحه
* می‌مونه — پس برخلافِ نسخه‌ی اول (نسبتِ ۹:۱۶ کاملِ letterbox که فضای خالیِ خیلی زیادی
* می‌ذاشت)، اینجا فقط دقیقاً به‌اندازه‌ی ارتفاعِ خودِ همون نوار + safe-area رزرو می‌شه،
* نه یه نسبتِ ابعادِ ثابت — تا پست مثلِ ریلزِ اندروید تقریباً تمام‌صفحه (edge-to-edge)
* بمونه و فقط از زیرِ نوار بیرون نزنه */
export const REELS_BOTTOM_RESERVED = "calc(96px + env(safe-area-inset-bottom))";
/** کلاس یکسان هر اسلات ریلز — snap + ارتفاع ثابت. خودِ اسلات کامل‌ارتفاعه (برای
* فاصله‌گذاریِ درستِ scroll-snap)؛ محتوای واقعی (ReelsPostCard) با REELS_BOTTOM_RESERVED
* کوتاه‌تر می‌شه و از بالا (زیرِ status bar) شروع می‌شه، نه وسط‌چین */
export const REELS_SLOT_CLASS =
"reels-snap-slot h-[100dvh] w-full shrink-0 snap-start snap-always";
"reels-snap-slot flex h-[100dvh] w-full shrink-0 snap-start snap-always items-start justify-center bg-black pt-[env(safe-area-inset-top)]";
/** پوسته ریلز — هم‌عرض منوبار */
export const REELS_SHELL_CLASS = "mx-auto h-[100dvh] w-full max-w-xl overflow-hidden";

View File

@@ -0,0 +1,15 @@
// عین utils/subUserPermissions.js بک‌اند — کلیدها باید دقیقاً یکی باشن
export const SUB_USER_PERMISSION_KEYS = [
"posts_stories",
"reply_dm",
"reply_comments",
"create_shop",
"shop_products",
"order_status",
"billboards",
"projects",
"academy_packages",
"bookings",
] as const;
export type SubUserPermissionKey = (typeof SUB_USER_PERMISSION_KEYS)[number];

View File

@@ -83,6 +83,10 @@ export function parseLoginResponse(raw: unknown): IVerifyOtp {
typeof obj.registrationComplete === "boolean"
? obj.registrationComplete
: undefined,
is_sub_user: obj.is_sub_user === true,
sub_user_permissions: Array.isArray(obj.sub_user_permissions)
? (obj.sub_user_permissions as string[])
: undefined,
};
}
@@ -128,6 +132,8 @@ export async function completeLogin(
step: response.step,
auth_provider: response.auth_provider,
email: response.email ?? undefined,
is_sub_user: response.is_sub_user,
sub_user_permissions: response.sub_user_permissions,
});
await pause(AUTH_SUCCESS_DELAY_MS);

View File

@@ -15,6 +15,8 @@ export type AuthUserMeta = {
step?: string;
auth_provider?: string;
email?: string;
is_sub_user?: boolean;
sub_user_permissions?: string[];
};
const cookieOptions = (): Cookies.CookieAttributes => ({
@@ -92,6 +94,35 @@ export async function setAuthSession(
writeLocalStorage("auth_provider", meta.auth_provider);
}
if (meta?.email) writeLocalStorage("email", meta.email);
writeLocalStorage("is_sub_user", meta?.is_sub_user ? "1" : "0");
writeLocalStorage(
"sub_user_permissions",
JSON.stringify(meta?.sub_user_permissions || [])
);
}
/** آیا نشستِ فعلی مالِ یه کاربرِ زیرمجموعه‌ست (نه خودِ صاحبِ اکانت) */
export function isSubUserSession(): boolean {
return readLocalStorage("is_sub_user") === "1";
}
/** پرمیژن‌های کاربرِ زیرمجموعه‌ی فعلی — برای کاربرِ اصلی همیشه خالیه (چون
* isSubUserSession false برمی‌گرده و جایی چک نمی‌شه) */
export function getSubUserPermissions(): string[] {
try {
const raw = readLocalStorage("sub_user_permissions");
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
/** true اگه کاربرِ اصلی باشه (همیشه دسترسی کامل) یا زیرمجموعه‌ای که این
* پرمیژنِ خاص رو داره */
export function canAccess(permissionKey: string): boolean {
if (!isSubUserSession()) return true;
return getSubUserPermissions().includes(permissionKey);
}
/** پاک کردن کامل session */
@@ -123,6 +154,8 @@ export async function clearAuthSession(): Promise<void> {
removeLocalStorage("step");
removeLocalStorage("auth_provider");
removeLocalStorage("email");
removeLocalStorage("is_sub_user");
removeLocalStorage("sub_user_permissions");
}
export function getStoredUserId(): string {

View File

@@ -1,6 +1,7 @@
import {
decryptText,
deriveDmAesKey,
encryptText,
importPublicKeySpkiB64,
} from "./crypto";
import { E2EE_LOCKED_PREVIEW, isE2eeEnvelope } from "./envelope";
@@ -77,19 +78,14 @@ export function clearDmKeyCache(): void {
dmKeyCache.clear();
}
// موقتاً غیرفعال شده به‌درخواستِ کاربر — با این‌که زیرساختِ بازیابیِ خودکار (رمزِ
// per-account، keys.ts: tryAutoRecoverIdentity) طبق طراحی دقیقاً همون رفتارِ
// «بدونِ رمزِ دستی مثلِ واتساپ» رو داشت، هنوز موردهایی بود که طرفِ مقابل همچنان
// «پیام رمزنگاری‌شده» می‌دید (هم توی وب هم اندروید). تا زمانی که با لاگِ واقعی
// ریشه‌یابی و رفع بشه، پیام‌های جدید ساده (بدونِ envelope) ارسال می‌شن —
// decryptDmContent برای پیام‌های قدیمیِ رمزنگاری‌شده دست‌نخورده مونده. کلِ زیرساختِ
// کلید/بک‌آپ دست‌نخورده مونده تا بعداً دوباره همینجا فعال بشه (فقط همین return رو بردار)
export async function encryptDmContent(
myUserId: string,
peerUserId: string,
plaintext: string
): Promise<string | null> {
return plaintext;
const key = await getDmConversationKey(myUserId, peerUserId);
if (!key) return null;
return encryptText(key, plaintext);
}
export async function decryptDmContent(

View File

@@ -4,6 +4,9 @@ export {
fetchMyKeyBackup,
saveKeyBackup,
recoverIdentityFromBackup,
backupIdentityWithPassword,
recoverIdentityWithPassword,
establishIdentityAtLogin,
createFreshIdentity,
IdentityRecoveryNeededError,
} from "./keys";

View File

@@ -50,64 +50,6 @@ 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;
}
}
/**
* پرتاب می‌شه وقتی روی سرور از قبل یه کلیدِ عمومی برای این کاربر ثبته، ولی این
* دستگاه هیچ کلیدِ محلی‌ای نداره — یعنی یه هویتِ قبلی وجود داره که این دستگاه بهش
@@ -126,6 +68,54 @@ export class IdentityRecoveryNeededError extends Error {
}
}
/**
* نشون می‌ده علتِ شکست یه مشکلِ گذرا (شبکه/سرور) بوده، نه نبودِ واقعیِ کلید/بک‌آپ.
* خیلی مهمه که این از «واقعاً بک‌آپی نیست» یا «رمزِ عبور/بک‌آپ ناسازگاره» جدا بمونه —
* جاهایی که این throw می‌شه نباید هیچ‌وقت منجر به createFreshIdentity (دورریختنِ یه
* هویتِ کاملاً قابل‌بازیابیِ واقعی به‌خاطرِ یه قطعیِ موقتِ اینترنت/سرور) بشن؛ فقط باید
* بی‌خطر نادیده گرفته بشن و تلاشِ بعدی (باز شدنِ یه چت، یا لاگینِ بعدی) دوباره امتحان کنه
*/
export class TransientE2eeError extends Error {
constructor(message = "E2EE transient failure") {
super(message);
this.name = "TransientE2eeError";
}
}
/** نسخه‌ی سخت‌گیرانه‌ی fetchPeerPublicKey — فقط برای تصمیم‌های حساس (آیا این کاربر
* از قبل هویتی روی سرور داره؟) که نباید شکستِ شبکه رو با «واقعاً کلیدی نیست» قاطی کنن */
async function fetchOwnPublicKeyStrict(uid: string): Promise<string | null> {
const base = getApiBaseUrl();
let res: Response;
try {
res = await fetch(
`${base}/account/e2ee/public-key/${encodeURIComponent(uid)}`,
{ headers: authHeaders() }
);
} catch {
throw new TransientE2eeError("Failed to check existing public key");
}
if (!res.ok) throw new TransientE2eeError("Failed to check existing public key");
const data = (await res.json()) as { publicKey?: string | null };
return data.publicKey || null;
}
/** نسخه‌ی سخت‌گیرانه‌ی fetchMyKeyBackup — همون دلیلِ بالا */
async function fetchMyKeyBackupStrict(): Promise<KeyBackupPayload | null> {
const base = getApiBaseUrl();
let res: Response;
try {
res = await fetch(`${base}/account/e2ee/key-backup`, {
headers: authHeaders(),
});
} catch {
throw new TransientE2eeError("Failed to fetch key backup");
}
if (!res.ok) throw new TransientE2eeError("Failed to fetch key backup");
const data = (await res.json()) as { backup?: KeyBackupPayload | null };
return data.backup || null;
}
async function storeAndAdopt(
uid: string,
privateKey: CryptoKey,
@@ -160,22 +150,20 @@ 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);
// کاربرِ تازه‌ست، نه یه دستگاهِ جدید/دیتای‌پاک‌شده برای یه هویتِ از قبل موجود. دیگه
// اینجا هیچ بازیابیِ خودکارِ بی‌رمزی امتحان نمی‌شه — بازیابی فقط با رمزِ عبورِ واقعیِ
// کاربر ممکنه (recoverIdentityWithPassword پایینِ همین فایل)، که فقط توی فلویِ لاگین
// (جایی که رمز تازه وارد شده) یا توی کارتِ بازیابیِ وسطِ سشن (با رمزِ ورودیِ کاربر)
// صدا زده می‌شه — نه اینجا، چون این تابع هیچ رمزی در اختیار نداره
// strict: اگه این چک به‌خاطرِ قطعیِ شبکه/سرور شکست بخوره، TransientE2eeError
// پرتاب می‌شه (نه این‌که انگار کلیدی نیست) — وگرنه یه هویتِ واقعی که روی سرور ثبته
// بی‌دلیل بازنویسی می‌شد
const existingPublicKey = await fetchOwnPublicKeyStrict(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));
}
@@ -189,9 +177,6 @@ async function loadOrCreateIdentityKeys(uid: string): Promise<{
} catch {
/* publish best-effort */
}
// هویتِ تازه رو فوراً با رمزِ خودکار بک‌آپ می‌گیره — دستگاهِ بعدی که با همین اکانت
// وارد بشه، دیگه هیچ‌وقت به این نقطه (existingPublicKey ولی بدون بک‌آپ) نمی‌رسه
await saveAutoKeyBackup(privateJwk);
return result;
}
@@ -254,17 +239,94 @@ export async function recoverIdentityFromBackup(
pin: string
): Promise<void> {
const uid = String(userId);
const backup = await fetchMyKeyBackup();
// strict: شکستِ شبکه اینجا نباید به «بک‌آپی نیست» تعبیر بشه — recoverIdentityWithPassword
// پایین‌تر این فرق رو می‌شناسه و TransientE2eeError رو دوباره پرتاب می‌کنه، نه false
const backup = await fetchMyKeyBackupStrict();
if (!backup) throw new Error("No backup found");
const privateJwk = await decryptPrivateKeyFromBackup(backup, pin);
const privateKey = await importPrivateKeyJwk(privateJwk);
const publicKeyB64 = await fetchPeerPublicKey(uid);
const publicKeyB64 = await fetchOwnPublicKeyStrict(uid);
if (!publicKeyB64) throw new Error("Could not fetch published public key");
await storeAndAdopt(uid, privateKey, privateJwk, publicKeyB64);
}
/** بک‌آپِ کلیدِ این مرورگر رو زیرِ رمزِ عبورِ خودِ کاربر می‌سازه/به‌روز می‌کنه — بی‌صدا و best-effort (دقیقاً عینِ saveKeyBackup، فقط پارامترش رمزِ عبوره نه PIN دستی) */
export async function backupIdentityWithPassword(
userId: string,
password: string
): Promise<void> {
try {
await saveKeyBackup(userId, password);
} catch {
/* best-effort — اگه الان شکست بخوره، لاگینِ بعدی دوباره امتحان می‌کنه */
}
}
/** بازیابیِ هویت با رمزِ عبورِ خودِ کاربر — true/false به‌جای throw، چون صدازننده (فلویِ لاگین)
* می‌خواد بی‌سروصدا fallback کنه، نه کرش کنه. استثنا: TransientE2eeError (قطعیِ
* شبکه/سرور، نه یه شکستِ واقعی) دوباره throw می‌شه — صدازننده (establishIdentityAtLogin)
* این رو از یه شکستِ واقعی تشخیص می‌ده و createFreshIdentity صدا نمی‌زنه، چون معنیِ
* false اینجا باید «واقعاً بک‌آپی نیست یا رمز جواب نداد» باشه، نه «نت قطع بود» */
export async function recoverIdentityWithPassword(
userId: string,
password: string
): Promise<boolean> {
try {
await recoverIdentityFromBackup(userId, password);
return true;
} catch (err) {
if (err instanceof TransientE2eeError) throw err;
return false;
}
}
/**
* نقطه‌ی واحدِ فراخوانی از صفحه‌های لاگین/تنظیمِ رمزِ عبور — همون لحظه‌ای که رمزِ خامِ
* کاربر توی scope موجوده. یا کلیدِ محلیِ موجود رو زیرِ این رمز بک‌آپ می‌گیره، یا (اگه
* این مرورگر کلیدی نداره ولی سرور یه هویتِ قبلی داره) با همین رمز بازیابیش می‌کنه —
* بدونِ اینکه کاربر چیزی غیر از رمزِ عبورِ خودش وارد کنه. طبقِ خواسته‌ی کاربر، E2EE باید
* کاملاً نامرئی باشه: دیگه هیچ کارتِ بازیابی‌ای وسطِ سشن از کاربر رمز/کلید نمی‌خواد — اگه
* بازیابی با همین رمز هم شکست بخوره (مثلاً بک‌آپِ قدیمی با مکانیزمِ قبلی رمزنگاری شده و
* دیگه قابل‌بازگشت نیست)، به‌جای موندن در حالتِ «نیاز به بازیابی» (که بعداً باعثِ throw شدنِ
* IdentityRecoveryNeededError و قفل دیدنِ پیام‌ها می‌شد)، بی‌صدا یه هویتِ تازه ساخته
* می‌شه و زیرِ همین رمز بک‌آپ می‌گیره — پیام‌های قدیمیِ رمزنگاری‌شده‌ی این دستگاه دیگه
* قابل‌بازگشت نیستن (طبق تصمیمِ قبلی: بدونِ مسیرِ migration)، ولی کاربر هیچ وقفه‌ای حس نمی‌کنه.
*
* نکته‌ی خیلی مهم (حفظِ اطلاعاتِ چت): createFreshIdentity فقط وقتی صدا زده می‌شه که
* recoverIdentityWithPassword با قطعیت false برگردونه (واقعاً بک‌آپی نیست یا رمز روش
* جواب نمی‌ده) — اگه دلیلِ شکست یه قطعیِ گذرای شبکه/سرور باشه (TransientE2eeError)،
* اصلاً createFreshIdentity صدا زده نمی‌شه، چون این یعنی هویتِ قدیمی احتمالاً هنوز
* قابل‌بازیابیه و فقط الان نتونستیم بررسیش کنیم؛ همین لاگین دوباره (یا باز شدنِ بعدیِ
* یه چت) دوباره امتحان می‌کنه — همین‌جوری هیچ‌وقت یه هویتِ واقعی به‌خاطرِ قطعیِ اینترنت
* دور ریخته نمی‌شه
*/
export async function establishIdentityAtLogin(
userId: string,
password: string
): Promise<void> {
const uid = String(userId);
try {
await ensureIdentityKeys(uid);
await backupIdentityWithPassword(uid, password);
} catch (err) {
if (err instanceof TransientE2eeError) return;
if (err instanceof IdentityRecoveryNeededError) {
try {
const recovered = await recoverIdentityWithPassword(uid, password);
if (!recovered) {
await createFreshIdentity(uid);
await backupIdentityWithPassword(uid, password);
}
} catch {
/* TransientE2eeError یا هر خطای غیرمنتظره‌ی دیگه حینِ بازیابی — بی‌خطر نادیده
گرفته می‌شه؛ هویتِ محلی دست‌نخورده می‌مونه، createFreshIdentity صدا زده نمی‌شه */
}
}
}
}
/**
* وقتی هیچ بک‌آپی نیست و هیچ دستگاهِ دیگه‌ای هم برای لینک‌کردن در دسترس نیست — کاربر
* صراحتاً می‌پذیره که پیام‌های قدیمیِ رمزنگاری‌شده برای همیشه غیرقابل‌بازگشت می‌مونن و
@@ -285,9 +347,8 @@ export async function createFreshIdentity(userId: string): Promise<{
} catch {
/* publish best-effort */
}
// این هویتِ تازه رو هم فوراً با رمزِ خودکار بک‌آپ می‌گیره — وگرنه دستگاهِ بعدی که با
// همین اکانت وارد بشه، دوباره به همین بن‌بست (بدون بک‌آپ) می‌رسید
await saveAutoKeyBackup(privateJwk);
// دیگه اینجا بک‌آپی گرفته نمی‌شه (رمزی در اختیار نیست) — همون اولین لاگینِ بعدی
// (establishIdentityAtLogin) این کلیدِ تازه رو زیرِ رمزِ عبورِ کاربر بک‌آپ می‌گیره
return result;
}

View File

@@ -2,6 +2,7 @@ import Cookies from "js-cookie";
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import {
decryptText,
encryptText,
generateRoomKeyRaw,
importPublicKeySpkiB64,
importRoomAesKey,
@@ -223,15 +224,14 @@ export async function ensureRoomAesKey(
return task;
}
// موقتاً غیرفعال شده به‌درخواستِ کاربر — عینِ همون دلیلِ encryptDmContent (dm.ts).
// پیام‌های جدیدِ روم ساده ارسال می‌شن؛ decryptRoomContent برای پیام‌های قدیمیِ
// رمزنگاری‌شده دست‌نخورده می‌مونه. برای فعال‌سازیِ دوباره، همین return رو بردار.
export async function encryptRoomContent(
myUserId: string,
roomId: string,
plaintext: string,
): Promise<string | null> {
return plaintext;
const key = await ensureRoomAesKey(myUserId, roomId);
if (!key) return null;
return encryptText(key, plaintext);
}
export async function decryptRoomContent(

View File

@@ -0,0 +1,13 @@
import type { QueryClient } from "@tanstack/react-query";
/**
* استوری تازه ثبت‌شده که هنوز کامل آپلود نشده — مثل اینستاگرام، کاربر بلافاصله به فید
* برمی‌گرده و حلقه‌ی چرخانِ «در حال آپلود» رو می‌بینه، نه این‌که منتظر اتمام آپلود بمونه.
* از کشِ react-query به‌عنوان یه state سبک و global استفاده می‌کنیم تا StoriesBar (که تو
* صفحه‌ی دیگه‌ای mount می‌شه، جدا از صفحه‌ی new-post) هم بتونه بهش گوش بده.
*/
export const PENDING_STORY_UPLOAD_KEY = ["pendingStoryUpload"] as const;
export function setPendingStoryUpload(queryClient: QueryClient, pending: boolean) {
queryClient.setQueryData(PENDING_STORY_UPLOAD_KEY, pending);
}

View File

@@ -293,6 +293,7 @@
"chats": "Messages",
"notifications": "Notifications",
"financial": "Payments",
"subUsers": "Manage Users",
"edit": "Edit profile",
"support": "Support"
},
@@ -532,6 +533,7 @@
"username": "Username",
"password": "Password",
"googleAccount": "Google account",
"registerMobile": "Register mobile number",
"instagramImport": "Import from Instagram",
"twoFactor": "Two-factor login",
"linkDevice": "Link device (chat encryption)",
@@ -571,6 +573,10 @@
"linkedHint": "Your Google account is linked:",
"unlinkedHint": "Link your Google account to sign in with Google in addition to mobile."
},
"registerMobile": {
"hint": "Enter your mobile number. Once registered, it cannot be changed.",
"success": "Mobile number registered successfully"
},
"instagramImport": {
"linkButton": "Connect Instagram account",
"orLinkWith": "Or connect with",
@@ -1337,6 +1343,15 @@
"postArchived": "Post archived",
"postUnarchived": "Post published again",
"archiveFailed": "Could not archive post",
"deletePost": "Delete post",
"deletePostConfirmTitle": "Delete this post?",
"deletePostConfirmBody": "This post, along with its likes and comments, will be permanently deleted and cannot be recovered.",
"cancel": "Cancel",
"deleteConfirm": "Delete",
"deleting": "Deleting...",
"postDeleted": "Post deleted",
"deleteFailed": "Could not delete post",
"postWasDeleted": "This post has been deleted",
"reportReasons": {
"inappropriate": "Inappropriate content",
"spam": "Spam",
@@ -1996,12 +2011,14 @@
"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.",
"recoveryHasBackupDesc": "This browser doesn't have access to your previous encryption key. Enter your account password to recover it.",
"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",
"passwordPlaceholder": "Your account password",
"recoverButton": "Recover with password",
"wrongPassword": "Incorrect password",
"recoverFailed": "No backup found on the server, or recovery failed",
"serverError": "Error reaching the server — try again",
"startFreshLink": "I don't have a backup, start fresh",
"confirmFreshWarning": "This will permanently make your old encrypted messages unreadable. Are you sure?"
},
@@ -2769,6 +2786,7 @@
"noPosts": "No posts found.",
"viewPost": "View post",
"postAlt": "Post {{id}}",
"postPending": "Pending approval",
"nothingFound": "Nothing found",
"appearanceDetails": "Appearance details",
"eyeColorLabel": "Eye color",
@@ -3279,5 +3297,46 @@
"chargeBonusHint": "Charging your wallet gives you a 5% bonus gift",
"chargeAmountPlaceholder": "Charge amount (Toman)",
"chargeWallet": "Charge wallet"
},
"subUsers": {
"listTitle": "Manage Users",
"emptyTitle": "You haven't created any sub-users yet",
"emptyHint": "Use the + button to create a sub-user with limited access",
"active": "Active",
"inactive": "Inactive",
"createTitle": "Create Sub-User",
"editTitle": "Edit Sub-User",
"nameLabel": "First name",
"namePlaceholder": "First name",
"lastNameLabel": "Last name",
"lastNamePlaceholder": "Last name",
"usernameLabel": "Username",
"usernamePlaceholder": "Username",
"passwordLabel": "Password",
"passwordPlaceholder": "Password",
"passwordEditHint": "Enter a new value to change the password",
"permissionsLabel": "Permissions",
"permissionsTitle": "Permissions",
"save": "Save",
"saving": "Saving...",
"createSuccess": "Sub-user created successfully",
"updateSuccess": "Updated successfully",
"edit": "Edit",
"enable": "Enable",
"disable": "Disable",
"enableSuccess": "User enabled",
"disableSuccess": "User disabled",
"permissions": {
"posts_stories": "Create posts & stories",
"reply_dm": "Reply to direct messages",
"reply_comments": "Reply to comments",
"create_shop": "Create a shop",
"shop_products": "Create/edit products",
"order_status": "Change order status",
"billboards": "Create/edit billboards",
"projects": "Create/manage projects",
"academy_packages": "Create/manage academy packages",
"bookings": "Create online bookings"
}
}
}

View File

@@ -293,6 +293,7 @@
"chats": "پیامها",
"notifications": "اعلانات",
"financial": "مالی",
"subUsers": "مدیریت کاربران",
"edit": "ویرایش",
"support": "پشتیبانی"
},
@@ -532,6 +533,7 @@
"username": "نام کاربری",
"password": "رمز عبور",
"googleAccount": "حساب گوگل",
"registerMobile": "ثبت شماره موبایل",
"instagramImport": "وارد کردن از اینستاگرام",
"twoFactor": "ورود دو مرحله‌ای",
"linkDevice": "اتصال دستگاه (رمزنگاری چت)",
@@ -571,6 +573,10 @@
"linkedHint": "حساب گوگل شما متصل است:",
"unlinkedHint": "می‌توانید حساب گوگل خود را به این حساب متصل کنید و علاوه بر موبایل، با گوگل هم وارد شوید."
},
"registerMobile": {
"hint": "شماره موبایل خود را وارد کنید. بعد از ثبت، دیگر امکان تغییر آن وجود نخواهد داشت.",
"success": "شماره موبایل با موفقیت ثبت شد"
},
"instagramImport": {
"linkButton": "اتصال حساب اینستاگرام",
"orLinkWith": "یا اتصال با",
@@ -1337,6 +1343,15 @@
"postArchived": "پست بایگانی شد",
"postUnarchived": "پست دوباره منتشر شد",
"archiveFailed": "بایگانی کردن پست ناموفق بود",
"deletePost": "حذف پست",
"deletePostConfirmTitle": "این پست حذف بشه؟",
"deletePostConfirmBody": "این پست به‌همراه لایک‌ها و کامنت‌هاش برای همیشه حذف می‌شه و قابل بازیابی نخواهد بود.",
"cancel": "انصراف",
"deleteConfirm": "حذف کن",
"deleting": "در حال حذف...",
"postDeleted": "پست حذف شد",
"deleteFailed": "حذف پست ناموفق بود",
"postWasDeleted": "این پست حذف شده است",
"reportReasons": {
"inappropriate": "محتوای نامناسب",
"spam": "اسپم",
@@ -1996,12 +2011,14 @@
"chatRoomDesc": "گفتگوی گروهی با کاربران",
"e2ee": {
"recoveryTitle": "بازیابی رمزنگاری گفتگوها",
"recoveryHasBackupDesc": "بازیابیِ خودکار این دستگاه به کلیدِ رمزنگاریِ قبلی موفق نشد. یه‌بار دیگه امتحان کن، یا اگه قبلاً رمزِ بازیابیِ دستی تنظیم کرده بودید همون رو وارد کنید.",
"recoveryHasBackupDesc": "این مرورگر به کلیدِ رمزنگاریِ قبلی دسترسی نداره. رمزِ عبورِ حسابتون رو وارد کنید تا دوباره بازیابی بشه.",
"recoveryNoBackupDesc": "این دستگاه به کلید رمزنگاری قبلی دسترسی نداره و بک‌آپی هم ثبت نشده. می‌تونید از یه دستگاه دیگه که قبلاً واردش شدید لینک کنید، یا با هویت تازه ادامه بدید (پیام‌های قدیمی رمزنگاری‌شده برای همیشه غیرقابل‌خوندن می‌مونن).",
"retryButton": "تلاش دوباره",
"pinPlaceholder": "رمز بازیابی دستی (در صورت وجود)",
"recoverButton": "بازیابی با رمز دستی",
"wrongPin": "رمز اشتباه است یا بازیابی ناموفق بود",
"passwordPlaceholder": "رمز عبور حساب شما",
"recoverButton": "بازیابی با رمز عبور",
"wrongPassword": "رمز عبور اشتباه است",
"recoverFailed": "بک‌آپی روی سرور پیدا نشد یا بازیابی ناموفق بود",
"serverError": "خطا در ارتباط با سرور — دوباره تلاش کنید",
"startFreshLink": "بک‌آپی ندارم، هویت تازه بساز",
"confirmFreshWarning": "با این کار پیام‌های رمزنگاری‌شده‌ی قبلی برای همیشه غیرقابل‌بازگشت می‌شن. مطمئنید؟"
},
@@ -2768,6 +2785,7 @@
"noPosts": "هیچ پستی یافت نشد.",
"viewPost": "مشاهده پست",
"postAlt": "پست {{id}}",
"postPending": "در انتظار تایید",
"nothingFound": "موردی یافت نشد",
"appearanceDetails": "مشخصات ظاهری",
"eyeColorLabel": "رنگ چشم",
@@ -3278,5 +3296,46 @@
"chargeBonusHint": "با شارژ کیف پول، ۵٪ هدیه اضافه دریافت می‌کنید",
"chargeAmountPlaceholder": "مبلغ شارژ (تومان)",
"chargeWallet": "شارژ کیف پول"
},
"subUsers": {
"listTitle": "مدیریت کاربران",
"emptyTitle": "هنوز کاربر زیرمجموعه‌ای نساخته‌اید",
"emptyHint": "با دکمه‌ی + یک کاربر زیرمجموعه با دسترسی‌های محدود بسازید",
"active": "فعال",
"inactive": "غیرفعال",
"createTitle": "ساخت کاربر زیرمجموعه",
"editTitle": "ویرایش کاربر زیرمجموعه",
"nameLabel": "نام",
"namePlaceholder": "نام کاربر",
"lastNameLabel": "نام خانوادگی",
"lastNamePlaceholder": "نام خانوادگی کاربر",
"usernameLabel": "نام کاربری",
"usernamePlaceholder": "نام کاربری",
"passwordLabel": "رمز عبور",
"passwordPlaceholder": "رمز عبور",
"passwordEditHint": "برای تغییر رمز عبور مقدار جدید وارد کنید",
"permissionsLabel": "دسترسی‌ها",
"permissionsTitle": "دسترسی‌ها",
"save": "ذخیره",
"saving": "در حال ذخیره...",
"createSuccess": "کاربر زیرمجموعه با موفقیت ساخته شد",
"updateSuccess": "اطلاعات با موفقیت به‌روزرسانی شد",
"edit": "ویرایش",
"enable": "فعال‌سازی",
"disable": "غیرفعال‌سازی",
"enableSuccess": "کاربر فعال شد",
"disableSuccess": "کاربر غیرفعال شد",
"permissions": {
"posts_stories": "ثبت پست و استوری",
"reply_dm": "جواب دادن به دایرکت",
"reply_comments": "جواب دادن به کامنت",
"create_shop": "ساخت فروشگاه",
"shop_products": "ثبت و ویرایش کالا",
"order_status": "تغییر وضعیت سفارشات",
"billboards": "ساخت و ویرایش بیلبورد",
"projects": "ساخت و مدیریت پروژه",
"academy_packages": "ساخت و مدیریت پکیج آموزشی",
"bookings": "ساخت رزرو آنلاین"
}
}
}

View File

@@ -15,6 +15,8 @@ export interface IVerifyOtp {
requires_2fa?: boolean;
temp_token?: string;
registrationComplete?: boolean;
is_sub_user?: boolean;
sub_user_permissions?: string[];
}
export interface Academy {
academy_image:string,