Compare commits

...

5 Commits

Author SHA1 Message Date
payacom
551260a836 reels 2026-10-09 15:44:12 +03:30
payacom
f364f989ed pay 2026-10-06 17:07:08 +03:30
payacom
d6386b779f e2ee 2026-10-02 18:12:58 +03:30
payacom
9fb454b0a7 reels 2026-10-01 21:11:37 +03:30
payacom
6e76e367f3 4.2.1 2026-09-25 13:35:34 +03:30
112 changed files with 4481 additions and 2429 deletions

Submodule .claude/worktrees/agent-af1dfc8fe9a6cb198 deleted from 3b89bbfec3

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.4.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack --port 3004",

View File

@@ -56,6 +56,17 @@ self.addEventListener("notificationclick", (event) => {
const CACHE_NAME = "pwa-cache-v12";
const MAX_CACHED_IMAGES = 200;
async function trimCache(name, maxEntries) {
const cache = await caches.open(name);
const keys = await cache.keys();
const extra = keys.length - maxEntries;
if (extra > 0) {
await Promise.all(keys.slice(0, extra).map((key) => cache.delete(key)));
}
}
const PRECACHE_URLS = [
"/",
"/offline.html",
@@ -135,7 +146,8 @@ self.addEventListener("fetch", (event) => {
const clone = res.clone();
caches
.open(CACHE_NAME)
.then((cache) => cache.put(event.request, clone));
.then((cache) => cache.put(event.request, clone))
.then(() => trimCache(CACHE_NAME, MAX_CACHED_IMAGES));
}
return res;
});

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) {
// [E2EE] موقتاً غیرفعال — void establishIdentityAtLogin(response.id, values.password);
}
const loggedIn = await completeLogin(router, response, {
redirectTo: redirectPath,
});

View File

@@ -1,12 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import AuthHead from "@/components/auth/AuthHead";
import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import Image from "next/image";
import useAxios from "@/hooks/useAxios";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
@@ -14,6 +14,7 @@ import toast from "react-hot-toast";
import { getSafeRedirectPath } from "@/lib/auth/postLogin";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import { IProfileData } from "@/types/types";
function RegisterCompletePage() {
@@ -21,6 +22,16 @@ function RegisterCompletePage() {
const router = useRouter();
const { request, loading } = useAxios();
const [action, setAction] = useState<"app" | "continue" | null>(null);
const [user, setUser] = useState<IProfileData | null>(null);
useEffect(() => {
request<{ user: IProfileData }>("GET", "/profile")
.then((res) => setUser(res?.user ?? null))
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const name = [user?.first_name, user?.last_name].filter(Boolean).join(" ");
// مطابق درخواست: عکس پروفایل حالا زودتر، توی خودِ صفحه‌ی register/avatar گرفته
// می‌شه (قبل از این صفحه) — پس دیگه لازم نیست ویزارد تایید هویت با verify/avatar
@@ -49,7 +60,10 @@ function RegisterCompletePage() {
if (next === "continue") {
await goToVerifyWizard();
} else {
router.push(getSafeRedirectPath());
// navigation سخت — همون دلیلِ Logout.tsx: router.push ممکنه نسخه‌ی
// کش‌شده‌ی فید رو از زمانی که هنوز مهمان بودیم (قبل از تکمیلِ ثبت‌نام،
// ریدایرکت‌شده به ورود) دوباره نشون بده، بدون اجرای دوباره‌ی میدل‌ور
window.location.href = getSafeRedirectPath();
}
} catch (err: any) {
toast.error(
@@ -65,32 +79,53 @@ function RegisterCompletePage() {
<AuthPageLayout>
<div className="flex w-full flex-1 flex-col items-center">
<AuthPageContent>
<AuthHead title={t("auth.registerCompleteTitle")} />
<p className="text-sm text-gray-500 text-center max-w-sm mt-2">
<div className="flex flex-col items-center">
<Image
src={
user?.profile_image
? IMAGE_BASE_URL + user.profile_image
: "/images/fake-avatar.png"
}
alt=""
width={72}
height={72}
unoptimized
className="mb-2 h-[72px] w-[72px] rounded-full object-cover bg-gray-100"
/>
{name ? (
<span className="text-base font-bold text-foreground">
{name}
</span>
) : null}
{user?.user_name ? (
<span className="text-sm text-gray-500">{user.user_name}</span>
) : null}
</div>
<p className="text-sm text-gray-500 text-center max-w-sm mt-6">
{t("auth.registerCompleteDesc")}
</p>
</AuthPageContent>
<AuthFormFooter>
<div className="flex flex-col sm:flex-row gap-3 w-full max-w-sm justify-center items-center">
<AuthNextButton
type="button"
onClick={() => finishRegistration("continue")}
className="!border-[#16a34a] !text-[#16a34a] w-full sm:w-auto"
disabled={loading}
loading={loading && action === "continue"}
>
{t("auth.continueRegistration")}
</AuthNextButton>
<div className="flex flex-row gap-3 w-full max-w-sm justify-center items-center">
<AuthNextButton
type="button"
onClick={() => finishRegistration("app")}
className="!border-[#FF0000] !text-[#FF0000] w-full sm:w-auto"
className="!border-[#16a34a] !text-[#16a34a] flex-1"
disabled={loading}
loading={loading && action === "app"}
>
{t("auth.enterApp")}
</AuthNextButton>
<AuthNextButton
type="button"
onClick={() => finishRegistration("continue")}
className="!border-[#FF0000] !text-[#FF0000] flex-1"
disabled={loading}
loading={loading && action === "continue"}
>
{t("auth.continueRegistration")}
</AuthNextButton>
</div>
</AuthFormFooter>
</div>

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();
// [E2EE] موقتاً غیرفعال — if (userId) void establishIdentityAtLogin(userId, values.password);
router.push("/register/fullname");
} catch (err: any) {
const message =

View File

@@ -26,7 +26,7 @@ import { Comments } from "@/components/academy/CommentsModal";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import VerificationBadge from "@/components/main/VerificationBadge";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import { usePathname } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import Cookies from "js-cookie";
import { trackExploreInteraction } from "@/api/trackExploreInteraction";
import BillboardContactInfoModal from "@/components/billboards/BillboardPage/BillboardContactInfoModal";
@@ -112,6 +112,16 @@ export default function CourseDetail({
initialIsPurchased = false,
}: CourseDetailProps) {
const { t } = useTranslation("common");
const router = useRouter();
const searchParams = useSearchParams();
const paymentResult = searchParams.get("payment");
useEffect(() => {
if (!paymentResult) return;
if (paymentResult === "success") toast.success(t("shops.paymentSuccessTitle"));
else toast.error(t("shops.paymentFailedTitle"));
router.replace(window.location.pathname);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [paymentResult]);
const [selectedVideo, setSelectedVideo] = useState(MOCK_VIDEOS[0]);
const [isPurchased, setIsPurchased] = useState(initialIsPurchased);
const [purchasing, setPurchasing] = useState(false);

View File

@@ -23,6 +23,7 @@ function FailedProject() {
const [project, setProject] = useState<Project>();
const [typeList, setTypeList] = useState<IProjectType[] | null>(null);
const [price, setPrice] = useState("");
const [paying, setPaying] = useState(false);
const getDisplayTypeLabel = (projectType?: string) => {
if (projectType === "normal") {
@@ -68,6 +69,9 @@ function FailedProject() {
}, [project, typeList]);
const payHandler = async () => {
// قبلاً هیچ گاردی نداشت — دابل‌کلیک دو درخواستِ initiate-payment-web هم‌زمان می‌فرستاد
if (paying) return;
setPaying(true);
try {
const response = await request<{ authority?: string; type?: string }>(
"POST",
@@ -88,6 +92,7 @@ function FailedProject() {
}
} catch (error: unknown) {
console.log(error);
setPaying(false);
}
};
@@ -108,7 +113,7 @@ function FailedProject() {
</div>
<div className="px-4 w-full text-sm md:text-md font-semibold mt-10">
{project && <MainProjectCard project={project} />}
<div className="flex flex-col items-center gap-4 mt-4">
<div className="mt-6 flex w-full max-w-sm flex-row items-center justify-center gap-3">
<RoundedDiv className="p-2 w-full">
{getDisplayTypeLabel(project?.project_type)}:{" "}
{Number(price).toLocaleString()} {t("settings.toman")}
@@ -122,8 +127,9 @@ function FailedProject() {
onClick={() => {
payHandler();
}}
disabled={paying}
variant="primary"
className="w-32 h-9"
className="flex-1 h-9"
>
{t("academy.payment.retry")}
</RoundedButton>

View File

@@ -3,6 +3,7 @@ import { Suspense, cache } from "react";
import { cookies } from "next/headers";
import ProjectDetailClient from "./ProjectDetailClient";
import PageLoader from "@/components/ui/PageLoader";
import GuestAuthPromptModal from "@/components/auth/GuestAuthPromptModal";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import { fetchProject } from "@/api/fetchProject";
import {
@@ -67,6 +68,7 @@ export default async function ProjectDetailPage({
<Suspense fallback={<PageLoader className="min-h-[50vh]" />}>
<ProjectDetailClient id={id} />
</Suspense>
<GuestAuthPromptModal />
</>
);
}

View File

@@ -0,0 +1,15 @@
import IOSSpinner from "@/components/ui/IOSSpinner";
import { getServerLanguage } from "@/lib/i18n/server";
import { translateCommon } from "@/lib/i18n/translate";
export default async function ProjectsLoading() {
const lang = await getServerLanguage();
const label = translateCommon(lang, "common.loading");
return (
<div className="mx-auto flex min-h-[50vh] w-full max-w-xl flex-col items-center justify-center gap-3 px-4">
<IOSSpinner size={28} color="#ff107d" />
<span className="text-sm text-neutral-500">{label}</span>
</div>
);
}

View File

@@ -6,7 +6,7 @@ import useAxios from "@/hooks/useAxios";
import React, { useEffect, useState } from "react";
import RoundedDiv from "@/components/elements/RoundedDiv";
import RoundedButton from "@/components/elements/RoundedButton";
import { useSearchParams } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import Image from "next/image";
import MainProjectCard from "@/components/projects/MainProjectCard";
import { useTranslation } from "react-i18next";
@@ -18,11 +18,13 @@ function FailedProject() {
const { t } = useTranslation("common");
const { request } = useAxios();
const searchParams = useSearchParams();
const router = useRouter();
const projectId = searchParams.get("projectId");
const [project, setProject] = useState<Project>();
const [typeList, setTypeList] = useState<IProjectType[] | null>(null);
const [price, setPrice] = useState("");
const [paying, setPaying] = useState(false);
const getDisplayTypeLabel = (projectType?: string) => {
if (projectType === "normal" || projectType === "free") {
@@ -68,6 +70,9 @@ function FailedProject() {
}, [project, typeList]);
const payHandler = async () => {
// قبلاً هیچ گاردی نداشت — دابل‌کلیک دو درخواستِ initiate-payment-web هم‌زمان می‌فرستاد
if (paying) return;
setPaying(true);
try {
const response = await request<{
authority?: string;
@@ -88,12 +93,14 @@ function FailedProject() {
window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`;
} else {
toast.error(response?.message || t("projects.payment.initFailed"));
setPaying(false);
}
} catch (error: unknown) {
const axiosErr = error as { response?: { data?: { message?: string } } };
toast.error(
axiosErr?.response?.data?.message || t("projects.payment.initFailed")
);
setPaying(false);
}
};
@@ -114,7 +121,7 @@ function FailedProject() {
</div>
<div className="mt-10 w-full px-4 text-sm font-semibold md:text-base">
{project && <MainProjectCard project={project} />}
<div className="mt-4 flex flex-col items-center gap-4">
<div className="mt-6 flex w-full max-w-sm flex-row items-center justify-center gap-3">
<RoundedDiv className="w-full p-2">
{getDisplayTypeLabel(project?.project_type)}:{" "}
{Number(price).toLocaleString()} {t("settings.toman")}
@@ -126,11 +133,18 @@ function FailedProject() {
</RoundedDiv>
<RoundedButton
onClick={payHandler}
disabled={paying}
variant="primary"
className="h-9 w-32"
className="h-9 flex-1"
>
{t("projects.payment.retry")}
</RoundedButton>
<RoundedButton
onClick={() => router.push("/settings/workroom?filter=unpaid")}
className="h-9 flex-1"
>
{t("common.back")}
</RoundedButton>
</div>
</div>
</Container>

View File

@@ -8,7 +8,6 @@ import RoundedDiv from "@/components/elements/RoundedDiv";
import RoundedButton from "@/components/elements/RoundedButton";
import { useSearchParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import MainProjectCard from "@/components/projects/MainProjectCard";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
@@ -124,11 +123,13 @@ function SuccessProject() {
</RoundedDiv>
)}
<p className="my-5">{t("projects.payment.reviewNotice")}</p>
<Link href={"/settings/workroom"}>
<RoundedButton variant="primary" className="h-9 w-32">
{t("projects.payment.workroom")}
</RoundedButton>
</Link>
<RoundedButton
variant="primary"
className="h-9 w-32"
onClick={() => (window.location.href = projectId ? `/projects/${projectId}` : "/settings/workroom")}
>
{t("settings.confirm")}
</RoundedButton>
</div>
</div>
</Container>

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

@@ -1,5 +1,6 @@
import { BASE_URL, IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import BillboardDetailContent from "@/components/billboards/BillboardDetailContent";
import GuestAuthPromptModal from "@/components/auth/GuestAuthPromptModal";
import { IAdvertising, IRate } from "@/types/types";
import { cookies } from "next/headers";
import { cache } from "react";
@@ -135,6 +136,7 @@ async function BillboardPage({ params }: IBillboardProps) {
}}
/>
<BillboardDetailContent billboard={billboard} rate={rate} />
<GuestAuthPromptModal />
</>
);
}

View File

@@ -26,6 +26,7 @@ function BillboardPayment({ params }: IBillboardProps) {
const [advertising, setAdvertising] = useState<IAdvertising>();
const [typeList, setTypeList] = useState<IAdvertisingType[] | null>(null);
const [price, setPrice] = useState("");
const [paying, setPaying] = useState(false);
useEffect(() => {
const fetchAd = async () => {
@@ -50,6 +51,9 @@ function BillboardPayment({ params }: IBillboardProps) {
}, [id, request]);
const payHandler = async () => {
// قبلاً هیچ گاردی نداشت — دابل‌کلیک دو درخواستِ initiate-payment-web هم‌زمان می‌فرستاد
if (paying) return;
setPaying(true);
try {
const response = await request<{ authority?: string }>(
"POST",
@@ -72,6 +76,7 @@ function BillboardPayment({ params }: IBillboardProps) {
}
} catch (error: unknown) {
console.log(error);
setPaying(false);
}
};
@@ -103,6 +108,7 @@ function BillboardPayment({ params }: IBillboardProps) {
<RoundedButton
variant="primary"
onClick={payHandler}
disabled={paying}
className="h-9 w-32"
>
{t("billboards.payment.title")}

View File

@@ -27,6 +27,7 @@ function FailedBillboard() {
const price = searchParams.get("price") || "0";
const [advertising, setAdvertising] = useState<IAdvertising>();
const [paying, setPaying] = useState(false);
useEffect(() => {
const fetchAd = async () => {
@@ -39,7 +40,11 @@ function FailedBillboard() {
fetchAd();
}, [projectId, request]);
// قبلاً هیچ‌کدومِ این دو تابع گاردِ submitting نداشتن — دابل‌کلیکِ «تلاشِ دوباره»
// دو درخواستِ پرداختِ هم‌زمان می‌فرستاد
const payHandler = async () => {
if (paying) return;
setPaying(true);
try {
const response = await request<{ authority?: string }>(
"POST",
@@ -62,10 +67,13 @@ function FailedBillboard() {
}
} catch (error: unknown) {
console.log(error);
setPaying(false);
}
};
const republishHandler = async () => {
if (paying) return;
setPaying(true);
try {
const response = await request<{ authority?: string }>(
"POST",
@@ -88,6 +96,7 @@ function FailedBillboard() {
}
} catch (error: unknown) {
console.log(error);
setPaying(false);
}
};
@@ -108,7 +117,7 @@ function FailedBillboard() {
</div>
<div className="px-4 w-full text-sm md:text-md font-semibold mt-10">
{advertising && <MainBillboardCard billboard={advertising} />}
<div className="flex flex-col items-center gap-4 mt-4">
<div className="mt-6 flex w-full max-w-sm flex-row items-center justify-center gap-3">
<RoundedDiv className="p-2 w-full">
{getBillboardDisplayTypeLabel(t, advertising?.type)}:{" "}
{Number(price).toLocaleString()} {t("settings.toman")}
@@ -126,11 +135,18 @@ function FailedBillboard() {
payHandler();
}
}}
disabled={paying}
variant="primary"
className="w-32 h-9"
className="flex-1 h-9"
>
{t("billboards.payment.retry")}
</RoundedButton>
<RoundedButton
onClick={() => router.push("/settings/my-billboards?filter=unpaid")}
className="flex-1 h-9"
>
{t("common.back")}
</RoundedButton>
</div>
</div>
</Container>

View File

@@ -12,7 +12,6 @@ import PageTitle from "@/components/settings/PageTitle";
import { getBillboardDisplayTypeLabel } from "@/lib/billboards/displayTypeLabel";
import { useSearchParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { useTranslation } from "react-i18next";
function SuccessBillboard() {
@@ -59,11 +58,13 @@ function SuccessBillboard() {
{Number(price).toLocaleString()} {t("settings.toman")}
</RoundedDiv>
<p className="my-5">{t("billboards.payment.registeredMessage")}</p>
<Link href={"/settings/my-billboards"}>
<RoundedButton variant="primary" className="w-32 h-9">
{t("billboards.payment.myBillboards")}
</RoundedButton>
</Link>
<RoundedButton
variant="primary"
className="w-32 h-9"
onClick={() => (window.location.href = projectId ? `/settings/my-billboards/${projectId}/billboard` : "/settings/my-billboards")}
>
{t("settings.confirm")}
</RoundedButton>
</div>
</div>
</Container>

View File

@@ -8,6 +8,7 @@ import {
buildPostSeoTitle,
} from "@/lib/postSlug";
import ExploreReelClient from "./ExploreReelClient";
import GuestAuthPromptModal from "@/components/auth/GuestAuthPromptModal";
import { getServerLanguage } from "@/lib/i18n/server";
import { translateCommon } from "@/lib/i18n/translate";
@@ -44,5 +45,10 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
export default async function ExploreReelPage({ params }: Props) {
const { id } = await params;
return <ExploreReelClient id={id} />;
return (
<>
<ExploreReelClient id={id} />
<GuestAuthPromptModal />
</>
);
}

View File

@@ -4,13 +4,14 @@ import Container from "@/components/elements/Container";
import RoundedButton from "@/components/elements/RoundedButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import PageTitle from "@/components/settings/PageTitle";
import { useSearchParams } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { useTranslation } from "react-i18next";
function GiftPaymentFailed() {
const { t } = useTranslation("common");
const router = useRouter();
const searchParams = useSearchParams();
const receiverId = searchParams.get("receiverId");
@@ -28,19 +29,17 @@ function GiftPaymentFailed() {
/>
<span>{t("giftPage.paymentFailed.message")}</span>
</div>
<div className="mt-8 grid grid-cols-2 gap-4">
<div className="mt-6 flex w-full max-w-sm flex-row items-center justify-center gap-3">
{receiverId ? (
<Link href={`/gift/${receiverId}`}>
<RoundedButton variant="primary" className="h-9 w-32">
<Link href={`/gift/${receiverId}`} className="flex-1">
<RoundedButton variant="primary" className="h-9 w-full">
{t("giftPage.paymentFailed.retry")}
</RoundedButton>
</Link>
) : null}
<Link href="/">
<RoundedButton variant="primary" className="h-9 w-32">
{t("giftPage.paymentFailed.back")}
</RoundedButton>
</Link>
<RoundedButton variant="default" className="h-9 flex-1" onClick={() => router.push(receiverId ? `/gift/${receiverId}` : "/")}>
{t("giftPage.paymentFailed.back")}
</RoundedButton>
</div>
</Container>
</LocalePageShell>

View File

@@ -182,6 +182,14 @@
html {
scroll-behavior: smooth;
/* بدون این، Chrome روی گوشی‌هایی که «Force dark برای محتوای وب» روشنه (پیش‌فرض بعضی
بیلدهای شیائومی/سامسونگ) فکر می‌کنه این سایت از تم تیره پشتیبانی نمی‌کنه و خودش
رنگ‌های input/text رو الگوریتمی معکوس می‌کنه — دقیقاً همون چیزی که باعث می‌شد نام/
نام‌خانوادگی توی تم تیره ناخوانا بشه. اعلامِ صریحِ color-scheme این رفتار رو خاموش می‌کنه */
color-scheme: light;
}
html.dark {
color-scheme: dark;
}
body {
direction: rtl;
@@ -641,11 +649,17 @@ select {
border-color: rgba(255, 255, 255, 0.15);
}
/* env(safe-area-inset-bottom) روی اندروید (برخلافِ iOS) تقریباً همیشه 0px
برمی‌گرده، حتی وقتی نوار جست‌وجوی سیستم/نشانگرِ ژستِ اندروید همون چند پیکسلِ
پایینِ صفحه رو اشغال کرده — یعنی قبلاً تکیه‌ی تنها به env() باعث می‌شد دکمه‌ی
ارسال/ویس روی خیلی از گوشی‌های اندروید دقیقاً روی لبه‌ی پایینیِ صفحه بشینه و
زیرِ نوارِ سیستم بره. max() یه حداقلِ ثابت (که این نوارها رو پوشش می‌ده) رو
تضمین می‌کنه، مستقل از این‌که env() چی برگردونه */
.chat-input-area {
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));
padding-bottom: max(1.5rem, calc(0.75rem + env(safe-area-inset-bottom, 0px)));
}
.chat-input-area--thread {
padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
padding-bottom: max(1.5rem, calc(1rem + env(safe-area-inset-bottom, 0px)));
}
.ig-dm-send-btn {
@@ -712,6 +726,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 */
@@ -797,22 +815,20 @@ select {
}
.glass-modal-overlay {
background: rgba(8, 8, 10, 0.52);
backdrop-filter: blur(20px) saturate(120%);
-webkit-backdrop-filter: blur(20px) saturate(120%);
background: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.dark .glass-modal-overlay {
background: rgba(0, 0, 0, 0.62);
backdrop-filter: blur(22px) saturate(110%);
-webkit-backdrop-filter: blur(22px) saturate(110%);
background: transparent;
}
.glass-modal-overlay--dark {
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(24px) saturate(110%);
-webkit-backdrop-filter: blur(24px) saturate(110%);
background: transparent;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.dark .glass-modal-overlay--dark {
background: rgba(0, 0, 0, 0.82);
background: transparent;
}
.glass-modal-panel {

View File

@@ -1,125 +1,127 @@
import type { Metadata, Viewport } from "next";
import localFont from "next/font/local";
import { defaultSEOConfig } from "@/config/seoConfig";
import { SITE_URL } from "@/config/site";
import { getSiteSeoMeta, getSeoPage, buildHreflangAlternates } from "@/lib/i18n/seo";
import { getLanguageDefinition } from "@/lib/i18n/registry";
import { getServerLanguage } from "@/lib/i18n/server";
import SiteJsonLd from "@/components/seo/SiteJsonLd";
import Script from 'next/script';
import "./globals.css";
import Layout from "@/components/Layout";
import RegisterSW from "@/components/RegisterSW";
import PushNotificationListener from "@/components/PushNotificationListener";
import VpnWarningGate from "@/components/VpnWarningGate";
import ClientErrorBoundary from "@/components/ClientErrorBoundary";
const iranSansFont = localFont({
src: "../../public/fonts/IRANSansX-Regular.woff",
display: "swap",
fallback: ["Tahoma", "Arial", "sans-serif"],
});
export async function generateMetadata(): Promise<Metadata> {
const lang = await getServerLanguage();
const site = getSiteSeoMeta(lang);
const home = getSeoPage("home", lang);
return {
title: {
default: home.title || site.name,
template: `%s | ${site.titleSuffix}`,
},
description: home.description,
metadataBase: new URL(SITE_URL),
keywords: site.keywords,
authors: [{ name: "Modstagram", url: SITE_URL }],
creator: "Modstagram",
alternates: buildHreflangAlternates(home.path || "/", lang),
manifest: "/manifest.webmanifest",
applicationName: site.name,
icons: {
icon: [
{ url: "/favicon.png", type: "image/png", sizes: "32x32" },
{ url: "/images/icons/192x192.png", type: "image/png", sizes: "192x192" },
],
shortcut: ["/favicon.png"],
apple: [
{ url: "/images/icons/apple-touch-icon.png", sizes: "180x180" },
{ url: "/images/icons/192x192.png", sizes: "192x192" },
],
},
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: site.name,
},
openGraph: {
siteName: site.name,
locale: lang === "fa" ? "fa_IR" : "en_US",
type: "website",
images: defaultSEOConfig.openGraph?.images || [],
},
robots: "index, follow",
twitter: {
card: "summary_large_image",
creator: "@modstagram",
site: "@modstagram",
images: defaultSEOConfig.openGraph?.images?.map((img) => img.url) || [],
},
verification: {
google: "yVjB5yKPchPUtxl33GWVyMvjH6wCEInqEvaH7ZsXXMo",
},
other: {
"msapplication-TileColor": "#FF107D",
samandehi: "522190795",
},
};
}
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
viewportFit: "cover",
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
],
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const lang = await getServerLanguage();
const def = getLanguageDefinition(lang);
return (
<html lang={def.htmlLang} dir={def.direction} suppressHydrationWarning>
<body className={`${iranSansFont.className} antialiased`}>
<SiteJsonLd />
<RegisterSW />
<PushNotificationListener />
<VpnWarningGate />
<ClientErrorBoundary>
<Layout initialLanguage={lang}>{children}</Layout>
</ClientErrorBoundary>
{/* ====================== گوگل آنالیتیکس ====================== */}
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-GT0QQX6F97"
strategy="afterInteractive"
/>
<Script id="google-analytics" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-GT0QQX6F97');
`}
</Script>
{/* ======================================================== */} </body>
</html>
);
}
import type { Metadata, Viewport } from "next";
import localFont from "next/font/local";
import { defaultSEOConfig } from "@/config/seoConfig";
import { SITE_URL } from "@/config/site";
import { getSiteSeoMeta, getSeoPage, buildHreflangAlternates } from "@/lib/i18n/seo";
import { getLanguageDefinition } from "@/lib/i18n/registry";
import { getServerLanguage } from "@/lib/i18n/server";
import SiteJsonLd from "@/components/seo/SiteJsonLd";
import { toPwaLang } from "@/lib/i18n/pwaLang";
import { PWA_VERSION } from "@/lib/pwaConfig";
import Script from 'next/script';
import "./globals.css";
import Layout from "@/components/Layout";
import RegisterSW from "@/components/RegisterSW";
import PushNotificationListener from "@/components/PushNotificationListener";
import VpnWarningGate from "@/components/VpnWarningGate";
import ClientErrorBoundary from "@/components/ClientErrorBoundary";
const iranSansFont = localFont({
src: "../../public/fonts/IRANSansX-Regular.woff",
display: "swap",
fallback: ["Tahoma", "Arial", "sans-serif"],
});
export async function generateMetadata(): Promise<Metadata> {
const lang = await getServerLanguage();
const site = getSiteSeoMeta(lang);
const home = getSeoPage("home", lang);
return {
title: {
default: home.title || site.name,
template: `%s | ${site.titleSuffix}`,
},
description: home.description,
metadataBase: new URL(SITE_URL),
keywords: site.keywords,
authors: [{ name: "Modstagram", url: SITE_URL }],
creator: "Modstagram",
alternates: buildHreflangAlternates(home.path || "/", lang),
manifest: `/manifest.webmanifest?lang=${toPwaLang(lang)}&v=${PWA_VERSION}`,
applicationName: site.name,
icons: {
icon: [
{ url: "/favicon.png", type: "image/png", sizes: "32x32" },
{ url: "/images/icons/192x192.png", type: "image/png", sizes: "192x192" },
],
shortcut: ["/favicon.png"],
apple: [
{ url: "/images/icons/apple-touch-icon.png", sizes: "180x180" },
{ url: "/images/icons/192x192.png", sizes: "192x192" },
],
},
appleWebApp: {
capable: true,
statusBarStyle: "default",
title: site.name,
},
openGraph: {
siteName: site.name,
locale: lang === "fa" ? "fa_IR" : "en_US",
type: "website",
images: defaultSEOConfig.openGraph?.images || [],
},
robots: "index, follow",
twitter: {
card: "summary_large_image",
creator: "@modstagram",
site: "@modstagram",
images: defaultSEOConfig.openGraph?.images?.map((img) => img.url) || [],
},
verification: {
google: "yVjB5yKPchPUtxl33GWVyMvjH6wCEInqEvaH7ZsXXMo",
},
other: {
"msapplication-TileColor": "#FF107D",
samandehi: "522190795",
},
};
}
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
viewportFit: "cover",
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
],
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const lang = await getServerLanguage();
const def = getLanguageDefinition(lang);
return (
<html lang={def.htmlLang} dir={def.direction} suppressHydrationWarning>
<body className={`${iranSansFont.className} antialiased`}>
<SiteJsonLd />
<RegisterSW />
<PushNotificationListener />
<VpnWarningGate />
<ClientErrorBoundary>
<Layout initialLanguage={lang}>{children}</Layout>
</ClientErrorBoundary>
{/* ====================== گوگل آنالیتیکس ====================== */}
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-GT0QQX6F97"
strategy="afterInteractive"
/>
<Script id="google-analytics" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-GT0QQX6F97');
`}
</Script>
{/* ======================================================== */} </body>
</html>
);
}

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

@@ -1,316 +1,343 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import Container from "@/components/elements/Container";
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 ScoreRateStats from "@/components/main/ScoreRateStats";
import PageTitle from "@/components/settings/PageTitle";
import useAxios from "@/hooks/useAxios";
import { IOfferType, User } from "@/types/types";
import axios from "axios";
import { useFormik } from "formik";
import { useRouter } from "next/navigation";
import React, { useState, useEffect, useMemo } from "react";
import toast from "react-hot-toast";
import Cookies from "js-cookie";
import * as Yup from "yup";
import { useTranslation } from "react-i18next";
import { useUser } from "@/hooks/useUser";
import WalletUsagePrompt from "@/components/payment/WalletUsagePrompt";
type OfferTypeRow = IOfferType & {
disabled?: boolean;
unavailableReason?: string | null;
};
interface ITicketChatProps {
params: Promise<{ id: string; title: string }>;
}
const FALLBACK_TYPES: OfferTypeRow[] = [
{ _id: "normal", name: "normal", price: 50000 },
{ _id: "free", name: "free", price: 0 },
];
function sortOfferTypes(list: OfferTypeRow[]): OfferTypeRow[] {
const order = ["normal", "special", "highlight", "free"];
return [...list].sort((a, b) => {
const ai = order.indexOf(a.name);
const bi = order.indexOf(b.name);
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
});
}
function TicketChat({ params }: ITicketChatProps) {
const { t } = useTranslation("common");
const resolvedParams = React.use(params);
const { request } = useAxios();
const { id } = resolvedParams;
const router = useRouter();
const me = useUser();
const validationSchema = Yup.object({
selectedType: Yup.string().required(t("offerPage.typeRequired")),
});
const [userDetail, setUserDetail] = useState<User>();
const [selectedType, setSelectedType] = useState("normal");
const [typeList, setTypeList] = useState<OfferTypeRow[] | null>(null);
const [hasUsedFreeOffer, setHasUsedFreeOffer] = useState(false);
const [walletBalance, setWalletBalance] = useState(0);
const [showWalletPrompt, setShowWalletPrompt] = useState(false);
useEffect(() => {
const fetchUser = async () => {
const response = await request<{ user: User }>(
"GET",
`/users/get?user_id=${id}`
);
setUserDetail(response?.user);
};
void fetchUser();
request<{ charged?: { available?: number } }>("GET", "/wallet/summary")
.then((res) => setWalletBalance(res?.charged?.available || 0))
.catch(() => setWalletBalance(0));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, request]);
useEffect(() => {
const monthly = Number(me?.monthly_free_offer ?? 1);
setHasUsedFreeOffer(monthly <= 0);
}, [me]);
const fetchOfferTypes = async () => {
try {
const response = await request<{
offerTypes: OfferTypeRow[];
monthly_free_offer?: number;
}>("GET", "/offers/types");
const list = sortOfferTypes(
response?.offerTypes?.length ? response.offerTypes : FALLBACK_TYPES
);
setTypeList(list);
if (typeof response?.monthly_free_offer === "number") {
setHasUsedFreeOffer(response.monthly_free_offer <= 0);
}
// همیشه پیش‌فرض روی پولی (normal)
const paid =
list.find((item) => item.name === "normal" && !item.disabled) ||
list.find((item) => item.name !== "free" && !item.disabled);
setSelectedType(paid?.name || "normal");
} catch (err) {
console.log(err);
setTypeList(FALLBACK_TYPES);
setSelectedType("normal");
}
};
useEffect(() => {
void fetchOfferTypes();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const getToken = (): string => {
if (typeof window !== "undefined") {
const token = Cookies.get("token");
return token ? `Bearer ${token}` : "";
}
return "";
};
const startPayment = async (offerType: string, useWallet: boolean) => {
try {
const response = await axios.post(
`${BASE_URL}/offers/initiate-payment-web`,
{
offerType,
receiverId: id,
useWallet,
},
{
headers: {
Authorization: getToken(),
"Content-Type": "application/json",
},
}
);
const data: {
authority?: string;
paymentUrl?: string;
type?: string;
paidFromWallet?: boolean;
} = response.data;
if (offerType === "free" || data.type === "free") {
toast.success(t("offerPage.freeSuccess"));
setHasUsedFreeOffer(true);
router.push(`/offer/payment/success?userId=${id}`);
} else if (data.paidFromWallet) {
toast.success(t("wallet.paidFromWalletSuccess"));
router.push(`/offer/payment/success?userId=${id}`);
} else if (data.paymentUrl) {
// window.open بعدِ await در سافاری (دسکتاپ/iOS) پاپ‌آپ‌بلاک می‌شه چون دیگه
// synchronously به کلیکِ کاربر متصل نیست؛ ضمناً بازگشتِ ZarinPal (از طریق
// offer/payment-web/route.js) کلِ همون تب رو به "/" ریدایرکت می‌کنه، نه تبِ
// اصلی — پس با _blank دو تب باز می‌موند. window.location.href هم امن‌تره
// هم با بقیه‌ی مسیرهای پرداختِ این اپ یکی می‌مونه
window.location.href = data.paymentUrl;
} else if (data.authority) {
window.location.href = `https://www.zarinpal.com/pg/StartPay/${data.authority}`;
} else {
toast.error(t("offerPage.paymentInfoError"));
}
} catch (err: any) {
console.error("Payment initiation error:", err);
toast.error(
err?.response?.data?.error ||
err?.response?.data?.message ||
t("offerPage.paymentStartError")
);
}
};
const formik = useFormik({
initialValues: {
selectedType: "normal",
},
validationSchema,
enableReinitialize: true,
onSubmit: async (values, { setSubmitting }) => {
const chosen = typeList?.find((t) => t.name === values.selectedType);
if (
chosen?.disabled ||
(values.selectedType === "free" && hasUsedFreeOffer)
) {
toast.error(
chosen?.unavailableReason || t("offerPage.freeAlreadyUsed")
);
return;
}
try {
if (values.selectedType !== "free" && walletBalance > 0) {
setShowWalletPrompt(true);
return;
}
await startPayment(values.selectedType, false);
} finally {
setSubmitting(false);
}
},
});
useEffect(() => {
void formik.setFieldValue("selectedType", selectedType);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedType]);
const displayTypes = useMemo(
() => sortOfferTypes(typeList || FALLBACK_TYPES),
[typeList]
);
const typeLabel = (name: string) => {
if (name === "normal") return t("offerPage.types.normal");
if (name === "free") return t("offerPage.types.free");
if (name === "special") return t("offerPage.types.special");
return t("offerPage.types.highlight");
};
const userHeader = userDetail ? (
<div className="flex w-full items-end justify-between">
<UserInfo
first_name={userDetail?.first_name}
is_verified={userDetail?.verify_badge}
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} />
}
/>
</div>
) : null;
return (
<Container>
<div className="mx-3 mb-10">
<PageTitle>{t("offerPage.paymentTitle")}</PageTitle>
<form
className="flex w-full flex-col items-center gap-2 text-sm"
onSubmit={formik.handleSubmit}
>
{displayTypes.map((item) => {
const isFree = item.name === "free";
const isDisabled =
Boolean(item.disabled) || (isFree && hasUsedFreeOffer);
return (
<div
key={item?._id || item.name}
className="mt-8 flex w-full max-w-md cursor-pointer flex-col items-center gap-4"
onClick={() => {
if (isDisabled) return;
setSelectedType(item.name);
void formik.setFieldValue("selectedType", item.name);
}}
>
{userHeader}
<RoundedDiv
className={`${selectionCardClass(selectedType === item?.name)} ${
isDisabled ? "cursor-not-allowed opacity-50" : ""
}`}
>
{typeLabel(item.name)}
{Number(item.price) !== 0
? ": " +
Number(item.price).toLocaleString() +
t("offerPage.currencySuffix")
: ""}
</RoundedDiv>
{isDisabled && item.unavailableReason ? (
<p className="text-center text-[11px] text-red-500">
{item.unavailableReason}
</p>
) : null}
</div>
);
})}
<RoundedButton
type="submit"
variant="primary"
className="mt-4 px-8 py-2"
disabled={formik.isSubmitting}
>
{t("offerPage.submitRequest")}
</RoundedButton>
</form>
<WalletUsagePrompt
isOpen={showWalletPrompt}
amount={
Number(
displayTypes.find((item) => item.name === selectedType)?.price
) || 0
}
walletBalance={walletBalance}
onAnswer={(useWallet) => {
setShowWalletPrompt(false);
void startPayment(selectedType, useWallet);
}}
/>
</div>
</Container>
);
}
export default TicketChat;
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import Container from "@/components/elements/Container";
import RoundedButton from "@/components/elements/RoundedButton";
import { BASE_URL } from "@/components/main/BaseUrl";
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";
import axios from "axios";
import { useFormik } from "formik";
import { useRouter } from "next/navigation";
import React, { useState, useEffect, useMemo } from "react";
import toast from "react-hot-toast";
import Cookies from "js-cookie";
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;
};
interface ITicketChatProps {
params: Promise<{ id: string; title: string }>;
}
const FALLBACK_TYPES: OfferTypeRow[] = [
{ _id: "normal", name: "normal", price: 50000 },
{ _id: "free", name: "free", price: 0 },
];
function sortOfferTypes(list: OfferTypeRow[]): OfferTypeRow[] {
const order = ["normal", "special", "highlight", "free"];
return [...list].sort((a, b) => {
const ai = order.indexOf(a.name);
const bi = order.indexOf(b.name);
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
});
}
function TicketChat({ params }: ITicketChatProps) {
const { t } = useTranslation("common");
const resolvedParams = React.use(params);
const { request } = useAxios();
const { id } = resolvedParams;
const router = useRouter();
const me = useUser();
const validationSchema = Yup.object({
selectedType: Yup.string().required(t("offerPage.typeRequired")),
});
const [userDetail, setUserDetail] = useState<User>();
const [selectedType, setSelectedType] = useState("normal");
const [typeList, setTypeList] = useState<OfferTypeRow[] | null>(null);
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 () => {
const response = await request<{ user: User }>(
"GET",
`/users/get?user_id=${id}`
);
setUserDetail(response?.user);
};
void fetchUser();
request<{ charged?: { available?: number } }>("GET", "/wallet/summary")
.then((res) => setWalletBalance(res?.charged?.available || 0))
.catch(() => setWalletBalance(0));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, request]);
const fetchOfferTypes = async () => {
try {
const response = await request<{
offerTypes: OfferTypeRow[];
}>("GET", `/offers/types?userId=${id}`);
const list = sortOfferTypes(
response?.offerTypes?.length ? response.offerTypes : FALLBACK_TYPES
);
setTypeList(list);
// همیشه پیش‌فرض روی پولی (normal)
const paid =
list.find((item) => item.name === "normal" && !item.disabled) ||
list.find((item) => item.name !== "free" && !item.disabled);
setSelectedType(paid?.name || "normal");
} catch (err) {
console.log(err);
setTypeList(FALLBACK_TYPES);
setSelectedType("normal");
}
};
useEffect(() => {
void fetchOfferTypes();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const getToken = (): string => {
if (typeof window !== "undefined") {
const token = Cookies.get("token");
return token ? `Bearer ${token}` : "";
}
return "";
};
const startPayment = async (offerType: string, useWallet: boolean) => {
try {
const response = await axios.post(
`${BASE_URL}/offers/initiate-payment-web`,
{
offerType,
receiverId: id,
useWallet,
},
{
headers: {
Authorization: getToken(),
"Content-Type": "application/json",
},
}
);
const data: {
authority?: string;
paymentUrl?: string;
type?: string;
paidFromWallet?: boolean;
} = response.data;
if (offerType === "free" || data.type === "free") {
toast.success(t("offerPage.freeSuccess"));
router.push(`/offer/payment/success?userId=${id}`);
} else if (data.paidFromWallet) {
toast.success(t("wallet.paidFromWalletSuccess"));
router.push(`/offer/payment/success?userId=${id}`);
} else if (data.paymentUrl) {
// window.open بعدِ await در سافاری (دسکتاپ/iOS) پاپ‌آپ‌بلاک می‌شه چون دیگه
// synchronously به کلیکِ کاربر متصل نیست؛ ضمناً بازگشتِ ZarinPal (از طریق
// offer/payment-web/route.js) کلِ همون تب رو به "/" ریدایرکت می‌کنه، نه تبِ
// اصلی — پس با _blank دو تب باز می‌موند. window.location.href هم امن‌تره
// هم با بقیه‌ی مسیرهای پرداختِ این اپ یکی می‌مونه
window.location.href = data.paymentUrl;
} else if (data.authority) {
window.location.href = `https://www.zarinpal.com/pg/StartPay/${data.authority}`;
} else {
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 ||
err?.response?.data?.message ||
t("offerPage.paymentStartError")
);
}
};
const formik = useFormik({
initialValues: {
selectedType: "normal",
},
validationSchema,
enableReinitialize: true,
onSubmit: async (values, { setSubmitting }) => {
const chosen = typeList?.find((t) => t.name === values.selectedType);
if (chosen?.disabled) {
toast.error(
chosen?.unavailableReason || t("offerPage.freeAlreadyUsed")
);
return;
}
try {
if (values.selectedType !== "free" && walletBalance > 0) {
setShowWalletPrompt(true);
return;
}
await startPayment(values.selectedType, false);
} finally {
setSubmitting(false);
}
},
});
useEffect(() => {
void formik.setFieldValue("selectedType", selectedType);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedType]);
const displayTypes = useMemo(
() => sortOfferTypes(typeList || FALLBACK_TYPES),
[typeList]
);
const typeLabel = (name: string) => {
if (name === "normal") return t("offerPage.types.normal");
if (name === "free") return t("offerPage.types.free");
if (name === "special") return t("offerPage.types.special");
return t("offerPage.types.highlight");
};
// چیدمان این صفحه با UserInfo مشترک (استفاده‌شده توی نتایج جستجو/هدیه/...) فرق
// داره — عین اندروید (OfferScreen.tsx): ستون نام+یوزرنیم کنار آواتار، ستون
// سطح+ریت/امتیاز روبروش؛ به‌جای تغییر کامپوننت مشترک، چیدمان مخصوص همین صفحه‌ست
const userHeader = userDetail ? (
<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}
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;
return (
<Container>
<div className="mx-3 mb-10">
<PageTitle>{t("offerPage.paymentTitle")}</PageTitle>
<form
className="flex w-full flex-col items-center gap-2 text-sm"
onSubmit={formik.handleSubmit}
>
{displayTypes.map((item) => {
const isDisabled = Boolean(item.disabled);
return (
<div
key={item?._id || item.name}
className="mt-4 flex w-full max-w-md cursor-pointer flex-col items-center gap-4"
onClick={() => {
if (isDisabled) return;
setSelectedType(item.name);
void formik.setFieldValue("selectedType", item.name);
}}
>
{userHeader ? <div className="w-full">{userHeader}</div> : null}
<RoundedButton
type="button"
variant={selectedType === item?.name ? "primary" : "default"}
disabled={isDisabled}
className="w-full h-10"
>
{typeLabel(item.name)}
{Number(item.price) !== 0
? ": " +
Number(item.price).toLocaleString() +
t("offerPage.currencySuffix")
: ""}
</RoundedButton>
{isDisabled && item.unavailableReason ? (
<p className="text-center text-[11px] text-red-500">
{item.unavailableReason}
</p>
) : null}
</div>
);
})}
<div className="pointer-events-none fixed bottom-28 left-0 right-0 z-[54] flex justify-center px-4">
<RoundedButton
type="submit"
variant="primary"
className="pointer-events-auto h-10 w-[160px]"
disabled={formik.isSubmitting}
>
{t("offerPage.submitRequest")}
</RoundedButton>
</div>
</form>
<WalletUsagePrompt
isOpen={showWalletPrompt}
amount={
Number(
displayTypes.find((item) => item.name === selectedType)?.price
) || 0
}
walletBalance={walletBalance}
onAnswer={(useWallet) => {
setShowWalletPrompt(false);
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>
);
}
export default TicketChat;

View File

@@ -5,13 +5,14 @@ import React from "react";
import RoundedButton from "@/components/elements/RoundedButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import PageTitle from "@/components/settings/PageTitle";
import { useSearchParams } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { useTranslation } from "react-i18next";
function FailedBillboard() {
const { t } = useTranslation("common");
const router = useRouter();
const searchParams = useSearchParams();
const userId = searchParams.get("userId");
@@ -29,17 +30,19 @@ function FailedBillboard() {
/>
<span>{t("offerPage.paymentFailed.message")}</span>
</div>
<div className="grid grid-cols-2 gap-4 mt-8">
<Link href={`/offer/${userId}`}>
<RoundedButton variant="primary" className="w-32 h-9">
<div className="mt-6 flex w-full max-w-sm flex-row items-center justify-center gap-3">
<Link href={`/offer/${userId}`} className="flex-1">
<RoundedButton variant="primary" className="h-9 w-full">
{t("offerPage.paymentFailed.retry")}
</RoundedButton>
</Link>
<Link href="/">
<RoundedButton variant="primary" className="w-32 h-9">
{t("offerPage.paymentFailed.back")}
</RoundedButton>
</Link>
<RoundedButton
variant="default"
className="h-9 flex-1"
onClick={() => router.push(`/offer/${userId}`)}
>
{t("offerPage.paymentFailed.back")}
</RoundedButton>
</div>
</Container>
</LocalePageShell>

View File

@@ -7,7 +7,7 @@ import React, { useEffect, useState } from "react";
import RoundedButton from "@/components/elements/RoundedButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import PageTitle from "@/components/settings/PageTitle";
import { useSearchParams } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
import { useTranslation } from "react-i18next";
@@ -15,6 +15,7 @@ import { useTranslation } from "react-i18next";
function SuccessBillboard() {
const { t } = useTranslation("common");
const { request } = useAxios();
const router = useRouter();
const searchParams = useSearchParams();
const userId = searchParams.get("userId");
@@ -50,13 +51,23 @@ function SuccessBillboard() {
username: userDetail?.user_name ?? "",
})}
</p>
<Link href={`/settings/chats/${userDetail?.user_name}/${userId}`}>
<RoundedButton variant="primary" className="w-40 h-9">
{t("offerPage.paymentSuccess.sendMessage")}
</RoundedButton>
</Link>
</div>
</div>
{/* دکمه‌های پایین صفحه: ارسال پیام (به گفتگو) و بازگشت (به صفحه‌ی درخواست) */}
<div className="fixed bottom-28 left-0 right-0 z-[54] flex justify-center gap-3 px-4">
<Link href={`/settings/chats/${userDetail?.user_name}/${userId}`} className="flex-1 max-w-[9rem]">
<RoundedButton variant="primary" className="h-10 w-full">
{t("offerPage.paymentSuccess.sendMessage")}
</RoundedButton>
</Link>
<RoundedButton
variant="default"
className="h-10 flex-1 max-w-[9rem]"
onClick={() => router.push(`/offer/${userId}`)}
>
{t("offerPage.paymentFailed.back")}
</RoundedButton>
</div>
</Container>
</LocalePageShell>
);

View File

@@ -2,6 +2,7 @@ import { Metadata } from "next";
import { cache } from "react";
import { fetchPostById } from "@/api/fetchPostById";
import PostFeedView, { PostFeedSearchParams } from "@/components/posts/PostFeedView";
import GuestAuthPromptModal from "@/components/auth/GuestAuthPromptModal";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import {
@@ -125,6 +126,7 @@ export default async function PostPage({ params, searchParams }: Props) {
showClose
searchParams={feedSearchParams}
/>
<GuestAuthPromptModal />
</main>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -15,8 +15,7 @@ import {
joinGroupRoom,
joinUserRoom,
leaveGroupRoom,
releaseChatSocket,
} from "@/lib/chat/socketClient";
releaseChatSocket, isChatSocketConnected, } from "@/lib/chat/socketClient";
import MessageInput from "@/components/chat/MessageInput";
import ChatActionBar from "@/components/chat/ChatActionBar";
import AddChatRoomMembersModal from "@/components/chat/AddChatRoomMembersModal";
@@ -43,12 +42,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 +165,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 +210,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 +299,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 +339,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)،
@@ -535,7 +548,12 @@ export default function ChatRoomThreadPage({
useEffect(() => {
if (loading) return;
// وقتی سوکت وصله، پیام‌های گروه real-time (newGroupMessage) می‌رسن؛ polling فقط
// همگام‌سازیِ ایمنی با فاصله‌ی بیشتره، وگرنه همون ۸ ثانیه‌ی قبلی
let tick = 0;
const poll = window.setInterval(() => {
tick += 1;
if (isChatSocketConnected() && tick % 8 !== 0) return;
void loadMessages();
void loadRoom();
}, 8000);
@@ -548,9 +566,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 +626,17 @@ 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 */
}
// ارسالِ فایل/عکس/ویس/ویدیو توی روم هم فعلاً بدونِ e2ee می‌ره — عینِ دلیلِ همین
// تغییر توی چتِ مستقیم (chats/[username]/[id]/page.tsx): اگه encryptRoomContent
// به هر دلیلی null برگردونه، قبلاً کلِ آپلودِ فایل هم با همین خطا شکست می‌خورد
const sendContent = fileForUpload
? text
: await (async () => {
await ensureIdentityKeys(senderId);
const encrypted = await encryptRoomContent(senderId, roomId, text);
if (encrypted === null) throw new Error("E2EE encryption failed");
return encrypted;
})();
let res: { message?: RoomMessage } | null = null;
if (fileForUpload) {
@@ -663,6 +685,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 +990,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) {
// [E2EE] موقتاً غیرفعال — 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

@@ -29,8 +29,12 @@ export default function Financial() {
limit: 15,
});
// قبلاً flatMap روی خودِ page.financial (یه آبجکت {docs, totalPages}، نه آرایه) اجرا
// می‌شد، پس هر صفحه فقط به یه آیتم تبدیل می‌شد و allTransactions[0]?.docs همیشه فقط
// صفحه‌ی اول رو می‌داد — اسکرول به پایین صفحه‌های بعدی رو دانلود می‌کرد ولی هیچ‌وقت
// نمایش نمی‌داد
const allTransactions =
data?.pages.flatMap((page) => page.financial || []) ?? [];
data?.pages.flatMap((page) => page.financial?.docs ?? []) ?? [];
const getTransactionType = (type: string) => {
const known = [
@@ -98,7 +102,7 @@ export default function Financial() {
ref={containerRef}
className="space-y-4 max-h-[65vh] overflow-y-auto scrollbar-thin scrollbar-thumb-neutral-300 dark:scrollbar-thumb-neutral-600"
>
{allTransactions[0]?.docs?.map((item: IFinancial) => (
{allTransactions.map((item: IFinancial) => (
<div
key={item._id}
className="p-5 border rounded-3xl border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 shadow-sm hover:shadow-md transition-all duration-200"

View File

@@ -1,5 +1,7 @@
"use client";
import { useSearchParams } from "next/navigation";
import MainBillboardCard from "@/components/billboards/MainBillboardCard/MainBillboardCard";
import Container from "@/components/elements/Container";
import EmptyState from "@/components/elements/EmptyState";
@@ -31,6 +33,8 @@ function MyBillboard() {
const { t } = useTranslation("common");
const router = useRouter();
const searchParams = useSearchParams();
const unpaidOnly = searchParams.get("filter") === "unpaid";
const [searchText, setSearchText] = useState("");
const [search, setSearch] = useState("");
const [showFilterModal, setShowFilterModal] = useState<boolean>(false);
@@ -106,7 +110,9 @@ function MyBillboard() {
) : (
data?.pages?.map((page, pageIndex) => (
<React.Fragment key={pageIndex}>
{page?.advertisings?.map((item: IAdvertising) => (
{page?.advertisings
?.filter((item: IAdvertising) => !unpaidOnly || (item as { effective_status?: string })?.effective_status === "pending_payment")
.map((item: IAdvertising) => (
<div
className="cursor-pointer"
onClick={() =>

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

@@ -8,7 +8,6 @@ import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -31,7 +30,6 @@ type WalletSummary = {
export default function WalletSettingsPage() {
const { t } = useTranslation("common");
const searchParams = useSearchParams();
const { request, loading } = useAxios();
const [bucket, setBucket] = useState<string>(BUCKET_SHOP);
@@ -47,9 +45,17 @@ export default function WalletSettingsPage() {
useEffect(() => {
loadSummary();
const charge = searchParams.get("charge");
if (charge === "success") toast.success(t("shops.paymentSuccessTitle"));
if (charge === "failed") toast.error(t("shops.paymentFailedTitle"));
const onVisible = () => {
if (document.visibilityState === "visible") loadSummary();
};
window.addEventListener("pageshow", loadSummary);
window.addEventListener("focus", loadSummary);
document.addEventListener("visibilitychange", onVisible);
return () => {
window.removeEventListener("pageshow", loadSummary);
window.removeEventListener("focus", loadSummary);
document.removeEventListener("visibilitychange", onVisible);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -81,16 +87,19 @@ export default function WalletSettingsPage() {
const amount = Number(chargeAmount);
if (!amount || amount <= 0) return;
try {
const response = await request<{ authority?: string }>(
const response = await request<{ authority?: string; paymentUrl?: string }>(
"POST",
"/wallet/charge/initiate",
{ amount }
);
if (response?.authority) {
// لینک خارجی — router.push رفتارِ تضمین‌شده‌ای نداره؛ window.location.href
// مطمئن‌تره (خصوصاً سافاری)
window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`;
}
const target =
response?.paymentUrl ||
(response?.authority
? `https://www.zarinpal.com/pg/StartPay/${response.authority}`
: null);
// لینک خارجی — router.push رفتارِ تضمین‌شده‌ای نداره؛ window.location.href
// مطمئن‌تره (خصوصاً سافاری)
if (target) window.location.href = target;
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response
@@ -99,9 +108,21 @@ export default function WalletSettingsPage() {
}
};
const cta =
bucket === BUCKET_CHARGED
? { label: t("shops.chargeWallet"), onClick: handleCharge, disabled: loading }
: {
label: t("shops.requestWithdrawal"),
onClick: handleWithdraw,
disabled:
loading ||
(bucket === BUCKET_GIFT &&
(!summary || summary.gift.available < summary.gift.minWithdrawal)),
};
return (
<LocalePageShell>
<Container>
<Container className="pb-28">
<PageTitle>{t("settings.nav.wallet")}</PageTitle>
<div className="mx-auto mt-4 max-w-md text-center">
@@ -173,14 +194,6 @@ export default function WalletSettingsPage() {
value={withdrawAmount}
onChange={(e) => setWithdrawAmount(e.target.value)}
/>
<RoundedButton
variant="primary"
className="h-10 w-full max-w-[200px]"
disabled={loading}
onClick={handleWithdraw}
>
{t("shops.requestWithdrawal")}
</RoundedButton>
</div>
</>
)}
@@ -206,14 +219,6 @@ export default function WalletSettingsPage() {
value={withdrawAmount}
onChange={(e) => setWithdrawAmount(e.target.value)}
/>
<RoundedButton
variant="primary"
className="h-10 w-full max-w-[200px]"
disabled={loading || summary.gift.available < summary.gift.minWithdrawal}
onClick={handleWithdraw}
>
{t("shops.requestWithdrawal")}
</RoundedButton>
</div>
</>
)}
@@ -234,14 +239,6 @@ export default function WalletSettingsPage() {
value={withdrawAmount}
onChange={(e) => setWithdrawAmount(e.target.value)}
/>
<RoundedButton
variant="primary"
className="h-10 w-full max-w-[200px]"
disabled={loading}
onClick={handleWithdraw}
>
{t("shops.requestWithdrawal")}
</RoundedButton>
</div>
</>
)}
@@ -262,14 +259,6 @@ export default function WalletSettingsPage() {
value={withdrawAmount}
onChange={(e) => setWithdrawAmount(e.target.value)}
/>
<RoundedButton
variant="primary"
className="h-10 w-full max-w-[200px]"
disabled={loading}
onClick={handleWithdraw}
>
{t("shops.requestWithdrawal")}
</RoundedButton>
</div>
</>
)}
@@ -290,14 +279,6 @@ export default function WalletSettingsPage() {
value={withdrawAmount}
onChange={(e) => setWithdrawAmount(e.target.value)}
/>
<RoundedButton
variant="primary"
className="h-10 w-full max-w-[200px]"
disabled={loading}
onClick={handleWithdraw}
>
{t("shops.requestWithdrawal")}
</RoundedButton>
</div>
</>
)}
@@ -319,19 +300,24 @@ export default function WalletSettingsPage() {
value={chargeAmount}
onChange={(e) => setChargeAmount(e.target.value)}
/>
<RoundedButton
variant="primary"
className="h-10 w-full max-w-[200px]"
disabled={loading}
onClick={handleCharge}
>
{t("shops.chargeWallet")}
</RoundedButton>
</div>
</>
)}
</div>
</Container>
{summary ? (
<div className="pointer-events-none fixed bottom-28 left-0 right-0 z-[54] flex justify-center px-4">
<RoundedButton
variant="primary"
className="pointer-events-auto h-10 w-full max-w-[200px]"
disabled={cta.disabled}
onClick={cta.onClick}
>
{cta.label}
</RoundedButton>
</div>
) : null}
</LocalePageShell>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
import Container from "@/components/elements/Container";
import RoundedButton from "@/components/elements/RoundedButton";
import RoundedDiv from "@/components/elements/RoundedDiv";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import Image from "next/image";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
export default function WalletPaymentFailedPage() {
const { t } = useTranslation("common");
const router = useRouter();
const searchParams = useSearchParams();
const { request } = useAxios();
const [paying, setPaying] = useState(false);
const amount = Number(searchParams.get("amount") || 0);
const retryPayment = async () => {
if (!amount || amount <= 0 || paying) return;
setPaying(true);
try {
const response = await request<{ authority?: string; paymentUrl?: string }>(
"POST",
"/wallet/charge/initiate",
{ amount },
{ noToast: true }
);
const paymentUrl =
response.paymentUrl ||
(response.authority
? `https://www.zarinpal.com/pg/StartPay/${response.authority}`
: null);
if (!paymentUrl) {
toast.error(t("settings.paymentStartError"));
setPaying(false);
return;
}
window.location.href = paymentUrl;
} catch (error: unknown) {
const message =
(error as { response?: { data?: { message?: string } } })?.response
?.data?.message || t("settings.paymentStartError");
toast.error(message);
setPaying(false);
}
};
return (
<LocalePageShell>
<Container>
<PageTitle>{t("shops.paymentFailedTitle")}</PageTitle>
<div className="flex flex-col items-center text-sm">
<Image
width={50}
height={50}
alt=""
src="/images/icons/failed.svg"
className="mb-5 pb-1"
/>
<span>{t("shops.paymentFailedHint")}</span>
</div>
<div className="mt-8 w-full px-4">
{amount > 0 && (
<RoundedDiv className="w-full p-3 text-center">
{amount.toLocaleString()} {t("settings.toman")}
</RoundedDiv>
)}
<div className="mt-6 flex w-full max-w-sm flex-row items-center justify-center gap-3">
<RoundedButton
onClick={retryPayment}
disabled={paying || !amount}
variant="primary"
className="h-9 flex-1"
>
{paying ? "..." : t("offerPage.paymentFailed.retry")}
</RoundedButton>
<RoundedButton
onClick={() => router.push("/settings/wallet")}
className="h-9 flex-1"
>
{t("common.back")}
</RoundedButton>
</div>
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import Container from "@/components/elements/Container";
import RoundedButton from "@/components/elements/RoundedButton";
import RoundedDiv from "@/components/elements/RoundedDiv";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import Image from "next/image";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useTranslation } from "react-i18next";
export default function WalletPaymentSuccessPage() {
const { t } = useTranslation("common");
const searchParams = useSearchParams();
const amount = Number(searchParams.get("amount") || 0);
return (
<LocalePageShell>
<Container>
<PageTitle>{t("shops.paymentSuccessTitle")}</PageTitle>
<div className="flex flex-col items-center text-sm">
<Image
width={50}
height={50}
alt=""
src="/images/icons/success.svg"
className="mb-5 pb-1"
/>
<p className="text-center text-sm text-neutral-500">
{t("shops.paymentSuccessHint")}
</p>
</div>
{amount > 0 && (
<div className="mt-8 w-full px-4">
<RoundedDiv className="w-full p-3 text-center">
{amount.toLocaleString()} {t("settings.toman")}
</RoundedDiv>
</div>
)}
<div className="mt-8 flex justify-center">
<Link href="/settings/wallet">
?
<RoundedButton variant="primary" className="h-9 w-40">
?
{t("settings.confirm")}
</RoundedButton>
</Link>
</div>
</Container>
</LocalePageShell>

View File

@@ -1,5 +1,7 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Container from "@/components/elements/Container";
@@ -37,7 +39,8 @@ const Workroom = () => {
const { t } = useTranslation("common");
const router = useRouter();
const user = useUser();
const [statusFilter, setStatusFilter] = useState("");
const searchParams = useSearchParams();
const [statusFilter, setStatusFilter] = useState(searchParams.get("filter") === "unpaid" ? "unpaid" : "");
const [showDrafts, setShowDrafts] = useState(false);
const [drafts, setDrafts] = useState<ProjectDraft[]>([]);

View File

@@ -564,7 +564,7 @@ export default function ListingDetailClient() {
<TabNavigation currentPage="/settings" />
{showAddressModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-transparent p-4">
<div className="w-full max-w-sm rounded-2xl bg-white p-6 text-center dark:bg-neutral-900">
<p className="mb-4 text-sm">{t("shops.addressRequiredHint")}</p>
<button

View File

@@ -66,22 +66,22 @@ export default function ShopPaymentFailedPage() {
/>
<span>{t("shops.paymentFailedHint")}</span>
</div>
<div className="mt-10 flex flex-col items-center gap-4">
<div className="mt-6 flex w-full max-w-sm flex-row items-center justify-center gap-3">
<RoundedButton
variant="primary"
className="h-9 w-32"
className="h-9 flex-1"
onClick={retry}
disabled={loading || !orderId}
>
{t("shops.retryPayment")}
</RoundedButton>
<RoundedButton
className="!border-transparent h-9 w-32 !bg-neutral-100 text-neutral-600 dark:!bg-neutral-800 dark:text-neutral-300"
className="!border-transparent h-9 flex-1 !bg-neutral-100 text-neutral-600 dark:!bg-neutral-800 dark:text-neutral-300"
onClick={() =>
router.push(shopId ? `/shops/profile/${shopId}` : "/shops/discover")
router.push("/settings/shop")
}
>
{t("shops.backToShopList")}
{t("common.back")}
</RoundedButton>
</div>
</Container>

View File

@@ -32,17 +32,9 @@ export default function ShopPaymentSuccessPage() {
<RoundedButton
variant="primary"
className="h-9 w-48"
onClick={() =>
router.push(orderId ? `/shops/orders/${orderId}` : "/settings/shop")
}
onClick={() => router.push("/settings/shop")}
>
{t("shops.viewOrder")}
</RoundedButton>
<RoundedButton
className="!border-transparent h-9 w-48 !bg-neutral-100 text-neutral-600 dark:!bg-neutral-800 dark:text-neutral-300"
onClick={() => router.push("/")}
>
{t("shops.backToHome")}
{t("settings.confirm")}
</RoundedButton>
</div>
</Container>

View File

@@ -14,18 +14,24 @@ type GeoCheckResult = {
isProxy: boolean;
};
const DISMISSED_KEY = "vpn_warning_dismissed";
// عینِ VpnWarningGate.tsx اندروید: هر بار اپِ وب لود می‌شه (mount ریشه‌ی layout.tsx)
// چک می‌شه که کاربر با آی‌پیِ غیرایران یا VPN/پراکسی وصل شده یا نه؛ اگه بله، هم یه
// toast (معادلِ نوتیفِ اندروید — چون وب پرمیشنِ Notification API لازم داره) هم یه
// مودالِ غیراجباری نشون داده می‌شه
// مودالِ غیراجباری با دکمه‌ی تایید + تیکِ «دیگر نشان نده» کنارش نشون داده می‌شه. با
// زدنِ اون تیک، دیگه هیچ‌وقت (حتی خودِ چکِ شبکه‌ش) دوباره اجرا نمی‌شه
export default function VpnWarningGate() {
const { t } = useTranslation("common");
const [open, setOpen] = useState(false);
const [checked, setChecked] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
try {
if (localStorage.getItem(DISMISSED_KEY) === "1") return;
const res = await apiClient.get<GeoCheckResult>(
`${getApiBaseUrl()}/geo/check`
);
@@ -42,20 +48,38 @@ export default function VpnWarningGate() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const close = () => {
setOpen(false);
if (checked) {
localStorage.setItem(DISMISSED_KEY, "1");
}
};
return (
<Modal isOpen={open} onClose={() => setOpen(false)} height="auto" elevated>
<Modal isOpen={open} onClose={close} height="auto" elevated>
<div className="flex flex-col items-center gap-4 py-2 text-center">
<h2 className="text-lg font-bold">{t("settings.vpnWarningTitle")}</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t("settings.vpnWarningMessage")}
</p>
<RoundedButton
variant="primary"
className="mt-2 h-10 w-40"
onClick={() => setOpen(false)}
>
{t("settings.vpnWarningAction")}
</RoundedButton>
<div className="mt-2 flex w-full max-w-xs items-center justify-center gap-4">
<RoundedButton
variant="primary"
className="h-10 flex-1"
onClick={close}
>
{t("settings.vpnWarningAction")}
</RoundedButton>
<label className="flex shrink-0 cursor-pointer items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400">
<input
type="checkbox"
className="scale-110"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
/>
{t("settings.vpnWarningDontShowAgain")}
</label>
</div>
</div>
</Modal>
);

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();
// [E2EE] موقتاً غیرفعال — 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

@@ -0,0 +1,57 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import Modal from "@/components/elements/Modal";
import RoundedButton from "@/components/elements/RoundedButton";
import { isAuthenticated } from "@/lib/auth/session";
// روی صفحه‌ی تکی‌ِ پست/ریلز/بیلبورد/پروژه (قابل‌اشتراک‌گذاری و قابل‌دیدن برای گوگل
// و مهمان، مثل اینستاگرام) — اگه کاربر لاگین نکرده، همین مودالِ پایین‌صفحه (قابل
// بستن) رو نشون بده با دو دکمه‌ی ورود/ثبت‌نام؛ خودِ محتوا و رفتارهایی مثل لایک/کامنت
// دست‌نخورده می‌مونه (لایک از قبل با useRequireAuth کاربرِ مهمان رو به ورود می‌فرسته)
export default function GuestAuthPromptModal() {
const { t } = useTranslation("common");
const router = useRouter();
const [open, setOpen] = useState(false);
useEffect(() => {
if (!isAuthenticated()) setOpen(true);
}, []);
const goTo = (path: "/login" | "/register") => {
const current =
typeof window !== "undefined"
? window.location.pathname + window.location.search
: "/";
setOpen(false);
router.push(`${path}?redirect=${encodeURIComponent(current)}`);
};
return (
<Modal isOpen={open} onClose={() => setOpen(false)} height="fit" elevated>
<div className="mx-auto w-full max-w-sm">
<p className="mb-4 text-center text-sm">{t("auth.guestPromptMessage")}</p>
<div className="flex flex-row gap-3">
<RoundedButton
type="button"
variant="primary"
className="h-10 flex-1"
onClick={() => goTo("/login")}
>
{t("auth.guestPromptLogin")}
</RoundedButton>
<RoundedButton
type="button"
variant="default"
className="h-10 flex-1"
onClick={() => goTo("/register")}
>
{t("auth.guestPromptRegister")}
</RoundedButton>
</div>
</div>
</Modal>
);
}

View File

@@ -200,11 +200,11 @@ const Step5 = () => {
},
});
useEffect(() => {
if (isEditing && formik.values.selectedType) {
formik.submitForm();
}
}, [isEditing, formik.values.selectedType]);
// این افکت قبلاً با هر تغییرِ selectedType (یعنی هر کلیکِ کاربر روی یه کارتِ نوعِ
// دیگه، توی حالتِ ویرایش) دوباره اجرا می‌شد و formik.submitForm() رو صدا می‌زد —
// یعنی هر کلیک یه PATCH واقعی به /advertising/edit می‌فرستاد، نه فقط کلیکِ دکمه‌ی
// ثبتِ واقعی. حذف شد — کاربر توی حالتِ ویرایش هم باید مثلِ حالتِ عادی، دکمه‌ی
// ارسال/ثبتِ پایینِ همین فرم رو بزنه
return (
<form

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 === null) 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

@@ -40,7 +40,9 @@ interface MessageInputProps {
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
return `${mins.toString().padStart(2, "0")}:${secs
.toString()
.padStart(2, "0")}`;
};
const MessageInput = ({
@@ -64,17 +66,21 @@ const MessageInput = ({
giftDisabled = false,
}: MessageInputProps) => {
const { t } = useTranslation("common");
const [isRecording, setIsRecording] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const [menuOpen, setMenuOpen] = useState(false);
const [isLocked, setIsLocked] = useState(false);
const [dragProgress, setDragProgress] = useState(0);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const dragStartYRef = useRef<number | null>(null);
const LOCK_THRESHOLD_PX = 80;
const attachBtnRef = useRef<HTMLButtonElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const videoInputRef = useRef<HTMLInputElement>(null);
@@ -84,8 +90,11 @@ const MessageInput = ({
const adjustTextareaHeight = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
const maxHeight = 160;
el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`;
}, []);
@@ -94,24 +103,32 @@ const MessageInput = ({
}, [newMessage, adjustTextareaHeight]);
const hasText = newMessage.trim().length > 0;
const showSend = hasText && !isRecording;
const handleSend = useCallback(() => {
if (hasText && !blocked_you) sendMessage();
if (hasText && !blocked_you) {
sendMessage();
}
}, [hasText, blocked_you, sendMessage]);
// ردیف تایمر/یک‌بارمصرف فقط هنگام ضبط ویس (برای عکس داخل پیش‌نمایش نمایش داده می‌شود)
// ردیف تایمر/یک‌بارمصرف فقط هنگام ضبط ویس
const showMediaOptions = isChatThread && isRecording;
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const MAX_SIZE = 200 * 1024 * 1024;
if (file.size > MAX_SIZE) {
alert(t("chats.toast.fileTooLarge"));
return;
}
}
handleFileSelection(e);
e.target.value = "";
};
@@ -121,11 +138,17 @@ const MessageInput = ({
setRecordingTime((prev) => prev + 1);
}, 1000);
} else {
if (timerRef.current) clearInterval(timerRef.current);
if (timerRef.current) {
clearInterval(timerRef.current);
}
setRecordingTime(0);
}
return () => {
if (timerRef.current) clearInterval(timerRef.current);
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}, [isRecording]);
@@ -134,14 +157,19 @@ const MessageInput = ({
if (mediaRecorderRef.current?.state === "recording") {
mediaRecorderRef.current.stop();
}
setIsRecording(false);
setIsLocked(false);
setDragProgress(0);
dragStartYRef.current = null;
if (!shouldSend) {
audioChunksRef.current = [];
}
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null;
},
[]
@@ -149,11 +177,18 @@ const MessageInput = ({
const startRecording = async () => {
if (blocked_you) return;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
});
streamRef.current = stream;
const mediaRecorder = new MediaRecorder(stream);
mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = [];
mediaRecorder.ondataavailable = (event) => {
@@ -165,32 +200,53 @@ const MessageInput = ({
const audioBlob = new Blob(audioChunksRef.current, {
type: "audio/webm",
});
handleVoiceUpload(audioBlob);
}
audioChunksRef.current = [];
};
mediaRecorder.start();
setIsRecording(true);
} catch {
alert(t("chats.toast.micDenied"));
}
};
const handleMicPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
const handleMicPointerDown = (
e: React.PointerEvent<HTMLButtonElement>
) => {
if (!hasText && !blocked_you) {
dragStartYRef.current = e.clientY;
setIsLocked(false);
setDragProgress(0);
e.currentTarget.setPointerCapture(e.pointerId);
startRecording();
}
};
const handleMicPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
if (!isRecording || isLocked || dragStartYRef.current == null) return;
const handleMicPointerMove = (
e: React.PointerEvent<HTMLButtonElement>
) => {
if (
!isRecording ||
isLocked ||
dragStartYRef.current == null
) {
return;
}
const delta = dragStartYRef.current - e.clientY;
setDragProgress(Math.max(0, Math.min(1, delta / LOCK_THRESHOLD_PX)));
setDragProgress(
Math.max(0, Math.min(1, delta / LOCK_THRESHOLD_PX))
);
if (delta >= LOCK_THRESHOLD_PX) {
setIsLocked(true);
dragStartYRef.current = null;
@@ -198,8 +254,12 @@ const MessageInput = ({
};
const handleMicPointerUp = () => {
if (isRecording && !isLocked) stopRecording(true);
if (isRecording && !isLocked) {
stopRecording(true);
}
dragStartYRef.current = null;
setDragProgress(0);
};
@@ -209,12 +269,15 @@ const MessageInput = ({
case "camera":
imageInputRef.current?.click();
break;
case "video":
videoInputRef.current?.click();
break;
case "file":
fileInputRef.current?.click();
break;
case "location":
onLocationShare?.();
break;
@@ -223,8 +286,10 @@ const MessageInput = ({
return (
<div
className={`pointer-events-none fixed left-0 right-0 z-50 flex flex-col items-center bg-transparent px-3 sm:px-4 ${
isChatThread ? "bottom-0 chat-input-area--thread" : "bottom-[5.5rem] chat-input-area"
className={`pointer-events-none fixed left-0 right-0 z-50 flex flex-col items-center bg-transparent px-2 sm:px-3 ${
isChatThread
? "bottom-0 chat-input-area--thread"
: "bottom-[5.5rem] chat-input-area"
}`}
>
{(replyLabel ||
@@ -237,6 +302,7 @@ const MessageInput = ({
<span className="text-[11px] font-medium text-neutral-500 dark:text-neutral-400">
{t("chats.mediaOptions")}
</span>
<div className="flex items-center gap-1.5">
{onSelfDestructChange && (
<TimedMessagePicker
@@ -245,6 +311,7 @@ const MessageInput = ({
disabled={blocked_you}
/>
)}
{onViewOnceChange && (
<ViewOnceToggle
value={viewOnceMedia}
@@ -255,6 +322,7 @@ const MessageInput = ({
</div>
</div>
)}
{replyLabel && (
<button
type="button"
@@ -264,6 +332,7 @@ const MessageInput = ({
<span className="flex-1 truncate text-neutral-700 dark:text-neutral-200">
{replyLabel}
</span>
<span
role="button"
tabIndex={0}
@@ -284,19 +353,32 @@ const MessageInput = ({
</span>
</button>
)}
{selfDestructSeconds != null && isChatThread && (
<div className="chat-timer-glass chat-timer-glass--active flex items-center gap-2 rounded-2xl px-3 py-1.5 text-[11px]">
<ChatBoldIcon name="timer" size={14} className="shrink-0" />
<ChatBoldIcon
name="timer"
size={14}
className="shrink-0"
/>
<span>
{t("chats.timedBanner", {
duration: formatTimedLabel(selfDestructSeconds, t),
duration: formatTimedLabel(
selfDestructSeconds,
t
),
})}
</span>
</div>
)}
{viewOnceMedia && isChatThread && (
<div className="chat-timer-glass chat-timer-glass--active flex items-center gap-2 rounded-2xl px-3 py-1.5 text-[11px] text-[#ff9500]">
<span className="font-semibold">{t("chats.viewOnceBadge")}</span>
<span className="font-semibold">
{t("chats.viewOnceBadge")}
</span>
<span>
{t("chats.viewOnceBanner", {
label: viewOnceLabel("image", t),
@@ -306,9 +388,16 @@ const MessageInput = ({
)}
</div>
)}
{/*
مهم:
این ردیف همان max-w-lg هدر را دارد.
min-w-0 روی ردیف و کادر پیام باعث می‌شود
در موبایل از عرض تعیین‌شده بیرون نزند.
*/}
<div
dir="ltr"
className="pointer-events-auto relative z-[1] flex w-full max-w-lg items-end gap-2 sm:gap-2.5"
className="pointer-events-auto relative z-[1] flex w-full min-w-0 max-w-lg items-end gap-2 overflow-visible sm:gap-2.5"
>
<div className="relative flex shrink-0 items-center gap-1.5">
<button
@@ -320,31 +409,46 @@ const MessageInput = ({
style={chatHeaderGlassStyle}
aria-label={t("chats.aria.attach")}
>
<ChatBoldIcon name="attachment" size={22} className="text-current" />
<ChatBoldIcon
name="attachment"
size={22}
className="text-current"
/>
</button>
<AttachmentMenu
open={menuOpen}
onClose={() => setMenuOpen(false)}
onSelect={handleAttachmentSelect}
anchorRef={attachBtnRef}
/>
{onGiftClick && (
<button
type="button"
disabled={blocked_you || isRecording || giftDisabled}
disabled={
blocked_you ||
isRecording ||
giftDisabled
}
onClick={onGiftClick}
className={`${chatHeaderCircleBtn} gentle-transition disabled:opacity-40`}
style={chatHeaderGlassStyle}
aria-label={t("chats.aria.gift")}
>
<BoldIcon name="gift" size={20} tinted className="text-current" />
<BoldIcon
name="gift"
size={20}
tinted
className="text-current"
/>
</button>
)}
</div>
{/* Glass input box */}
<div
className="glass-chat-input gentle-transition flex min-h-11 flex-1 items-center rounded-[22px] px-4 py-2"
className="glass-chat-input gentle-transition flex min-h-11 min-w-0 flex-1 items-center rounded-[22px] px-4 py-2"
style={glassBackdropStyle("glass-chat-input")}
>
<input
@@ -356,6 +460,7 @@ const MessageInput = ({
onChange={handleFileChange}
disabled={blocked_you}
/>
<input
ref={videoInputRef}
type="file"
@@ -364,6 +469,7 @@ const MessageInput = ({
onChange={handleFileChange}
disabled={blocked_you}
/>
<input
ref={fileInputRef}
type="file"
@@ -373,33 +479,49 @@ const MessageInput = ({
/>
{isRecording ? (
<div className="flex w-full items-center justify-between gap-2 text-red-500">
<div className="flex items-center gap-2">
<span className="relative flex h-2 w-2">
<div className="flex w-full min-w-0 items-center justify-between gap-2 text-red-500">
<div className="flex min-w-0 items-center gap-2">
<span className="relative flex h-2 w-2 shrink-0">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-red-500" />
</span>
<span className="font-mono text-xs font-bold">
{formatTime(recordingTime)}
</span>
{!isLocked && (
<span className="text-[10px] font-normal text-neutral-400">
<span className="truncate text-[10px] font-normal text-neutral-400">
{t("chats.voice.slideUpToLock")}
</span>
)}
{isLocked && (
<BoldIcon name="lock" size={13} tinted className="text-neutral-400" />
<BoldIcon
name="lock"
size={13}
tinted
className="shrink-0 text-neutral-400"
/>
)}
</div>
<div className="flex items-center gap-1.5">
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={() => stopRecording(false)}
className="rounded-full p-1"
aria-label={t("chats.aria.cancelVoice")}
>
<BoldIcon name="trash" size={18} tinted className="text-current" />
<BoldIcon
name="trash"
size={18}
tinted
className="text-current"
/>
</button>
{isLocked && (
<button
type="button"
@@ -407,7 +529,11 @@ const MessageInput = ({
className="flex h-7 w-7 items-center justify-center rounded-full bg-[#0095f6] text-white"
aria-label={t("chats.aria.sendVoice")}
>
<ChatBoldIcon name="send" size={14} className="text-current -mr-0.5" />
<ChatBoldIcon
name="send"
size={14}
className="text-current -mr-0.5"
/>
</button>
)}
</div>
@@ -434,7 +560,7 @@ const MessageInput = ({
}
}}
disabled={blocked_you}
className="min-h-[1.5rem] max-h-40 flex-1 resize-none overflow-y-auto bg-transparent text-[15px] leading-6 outline-none placeholder:text-neutral-400 dark:text-white"
className="min-h-[1.5rem] min-w-0 max-h-40 flex-1 resize-none overflow-y-auto bg-transparent text-[15px] leading-6 outline-none placeholder:text-neutral-400 dark:text-white"
/>
)}
</div>
@@ -455,21 +581,42 @@ const MessageInput = ({
style={chatHeaderGlassStyle}
aria-label={t("chats.aria.send")}
>
<ChatBoldIcon name="send" size={20} className="text-current -mr-0.5" />
<ChatBoldIcon
name="send"
size={20}
className="text-current -mr-0.5"
/>
</motion.button>
) : (
<motion.div key="mic" className="relative">
<motion.div
key="mic"
className="relative shrink-0"
>
{isRecording && !isLocked && (
<div
className="pointer-events-none absolute bottom-full right-1/2 mb-2 flex translate-x-1/2 flex-col items-center gap-1"
style={{ opacity: 0.4 + dragProgress * 0.6 }}
style={{
opacity: 0.4 + dragProgress * 0.6,
}}
>
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-black/40 text-white backdrop-blur-sm">
<BoldIcon name="lock" size={14} tinted className="text-white" />
<BoldIcon
name="lock"
size={14}
tinted
className="text-white"
/>
</span>
<BoldIcon name="arrow-up-2" size={12} tinted className="text-neutral-400" />
<BoldIcon
name="arrow-up-2"
size={12}
tinted
className="text-neutral-400"
/>
</div>
)}
<motion.button
initial={{ scale: 0.6, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
@@ -480,14 +627,37 @@ const MessageInput = ({
onPointerDown={handleMicPointerDown}
onPointerMove={handleMicPointerMove}
onPointerUp={handleMicPointerUp}
onPointerLeave={() => isRecording && !isLocked && stopRecording(true)}
onPointerLeave={() =>
isRecording &&
!isLocked &&
stopRecording(true)
}
className={`${chatHeaderCircleBtn} gentle-transition ${
isRecording ? "scale-110 !bg-red-500 text-white" : ""
isRecording
? "scale-110 !bg-red-500 text-white"
: ""
} active:scale-90`}
style={isRecording ? undefined : chatHeaderGlassStyle}
style={
isRecording
? undefined
: chatHeaderGlassStyle
}
aria-label={t("chats.aria.recordVoice")}
>
{isRecording ? <BoldIcon name="stop" size={20} tinted className="text-white" /> : <ChatBoldIcon name="microphone" size={22} className="text-current" />}
{isRecording ? (
<BoldIcon
name="stop"
size={20}
tinted
className="text-white"
/>
) : (
<ChatBoldIcon
name="microphone"
size={22}
className="text-current"
/>
)}
</motion.button>
</motion.div>
)}
@@ -497,4 +667,4 @@ const MessageInput = ({
);
};
export default MessageInput;
export default MessageInput;

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

@@ -417,7 +417,7 @@ export default function ExploreReelsView({
if (!scrollRef.current || index + 1 >= items.length) return;
scrollRef.current.scrollTo({
top: (index + 1) * getReelsStepHeight(scrollRef.current),
behavior: "smooth",
behavior: "auto",
});
}}
/>

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

@@ -74,7 +74,7 @@ export default function InfinitePosts({
queryFn: ({ pageParam = 1 }) =>
fetchPosts(
pageParam as number,
4,
12,
{ ...filters, feedMode: "grid" },
token
),
@@ -94,7 +94,7 @@ export default function InfinitePosts({
try {
const peek = await fetchPosts(
1,
4,
12,
{ ...filters, feedMode: "grid" },
token
);

View File

@@ -33,6 +33,9 @@ import { useTranslation } from "react-i18next";
import { getPostVideoSrc } from "@/lib/videoPreload";
import { getVideoPoster } from "@/lib/explore/postMedia";
import { resolvePostMediaType } from "@/lib/reelsMediaType";
import { usePostSocialState } from "@/hooks/usePostSocialActions";
const DOUBLE_TAP_MS = 280;
// ایجاد Context برای به اشتراک گذاشتن وضعیت muted بین همه کارت‌ها
const MutedContext = createContext<{
@@ -81,11 +84,6 @@ function MainModelCard({
}, [postData._id]);
const [imageLoading, setImageLoading] = useState(true);
const videoRef = useRef<HTMLVideoElement | null>(null);
const [ripple, setRipple] = useState<{
x: number;
y: number;
key: number;
} | null>(null);
// استفاده از Context برای وضعیت muted گلوبال
const { muted, setMuted } = useContext(MutedContext);
@@ -122,27 +120,38 @@ function MainModelCard({
const videoPoster =
mediaType === "video" ? getVideoPoster(postData) : undefined;
// تابع برای ایجاد افکت ripple و تغییر وضعیت صدا
const handleVideoClick = (e: React.MouseEvent<HTMLVideoElement>) => {
const social = usePostSocialState(postData);
const singleTapTimerRef = useRef<number | null>(null);
const [likeBurstKey, setLikeBurstKey] = useState<number | null>(null);
// یک ضربه = باز کردن ریلز (مثل اندروید)، دو ضربه‌ی سریع = پسندیدن. دکمه‌ی صدا جدا و
// کلیک‌پذیره، پس با کلیک روی ویدیو دیگه صدا عوض نمی‌شه
const handleMediaTap = () => {
if (singleTapTimerRef.current) {
window.clearTimeout(singleTapTimerRef.current);
singleTapTimerRef.current = null;
if (!social.liked) void social.toggleLike();
setLikeBurstKey(Date.now());
window.setTimeout(() => setLikeBurstKey(null), 900);
return;
}
if (!onMediaClick) return;
singleTapTimerRef.current = window.setTimeout(() => {
singleTapTimerRef.current = null;
onMediaClick();
}, DOUBLE_TAP_MS);
};
const toggleVideoMute = (e: React.MouseEvent) => {
e.stopPropagation();
const v = videoRef.current;
if (!v) return;
const rect = v.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setRipple({ x, y, key: Date.now() });
// --- مهمترین اصلاح ---
v.muted = !v.muted;
setVideoMuted(v.muted);
setMuted(v.muted);
if (!v.muted) {
v.play().catch(() => {});
if (!v.muted && v.paused) {
void v.play().catch(() => {});
}
setTimeout(() => setRipple(null), 600);
};
// استفاده از IntersectionObserver برای پخش/توقف ویدیو بر اساس ویوپورت
@@ -206,11 +215,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 +238,7 @@ function MainModelCard({
alt={user_name}
fill
sizes="72px"
className="aspect-square rounded-2xl object-cover"
className="aspect-square object-cover"
priority
/>
</div>
@@ -370,7 +385,7 @@ function MainModelCard({
<div
className={`overflow-hidden rounded-2xl ${onMediaClick ? "cursor-pointer" : ""}`}
onClick={onMediaClick}
onClick={handleMediaTap}
onKeyDown={
onMediaClick
? (e) => {
@@ -395,14 +410,6 @@ function MainModelCard({
{mediaType === "video" && videoSrc && (
<div className="relative w-full rounded-2xl overflow-hidden bg-black">
<video
onClick={(e) => {
if (onMediaClick) {
e.stopPropagation();
onMediaClick();
return;
}
handleVideoClick(e);
}}
ref={videoRef}
src={videoSrc}
poster={videoPoster}
@@ -412,18 +419,18 @@ function MainModelCard({
onLoadedData={() => setImageLoading(false)}
muted={muted}
/>
{ripple && (
<span
className="absolute rounded-full bg-white/50 animate-ripple"
style={{
left: ripple.x - 25,
top: ripple.y - 25,
width: 50,
height: 50,
}}
key={ripple.key}
/>
)}
{likeBurstKey ? (
<div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center">
<Image
key={likeBurstKey}
src="/images/icons/red-heart.svg"
alt=""
width={96}
height={96}
className="animate-ping"
/>
</div>
) : null}
{imageLoading && (
<div className="absolute inset-0 flex justify-center items-center bg-black/30 z-10">
<Image
@@ -434,18 +441,23 @@ function MainModelCard({
/>
</div>
)}
<div className="pointer-events-none absolute bottom-3 left-3 z-50 rounded-full bg-black/50 p-2 backdrop-blur-md">
<button
type="button"
onClick={toggleVideoMute}
className="absolute bottom-3 left-3 z-50 flex h-9 w-9 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur-md"
aria-label={videoMuted ? t("models.unmute") : t("models.mute")}
>
{videoMuted ? (
<BoldIcon name="volume-slash" size={20} tinted className="text-white" aria-label={t("models.mute")} />
<BoldIcon name="volume-slash" size={18} tinted className="text-white" />
) : (
<BoldIcon name="volume-high" size={20} tinted className="text-white" aria-label={t("models.unmute")} />
<BoldIcon name="volume-high" size={18} tinted className="text-white" />
)}
</div>
</button>
</div>
)}
</div>
<MainModelCardActions postData={postData} />
<MainModelCardActions postData={postData} social={social} />
{caption?.includes("@") && (
<div className="mx-2 mt-1 flex items-center gap-1 text-xs text-neutral-500">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
@@ -466,20 +478,6 @@ function MainModelCard({
)}
<style jsx>{`
.animate-ripple {
animation: ripple 600ms ease-out;
pointer-events: none;
}
@keyframes ripple {
0% {
transform: scale(0);
opacity: 0.5;
}
100% {
transform: scale(4);
opacity: 0;
}
}
`}</style>
</div>
);

View File

@@ -17,7 +17,13 @@ import {
} from "@/hooks/usePostSocialActions";
import { useTranslation } from "react-i18next";
function MainModelCardActions({ postData }: { postData: Post }) {
function MainModelCardActions({
postData,
social: sharedSocial,
}: {
postData: Post;
social?: ReturnType<typeof usePostSocialState>;
}) {
const { _id: postId, user_id } = postData;
const { t } = useTranslation("common");
@@ -36,7 +42,8 @@ function MainModelCardActions({ postData }: { postData: Post }) {
const [showSendModal, setShowSendModal] = useState(false);
const [showServicesModal, setShowServicesModal] = useState<boolean>(false);
const social = usePostSocialState(postData);
const ownSocial = usePostSocialState(postData);
const social = sharedSocial ?? ownSocial;
const iconSize = "sm" as const;
// عین allowSave توی ReelsPostCard.tsx/ReelsPostActions.tsx — این کارت (فید خانه) این
// چک رو نداشت و دکمه‌ی ذخیره همیشه نمایش داده می‌شد، حتی وقتی نویسنده allow_save_posts

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

@@ -807,7 +807,7 @@ export default function PostFeedView({
if (!scrollRef.current || index + 1 >= displayPosts.length) return;
scrollRef.current.scrollTo({
top: (index + 1) * getReelsStepHeight(scrollRef.current),
behavior: "smooth",
behavior: "auto",
});
}}
/>

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;
@@ -96,9 +97,9 @@ export default function ReelsPostCard({
const videoSrc = type === "video" ? getPostVideoSrc(postData) : undefined;
const videoPreload =
preloadPriority === "active" || preloadPriority === "nearby"
preloadPriority === "active"
? "auto"
: preloadPriority === "idle"
: preloadPriority === "idle" || preloadPriority === "nearby"
? "metadata"
: "none";
@@ -152,6 +153,10 @@ export default function ReelsPostCard({
const holdTriggeredRef = useRef(false);
const singleTapTimerRef = useRef<number | null>(null);
const [commentsMountedOnce, setCommentsMountedOnce] = useState(false);
useEffect(() => {
if (commentsOpen) setCommentsMountedOnce(true);
}, [commentsOpen]);
const { muted, setMuted } = useContext(MutedContext);
const [videoMuted, setVideoMuted] = useState(true);
const exploreUi = useExploreReelsUi();
@@ -226,57 +231,40 @@ export default function ReelsPostCard({
holdTriggeredRef.current = false;
}, [clearHoldTimer, exploreUi]);
const handleMediaClick = useCallback(
(e: React.MouseEvent) => {
if (postPage || commentsOpen || !isActive) return;
if (singleTapTimerRef.current) {
window.clearTimeout(singleTapTimerRef.current);
}
singleTapTimerRef.current = window.setTimeout(() => {
singleTapTimerRef.current = null;
if (exploreUi?.chromeHidden) {
exploreUi.setChromeHidden(false);
return;
}
if (e.target === e.currentTarget) {
// tap on empty overlay only
}
}, DOUBLE_TAP_MS);
},
[postPage, commentsOpen, isActive, exploreUi]
);
// یک ضربه = پخش/توقف (یا نمایش/مخفی‌کردنِ آیکون‌ها)، دو ضربه‌ی سریع = پسندیدن — عینِ
// اندروید. با تایمر خودمون انجام می‌شه، نه dblclick مرورگر (که روی لمسی معمولاً اجرا نمی‌شه)
const handleMediaTap = useCallback(() => {
if (postPage || commentsOpen || !isActive || holdTriggeredRef.current) return;
const handleMediaDoubleClick = useCallback(
(e: React.MouseEvent) => {
if (postPage || commentsOpen || !isActive) return;
e.stopPropagation();
if (singleTapTimerRef.current) {
window.clearTimeout(singleTapTimerRef.current);
singleTapTimerRef.current = null;
}
if (singleTapTimerRef.current) {
window.clearTimeout(singleTapTimerRef.current);
singleTapTimerRef.current = null;
if (!social.liked) {
void social.toggleLike();
}
setLikeBurstKey(Date.now());
window.setTimeout(() => setLikeBurstKey(null), 900);
},
[postPage, commentsOpen, isActive, social]
);
const handleVideoClick = (e: React.MouseEvent<HTMLVideoElement>) => {
if (holdTriggeredRef.current || e.detail > 1) return;
e.stopPropagation();
const v = videoRef.current;
if (!v) return;
if (v.paused) {
void v.play().catch(() => {});
setVideoPaused(false);
} else {
v.pause();
setVideoPaused(true);
return;
}
};
singleTapTimerRef.current = window.setTimeout(() => {
singleTapTimerRef.current = null;
if (exploreUi?.chromeHidden) {
exploreUi.setChromeHidden(false);
return;
}
if (type !== "video") return;
const v = videoRef.current;
if (!v) return;
if (v.paused) {
void v.play().catch(() => {});
setVideoPaused(false);
} else {
v.pause();
setVideoPaused(true);
}
}, DOUBLE_TAP_MS);
}, [postPage, commentsOpen, isActive, exploreUi, social, type]);
const toggleVideoMute = (e: React.MouseEvent) => {
e.stopPropagation();
@@ -349,7 +337,7 @@ export default function ReelsPostCard({
useEffect(() => {
if (type !== "video" || !videoSrc) return;
if (preloadPriority === "active" || preloadPriority === "nearby") {
if (preloadPriority === "active") {
prefetchVideoUrl(videoSrc, "high");
}
}, [type, videoSrc, preloadPriority]);
@@ -473,7 +461,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(
@@ -489,8 +481,7 @@ export default function ReelsPostCard({
onPointerUp={handleMediaPointerUp}
onPointerLeave={handleMediaPointerUp}
onPointerCancel={handleMediaPointerUp}
onClick={handleMediaClick}
onDoubleClick={handleMediaDoubleClick}
onClick={handleMediaTap}
>
<div className="absolute inset-0 flex items-center justify-center">
{type !== "video" && imageFiles.length > 0 && (
@@ -515,8 +506,14 @@ export default function ReelsPostCard({
// دقیقاً روی لبه‌ی پایینِ کادر بشینه (نه وسط‌چین) — تا صرف‌نظر از نسبتِ
// ابعادِ صفحه‌نمایشِ گوشی، این خط ثابت بمونه؛ پوستر هم باید با همون
// لنگرگاه نمایش داده بشه، وگرنه لحظه‌ی لود شدنِ ویدیو یه پرش دیده می‌شه
className="object-contain object-bottom"
className="object-cover object-center"
priority={isActive || preloadPriority === "nearby"}
// unoptimized: این فقط یه پیش‌نمایشِ کوتاه‌مدته تا ویدیو آماده بشه — رد
// کردنش از مسیرِ بهینه‌سازیِ next/image (ریسایز+تبدیل به avif/webp روی
// سرور) دقیقاً همونیه که اندروید هم با FastImage انجام می‌ده: دانلودِ مستقیمِ
// فایلِ اصلی از CDN بدون یک هاپِ اضافه؛ همون هاپِ اضافه (و کیفیتِ کم‌شده‌ی
// خروجیِ تبدیل) عامل اصلیِ «تار دیده شدن و کند لود شدنِ» پوستر نسبت به اندروید بود
unoptimized
draggable={!allowSave ? false : undefined}
onContextMenu={
!allowSave ? (e) => e.preventDefault() : undefined
@@ -532,10 +529,9 @@ export default function ReelsPostCard({
preload={videoPreload}
controlsList="nodownload"
className={cn(
"h-full w-full object-contain object-bottom",
"h-full w-full object-cover object-center",
videoPoster && !videoReady && "opacity-0"
)}
onClick={handleVideoClick}
// مرورگر نمی‌تونه از اسکرین‌شات واقعی جلوگیری کنه (هیچ API مرورگری برای این
// وجود نداره)؛ این فقط راه‌های ساده‌ی دانلود/ذخیره (کلیک راست، درگ) رو می‌بندد
onContextMenu={
@@ -588,6 +584,21 @@ export default function ReelsPostCard({
</>
)}
{type === "video" && isActive && !commentsOpen && !postPage && !videoPaused && !chromeHidden && (
<button
type="button"
onClick={toggleVideoMute}
className="absolute bottom-3 left-3 z-[26] flex h-8 w-8 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur-md"
aria-label={videoMuted ? t("posts.unmute") : t("posts.mute")}
>
{videoMuted ? (
<BoldIcon name="volume-slash" size={16} tinted className="text-white" />
) : (
<BoldIcon name="volume-high" size={16} tinted className="text-white" />
)}
</button>
)}
{imageLoading && isActive && !commentsOpen && <ReelsMediaSkeleton />}
{likeBurstKey ? (
@@ -611,7 +622,7 @@ export default function ReelsPostCard({
</div>
{showLongVideoBar && (
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-30 px-3 pb-1">
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-30 px-3">
<p className="mb-1 text-right text-[10px] tabular-nums text-white/85">
{formatVideoTime(remaining)}
</p>
@@ -627,11 +638,11 @@ export default function ReelsPostCard({
>
<div className="relative h-0.5 overflow-hidden rounded-full bg-white/25">
<div
className="absolute inset-y-0 left-0 rounded-full bg-white/35"
className="absolute inset-y-0 right-0 rounded-full bg-white/35"
style={{ width: `${bufferPct}%` }}
/>
<div
className="absolute inset-y-0 left-0 rounded-full bg-white"
className="absolute inset-y-0 right-0 rounded-full bg-[#FF107D]"
style={{ width: `${progressPct}%` }}
/>
</div>
@@ -663,7 +674,7 @@ export default function ReelsPostCard({
<BoldIcon name={videoPaused ? "play" : "pause"} size={16} tinted className="text-white" />
</button>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-30 px-3 pb-1.5">
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-30 px-3">
<div
className="relative -my-3 cursor-pointer touch-none py-3 pointer-events-auto"
onPointerDown={handleProgressPointerDown}
@@ -673,11 +684,11 @@ export default function ReelsPostCard({
>
<div className="relative h-0.5 overflow-hidden rounded-full bg-white/25">
<div
className="absolute inset-y-0 left-0 rounded-full bg-white/35"
className="absolute inset-y-0 right-0 rounded-full bg-white/35"
style={{ width: `${bufferPct}%` }}
/>
<div
className="absolute inset-y-0 left-0 rounded-full bg-white"
className="absolute inset-y-0 right-0 rounded-full bg-[#FF107D]"
style={{ width: `${progressPct}%` }}
/>
</div>
@@ -700,7 +711,7 @@ export default function ReelsPostCard({
// justify-between (ریت/امتیاز) زیرِ RTL فیزیکاً همون سمت می‌شینه، این
// فاصله‌ی اضافه (۶۴px، هم‌اندازه‌ی عرضِ ستونِ آیکون‌ها) از افتادنِ
// ریت/امتیاز زیرِ دکمه‌ی جمع‌شونده جلوگیری می‌کنه
"pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/90 via-black/60 to-transparent pl-16 pr-4 pt-16",
"pointer-events-none absolute inset-x-0 bottom-0 z-20 pl-16 pr-4 pt-16",
// وقتی نوار پیشرفت ویدیو نمایش داده می‌شه، این بلوک باید بالای خطِ اون
// بشینه نه روش؛ وگرنه (تصویر/ویدیوی کوتاه) فقط فاصله‌ی پایینِ معمولی کافیه
showLongVideoBar
@@ -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}`}
@@ -802,7 +842,7 @@ export default function ReelsPostCard({
/>
)}
{useCommentsSheet && (
{useCommentsSheet && (commentsOpen || commentsMountedOnce) && (
<CommentDrawer
isOpen={commentsOpen}
onOpenChange={(open) => !open && setCommentsOpen(false)}

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

@@ -183,7 +183,15 @@ const Step4 = () => {
{formik.touched.selectedType && formik.errors.selectedType && (
<p className="text-red-500">{formik.errors.selectedType}</p>
)}
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
{/* قبلاً هیچ گاردی نبود — فرمیک isSubmitting رو خودش درست مدیریت می‌کنه (چون
onSubmit یه تابعِ async هست)، فقط به دکمه وصل نشده بود؛ دابل‌کلیک دو
POST /projects/create هم‌زمان می‌فرستاد و دو پروژه‌ی تکراری می‌ساخت */}
<RoundedButton
type="submit"
variant="primary"
disabled={formik.isSubmitting}
className="mt-4 px-8 py-2"
>
{t("projects.newProject.submitRequest")}
</RoundedButton>
</form>

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

@@ -201,7 +201,7 @@ function ProjectRequestsAction({
{viewerUrl ? (
<div
className="fixed inset-0 z-[300] flex items-center justify-center bg-black/90"
className="fixed inset-0 z-[300] flex items-center justify-center bg-transparent"
onClick={() => setViewerUrl(null)}
>
<button

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

@@ -4,7 +4,6 @@ import { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import { useRouter } from "next/navigation";
import { btnPrimary, btnDefault } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
@@ -27,7 +26,6 @@ export default function AccountActionModals({
}: AccountActionModalsProps) {
const { t } = useTranslation("common");
const { request } = useAxios();
const router = useRouter();
const [step, setStep] = useState<Step>("confirm");
const [typed, setTyped] = useState("");
const [loading, setLoading] = useState(false);
@@ -59,7 +57,8 @@ export default function AccountActionModals({
await request("POST", "/account/delete", { user_name: typed.trim() });
await clearAuthSession();
toast.success(t("settings.accountActions.deleted"));
router.push("/login");
// navigation سخت — همون دلیلِ Logout.tsx، کَشِ کلاینتیِ روتر رو دور می‌زنه
window.location.href = "/login";
}
} catch (err) {
toast.error(

View File

@@ -1,5 +1,4 @@
"use client";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import Modal from "../../elements/Modal";
import RoundedButton from "../../elements/RoundedButton";
@@ -10,11 +9,12 @@ import { useTranslation } from "react-i18next";
function Logout() {
const { t } = useTranslation("common");
const [showModal, setShowModal] = useState<boolean>(false);
const router = useRouter();
const logoutHandler = async () => {
await clearAuthSession();
router.push("/login");
router.refresh();
// router.push (soft navigation) می‌تونه کَشِ کلاینتیِ روتر (RSC payload قبلیِ
// صفحه‌ی اصلی، از وقتی لاگین بودیم) رو دوباره نشون بده بدون اینکه میدل‌ور
// دوباره اجرا بشه — navigation سخت، درخواست تازه به سرور می‌زنه
window.location.href = "/login";
};
return (
<>

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

@@ -550,7 +550,7 @@ export default function StoryEditor({
{stickerModal && (
<div
className="fixed inset-0 z-[60] flex items-end justify-center bg-black/60"
className="fixed inset-0 z-[60] flex items-end justify-center bg-transparent"
onClick={() => setStickerModal(null)}
>
<div
@@ -585,7 +585,7 @@ export default function StoryEditor({
{emojiPickerOpen && (
<div
className="fixed inset-0 z-[60] flex items-end justify-center bg-black/60"
className="fixed inset-0 z-[60] flex items-end justify-center bg-transparent"
onClick={() => setEmojiPickerOpen(false)}
>
<div

View File

@@ -140,7 +140,7 @@ export default function StoryLinkPicker({
}, [load]);
return (
<div className="fixed inset-0 z-[60] flex items-end justify-center bg-black/60" onClick={onClose}>
<div className="fixed inset-0 z-[60] flex items-end justify-center bg-transparent" onClick={onClose}>
<div
className="max-h-[70vh] w-full max-w-md overflow-y-auto rounded-t-3xl bg-white p-4 dark:bg-neutral-900"
onClick={(e) => e.stopPropagation()}

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;
}
@@ -65,7 +57,7 @@ export default function StoryMentionPicker({
}, [query, request]);
return (
<div className="fixed inset-0 z-[60] flex items-end justify-center bg-black/60" onClick={onClose}>
<div className="fixed inset-0 z-[60] flex items-end justify-center bg-transparent" onClick={onClose}>
<div
className="max-h-[70vh] w-full max-w-md overflow-y-auto rounded-t-3xl bg-white p-4 dark:bg-neutral-900"
onClick={(e) => e.stopPropagation()}
@@ -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,21 @@ export const REELS_PAGE_SIZE = 6;
/** فقط کارت‌های نزدیک پست فعال mount می‌شوند */
export const REELS_RENDER_WINDOW = 5;
/** کلاس یکسان هر اسلات ریلز — snap + ارتفاع ثابت */
/** فضای رزروشده‌ی پایینِ هر اسلات برای نوارِ ناوبریِ شناورِ پایینِ صفحه (TabNavigation.tsx)
* که برخلافِ اندروید (که تبِ پایین رو کاملاً مخفی می‌کنه)، توی ریلزِ وب همیشه روی صفحه
* می‌مونه — پس برخلافِ نسخه‌ی اول (نسبتِ ۹:۱۶ کاملِ letterbox که فضای خالیِ خیلی زیادی
* می‌ذاشت)، اینجا فقط دقیقاً به‌اندازه‌ی ارتفاعِ خودِ همون نوار + safe-area رزرو می‌شه،
* نه یه نسبتِ ابعادِ ثابت — تا پست مثلِ ریلزِ اندروید تقریباً تمام‌صفحه (edge-to-edge)
* بمونه و فقط از زیرِ نوار بیرون نزنه */
// مثل اندروید: فاصله‌ی ثابت ۵۰px از پایینِ صفحه، بدون وابستگی به safe-area،
// تا کادرِ ریلز و خطِ پیشرفت روی همه‌ی گوشی‌ها یک شکل باشن
export const REELS_BOTTOM_RESERVED = "50px";
/** کلاس یکسان هر اسلات ریلز — 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";
/** پوسته ریلز — هم‌عرض منوبار */
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

@@ -3,6 +3,25 @@ import { useState, useEffect } from "react";
import useAxios from "./useAxios";
import { User } from "@/types/types";
const USER_CACHE_TTL_MS = 5 * 60 * 1000;
const userCache = new Map<string, { at: number; promise: Promise<User | undefined> }>();
function loadUserOnce(
id: string,
request: (method: "GET", url: string) => Promise<{ user: User }>
): Promise<User | undefined> {
const cached = userCache.get(id);
if (cached && Date.now() - cached.at < USER_CACHE_TTL_MS) return cached.promise;
const promise = request("GET", `/profile/${id}`)
.then((response) => response.user)
.catch((error) => {
userCache.delete(id);
throw error;
});
userCache.set(id, { at: Date.now(), promise });
return promise;
}
export const useUserById = (_id: string) => {
const [user, setUser] = useState<User>();
const { request } = useAxios();
@@ -10,8 +29,10 @@ export const useUserById = (_id: string) => {
useEffect(() => {
const fetchUser = async () => {
try {
const response = await request<{ user: User }>("GET", `/profile/${_id}`);
setUser(response.user);
const loaded = await loadUserOnce(_id, (method, url) =>
request<{ user: User }>(method, url)
);
setUser(loaded);
} catch (error) {
console.error("Failed to fetch user:", error);
}

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 {

3
src/lib/e2ee/config.ts Normal file
View File

@@ -0,0 +1,3 @@
// موقتاً غیرفعال: کل پیام‌ها (متن، فایل، ویس) بدون رمزنگاری ارسال/دریافت می‌شن.
// برای برگرداندنِ e2ee فقط این مقدار را true کنید؛ کدِ اصلی دست‌نخورده باقی مونده.
export const E2EE_ENABLED = false;

Some files were not shown because too many files have changed in this diff Show More