diff --git a/.claude/worktrees/agent-af1dfc8fe9a6cb198 b/.claude/worktrees/agent-af1dfc8fe9a6cb198 deleted file mode 160000 index 3b89bbf..0000000 --- a/.claude/worktrees/agent-af1dfc8fe9a6cb198 +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3b89bbfec327e50e9a1d5952f429864b48f058a3 diff --git a/public/service-worker.js b/public/service-worker.js index e7c61e1..78d77a4 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -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; }); diff --git a/src/app/(auth)/(login)/login-with-username/page.tsx b/src/app/(auth)/(login)/login-with-username/page.tsx index fc96bd9..0d12723 100644 --- a/src/app/(auth)/(login)/login-with-username/page.tsx +++ b/src/app/(auth)/(login)/login-with-username/page.tsx @@ -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, { diff --git a/src/app/(auth)/(register)/register/password/page.tsx b/src/app/(auth)/(register)/register/password/page.tsx index 690e8ce..38914d8 100644 --- a/src/app/(auth)/(register)/register/password/page.tsx +++ b/src/app/(auth)/(register)/register/password/page.tsx @@ -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 = diff --git a/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx b/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx index 796283b..c3854b6 100644 --- a/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx +++ b/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx @@ -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); diff --git a/src/app/(projects)/academy/payment/failed/page.tsx b/src/app/(projects)/academy/payment/failed/page.tsx index 506fdc5..e65d4ee 100644 --- a/src/app/(projects)/academy/payment/failed/page.tsx +++ b/src/app/(projects)/academy/payment/failed/page.tsx @@ -113,7 +113,7 @@ function FailedProject() {
{project && } -
+
{getDisplayTypeLabel(project?.project_type)}:{" "} {Number(price).toLocaleString()} {t("settings.toman")} @@ -129,7 +129,7 @@ function FailedProject() { }} disabled={paying} variant="primary" - className="w-32 h-9" + className="flex-1 h-9" > {t("academy.payment.retry")} diff --git a/src/app/(projects)/projects/loading.tsx b/src/app/(projects)/projects/loading.tsx new file mode 100644 index 0000000..2117200 --- /dev/null +++ b/src/app/(projects)/projects/loading.tsx @@ -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 ( +
+ + {label} +
+ ); +} diff --git a/src/app/(projects)/projects/payment/failed/page.tsx b/src/app/(projects)/projects/payment/failed/page.tsx index c60324f..7b864dc 100644 --- a/src/app/(projects)/projects/payment/failed/page.tsx +++ b/src/app/(projects)/projects/payment/failed/page.tsx @@ -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,6 +18,7 @@ function FailedProject() { const { t } = useTranslation("common"); const { request } = useAxios(); const searchParams = useSearchParams(); + const router = useRouter(); const projectId = searchParams.get("projectId"); const [project, setProject] = useState(); @@ -120,7 +121,7 @@ function FailedProject() {
{project && } -
+
{getDisplayTypeLabel(project?.project_type)}:{" "} {Number(price).toLocaleString()} {t("settings.toman")} @@ -134,10 +135,16 @@ function FailedProject() { onClick={payHandler} disabled={paying} variant="primary" - className="h-9 w-32" + className="h-9 flex-1" > {t("projects.payment.retry")} + router.push("/settings/workroom?filter=unpaid")} + className="h-9 flex-1" + > + {t("common.back")} +
diff --git a/src/app/(projects)/projects/payment/success/page.tsx b/src/app/(projects)/projects/payment/success/page.tsx index 182a096..6c81bde 100644 --- a/src/app/(projects)/projects/payment/success/page.tsx +++ b/src/app/(projects)/projects/payment/success/page.tsx @@ -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() { )}

{t("projects.payment.reviewNotice")}

- - - {t("projects.payment.workroom")} - - + (window.location.href = projectId ? `/projects/${projectId}` : "/settings/workroom")} + > + {t("settings.confirm")} +
diff --git a/src/app/billboards/payment/failed/page.tsx b/src/app/billboards/payment/failed/page.tsx index 58ea433..591656c 100644 --- a/src/app/billboards/payment/failed/page.tsx +++ b/src/app/billboards/payment/failed/page.tsx @@ -117,7 +117,7 @@ function FailedBillboard() {
{advertising && } -
+
{getBillboardDisplayTypeLabel(t, advertising?.type)}:{" "} {Number(price).toLocaleString()} {t("settings.toman")} @@ -137,10 +137,16 @@ function FailedBillboard() { }} disabled={paying} variant="primary" - className="w-32 h-9" + className="flex-1 h-9" > {t("billboards.payment.retry")} + router.push("/settings/my-billboards?filter=unpaid")} + className="flex-1 h-9" + > + {t("common.back")} +
diff --git a/src/app/billboards/payment/success/page.tsx b/src/app/billboards/payment/success/page.tsx index a245cc1..2340cc2 100644 --- a/src/app/billboards/payment/success/page.tsx +++ b/src/app/billboards/payment/success/page.tsx @@ -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")}

{t("billboards.payment.registeredMessage")}

- - - {t("billboards.payment.myBillboards")} - - + (window.location.href = projectId ? `/settings/my-billboards/${projectId}/billboard` : "/settings/my-billboards")} + > + {t("settings.confirm")} +
diff --git a/src/app/gift/payment/failed/page.tsx b/src/app/gift/payment/failed/page.tsx index 0a9e11b..0dd4308 100644 --- a/src/app/gift/payment/failed/page.tsx +++ b/src/app/gift/payment/failed/page.tsx @@ -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() { /> {t("giftPage.paymentFailed.message")} -
+
{receiverId ? ( - - + + {t("giftPage.paymentFailed.retry")} ) : null} - - - {t("giftPage.paymentFailed.back")} - - + router.push(receiverId ? `/gift/${receiverId}` : "/")}> + {t("giftPage.paymentFailed.back")} +
diff --git a/src/app/globals.css b/src/app/globals.css index 7118307..63f693f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -807,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 { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c9d72ad..5af2ba9 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 { - 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 ( - - - - - - - - {children} - - {/* ====================== گوگل آنالیتیکس ====================== */} - - {/* ======================================================== */} - - ); -} +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 { + 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 ( + + + + + + + + {children} + + {/* ====================== گوگل آنالیتیکس ====================== */} + + {/* ======================================================== */} + + ); +} diff --git a/src/app/offer/[id]/page.tsx b/src/app/offer/[id]/page.tsx index 6590930..04a89e8 100644 --- a/src/app/offer/[id]/page.tsx +++ b/src/app/offer/[id]/page.tsx @@ -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(); - const [selectedType, setSelectedType] = useState("normal"); - const [typeList, setTypeList] = useState(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 ? ( -
- -
-
-
- {formatFullName(userDetail?.first_name, userDetail?.last_name)} -
- - {userDetail?.user_name} - - -
-
- - -
-
-
- ) : null; - - return ( - -
- {t("offerPage.paymentTitle")} - -
- {displayTypes.map((item) => { - const isFree = item.name === "free"; - const isDisabled = - Boolean(item.disabled) || (isFree && hasUsedFreeOffer); - - return ( -
{ - if (isDisabled) return; - setSelectedType(item.name); - void formik.setFieldValue("selectedType", item.name); - }} - > - {userHeader} - - {typeLabel(item.name)} - {Number(item.price) !== 0 - ? ": " + - Number(item.price).toLocaleString() + - t("offerPage.currencySuffix") - : ""} - - {isDisabled && item.unavailableReason ? ( -

- {item.unavailableReason} -

- ) : null} -
- ); - })} - - - {t("offerPage.submitRequest")} - -
- - item.name === selectedType)?.price - ) || 0 - } - walletBalance={walletBalance} - onAnswer={(useWallet) => { - setShowWalletPrompt(false); - void startPayment(selectedType, useWallet); - }} - /> - - { - setSocialSignupOpen(false); - pendingPaymentRetryRef.current = null; - }} - onSuccess={() => { - setSocialSignupOpen(false); - pendingPaymentRetryRef.current?.(); - pendingPaymentRetryRef.current = null; - }} - /> -
-
- ); -} - -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(); + const [selectedType, setSelectedType] = useState("normal"); + const [typeList, setTypeList] = useState(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 ? ( +
+ +
+
+
+ {formatFullName(userDetail?.first_name, userDetail?.last_name)} +
+ + {userDetail?.user_name} + + +
+
+ + +
+
+
+ ) : null; + + return ( + +
+ {t("offerPage.paymentTitle")} + +
+ {displayTypes.map((item) => { + const isDisabled = Boolean(item.disabled); + + return ( +
{ + if (isDisabled) return; + setSelectedType(item.name); + void formik.setFieldValue("selectedType", item.name); + }} + > + {userHeader ?
{userHeader}
: null} + + {typeLabel(item.name)} + {Number(item.price) !== 0 + ? ": " + + Number(item.price).toLocaleString() + + t("offerPage.currencySuffix") + : ""} + + {isDisabled && item.unavailableReason ? ( +

+ {item.unavailableReason} +

+ ) : null} +
+ ); + })} + +
+ + {t("offerPage.submitRequest")} + +
+
+ + item.name === selectedType)?.price + ) || 0 + } + walletBalance={walletBalance} + onAnswer={(useWallet) => { + setShowWalletPrompt(false); + void startPayment(selectedType, useWallet); + }} + /> + + { + setSocialSignupOpen(false); + pendingPaymentRetryRef.current = null; + }} + onSuccess={() => { + setSocialSignupOpen(false); + pendingPaymentRetryRef.current?.(); + pendingPaymentRetryRef.current = null; + }} + /> +
+
+ ); +} + +export default TicketChat; diff --git a/src/app/offer/payment/failed/page.tsx b/src/app/offer/payment/failed/page.tsx index b8ae509..7a9c74b 100644 --- a/src/app/offer/payment/failed/page.tsx +++ b/src/app/offer/payment/failed/page.tsx @@ -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() { /> {t("offerPage.paymentFailed.message")}
-
- - +
+ + {t("offerPage.paymentFailed.retry")} - - - {t("offerPage.paymentFailed.back")} - - + router.push(`/offer/${userId}`)} + > + {t("offerPage.paymentFailed.back")} +
diff --git a/src/app/offer/payment/success/page.tsx b/src/app/offer/payment/success/page.tsx index 6df117f..865e7ff 100644 --- a/src/app/offer/payment/success/page.tsx +++ b/src/app/offer/payment/success/page.tsx @@ -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 ?? "", })}

- - - {t("offerPage.paymentSuccess.sendMessage")} - -
+ {/* دکمه‌های پایین صفحه: ارسال پیام (به گفتگو) و بازگشت (به صفحه‌ی درخواست) */} +
+ + + {t("offerPage.paymentSuccess.sendMessage")} + + + router.push(`/offer/${userId}`)} + > + {t("offerPage.paymentFailed.back")} + +
); diff --git a/src/app/settings/chats/[username]/[id]/page.tsx b/src/app/settings/chats/[username]/[id]/page.tsx index 553c65e..4abfab2 100644 --- a/src/app/settings/chats/[username]/[id]/page.tsx +++ b/src/app/settings/chats/[username]/[id]/page.tsx @@ -1,849 +1,849 @@ -/* eslint-disable react-hooks/rules-of-hooks */ -"use client"; - -import Container from "@/components/elements/Container"; -import useAxios from "@/hooks/useAxios"; -import { User } from "@/types/types"; -import React, { useState, useEffect, useRef, useCallback } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { - getChatSocket, -} from "@/lib/chat/socketClient"; -import MessageInput from "@/components/chat/MessageInput"; -import GiftInChatModal from "@/components/chat/GiftInChatModal"; -import CompleteSocialSignupModal from "@/components/auth/CompleteSocialSignupModal"; -import { AxiosError } from "axios"; -import GiftPaymentResultModal from "@/components/chat/GiftPaymentResultModal"; -import MultiImageModal from "@/components/chat/MultiImageModal"; -import MediaPreviewModal from "@/components/chat/MediaPreviewModal"; -import ChatActionBar from "@/components/chat/ChatActionBar"; -import ChatDeleteBar from "@/components/chat/ChatDeleteBar"; -import { useUser } from "@/hooks/useUser"; -import ChatMessageList from "@/components/chat/ChatMessageList"; -import ChatHeader from "@/components/chat/ChatHeader"; -import dynamic from "next/dynamic"; -import type { ChatMessage } from "@/components/chat/ChatMessageCard"; -import toast from "react-hot-toast"; -import { optimizeMediaFile } from "@/lib/media"; -import ForwardMessageModal from "@/components/chat/ForwardMessageModal"; -import { AnimatePresence } from "framer-motion"; -import { useQueryClient } from "@tanstack/react-query"; -import { chatThreadQueryKey } from "@/lib/chat/queryKeys"; -import { normalizeThreadId, upsertMessageInThreadCache, type ChatThreadData } from "@/lib/chat/threadCache"; -import { useStableMessageKeys } from "@/hooks/useStableMessageKeys"; -import { getStoredUserId } from "@/lib/auth/session"; -import { isViewOnceMediaType } from "@/lib/chat/viewOnce"; -import { - copyTextToClipboard, - getCopyableMessageText, -} from "@/lib/chat/getCopyableMessageText"; -import { - findPendingMatchForServer, -} from "@/lib/chat/dedupeMessages"; -import { useTranslation } from "react-i18next"; -import LocalePageShell from "@/components/i18n/LocalePageShell"; -import { - decryptDmContent, - encryptDmContent, - E2EE_LOCKED_PREVIEW, - rememberOutboundPlaintext, - ensureIdentityKeys, -} from "@/lib/e2ee"; - -interface ITicketChatProps { - params: Promise<{ id: string; username: string }>; -} - -function normalizeMessage(raw: ChatMessage & { data?: ChatMessage }): ChatMessage { - const m = (raw as { data?: ChatMessage }).data ?? raw; - return { - ...m, - replyTo: m.replyTo ?? undefined, - }; -} - -function TicketChat({ params }: ITicketChatProps) { - const { t } = useTranslation("common"); - const resolvedParams = React.use(params); - const { request } = useAxios(); - const router = useRouter(); - const searchParams = useSearchParams(); - const { username, id: chatPartnerId } = resolvedParams; - const [newMessage, setNewMessage] = useState(""); - const [showGiftModal, setShowGiftModal] = useState(false); - const [giftResultStatus, setGiftResultStatus] = useState<"success" | "failed" | null>(null); - const [userTwoDetail, setUserTwoDetail] = useState(); - const [pendingImages, setPendingImages] = useState([]); - const [showMultiModal, setShowMultiModal] = useState(false); - const [pendingMediaPreview, setPendingMediaPreview] = useState<{ - file: File; - kind: "video" | "file"; - } | null>(null); - const [pendingMessages, setPendingMessages] = useState([]); - const [replyingTo, setReplyingTo] = useState(null); - const [forwardMessage, setForwardMessage] = useState(null); - const [selectedMessage, setSelectedMessage] = useState(null); - const [actionMode, setActionMode] = useState(false); - const [deleteMode, setDeleteMode] = useState(false); - const [selectedDeleteIds, setSelectedDeleteIds] = useState([]); - const [deleteLoading, setDeleteLoading] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const [selfDestructSeconds, setSelfDestructSeconds] = useState( - null - ); - const [viewOnceMedia, setViewOnceMedia] = useState(false); - const [socialSignupOpen, setSocialSignupOpen] = useState(false); - const pendingSendRetryRef = useRef<(() => void) | null>(null); - const user = useUser(); - const queryClient = useQueryClient(); - const { linkIds, getStableKey } = useStableMessageKeys(); - const [receiverId, setReceiverId] = useState(chatPartnerId); - const [isTyping, setIsTyping] = useState(false); - const typingTimeoutRef = useRef(null); - const activeReplyRef = useRef(null); - const scrollToMessageRef = useRef<(targetId: string) => void>(() => {}); - - const handleIncomingMessage = useCallback( - (msg: ChatMessage) => { - setPendingMessages((prev) => { - const match = findPendingMatchForServer(msg, prev); - if (match) { - linkIds(match._id, msg._id); - return prev.filter((m) => m._id !== match._id); - } - return prev; - }); - }, - [linkIds] - ); - - const confirmSentMessage = useCallback( - (tempId: string, serverMsg: ChatMessage, replyPayload?: ChatMessage["replyTo"]) => { - const sentMsg: ChatMessage = { - ...serverMsg, - status: "sent", - replyTo: serverMsg.replyTo ?? replyPayload, - }; - - const currentUserId = normalizeThreadId(user?._id ?? getStoredUserId()); - const targetReceiverId = normalizeThreadId( - userTwoDetail?._id ?? receiverId ?? chatPartnerId - ); - - linkIds(tempId, sentMsg._id); - - if (currentUserId && targetReceiverId) { - queryClient.setQueryData( - chatThreadQueryKey(currentUserId, targetReceiverId), - (oldData: ChatThreadData | undefined) => upsertMessageInThreadCache(oldData, sentMsg) - ); - } - - setPendingMessages((prev) => prev.filter((m) => m._id !== tempId)); - }, - [user?._id, userTwoDetail?._id, receiverId, chatPartnerId, linkIds, queryClient] - ); - - const handleTyping = useCallback(() => { - const socket = getChatSocket(); - if (!socket || !user?._id || !receiverId) return; - socket.emit("typing", { - senderId: user._id, - receiverId, - }); - if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); - typingTimeoutRef.current = setTimeout(() => { - getChatSocket()?.emit("stopTyping", { - senderId: user._id, - receiverId, - }); - }, 2000); - }, [user?._id, receiverId]); - - useEffect(() => { - const fetchStates = async () => { - try { - const response = await request<{ user: User }>( - "GET", - `/users/get/web?user_name=${username}&_t=${Date.now()}` - ); - setUserTwoDetail(response?.user); - } catch (err) { - console.error("Error fetching user:", err); - } - }; - fetchStates(); - }, [username, request]); - - useEffect(() => { - setReceiverId(userTwoDetail?._id || chatPartnerId); - }, [user, userTwoDetail, chatPartnerId]); - - const handleFileSelection = (e: React.ChangeEvent) => { - const picked = Array.from(e.target.files || []); - if (!picked.length) return; - const MAX_SIZE = 200 * 1024 * 1024; - const valid = picked.filter((f) => { - if (f.size > MAX_SIZE) { - toast.error(t("chats.toast.fileTooLargeNamed", { name: f.name })); - return false; - } - return true; - }); - if (!valid.length) return; - - const videos = valid.filter((f) => f.type.startsWith("video/")); - const images = valid.filter((f) => f.type.startsWith("image/")); - const others = valid.filter( - (f) => !f.type.startsWith("video/") && !f.type.startsWith("image/") - ); - - if (videos.length === 1 && images.length === 0 && others.length === 0) { - setPendingMediaPreview({ file: videos[0], kind: "video" }); - return; - } - - if (others.length === 1 && images.length === 0 && videos.length === 0) { - setPendingMediaPreview({ file: others[0], kind: "file" }); - return; - } - - if (images.length) { - setPendingImages((prev) => [...prev, ...images].slice(0, 10)); - setShowMultiModal(true); - } - }; - - const handleVoiceUpload = (blob: Blob) => { - const voiceFile = new File([blob], "voice-message.webm", { - type: "audio/webm", - }); - processSendMessage(voiceFile, "voice"); - }; - - const handleLocationShare = () => { - if (!navigator.geolocation) { - toast.error(t("chats.toast.geolocationUnsupported")); - return; - } - navigator.geolocation.getCurrentPosition( - (pos) => { - const locationContent = JSON.stringify({ - lat: pos.coords.latitude, - lng: pos.coords.longitude, - label: t("chats.locationLabel"), - }); - processSendMessage(null, "location", locationContent); - }, - () => toast.error(t("chats.toast.geolocationDenied")) - ); - }; - - const sendGiftMessage = useCallback( - (gift: { _id: string; amount: number; message: string | null }) => { - const content = JSON.stringify({ - sharedGift: { - giftId: gift._id, - amount: gift.amount, - message: gift.message, - }, - }); - void processSendMessage(null, undefined, content); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [userTwoDetail, receiverId, chatPartnerId, selfDestructSeconds, viewOnceMedia] - ); - - useEffect(() => { - const giftId = searchParams.get("giftId"); - const giftFailed = searchParams.get("giftPaymentFailed"); - if (!giftId && !giftFailed) return; - - if (giftId) { - const giftAmount = Number(searchParams.get("giftAmount") || 0); - const giftMessage = searchParams.get("giftMessage") || ""; - sendGiftMessage({ _id: giftId, amount: giftAmount, message: giftMessage || null }); - setGiftResultStatus("success"); - } else if (giftFailed) { - setGiftResultStatus("failed"); - } - - const params = new URLSearchParams(searchParams.toString()); - params.delete("giftId"); - params.delete("giftAmount"); - params.delete("giftMessage"); - params.delete("giftPaymentFailed"); - const query = params.toString(); - router.replace(`/settings/chats/${username}/${chatPartnerId}${query ? `?${query}` : ""}`); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const sendMessage = () => { - if (pendingImages.length > 0) { - void sendAllImages(); - return; - } - processSendMessage(null); - }; - - const sendAllImages = async () => { - const batch = [...pendingImages]; - setShowMultiModal(false); - setPendingImages([]); - for (const file of batch) { - await processSendMessage(file, "image"); - } - }; - - const buildReplyPayload = (source: ChatMessage | null) => { - if (!source) return undefined; - return { - _id: source._id, - content: source.content?.slice(0, 120) || t("chats.message"), - senderName: - source.senderId === user?._id - ? t("chats.you") - : `${userTwoDetail?.first_name || ""} ${userTwoDetail?.last_name || ""}`.trim(), - }; - }; - - const processSendMessage = async ( - fileToUpload: File | null, - fileType?: ChatMessage["fileType"], - contentOverride?: string - ) => { - const replySource = activeReplyRef.current; - let textContent = contentOverride ?? newMessage; - if (textContent.trim() === "" && !fileToUpload) return; - - if (userTwoDetail?.blocked_you || userTwoDetail?.is_blocked) { - toast.error(t("chats.toast.sendBlocked")); - return; - } - - const tempId = `temp-${Date.now()}-${Math.random()}`; - const replyPayload = buildReplyPayload(replySource); - - const currentUserId = normalizeThreadId(user?._id ?? getStoredUserId()); - const targetReceiverId = normalizeThreadId( - userTwoDetail?._id ?? receiverId ?? chatPartnerId - ); - - const applyViewOnce = - viewOnceMedia && fileType && isViewOnceMediaType(fileType); - - const optimisticMsg: ChatMessage = { - _id: tempId, - content: textContent, - senderId: currentUserId, - receiverId: targetReceiverId, - createdAt: new Date().toISOString(), - selfDestructSeconds: selfDestructSeconds || undefined, - status: "pending", - file: fileToUpload ? URL.createObjectURL(fileToUpload) : "", - fileType: fileType, - replyTo: replyPayload, - viewOnce: applyViewOnce || undefined, - }; - - setPendingMessages((prev) => [...prev, optimisticMsg]); - setNewMessage(""); - - let fileForUpload = fileToUpload; - if (fileToUpload && fileType !== "location") { - try { - toast.loading(t("chats.toast.optimizingFile"), { id: "media-opt" }); - fileForUpload = await optimizeMediaFile(fileToUpload); - toast.dismiss("media-opt"); - } catch { - toast.dismiss("media-opt"); - } - } - - const isFileUpload = fileForUpload && fileType !== "location"; - - try { - // ارسالِ فایل/عکس/ویس/ویدیو فعلاً بدونِ e2ee می‌ره (طبقِ تصمیمِ فعلی، رمزنگاریِ - // مدیا بعداً اضافه می‌شه) — قبلاً حتی برای همین آپلودها هم این مرحله اجباری بود - // و اگه encryptDmContent به هر دلیلی (مثلاً هنوز کلیدِ عمومیِ طرفِ مقابل واکشی - // نشده) null برمی‌گردوند، کلِ ارسالِ فایل هم با «E2EE encryption failed» شکست - // می‌خورد — دقیقاً مثلِ اندروید که از اول اصلاً این مرحله رو برای فایل صدا نمی‌زد - const sendContent = isFileUpload - ? textContent - : await (async () => { - await ensureIdentityKeys(currentUserId); - const encrypted = await encryptDmContent( - currentUserId, - targetReceiverId, - textContent - ); - if (!encrypted) throw new Error("E2EE encryption failed"); - return encrypted; - })(); - - let messagePayload: Record = { - content: sendContent, - receiverId: targetReceiverId, - }; - - if (replySource?._id && !replySource._id.startsWith("temp")) { - messagePayload.replyToId = replySource._id; - } - if (selfDestructSeconds) { - messagePayload.selfDestructSeconds = selfDestructSeconds; - } - if (fileType) { - messagePayload.fileType = fileType; - } - if (applyViewOnce) { - messagePayload.viewOnce = true; - } - - let response: ChatMessage | { data: ChatMessage }; - - if (isFileUpload) { - const formData = new FormData(); - for (const key in messagePayload) { - formData.append(key, String(messagePayload[key])); - } - // isFileUpload بالا (که این بلوک زیرِ اونه) از قبل fileForUpload رو truthy چک کرده - formData.append("file", fileForUpload as File); - - response = await request( - "post", - "/chat/file", - formData - ); - } else { - response = await request( - "POST", - "/chat", - messagePayload - ); - } - - const serverMsg = normalizeMessage(response as ChatMessage); - // همیشه متن اصلی خودمان را نگه دار — اگر decrypt fail شود نباید «رمزنگاری‌شده» ببینیم - if (sendContent && sendContent !== textContent) { - rememberOutboundPlaintext(sendContent, textContent); - } - if (serverMsg.content && serverMsg.content !== textContent) { - rememberOutboundPlaintext(String(serverMsg.content), textContent); - } - try { - const plain = await decryptDmContent( - currentUserId, - targetReceiverId, - serverMsg.content || textContent - ); - serverMsg.content = - !plain || plain === E2EE_LOCKED_PREVIEW ? textContent : plain; - } catch { - serverMsg.content = textContent; - } - confirmSentMessage(tempId, serverMsg, replyPayload); - void queryClient.invalidateQueries({ queryKey: ["messages"] }); - } catch (error) { - setPendingMessages((prev) => prev.filter((m) => m._id !== tempId)); - - const errType = (error as AxiosError<{ type?: string }>)?.response?.data - ?.type; - if (errType === "needs_username_password") { - pendingSendRetryRef.current = () => { - void processSendMessage(fileToUpload, fileType, textContent); - }; - setSocialSignupOpen(true); - return; - } - - console.error("Error sending message:", error); - toast.error(t("chats.toast.sendFailedDot")); - return; - } - - setReplyingTo(null); - activeReplyRef.current = null; - setSelfDestructSeconds(null); - if (applyViewOnce) setViewOnceMedia(false); - }; - - const handleExpirePending = useCallback((ids: string[]) => { - setPendingMessages((prev) => prev.filter((m) => !ids.includes(m._id))); - }, []); - - const openActionFor = (msg: ChatMessage) => { - setSelectedMessage(msg); - setActionMode(true); - }; - - const closeActionMode = () => { - setActionMode(false); - setSelectedMessage(null); - }; - - const startReplyFor = useCallback((msg: ChatMessage) => { - setReplyingTo(msg); - activeReplyRef.current = msg; - closeActionMode(); - }, []); - - const startReply = () => { - if (!selectedMessage) return; - startReplyFor(selectedMessage); - }; - - const startForward = () => { - if (!selectedMessage) return; - setForwardMessage(selectedMessage); - closeActionMode(); - }; - - const copySelectedMessage = async () => { - if (!selectedMessage) return; - const text = getCopyableMessageText(selectedMessage); - if (!text) { - toast.error(t("chats.toast.copyEmpty")); - return; - } - - const copied = await copyTextToClipboard(text); - if (copied) { - toast.success(t("chats.toast.copySuccess")); - closeActionMode(); - return; - } - - toast.error(t("chats.toast.copyFailed")); - }; - - const reactToMessage = async (emoji: string) => { - if (!selectedMessage?._id || selectedMessage._id.startsWith("temp-")) return; - const messageId = selectedMessage._id; - const currentUserId = normalizeThreadId(user?._id ?? getStoredUserId()); - const targetReceiverId = normalizeThreadId( - userTwoDetail?._id ?? receiverId ?? chatPartnerId - ); - - const toggleReaction = ( - reactions: ChatMessage["reactions"] = [] - ): ChatMessage["reactions"] => { - const list = [...(reactions ?? [])]; - const idx = list.findIndex((r) => String(r.userId) === currentUserId); - if (idx >= 0 && list[idx].emoji === emoji) { - list.splice(idx, 1); - } else if (idx >= 0) { - list[idx] = { userId: currentUserId, emoji }; - } else { - list.push({ userId: currentUserId, emoji }); - } - return list; - }; - - const optimisticReactions = toggleReaction(selectedMessage.reactions); - if (currentUserId && targetReceiverId) { - queryClient.setQueryData( - chatThreadQueryKey(currentUserId, targetReceiverId), - (oldData: ChatThreadData | undefined) => { - if (!oldData) return oldData; - return { - ...oldData, - pages: oldData.pages.map((page) => ({ - ...page, - messages: page.messages.map((m) => - m._id === messageId - ? { ...m, reactions: optimisticReactions } - : m - ), - })), - }; - } - ); - } - - try { - const response = await request<{ data?: ChatMessage }>( - "POST", - "/chat/reaction", - { - messageId, - emoji, - receiverId: targetReceiverId, - } - ); - const updated = response?.data; - if (updated && currentUserId && targetReceiverId) { - queryClient.setQueryData( - chatThreadQueryKey(currentUserId, targetReceiverId), - (oldData: ChatThreadData | undefined) => upsertMessageInThreadCache(oldData, updated) - ); - } - closeActionMode(); - } catch { - if (currentUserId && targetReceiverId) { - queryClient.setQueryData( - chatThreadQueryKey(currentUserId, targetReceiverId), - (oldData: ChatThreadData | undefined) => { - if (!oldData) return oldData; - return { - ...oldData, - pages: oldData.pages.map((page) => ({ - ...page, - messages: page.messages.map((m) => - m._id === messageId - ? { ...m, reactions: selectedMessage.reactions } - : m - ), - })), - }; - } - ); - } - toast.error(t("chats.toast.reactFailed")); - } - }; - - const handleBlockChange = (blocked?: boolean) => { - if (typeof blocked === "boolean") { - setUserTwoDetail((prev) => - prev ? { ...prev, is_blocked: blocked } : prev - ); - } - void request<{ user: User }>( - "GET", - `/users/get/web?user_name=${username}&_t=${Date.now()}` - ).then((res) => { - if (res?.user) setUserTwoDetail(res.user); - }); - }; - - const startDeleteMode = () => { - closeActionMode(); - setSearchQuery(""); - setDeleteMode(true); - setSelectedDeleteIds([]); - }; - - const cancelDeleteMode = () => { - setDeleteMode(false); - setSelectedDeleteIds([]); - }; - - const toggleDeleteSelect = (msg: ChatMessage) => { - setSelectedDeleteIds((prev) => - prev.includes(msg._id) - ? prev.filter((id) => id !== msg._id) - : [...prev, msg._id] - ); - }; - - const confirmDeleteMessages = async () => { - if (!selectedDeleteIds.length || !user?._id) return; - setDeleteLoading(true); - try { - const response = await request<{ deletedIds: string[]; message?: string }>( - "DELETE", - "/chat", - { messageIds: selectedDeleteIds } - ); - const deleted = new Set(response?.deletedIds || []); - setPendingMessages((prev) => prev.filter((m) => !deleted.has(m._id))); - queryClient.setQueryData( - chatThreadQueryKey( - normalizeThreadId(user._id), - normalizeThreadId(userTwoDetail?._id ?? receiverId ?? chatPartnerId) - ), - (oldData: { pages: { messages: ChatMessage[] }[] } | undefined) => { - if (!oldData) return oldData; - return { - ...oldData, - pages: oldData.pages.map((page) => ({ - ...page, - messages: page.messages.filter((m) => !deleted.has(m._id)), - })), - }; - } - ); - toast.success(response?.message || t("chats.toast.messagesDeleted")); - cancelDeleteMode(); - } catch { - toast.error(t("chats.toast.deleteMessagesFailed")); - } finally { - setDeleteLoading(false); - } - }; - - return ( - - - - { - scrollToMessageRef.current = fn; - }} - extraBottomRem={ - (replyingTo ? 2.75 : 0) + - (selfDestructSeconds != null ? 2 : 0) + - (viewOnceMedia ? 2 : 0) - } - /> - - - {deleteMode ? ( - - ) : actionMode ? ( - - ) : ( - { - setReplyingTo(null); - activeReplyRef.current = null; - }} - onReplyPreviewClick={() => { - if (replyingTo?._id) scrollToMessageRef.current(replyingTo._id); - }} - replyLabel={ - replyingTo - ? replyingTo.content?.slice(0, 80) || t("chats.message") - : undefined - } - selfDestructSeconds={selfDestructSeconds} - onSelfDestructChange={setSelfDestructSeconds} - viewOnceMedia={viewOnceMedia} - onViewOnceChange={setViewOnceMedia} - onGiftClick={() => setShowGiftModal(true)} - giftDisabled={userTwoDetail?.gift_enabled === false} - /> - )} - - - setShowGiftModal(false)} - receiverId={String(userTwoDetail?._id ?? receiverId ?? chatPartnerId)} - returnPath={`/settings/chats/${username}/${chatPartnerId}`} - onWalletGiftSent={sendGiftMessage} - /> - - setGiftResultStatus(null)} - onRetry={() => { - setGiftResultStatus(null); - setShowGiftModal(true); - }} - /> - - { - setSocialSignupOpen(false); - pendingSendRetryRef.current = null; - }} - onSuccess={() => { - setSocialSignupOpen(false); - pendingSendRetryRef.current?.(); - pendingSendRetryRef.current = null; - }} - /> - - - setPendingImages((prev) => { - const next = prev.filter((_, idx) => idx !== i); - if (next.length === 0) setShowMultiModal(false); - return next; - }) - } - onCancel={() => { - setPendingImages([]); - setShowMultiModal(false); - }} - onConfirm={sendAllImages} - viewOnceMedia={viewOnceMedia} - onViewOnceChange={setViewOnceMedia} - selfDestructSeconds={selfDestructSeconds} - onSelfDestructChange={setSelfDestructSeconds} - /> - setPendingMediaPreview(null)} - onConfirm={() => { - if (!pendingMediaPreview) return; - const { file, kind } = pendingMediaPreview; - setPendingMediaPreview(null); - void processSendMessage(file, kind === "video" ? "video" : "file"); - }} - viewOnceMedia={viewOnceMedia} - onViewOnceChange={setViewOnceMedia} - selfDestructSeconds={selfDestructSeconds} - onSelfDestructChange={setSelfDestructSeconds} - /> - {forwardMessage && user?._id && ( - setForwardMessage(null)} - message={forwardMessage} - currentUserId={user._id} - currentReceiverId={receiverId} - /> - )} - - - ); -} - -export default dynamic(() => Promise.resolve(TicketChat), { ssr: false }); +/* eslint-disable react-hooks/rules-of-hooks */ +"use client"; + +import Container from "@/components/elements/Container"; +import useAxios from "@/hooks/useAxios"; +import { User } from "@/types/types"; +import React, { useState, useEffect, useRef, useCallback } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { + getChatSocket, +} from "@/lib/chat/socketClient"; +import MessageInput from "@/components/chat/MessageInput"; +import GiftInChatModal from "@/components/chat/GiftInChatModal"; +import CompleteSocialSignupModal from "@/components/auth/CompleteSocialSignupModal"; +import { AxiosError } from "axios"; +import GiftPaymentResultModal from "@/components/chat/GiftPaymentResultModal"; +import MultiImageModal from "@/components/chat/MultiImageModal"; +import MediaPreviewModal from "@/components/chat/MediaPreviewModal"; +import ChatActionBar from "@/components/chat/ChatActionBar"; +import ChatDeleteBar from "@/components/chat/ChatDeleteBar"; +import { useUser } from "@/hooks/useUser"; +import ChatMessageList from "@/components/chat/ChatMessageList"; +import ChatHeader from "@/components/chat/ChatHeader"; +import dynamic from "next/dynamic"; +import type { ChatMessage } from "@/components/chat/ChatMessageCard"; +import toast from "react-hot-toast"; +import { optimizeMediaFile } from "@/lib/media"; +import ForwardMessageModal from "@/components/chat/ForwardMessageModal"; +import { AnimatePresence } from "framer-motion"; +import { useQueryClient } from "@tanstack/react-query"; +import { chatThreadQueryKey } from "@/lib/chat/queryKeys"; +import { normalizeThreadId, upsertMessageInThreadCache, type ChatThreadData } from "@/lib/chat/threadCache"; +import { useStableMessageKeys } from "@/hooks/useStableMessageKeys"; +import { getStoredUserId } from "@/lib/auth/session"; +import { isViewOnceMediaType } from "@/lib/chat/viewOnce"; +import { + copyTextToClipboard, + getCopyableMessageText, +} from "@/lib/chat/getCopyableMessageText"; +import { + findPendingMatchForServer, +} from "@/lib/chat/dedupeMessages"; +import { useTranslation } from "react-i18next"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; +import { + decryptDmContent, + encryptDmContent, + E2EE_LOCKED_PREVIEW, + rememberOutboundPlaintext, + ensureIdentityKeys, +} from "@/lib/e2ee"; + +interface ITicketChatProps { + params: Promise<{ id: string; username: string }>; +} + +function normalizeMessage(raw: ChatMessage & { data?: ChatMessage }): ChatMessage { + const m = (raw as { data?: ChatMessage }).data ?? raw; + return { + ...m, + replyTo: m.replyTo ?? undefined, + }; +} + +function TicketChat({ params }: ITicketChatProps) { + const { t } = useTranslation("common"); + const resolvedParams = React.use(params); + const { request } = useAxios(); + const router = useRouter(); + const searchParams = useSearchParams(); + const { username, id: chatPartnerId } = resolvedParams; + const [newMessage, setNewMessage] = useState(""); + const [showGiftModal, setShowGiftModal] = useState(false); + const [giftResultStatus, setGiftResultStatus] = useState<"success" | "failed" | null>(null); + const [userTwoDetail, setUserTwoDetail] = useState(); + const [pendingImages, setPendingImages] = useState([]); + const [showMultiModal, setShowMultiModal] = useState(false); + const [pendingMediaPreview, setPendingMediaPreview] = useState<{ + file: File; + kind: "video" | "file"; + } | null>(null); + const [pendingMessages, setPendingMessages] = useState([]); + const [replyingTo, setReplyingTo] = useState(null); + const [forwardMessage, setForwardMessage] = useState(null); + const [selectedMessage, setSelectedMessage] = useState(null); + const [actionMode, setActionMode] = useState(false); + const [deleteMode, setDeleteMode] = useState(false); + const [selectedDeleteIds, setSelectedDeleteIds] = useState([]); + const [deleteLoading, setDeleteLoading] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [selfDestructSeconds, setSelfDestructSeconds] = useState( + null + ); + const [viewOnceMedia, setViewOnceMedia] = useState(false); + const [socialSignupOpen, setSocialSignupOpen] = useState(false); + const pendingSendRetryRef = useRef<(() => void) | null>(null); + const user = useUser(); + const queryClient = useQueryClient(); + const { linkIds, getStableKey } = useStableMessageKeys(); + const [receiverId, setReceiverId] = useState(chatPartnerId); + const [isTyping, setIsTyping] = useState(false); + const typingTimeoutRef = useRef(null); + const activeReplyRef = useRef(null); + const scrollToMessageRef = useRef<(targetId: string) => void>(() => {}); + + const handleIncomingMessage = useCallback( + (msg: ChatMessage) => { + setPendingMessages((prev) => { + const match = findPendingMatchForServer(msg, prev); + if (match) { + linkIds(match._id, msg._id); + return prev.filter((m) => m._id !== match._id); + } + return prev; + }); + }, + [linkIds] + ); + + const confirmSentMessage = useCallback( + (tempId: string, serverMsg: ChatMessage, replyPayload?: ChatMessage["replyTo"]) => { + const sentMsg: ChatMessage = { + ...serverMsg, + status: "sent", + replyTo: serverMsg.replyTo ?? replyPayload, + }; + + const currentUserId = normalizeThreadId(user?._id ?? getStoredUserId()); + const targetReceiverId = normalizeThreadId( + userTwoDetail?._id ?? receiverId ?? chatPartnerId + ); + + linkIds(tempId, sentMsg._id); + + if (currentUserId && targetReceiverId) { + queryClient.setQueryData( + chatThreadQueryKey(currentUserId, targetReceiverId), + (oldData: ChatThreadData | undefined) => upsertMessageInThreadCache(oldData, sentMsg) + ); + } + + setPendingMessages((prev) => prev.filter((m) => m._id !== tempId)); + }, + [user?._id, userTwoDetail?._id, receiverId, chatPartnerId, linkIds, queryClient] + ); + + const handleTyping = useCallback(() => { + const socket = getChatSocket(); + if (!socket || !user?._id || !receiverId) return; + socket.emit("typing", { + senderId: user._id, + receiverId, + }); + if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); + typingTimeoutRef.current = setTimeout(() => { + getChatSocket()?.emit("stopTyping", { + senderId: user._id, + receiverId, + }); + }, 2000); + }, [user?._id, receiverId]); + + useEffect(() => { + const fetchStates = async () => { + try { + const response = await request<{ user: User }>( + "GET", + `/users/get/web?user_name=${username}&_t=${Date.now()}` + ); + setUserTwoDetail(response?.user); + } catch (err) { + console.error("Error fetching user:", err); + } + }; + fetchStates(); + }, [username, request]); + + useEffect(() => { + setReceiverId(userTwoDetail?._id || chatPartnerId); + }, [user, userTwoDetail, chatPartnerId]); + + const handleFileSelection = (e: React.ChangeEvent) => { + const picked = Array.from(e.target.files || []); + if (!picked.length) return; + const MAX_SIZE = 200 * 1024 * 1024; + const valid = picked.filter((f) => { + if (f.size > MAX_SIZE) { + toast.error(t("chats.toast.fileTooLargeNamed", { name: f.name })); + return false; + } + return true; + }); + if (!valid.length) return; + + const videos = valid.filter((f) => f.type.startsWith("video/")); + const images = valid.filter((f) => f.type.startsWith("image/")); + const others = valid.filter( + (f) => !f.type.startsWith("video/") && !f.type.startsWith("image/") + ); + + if (videos.length === 1 && images.length === 0 && others.length === 0) { + setPendingMediaPreview({ file: videos[0], kind: "video" }); + return; + } + + if (others.length === 1 && images.length === 0 && videos.length === 0) { + setPendingMediaPreview({ file: others[0], kind: "file" }); + return; + } + + if (images.length) { + setPendingImages((prev) => [...prev, ...images].slice(0, 10)); + setShowMultiModal(true); + } + }; + + const handleVoiceUpload = (blob: Blob) => { + const voiceFile = new File([blob], "voice-message.webm", { + type: "audio/webm", + }); + processSendMessage(voiceFile, "voice"); + }; + + const handleLocationShare = () => { + if (!navigator.geolocation) { + toast.error(t("chats.toast.geolocationUnsupported")); + return; + } + navigator.geolocation.getCurrentPosition( + (pos) => { + const locationContent = JSON.stringify({ + lat: pos.coords.latitude, + lng: pos.coords.longitude, + label: t("chats.locationLabel"), + }); + processSendMessage(null, "location", locationContent); + }, + () => toast.error(t("chats.toast.geolocationDenied")) + ); + }; + + const sendGiftMessage = useCallback( + (gift: { _id: string; amount: number; message: string | null }) => { + const content = JSON.stringify({ + sharedGift: { + giftId: gift._id, + amount: gift.amount, + message: gift.message, + }, + }); + void processSendMessage(null, undefined, content); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [userTwoDetail, receiverId, chatPartnerId, selfDestructSeconds, viewOnceMedia] + ); + + useEffect(() => { + const giftId = searchParams.get("giftId"); + const giftFailed = searchParams.get("giftPaymentFailed"); + if (!giftId && !giftFailed) return; + + if (giftId) { + const giftAmount = Number(searchParams.get("giftAmount") || 0); + const giftMessage = searchParams.get("giftMessage") || ""; + sendGiftMessage({ _id: giftId, amount: giftAmount, message: giftMessage || null }); + setGiftResultStatus("success"); + } else if (giftFailed) { + setGiftResultStatus("failed"); + } + + const params = new URLSearchParams(searchParams.toString()); + params.delete("giftId"); + params.delete("giftAmount"); + params.delete("giftMessage"); + params.delete("giftPaymentFailed"); + const query = params.toString(); + router.replace(`/settings/chats/${username}/${chatPartnerId}${query ? `?${query}` : ""}`); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const sendMessage = () => { + if (pendingImages.length > 0) { + void sendAllImages(); + return; + } + processSendMessage(null); + }; + + const sendAllImages = async () => { + const batch = [...pendingImages]; + setShowMultiModal(false); + setPendingImages([]); + for (const file of batch) { + await processSendMessage(file, "image"); + } + }; + + const buildReplyPayload = (source: ChatMessage | null) => { + if (!source) return undefined; + return { + _id: source._id, + content: source.content?.slice(0, 120) || t("chats.message"), + senderName: + source.senderId === user?._id + ? t("chats.you") + : `${userTwoDetail?.first_name || ""} ${userTwoDetail?.last_name || ""}`.trim(), + }; + }; + + const processSendMessage = async ( + fileToUpload: File | null, + fileType?: ChatMessage["fileType"], + contentOverride?: string + ) => { + const replySource = activeReplyRef.current; + let textContent = contentOverride ?? newMessage; + if (textContent.trim() === "" && !fileToUpload) return; + + if (userTwoDetail?.blocked_you || userTwoDetail?.is_blocked) { + toast.error(t("chats.toast.sendBlocked")); + return; + } + + const tempId = `temp-${Date.now()}-${Math.random()}`; + const replyPayload = buildReplyPayload(replySource); + + const currentUserId = normalizeThreadId(user?._id ?? getStoredUserId()); + const targetReceiverId = normalizeThreadId( + userTwoDetail?._id ?? receiverId ?? chatPartnerId + ); + + const applyViewOnce = + viewOnceMedia && fileType && isViewOnceMediaType(fileType); + + const optimisticMsg: ChatMessage = { + _id: tempId, + content: textContent, + senderId: currentUserId, + receiverId: targetReceiverId, + createdAt: new Date().toISOString(), + selfDestructSeconds: selfDestructSeconds || undefined, + status: "pending", + file: fileToUpload ? URL.createObjectURL(fileToUpload) : "", + fileType: fileType, + replyTo: replyPayload, + viewOnce: applyViewOnce || undefined, + }; + + setPendingMessages((prev) => [...prev, optimisticMsg]); + setNewMessage(""); + + let fileForUpload = fileToUpload; + if (fileToUpload && fileType !== "location") { + try { + toast.loading(t("chats.toast.optimizingFile"), { id: "media-opt" }); + fileForUpload = await optimizeMediaFile(fileToUpload); + toast.dismiss("media-opt"); + } catch { + toast.dismiss("media-opt"); + } + } + + const isFileUpload = fileForUpload && fileType !== "location"; + + try { + // ارسالِ فایل/عکس/ویس/ویدیو فعلاً بدونِ e2ee می‌ره (طبقِ تصمیمِ فعلی، رمزنگاریِ + // مدیا بعداً اضافه می‌شه) — قبلاً حتی برای همین آپلودها هم این مرحله اجباری بود + // و اگه encryptDmContent به هر دلیلی (مثلاً هنوز کلیدِ عمومیِ طرفِ مقابل واکشی + // نشده) null برمی‌گردوند، کلِ ارسالِ فایل هم با «E2EE encryption failed» شکست + // می‌خورد — دقیقاً مثلِ اندروید که از اول اصلاً این مرحله رو برای فایل صدا نمی‌زد + const sendContent = isFileUpload + ? textContent + : await (async () => { + await ensureIdentityKeys(currentUserId); + const encrypted = await encryptDmContent( + currentUserId, + targetReceiverId, + textContent + ); + if (encrypted === null) throw new Error("E2EE encryption failed"); + return encrypted; + })(); + + let messagePayload: Record = { + content: sendContent, + receiverId: targetReceiverId, + }; + + if (replySource?._id && !replySource._id.startsWith("temp")) { + messagePayload.replyToId = replySource._id; + } + if (selfDestructSeconds) { + messagePayload.selfDestructSeconds = selfDestructSeconds; + } + if (fileType) { + messagePayload.fileType = fileType; + } + if (applyViewOnce) { + messagePayload.viewOnce = true; + } + + let response: ChatMessage | { data: ChatMessage }; + + if (isFileUpload) { + const formData = new FormData(); + for (const key in messagePayload) { + formData.append(key, String(messagePayload[key])); + } + // isFileUpload بالا (که این بلوک زیرِ اونه) از قبل fileForUpload رو truthy چک کرده + formData.append("file", fileForUpload as File); + + response = await request( + "post", + "/chat/file", + formData + ); + } else { + response = await request( + "POST", + "/chat", + messagePayload + ); + } + + const serverMsg = normalizeMessage(response as ChatMessage); + // همیشه متن اصلی خودمان را نگه دار — اگر decrypt fail شود نباید «رمزنگاری‌شده» ببینیم + if (sendContent && sendContent !== textContent) { + rememberOutboundPlaintext(sendContent, textContent); + } + if (serverMsg.content && serverMsg.content !== textContent) { + rememberOutboundPlaintext(String(serverMsg.content), textContent); + } + try { + const plain = await decryptDmContent( + currentUserId, + targetReceiverId, + serverMsg.content || textContent + ); + serverMsg.content = + !plain || plain === E2EE_LOCKED_PREVIEW ? textContent : plain; + } catch { + serverMsg.content = textContent; + } + confirmSentMessage(tempId, serverMsg, replyPayload); + void queryClient.invalidateQueries({ queryKey: ["messages"] }); + } catch (error) { + setPendingMessages((prev) => prev.filter((m) => m._id !== tempId)); + + const errType = (error as AxiosError<{ type?: string }>)?.response?.data + ?.type; + if (errType === "needs_username_password") { + pendingSendRetryRef.current = () => { + void processSendMessage(fileToUpload, fileType, textContent); + }; + setSocialSignupOpen(true); + return; + } + + console.error("Error sending message:", error); + toast.error(t("chats.toast.sendFailedDot")); + return; + } + + setReplyingTo(null); + activeReplyRef.current = null; + setSelfDestructSeconds(null); + if (applyViewOnce) setViewOnceMedia(false); + }; + + const handleExpirePending = useCallback((ids: string[]) => { + setPendingMessages((prev) => prev.filter((m) => !ids.includes(m._id))); + }, []); + + const openActionFor = (msg: ChatMessage) => { + setSelectedMessage(msg); + setActionMode(true); + }; + + const closeActionMode = () => { + setActionMode(false); + setSelectedMessage(null); + }; + + const startReplyFor = useCallback((msg: ChatMessage) => { + setReplyingTo(msg); + activeReplyRef.current = msg; + closeActionMode(); + }, []); + + const startReply = () => { + if (!selectedMessage) return; + startReplyFor(selectedMessage); + }; + + const startForward = () => { + if (!selectedMessage) return; + setForwardMessage(selectedMessage); + closeActionMode(); + }; + + const copySelectedMessage = async () => { + if (!selectedMessage) return; + const text = getCopyableMessageText(selectedMessage); + if (!text) { + toast.error(t("chats.toast.copyEmpty")); + return; + } + + const copied = await copyTextToClipboard(text); + if (copied) { + toast.success(t("chats.toast.copySuccess")); + closeActionMode(); + return; + } + + toast.error(t("chats.toast.copyFailed")); + }; + + const reactToMessage = async (emoji: string) => { + if (!selectedMessage?._id || selectedMessage._id.startsWith("temp-")) return; + const messageId = selectedMessage._id; + const currentUserId = normalizeThreadId(user?._id ?? getStoredUserId()); + const targetReceiverId = normalizeThreadId( + userTwoDetail?._id ?? receiverId ?? chatPartnerId + ); + + const toggleReaction = ( + reactions: ChatMessage["reactions"] = [] + ): ChatMessage["reactions"] => { + const list = [...(reactions ?? [])]; + const idx = list.findIndex((r) => String(r.userId) === currentUserId); + if (idx >= 0 && list[idx].emoji === emoji) { + list.splice(idx, 1); + } else if (idx >= 0) { + list[idx] = { userId: currentUserId, emoji }; + } else { + list.push({ userId: currentUserId, emoji }); + } + return list; + }; + + const optimisticReactions = toggleReaction(selectedMessage.reactions); + if (currentUserId && targetReceiverId) { + queryClient.setQueryData( + chatThreadQueryKey(currentUserId, targetReceiverId), + (oldData: ChatThreadData | undefined) => { + if (!oldData) return oldData; + return { + ...oldData, + pages: oldData.pages.map((page) => ({ + ...page, + messages: page.messages.map((m) => + m._id === messageId + ? { ...m, reactions: optimisticReactions } + : m + ), + })), + }; + } + ); + } + + try { + const response = await request<{ data?: ChatMessage }>( + "POST", + "/chat/reaction", + { + messageId, + emoji, + receiverId: targetReceiverId, + } + ); + const updated = response?.data; + if (updated && currentUserId && targetReceiverId) { + queryClient.setQueryData( + chatThreadQueryKey(currentUserId, targetReceiverId), + (oldData: ChatThreadData | undefined) => upsertMessageInThreadCache(oldData, updated) + ); + } + closeActionMode(); + } catch { + if (currentUserId && targetReceiverId) { + queryClient.setQueryData( + chatThreadQueryKey(currentUserId, targetReceiverId), + (oldData: ChatThreadData | undefined) => { + if (!oldData) return oldData; + return { + ...oldData, + pages: oldData.pages.map((page) => ({ + ...page, + messages: page.messages.map((m) => + m._id === messageId + ? { ...m, reactions: selectedMessage.reactions } + : m + ), + })), + }; + } + ); + } + toast.error(t("chats.toast.reactFailed")); + } + }; + + const handleBlockChange = (blocked?: boolean) => { + if (typeof blocked === "boolean") { + setUserTwoDetail((prev) => + prev ? { ...prev, is_blocked: blocked } : prev + ); + } + void request<{ user: User }>( + "GET", + `/users/get/web?user_name=${username}&_t=${Date.now()}` + ).then((res) => { + if (res?.user) setUserTwoDetail(res.user); + }); + }; + + const startDeleteMode = () => { + closeActionMode(); + setSearchQuery(""); + setDeleteMode(true); + setSelectedDeleteIds([]); + }; + + const cancelDeleteMode = () => { + setDeleteMode(false); + setSelectedDeleteIds([]); + }; + + const toggleDeleteSelect = (msg: ChatMessage) => { + setSelectedDeleteIds((prev) => + prev.includes(msg._id) + ? prev.filter((id) => id !== msg._id) + : [...prev, msg._id] + ); + }; + + const confirmDeleteMessages = async () => { + if (!selectedDeleteIds.length || !user?._id) return; + setDeleteLoading(true); + try { + const response = await request<{ deletedIds: string[]; message?: string }>( + "DELETE", + "/chat", + { messageIds: selectedDeleteIds } + ); + const deleted = new Set(response?.deletedIds || []); + setPendingMessages((prev) => prev.filter((m) => !deleted.has(m._id))); + queryClient.setQueryData( + chatThreadQueryKey( + normalizeThreadId(user._id), + normalizeThreadId(userTwoDetail?._id ?? receiverId ?? chatPartnerId) + ), + (oldData: { pages: { messages: ChatMessage[] }[] } | undefined) => { + if (!oldData) return oldData; + return { + ...oldData, + pages: oldData.pages.map((page) => ({ + ...page, + messages: page.messages.filter((m) => !deleted.has(m._id)), + })), + }; + } + ); + toast.success(response?.message || t("chats.toast.messagesDeleted")); + cancelDeleteMode(); + } catch { + toast.error(t("chats.toast.deleteMessagesFailed")); + } finally { + setDeleteLoading(false); + } + }; + + return ( + + + + { + scrollToMessageRef.current = fn; + }} + extraBottomRem={ + (replyingTo ? 2.75 : 0) + + (selfDestructSeconds != null ? 2 : 0) + + (viewOnceMedia ? 2 : 0) + } + /> + + + {deleteMode ? ( + + ) : actionMode ? ( + + ) : ( + { + setReplyingTo(null); + activeReplyRef.current = null; + }} + onReplyPreviewClick={() => { + if (replyingTo?._id) scrollToMessageRef.current(replyingTo._id); + }} + replyLabel={ + replyingTo + ? replyingTo.content?.slice(0, 80) || t("chats.message") + : undefined + } + selfDestructSeconds={selfDestructSeconds} + onSelfDestructChange={setSelfDestructSeconds} + viewOnceMedia={viewOnceMedia} + onViewOnceChange={setViewOnceMedia} + onGiftClick={() => setShowGiftModal(true)} + giftDisabled={userTwoDetail?.gift_enabled === false} + /> + )} + + + setShowGiftModal(false)} + receiverId={String(userTwoDetail?._id ?? receiverId ?? chatPartnerId)} + returnPath={`/settings/chats/${username}/${chatPartnerId}`} + onWalletGiftSent={sendGiftMessage} + /> + + setGiftResultStatus(null)} + onRetry={() => { + setGiftResultStatus(null); + setShowGiftModal(true); + }} + /> + + { + setSocialSignupOpen(false); + pendingSendRetryRef.current = null; + }} + onSuccess={() => { + setSocialSignupOpen(false); + pendingSendRetryRef.current?.(); + pendingSendRetryRef.current = null; + }} + /> + + + setPendingImages((prev) => { + const next = prev.filter((_, idx) => idx !== i); + if (next.length === 0) setShowMultiModal(false); + return next; + }) + } + onCancel={() => { + setPendingImages([]); + setShowMultiModal(false); + }} + onConfirm={sendAllImages} + viewOnceMedia={viewOnceMedia} + onViewOnceChange={setViewOnceMedia} + selfDestructSeconds={selfDestructSeconds} + onSelfDestructChange={setSelfDestructSeconds} + /> + setPendingMediaPreview(null)} + onConfirm={() => { + if (!pendingMediaPreview) return; + const { file, kind } = pendingMediaPreview; + setPendingMediaPreview(null); + void processSendMessage(file, kind === "video" ? "video" : "file"); + }} + viewOnceMedia={viewOnceMedia} + onViewOnceChange={setViewOnceMedia} + selfDestructSeconds={selfDestructSeconds} + onSelfDestructChange={setSelfDestructSeconds} + /> + {forwardMessage && user?._id && ( + setForwardMessage(null)} + message={forwardMessage} + currentUserId={user._id} + currentReceiverId={receiverId} + /> + )} + + + ); +} + +export default dynamic(() => Promise.resolve(TicketChat), { ssr: false }); diff --git a/src/app/settings/chats/rooms/[roomId]/page.tsx b/src/app/settings/chats/rooms/[roomId]/page.tsx index e85817e..ac4585c 100644 --- a/src/app/settings/chats/rooms/[roomId]/page.tsx +++ b/src/app/settings/chats/rooms/[roomId]/page.tsx @@ -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); @@ -630,7 +634,7 @@ export default function ChatRoomThreadPage({ : await (async () => { await ensureIdentityKeys(senderId); const encrypted = await encryptRoomContent(senderId, roomId, text); - if (!encrypted) throw new Error("E2EE encryption failed"); + if (encrypted === null) throw new Error("E2EE encryption failed"); return encrypted; })(); diff --git a/src/app/settings/edit/password/page.tsx b/src/app/settings/edit/password/page.tsx index 178d6f2..0e39c46 100644 --- a/src/app/settings/edit/password/page.tsx +++ b/src/app/settings/edit/password/page.tsx @@ -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 diff --git a/src/app/settings/my-billboards/page.tsx b/src/app/settings/my-billboards/page.tsx index c72ab86..6ee749b 100644 --- a/src/app/settings/my-billboards/page.tsx +++ b/src/app/settings/my-billboards/page.tsx @@ -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(false); @@ -106,7 +110,9 @@ function MyBillboard() { ) : ( data?.pages?.map((page, pageIndex) => ( - {page?.advertisings?.map((item: IAdvertising) => ( + {page?.advertisings + ?.filter((item: IAdvertising) => !unpaidOnly || (item as { effective_status?: string })?.effective_status === "pending_payment") + .map((item: IAdvertising) => (
diff --git a/src/app/settings/wallet/page.tsx b/src/app/settings/wallet/page.tsx index 15e450f..b0aa041 100644 --- a/src/app/settings/wallet/page.tsx +++ b/src/app/settings/wallet/page.tsx @@ -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(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 ( - + {t("settings.nav.wallet")}
@@ -173,14 +194,6 @@ export default function WalletSettingsPage() { value={withdrawAmount} onChange={(e) => setWithdrawAmount(e.target.value)} /> - - {t("shops.requestWithdrawal")} -
)} @@ -206,14 +219,6 @@ export default function WalletSettingsPage() { value={withdrawAmount} onChange={(e) => setWithdrawAmount(e.target.value)} /> - - {t("shops.requestWithdrawal")} -
)} @@ -234,14 +239,6 @@ export default function WalletSettingsPage() { value={withdrawAmount} onChange={(e) => setWithdrawAmount(e.target.value)} /> - - {t("shops.requestWithdrawal")} - )} @@ -262,14 +259,6 @@ export default function WalletSettingsPage() { value={withdrawAmount} onChange={(e) => setWithdrawAmount(e.target.value)} /> - - {t("shops.requestWithdrawal")} - )} @@ -290,14 +279,6 @@ export default function WalletSettingsPage() { value={withdrawAmount} onChange={(e) => setWithdrawAmount(e.target.value)} /> - - {t("shops.requestWithdrawal")} - )} @@ -319,19 +300,24 @@ export default function WalletSettingsPage() { value={chargeAmount} onChange={(e) => setChargeAmount(e.target.value)} /> - - {t("shops.chargeWallet")} - )} + + {summary ? ( +
+ + {cta.label} + +
+ ) : null} ); } diff --git a/src/app/settings/wallet/payment/failed/page.tsx b/src/app/settings/wallet/payment/failed/page.tsx new file mode 100644 index 0000000..195796f --- /dev/null +++ b/src/app/settings/wallet/payment/failed/page.tsx @@ -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 ( + + + {t("shops.paymentFailedTitle")} + +
+ + {t("shops.paymentFailedHint")} +
+ +
+ {amount > 0 && ( + + {amount.toLocaleString()} {t("settings.toman")} + + )} + +
+ + {paying ? "..." : t("offerPage.paymentFailed.retry")} + + + router.push("/settings/wallet")} + className="h-9 flex-1" + > + {t("common.back")} + +
+
+
+
+ ); +} diff --git a/src/app/settings/wallet/payment/success/page.tsx b/src/app/settings/wallet/payment/success/page.tsx new file mode 100644 index 0000000..a03ace3 --- /dev/null +++ b/src/app/settings/wallet/payment/success/page.tsx @@ -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 ( + + + {t("shops.paymentSuccessTitle")} + +
+ + +

+ {t("shops.paymentSuccessHint")} +

+
+ + {amount > 0 && ( +
+ + {amount.toLocaleString()} {t("settings.toman")} + +
+ )} + +
+ ? + ? + {t("settings.confirm")} + + +
+
+
+ ); +} diff --git a/src/app/settings/workroom/page.tsx b/src/app/settings/workroom/page.tsx index a7c2e21..df8dd50 100644 --- a/src/app/settings/workroom/page.tsx +++ b/src/app/settings/workroom/page.tsx @@ -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([]); diff --git a/src/app/shops/listing/[listingId]/ListingDetailClient.tsx b/src/app/shops/listing/[listingId]/ListingDetailClient.tsx index d592f83..fdd5a65 100644 --- a/src/app/shops/listing/[listingId]/ListingDetailClient.tsx +++ b/src/app/shops/listing/[listingId]/ListingDetailClient.tsx @@ -564,7 +564,7 @@ export default function ListingDetailClient() { {showAddressModal && ( -
+

{t("shops.addressRequiredHint")}

-
+
{t("shops.retryPayment")} - router.push(shopId ? `/shops/profile/${shopId}` : "/shops/discover") + router.push("/settings/shop") } > - {t("shops.backToShopList")} + {t("common.back")}
diff --git a/src/app/shops/payment/success/page.tsx b/src/app/shops/payment/success/page.tsx index 31f1583..1fcfe98 100644 --- a/src/app/shops/payment/success/page.tsx +++ b/src/app/shops/payment/success/page.tsx @@ -32,17 +32,9 @@ export default function ShopPaymentSuccessPage() { - router.push(orderId ? `/shops/orders/${orderId}` : "/settings/shop") - } + onClick={() => router.push("/settings/shop")} > - {t("shops.viewOrder")} - - router.push("/")} - > - {t("shops.backToHome")} + {t("settings.confirm")}
diff --git a/src/components/auth/CompleteSocialSignupModal.tsx b/src/components/auth/CompleteSocialSignupModal.tsx index df2aba3..247282b 100644 --- a/src/components/auth/CompleteSocialSignupModal.tsx +++ b/src/components/auth/CompleteSocialSignupModal.tsx @@ -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) { diff --git a/src/components/chat/ForwardMessageModal.tsx b/src/components/chat/ForwardMessageModal.tsx index e1f8706..14d0993 100644 --- a/src/components/chat/ForwardMessageModal.tsx +++ b/src/components/chat/ForwardMessageModal.tsx @@ -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, diff --git a/src/components/chat/MessageInput.tsx b/src/components/chat/MessageInput.tsx index 45aa34a..d2f9606 100644 --- a/src/components/chat/MessageInput.tsx +++ b/src/components/chat/MessageInput.tsx @@ -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(null); const audioChunksRef = useRef([]); const timerRef = useRef(null); const streamRef = useRef(null); const dragStartYRef = useRef(null); + const LOCK_THRESHOLD_PX = 80; + const attachBtnRef = useRef(null); const imageInputRef = useRef(null); const videoInputRef = useRef(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) => { 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) => { + const handleMicPointerDown = ( + e: React.PointerEvent + ) => { if (!hasText && !blocked_you) { dragStartYRef.current = e.clientY; + setIsLocked(false); setDragProgress(0); + e.currentTarget.setPointerCapture(e.pointerId); + startRecording(); } }; - const handleMicPointerMove = (e: React.PointerEvent) => { - if (!isRecording || isLocked || dragStartYRef.current == null) return; + const handleMicPointerMove = ( + e: React.PointerEvent + ) => { + 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; @@ -224,7 +287,9 @@ const MessageInput = ({ return (
{(replyLabel || @@ -237,6 +302,7 @@ const MessageInput = ({ {t("chats.mediaOptions")} +
{onSelfDestructChange && ( )} + {onViewOnceChange && (
)} + {replyLabel && ( )} + {selfDestructSeconds != null && isChatThread && (
- + + {t("chats.timedBanner", { - duration: formatTimedLabel(selfDestructSeconds, t), + duration: formatTimedLabel( + selfDestructSeconds, + t + ), })}
)} + {viewOnceMedia && isChatThread && (
- {t("chats.viewOnceBadge")} + + {t("chats.viewOnceBadge")} + + {t("chats.viewOnceBanner", { label: viewOnceLabel("image", t), @@ -306,9 +388,16 @@ const MessageInput = ({ )}
)} + + {/* + مهم: + این ردیف همان max-w-lg هدر را دارد. + min-w-0 روی ردیف و کادر پیام باعث می‌شود + در موبایل از عرض تعیین‌شده بیرون نزند. + */}
+ setMenuOpen(false)} onSelect={handleAttachmentSelect} anchorRef={attachBtnRef} /> + {onGiftClick && ( )}
{/* Glass input box */}
+ + {isRecording ? ( -
-
- +
+
+ + + {formatTime(recordingTime)} + {!isLocked && ( - + {t("chats.voice.slideUpToLock")} )} + {isLocked && ( - + )}
-
+ +
+ {isLocked && ( )}
@@ -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" /> )}
@@ -455,21 +581,42 @@ const MessageInput = ({ style={chatHeaderGlassStyle} aria-label={t("chats.aria.send")} > - + ) : ( - + {isRecording && !isLocked && (
- + - + +
)} + 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 ? : } + {isRecording ? ( + + ) : ( + + )}
)} @@ -497,4 +667,4 @@ const MessageInput = ({ ); }; -export default MessageInput; +export default MessageInput; \ No newline at end of file diff --git a/src/components/explore/ExploreReelsView.tsx b/src/components/explore/ExploreReelsView.tsx index 78d04cd..f2ca129 100644 --- a/src/components/explore/ExploreReelsView.tsx +++ b/src/components/explore/ExploreReelsView.tsx @@ -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", }); }} /> diff --git a/src/components/models/InfinitePosts.tsx b/src/components/models/InfinitePosts.tsx index 9f4489c..f81ec8c 100644 --- a/src/components/models/InfinitePosts.tsx +++ b/src/components/models/InfinitePosts.tsx @@ -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 ); diff --git a/src/components/models/MainModelCard/MainModelCard.tsx b/src/components/models/MainModelCard/MainModelCard.tsx index f9e6861..e7d9633 100644 --- a/src/components/models/MainModelCard/MainModelCard.tsx +++ b/src/components/models/MainModelCard/MainModelCard.tsx @@ -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(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) => { + const social = usePostSocialState(postData); + const singleTapTimerRef = useRef(null); + const [likeBurstKey, setLikeBurstKey] = useState(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({
{ @@ -401,14 +410,6 @@ function MainModelCard({ {mediaType === "video" && videoSrc && (
)}
- + {caption?.includes("@") && (
@@ -472,20 +478,6 @@ function MainModelCard({ )}
); diff --git a/src/components/models/MainModelCard/MainModelCardActions.tsx b/src/components/models/MainModelCard/MainModelCardActions.tsx index 5ed9d2d..968c625 100644 --- a/src/components/models/MainModelCard/MainModelCardActions.tsx +++ b/src/components/models/MainModelCard/MainModelCardActions.tsx @@ -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; +}) { 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(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 diff --git a/src/components/posts/PostFeedView.tsx b/src/components/posts/PostFeedView.tsx index c7c1dbb..bb26bae 100644 --- a/src/components/posts/PostFeedView.tsx +++ b/src/components/posts/PostFeedView.tsx @@ -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", }); }} /> diff --git a/src/components/posts/ReelsPostCard.tsx b/src/components/posts/ReelsPostCard.tsx index a906955..3a0153e 100644 --- a/src/components/posts/ReelsPostCard.tsx +++ b/src/components/posts/ReelsPostCard.tsx @@ -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(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) => { - 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} >
{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 && ( + + )} + {imageLoading && isActive && !commentsOpen && } {likeBurstKey ? ( @@ -622,7 +622,7 @@ export default function ReelsPostCard({
{showLongVideoBar && ( -
+

{formatVideoTime(remaining)}

@@ -638,11 +638,11 @@ export default function ReelsPostCard({ >
@@ -674,7 +674,7 @@ export default function ReelsPostCard({
-
+
@@ -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) && ( !open && setCommentsOpen(false)} diff --git a/src/components/projects/Workroom/ProjectRequestsAction.tsx b/src/components/projects/Workroom/ProjectRequestsAction.tsx index 7748c2e..995168b 100644 --- a/src/components/projects/Workroom/ProjectRequestsAction.tsx +++ b/src/components/projects/Workroom/ProjectRequestsAction.tsx @@ -201,7 +201,7 @@ function ProjectRequestsAction({ {viewerUrl ? (
setViewerUrl(null)} >