diff --git a/src/app/(auth)/(login)/login-with-username/page.tsx b/src/app/(auth)/(login)/login-with-username/page.tsx index 1bfe64e..fc96bd9 100644 --- a/src/app/(auth)/(login)/login-with-username/page.tsx +++ b/src/app/(auth)/(login)/login-with-username/page.tsx @@ -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, }); diff --git a/src/app/(auth)/(register)/register/password/page.tsx b/src/app/(auth)/(register)/register/password/page.tsx index f2be62a..690e8ce 100644 --- a/src/app/(auth)/(register)/register/password/page.tsx +++ b/src/app/(auth)/(register)/register/password/page.tsx @@ -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 = diff --git a/src/app/(projects)/projects/specialists/page.tsx b/src/app/(projects)/projects/specialists/page.tsx index bfe7fdb..0f6516d 100644 --- a/src/app/(projects)/projects/specialists/page.tsx +++ b/src/app/(projects)/projects/specialists/page.tsx @@ -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" )} > -
- {profileUrl ? ( - - ) : ( -
- {displayName.charAt(0)} -
- )} +
+
{isMain ? ( diff --git a/src/app/globals.css b/src/app/globals.css index 2aace67..d9a95ac 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 */ diff --git a/src/app/new-post/page.tsx b/src/app/new-post/page.tsx index 9d328b7..b809b40 100644 --- a/src/app/new-post/page.tsx +++ b/src/app/new-post/page.tsx @@ -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; } diff --git a/src/app/offer/[id]/page.tsx b/src/app/offer/[id]/page.tsx index d3ad015..6590930 100644 --- a/src/app/offer/[id]/page.tsx +++ b/src/app/offer/[id]/page.tsx @@ -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 ? ( -
- + - } + verifyBadge={userDetail?.verify_badge} /> +
+
+
+ {formatFullName(userDetail?.first_name, userDetail?.last_name)} +
+ + {userDetail?.user_name} + + +
+
+ + +
+
) : null; @@ -308,6 +335,20 @@ function TicketChat({ params }: ITicketChatProps) { void startPayment(selectedType, useWallet); }} /> + + { + setSocialSignupOpen(false); + pendingPaymentRetryRef.current = null; + }} + onSuccess={() => { + setSocialSignupOpen(false); + pendingPaymentRetryRef.current?.(); + pendingPaymentRetryRef.current = null; + }} + />
); diff --git a/src/app/settings/chats/[username]/[id]/page.tsx b/src/app/settings/chats/[username]/[id]/page.tsx index 500c5ba..8097eb3 100644 --- a/src/app/settings/chats/[username]/[id]/page.tsx +++ b/src/app/settings/chats/[username]/[id]/page.tsx @@ -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 = { 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) { }} /> + { + setSocialSignupOpen(false); + pendingSendRetryRef.current = null; + }} + onSuccess={() => { + setSocialSignupOpen(false); + pendingSendRetryRef.current?.(); + pendingSendRetryRef.current = null; + }} + /> + { @@ -203,7 +211,11 @@ function RoomMessageRow({ {msg.replyTo.senderName} -

{msg.replyTo.content}

+

+ {msg.replyTo.content === E2EE_LOCKED_PREVIEW + ? "" + : msg.replyTo.content} +

) : null}
@@ -288,9 +300,9 @@ function RoomMessageRow({ File ) : null} - {msg.content ? ( + {bubbleContent ? (

- {msg.content} + {bubbleContent}

) : null} @@ -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(null); const [messages, setMessages] = useState([]); // این صفحه با تغییرِ فقط پارامترِ 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()} /> + + { + setSocialSignupOpen(false); + pendingSendRetryRef.current = null; + }} + onSuccess={() => { + setSocialSignupOpen(false); + pendingSendRetryRef.current?.(); + pendingSendRetryRef.current = null; + }} + /> ); diff --git a/src/app/settings/edit/blocked-users/page.tsx b/src/app/settings/edit/blocked-users/page.tsx index 5feeb55..0c81d5c 100644 --- a/src/app/settings/edit/blocked-users/page.tsx +++ b/src/app/settings/edit/blocked-users/page.tsx @@ -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" > -
- -
+

{name}

diff --git a/src/app/settings/edit/page.tsx b/src/app/settings/edit/page.tsx index 002cc6c..31ceda7 100644 --- a/src/app/settings/edit/page.tsx +++ b/src/app/settings/edit/page.tsx @@ -16,6 +16,7 @@ const EDIT_NAV_KEY: Record = { "/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) => ( ("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 ( + + + {t("settings.edit.nav.registerMobile")} +

+ {step === "mobile" ? ( + <> +

+ {t("settings.edit.registerMobile.hint")} +

+ setMobile(e.target.value)} + placeholder="09xxxxxxxxx" + dir="ltr" + className="text-center" + maxLength={11} + /> + + {sending ? t("common.loading") : t("auth.sendCode")} + + + ) : ( + <> + { + setResendLoading(true); + try { + await request("POST", "/account/mobile/request", { + mobile: mobile.replace(/\D/g, ""), + }); + setOtp(""); + setOtpError(false); + } finally { + setResendLoading(false); + } + }} + /> + { + setOtp(v); + setOtpError(false); + }} + onComplete={() => setTimeout(() => void verify(), 400)} + error={otpError} + disabled={verifying || otpExpired} + /> + void verify()} + > + {verifying ? t("common.loading") : t("auth.verifyCode")} + + + )} +
+ + + ); +} diff --git a/src/app/settings/profile/user-settings/page.tsx b/src/app/settings/profile/user-settings/page.tsx index d4a519d..b8702c3 100644 --- a/src/app/settings/profile/user-settings/page.tsx +++ b/src/app/settings/profile/user-settings/page.tsx @@ -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() { › + {/* «مدیریت کاربران» فقط مالِ خودِ صاحبِ اکانته، نه کاربرِ زیرمجموعه */} + {!isSubUserSession() && ( + + {t("settings.nav.subUsers")} + › + + )} + (); + const id = params?.id; + + // نه router.back()/Link — چون توی این صفحه یه غیرفعال/فعال‌سازی یا ویرایش ممکنه + // انجام شده باشه و لیستِ صفحه‌ی قبل (که فقط با useEffect یه‌بار دیتاش رو می‌گیره) + // بدونِ یه ناوبریِ کامل (نه soft) وضعیتِ تازه رو نشون نمی‌داد + const backToList = () => { + window.location.href = "/settings/sub-users"; + }; + + const [subUser, setSubUser] = useState(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(null); + const [avatarFile, setAvatarFile] = useState(null); + const [permissions, setPermissions] = useState([]); + + 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) => { + 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 ( + + + {t("subUsers.editTitle")} +

{t("common.loading")}

+
+
+ ); + } + + const name = [subUser.first_name, subUser.last_name].filter(Boolean).join(" ") || subUser.user_name; + + if (editing) { + return ( + + + {t("subUsers.editTitle")} +
+
+
+ {avatar ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + )} +
+ + +
+ +
+
+

{t("subUsers.nameLabel")}

+ setFirstName(e.target.value)} /> +
+
+

{t("subUsers.lastNameLabel")}

+ setLastName(e.target.value)} /> +
+
+

{t("subUsers.usernameLabel")}

+ setUserName(e.target.value)} dir="ltr" /> +
+
+

{t("subUsers.passwordLabel")}

+ setPassword(e.target.value)} + placeholder={t("subUsers.passwordEditHint")} + type="password" + dir="ltr" + /> +
+
+ +
+

{t("subUsers.permissionsLabel")}

+
+ {SUB_USER_PERMISSION_KEYS.map((key) => ( + + ))} +
+
+ +
+ setEditing(false)} disabled={saving}> + {t("ads.backAction")} + + + {saving ? t("subUsers.saving") : t("subUsers.save")} + +
+
+
+
+ ); + } + + return ( + + + {t("subUsers.editTitle")} +
+ +
+

{name}

+

@{subUser.user_name}

+
+ +
+

{t("subUsers.permissionsTitle")}

+
+ {SUB_USER_PERMISSION_KEYS.map((key) => { + const granted = subUser.sub_user_permissions?.includes(key); + return ( +
+

{t(`subUsers.permissions.${key}`)}

+
+ {t(granted ? "settings.yes" : "settings.no")} +
+
+ ); + })} +
+
+ +
+ + {t("subUsers.edit")} + + + {t(subUser.sub_user_active ? "subUsers.disable" : "subUsers.enable")} + +
+
+
+
+ ); +} diff --git a/src/app/settings/sub-users/new/page.tsx b/src/app/settings/sub-users/new/page.tsx new file mode 100644 index 0000000..8dbd609 --- /dev/null +++ b/src/app/settings/sub-users/new/page.tsx @@ -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(null); + const [avatarFile, setAvatarFile] = useState(null); + const [permissions, setPermissions] = useState([]); + 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) => { + 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 ( + + + {t("subUsers.createTitle")} + +
+
+
+ {avatar ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + )} +
+ + +
+ +
+
+

{t("subUsers.nameLabel")}

+ setFirstName(e.target.value)} + placeholder={t("subUsers.namePlaceholder")} + /> +
+
+

{t("subUsers.lastNameLabel")}

+ setLastName(e.target.value)} + placeholder={t("subUsers.lastNamePlaceholder")} + /> +
+
+

{t("subUsers.usernameLabel")}

+ setUserName(e.target.value)} + placeholder={t("subUsers.usernamePlaceholder")} + autoCapitalize="none" + dir="ltr" + /> +
+
+

{t("subUsers.passwordLabel")}

+ setPassword(e.target.value)} + placeholder={t("subUsers.passwordPlaceholder")} + type="password" + dir="ltr" + /> +
+
+ +
+

{t("subUsers.permissionsLabel")}

+
+ {SUB_USER_PERMISSION_KEYS.map((key) => ( + + ))} +
+
+ + + {submitting ? t("subUsers.saving") : t("subUsers.save")} + +
+
+
+ ); +} diff --git a/src/app/settings/sub-users/page.tsx b/src/app/settings/sub-users/page.tsx new file mode 100644 index 0000000..420aee9 --- /dev/null +++ b/src/app/settings/sub-users/page.tsx @@ -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(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 ( + + + + + + } + > + {t("subUsers.listTitle")} + + +
+ {subUsers === null ? ( +

{t("common.loading")}

+ ) : subUsers.length === 0 ? ( + + ) : ( + subUsers.map((su) => { + const name = [su.first_name, su.last_name].filter(Boolean).join(" ") || su.user_name; + return ( + + +
+

{name}

+

@{su.user_name}

+
+ + {t(su.sub_user_active ? "subUsers.active" : "subUsers.inactive")} + + + ); + }) + )} +
+
+
+ ); +} diff --git a/src/components/auth/CompleteSocialSignupModal.tsx b/src/components/auth/CompleteSocialSignupModal.tsx new file mode 100644 index 0000000..df2aba3 --- /dev/null +++ b/src/components/auth/CompleteSocialSignupModal.tsx @@ -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(null); + const [passwordError, setPasswordError] = useState(null); + const [suggestions, setSuggestions] = useState([]); + 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( + "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)?.response?.data; + if (data?.field === "user_name") { + setStep("username"); + setUsernameError(data.message || "نام کاربری نامعتبر است"); + setSuggestions(data.suggestions || []); + } else { + setPasswordError(data?.message || "خطایی رخ داد، دوباره تلاش کنید"); + } + } finally { + setSubmitting(false); + } + }; + + return ( + +
+

+ تکمیل حساب کاربری +

+

+ برای امنیت گفتگوهاتون، یک نام کاربری و رمز عبور تنظیم کنید +

+ + {step === "username" ? ( +
+ { + setUsername(e.target.value); + setUsernameError(null); + }} + onKeyDown={(e) => { + if (e.key === "Enter") handleUsernameNext(); + }} + /> + {usernameError ? ( +

{usernameError}

+ ) : null} + {suggestions.length > 0 ? ( +
+ {suggestions.map((s) => ( + + ))} +
+ ) : null} + + ثبت + +
+ ) : ( +
+ { + setPassword(e.target.value); + setPasswordError(null); + }} + /> + { + setConfirmPassword(e.target.value); + setPasswordError(null); + }} + onKeyDown={(e) => { + if (e.key === "Enter") void handleSubmit(); + }} + /> + {passwordError ? ( +

{passwordError}

+ ) : null} + void handleSubmit()} + disabled={submitting || !password || !confirmPassword} + > + {submitting ? "در حال ثبت..." : "تایید و ادامه"} + +
+ )} +
+
+ ); +} diff --git a/src/components/chat/ChatMessageCard.tsx b/src/components/chat/ChatMessageCard.tsx index 11510de..f360a9f 100644 --- a/src/components/chat/ChatMessageCard.tsx +++ b/src/components/chat/ChatMessageCard.tsx @@ -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 = ({ {message.replyTo.senderName} -

{message.replyTo.content}

+

+ {getBubbleTextContent(message.replyTo.content)} +

)} @@ -877,15 +897,12 @@ const ChatMessageCard = ({ {!isSender && (
{showAvatar && partnerAvatar ? ( -
- -
+ ) : (
)} diff --git a/src/components/chat/ChatRoomMemberPicker.tsx b/src/components/chat/ChatRoomMemberPicker.tsx index d32b7df..10c11ec 100644 --- a/src/components/chat/ChatRoomMemberPicker.tsx +++ b/src/components/chat/ChatRoomMemberPicker.tsx @@ -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) ? ( - - ) : ( - - {user.user_name?.[0]?.toUpperCase() || "?"} - - )} + {displayName(user)} diff --git a/src/components/chat/E2eeIdentityGate.tsx b/src/components/chat/E2eeIdentityGate.tsx index 38ea83d..ad988cb 100644 --- a/src/components/chat/E2eeIdentityGate.tsx +++ b/src/components/chat/E2eeIdentityGate.tsx @@ -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({ kind: "idle" }); - const [pin, setPin] = useState(""); - const [error, setError] = useState(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 ( - {}} height="fit" elevated> -
-

{t("chats.e2ee.recoveryTitle")}

-

- {state.hasBackup - ? t("chats.e2ee.recoveryHasBackupDesc") - : t("chats.e2ee.recoveryNoBackupDesc")} -

- - void handleRetryAuto()} - > - {busy ? "…" : t("chats.e2ee.retryButton")} - - - {state.hasBackup ? ( - <> - 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" - /> - void handleRecover()} - > - {busy ? "…" : t("chats.e2ee.recoverButton")} - - - ) : null} - - {error &&

{error}

} - - {confirmFresh ? ( -
-

- {t("chats.e2ee.confirmFreshWarning")} -

-
- setConfirmFresh(false)} - > - {t("common.no")} - - void handleStartFresh()} - > - {busy ? "…" : t("common.yes")} - -
-
- ) : ( - - )} -
-
- ); - } + void ensureIdentityKeys(userId).catch(() => { + /* بی‌خطر نادیده گرفته می‌شه — رجوع کن به کامنتِ بالا */ + }); + }, [userId]); return null; } diff --git a/src/components/chat/ForwardMessageModal.tsx b/src/components/chat/ForwardMessageModal.tsx index e843c43..e1f8706 100644 --- a/src/components/chat/ForwardMessageModal.tsx +++ b/src/components/chat/ForwardMessageModal.tsx @@ -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 ? ( - - ) : ( -
- )} + {u.first_name} {u.last_name} diff --git a/src/components/chat/SharedPostBubble.tsx b/src/components/chat/SharedPostBubble.tsx index 86ba5bf..521a2b0 100644 --- a/src/components/chat/SharedPostBubble.tsx +++ b/src/components/chat/SharedPostBubble.tsx @@ -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 ( +
+

{t("posts.postWasDeleted")}

+
+ ); + } + return ( -
- {item.profile_image ? ( - - ) : null} +
+
diff --git a/src/components/main/ProfileAvatar.tsx b/src/components/main/ProfileAvatar.tsx index e269ad6..ae2d2ed 100644 --- a/src/components/main/ProfileAvatar.tsx +++ b/src/components/main/ProfileAvatar.tsx @@ -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 — 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 = ( {alt} @@ -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 ( + {/* بدونِ rounded-2xl مستقل اینجا — والد (StoryRing > innerRounded) خودش + overflow-hidden + شعاعِ درست‌محاسبه‌شده (avatarRadius منهای padding) + داره؛ یه رادیوسِ ثابتِ دیگه‌ی این‌جا با اونش ناهم‌خون می‌شه و باعثِ + نازک/نامنظم دیده‌شدنِ گوشه‌ها می‌شه */}
@@ -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 />
diff --git a/src/components/models/ModelPage/MainModelCardPost.tsx b/src/components/models/ModelPage/MainModelCardPost.tsx index 6e783b0..e4825b2 100644 --- a/src/components/models/ModelPage/MainModelCardPost.tsx +++ b/src/components/models/ModelPage/MainModelCardPost.tsx @@ -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" >
-
- {user_name} +
+ + {/* بدونِ rounded-2xl مستقل — والد (StoryRing) خودش شعاعِ درست‌محاسبه‌شده + رو کلیپ می‌کنه، وگرنه گوشه‌ها نازک/نامنظم دیده می‌شن */} +
+ {user_name} +
+
diff --git a/src/components/models/ModelPage/ModelContentPosts.tsx b/src/components/models/ModelPage/ModelContentPosts.tsx index 5faf812..647c12f 100644 --- a/src/components/models/ModelPage/ModelContentPosts.tsx +++ b/src/components/models/ModelPage/ModelContentPosts.tsx @@ -127,14 +127,23 @@ function ModelContentPosts({ post.post_images?.[0] || mediaFiles.find((f) => f.type === "image")?.src; + const isPending = post.status === "pending"; + return ( ); @@ -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} e.preventDefault() : undefined @@ -709,28 +720,57 @@ export default function ReelsPostCard({ )} >
+ {/* عینِ ReelsProfileFollowButton: به‌جای دو حالتِ مجزا (حلقه‌ی رنگی وقتی + استوری داره / بدونِ هیچ قاب‌ی وقتی نداره)، یه لایه‌ی حلقه‌ی همیشگی + با هندسه‌ی ثابت (اندازه‌ی کل ۴۸، عکس ۴۰، ضخامت ۲px) که فقط رنگش + عوض می‌شه: سفید برای بدونِ استوری، رنگِ تیک/برند برای استوری‌دار */} - -
- {user_name} -
-
+ {(() => { + // فاصله‌ی خالیِ شفاف بینِ حلقه و عکس فقط برای کاربرِ استوری‌دار می‌مونه؛ + // برای کاربرِ بدونِ استوری، قابِ سفید بدونِ فاصله‌ست و عکس مستقیم باهاش + // فیت می‌شه — ولی اندازه‌ی کلِ حلقه (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 ( +
+
+ {user_name} +
+
+ ); + })()}
{ + 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")} + ) : null}
@@ -370,6 +403,40 @@ export default function ReelsPostOptionsMenu({ + setDeleteConfirmOpen(false)} + height="fit" + panelClassName="!p-5" + > +

+ {t("posts.deletePostConfirmTitle")} +

+

+ {t("posts.deletePostConfirmBody")} +

+
+ setDeleteConfirmOpen(false)} + className="h-10 flex-1 text-sm" + > + {t("posts.cancel")} + + void deletePost()} + className="h-10 flex-1 !bg-red-500 text-sm" + > + {deleting ? t("posts.deleting") : t("posts.deleteConfirm")} + +
+
+ {analyticsOpen ? ( -
- {u.profile_image ? ( - - ) : null} -
+ {name} 0 && (
{selected.map((u) => { - const src = avatarSrc(u.profile_image); return ( -
- {src ? ( - - ) : null} -
+ {displayName(u)} · @@ -163,7 +149,6 @@ export default function TagUsersPicker({ )}
    {results.map((u) => { - const src = avatarSrc(u.profile_image); const name = displayName(u); return (
  • @@ -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" > -
    - {src ? ( - - ) : null} -
    +

    {name}

    diff --git a/src/components/projects/SpecialistCarousel.tsx b/src/components/projects/SpecialistCarousel.tsx index 4c687e2..901404b 100644 --- a/src/components/projects/SpecialistCarousel.tsx +++ b/src/components/projects/SpecialistCarousel.tsx @@ -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({

    -
    - {profileUrl ? ( - - ) : ( -
    - {displayName.charAt(0)} -
    - )} +
    +

    diff --git a/src/components/projects/SpecialistsSearchModal.tsx b/src/components/projects/SpecialistsSearchModal.tsx index 1a836c0..e22c5d1 100644 --- a/src/components/projects/SpecialistsSearchModal.tsx +++ b/src/components/projects/SpecialistsSearchModal.tsx @@ -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" )} > -

    - {profileUrl ? ( - - ) : ( -
    - {displayName.charAt(0)} -
    - )} +
    +
    {isMain ? ( diff --git a/src/components/settings/NotificationRow.tsx b/src/components/settings/NotificationRow.tsx index f37cab7..9ed27c3 100644 --- a/src/components/settings/NotificationRow.tsx +++ b/src/components/settings/NotificationRow.tsx @@ -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 (
    {visual === "user" ? ( - {actor?.user_name ) : (
    diff --git a/src/components/settings/PageTitle.tsx b/src/components/settings/PageTitle.tsx index 3a0d3d3..305b923 100644 --- a/src/components/settings/PageTitle.tsx +++ b/src/components/settings/PageTitle.tsx @@ -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 in an outer flex row — an outer wrapper @@ -26,6 +29,7 @@ const PageTitle: React.FC = ({ children, showBack = true, backHref, + onBack, action, }) => { const titleText = typeof children === "string" ? children : null; @@ -34,7 +38,7 @@ const PageTitle: React.FC = ({ return (
    {showBack ? ( - + ) : ( )} diff --git a/src/components/settings/SettingsClient.tsx b/src/components/settings/SettingsClient.tsx index b4a7aee..e60106b 100644 --- a/src/components/settings/SettingsClient.tsx +++ b/src/components/settings/SettingsClient.tsx @@ -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 ( @@ -41,7 +48,7 @@ export default function SettingsClient() {
    - {SETTINGS_NAV.map((item) => ( + {visibleNav.map((item) => ( - - + diff --git a/src/components/shops/SendProductModal.tsx b/src/components/shops/SendProductModal.tsx index 317498e..6155041 100644 --- a/src/components/shops/SendProductModal.tsx +++ b/src/components/shops/SendProductModal.tsx @@ -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" : "" }`} > -
    - {u.profile_image ? ( - - ) : null} -
    + {name} -
    - {u.profile_image ? ( - - ) : null} -
    + {name} {comments.map((c) => (
  • -
    - {c.creator?.profile_image ? ( - - ) : null} -
    +

    {c.creator?.user_name}

    diff --git a/src/components/shops/ShopProductGrid.tsx b/src/components/shops/ShopProductGrid.tsx index 3928159..cce5b0d 100644 --- a/src/components/shops/ShopProductGrid.tsx +++ b/src/components/shops/ShopProductGrid.tsx @@ -120,6 +120,7 @@ export default function ShopProductGrid({ ? listing.shop : null; const aspect = hashAspect(listing._id); + const isPending = listing.status !== "active"; return (
    @@ -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" : "" + }`} >
    -
    - {u.profile_image ? ( - - ) : null} -
    +

    {u.first_name} {u.last_name} diff --git a/src/components/stories/StoriesBar.tsx b/src/components/stories/StoriesBar.tsx index 34dd5fd..5c73626 100644 --- a/src/components/stories/StoriesBar.tsx +++ b/src/components/stories/StoriesBar.tsx @@ -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({ + 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 */}

    - {showDynamicFeed && hasMyStory ? ( + {showDynamicFeed && (hasMyStory || isUploadingMyStory) ? (