Compare commits

...

2 Commits

Author SHA1 Message Date
payacom
f364f989ed pay 2026-10-06 17:07:08 +03:30
payacom
d6386b779f e2ee 2026-10-02 18:12:58 +03:30
54 changed files with 2139 additions and 1661 deletions

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

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

@@ -70,7 +70,7 @@ function LoginWithUsername() {
// قبل از این‌که password از scope خارج بشه، کلیدِ E2EE رو زیرِ همین رمز
// بک‌آپ/بازیابی کن — best-effort، نباید جلویِ خودِ لاگین رو بگیره
if (response.id) {
void establishIdentityAtLogin(response.id, values.password);
// [E2EE] موقتاً غیرفعال — void establishIdentityAtLogin(response.id, values.password);
}
const loggedIn = await completeLogin(router, response, {

View File

@@ -57,7 +57,7 @@ function PasswordPage() {
})) as IVerifyOtp;
// اولین رمزِ عبورِ این کاربره — همین الان کلیدِ E2EE رو زیرش بک‌آپ بگیر
const userId = getStoredUserId();
if (userId) void establishIdentityAtLogin(userId, values.password);
// [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

@@ -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

@@ -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

@@ -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

@@ -641,11 +641,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 {
@@ -801,22 +807,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

@@ -1,357 +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 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 [hasUsedFreeOffer, setHasUsedFreeOffer] = useState(false);
const [walletBalance, setWalletBalance] = useState(0);
const [showWalletPrompt, setShowWalletPrompt] = useState(false);
const [socialSignupOpen, setSocialSignupOpen] = useState(false);
const pendingPaymentRetryRef = React.useRef<(() => void) | null>(null);
useEffect(() => {
const fetchUser = async () => {
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) {
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 ||
(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");
};
// چیدمان این صفحه با 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 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);
}}
/>
<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;
/* 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>
);

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";
@@ -549,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);
@@ -622,10 +626,17 @@ export default function ChatRoomThreadPage({
}
try {
await ensureIdentityKeys(senderId);
const encrypted = await encryptRoomContent(senderId, roomId, text);
if (!encrypted) throw new Error("E2EE encryption failed");
const sendContent = encrypted;
// ارسالِ فایل/عکس/ویس/ویدیو توی روم هم فعلاً بدونِ 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) {

View File

@@ -64,7 +64,7 @@ function PasswordPage() {
// رمزِ عبور عوض شد (یا اولین‌بار تنظیم شد) — کلیدِ E2EEِ همین مرورگر
// (دست‌نخورده توی IndexedDB) رو زیرِ رمزِ جدید دوباره بک‌آپ بگیر
if (user?._id) {
void establishIdentityAtLogin(user._id, values.password);
// [E2EE] موقتاً غیرفعال — void establishIdentityAtLogin(user._id, values.password);
}
toast.success(
isGoogleUser

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

@@ -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

@@ -104,7 +104,7 @@ export default function CompleteSocialSignupModal({
);
// اولین باریه این کاربر رمزِ عبور داره — همین الان کلیدِ E2EE رو زیرش بک‌آپ بگیر
const userId = getStoredUserId();
if (userId) void establishIdentityAtLogin(userId, password);
// [E2EE] موقتاً غیرفعال — if (userId) void establishIdentityAtLogin(userId, password);
reset();
onSuccess(response.user_name);
} catch (err) {

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

@@ -101,7 +101,7 @@ export default function ForwardMessageModal({
receiverId,
content
);
if (!encrypted) throw new Error("E2EE encryption failed");
if (encrypted === null) throw new Error("E2EE encryption failed");
const sendContent = encrypted;
return request("POST", "/chat", {
content: sendContent,

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

@@ -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

@@ -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 برای پخش/توقف ویدیو بر اساس ویوپورت
@@ -376,7 +385,7 @@ function MainModelCard({
<div
className={`overflow-hidden rounded-2xl ${onMediaClick ? "cursor-pointer" : ""}`}
onClick={onMediaClick}
onClick={handleMediaTap}
onKeyDown={
onMediaClick
? (e) => {
@@ -401,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}
@@ -418,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
@@ -440,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>
@@ -472,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

@@ -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

@@ -97,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";
@@ -153,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();
@@ -227,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();
@@ -350,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]);
@@ -494,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 && (
@@ -520,7 +506,7 @@ export default function ReelsPostCard({
// دقیقاً روی لبه‌ی پایینِ کادر بشینه (نه وسط‌چین) — تا صرف‌نظر از نسبتِ
// ابعادِ صفحه‌نمایشِ گوشی، این خط ثابت بمونه؛ پوستر هم باید با همون
// لنگرگاه نمایش داده بشه، وگرنه لحظه‌ی لود شدنِ ویدیو یه پرش دیده می‌شه
className="object-contain object-bottom"
className="object-cover object-center"
priority={isActive || preloadPriority === "nearby"}
// unoptimized: این فقط یه پیش‌نمایشِ کوتاه‌مدته تا ویدیو آماده بشه — رد
// کردنش از مسیرِ بهینه‌سازیِ next/image (ریسایز+تبدیل به avif/webp روی
@@ -543,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={
@@ -599,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 ? (
@@ -622,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>
@@ -638,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>
@@ -674,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}
@@ -684,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>
@@ -711,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
@@ -842,7 +842,7 @@ export default function ReelsPostCard({
/>
)}
{useCommentsSheet && (
{useCommentsSheet && (commentsOpen || commentsMountedOnce) && (
<CommentDrawer
isOpen={commentsOpen}
onOpenChange={(open) => !open && setCommentsOpen(false)}

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

@@ -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

@@ -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

@@ -57,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()}

View File

@@ -13,13 +13,15 @@ export const REELS_RENDER_WINDOW = 5;
* می‌ذاشت)، اینجا فقط دقیقاً به‌اندازه‌ی ارتفاعِ خودِ همون نوار + safe-area رزرو می‌شه،
* نه یه نسبتِ ابعادِ ثابت — تا پست مثلِ ریلزِ اندروید تقریباً تمام‌صفحه (edge-to-edge)
* بمونه و فقط از زیرِ نوار بیرون نزنه */
export const REELS_BOTTOM_RESERVED = "calc(96px + env(safe-area-inset-bottom))";
// مثل اندروید: فاصله‌ی ثابت ۵۰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 flex h-[100dvh] w-full shrink-0 snap-start snap-always items-start justify-center bg-black pt-[env(safe-area-inset-top)]";
"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

@@ -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);
}

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

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

View File

@@ -4,6 +4,7 @@ import {
encryptText,
importPublicKeySpkiB64,
} from "./crypto";
import { E2EE_ENABLED } from "./config";
import { E2EE_LOCKED_PREVIEW, isE2eeEnvelope } from "./envelope";
import { ensureIdentityKeys, fetchPeerPublicKey } from "./keys";
@@ -83,6 +84,7 @@ export async function encryptDmContent(
peerUserId: string,
plaintext: string
): Promise<string | null> {
if (!E2EE_ENABLED) return plaintext;
const key = await getDmConversationKey(myUserId, peerUserId);
if (!key) return null;
return encryptText(key, plaintext);
@@ -94,7 +96,7 @@ export async function decryptDmContent(
content: string
): Promise<string> {
const raw = normalizeMessageContent(content);
if (!isE2eeEnvelope(raw)) return raw;
if (!E2EE_ENABLED || !isE2eeEnvelope(raw)) return raw;
const key = await getDmConversationKey(myUserId, peerUserId);
if (key) {
@@ -126,6 +128,7 @@ export async function decryptDmMessageList<
peerUserId: string,
messages: T[]
): Promise<T[]> {
if (!E2EE_ENABLED) return messages;
const out: T[] = [];
for (const msg of messages) {
// خطای رمزگشایی یک پیام نباید کل صفحه را از حالت رمزگشایی‌شده خارج کند —

View File

@@ -1,5 +1,6 @@
import Cookies from "js-cookie";
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import { E2EE_ENABLED } from "./config";
import {
decryptPrivateKeyFromBackup,
encryptPrivateKeyForBackup,
@@ -184,6 +185,9 @@ export async function ensureIdentityKeys(userId: string): Promise<{
privateKey: CryptoKey;
publicKeyB64: string;
}> {
if (!E2EE_ENABLED) {
return { privateKey: undefined as unknown as CryptoKey, publicKeyB64: "" };
}
const uid = String(userId);
if (memoryPair?.userId === uid) {
return {
@@ -306,6 +310,7 @@ export async function establishIdentityAtLogin(
userId: string,
password: string
): Promise<void> {
if (!E2EE_ENABLED) return;
const uid = String(userId);
try {
await ensureIdentityKeys(uid);

View File

@@ -9,6 +9,7 @@ import {
unwrapRoomKey,
wrapRoomKeyForRecipient,
} from "./crypto";
import { E2EE_ENABLED } from "./config";
import { E2EE_LOCKED_PREVIEW, isE2eeEnvelope } from "./envelope";
import { ensureIdentityKeys } from "./keys";
import { b64ToBuf, bufToB64, idbGet, idbSet } from "./storage";
@@ -209,6 +210,7 @@ export async function ensureRoomAesKey(
/** Bypass the in-memory/IndexedDB cache and re-unwrap from the server's current wrapped key. */
forceFresh = false,
): Promise<CryptoKey | null> {
if (!E2EE_ENABLED) return null;
if (!forceFresh) {
const cached = roomAesCache.get(roomId);
if (cached) return cached;
@@ -229,6 +231,7 @@ export async function encryptRoomContent(
roomId: string,
plaintext: string,
): Promise<string | null> {
if (!E2EE_ENABLED) return plaintext;
const key = await ensureRoomAesKey(myUserId, roomId);
if (!key) return null;
return encryptText(key, plaintext);
@@ -239,7 +242,7 @@ export async function decryptRoomContent(
roomId: string,
content: string,
): Promise<string> {
if (!isE2eeEnvelope(content)) return content;
if (!E2EE_ENABLED || !isE2eeEnvelope(content)) return content;
const key = await ensureRoomAesKey(myUserId, roomId);
if (key) {
@@ -263,6 +266,7 @@ export async function decryptRoomMessageList<
replyTo?: { content?: string; [k: string]: unknown } | null;
},
>(myUserId: string, roomId: string, messages: T[]): Promise<T[]> {
if (!E2EE_ENABLED) return messages;
await ensureRoomAesKey(myUserId, roomId);
const out: T[] = [];
for (const msg of messages) {

View File

@@ -17,8 +17,8 @@ export const GLASS_BACKDROP: Record<string, CSSProperties> = {
"glass-panel-strong": { backdropFilter: "blur(24px) saturate(1.4)", WebkitBackdropFilter: "blur(24px) saturate(1.4)" },
"comments-modal-glass": { backdropFilter: "blur(28px) saturate(1.5)", WebkitBackdropFilter: "blur(28px) saturate(1.5)" },
"tab-active-glass": { backdropFilter: "blur(12px)", WebkitBackdropFilter: "blur(12px)" },
"glass-modal-overlay": { backdropFilter: "blur(20px) saturate(120%)", WebkitBackdropFilter: "blur(20px) saturate(120%)" },
"glass-modal-overlay--dark": { backdropFilter: "blur(24px) saturate(110%)", WebkitBackdropFilter: "blur(24px) saturate(110%)" },
"glass-modal-overlay": { backdropFilter: "none", WebkitBackdropFilter: "none" },
"glass-modal-overlay--dark": { backdropFilter: "none", WebkitBackdropFilter: "none" },
"glass-modal-panel": { backdropFilter: "blur(28px) saturate(180%)", WebkitBackdropFilter: "blur(28px) saturate(180%)" },
};

View File

@@ -2,7 +2,16 @@ import { Post } from "@/types/types";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import { resolvePostMediaType } from "@/lib/reelsMediaType";
const MAX_WARM_VIDEOS = 8;
const MAX_WARM_VIDEOS = 3;
export function isConstrainedNetwork(): boolean {
if (typeof navigator === "undefined") return false;
const connection = (navigator as Navigator & {
connection?: { saveData?: boolean; effectiveType?: string };
}).connection;
if (!connection) return false;
return connection.saveData === true || ["slow-2g", "2g", "3g"].includes(connection.effectiveType ?? "");
}
const warmVideos = new Map<string, HTMLVideoElement>();
const prefetchedLinks = new Set<string>();
@@ -23,7 +32,7 @@ function evictOldestWarmVideo() {
}
function warmVideoElement(url: string) {
if (typeof window === "undefined" || !url || warmVideos.has(url)) return;
if (typeof window === "undefined" || !url || warmVideos.has(url) || isConstrainedNetwork()) return;
const video = document.createElement("video");
video.preload = "auto";
@@ -69,7 +78,7 @@ export function prefetchPostVideo(
post: Pick<Post, "files" | "type" | "_id"> | null | undefined,
priority: VideoPrefetchPriority = "high"
) {
prefetchVideoUrl(getPostVideoSrc(post ?? undefined), priority);
prefetchVideoUrl(post ? getPostVideoSrc(post) : undefined, priority);
}
export function prefetchPostVideos(

View File

@@ -37,7 +37,7 @@ export default function ChatSocketProvider({
{/* ensureIdentityKeys اینجا (تویِ خودِ گیت) صدا زده می‌شه، نه اینجا مستقیم —
تا اگه هویتِ قبلی روی سرور هست و این دستگاه بهش دسترسی نداره، به‌جای
fail شدنِ بی‌صدا، از کاربر بازیابی/تاییدِ صریح خواسته بشه */}
{uid ? <E2eeIdentityGate userId={uid} /> : null}
{/* [E2EE] موقتاً غیرفعال: {uid ? <E2eeIdentityGate userId={uid} /> : null} */}
{children}
</>
);