Compare commits
3 Commits
9fb454b0a7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
551260a836 | ||
|
|
f364f989ed | ||
|
|
d6386b779f |
Submodule .claude/worktrees/agent-af1dfc8fe9a6cb198 deleted from 3b89bbfec3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modstagram-next",
|
||||
"version": "4.2.0",
|
||||
"version": "4.4.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack --port 3004",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import AuthHead from "@/components/auth/AuthHead";
|
||||
import AuthPageLayout, {
|
||||
AuthFormFooter,
|
||||
AuthPageContent,
|
||||
} from "@/components/auth/AuthPageLayout";
|
||||
import React, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useRouter } from "next/navigation";
|
||||
import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
@@ -14,6 +14,7 @@ import toast from "react-hot-toast";
|
||||
import { getSafeRedirectPath } from "@/lib/auth/postLogin";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { IProfileData } from "@/types/types";
|
||||
|
||||
function RegisterCompletePage() {
|
||||
@@ -21,6 +22,16 @@ function RegisterCompletePage() {
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const [action, setAction] = useState<"app" | "continue" | null>(null);
|
||||
const [user, setUser] = useState<IProfileData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ user: IProfileData }>("GET", "/profile")
|
||||
.then((res) => setUser(res?.user ?? null))
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const name = [user?.first_name, user?.last_name].filter(Boolean).join(" ");
|
||||
|
||||
// مطابق درخواست: عکس پروفایل حالا زودتر، توی خودِ صفحهی register/avatar گرفته
|
||||
// میشه (قبل از این صفحه) — پس دیگه لازم نیست ویزارد تایید هویت با verify/avatar
|
||||
@@ -49,7 +60,10 @@ function RegisterCompletePage() {
|
||||
if (next === "continue") {
|
||||
await goToVerifyWizard();
|
||||
} else {
|
||||
router.push(getSafeRedirectPath());
|
||||
// navigation سخت — همون دلیلِ Logout.tsx: router.push ممکنه نسخهی
|
||||
// کششدهی فید رو از زمانی که هنوز مهمان بودیم (قبل از تکمیلِ ثبتنام،
|
||||
// ریدایرکتشده به ورود) دوباره نشون بده، بدون اجرای دوبارهی میدلور
|
||||
window.location.href = getSafeRedirectPath();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(
|
||||
@@ -65,32 +79,53 @@ function RegisterCompletePage() {
|
||||
<AuthPageLayout>
|
||||
<div className="flex w-full flex-1 flex-col items-center">
|
||||
<AuthPageContent>
|
||||
<AuthHead title={t("auth.registerCompleteTitle")} />
|
||||
<p className="text-sm text-gray-500 text-center max-w-sm mt-2">
|
||||
<div className="flex flex-col items-center">
|
||||
<Image
|
||||
src={
|
||||
user?.profile_image
|
||||
? IMAGE_BASE_URL + user.profile_image
|
||||
: "/images/fake-avatar.png"
|
||||
}
|
||||
alt=""
|
||||
width={72}
|
||||
height={72}
|
||||
unoptimized
|
||||
className="mb-2 h-[72px] w-[72px] rounded-full object-cover bg-gray-100"
|
||||
/>
|
||||
{name ? (
|
||||
<span className="text-base font-bold text-foreground">
|
||||
{name}
|
||||
</span>
|
||||
) : null}
|
||||
{user?.user_name ? (
|
||||
<span className="text-sm text-gray-500">{user.user_name}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 text-center max-w-sm mt-6">
|
||||
{t("auth.registerCompleteDesc")}
|
||||
</p>
|
||||
</AuthPageContent>
|
||||
|
||||
<AuthFormFooter>
|
||||
<div className="flex flex-col sm:flex-row gap-3 w-full max-w-sm justify-center items-center">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => finishRegistration("continue")}
|
||||
className="!border-[#16a34a] !text-[#16a34a] w-full sm:w-auto"
|
||||
disabled={loading}
|
||||
loading={loading && action === "continue"}
|
||||
>
|
||||
{t("auth.continueRegistration")}
|
||||
</AuthNextButton>
|
||||
<div className="flex flex-row gap-3 w-full max-w-sm justify-center items-center">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => finishRegistration("app")}
|
||||
className="!border-[#FF0000] !text-[#FF0000] w-full sm:w-auto"
|
||||
className="!border-[#16a34a] !text-[#16a34a] flex-1"
|
||||
disabled={loading}
|
||||
loading={loading && action === "app"}
|
||||
>
|
||||
{t("auth.enterApp")}
|
||||
</AuthNextButton>
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
onClick={() => finishRegistration("continue")}
|
||||
className="!border-[#FF0000] !text-[#FF0000] flex-1"
|
||||
disabled={loading}
|
||||
loading={loading && action === "continue"}
|
||||
>
|
||||
{t("auth.continueRegistration")}
|
||||
</AuthNextButton>
|
||||
</div>
|
||||
</AuthFormFooter>
|
||||
</div>
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -23,6 +23,7 @@ function FailedProject() {
|
||||
const [project, setProject] = useState<Project>();
|
||||
const [typeList, setTypeList] = useState<IProjectType[] | null>(null);
|
||||
const [price, setPrice] = useState("");
|
||||
const [paying, setPaying] = useState(false);
|
||||
|
||||
const getDisplayTypeLabel = (projectType?: string) => {
|
||||
if (projectType === "normal") {
|
||||
@@ -68,6 +69,9 @@ function FailedProject() {
|
||||
}, [project, typeList]);
|
||||
|
||||
const payHandler = async () => {
|
||||
// قبلاً هیچ گاردی نداشت — دابلکلیک دو درخواستِ initiate-payment-web همزمان میفرستاد
|
||||
if (paying) return;
|
||||
setPaying(true);
|
||||
try {
|
||||
const response = await request<{ authority?: string; type?: string }>(
|
||||
"POST",
|
||||
@@ -88,6 +92,7 @@ function FailedProject() {
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -108,7 +113,7 @@ function FailedProject() {
|
||||
</div>
|
||||
<div className="px-4 w-full text-sm md:text-md font-semibold mt-10">
|
||||
{project && <MainProjectCard project={project} />}
|
||||
<div className="flex flex-col items-center gap-4 mt-4">
|
||||
<div className="mt-6 flex w-full max-w-sm flex-row items-center justify-center gap-3">
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
{getDisplayTypeLabel(project?.project_type)}:{" "}
|
||||
{Number(price).toLocaleString()} {t("settings.toman")}
|
||||
@@ -122,8 +127,9 @@ function FailedProject() {
|
||||
onClick={() => {
|
||||
payHandler();
|
||||
}}
|
||||
disabled={paying}
|
||||
variant="primary"
|
||||
className="w-32 h-9"
|
||||
className="flex-1 h-9"
|
||||
>
|
||||
{t("academy.payment.retry")}
|
||||
</RoundedButton>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Suspense, cache } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import ProjectDetailClient from "./ProjectDetailClient";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
import GuestAuthPromptModal from "@/components/auth/GuestAuthPromptModal";
|
||||
import { generatePageMetadata } from "@/utils/generatePageMetadata";
|
||||
import { fetchProject } from "@/api/fetchProject";
|
||||
import {
|
||||
@@ -67,6 +68,7 @@ export default async function ProjectDetailPage({
|
||||
<Suspense fallback={<PageLoader className="min-h-[50vh]" />}>
|
||||
<ProjectDetailClient id={id} />
|
||||
</Suspense>
|
||||
<GuestAuthPromptModal />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
15
src/app/(projects)/projects/loading.tsx
Normal file
15
src/app/(projects)/projects/loading.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import IOSSpinner from "@/components/ui/IOSSpinner";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
import { translateCommon } from "@/lib/i18n/translate";
|
||||
|
||||
export default async function ProjectsLoading() {
|
||||
const lang = await getServerLanguage();
|
||||
const label = translateCommon(lang, "common.loading");
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[50vh] w-full max-w-xl flex-col items-center justify-center gap-3 px-4">
|
||||
<IOSSpinner size={28} color="#ff107d" />
|
||||
<span className="text-sm text-neutral-500">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import useAxios from "@/hooks/useAxios";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import RoundedDiv from "@/components/elements/RoundedDiv";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import MainProjectCard from "@/components/projects/MainProjectCard";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -18,11 +18,13 @@ function FailedProject() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const projectId = searchParams.get("projectId");
|
||||
|
||||
const [project, setProject] = useState<Project>();
|
||||
const [typeList, setTypeList] = useState<IProjectType[] | null>(null);
|
||||
const [price, setPrice] = useState("");
|
||||
const [paying, setPaying] = useState(false);
|
||||
|
||||
const getDisplayTypeLabel = (projectType?: string) => {
|
||||
if (projectType === "normal" || projectType === "free") {
|
||||
@@ -68,6 +70,9 @@ function FailedProject() {
|
||||
}, [project, typeList]);
|
||||
|
||||
const payHandler = async () => {
|
||||
// قبلاً هیچ گاردی نداشت — دابلکلیک دو درخواستِ initiate-payment-web همزمان میفرستاد
|
||||
if (paying) return;
|
||||
setPaying(true);
|
||||
try {
|
||||
const response = await request<{
|
||||
authority?: string;
|
||||
@@ -88,12 +93,14 @@ function FailedProject() {
|
||||
window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`;
|
||||
} else {
|
||||
toast.error(response?.message || t("projects.payment.initFailed"));
|
||||
setPaying(false);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const axiosErr = error as { response?: { data?: { message?: string } } };
|
||||
toast.error(
|
||||
axiosErr?.response?.data?.message || t("projects.payment.initFailed")
|
||||
);
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -114,7 +121,7 @@ function FailedProject() {
|
||||
</div>
|
||||
<div className="mt-10 w-full px-4 text-sm font-semibold md:text-base">
|
||||
{project && <MainProjectCard project={project} />}
|
||||
<div className="mt-4 flex flex-col items-center gap-4">
|
||||
<div className="mt-6 flex w-full max-w-sm flex-row items-center justify-center gap-3">
|
||||
<RoundedDiv className="w-full p-2">
|
||||
{getDisplayTypeLabel(project?.project_type)}:{" "}
|
||||
{Number(price).toLocaleString()} {t("settings.toman")}
|
||||
@@ -126,11 +133,18 @@ function FailedProject() {
|
||||
</RoundedDiv>
|
||||
<RoundedButton
|
||||
onClick={payHandler}
|
||||
disabled={paying}
|
||||
variant="primary"
|
||||
className="h-9 w-32"
|
||||
className="h-9 flex-1"
|
||||
>
|
||||
{t("projects.payment.retry")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
onClick={() => router.push("/settings/workroom?filter=unpaid")}
|
||||
className="h-9 flex-1"
|
||||
>
|
||||
{t("common.back")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -8,7 +8,6 @@ import RoundedDiv from "@/components/elements/RoundedDiv";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import MainProjectCard from "@/components/projects/MainProjectCard";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
@@ -124,11 +123,13 @@ function SuccessProject() {
|
||||
</RoundedDiv>
|
||||
)}
|
||||
<p className="my-5">{t("projects.payment.reviewNotice")}</p>
|
||||
<Link href={"/settings/workroom"}>
|
||||
<RoundedButton variant="primary" className="h-9 w-32">
|
||||
{t("projects.payment.workroom")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-9 w-32"
|
||||
onClick={() => (window.location.href = projectId ? `/projects/${projectId}` : "/settings/workroom")}
|
||||
>
|
||||
{t("settings.confirm")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BASE_URL, IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import BillboardDetailContent from "@/components/billboards/BillboardDetailContent";
|
||||
import GuestAuthPromptModal from "@/components/auth/GuestAuthPromptModal";
|
||||
import { IAdvertising, IRate } from "@/types/types";
|
||||
import { cookies } from "next/headers";
|
||||
import { cache } from "react";
|
||||
@@ -135,6 +136,7 @@ async function BillboardPage({ params }: IBillboardProps) {
|
||||
}}
|
||||
/>
|
||||
<BillboardDetailContent billboard={billboard} rate={rate} />
|
||||
<GuestAuthPromptModal />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ function BillboardPayment({ params }: IBillboardProps) {
|
||||
const [advertising, setAdvertising] = useState<IAdvertising>();
|
||||
const [typeList, setTypeList] = useState<IAdvertisingType[] | null>(null);
|
||||
const [price, setPrice] = useState("");
|
||||
const [paying, setPaying] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAd = async () => {
|
||||
@@ -50,6 +51,9 @@ function BillboardPayment({ params }: IBillboardProps) {
|
||||
}, [id, request]);
|
||||
|
||||
const payHandler = async () => {
|
||||
// قبلاً هیچ گاردی نداشت — دابلکلیک دو درخواستِ initiate-payment-web همزمان میفرستاد
|
||||
if (paying) return;
|
||||
setPaying(true);
|
||||
try {
|
||||
const response = await request<{ authority?: string }>(
|
||||
"POST",
|
||||
@@ -72,6 +76,7 @@ function BillboardPayment({ params }: IBillboardProps) {
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -103,6 +108,7 @@ function BillboardPayment({ params }: IBillboardProps) {
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
onClick={payHandler}
|
||||
disabled={paying}
|
||||
className="h-9 w-32"
|
||||
>
|
||||
{t("billboards.payment.title")}
|
||||
|
||||
@@ -27,6 +27,7 @@ function FailedBillboard() {
|
||||
const price = searchParams.get("price") || "0";
|
||||
|
||||
const [advertising, setAdvertising] = useState<IAdvertising>();
|
||||
const [paying, setPaying] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAd = async () => {
|
||||
@@ -39,7 +40,11 @@ function FailedBillboard() {
|
||||
fetchAd();
|
||||
}, [projectId, request]);
|
||||
|
||||
// قبلاً هیچکدومِ این دو تابع گاردِ submitting نداشتن — دابلکلیکِ «تلاشِ دوباره»
|
||||
// دو درخواستِ پرداختِ همزمان میفرستاد
|
||||
const payHandler = async () => {
|
||||
if (paying) return;
|
||||
setPaying(true);
|
||||
try {
|
||||
const response = await request<{ authority?: string }>(
|
||||
"POST",
|
||||
@@ -62,10 +67,13 @@ function FailedBillboard() {
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const republishHandler = async () => {
|
||||
if (paying) return;
|
||||
setPaying(true);
|
||||
try {
|
||||
const response = await request<{ authority?: string }>(
|
||||
"POST",
|
||||
@@ -88,6 +96,7 @@ function FailedBillboard() {
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(error);
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -108,7 +117,7 @@ function FailedBillboard() {
|
||||
</div>
|
||||
<div className="px-4 w-full text-sm md:text-md font-semibold mt-10">
|
||||
{advertising && <MainBillboardCard billboard={advertising} />}
|
||||
<div className="flex flex-col items-center gap-4 mt-4">
|
||||
<div className="mt-6 flex w-full max-w-sm flex-row items-center justify-center gap-3">
|
||||
<RoundedDiv className="p-2 w-full">
|
||||
{getBillboardDisplayTypeLabel(t, advertising?.type)}:{" "}
|
||||
{Number(price).toLocaleString()} {t("settings.toman")}
|
||||
@@ -126,11 +135,18 @@ function FailedBillboard() {
|
||||
payHandler();
|
||||
}
|
||||
}}
|
||||
disabled={paying}
|
||||
variant="primary"
|
||||
className="w-32 h-9"
|
||||
className="flex-1 h-9"
|
||||
>
|
||||
{t("billboards.payment.retry")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
onClick={() => router.push("/settings/my-billboards?filter=unpaid")}
|
||||
className="flex-1 h-9"
|
||||
>
|
||||
{t("common.back")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -12,7 +12,6 @@ import PageTitle from "@/components/settings/PageTitle";
|
||||
import { getBillboardDisplayTypeLabel } from "@/lib/billboards/displayTypeLabel";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function SuccessBillboard() {
|
||||
@@ -59,11 +58,13 @@ function SuccessBillboard() {
|
||||
{Number(price).toLocaleString()} {t("settings.toman")}
|
||||
</RoundedDiv>
|
||||
<p className="my-5">{t("billboards.payment.registeredMessage")}</p>
|
||||
<Link href={"/settings/my-billboards"}>
|
||||
<RoundedButton variant="primary" className="w-32 h-9">
|
||||
{t("billboards.payment.myBillboards")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="w-32 h-9"
|
||||
onClick={() => (window.location.href = projectId ? `/settings/my-billboards/${projectId}/billboard` : "/settings/my-billboards")}
|
||||
>
|
||||
{t("settings.confirm")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
buildPostSeoTitle,
|
||||
} from "@/lib/postSlug";
|
||||
import ExploreReelClient from "./ExploreReelClient";
|
||||
import GuestAuthPromptModal from "@/components/auth/GuestAuthPromptModal";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
import { translateCommon } from "@/lib/i18n/translate";
|
||||
|
||||
@@ -44,5 +45,10 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
|
||||
export default async function ExploreReelPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
return <ExploreReelClient id={id} />;
|
||||
return (
|
||||
<>
|
||||
<ExploreReelClient id={id} />
|
||||
<GuestAuthPromptModal />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,14 @@ import Container from "@/components/elements/Container";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function GiftPaymentFailed() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const receiverId = searchParams.get("receiverId");
|
||||
|
||||
@@ -28,19 +29,17 @@ function GiftPaymentFailed() {
|
||||
/>
|
||||
<span>{t("giftPage.paymentFailed.message")}</span>
|
||||
</div>
|
||||
<div className="mt-8 grid grid-cols-2 gap-4">
|
||||
<div className="mt-6 flex w-full max-w-sm flex-row items-center justify-center gap-3">
|
||||
{receiverId ? (
|
||||
<Link href={`/gift/${receiverId}`}>
|
||||
<RoundedButton variant="primary" className="h-9 w-32">
|
||||
<Link href={`/gift/${receiverId}`} className="flex-1">
|
||||
<RoundedButton variant="primary" className="h-9 w-full">
|
||||
{t("giftPage.paymentFailed.retry")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
) : null}
|
||||
<Link href="/">
|
||||
<RoundedButton variant="primary" className="h-9 w-32">
|
||||
{t("giftPage.paymentFailed.back")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
<RoundedButton variant="default" className="h-9 flex-1" onClick={() => router.push(receiverId ? `/gift/${receiverId}` : "/")}>
|
||||
{t("giftPage.paymentFailed.back")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
|
||||
@@ -182,6 +182,14 @@
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
/* بدون این، Chrome روی گوشیهایی که «Force dark برای محتوای وب» روشنه (پیشفرض بعضی
|
||||
بیلدهای شیائومی/سامسونگ) فکر میکنه این سایت از تم تیره پشتیبانی نمیکنه و خودش
|
||||
رنگهای input/text رو الگوریتمی معکوس میکنه — دقیقاً همون چیزی که باعث میشد نام/
|
||||
نامخانوادگی توی تم تیره ناخوانا بشه. اعلامِ صریحِ color-scheme این رفتار رو خاموش میکنه */
|
||||
color-scheme: light;
|
||||
}
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
direction: rtl;
|
||||
@@ -641,11 +649,17 @@ select {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* env(safe-area-inset-bottom) روی اندروید (برخلافِ iOS) تقریباً همیشه 0px
|
||||
برمیگرده، حتی وقتی نوار جستوجوی سیستم/نشانگرِ ژستِ اندروید همون چند پیکسلِ
|
||||
پایینِ صفحه رو اشغال کرده — یعنی قبلاً تکیهی تنها به env() باعث میشد دکمهی
|
||||
ارسال/ویس روی خیلی از گوشیهای اندروید دقیقاً روی لبهی پایینیِ صفحه بشینه و
|
||||
زیرِ نوارِ سیستم بره. max() یه حداقلِ ثابت (که این نوارها رو پوشش میده) رو
|
||||
تضمین میکنه، مستقل از اینکه env() چی برگردونه */
|
||||
.chat-input-area {
|
||||
padding-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));
|
||||
padding-bottom: max(1.5rem, calc(0.75rem + env(safe-area-inset-bottom, 0px)));
|
||||
}
|
||||
.chat-input-area--thread {
|
||||
padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
|
||||
padding-bottom: max(1.5rem, calc(1rem + env(safe-area-inset-bottom, 0px)));
|
||||
}
|
||||
|
||||
.ig-dm-send-btn {
|
||||
@@ -801,22 +815,20 @@ select {
|
||||
}
|
||||
|
||||
.glass-modal-overlay {
|
||||
background: rgba(8, 8, 10, 0.52);
|
||||
backdrop-filter: blur(20px) saturate(120%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(120%);
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
.dark .glass-modal-overlay {
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
backdrop-filter: blur(22px) saturate(110%);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(110%);
|
||||
background: transparent;
|
||||
}
|
||||
.glass-modal-overlay--dark {
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
backdrop-filter: blur(24px) saturate(110%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(110%);
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
.dark .glass-modal-overlay--dark {
|
||||
background: rgba(0, 0, 0, 0.82);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.glass-modal-panel {
|
||||
|
||||
@@ -1,125 +1,127 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import { defaultSEOConfig } from "@/config/seoConfig";
|
||||
import { SITE_URL } from "@/config/site";
|
||||
import { getSiteSeoMeta, getSeoPage, buildHreflangAlternates } from "@/lib/i18n/seo";
|
||||
import { getLanguageDefinition } from "@/lib/i18n/registry";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
import SiteJsonLd from "@/components/seo/SiteJsonLd";
|
||||
import Script from 'next/script';
|
||||
import "./globals.css";
|
||||
import Layout from "@/components/Layout";
|
||||
import RegisterSW from "@/components/RegisterSW";
|
||||
import PushNotificationListener from "@/components/PushNotificationListener";
|
||||
import VpnWarningGate from "@/components/VpnWarningGate";
|
||||
import ClientErrorBoundary from "@/components/ClientErrorBoundary";
|
||||
|
||||
const iranSansFont = localFont({
|
||||
src: "../../public/fonts/IRANSansX-Regular.woff",
|
||||
display: "swap",
|
||||
fallback: ["Tahoma", "Arial", "sans-serif"],
|
||||
});
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const lang = await getServerLanguage();
|
||||
const site = getSiteSeoMeta(lang);
|
||||
const home = getSeoPage("home", lang);
|
||||
|
||||
return {
|
||||
title: {
|
||||
default: home.title || site.name,
|
||||
template: `%s | ${site.titleSuffix}`,
|
||||
},
|
||||
description: home.description,
|
||||
metadataBase: new URL(SITE_URL),
|
||||
keywords: site.keywords,
|
||||
authors: [{ name: "Modstagram", url: SITE_URL }],
|
||||
creator: "Modstagram",
|
||||
alternates: buildHreflangAlternates(home.path || "/", lang),
|
||||
manifest: "/manifest.webmanifest",
|
||||
applicationName: site.name,
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.png", type: "image/png", sizes: "32x32" },
|
||||
{ url: "/images/icons/192x192.png", type: "image/png", sizes: "192x192" },
|
||||
],
|
||||
shortcut: ["/favicon.png"],
|
||||
apple: [
|
||||
{ url: "/images/icons/apple-touch-icon.png", sizes: "180x180" },
|
||||
{ url: "/images/icons/192x192.png", sizes: "192x192" },
|
||||
],
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: site.name,
|
||||
},
|
||||
openGraph: {
|
||||
siteName: site.name,
|
||||
locale: lang === "fa" ? "fa_IR" : "en_US",
|
||||
type: "website",
|
||||
images: defaultSEOConfig.openGraph?.images || [],
|
||||
},
|
||||
robots: "index, follow",
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
creator: "@modstagram",
|
||||
site: "@modstagram",
|
||||
images: defaultSEOConfig.openGraph?.images?.map((img) => img.url) || [],
|
||||
},
|
||||
verification: {
|
||||
google: "yVjB5yKPchPUtxl33GWVyMvjH6wCEInqEvaH7ZsXXMo",
|
||||
},
|
||||
other: {
|
||||
"msapplication-TileColor": "#FF107D",
|
||||
samandehi: "522190795",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
viewportFit: "cover",
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
|
||||
],
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const lang = await getServerLanguage();
|
||||
const def = getLanguageDefinition(lang);
|
||||
|
||||
return (
|
||||
<html lang={def.htmlLang} dir={def.direction} suppressHydrationWarning>
|
||||
<body className={`${iranSansFont.className} antialiased`}>
|
||||
<SiteJsonLd />
|
||||
<RegisterSW />
|
||||
<PushNotificationListener />
|
||||
<VpnWarningGate />
|
||||
<ClientErrorBoundary>
|
||||
<Layout initialLanguage={lang}>{children}</Layout>
|
||||
</ClientErrorBoundary>
|
||||
{/* ====================== گوگل آنالیتیکس ====================== */}
|
||||
<Script
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-GT0QQX6F97"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
<Script id="google-analytics" strategy="afterInteractive">
|
||||
{`
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'G-GT0QQX6F97');
|
||||
`}
|
||||
</Script>
|
||||
{/* ======================================================== */} </body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import { defaultSEOConfig } from "@/config/seoConfig";
|
||||
import { SITE_URL } from "@/config/site";
|
||||
import { getSiteSeoMeta, getSeoPage, buildHreflangAlternates } from "@/lib/i18n/seo";
|
||||
import { getLanguageDefinition } from "@/lib/i18n/registry";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
import SiteJsonLd from "@/components/seo/SiteJsonLd";
|
||||
import { toPwaLang } from "@/lib/i18n/pwaLang";
|
||||
import { PWA_VERSION } from "@/lib/pwaConfig";
|
||||
import Script from 'next/script';
|
||||
import "./globals.css";
|
||||
import Layout from "@/components/Layout";
|
||||
import RegisterSW from "@/components/RegisterSW";
|
||||
import PushNotificationListener from "@/components/PushNotificationListener";
|
||||
import VpnWarningGate from "@/components/VpnWarningGate";
|
||||
import ClientErrorBoundary from "@/components/ClientErrorBoundary";
|
||||
|
||||
const iranSansFont = localFont({
|
||||
src: "../../public/fonts/IRANSansX-Regular.woff",
|
||||
display: "swap",
|
||||
fallback: ["Tahoma", "Arial", "sans-serif"],
|
||||
});
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const lang = await getServerLanguage();
|
||||
const site = getSiteSeoMeta(lang);
|
||||
const home = getSeoPage("home", lang);
|
||||
|
||||
return {
|
||||
title: {
|
||||
default: home.title || site.name,
|
||||
template: `%s | ${site.titleSuffix}`,
|
||||
},
|
||||
description: home.description,
|
||||
metadataBase: new URL(SITE_URL),
|
||||
keywords: site.keywords,
|
||||
authors: [{ name: "Modstagram", url: SITE_URL }],
|
||||
creator: "Modstagram",
|
||||
alternates: buildHreflangAlternates(home.path || "/", lang),
|
||||
manifest: `/manifest.webmanifest?lang=${toPwaLang(lang)}&v=${PWA_VERSION}`,
|
||||
applicationName: site.name,
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.png", type: "image/png", sizes: "32x32" },
|
||||
{ url: "/images/icons/192x192.png", type: "image/png", sizes: "192x192" },
|
||||
],
|
||||
shortcut: ["/favicon.png"],
|
||||
apple: [
|
||||
{ url: "/images/icons/apple-touch-icon.png", sizes: "180x180" },
|
||||
{ url: "/images/icons/192x192.png", sizes: "192x192" },
|
||||
],
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "default",
|
||||
title: site.name,
|
||||
},
|
||||
openGraph: {
|
||||
siteName: site.name,
|
||||
locale: lang === "fa" ? "fa_IR" : "en_US",
|
||||
type: "website",
|
||||
images: defaultSEOConfig.openGraph?.images || [],
|
||||
},
|
||||
robots: "index, follow",
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
creator: "@modstagram",
|
||||
site: "@modstagram",
|
||||
images: defaultSEOConfig.openGraph?.images?.map((img) => img.url) || [],
|
||||
},
|
||||
verification: {
|
||||
google: "yVjB5yKPchPUtxl33GWVyMvjH6wCEInqEvaH7ZsXXMo",
|
||||
},
|
||||
other: {
|
||||
"msapplication-TileColor": "#FF107D",
|
||||
samandehi: "522190795",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
viewportFit: "cover",
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
|
||||
],
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const lang = await getServerLanguage();
|
||||
const def = getLanguageDefinition(lang);
|
||||
|
||||
return (
|
||||
<html lang={def.htmlLang} dir={def.direction} suppressHydrationWarning>
|
||||
<body className={`${iranSansFont.className} antialiased`}>
|
||||
<SiteJsonLd />
|
||||
<RegisterSW />
|
||||
<PushNotificationListener />
|
||||
<VpnWarningGate />
|
||||
<ClientErrorBoundary>
|
||||
<Layout initialLanguage={lang}>{children}</Layout>
|
||||
</ClientErrorBoundary>
|
||||
{/* ====================== گوگل آنالیتیکس ====================== */}
|
||||
<Script
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-GT0QQX6F97"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
<Script id="google-analytics" strategy="afterInteractive">
|
||||
{`
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', 'G-GT0QQX6F97');
|
||||
`}
|
||||
</Script>
|
||||
{/* ======================================================== */} </body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,357 +1,343 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import RoundedDiv from "@/components/elements/RoundedDiv";
|
||||
import { selectionCardClass } from "@/lib/ui/buttonStyles";
|
||||
import { BASE_URL } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import VerificationBadge from "@/components/main/VerificationBadge";
|
||||
import UserInfoClient from "@/components/main/UserInfoClient";
|
||||
import ScoreRateStats from "@/components/main/ScoreRateStats";
|
||||
import { formatFullName } from "@/lib/formatFullName";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { IOfferType, User } from "@/types/types";
|
||||
import axios from "axios";
|
||||
import { useFormik } from "formik";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import Cookies from "js-cookie";
|
||||
import * as Yup from "yup";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import WalletUsagePrompt from "@/components/payment/WalletUsagePrompt";
|
||||
import CompleteSocialSignupModal from "@/components/auth/CompleteSocialSignupModal";
|
||||
type OfferTypeRow = IOfferType & {
|
||||
disabled?: boolean;
|
||||
unavailableReason?: string | null;
|
||||
};
|
||||
|
||||
interface ITicketChatProps {
|
||||
params: Promise<{ id: string; title: string }>;
|
||||
}
|
||||
|
||||
const FALLBACK_TYPES: OfferTypeRow[] = [
|
||||
{ _id: "normal", name: "normal", price: 50000 },
|
||||
{ _id: "free", name: "free", price: 0 },
|
||||
];
|
||||
|
||||
function sortOfferTypes(list: OfferTypeRow[]): OfferTypeRow[] {
|
||||
const order = ["normal", "special", "highlight", "free"];
|
||||
return [...list].sort((a, b) => {
|
||||
const ai = order.indexOf(a.name);
|
||||
const bi = order.indexOf(b.name);
|
||||
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
||||
});
|
||||
}
|
||||
|
||||
function TicketChat({ params }: ITicketChatProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const resolvedParams = React.use(params);
|
||||
const { request } = useAxios();
|
||||
const { id } = resolvedParams;
|
||||
const router = useRouter();
|
||||
const me = useUser();
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
selectedType: Yup.string().required(t("offerPage.typeRequired")),
|
||||
});
|
||||
|
||||
const [userDetail, setUserDetail] = useState<User>();
|
||||
const [selectedType, setSelectedType] = useState("normal");
|
||||
const [typeList, setTypeList] = useState<OfferTypeRow[] | null>(null);
|
||||
const [hasUsedFreeOffer, setHasUsedFreeOffer] = useState(false);
|
||||
const [walletBalance, setWalletBalance] = useState(0);
|
||||
const [showWalletPrompt, setShowWalletPrompt] = useState(false);
|
||||
const [socialSignupOpen, setSocialSignupOpen] = useState(false);
|
||||
const pendingPaymentRetryRef = React.useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
const response = await request<{ user: User }>(
|
||||
"GET",
|
||||
`/users/get?user_id=${id}`
|
||||
);
|
||||
setUserDetail(response?.user);
|
||||
};
|
||||
void fetchUser();
|
||||
request<{ charged?: { available?: number } }>("GET", "/wallet/summary")
|
||||
.then((res) => setWalletBalance(res?.charged?.available || 0))
|
||||
.catch(() => setWalletBalance(0));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id, request]);
|
||||
|
||||
useEffect(() => {
|
||||
const monthly = Number(me?.monthly_free_offer ?? 1);
|
||||
setHasUsedFreeOffer(monthly <= 0);
|
||||
}, [me]);
|
||||
|
||||
const fetchOfferTypes = async () => {
|
||||
try {
|
||||
const response = await request<{
|
||||
offerTypes: OfferTypeRow[];
|
||||
monthly_free_offer?: number;
|
||||
}>("GET", "/offers/types");
|
||||
const list = sortOfferTypes(
|
||||
response?.offerTypes?.length ? response.offerTypes : FALLBACK_TYPES
|
||||
);
|
||||
setTypeList(list);
|
||||
if (typeof response?.monthly_free_offer === "number") {
|
||||
setHasUsedFreeOffer(response.monthly_free_offer <= 0);
|
||||
}
|
||||
// همیشه پیشفرض روی پولی (normal)
|
||||
const paid =
|
||||
list.find((item) => item.name === "normal" && !item.disabled) ||
|
||||
list.find((item) => item.name !== "free" && !item.disabled);
|
||||
setSelectedType(paid?.name || "normal");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
setTypeList(FALLBACK_TYPES);
|
||||
setSelectedType("normal");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void fetchOfferTypes();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const getToken = (): string => {
|
||||
if (typeof window !== "undefined") {
|
||||
const token = Cookies.get("token");
|
||||
return token ? `Bearer ${token}` : "";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const startPayment = async (offerType: string, useWallet: boolean) => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/offers/initiate-payment-web`,
|
||||
{
|
||||
offerType,
|
||||
receiverId: id,
|
||||
useWallet,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: getToken(),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const data: {
|
||||
authority?: string;
|
||||
paymentUrl?: string;
|
||||
type?: string;
|
||||
paidFromWallet?: boolean;
|
||||
} = response.data;
|
||||
|
||||
if (offerType === "free" || data.type === "free") {
|
||||
toast.success(t("offerPage.freeSuccess"));
|
||||
setHasUsedFreeOffer(true);
|
||||
router.push(`/offer/payment/success?userId=${id}`);
|
||||
} else if (data.paidFromWallet) {
|
||||
toast.success(t("wallet.paidFromWalletSuccess"));
|
||||
router.push(`/offer/payment/success?userId=${id}`);
|
||||
} else if (data.paymentUrl) {
|
||||
// window.open بعدِ await در سافاری (دسکتاپ/iOS) پاپآپبلاک میشه چون دیگه
|
||||
// synchronously به کلیکِ کاربر متصل نیست؛ ضمناً بازگشتِ ZarinPal (از طریق
|
||||
// offer/payment-web/route.js) کلِ همون تب رو به "/" ریدایرکت میکنه، نه تبِ
|
||||
// اصلی — پس با _blank دو تب باز میموند. window.location.href هم امنتره
|
||||
// هم با بقیهی مسیرهای پرداختِ این اپ یکی میمونه
|
||||
window.location.href = data.paymentUrl;
|
||||
} else if (data.authority) {
|
||||
window.location.href = `https://www.zarinpal.com/pg/StartPay/${data.authority}`;
|
||||
} else {
|
||||
toast.error(t("offerPage.paymentInfoError"));
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.response?.data?.type === "needs_username_password") {
|
||||
pendingPaymentRetryRef.current = () => {
|
||||
void startPayment(offerType, useWallet);
|
||||
};
|
||||
setSocialSignupOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Payment initiation error:", err);
|
||||
toast.error(
|
||||
err?.response?.data?.error ||
|
||||
err?.response?.data?.message ||
|
||||
t("offerPage.paymentStartError")
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
selectedType: "normal",
|
||||
},
|
||||
validationSchema,
|
||||
enableReinitialize: true,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
const chosen = typeList?.find((t) => t.name === values.selectedType);
|
||||
if (
|
||||
chosen?.disabled ||
|
||||
(values.selectedType === "free" && hasUsedFreeOffer)
|
||||
) {
|
||||
toast.error(
|
||||
chosen?.unavailableReason || t("offerPage.freeAlreadyUsed")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (values.selectedType !== "free" && walletBalance > 0) {
|
||||
setShowWalletPrompt(true);
|
||||
return;
|
||||
}
|
||||
await startPayment(values.selectedType, false);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void formik.setFieldValue("selectedType", selectedType);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedType]);
|
||||
|
||||
const displayTypes = useMemo(
|
||||
() => sortOfferTypes(typeList || FALLBACK_TYPES),
|
||||
[typeList]
|
||||
);
|
||||
|
||||
const typeLabel = (name: string) => {
|
||||
if (name === "normal") return t("offerPage.types.normal");
|
||||
if (name === "free") return t("offerPage.types.free");
|
||||
if (name === "special") return t("offerPage.types.special");
|
||||
return t("offerPage.types.highlight");
|
||||
};
|
||||
|
||||
// چیدمان این صفحه با UserInfo مشترک (استفادهشده توی نتایج جستجو/هدیه/...) فرق
|
||||
// داره — عین اندروید (OfferScreen.tsx): ستون نام+یوزرنیم کنار آواتار، ستون
|
||||
// سطح+ریت/امتیاز روبروش؛ بهجای تغییر کامپوننت مشترک، چیدمان مخصوص همین صفحهست
|
||||
const userHeader = userDetail ? (
|
||||
<div className="flex w-full items-center gap-3">
|
||||
<ProfileAvatar
|
||||
src={userDetail?.profile_image}
|
||||
alt={userDetail?.user_name || ""}
|
||||
size="md"
|
||||
rounded="2xl"
|
||||
userId={userDetail?._id}
|
||||
verifyBadge={userDetail?.verify_badge}
|
||||
/>
|
||||
<div className="flex flex-1 items-start justify-between gap-2 text-xs max-sm:text-[10px] md:text-sm font-semibold">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-semibold">
|
||||
{formatFullName(userDetail?.first_name, userDetail?.last_name)}
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{userDetail?.user_name}
|
||||
<VerificationBadge verifyBadge={userDetail?.verify_badge} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<UserInfoClient user_level={userDetail?.user_level} />
|
||||
<ScoreRateStats score={userDetail?.user_score} rate={userDetail?.rate} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="mx-3 mb-10">
|
||||
<PageTitle>{t("offerPage.paymentTitle")}</PageTitle>
|
||||
|
||||
<form
|
||||
className="flex w-full flex-col items-center gap-2 text-sm"
|
||||
onSubmit={formik.handleSubmit}
|
||||
>
|
||||
{displayTypes.map((item) => {
|
||||
const isFree = item.name === "free";
|
||||
const isDisabled =
|
||||
Boolean(item.disabled) || (isFree && hasUsedFreeOffer);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item?._id || item.name}
|
||||
className="mt-8 flex w-full max-w-md cursor-pointer flex-col items-center gap-4"
|
||||
onClick={() => {
|
||||
if (isDisabled) return;
|
||||
setSelectedType(item.name);
|
||||
void formik.setFieldValue("selectedType", item.name);
|
||||
}}
|
||||
>
|
||||
{userHeader}
|
||||
<RoundedDiv
|
||||
className={`${selectionCardClass(selectedType === item?.name)} ${
|
||||
isDisabled ? "cursor-not-allowed opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
{typeLabel(item.name)}
|
||||
{Number(item.price) !== 0
|
||||
? ": " +
|
||||
Number(item.price).toLocaleString() +
|
||||
t("offerPage.currencySuffix")
|
||||
: ""}
|
||||
</RoundedDiv>
|
||||
{isDisabled && item.unavailableReason ? (
|
||||
<p className="text-center text-[11px] text-red-500">
|
||||
{item.unavailableReason}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<RoundedButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="mt-4 px-8 py-2"
|
||||
disabled={formik.isSubmitting}
|
||||
>
|
||||
{t("offerPage.submitRequest")}
|
||||
</RoundedButton>
|
||||
</form>
|
||||
|
||||
<WalletUsagePrompt
|
||||
isOpen={showWalletPrompt}
|
||||
amount={
|
||||
Number(
|
||||
displayTypes.find((item) => item.name === selectedType)?.price
|
||||
) || 0
|
||||
}
|
||||
walletBalance={walletBalance}
|
||||
onAnswer={(useWallet) => {
|
||||
setShowWalletPrompt(false);
|
||||
void startPayment(selectedType, useWallet);
|
||||
}}
|
||||
/>
|
||||
|
||||
<CompleteSocialSignupModal
|
||||
open={socialSignupOpen}
|
||||
hasUsername={Boolean(me?.user_name)}
|
||||
onClose={() => {
|
||||
setSocialSignupOpen(false);
|
||||
pendingPaymentRetryRef.current = null;
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setSocialSignupOpen(false);
|
||||
pendingPaymentRetryRef.current?.();
|
||||
pendingPaymentRetryRef.current = null;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default TicketChat;
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { BASE_URL } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import VerificationBadge from "@/components/main/VerificationBadge";
|
||||
import UserInfoClient from "@/components/main/UserInfoClient";
|
||||
import ScoreRateStats from "@/components/main/ScoreRateStats";
|
||||
import { formatFullName } from "@/lib/formatFullName";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { IOfferType, User } from "@/types/types";
|
||||
import axios from "axios";
|
||||
import { useFormik } from "formik";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import Cookies from "js-cookie";
|
||||
import * as Yup from "yup";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import WalletUsagePrompt from "@/components/payment/WalletUsagePrompt";
|
||||
import CompleteSocialSignupModal from "@/components/auth/CompleteSocialSignupModal";
|
||||
type OfferTypeRow = IOfferType & {
|
||||
disabled?: boolean;
|
||||
unavailableReason?: string | null;
|
||||
};
|
||||
|
||||
interface ITicketChatProps {
|
||||
params: Promise<{ id: string; title: string }>;
|
||||
}
|
||||
|
||||
const FALLBACK_TYPES: OfferTypeRow[] = [
|
||||
{ _id: "normal", name: "normal", price: 50000 },
|
||||
{ _id: "free", name: "free", price: 0 },
|
||||
];
|
||||
|
||||
function sortOfferTypes(list: OfferTypeRow[]): OfferTypeRow[] {
|
||||
const order = ["normal", "special", "highlight", "free"];
|
||||
return [...list].sort((a, b) => {
|
||||
const ai = order.indexOf(a.name);
|
||||
const bi = order.indexOf(b.name);
|
||||
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
||||
});
|
||||
}
|
||||
|
||||
function TicketChat({ params }: ITicketChatProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const resolvedParams = React.use(params);
|
||||
const { request } = useAxios();
|
||||
const { id } = resolvedParams;
|
||||
const router = useRouter();
|
||||
const me = useUser();
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
selectedType: Yup.string().required(t("offerPage.typeRequired")),
|
||||
});
|
||||
|
||||
const [userDetail, setUserDetail] = useState<User>();
|
||||
const [selectedType, setSelectedType] = useState("normal");
|
||||
const [typeList, setTypeList] = useState<OfferTypeRow[] | null>(null);
|
||||
const [walletBalance, setWalletBalance] = useState(0);
|
||||
const [showWalletPrompt, setShowWalletPrompt] = useState(false);
|
||||
const [socialSignupOpen, setSocialSignupOpen] = useState(false);
|
||||
const pendingPaymentRetryRef = React.useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
const response = await request<{ user: User }>(
|
||||
"GET",
|
||||
`/users/get?user_id=${id}`
|
||||
);
|
||||
setUserDetail(response?.user);
|
||||
};
|
||||
void fetchUser();
|
||||
request<{ charged?: { available?: number } }>("GET", "/wallet/summary")
|
||||
.then((res) => setWalletBalance(res?.charged?.available || 0))
|
||||
.catch(() => setWalletBalance(0));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id, request]);
|
||||
|
||||
|
||||
const fetchOfferTypes = async () => {
|
||||
try {
|
||||
const response = await request<{
|
||||
offerTypes: OfferTypeRow[];
|
||||
}>("GET", `/offers/types?userId=${id}`);
|
||||
const list = sortOfferTypes(
|
||||
response?.offerTypes?.length ? response.offerTypes : FALLBACK_TYPES
|
||||
);
|
||||
setTypeList(list);
|
||||
// همیشه پیشفرض روی پولی (normal)
|
||||
const paid =
|
||||
list.find((item) => item.name === "normal" && !item.disabled) ||
|
||||
list.find((item) => item.name !== "free" && !item.disabled);
|
||||
setSelectedType(paid?.name || "normal");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
setTypeList(FALLBACK_TYPES);
|
||||
setSelectedType("normal");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void fetchOfferTypes();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const getToken = (): string => {
|
||||
if (typeof window !== "undefined") {
|
||||
const token = Cookies.get("token");
|
||||
return token ? `Bearer ${token}` : "";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const startPayment = async (offerType: string, useWallet: boolean) => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/offers/initiate-payment-web`,
|
||||
{
|
||||
offerType,
|
||||
receiverId: id,
|
||||
useWallet,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: getToken(),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const data: {
|
||||
authority?: string;
|
||||
paymentUrl?: string;
|
||||
type?: string;
|
||||
paidFromWallet?: boolean;
|
||||
} = response.data;
|
||||
|
||||
if (offerType === "free" || data.type === "free") {
|
||||
toast.success(t("offerPage.freeSuccess"));
|
||||
router.push(`/offer/payment/success?userId=${id}`);
|
||||
} else if (data.paidFromWallet) {
|
||||
toast.success(t("wallet.paidFromWalletSuccess"));
|
||||
router.push(`/offer/payment/success?userId=${id}`);
|
||||
} else if (data.paymentUrl) {
|
||||
// window.open بعدِ await در سافاری (دسکتاپ/iOS) پاپآپبلاک میشه چون دیگه
|
||||
// synchronously به کلیکِ کاربر متصل نیست؛ ضمناً بازگشتِ ZarinPal (از طریق
|
||||
// offer/payment-web/route.js) کلِ همون تب رو به "/" ریدایرکت میکنه، نه تبِ
|
||||
// اصلی — پس با _blank دو تب باز میموند. window.location.href هم امنتره
|
||||
// هم با بقیهی مسیرهای پرداختِ این اپ یکی میمونه
|
||||
window.location.href = data.paymentUrl;
|
||||
} else if (data.authority) {
|
||||
window.location.href = `https://www.zarinpal.com/pg/StartPay/${data.authority}`;
|
||||
} else {
|
||||
toast.error(t("offerPage.paymentInfoError"));
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.response?.data?.type === "needs_username_password") {
|
||||
pendingPaymentRetryRef.current = () => {
|
||||
void startPayment(offerType, useWallet);
|
||||
};
|
||||
setSocialSignupOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Payment initiation error:", err);
|
||||
toast.error(
|
||||
err?.response?.data?.error ||
|
||||
err?.response?.data?.message ||
|
||||
t("offerPage.paymentStartError")
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
selectedType: "normal",
|
||||
},
|
||||
validationSchema,
|
||||
enableReinitialize: true,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
const chosen = typeList?.find((t) => t.name === values.selectedType);
|
||||
if (chosen?.disabled) {
|
||||
toast.error(
|
||||
chosen?.unavailableReason || t("offerPage.freeAlreadyUsed")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (values.selectedType !== "free" && walletBalance > 0) {
|
||||
setShowWalletPrompt(true);
|
||||
return;
|
||||
}
|
||||
await startPayment(values.selectedType, false);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void formik.setFieldValue("selectedType", selectedType);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedType]);
|
||||
|
||||
const displayTypes = useMemo(
|
||||
() => sortOfferTypes(typeList || FALLBACK_TYPES),
|
||||
[typeList]
|
||||
);
|
||||
|
||||
const typeLabel = (name: string) => {
|
||||
if (name === "normal") return t("offerPage.types.normal");
|
||||
if (name === "free") return t("offerPage.types.free");
|
||||
if (name === "special") return t("offerPage.types.special");
|
||||
return t("offerPage.types.highlight");
|
||||
};
|
||||
|
||||
// چیدمان این صفحه با UserInfo مشترک (استفادهشده توی نتایج جستجو/هدیه/...) فرق
|
||||
// داره — عین اندروید (OfferScreen.tsx): ستون نام+یوزرنیم کنار آواتار، ستون
|
||||
// سطح+ریت/امتیاز روبروش؛ بهجای تغییر کامپوننت مشترک، چیدمان مخصوص همین صفحهست
|
||||
const userHeader = userDetail ? (
|
||||
<div className="flex w-full items-center gap-3">
|
||||
<ProfileAvatar
|
||||
src={userDetail?.profile_image}
|
||||
alt={userDetail?.user_name || ""}
|
||||
size="md"
|
||||
rounded="2xl"
|
||||
userId={userDetail?._id}
|
||||
verifyBadge={userDetail?.verify_badge}
|
||||
/>
|
||||
<div className="flex flex-1 items-start justify-between gap-2 text-xs max-sm:text-[10px] md:text-sm font-semibold">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-semibold">
|
||||
{formatFullName(userDetail?.first_name, userDetail?.last_name)}
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{userDetail?.user_name}
|
||||
<VerificationBadge verifyBadge={userDetail?.verify_badge} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<UserInfoClient user_level={userDetail?.user_level} />
|
||||
<ScoreRateStats score={userDetail?.user_score} rate={userDetail?.rate} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="mx-3 mb-10">
|
||||
<PageTitle>{t("offerPage.paymentTitle")}</PageTitle>
|
||||
|
||||
<form
|
||||
className="flex w-full flex-col items-center gap-2 text-sm"
|
||||
onSubmit={formik.handleSubmit}
|
||||
>
|
||||
{displayTypes.map((item) => {
|
||||
const isDisabled = Boolean(item.disabled);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item?._id || item.name}
|
||||
className="mt-4 flex w-full max-w-md cursor-pointer flex-col items-center gap-4"
|
||||
onClick={() => {
|
||||
if (isDisabled) return;
|
||||
setSelectedType(item.name);
|
||||
void formik.setFieldValue("selectedType", item.name);
|
||||
}}
|
||||
>
|
||||
{userHeader ? <div className="w-full">{userHeader}</div> : null}
|
||||
<RoundedButton
|
||||
type="button"
|
||||
variant={selectedType === item?.name ? "primary" : "default"}
|
||||
disabled={isDisabled}
|
||||
className="w-full h-10"
|
||||
>
|
||||
{typeLabel(item.name)}
|
||||
{Number(item.price) !== 0
|
||||
? ": " +
|
||||
Number(item.price).toLocaleString() +
|
||||
t("offerPage.currencySuffix")
|
||||
: ""}
|
||||
</RoundedButton>
|
||||
{isDisabled && item.unavailableReason ? (
|
||||
<p className="text-center text-[11px] text-red-500">
|
||||
{item.unavailableReason}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="pointer-events-none fixed bottom-28 left-0 right-0 z-[54] flex justify-center px-4">
|
||||
<RoundedButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="pointer-events-auto h-10 w-[160px]"
|
||||
disabled={formik.isSubmitting}
|
||||
>
|
||||
{t("offerPage.submitRequest")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<WalletUsagePrompt
|
||||
isOpen={showWalletPrompt}
|
||||
amount={
|
||||
Number(
|
||||
displayTypes.find((item) => item.name === selectedType)?.price
|
||||
) || 0
|
||||
}
|
||||
walletBalance={walletBalance}
|
||||
onAnswer={(useWallet) => {
|
||||
setShowWalletPrompt(false);
|
||||
void startPayment(selectedType, useWallet);
|
||||
}}
|
||||
/>
|
||||
|
||||
<CompleteSocialSignupModal
|
||||
open={socialSignupOpen}
|
||||
hasUsername={Boolean(me?.user_name)}
|
||||
onClose={() => {
|
||||
setSocialSignupOpen(false);
|
||||
pendingPaymentRetryRef.current = null;
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setSocialSignupOpen(false);
|
||||
pendingPaymentRetryRef.current?.();
|
||||
pendingPaymentRetryRef.current = null;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default TicketChat;
|
||||
|
||||
@@ -5,13 +5,14 @@ import React from "react";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function FailedBillboard() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const userId = searchParams.get("userId");
|
||||
|
||||
@@ -29,17 +30,19 @@ function FailedBillboard() {
|
||||
/>
|
||||
<span>{t("offerPage.paymentFailed.message")}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 mt-8">
|
||||
<Link href={`/offer/${userId}`}>
|
||||
<RoundedButton variant="primary" className="w-32 h-9">
|
||||
<div className="mt-6 flex w-full max-w-sm flex-row items-center justify-center gap-3">
|
||||
<Link href={`/offer/${userId}`} className="flex-1">
|
||||
<RoundedButton variant="primary" className="h-9 w-full">
|
||||
{t("offerPage.paymentFailed.retry")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
<Link href="/">
|
||||
<RoundedButton variant="primary" className="w-32 h-9">
|
||||
{t("offerPage.paymentFailed.back")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
<RoundedButton
|
||||
variant="default"
|
||||
className="h-9 flex-1"
|
||||
onClick={() => router.push(`/offer/${userId}`)}
|
||||
>
|
||||
{t("offerPage.paymentFailed.back")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
|
||||
@@ -7,7 +7,7 @@ import React, { useEffect, useState } from "react";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -15,6 +15,7 @@ import { useTranslation } from "react-i18next";
|
||||
function SuccessBillboard() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const userId = searchParams.get("userId");
|
||||
|
||||
@@ -50,13 +51,23 @@ function SuccessBillboard() {
|
||||
username: userDetail?.user_name ?? "",
|
||||
})}
|
||||
</p>
|
||||
<Link href={`/settings/chats/${userDetail?.user_name}/${userId}`}>
|
||||
<RoundedButton variant="primary" className="w-40 h-9">
|
||||
{t("offerPage.paymentSuccess.sendMessage")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{/* دکمههای پایین صفحه: ارسال پیام (به گفتگو) و بازگشت (به صفحهی درخواست) */}
|
||||
<div className="fixed bottom-28 left-0 right-0 z-[54] flex justify-center gap-3 px-4">
|
||||
<Link href={`/settings/chats/${userDetail?.user_name}/${userId}`} className="flex-1 max-w-[9rem]">
|
||||
<RoundedButton variant="primary" className="h-10 w-full">
|
||||
{t("offerPage.paymentSuccess.sendMessage")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
<RoundedButton
|
||||
variant="default"
|
||||
className="h-10 flex-1 max-w-[9rem]"
|
||||
onClick={() => router.push(`/offer/${userId}`)}
|
||||
>
|
||||
{t("offerPage.paymentFailed.back")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Metadata } from "next";
|
||||
import { cache } from "react";
|
||||
import { fetchPostById } from "@/api/fetchPostById";
|
||||
import PostFeedView, { PostFeedSearchParams } from "@/components/posts/PostFeedView";
|
||||
import GuestAuthPromptModal from "@/components/auth/GuestAuthPromptModal";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { generatePageMetadata } from "@/utils/generatePageMetadata";
|
||||
import {
|
||||
@@ -125,6 +126,7 @@ export default async function PostPage({ params, searchParams }: Props) {
|
||||
showClose
|
||||
searchParams={feedSearchParams}
|
||||
/>
|
||||
<GuestAuthPromptModal />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,8 +15,7 @@ import {
|
||||
joinGroupRoom,
|
||||
joinUserRoom,
|
||||
leaveGroupRoom,
|
||||
releaseChatSocket,
|
||||
} from "@/lib/chat/socketClient";
|
||||
releaseChatSocket, isChatSocketConnected, } from "@/lib/chat/socketClient";
|
||||
import MessageInput from "@/components/chat/MessageInput";
|
||||
import ChatActionBar from "@/components/chat/ChatActionBar";
|
||||
import AddChatRoomMembersModal from "@/components/chat/AddChatRoomMembersModal";
|
||||
@@ -549,7 +548,12 @@ export default function ChatRoomThreadPage({
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
// وقتی سوکت وصله، پیامهای گروه real-time (newGroupMessage) میرسن؛ polling فقط
|
||||
// همگامسازیِ ایمنی با فاصلهی بیشتره، وگرنه همون ۸ ثانیهی قبلی
|
||||
let tick = 0;
|
||||
const poll = window.setInterval(() => {
|
||||
tick += 1;
|
||||
if (isChatSocketConnected() && tick % 8 !== 0) return;
|
||||
void loadMessages();
|
||||
void loadRoom();
|
||||
}, 8000);
|
||||
@@ -622,10 +626,17 @@ export default function ChatRoomThreadPage({
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureIdentityKeys(senderId);
|
||||
const encrypted = await encryptRoomContent(senderId, roomId, text);
|
||||
if (!encrypted) throw new Error("E2EE encryption failed");
|
||||
const sendContent = encrypted;
|
||||
// ارسالِ فایل/عکس/ویس/ویدیو توی روم هم فعلاً بدونِ e2ee میره — عینِ دلیلِ همین
|
||||
// تغییر توی چتِ مستقیم (chats/[username]/[id]/page.tsx): اگه encryptRoomContent
|
||||
// به هر دلیلی null برگردونه، قبلاً کلِ آپلودِ فایل هم با همین خطا شکست میخورد
|
||||
const sendContent = fileForUpload
|
||||
? text
|
||||
: await (async () => {
|
||||
await ensureIdentityKeys(senderId);
|
||||
const encrypted = await encryptRoomContent(senderId, roomId, text);
|
||||
if (encrypted === null) throw new Error("E2EE encryption failed");
|
||||
return encrypted;
|
||||
})();
|
||||
|
||||
let res: { message?: RoomMessage } | null = null;
|
||||
if (fileForUpload) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,8 +29,12 @@ export default function Financial() {
|
||||
limit: 15,
|
||||
});
|
||||
|
||||
// قبلاً flatMap روی خودِ page.financial (یه آبجکت {docs, totalPages}، نه آرایه) اجرا
|
||||
// میشد، پس هر صفحه فقط به یه آیتم تبدیل میشد و allTransactions[0]?.docs همیشه فقط
|
||||
// صفحهی اول رو میداد — اسکرول به پایین صفحههای بعدی رو دانلود میکرد ولی هیچوقت
|
||||
// نمایش نمیداد
|
||||
const allTransactions =
|
||||
data?.pages.flatMap((page) => page.financial || []) ?? [];
|
||||
data?.pages.flatMap((page) => page.financial?.docs ?? []) ?? [];
|
||||
|
||||
const getTransactionType = (type: string) => {
|
||||
const known = [
|
||||
@@ -98,7 +102,7 @@ export default function Financial() {
|
||||
ref={containerRef}
|
||||
className="space-y-4 max-h-[65vh] overflow-y-auto scrollbar-thin scrollbar-thumb-neutral-300 dark:scrollbar-thumb-neutral-600"
|
||||
>
|
||||
{allTransactions[0]?.docs?.map((item: IFinancial) => (
|
||||
{allTransactions.map((item: IFinancial) => (
|
||||
<div
|
||||
key={item._id}
|
||||
className="p-5 border rounded-3xl border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 shadow-sm hover:shadow-md transition-all duration-200"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import MainBillboardCard from "@/components/billboards/MainBillboardCard/MainBillboardCard";
|
||||
import Container from "@/components/elements/Container";
|
||||
import EmptyState from "@/components/elements/EmptyState";
|
||||
@@ -31,6 +33,8 @@ function MyBillboard() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const unpaidOnly = searchParams.get("filter") === "unpaid";
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [showFilterModal, setShowFilterModal] = useState<boolean>(false);
|
||||
@@ -106,7 +110,9 @@ function MyBillboard() {
|
||||
) : (
|
||||
data?.pages?.map((page, pageIndex) => (
|
||||
<React.Fragment key={pageIndex}>
|
||||
{page?.advertisings?.map((item: IAdvertising) => (
|
||||
{page?.advertisings
|
||||
?.filter((item: IAdvertising) => !unpaidOnly || (item as { effective_status?: string })?.effective_status === "pending_payment")
|
||||
.map((item: IAdvertising) => (
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={() =>
|
||||
|
||||
@@ -8,7 +8,6 @@ import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -31,7 +30,6 @@ type WalletSummary = {
|
||||
|
||||
export default function WalletSettingsPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const searchParams = useSearchParams();
|
||||
const { request, loading } = useAxios();
|
||||
|
||||
const [bucket, setBucket] = useState<string>(BUCKET_SHOP);
|
||||
@@ -47,9 +45,17 @@ export default function WalletSettingsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
loadSummary();
|
||||
const charge = searchParams.get("charge");
|
||||
if (charge === "success") toast.success(t("shops.paymentSuccessTitle"));
|
||||
if (charge === "failed") toast.error(t("shops.paymentFailedTitle"));
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === "visible") loadSummary();
|
||||
};
|
||||
window.addEventListener("pageshow", loadSummary);
|
||||
window.addEventListener("focus", loadSummary);
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => {
|
||||
window.removeEventListener("pageshow", loadSummary);
|
||||
window.removeEventListener("focus", loadSummary);
|
||||
document.removeEventListener("visibilitychange", onVisible);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -81,16 +87,19 @@ export default function WalletSettingsPage() {
|
||||
const amount = Number(chargeAmount);
|
||||
if (!amount || amount <= 0) return;
|
||||
try {
|
||||
const response = await request<{ authority?: string }>(
|
||||
const response = await request<{ authority?: string; paymentUrl?: string }>(
|
||||
"POST",
|
||||
"/wallet/charge/initiate",
|
||||
{ amount }
|
||||
);
|
||||
if (response?.authority) {
|
||||
// لینک خارجی — router.push رفتارِ تضمینشدهای نداره؛ window.location.href
|
||||
// مطمئنتره (خصوصاً سافاری)
|
||||
window.location.href = `https://www.zarinpal.com/pg/StartPay/${response.authority}`;
|
||||
}
|
||||
const target =
|
||||
response?.paymentUrl ||
|
||||
(response?.authority
|
||||
? `https://www.zarinpal.com/pg/StartPay/${response.authority}`
|
||||
: null);
|
||||
// لینک خارجی — router.push رفتارِ تضمینشدهای نداره؛ window.location.href
|
||||
// مطمئنتره (خصوصاً سافاری)
|
||||
if (target) window.location.href = target;
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
(err as { response?: { data?: { message?: string } } })?.response
|
||||
@@ -99,9 +108,21 @@ export default function WalletSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const cta =
|
||||
bucket === BUCKET_CHARGED
|
||||
? { label: t("shops.chargeWallet"), onClick: handleCharge, disabled: loading }
|
||||
: {
|
||||
label: t("shops.requestWithdrawal"),
|
||||
onClick: handleWithdraw,
|
||||
disabled:
|
||||
loading ||
|
||||
(bucket === BUCKET_GIFT &&
|
||||
(!summary || summary.gift.available < summary.gift.minWithdrawal)),
|
||||
};
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<Container className="pb-28">
|
||||
<PageTitle>{t("settings.nav.wallet")}</PageTitle>
|
||||
|
||||
<div className="mx-auto mt-4 max-w-md text-center">
|
||||
@@ -173,14 +194,6 @@ export default function WalletSettingsPage() {
|
||||
value={withdrawAmount}
|
||||
onChange={(e) => setWithdrawAmount(e.target.value)}
|
||||
/>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 w-full max-w-[200px]"
|
||||
disabled={loading}
|
||||
onClick={handleWithdraw}
|
||||
>
|
||||
{t("shops.requestWithdrawal")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -206,14 +219,6 @@ export default function WalletSettingsPage() {
|
||||
value={withdrawAmount}
|
||||
onChange={(e) => setWithdrawAmount(e.target.value)}
|
||||
/>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 w-full max-w-[200px]"
|
||||
disabled={loading || summary.gift.available < summary.gift.minWithdrawal}
|
||||
onClick={handleWithdraw}
|
||||
>
|
||||
{t("shops.requestWithdrawal")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -234,14 +239,6 @@ export default function WalletSettingsPage() {
|
||||
value={withdrawAmount}
|
||||
onChange={(e) => setWithdrawAmount(e.target.value)}
|
||||
/>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 w-full max-w-[200px]"
|
||||
disabled={loading}
|
||||
onClick={handleWithdraw}
|
||||
>
|
||||
{t("shops.requestWithdrawal")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -262,14 +259,6 @@ export default function WalletSettingsPage() {
|
||||
value={withdrawAmount}
|
||||
onChange={(e) => setWithdrawAmount(e.target.value)}
|
||||
/>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 w-full max-w-[200px]"
|
||||
disabled={loading}
|
||||
onClick={handleWithdraw}
|
||||
>
|
||||
{t("shops.requestWithdrawal")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -290,14 +279,6 @@ export default function WalletSettingsPage() {
|
||||
value={withdrawAmount}
|
||||
onChange={(e) => setWithdrawAmount(e.target.value)}
|
||||
/>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 w-full max-w-[200px]"
|
||||
disabled={loading}
|
||||
onClick={handleWithdraw}
|
||||
>
|
||||
{t("shops.requestWithdrawal")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -319,19 +300,24 @@ export default function WalletSettingsPage() {
|
||||
value={chargeAmount}
|
||||
onChange={(e) => setChargeAmount(e.target.value)}
|
||||
/>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 w-full max-w-[200px]"
|
||||
disabled={loading}
|
||||
onClick={handleCharge}
|
||||
>
|
||||
{t("shops.chargeWallet")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
{summary ? (
|
||||
<div className="pointer-events-none fixed bottom-28 left-0 right-0 z-[54] flex justify-center px-4">
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="pointer-events-auto h-10 w-full max-w-[200px]"
|
||||
disabled={cta.disabled}
|
||||
onClick={cta.onClick}
|
||||
>
|
||||
{cta.label}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
) : null}
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
103
src/app/settings/wallet/payment/failed/page.tsx
Normal file
103
src/app/settings/wallet/payment/failed/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import RoundedDiv from "@/components/elements/RoundedDiv";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function WalletPaymentFailedPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { request } = useAxios();
|
||||
const [paying, setPaying] = useState(false);
|
||||
|
||||
const amount = Number(searchParams.get("amount") || 0);
|
||||
|
||||
const retryPayment = async () => {
|
||||
if (!amount || amount <= 0 || paying) return;
|
||||
|
||||
setPaying(true);
|
||||
try {
|
||||
const response = await request<{ authority?: string; paymentUrl?: string }>(
|
||||
"POST",
|
||||
"/wallet/charge/initiate",
|
||||
{ amount },
|
||||
{ noToast: true }
|
||||
);
|
||||
|
||||
const paymentUrl =
|
||||
response.paymentUrl ||
|
||||
(response.authority
|
||||
? `https://www.zarinpal.com/pg/StartPay/${response.authority}`
|
||||
: null);
|
||||
|
||||
if (!paymentUrl) {
|
||||
toast.error(t("settings.paymentStartError"));
|
||||
setPaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = paymentUrl;
|
||||
} catch (error: unknown) {
|
||||
const message =
|
||||
(error as { response?: { data?: { message?: string } } })?.response
|
||||
?.data?.message || t("settings.paymentStartError");
|
||||
toast.error(message);
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("shops.paymentFailedTitle")}</PageTitle>
|
||||
|
||||
<div className="flex flex-col items-center text-sm">
|
||||
<Image
|
||||
width={50}
|
||||
height={50}
|
||||
alt=""
|
||||
src="/images/icons/failed.svg"
|
||||
className="mb-5 pb-1"
|
||||
/>
|
||||
<span>{t("shops.paymentFailedHint")}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 w-full px-4">
|
||||
{amount > 0 && (
|
||||
<RoundedDiv className="w-full p-3 text-center">
|
||||
{amount.toLocaleString()} {t("settings.toman")}
|
||||
</RoundedDiv>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex w-full max-w-sm flex-row items-center justify-center gap-3">
|
||||
<RoundedButton
|
||||
onClick={retryPayment}
|
||||
disabled={paying || !amount}
|
||||
variant="primary"
|
||||
className="h-9 flex-1"
|
||||
>
|
||||
{paying ? "..." : t("offerPage.paymentFailed.retry")}
|
||||
</RoundedButton>
|
||||
|
||||
<RoundedButton
|
||||
onClick={() => router.push("/settings/wallet")}
|
||||
className="h-9 flex-1"
|
||||
>
|
||||
{t("common.back")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
55
src/app/settings/wallet/payment/success/page.tsx
Normal file
55
src/app/settings/wallet/payment/success/page.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import RoundedDiv from "@/components/elements/RoundedDiv";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function WalletPaymentSuccessPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const searchParams = useSearchParams();
|
||||
const amount = Number(searchParams.get("amount") || 0);
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("shops.paymentSuccessTitle")}</PageTitle>
|
||||
|
||||
<div className="flex flex-col items-center text-sm">
|
||||
<Image
|
||||
width={50}
|
||||
height={50}
|
||||
alt=""
|
||||
src="/images/icons/success.svg"
|
||||
className="mb-5 pb-1"
|
||||
/>
|
||||
|
||||
<p className="text-center text-sm text-neutral-500">
|
||||
{t("shops.paymentSuccessHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{amount > 0 && (
|
||||
<div className="mt-8 w-full px-4">
|
||||
<RoundedDiv className="w-full p-3 text-center">
|
||||
{amount.toLocaleString()} {t("settings.toman")}
|
||||
</RoundedDiv>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex justify-center">
|
||||
<Link href="/settings/wallet">
|
||||
?
|
||||
<RoundedButton variant="primary" className="h-9 w-40">
|
||||
?
|
||||
{t("settings.confirm")}
|
||||
</RoundedButton>
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Container from "@/components/elements/Container";
|
||||
@@ -37,7 +39,8 @@ const Workroom = () => {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const user = useUser();
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const searchParams = useSearchParams();
|
||||
const [statusFilter, setStatusFilter] = useState(searchParams.get("filter") === "unpaid" ? "unpaid" : "");
|
||||
const [showDrafts, setShowDrafts] = useState(false);
|
||||
const [drafts, setDrafts] = useState<ProjectDraft[]>([]);
|
||||
|
||||
|
||||
@@ -564,7 +564,7 @@ export default function ListingDetailClient() {
|
||||
<TabNavigation currentPage="/settings" />
|
||||
|
||||
{showAddressModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-transparent p-4">
|
||||
<div className="w-full max-w-sm rounded-2xl bg-white p-6 text-center dark:bg-neutral-900">
|
||||
<p className="mb-4 text-sm">{t("shops.addressRequiredHint")}</p>
|
||||
<button
|
||||
|
||||
@@ -66,22 +66,22 @@ export default function ShopPaymentFailedPage() {
|
||||
/>
|
||||
<span>{t("shops.paymentFailedHint")}</span>
|
||||
</div>
|
||||
<div className="mt-10 flex flex-col items-center gap-4">
|
||||
<div className="mt-6 flex w-full max-w-sm flex-row items-center justify-center gap-3">
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-9 w-32"
|
||||
className="h-9 flex-1"
|
||||
onClick={retry}
|
||||
disabled={loading || !orderId}
|
||||
>
|
||||
{t("shops.retryPayment")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
className="!border-transparent h-9 w-32 !bg-neutral-100 text-neutral-600 dark:!bg-neutral-800 dark:text-neutral-300"
|
||||
className="!border-transparent h-9 flex-1 !bg-neutral-100 text-neutral-600 dark:!bg-neutral-800 dark:text-neutral-300"
|
||||
onClick={() =>
|
||||
router.push(shopId ? `/shops/profile/${shopId}` : "/shops/discover")
|
||||
router.push("/settings/shop")
|
||||
}
|
||||
>
|
||||
{t("shops.backToShopList")}
|
||||
{t("common.back")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -32,17 +32,9 @@ export default function ShopPaymentSuccessPage() {
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-9 w-48"
|
||||
onClick={() =>
|
||||
router.push(orderId ? `/shops/orders/${orderId}` : "/settings/shop")
|
||||
}
|
||||
onClick={() => router.push("/settings/shop")}
|
||||
>
|
||||
{t("shops.viewOrder")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
className="!border-transparent h-9 w-48 !bg-neutral-100 text-neutral-600 dark:!bg-neutral-800 dark:text-neutral-300"
|
||||
onClick={() => router.push("/")}
|
||||
>
|
||||
{t("shops.backToHome")}
|
||||
{t("settings.confirm")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -14,18 +14,24 @@ type GeoCheckResult = {
|
||||
isProxy: boolean;
|
||||
};
|
||||
|
||||
const DISMISSED_KEY = "vpn_warning_dismissed";
|
||||
|
||||
// عینِ VpnWarningGate.tsx اندروید: هر بار اپِ وب لود میشه (mount ریشهی layout.tsx)
|
||||
// چک میشه که کاربر با آیپیِ غیرایران یا VPN/پراکسی وصل شده یا نه؛ اگه بله، هم یه
|
||||
// toast (معادلِ نوتیفِ اندروید — چون وب پرمیشنِ Notification API لازم داره) هم یه
|
||||
// مودالِ غیراجباری نشون داده میشه
|
||||
// مودالِ غیراجباری با دکمهی تایید + تیکِ «دیگر نشان نده» کنارش نشون داده میشه. با
|
||||
// زدنِ اون تیک، دیگه هیچوقت (حتی خودِ چکِ شبکهش) دوباره اجرا نمیشه
|
||||
export default function VpnWarningGate() {
|
||||
const { t } = useTranslation("common");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
if (localStorage.getItem(DISMISSED_KEY) === "1") return;
|
||||
|
||||
const res = await apiClient.get<GeoCheckResult>(
|
||||
`${getApiBaseUrl()}/geo/check`
|
||||
);
|
||||
@@ -42,20 +48,38 @@ export default function VpnWarningGate() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
if (checked) {
|
||||
localStorage.setItem(DISMISSED_KEY, "1");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={open} onClose={() => setOpen(false)} height="auto" elevated>
|
||||
<Modal isOpen={open} onClose={close} height="auto" elevated>
|
||||
<div className="flex flex-col items-center gap-4 py-2 text-center">
|
||||
<h2 className="text-lg font-bold">{t("settings.vpnWarningTitle")}</h2>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t("settings.vpnWarningMessage")}
|
||||
</p>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="mt-2 h-10 w-40"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t("settings.vpnWarningAction")}
|
||||
</RoundedButton>
|
||||
<div className="mt-2 flex w-full max-w-xs items-center justify-center gap-4">
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 flex-1"
|
||||
onClick={close}
|
||||
>
|
||||
{t("settings.vpnWarningAction")}
|
||||
</RoundedButton>
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="scale-110"
|
||||
checked={checked}
|
||||
onChange={(e) => setChecked(e.target.checked)}
|
||||
/>
|
||||
{t("settings.vpnWarningDontShowAgain")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
57
src/components/auth/GuestAuthPromptModal.tsx
Normal file
57
src/components/auth/GuestAuthPromptModal.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Modal from "@/components/elements/Modal";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { isAuthenticated } from "@/lib/auth/session";
|
||||
|
||||
// روی صفحهی تکیِ پست/ریلز/بیلبورد/پروژه (قابلاشتراکگذاری و قابلدیدن برای گوگل
|
||||
// و مهمان، مثل اینستاگرام) — اگه کاربر لاگین نکرده، همین مودالِ پایینصفحه (قابل
|
||||
// بستن) رو نشون بده با دو دکمهی ورود/ثبتنام؛ خودِ محتوا و رفتارهایی مثل لایک/کامنت
|
||||
// دستنخورده میمونه (لایک از قبل با useRequireAuth کاربرِ مهمان رو به ورود میفرسته)
|
||||
export default function GuestAuthPromptModal() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated()) setOpen(true);
|
||||
}, []);
|
||||
|
||||
const goTo = (path: "/login" | "/register") => {
|
||||
const current =
|
||||
typeof window !== "undefined"
|
||||
? window.location.pathname + window.location.search
|
||||
: "/";
|
||||
setOpen(false);
|
||||
router.push(`${path}?redirect=${encodeURIComponent(current)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={open} onClose={() => setOpen(false)} height="fit" elevated>
|
||||
<div className="mx-auto w-full max-w-sm">
|
||||
<p className="mb-4 text-center text-sm">{t("auth.guestPromptMessage")}</p>
|
||||
<div className="flex flex-row gap-3">
|
||||
<RoundedButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="h-10 flex-1"
|
||||
onClick={() => goTo("/login")}
|
||||
>
|
||||
{t("auth.guestPromptLogin")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
type="button"
|
||||
variant="default"
|
||||
className="h-10 flex-1"
|
||||
onClick={() => goTo("/register")}
|
||||
>
|
||||
{t("auth.guestPromptRegister")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -200,11 +200,11 @@ const Step5 = () => {
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && formik.values.selectedType) {
|
||||
formik.submitForm();
|
||||
}
|
||||
}, [isEditing, formik.values.selectedType]);
|
||||
// این افکت قبلاً با هر تغییرِ selectedType (یعنی هر کلیکِ کاربر روی یه کارتِ نوعِ
|
||||
// دیگه، توی حالتِ ویرایش) دوباره اجرا میشد و formik.submitForm() رو صدا میزد —
|
||||
// یعنی هر کلیک یه PATCH واقعی به /advertising/edit میفرستاد، نه فقط کلیکِ دکمهی
|
||||
// ثبتِ واقعی. حذف شد — کاربر توی حالتِ ویرایش هم باید مثلِ حالتِ عادی، دکمهی
|
||||
// ارسال/ثبتِ پایینِ همین فرم رو بزنه
|
||||
|
||||
return (
|
||||
<form
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -40,7 +40,9 @@ interface MessageInputProps {
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
||||
return `${mins.toString().padStart(2, "0")}:${secs
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const MessageInput = ({
|
||||
@@ -64,17 +66,21 @@ const MessageInput = ({
|
||||
giftDisabled = false,
|
||||
}: MessageInputProps) => {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [recordingTime, setRecordingTime] = useState(0);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
const [dragProgress, setDragProgress] = useState(0);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const dragStartYRef = useRef<number | null>(null);
|
||||
|
||||
const LOCK_THRESHOLD_PX = 80;
|
||||
|
||||
const attachBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const videoInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -84,8 +90,11 @@ const MessageInput = ({
|
||||
const adjustTextareaHeight = useCallback(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
|
||||
el.style.height = "auto";
|
||||
|
||||
const maxHeight = 160;
|
||||
|
||||
el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`;
|
||||
}, []);
|
||||
|
||||
@@ -94,24 +103,32 @@ const MessageInput = ({
|
||||
}, [newMessage, adjustTextareaHeight]);
|
||||
|
||||
const hasText = newMessage.trim().length > 0;
|
||||
|
||||
const showSend = hasText && !isRecording;
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
if (hasText && !blocked_you) sendMessage();
|
||||
if (hasText && !blocked_you) {
|
||||
sendMessage();
|
||||
}
|
||||
}, [hasText, blocked_you, sendMessage]);
|
||||
// ردیف تایمر/یکبارمصرف فقط هنگام ضبط ویس (برای عکس داخل پیشنمایش نمایش داده میشود)
|
||||
|
||||
// ردیف تایمر/یکبارمصرف فقط هنگام ضبط ویس
|
||||
const showMediaOptions = isChatThread && isRecording;
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (file) {
|
||||
const MAX_SIZE = 200 * 1024 * 1024;
|
||||
|
||||
if (file.size > MAX_SIZE) {
|
||||
alert(t("chats.toast.fileTooLarge"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
handleFileSelection(e);
|
||||
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
@@ -121,11 +138,17 @@ const MessageInput = ({
|
||||
setRecordingTime((prev) => prev + 1);
|
||||
}, 1000);
|
||||
} else {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
|
||||
setRecordingTime(0);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [isRecording]);
|
||||
|
||||
@@ -134,14 +157,19 @@ const MessageInput = ({
|
||||
if (mediaRecorderRef.current?.state === "recording") {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
|
||||
setIsRecording(false);
|
||||
setIsLocked(false);
|
||||
setDragProgress(0);
|
||||
|
||||
dragStartYRef.current = null;
|
||||
|
||||
if (!shouldSend) {
|
||||
audioChunksRef.current = [];
|
||||
}
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
|
||||
streamRef.current?.getTracks().forEach((track) => track.stop());
|
||||
|
||||
streamRef.current = null;
|
||||
},
|
||||
[]
|
||||
@@ -149,11 +177,18 @@ const MessageInput = ({
|
||||
|
||||
const startRecording = async () => {
|
||||
if (blocked_you) return;
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true,
|
||||
});
|
||||
|
||||
streamRef.current = stream;
|
||||
|
||||
const mediaRecorder = new MediaRecorder(stream);
|
||||
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
|
||||
audioChunksRef.current = [];
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
@@ -165,32 +200,53 @@ const MessageInput = ({
|
||||
const audioBlob = new Blob(audioChunksRef.current, {
|
||||
type: "audio/webm",
|
||||
});
|
||||
|
||||
handleVoiceUpload(audioBlob);
|
||||
}
|
||||
|
||||
audioChunksRef.current = [];
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
|
||||
setIsRecording(true);
|
||||
} catch {
|
||||
alert(t("chats.toast.micDenied"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMicPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
const handleMicPointerDown = (
|
||||
e: React.PointerEvent<HTMLButtonElement>
|
||||
) => {
|
||||
if (!hasText && !blocked_you) {
|
||||
dragStartYRef.current = e.clientY;
|
||||
|
||||
setIsLocked(false);
|
||||
setDragProgress(0);
|
||||
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
|
||||
startRecording();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMicPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (!isRecording || isLocked || dragStartYRef.current == null) return;
|
||||
const handleMicPointerMove = (
|
||||
e: React.PointerEvent<HTMLButtonElement>
|
||||
) => {
|
||||
if (
|
||||
!isRecording ||
|
||||
isLocked ||
|
||||
dragStartYRef.current == null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delta = dragStartYRef.current - e.clientY;
|
||||
setDragProgress(Math.max(0, Math.min(1, delta / LOCK_THRESHOLD_PX)));
|
||||
|
||||
setDragProgress(
|
||||
Math.max(0, Math.min(1, delta / LOCK_THRESHOLD_PX))
|
||||
);
|
||||
|
||||
if (delta >= LOCK_THRESHOLD_PX) {
|
||||
setIsLocked(true);
|
||||
dragStartYRef.current = null;
|
||||
@@ -198,8 +254,12 @@ const MessageInput = ({
|
||||
};
|
||||
|
||||
const handleMicPointerUp = () => {
|
||||
if (isRecording && !isLocked) stopRecording(true);
|
||||
if (isRecording && !isLocked) {
|
||||
stopRecording(true);
|
||||
}
|
||||
|
||||
dragStartYRef.current = null;
|
||||
|
||||
setDragProgress(0);
|
||||
};
|
||||
|
||||
@@ -209,12 +269,15 @@ const MessageInput = ({
|
||||
case "camera":
|
||||
imageInputRef.current?.click();
|
||||
break;
|
||||
|
||||
case "video":
|
||||
videoInputRef.current?.click();
|
||||
break;
|
||||
|
||||
case "file":
|
||||
fileInputRef.current?.click();
|
||||
break;
|
||||
|
||||
case "location":
|
||||
onLocationShare?.();
|
||||
break;
|
||||
@@ -223,8 +286,10 @@ const MessageInput = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-none fixed left-0 right-0 z-50 flex flex-col items-center bg-transparent px-3 sm:px-4 ${
|
||||
isChatThread ? "bottom-0 chat-input-area--thread" : "bottom-[5.5rem] chat-input-area"
|
||||
className={`pointer-events-none fixed left-0 right-0 z-50 flex flex-col items-center bg-transparent px-2 sm:px-3 ${
|
||||
isChatThread
|
||||
? "bottom-0 chat-input-area--thread"
|
||||
: "bottom-[5.5rem] chat-input-area"
|
||||
}`}
|
||||
>
|
||||
{(replyLabel ||
|
||||
@@ -237,6 +302,7 @@ const MessageInput = ({
|
||||
<span className="text-[11px] font-medium text-neutral-500 dark:text-neutral-400">
|
||||
{t("chats.mediaOptions")}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{onSelfDestructChange && (
|
||||
<TimedMessagePicker
|
||||
@@ -245,6 +311,7 @@ const MessageInput = ({
|
||||
disabled={blocked_you}
|
||||
/>
|
||||
)}
|
||||
|
||||
{onViewOnceChange && (
|
||||
<ViewOnceToggle
|
||||
value={viewOnceMedia}
|
||||
@@ -255,6 +322,7 @@ const MessageInput = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{replyLabel && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -264,6 +332,7 @@ const MessageInput = ({
|
||||
<span className="flex-1 truncate text-neutral-700 dark:text-neutral-200">
|
||||
{replyLabel}
|
||||
</span>
|
||||
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -284,19 +353,32 @@ const MessageInput = ({
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{selfDestructSeconds != null && isChatThread && (
|
||||
<div className="chat-timer-glass chat-timer-glass--active flex items-center gap-2 rounded-2xl px-3 py-1.5 text-[11px]">
|
||||
<ChatBoldIcon name="timer" size={14} className="shrink-0" />
|
||||
<ChatBoldIcon
|
||||
name="timer"
|
||||
size={14}
|
||||
className="shrink-0"
|
||||
/>
|
||||
|
||||
<span>
|
||||
{t("chats.timedBanner", {
|
||||
duration: formatTimedLabel(selfDestructSeconds, t),
|
||||
duration: formatTimedLabel(
|
||||
selfDestructSeconds,
|
||||
t
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewOnceMedia && isChatThread && (
|
||||
<div className="chat-timer-glass chat-timer-glass--active flex items-center gap-2 rounded-2xl px-3 py-1.5 text-[11px] text-[#ff9500]">
|
||||
<span className="font-semibold">{t("chats.viewOnceBadge")}</span>
|
||||
<span className="font-semibold">
|
||||
{t("chats.viewOnceBadge")}
|
||||
</span>
|
||||
|
||||
<span>
|
||||
{t("chats.viewOnceBanner", {
|
||||
label: viewOnceLabel("image", t),
|
||||
@@ -306,9 +388,16 @@ const MessageInput = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/*
|
||||
مهم:
|
||||
این ردیف همان max-w-lg هدر را دارد.
|
||||
min-w-0 روی ردیف و کادر پیام باعث میشود
|
||||
در موبایل از عرض تعیینشده بیرون نزند.
|
||||
*/}
|
||||
<div
|
||||
dir="ltr"
|
||||
className="pointer-events-auto relative z-[1] flex w-full max-w-lg items-end gap-2 sm:gap-2.5"
|
||||
className="pointer-events-auto relative z-[1] flex w-full min-w-0 max-w-lg items-end gap-2 overflow-visible sm:gap-2.5"
|
||||
>
|
||||
<div className="relative flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
@@ -320,31 +409,46 @@ const MessageInput = ({
|
||||
style={chatHeaderGlassStyle}
|
||||
aria-label={t("chats.aria.attach")}
|
||||
>
|
||||
<ChatBoldIcon name="attachment" size={22} className="text-current" />
|
||||
<ChatBoldIcon
|
||||
name="attachment"
|
||||
size={22}
|
||||
className="text-current"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<AttachmentMenu
|
||||
open={menuOpen}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
onSelect={handleAttachmentSelect}
|
||||
anchorRef={attachBtnRef}
|
||||
/>
|
||||
|
||||
{onGiftClick && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={blocked_you || isRecording || giftDisabled}
|
||||
disabled={
|
||||
blocked_you ||
|
||||
isRecording ||
|
||||
giftDisabled
|
||||
}
|
||||
onClick={onGiftClick}
|
||||
className={`${chatHeaderCircleBtn} gentle-transition disabled:opacity-40`}
|
||||
style={chatHeaderGlassStyle}
|
||||
aria-label={t("chats.aria.gift")}
|
||||
>
|
||||
<BoldIcon name="gift" size={20} tinted className="text-current" />
|
||||
<BoldIcon
|
||||
name="gift"
|
||||
size={20}
|
||||
tinted
|
||||
className="text-current"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Glass input box */}
|
||||
<div
|
||||
className="glass-chat-input gentle-transition flex min-h-11 flex-1 items-center rounded-[22px] px-4 py-2"
|
||||
className="glass-chat-input gentle-transition flex min-h-11 min-w-0 flex-1 items-center rounded-[22px] px-4 py-2"
|
||||
style={glassBackdropStyle("glass-chat-input")}
|
||||
>
|
||||
<input
|
||||
@@ -356,6 +460,7 @@ const MessageInput = ({
|
||||
onChange={handleFileChange}
|
||||
disabled={blocked_you}
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={videoInputRef}
|
||||
type="file"
|
||||
@@ -364,6 +469,7 @@ const MessageInput = ({
|
||||
onChange={handleFileChange}
|
||||
disabled={blocked_you}
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -373,33 +479,49 @@ const MessageInput = ({
|
||||
/>
|
||||
|
||||
{isRecording ? (
|
||||
<div className="flex w-full items-center justify-between gap-2 text-red-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<div className="flex w-full min-w-0 items-center justify-between gap-2 text-red-500">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="relative flex h-2 w-2 shrink-0">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75" />
|
||||
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-red-500" />
|
||||
</span>
|
||||
|
||||
<span className="font-mono text-xs font-bold">
|
||||
{formatTime(recordingTime)}
|
||||
</span>
|
||||
|
||||
{!isLocked && (
|
||||
<span className="text-[10px] font-normal text-neutral-400">
|
||||
<span className="truncate text-[10px] font-normal text-neutral-400">
|
||||
{t("chats.voice.slideUpToLock")}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isLocked && (
|
||||
<BoldIcon name="lock" size={13} tinted className="text-neutral-400" />
|
||||
<BoldIcon
|
||||
name="lock"
|
||||
size={13}
|
||||
tinted
|
||||
className="shrink-0 text-neutral-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => stopRecording(false)}
|
||||
className="rounded-full p-1"
|
||||
aria-label={t("chats.aria.cancelVoice")}
|
||||
>
|
||||
<BoldIcon name="trash" size={18} tinted className="text-current" />
|
||||
<BoldIcon
|
||||
name="trash"
|
||||
size={18}
|
||||
tinted
|
||||
className="text-current"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isLocked && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -407,7 +529,11 @@ const MessageInput = ({
|
||||
className="flex h-7 w-7 items-center justify-center rounded-full bg-[#0095f6] text-white"
|
||||
aria-label={t("chats.aria.sendVoice")}
|
||||
>
|
||||
<ChatBoldIcon name="send" size={14} className="text-current -mr-0.5" />
|
||||
<ChatBoldIcon
|
||||
name="send"
|
||||
size={14}
|
||||
className="text-current -mr-0.5"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -434,7 +560,7 @@ const MessageInput = ({
|
||||
}
|
||||
}}
|
||||
disabled={blocked_you}
|
||||
className="min-h-[1.5rem] max-h-40 flex-1 resize-none overflow-y-auto bg-transparent text-[15px] leading-6 outline-none placeholder:text-neutral-400 dark:text-white"
|
||||
className="min-h-[1.5rem] min-w-0 max-h-40 flex-1 resize-none overflow-y-auto bg-transparent text-[15px] leading-6 outline-none placeholder:text-neutral-400 dark:text-white"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -455,21 +581,42 @@ const MessageInput = ({
|
||||
style={chatHeaderGlassStyle}
|
||||
aria-label={t("chats.aria.send")}
|
||||
>
|
||||
<ChatBoldIcon name="send" size={20} className="text-current -mr-0.5" />
|
||||
<ChatBoldIcon
|
||||
name="send"
|
||||
size={20}
|
||||
className="text-current -mr-0.5"
|
||||
/>
|
||||
</motion.button>
|
||||
) : (
|
||||
<motion.div key="mic" className="relative">
|
||||
<motion.div
|
||||
key="mic"
|
||||
className="relative shrink-0"
|
||||
>
|
||||
{isRecording && !isLocked && (
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-full right-1/2 mb-2 flex translate-x-1/2 flex-col items-center gap-1"
|
||||
style={{ opacity: 0.4 + dragProgress * 0.6 }}
|
||||
style={{
|
||||
opacity: 0.4 + dragProgress * 0.6,
|
||||
}}
|
||||
>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-black/40 text-white backdrop-blur-sm">
|
||||
<BoldIcon name="lock" size={14} tinted className="text-white" />
|
||||
<BoldIcon
|
||||
name="lock"
|
||||
size={14}
|
||||
tinted
|
||||
className="text-white"
|
||||
/>
|
||||
</span>
|
||||
<BoldIcon name="arrow-up-2" size={12} tinted className="text-neutral-400" />
|
||||
|
||||
<BoldIcon
|
||||
name="arrow-up-2"
|
||||
size={12}
|
||||
tinted
|
||||
className="text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<motion.button
|
||||
initial={{ scale: 0.6, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
@@ -480,14 +627,37 @@ const MessageInput = ({
|
||||
onPointerDown={handleMicPointerDown}
|
||||
onPointerMove={handleMicPointerMove}
|
||||
onPointerUp={handleMicPointerUp}
|
||||
onPointerLeave={() => isRecording && !isLocked && stopRecording(true)}
|
||||
onPointerLeave={() =>
|
||||
isRecording &&
|
||||
!isLocked &&
|
||||
stopRecording(true)
|
||||
}
|
||||
className={`${chatHeaderCircleBtn} gentle-transition ${
|
||||
isRecording ? "scale-110 !bg-red-500 text-white" : ""
|
||||
isRecording
|
||||
? "scale-110 !bg-red-500 text-white"
|
||||
: ""
|
||||
} active:scale-90`}
|
||||
style={isRecording ? undefined : chatHeaderGlassStyle}
|
||||
style={
|
||||
isRecording
|
||||
? undefined
|
||||
: chatHeaderGlassStyle
|
||||
}
|
||||
aria-label={t("chats.aria.recordVoice")}
|
||||
>
|
||||
{isRecording ? <BoldIcon name="stop" size={20} tinted className="text-white" /> : <ChatBoldIcon name="microphone" size={22} className="text-current" />}
|
||||
{isRecording ? (
|
||||
<BoldIcon
|
||||
name="stop"
|
||||
size={20}
|
||||
tinted
|
||||
className="text-white"
|
||||
/>
|
||||
) : (
|
||||
<ChatBoldIcon
|
||||
name="microphone"
|
||||
size={22}
|
||||
className="text-current"
|
||||
/>
|
||||
)}
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -497,4 +667,4 @@ const MessageInput = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageInput;
|
||||
export default MessageInput;
|
||||
@@ -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",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -33,6 +33,9 @@ import { useTranslation } from "react-i18next";
|
||||
import { getPostVideoSrc } from "@/lib/videoPreload";
|
||||
import { getVideoPoster } from "@/lib/explore/postMedia";
|
||||
import { resolvePostMediaType } from "@/lib/reelsMediaType";
|
||||
import { usePostSocialState } from "@/hooks/usePostSocialActions";
|
||||
|
||||
const DOUBLE_TAP_MS = 280;
|
||||
|
||||
// ایجاد Context برای به اشتراک گذاشتن وضعیت muted بین همه کارتها
|
||||
const MutedContext = createContext<{
|
||||
@@ -81,11 +84,6 @@ function MainModelCard({
|
||||
}, [postData._id]);
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [ripple, setRipple] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
key: number;
|
||||
} | null>(null);
|
||||
|
||||
// استفاده از Context برای وضعیت muted گلوبال
|
||||
const { muted, setMuted } = useContext(MutedContext);
|
||||
@@ -122,27 +120,38 @@ function MainModelCard({
|
||||
const videoPoster =
|
||||
mediaType === "video" ? getVideoPoster(postData) : undefined;
|
||||
|
||||
// تابع برای ایجاد افکت ripple و تغییر وضعیت صدا
|
||||
const handleVideoClick = (e: React.MouseEvent<HTMLVideoElement>) => {
|
||||
const social = usePostSocialState(postData);
|
||||
const singleTapTimerRef = useRef<number | null>(null);
|
||||
const [likeBurstKey, setLikeBurstKey] = useState<number | null>(null);
|
||||
|
||||
// یک ضربه = باز کردن ریلز (مثل اندروید)، دو ضربهی سریع = پسندیدن. دکمهی صدا جدا و
|
||||
// کلیکپذیره، پس با کلیک روی ویدیو دیگه صدا عوض نمیشه
|
||||
const handleMediaTap = () => {
|
||||
if (singleTapTimerRef.current) {
|
||||
window.clearTimeout(singleTapTimerRef.current);
|
||||
singleTapTimerRef.current = null;
|
||||
if (!social.liked) void social.toggleLike();
|
||||
setLikeBurstKey(Date.now());
|
||||
window.setTimeout(() => setLikeBurstKey(null), 900);
|
||||
return;
|
||||
}
|
||||
if (!onMediaClick) return;
|
||||
singleTapTimerRef.current = window.setTimeout(() => {
|
||||
singleTapTimerRef.current = null;
|
||||
onMediaClick();
|
||||
}, DOUBLE_TAP_MS);
|
||||
};
|
||||
|
||||
const toggleVideoMute = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
const rect = v.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
|
||||
setRipple({ x, y, key: Date.now() });
|
||||
|
||||
// --- مهمترین اصلاح ---
|
||||
v.muted = !v.muted;
|
||||
setVideoMuted(v.muted);
|
||||
setMuted(v.muted);
|
||||
|
||||
if (!v.muted) {
|
||||
v.play().catch(() => {});
|
||||
if (!v.muted && v.paused) {
|
||||
void v.play().catch(() => {});
|
||||
}
|
||||
|
||||
setTimeout(() => setRipple(null), 600);
|
||||
};
|
||||
|
||||
// استفاده از IntersectionObserver برای پخش/توقف ویدیو بر اساس ویوپورت
|
||||
@@ -376,7 +385,7 @@ function MainModelCard({
|
||||
|
||||
<div
|
||||
className={`overflow-hidden rounded-2xl ${onMediaClick ? "cursor-pointer" : ""}`}
|
||||
onClick={onMediaClick}
|
||||
onClick={handleMediaTap}
|
||||
onKeyDown={
|
||||
onMediaClick
|
||||
? (e) => {
|
||||
@@ -401,14 +410,6 @@ function MainModelCard({
|
||||
{mediaType === "video" && videoSrc && (
|
||||
<div className="relative w-full rounded-2xl overflow-hidden bg-black">
|
||||
<video
|
||||
onClick={(e) => {
|
||||
if (onMediaClick) {
|
||||
e.stopPropagation();
|
||||
onMediaClick();
|
||||
return;
|
||||
}
|
||||
handleVideoClick(e);
|
||||
}}
|
||||
ref={videoRef}
|
||||
src={videoSrc}
|
||||
poster={videoPoster}
|
||||
@@ -418,18 +419,18 @@ function MainModelCard({
|
||||
onLoadedData={() => setImageLoading(false)}
|
||||
muted={muted}
|
||||
/>
|
||||
{ripple && (
|
||||
<span
|
||||
className="absolute rounded-full bg-white/50 animate-ripple"
|
||||
style={{
|
||||
left: ripple.x - 25,
|
||||
top: ripple.y - 25,
|
||||
width: 50,
|
||||
height: 50,
|
||||
}}
|
||||
key={ripple.key}
|
||||
/>
|
||||
)}
|
||||
{likeBurstKey ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center">
|
||||
<Image
|
||||
key={likeBurstKey}
|
||||
src="/images/icons/red-heart.svg"
|
||||
alt=""
|
||||
width={96}
|
||||
height={96}
|
||||
className="animate-ping"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{imageLoading && (
|
||||
<div className="absolute inset-0 flex justify-center items-center bg-black/30 z-10">
|
||||
<Image
|
||||
@@ -440,18 +441,23 @@ function MainModelCard({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="pointer-events-none absolute bottom-3 left-3 z-50 rounded-full bg-black/50 p-2 backdrop-blur-md">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleVideoMute}
|
||||
className="absolute bottom-3 left-3 z-50 flex h-9 w-9 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur-md"
|
||||
aria-label={videoMuted ? t("models.unmute") : t("models.mute")}
|
||||
>
|
||||
{videoMuted ? (
|
||||
<BoldIcon name="volume-slash" size={20} tinted className="text-white" aria-label={t("models.mute")} />
|
||||
<BoldIcon name="volume-slash" size={18} tinted className="text-white" />
|
||||
) : (
|
||||
<BoldIcon name="volume-high" size={20} tinted className="text-white" aria-label={t("models.unmute")} />
|
||||
<BoldIcon name="volume-high" size={18} tinted className="text-white" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MainModelCardActions postData={postData} />
|
||||
<MainModelCardActions postData={postData} social={social} />
|
||||
{caption?.includes("@") && (
|
||||
<div className="mx-2 mt-1 flex items-center gap-1 text-xs text-neutral-500">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
@@ -472,20 +478,6 @@ function MainModelCard({
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.animate-ripple {
|
||||
animation: ripple 600ms ease-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes ripple {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
transform: scale(4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,13 @@ import {
|
||||
} from "@/hooks/usePostSocialActions";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function MainModelCardActions({ postData }: { postData: Post }) {
|
||||
function MainModelCardActions({
|
||||
postData,
|
||||
social: sharedSocial,
|
||||
}: {
|
||||
postData: Post;
|
||||
social?: ReturnType<typeof usePostSocialState>;
|
||||
}) {
|
||||
const { _id: postId, user_id } = postData;
|
||||
|
||||
const { t } = useTranslation("common");
|
||||
@@ -36,7 +42,8 @@ function MainModelCardActions({ postData }: { postData: Post }) {
|
||||
const [showSendModal, setShowSendModal] = useState(false);
|
||||
const [showServicesModal, setShowServicesModal] = useState<boolean>(false);
|
||||
|
||||
const social = usePostSocialState(postData);
|
||||
const ownSocial = usePostSocialState(postData);
|
||||
const social = sharedSocial ?? ownSocial;
|
||||
const iconSize = "sm" as const;
|
||||
// عین allowSave توی ReelsPostCard.tsx/ReelsPostActions.tsx — این کارت (فید خانه) این
|
||||
// چک رو نداشت و دکمهی ذخیره همیشه نمایش داده میشد، حتی وقتی نویسنده allow_save_posts
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -97,9 +97,9 @@ export default function ReelsPostCard({
|
||||
const videoSrc = type === "video" ? getPostVideoSrc(postData) : undefined;
|
||||
|
||||
const videoPreload =
|
||||
preloadPriority === "active" || preloadPriority === "nearby"
|
||||
preloadPriority === "active"
|
||||
? "auto"
|
||||
: preloadPriority === "idle"
|
||||
: preloadPriority === "idle" || preloadPriority === "nearby"
|
||||
? "metadata"
|
||||
: "none";
|
||||
|
||||
@@ -153,6 +153,10 @@ export default function ReelsPostCard({
|
||||
const holdTriggeredRef = useRef(false);
|
||||
const singleTapTimerRef = useRef<number | null>(null);
|
||||
|
||||
const [commentsMountedOnce, setCommentsMountedOnce] = useState(false);
|
||||
useEffect(() => {
|
||||
if (commentsOpen) setCommentsMountedOnce(true);
|
||||
}, [commentsOpen]);
|
||||
const { muted, setMuted } = useContext(MutedContext);
|
||||
const [videoMuted, setVideoMuted] = useState(true);
|
||||
const exploreUi = useExploreReelsUi();
|
||||
@@ -227,57 +231,40 @@ export default function ReelsPostCard({
|
||||
holdTriggeredRef.current = false;
|
||||
}, [clearHoldTimer, exploreUi]);
|
||||
|
||||
const handleMediaClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (postPage || commentsOpen || !isActive) return;
|
||||
if (singleTapTimerRef.current) {
|
||||
window.clearTimeout(singleTapTimerRef.current);
|
||||
}
|
||||
singleTapTimerRef.current = window.setTimeout(() => {
|
||||
singleTapTimerRef.current = null;
|
||||
if (exploreUi?.chromeHidden) {
|
||||
exploreUi.setChromeHidden(false);
|
||||
return;
|
||||
}
|
||||
if (e.target === e.currentTarget) {
|
||||
// tap on empty overlay only
|
||||
}
|
||||
}, DOUBLE_TAP_MS);
|
||||
},
|
||||
[postPage, commentsOpen, isActive, exploreUi]
|
||||
);
|
||||
// یک ضربه = پخش/توقف (یا نمایش/مخفیکردنِ آیکونها)، دو ضربهی سریع = پسندیدن — عینِ
|
||||
// اندروید. با تایمر خودمون انجام میشه، نه dblclick مرورگر (که روی لمسی معمولاً اجرا نمیشه)
|
||||
const handleMediaTap = useCallback(() => {
|
||||
if (postPage || commentsOpen || !isActive || holdTriggeredRef.current) return;
|
||||
|
||||
const handleMediaDoubleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (postPage || commentsOpen || !isActive) return;
|
||||
e.stopPropagation();
|
||||
if (singleTapTimerRef.current) {
|
||||
window.clearTimeout(singleTapTimerRef.current);
|
||||
singleTapTimerRef.current = null;
|
||||
}
|
||||
if (singleTapTimerRef.current) {
|
||||
window.clearTimeout(singleTapTimerRef.current);
|
||||
singleTapTimerRef.current = null;
|
||||
if (!social.liked) {
|
||||
void social.toggleLike();
|
||||
}
|
||||
setLikeBurstKey(Date.now());
|
||||
window.setTimeout(() => setLikeBurstKey(null), 900);
|
||||
},
|
||||
[postPage, commentsOpen, isActive, social]
|
||||
);
|
||||
|
||||
const handleVideoClick = (e: React.MouseEvent<HTMLVideoElement>) => {
|
||||
if (holdTriggeredRef.current || e.detail > 1) return;
|
||||
e.stopPropagation();
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
if (v.paused) {
|
||||
void v.play().catch(() => {});
|
||||
setVideoPaused(false);
|
||||
} else {
|
||||
v.pause();
|
||||
setVideoPaused(true);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
singleTapTimerRef.current = window.setTimeout(() => {
|
||||
singleTapTimerRef.current = null;
|
||||
if (exploreUi?.chromeHidden) {
|
||||
exploreUi.setChromeHidden(false);
|
||||
return;
|
||||
}
|
||||
if (type !== "video") return;
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
if (v.paused) {
|
||||
void v.play().catch(() => {});
|
||||
setVideoPaused(false);
|
||||
} else {
|
||||
v.pause();
|
||||
setVideoPaused(true);
|
||||
}
|
||||
}, DOUBLE_TAP_MS);
|
||||
}, [postPage, commentsOpen, isActive, exploreUi, social, type]);
|
||||
|
||||
const toggleVideoMute = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -350,7 +337,7 @@ export default function ReelsPostCard({
|
||||
|
||||
useEffect(() => {
|
||||
if (type !== "video" || !videoSrc) return;
|
||||
if (preloadPriority === "active" || preloadPriority === "nearby") {
|
||||
if (preloadPriority === "active") {
|
||||
prefetchVideoUrl(videoSrc, "high");
|
||||
}
|
||||
}, [type, videoSrc, preloadPriority]);
|
||||
@@ -494,8 +481,7 @@ export default function ReelsPostCard({
|
||||
onPointerUp={handleMediaPointerUp}
|
||||
onPointerLeave={handleMediaPointerUp}
|
||||
onPointerCancel={handleMediaPointerUp}
|
||||
onClick={handleMediaClick}
|
||||
onDoubleClick={handleMediaDoubleClick}
|
||||
onClick={handleMediaTap}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{type !== "video" && imageFiles.length > 0 && (
|
||||
@@ -520,7 +506,7 @@ export default function ReelsPostCard({
|
||||
// دقیقاً روی لبهی پایینِ کادر بشینه (نه وسطچین) — تا صرفنظر از نسبتِ
|
||||
// ابعادِ صفحهنمایشِ گوشی، این خط ثابت بمونه؛ پوستر هم باید با همون
|
||||
// لنگرگاه نمایش داده بشه، وگرنه لحظهی لود شدنِ ویدیو یه پرش دیده میشه
|
||||
className="object-contain object-bottom"
|
||||
className="object-cover object-center"
|
||||
priority={isActive || preloadPriority === "nearby"}
|
||||
// unoptimized: این فقط یه پیشنمایشِ کوتاهمدته تا ویدیو آماده بشه — رد
|
||||
// کردنش از مسیرِ بهینهسازیِ next/image (ریسایز+تبدیل به avif/webp روی
|
||||
@@ -543,10 +529,9 @@ export default function ReelsPostCard({
|
||||
preload={videoPreload}
|
||||
controlsList="nodownload"
|
||||
className={cn(
|
||||
"h-full w-full object-contain object-bottom",
|
||||
"h-full w-full object-cover object-center",
|
||||
videoPoster && !videoReady && "opacity-0"
|
||||
)}
|
||||
onClick={handleVideoClick}
|
||||
// مرورگر نمیتونه از اسکرینشات واقعی جلوگیری کنه (هیچ API مرورگری برای این
|
||||
// وجود نداره)؛ این فقط راههای سادهی دانلود/ذخیره (کلیک راست، درگ) رو میبندد
|
||||
onContextMenu={
|
||||
@@ -599,6 +584,21 @@ export default function ReelsPostCard({
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "video" && isActive && !commentsOpen && !postPage && !videoPaused && !chromeHidden && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleVideoMute}
|
||||
className="absolute bottom-3 left-3 z-[26] flex h-8 w-8 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur-md"
|
||||
aria-label={videoMuted ? t("posts.unmute") : t("posts.mute")}
|
||||
>
|
||||
{videoMuted ? (
|
||||
<BoldIcon name="volume-slash" size={16} tinted className="text-white" />
|
||||
) : (
|
||||
<BoldIcon name="volume-high" size={16} tinted className="text-white" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{imageLoading && isActive && !commentsOpen && <ReelsMediaSkeleton />}
|
||||
|
||||
{likeBurstKey ? (
|
||||
@@ -622,7 +622,7 @@ export default function ReelsPostCard({
|
||||
</div>
|
||||
|
||||
{showLongVideoBar && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-30 px-3 pb-1">
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-30 px-3">
|
||||
<p className="mb-1 text-right text-[10px] tabular-nums text-white/85">
|
||||
{formatVideoTime(remaining)}
|
||||
</p>
|
||||
@@ -638,11 +638,11 @@ export default function ReelsPostCard({
|
||||
>
|
||||
<div className="relative h-0.5 overflow-hidden rounded-full bg-white/25">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-white/35"
|
||||
className="absolute inset-y-0 right-0 rounded-full bg-white/35"
|
||||
style={{ width: `${bufferPct}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-white"
|
||||
className="absolute inset-y-0 right-0 rounded-full bg-[#FF107D]"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -674,7 +674,7 @@ export default function ReelsPostCard({
|
||||
<BoldIcon name={videoPaused ? "play" : "pause"} size={16} tinted className="text-white" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-30 px-3 pb-1.5">
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-30 px-3">
|
||||
<div
|
||||
className="relative -my-3 cursor-pointer touch-none py-3 pointer-events-auto"
|
||||
onPointerDown={handleProgressPointerDown}
|
||||
@@ -684,11 +684,11 @@ export default function ReelsPostCard({
|
||||
>
|
||||
<div className="relative h-0.5 overflow-hidden rounded-full bg-white/25">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-white/35"
|
||||
className="absolute inset-y-0 right-0 rounded-full bg-white/35"
|
||||
style={{ width: `${bufferPct}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-white"
|
||||
className="absolute inset-y-0 right-0 rounded-full bg-[#FF107D]"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -711,7 +711,7 @@ export default function ReelsPostCard({
|
||||
// justify-between (ریت/امتیاز) زیرِ RTL فیزیکاً همون سمت میشینه، این
|
||||
// فاصلهی اضافه (۶۴px، هماندازهی عرضِ ستونِ آیکونها) از افتادنِ
|
||||
// ریت/امتیاز زیرِ دکمهی جمعشونده جلوگیری میکنه
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/90 via-black/60 to-transparent pl-16 pr-4 pt-16",
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 z-20 pl-16 pr-4 pt-16",
|
||||
// وقتی نوار پیشرفت ویدیو نمایش داده میشه، این بلوک باید بالای خطِ اون
|
||||
// بشینه نه روش؛ وگرنه (تصویر/ویدیوی کوتاه) فقط فاصلهی پایینِ معمولی کافیه
|
||||
showLongVideoBar
|
||||
@@ -842,7 +842,7 @@ export default function ReelsPostCard({
|
||||
/>
|
||||
)}
|
||||
|
||||
{useCommentsSheet && (
|
||||
{useCommentsSheet && (commentsOpen || commentsMountedOnce) && (
|
||||
<CommentDrawer
|
||||
isOpen={commentsOpen}
|
||||
onOpenChange={(open) => !open && setCommentsOpen(false)}
|
||||
|
||||
@@ -183,7 +183,15 @@ const Step4 = () => {
|
||||
{formik.touched.selectedType && formik.errors.selectedType && (
|
||||
<p className="text-red-500">{formik.errors.selectedType}</p>
|
||||
)}
|
||||
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
|
||||
{/* قبلاً هیچ گاردی نبود — فرمیک isSubmitting رو خودش درست مدیریت میکنه (چون
|
||||
onSubmit یه تابعِ async هست)، فقط به دکمه وصل نشده بود؛ دابلکلیک دو
|
||||
POST /projects/create همزمان میفرستاد و دو پروژهی تکراری میساخت */}
|
||||
<RoundedButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={formik.isSubmitting}
|
||||
className="mt-4 px-8 py-2"
|
||||
>
|
||||
{t("projects.newProject.submitRequest")}
|
||||
</RoundedButton>
|
||||
</form>
|
||||
|
||||
@@ -201,7 +201,7 @@ function ProjectRequestsAction({
|
||||
|
||||
{viewerUrl ? (
|
||||
<div
|
||||
className="fixed inset-0 z-[300] flex items-center justify-center bg-black/90"
|
||||
className="fixed inset-0 z-[300] flex items-center justify-center bg-transparent"
|
||||
onClick={() => setViewerUrl(null)}
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import toast from "react-hot-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { btnPrimary, btnDefault } from "@/lib/ui/buttonStyles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -27,7 +26,6 @@ export default function AccountActionModals({
|
||||
}: AccountActionModalsProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState<Step>("confirm");
|
||||
const [typed, setTyped] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -59,7 +57,8 @@ export default function AccountActionModals({
|
||||
await request("POST", "/account/delete", { user_name: typed.trim() });
|
||||
await clearAuthSession();
|
||||
toast.success(t("settings.accountActions.deleted"));
|
||||
router.push("/login");
|
||||
// navigation سخت — همون دلیلِ Logout.tsx، کَشِ کلاینتیِ روتر رو دور میزنه
|
||||
window.location.href = "/login";
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"use client";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import Modal from "../../elements/Modal";
|
||||
import RoundedButton from "../../elements/RoundedButton";
|
||||
@@ -10,11 +9,12 @@ import { useTranslation } from "react-i18next";
|
||||
function Logout() {
|
||||
const { t } = useTranslation("common");
|
||||
const [showModal, setShowModal] = useState<boolean>(false);
|
||||
const router = useRouter();
|
||||
const logoutHandler = async () => {
|
||||
await clearAuthSession();
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
// router.push (soft navigation) میتونه کَشِ کلاینتیِ روتر (RSC payload قبلیِ
|
||||
// صفحهی اصلی، از وقتی لاگین بودیم) رو دوباره نشون بده بدون اینکه میدلور
|
||||
// دوباره اجرا بشه — navigation سخت، درخواست تازه به سرور میزنه
|
||||
window.location.href = "/login";
|
||||
};
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -550,7 +550,7 @@ export default function StoryEditor({
|
||||
|
||||
{stickerModal && (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-end justify-center bg-black/60"
|
||||
className="fixed inset-0 z-[60] flex items-end justify-center bg-transparent"
|
||||
onClick={() => setStickerModal(null)}
|
||||
>
|
||||
<div
|
||||
@@ -585,7 +585,7 @@ export default function StoryEditor({
|
||||
|
||||
{emojiPickerOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-end justify-center bg-black/60"
|
||||
className="fixed inset-0 z-[60] flex items-end justify-center bg-transparent"
|
||||
onClick={() => setEmojiPickerOpen(false)}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -140,7 +140,7 @@ export default function StoryLinkPicker({
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-end justify-center bg-black/60" onClick={onClose}>
|
||||
<div className="fixed inset-0 z-[60] flex items-end justify-center bg-transparent" onClick={onClose}>
|
||||
<div
|
||||
className="max-h-[70vh] w-full max-w-md overflow-y-auto rounded-t-3xl bg-white p-4 dark:bg-neutral-900"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function StoryMentionPicker({
|
||||
}, [query, request]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-end justify-center bg-black/60" onClick={onClose}>
|
||||
<div className="fixed inset-0 z-[60] flex items-end justify-center bg-transparent" onClick={onClose}>
|
||||
<div
|
||||
className="max-h-[70vh] w-full max-w-md overflow-y-auto rounded-t-3xl bg-white p-4 dark:bg-neutral-900"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -13,13 +13,15 @@ export const REELS_RENDER_WINDOW = 5;
|
||||
* میذاشت)، اینجا فقط دقیقاً بهاندازهی ارتفاعِ خودِ همون نوار + safe-area رزرو میشه،
|
||||
* نه یه نسبتِ ابعادِ ثابت — تا پست مثلِ ریلزِ اندروید تقریباً تمامصفحه (edge-to-edge)
|
||||
* بمونه و فقط از زیرِ نوار بیرون نزنه */
|
||||
export const REELS_BOTTOM_RESERVED = "calc(96px + env(safe-area-inset-bottom))";
|
||||
// مثل اندروید: فاصلهی ثابت ۵۰px از پایینِ صفحه، بدون وابستگی به safe-area،
|
||||
// تا کادرِ ریلز و خطِ پیشرفت روی همهی گوشیها یک شکل باشن
|
||||
export const REELS_BOTTOM_RESERVED = "50px";
|
||||
|
||||
/** کلاس یکسان هر اسلات ریلز — snap + ارتفاع ثابت. خودِ اسلات کاملارتفاعه (برای
|
||||
* فاصلهگذاریِ درستِ scroll-snap)؛ محتوای واقعی (ReelsPostCard) با REELS_BOTTOM_RESERVED
|
||||
* کوتاهتر میشه و از بالا (زیرِ status bar) شروع میشه، نه وسطچین */
|
||||
export const REELS_SLOT_CLASS =
|
||||
"reels-snap-slot flex h-[100dvh] w-full shrink-0 snap-start snap-always items-start justify-center bg-black pt-[env(safe-area-inset-top)]";
|
||||
"reels-snap-slot flex h-[100dvh] w-full shrink-0 snap-start snap-always items-start justify-center bg-black";
|
||||
|
||||
/** پوسته ریلز — همعرض منوبار */
|
||||
export const REELS_SHELL_CLASS = "mx-auto h-[100dvh] w-full max-w-xl overflow-hidden";
|
||||
|
||||
@@ -3,6 +3,25 @@ import { useState, useEffect } from "react";
|
||||
import useAxios from "./useAxios";
|
||||
import { User } from "@/types/types";
|
||||
|
||||
const USER_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const userCache = new Map<string, { at: number; promise: Promise<User | undefined> }>();
|
||||
|
||||
function loadUserOnce(
|
||||
id: string,
|
||||
request: (method: "GET", url: string) => Promise<{ user: User }>
|
||||
): Promise<User | undefined> {
|
||||
const cached = userCache.get(id);
|
||||
if (cached && Date.now() - cached.at < USER_CACHE_TTL_MS) return cached.promise;
|
||||
const promise = request("GET", `/profile/${id}`)
|
||||
.then((response) => response.user)
|
||||
.catch((error) => {
|
||||
userCache.delete(id);
|
||||
throw error;
|
||||
});
|
||||
userCache.set(id, { at: Date.now(), promise });
|
||||
return promise;
|
||||
}
|
||||
|
||||
export const useUserById = (_id: string) => {
|
||||
const [user, setUser] = useState<User>();
|
||||
const { request } = useAxios();
|
||||
@@ -10,8 +29,10 @@ export const useUserById = (_id: string) => {
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const response = await request<{ user: User }>("GET", `/profile/${_id}`);
|
||||
setUser(response.user);
|
||||
const loaded = await loadUserOnce(_id, (method, url) =>
|
||||
request<{ user: User }>(method, url)
|
||||
);
|
||||
setUser(loaded);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user:", error);
|
||||
}
|
||||
|
||||
3
src/lib/e2ee/config.ts
Normal file
3
src/lib/e2ee/config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// موقتاً غیرفعال: کل پیامها (متن، فایل، ویس) بدون رمزنگاری ارسال/دریافت میشن.
|
||||
// برای برگرداندنِ e2ee فقط این مقدار را true کنید؛ کدِ اصلی دستنخورده باقی مونده.
|
||||
export const E2EE_ENABLED = false;
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
encryptText,
|
||||
importPublicKeySpkiB64,
|
||||
} from "./crypto";
|
||||
import { E2EE_ENABLED } from "./config";
|
||||
import { E2EE_LOCKED_PREVIEW, isE2eeEnvelope } from "./envelope";
|
||||
import { ensureIdentityKeys, fetchPeerPublicKey } from "./keys";
|
||||
|
||||
@@ -83,6 +84,7 @@ export async function encryptDmContent(
|
||||
peerUserId: string,
|
||||
plaintext: string
|
||||
): Promise<string | null> {
|
||||
if (!E2EE_ENABLED) return plaintext;
|
||||
const key = await getDmConversationKey(myUserId, peerUserId);
|
||||
if (!key) return null;
|
||||
return encryptText(key, plaintext);
|
||||
@@ -94,7 +96,7 @@ export async function decryptDmContent(
|
||||
content: string
|
||||
): Promise<string> {
|
||||
const raw = normalizeMessageContent(content);
|
||||
if (!isE2eeEnvelope(raw)) return raw;
|
||||
if (!E2EE_ENABLED || !isE2eeEnvelope(raw)) return raw;
|
||||
|
||||
const key = await getDmConversationKey(myUserId, peerUserId);
|
||||
if (key) {
|
||||
@@ -126,6 +128,7 @@ export async function decryptDmMessageList<
|
||||
peerUserId: string,
|
||||
messages: T[]
|
||||
): Promise<T[]> {
|
||||
if (!E2EE_ENABLED) return messages;
|
||||
const out: T[] = [];
|
||||
for (const msg of messages) {
|
||||
// خطای رمزگشایی یک پیام نباید کل صفحه را از حالت رمزگشاییشده خارج کند —
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Cookies from "js-cookie";
|
||||
import { getApiBaseUrl } from "@/components/main/BaseUrl";
|
||||
import { E2EE_ENABLED } from "./config";
|
||||
import {
|
||||
decryptPrivateKeyFromBackup,
|
||||
encryptPrivateKeyForBackup,
|
||||
@@ -184,6 +185,9 @@ export async function ensureIdentityKeys(userId: string): Promise<{
|
||||
privateKey: CryptoKey;
|
||||
publicKeyB64: string;
|
||||
}> {
|
||||
if (!E2EE_ENABLED) {
|
||||
return { privateKey: undefined as unknown as CryptoKey, publicKeyB64: "" };
|
||||
}
|
||||
const uid = String(userId);
|
||||
if (memoryPair?.userId === uid) {
|
||||
return {
|
||||
@@ -306,6 +310,7 @@ export async function establishIdentityAtLogin(
|
||||
userId: string,
|
||||
password: string
|
||||
): Promise<void> {
|
||||
if (!E2EE_ENABLED) return;
|
||||
const uid = String(userId);
|
||||
try {
|
||||
await ensureIdentityKeys(uid);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
unwrapRoomKey,
|
||||
wrapRoomKeyForRecipient,
|
||||
} from "./crypto";
|
||||
import { E2EE_ENABLED } from "./config";
|
||||
import { E2EE_LOCKED_PREVIEW, isE2eeEnvelope } from "./envelope";
|
||||
import { ensureIdentityKeys } from "./keys";
|
||||
import { b64ToBuf, bufToB64, idbGet, idbSet } from "./storage";
|
||||
@@ -209,6 +210,7 @@ export async function ensureRoomAesKey(
|
||||
/** Bypass the in-memory/IndexedDB cache and re-unwrap from the server's current wrapped key. */
|
||||
forceFresh = false,
|
||||
): Promise<CryptoKey | null> {
|
||||
if (!E2EE_ENABLED) return null;
|
||||
if (!forceFresh) {
|
||||
const cached = roomAesCache.get(roomId);
|
||||
if (cached) return cached;
|
||||
@@ -229,6 +231,7 @@ export async function encryptRoomContent(
|
||||
roomId: string,
|
||||
plaintext: string,
|
||||
): Promise<string | null> {
|
||||
if (!E2EE_ENABLED) return plaintext;
|
||||
const key = await ensureRoomAesKey(myUserId, roomId);
|
||||
if (!key) return null;
|
||||
return encryptText(key, plaintext);
|
||||
@@ -239,7 +242,7 @@ export async function decryptRoomContent(
|
||||
roomId: string,
|
||||
content: string,
|
||||
): Promise<string> {
|
||||
if (!isE2eeEnvelope(content)) return content;
|
||||
if (!E2EE_ENABLED || !isE2eeEnvelope(content)) return content;
|
||||
|
||||
const key = await ensureRoomAesKey(myUserId, roomId);
|
||||
if (key) {
|
||||
@@ -263,6 +266,7 @@ export async function decryptRoomMessageList<
|
||||
replyTo?: { content?: string; [k: string]: unknown } | null;
|
||||
},
|
||||
>(myUserId: string, roomId: string, messages: T[]): Promise<T[]> {
|
||||
if (!E2EE_ENABLED) return messages;
|
||||
await ensureRoomAesKey(myUserId, roomId);
|
||||
const out: T[] = [];
|
||||
for (const msg of messages) {
|
||||
|
||||
@@ -17,8 +17,8 @@ export const GLASS_BACKDROP: Record<string, CSSProperties> = {
|
||||
"glass-panel-strong": { backdropFilter: "blur(24px) saturate(1.4)", WebkitBackdropFilter: "blur(24px) saturate(1.4)" },
|
||||
"comments-modal-glass": { backdropFilter: "blur(28px) saturate(1.5)", WebkitBackdropFilter: "blur(28px) saturate(1.5)" },
|
||||
"tab-active-glass": { backdropFilter: "blur(12px)", WebkitBackdropFilter: "blur(12px)" },
|
||||
"glass-modal-overlay": { backdropFilter: "blur(20px) saturate(120%)", WebkitBackdropFilter: "blur(20px) saturate(120%)" },
|
||||
"glass-modal-overlay--dark": { backdropFilter: "blur(24px) saturate(110%)", WebkitBackdropFilter: "blur(24px) saturate(110%)" },
|
||||
"glass-modal-overlay": { backdropFilter: "none", WebkitBackdropFilter: "none" },
|
||||
"glass-modal-overlay--dark": { backdropFilter: "none", WebkitBackdropFilter: "none" },
|
||||
"glass-modal-panel": { backdropFilter: "blur(28px) saturate(180%)", WebkitBackdropFilter: "blur(28px) saturate(180%)" },
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,16 @@ import { Post } from "@/types/types";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { resolvePostMediaType } from "@/lib/reelsMediaType";
|
||||
|
||||
const MAX_WARM_VIDEOS = 8;
|
||||
const MAX_WARM_VIDEOS = 3;
|
||||
|
||||
export function isConstrainedNetwork(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
const connection = (navigator as Navigator & {
|
||||
connection?: { saveData?: boolean; effectiveType?: string };
|
||||
}).connection;
|
||||
if (!connection) return false;
|
||||
return connection.saveData === true || ["slow-2g", "2g", "3g"].includes(connection.effectiveType ?? "");
|
||||
}
|
||||
const warmVideos = new Map<string, HTMLVideoElement>();
|
||||
const prefetchedLinks = new Set<string>();
|
||||
|
||||
@@ -23,7 +32,7 @@ function evictOldestWarmVideo() {
|
||||
}
|
||||
|
||||
function warmVideoElement(url: string) {
|
||||
if (typeof window === "undefined" || !url || warmVideos.has(url)) return;
|
||||
if (typeof window === "undefined" || !url || warmVideos.has(url) || isConstrainedNetwork()) return;
|
||||
|
||||
const video = document.createElement("video");
|
||||
video.preload = "auto";
|
||||
@@ -69,7 +78,7 @@ export function prefetchPostVideo(
|
||||
post: Pick<Post, "files" | "type" | "_id"> | null | undefined,
|
||||
priority: VideoPrefetchPriority = "high"
|
||||
) {
|
||||
prefetchVideoUrl(getPostVideoSrc(post ?? undefined), priority);
|
||||
prefetchVideoUrl(post ? getPostVideoSrc(post) : undefined, priority);
|
||||
}
|
||||
|
||||
export function prefetchPostVideos(
|
||||
|
||||
@@ -215,8 +215,9 @@
|
||||
},
|
||||
"settings": {
|
||||
"vpnWarningTitle": "VPN usage detected",
|
||||
"vpnWarningMessage": "For a better experience on the Modstagram platform, please turn off your VPN.",
|
||||
"vpnWarningAction": "Got it",
|
||||
"vpnWarningMessage": "Turn off your VPN for a better experience with Modstagram",
|
||||
"vpnWarningAction": "Confirm",
|
||||
"vpnWarningDontShowAgain": "Don't show again",
|
||||
"title": "Settings",
|
||||
"metaTitle": "Settings | Modstagram",
|
||||
"metaDescription": "Account settings on Modstagram",
|
||||
@@ -1183,6 +1184,9 @@
|
||||
"savePasswordFailed": "Could not save password",
|
||||
"registerCompleteTitle": "Initial registration complete",
|
||||
"registerCompleteDesc": "You can enter the app or continue full registration (identity verification and profile).",
|
||||
"guestPromptMessage": "Log in or sign up to see more and use all of Modstagram's features",
|
||||
"guestPromptLogin": "Log in",
|
||||
"guestPromptRegister": "Sign up",
|
||||
"identityVerification": "Identity verification",
|
||||
"forIdentityVerification": "For identity verification",
|
||||
"nationalCodePlaceholder": "National ID",
|
||||
|
||||
@@ -215,8 +215,9 @@
|
||||
},
|
||||
"settings": {
|
||||
"vpnWarningTitle": "استفاده از VPN شناسایی شد",
|
||||
"vpnWarningMessage": "برای تجربه بهتر از پلتفرم مدستاگرام، لطفاً VPN خود را خاموش کنید.",
|
||||
"vpnWarningAction": "متوجه شدم",
|
||||
"vpnWarningMessage": "برای تجربه بهتر از پلتفرم مدستاگرام VPN خود را خاموش کنید",
|
||||
"vpnWarningAction": "تایید",
|
||||
"vpnWarningDontShowAgain": "دیگر نشان نده",
|
||||
"title": "تنظیمات",
|
||||
"metaTitle": "تنظیمات | مدستاگرام",
|
||||
"metaDescription": "تنظیمات حساب کاربری در مدستاگرام",
|
||||
@@ -1183,6 +1184,9 @@
|
||||
"savePasswordFailed": "ذخیره کلمه عبور ناموفق بود",
|
||||
"registerCompleteTitle": "ثبتنام اولیه تکمیل شد",
|
||||
"registerCompleteDesc": "میتوانید وارد برنامه شوید یا ثبتنام کامل (تایید هویت و پروفایل) را ادامه دهید.",
|
||||
"guestPromptMessage": "برای دیدن کامل و استفاده از امکانات مدستاگرام وارد شوید یا ثبتنام کنید",
|
||||
"guestPromptLogin": "ورود",
|
||||
"guestPromptRegister": "ثبتنام",
|
||||
"identityVerification": "احراز هویت",
|
||||
"forIdentityVerification": "جهت احراز هویت",
|
||||
"nationalCodePlaceholder": "کد ملی",
|
||||
|
||||
@@ -1,82 +1,127 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
buildLoginRedirect,
|
||||
requiresRegistrationToken,
|
||||
} from "@/lib/auth/registrationRoutes";
|
||||
import { LANGUAGE_COOKIE } from "@/lib/i18n/registry";
|
||||
|
||||
// فعلاً فقط en بهصورت prefix مسیر پشتیبانی میشود؛ fa همچنان بدون prefix
|
||||
// (روش سبک: بدون تغییر ساختار src/app، فقط rewrite داخلی + هدر زبان)
|
||||
const LOCALE_PREFIXES = ["en"] as const;
|
||||
type LocalePrefix = (typeof LOCALE_PREFIXES)[number];
|
||||
|
||||
function stripLocalePrefix(pathname: string): {
|
||||
locale: LocalePrefix | null;
|
||||
path: string;
|
||||
} {
|
||||
for (const locale of LOCALE_PREFIXES) {
|
||||
if (pathname === `/${locale}`) return { locale, path: "/" };
|
||||
if (pathname.startsWith(`/${locale}/`)) {
|
||||
return { locale, path: pathname.slice(locale.length + 1) };
|
||||
}
|
||||
}
|
||||
return { locale: null, path: pathname };
|
||||
}
|
||||
|
||||
export function middleware(req: NextRequest) {
|
||||
const isPrefetch =
|
||||
req.headers.get("next-router-prefetch") === "1" ||
|
||||
req.headers.get("purpose") === "prefetch";
|
||||
|
||||
const { locale, path: pathname } = stripLocalePrefix(req.nextUrl.pathname);
|
||||
const localePrefix = locale ? `/${locale}` : "";
|
||||
|
||||
// prefetch بدون کوکی نباید redirect به /login کش شود
|
||||
if (!isPrefetch) {
|
||||
const token = req.cookies.get("token")?.value?.trim();
|
||||
|
||||
// صفحاتی که ذاتاً نیازمند ورود هستند (حساب کاربری و ساخت محتوا)
|
||||
const protectedPaths = ["/settings", "/new-post", "/new-project"];
|
||||
const needsAuth = protectedPaths.some((p) => pathname.startsWith(p));
|
||||
|
||||
if (needsAuth && !token) {
|
||||
const loginUrl = new URL(`${localePrefix}/login`, req.url);
|
||||
loginUrl.searchParams.set("redirect", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
// مراحل میانی ثبتنام بدون توکن → ورود
|
||||
if (requiresRegistrationToken(pathname) && !token) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`${localePrefix}${buildLoginRedirect(pathname)}`, req.url)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (locale) {
|
||||
const rewriteUrl = req.nextUrl.clone();
|
||||
rewriteUrl.pathname = pathname;
|
||||
|
||||
const requestHeaders = new Headers(req.headers);
|
||||
requestHeaders.set("x-app-language", locale);
|
||||
|
||||
const res = NextResponse.rewrite(rewriteUrl, {
|
||||
request: { headers: requestHeaders },
|
||||
});
|
||||
// برای اینکه ناوبریهای بعدی (بدون prefix) هم انگلیسی بمونن، مثل انتخاب
|
||||
// دستی زبان از سوییچر عمل میکنیم
|
||||
res.cookies.set(LANGUAGE_COOKIE, locale, {
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
sameSite: "lax",
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)"],
|
||||
};
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
buildLoginRedirect,
|
||||
requiresRegistrationToken,
|
||||
} from "@/lib/auth/registrationRoutes";
|
||||
import { LANGUAGE_COOKIE } from "@/lib/i18n/registry";
|
||||
|
||||
// فعلاً فقط en بهصورت prefix مسیر پشتیبانی میشود؛ fa همچنان بدون prefix
|
||||
// (روش سبک: بدون تغییر ساختار src/app، فقط rewrite داخلی + هدر زبان)
|
||||
const LOCALE_PREFIXES = ["en"] as const;
|
||||
type LocalePrefix = (typeof LOCALE_PREFIXES)[number];
|
||||
|
||||
function stripLocalePrefix(pathname: string): {
|
||||
locale: LocalePrefix | null;
|
||||
path: string;
|
||||
} {
|
||||
for (const locale of LOCALE_PREFIXES) {
|
||||
if (pathname === `/${locale}`) return { locale, path: "/" };
|
||||
if (pathname.startsWith(`/${locale}/`)) {
|
||||
return { locale, path: pathname.slice(locale.length + 1) };
|
||||
}
|
||||
}
|
||||
return { locale: null, path: pathname };
|
||||
}
|
||||
|
||||
// صفحاتی که کاربر مهمان (بدون توکن) همیشه باید بتونه ببینه: خودِ صفحات
|
||||
// ورود/ثبتنام/قانونی، و callback اکانتِ گوگل/اینستاگرام
|
||||
const PUBLIC_EXACT_PATHS = new Set([
|
||||
"/login",
|
||||
"/login-with-username",
|
||||
"/login-2fa",
|
||||
"/forget-password",
|
||||
"/forget-password-otp",
|
||||
"/change-passowrd",
|
||||
"/verify-otp",
|
||||
"/register",
|
||||
"/register-otp",
|
||||
"/about-us",
|
||||
"/privacy",
|
||||
"/instagram-data-deletion",
|
||||
]);
|
||||
|
||||
const PUBLIC_PREFIXES = ["/auth/", "/sitemaps/", "/storage/"];
|
||||
|
||||
// صفحهی تکیِ یک پست/ریلز/بیلبورد/پروژه که بهاشتراک گذاشته میشه — باید عین
|
||||
// اینستاگرام برای گوگل و کاربرِ مهمان قابلدیدن بمونه (SEO + پیشنمایشِ لینکِ
|
||||
// اشتراکگذاری)؛ خودِ مودالِ ورود/ثبتنام و قفلبودنِ اقدامهایی مثل لایک سمتِ
|
||||
// کلاینت مدیریت میشه، نه اینجا
|
||||
const RESERVED_FIRST_SEGMENT: Record<string, Set<string>> = {
|
||||
billboards: new Set(["new", "payment", "profile"]),
|
||||
projects: new Set(["new", "payment", "specialists"]),
|
||||
};
|
||||
|
||||
function isSharedDetailPath(pathname: string, root: "billboards" | "projects"): boolean {
|
||||
const match = pathname.match(new RegExp(`^/${root}/([^/]+)/([^/]+)$`));
|
||||
if (!match) return false;
|
||||
return !RESERVED_FIRST_SEGMENT[root].has(match[1]);
|
||||
}
|
||||
|
||||
function isPublicGuestPath(pathname: string): boolean {
|
||||
if (PUBLIC_EXACT_PATHS.has(pathname)) return true;
|
||||
if (PUBLIC_PREFIXES.some((p) => pathname.startsWith(p))) return true;
|
||||
if (/^\/posts\/[^/]+(\/.*)?$/.test(pathname)) return true;
|
||||
if (/^\/explore\/[^/]+$/.test(pathname)) return true;
|
||||
if (isSharedDetailPath(pathname, "billboards")) return true;
|
||||
if (isSharedDetailPath(pathname, "projects")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function middleware(req: NextRequest) {
|
||||
const isPrefetch =
|
||||
req.headers.get("next-router-prefetch") === "1" ||
|
||||
req.headers.get("purpose") === "prefetch";
|
||||
|
||||
const { locale, path: pathname } = stripLocalePrefix(req.nextUrl.pathname);
|
||||
const localePrefix = locale ? `/${locale}` : "";
|
||||
|
||||
// prefetch بدون کوکی نباید redirect به /login کش شود
|
||||
if (!isPrefetch) {
|
||||
const token = req.cookies.get("token")?.value?.trim();
|
||||
|
||||
if (!token) {
|
||||
// مراحل میانی ثبتنام: نیاز به توکنِ مرحلهایِ خودِ ثبتنام (redirect با پارامترِ
|
||||
// بازگشتِ دقیق به همون مرحله)
|
||||
if (requiresRegistrationToken(pathname)) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`${localePrefix}${buildLoginRedirect(pathname)}`, req.url)
|
||||
);
|
||||
}
|
||||
|
||||
// کاربر مهمان مستقیم وارد خودِ اپ نمیشه — اول باید ورود/ثبتنام کنه؛ فقط
|
||||
// صفحاتِ عمومی (ورود/ثبتنام/قانونی) و صفحهی تکیِ اشتراکگذاریشده
|
||||
// (پست/ریلز/بیلبورد/پروژه) از این قاعده مستثنیاند
|
||||
if (!isPublicGuestPath(pathname)) {
|
||||
const loginUrl = new URL(`${localePrefix}/login`, req.url);
|
||||
loginUrl.searchParams.set("redirect", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (locale) {
|
||||
const rewriteUrl = req.nextUrl.clone();
|
||||
rewriteUrl.pathname = pathname;
|
||||
|
||||
const requestHeaders = new Headers(req.headers);
|
||||
requestHeaders.set("x-app-language", locale);
|
||||
|
||||
const res = NextResponse.rewrite(rewriteUrl, {
|
||||
request: { headers: requestHeaders },
|
||||
});
|
||||
// برای اینکه ناوبریهای بعدی (بدون prefix) هم انگلیسی بمونن، مثل انتخاب
|
||||
// دستی زبان از سوییچر عمل میکنیم
|
||||
res.cookies.set(LANGUAGE_COOKIE, locale, {
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
sameSite: "lax",
|
||||
});
|
||||
return res;
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)"],
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ export default function ChatSocketProvider({
|
||||
{/* ensureIdentityKeys اینجا (تویِ خودِ گیت) صدا زده میشه، نه اینجا مستقیم —
|
||||
تا اگه هویتِ قبلی روی سرور هست و این دستگاه بهش دسترسی نداره، بهجای
|
||||
fail شدنِ بیصدا، از کاربر بازیابی/تاییدِ صریح خواسته بشه */}
|
||||
{uid ? <E2eeIdentityGate userId={uid} /> : null}
|
||||
{/* [E2EE] موقتاً غیرفعال: {uid ? <E2eeIdentityGate userId={uid} /> : null} */}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user