Compare commits
5 Commits
200b387504
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
551260a836 | ||
|
|
f364f989ed | ||
|
|
d6386b779f | ||
|
|
9fb454b0a7 | ||
|
|
6e76e367f3 |
Submodule .claude/worktrees/agent-af1dfc8fe9a6cb198 deleted from 3b89bbfec3
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "modstagram-next",
|
||||
"version": "4.1.5",
|
||||
"version": "4.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "modstagram-next",
|
||||
"version": "4.1.5",
|
||||
"version": "4.2.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modstagram-next",
|
||||
"version": "4.1.5",
|
||||
"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;
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import toast from "react-hot-toast";
|
||||
import { useAuthSessionRedirect } from "@/hooks/useAuthSessionRedirect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import { establishIdentityAtLogin } from "@/lib/e2ee";
|
||||
|
||||
function LoginWithUsername() {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
@@ -66,6 +67,12 @@ function LoginWithUsername() {
|
||||
{ noToast: true }
|
||||
)) as IVerifyOtp;
|
||||
|
||||
// قبل از اینکه password از scope خارج بشه، کلیدِ E2EE رو زیرِ همین رمز
|
||||
// بکآپ/بازیابی کن — best-effort، نباید جلویِ خودِ لاگین رو بگیره
|
||||
if (response.id) {
|
||||
// [E2EE] موقتاً غیرفعال — void establishIdentityAtLogin(response.id, values.password);
|
||||
}
|
||||
|
||||
const loggedIn = await completeLogin(router, response, {
|
||||
redirectTo: redirectPath,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -17,6 +17,8 @@ import AuthNextButton from "@/components/auth/AuthNextButton";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import { getStoredUserId } from "@/lib/auth/session";
|
||||
import { establishIdentityAtLogin } from "@/lib/e2ee";
|
||||
|
||||
function PasswordPage() {
|
||||
const { t } = useTranslation("common");
|
||||
@@ -53,6 +55,9 @@ function PasswordPage() {
|
||||
(await request("POST", "/register/password", {
|
||||
new_password: values.password,
|
||||
})) as IVerifyOtp;
|
||||
// اولین رمزِ عبورِ این کاربره — همین الان کلیدِ E2EE رو زیرش بکآپ بگیر
|
||||
const userId = getStoredUserId();
|
||||
// [E2EE] موقتاً غیرفعال — if (userId) void establishIdentityAtLogin(userId, values.password);
|
||||
router.push("/register/fullname");
|
||||
} catch (err: any) {
|
||||
const message =
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -12,7 +12,7 @@ import Container from "@/components/elements/Container";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import SpecialistCarousel from "@/components/projects/SpecialistCarousel";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SuggestedFollowUser } from "@/components/posts/FollowingSuggestionsCarousel";
|
||||
@@ -70,9 +70,6 @@ function SpecialistCard({
|
||||
const media = user.previewPost
|
||||
? getPostMedia(user.previewPost as Post)
|
||||
: null;
|
||||
const profileUrl = user.profile_image
|
||||
? buildStorageUrl(user.profile_image)
|
||||
: null;
|
||||
const isMain = variant === "main";
|
||||
const profileHref = user.user_name ? `/users/${user.user_name}` : "#";
|
||||
|
||||
@@ -130,25 +127,13 @@ function SpecialistCard({
|
||||
isMain ? "px-4 pb-5" : "px-2 pb-3"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative mb-2 overflow-hidden rounded-full ring-2 ring-white/90",
|
||||
isMain ? "h-16 w-16" : "h-9 w-9"
|
||||
)}
|
||||
>
|
||||
{profileUrl ? (
|
||||
<Image
|
||||
src={profileUrl}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes={isMain ? "64px" : "36px"}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-neutral-700 text-white">
|
||||
{displayName.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-2 overflow-hidden rounded-full ring-2 ring-white/90">
|
||||
<ProfileAvatar
|
||||
src={user.profile_image}
|
||||
userId={user._id}
|
||||
sizePx={isMain ? 64 : 36}
|
||||
rounded="full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isMain ? (
|
||||
|
||||
@@ -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 {
|
||||
@@ -712,6 +726,10 @@ select {
|
||||
.story-ring-live {
|
||||
animation: story-ring-spin 3s linear infinite;
|
||||
}
|
||||
/* حلقهی «در حالِ آپلودِ استوری» — چرخشِ سریعتر، عینِ لودینگِ ثبتِ استوری اینستاگرام */
|
||||
.story-ring-uploading {
|
||||
animation: story-ring-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
/* Message enter — handled by framer-motion in ChatMessageCard */
|
||||
|
||||
@@ -797,22 +815,20 @@ select {
|
||||
}
|
||||
|
||||
.glass-modal-overlay {
|
||||
background: rgba(8, 8, 10, 0.52);
|
||||
backdrop-filter: blur(20px) saturate(120%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(120%);
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
.dark .glass-modal-overlay {
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
backdrop-filter: blur(22px) saturate(110%);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(110%);
|
||||
background: transparent;
|
||||
}
|
||||
.glass-modal-overlay--dark {
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
backdrop-filter: blur(24px) saturate(110%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(110%);
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
.dark .glass-modal-overlay--dark {
|
||||
background: rgba(0, 0, 0, 0.82);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.glass-modal-panel {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { optimizeImageToWebP, optimizeVideoToMp4, isImageAspectRatioAllowed } from "@/lib/media";
|
||||
import { createStoryBase64, StoryOverlay } from "@/api/fetchStories";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { setPendingStoryUpload } from "@/lib/stories/pendingStoryUpload";
|
||||
import StoryEditor from "@/components/stories/StoryEditor";
|
||||
import StoryCameraCapture from "@/components/stories/StoryCameraCapture";
|
||||
import TagUsersPicker, { TaggedUser } from "@/components/posts/TagUsersPicker";
|
||||
@@ -62,6 +64,7 @@ export default function NewPostPage() {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
const captionLang = i18n.language === "en" ? "en" : "fa";
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const searchParams = useSearchParams();
|
||||
const { request, loading } = useAxios();
|
||||
const user = useUser();
|
||||
@@ -303,39 +306,49 @@ export default function NewPostPage() {
|
||||
toast.error(t("posts.storyMediaRequired"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
toast.loading(t("posts.publishingStory"), { id: "story-upload" });
|
||||
let optimized: File;
|
||||
if (file.type.startsWith("video/")) {
|
||||
// اگر تبدیل ویدیو (ffmpeg) در دسترس نبود، فایل اصلی ارسال میشود
|
||||
try {
|
||||
optimized = await optimizeVideoToMp4(file);
|
||||
} catch {
|
||||
optimized = file;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
optimized = await optimizeImageToWebP(file);
|
||||
} catch {
|
||||
optimized = file;
|
||||
|
||||
// عین اینستاگرام: بلافاصله به فید برمیگردیم و آپلود پسزمینه ادامه پیدا میکنه —
|
||||
// StoriesBar با خوندنِ همین پرچمِ pending، حلقهی چرخانِ «در حال آپلود» رو نشون
|
||||
// میده تا وقتی createStoryBase64 واقعاً تموم بشه، بدون اینکه کاربر منتظر بمونه
|
||||
const pendingFile = file;
|
||||
const pendingOverlays = storyOverlays;
|
||||
setPendingStoryUpload(queryClient, true);
|
||||
router.push("/");
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
let optimized: File;
|
||||
if (pendingFile.type.startsWith("video/")) {
|
||||
// اگر تبدیل ویدیو (ffmpeg) در دسترس نبود، فایل اصلی ارسال میشود
|
||||
try {
|
||||
optimized = await optimizeVideoToMp4(pendingFile);
|
||||
} catch {
|
||||
optimized = pendingFile;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
optimized = await optimizeImageToWebP(pendingFile);
|
||||
} catch {
|
||||
optimized = pendingFile;
|
||||
}
|
||||
}
|
||||
await createStoryBase64(
|
||||
{
|
||||
name: optimized.name,
|
||||
type: optimized.type,
|
||||
data: await fileToBase64(optimized),
|
||||
},
|
||||
token,
|
||||
pendingOverlays
|
||||
);
|
||||
toast.success(t("posts.storyPublished"));
|
||||
void queryClient.invalidateQueries({ queryKey: ["stories-feed"] });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t("posts.storyPublishError"));
|
||||
} finally {
|
||||
setPendingStoryUpload(queryClient, false);
|
||||
}
|
||||
await createStoryBase64(
|
||||
{
|
||||
name: optimized.name,
|
||||
type: optimized.type,
|
||||
data: await fileToBase64(optimized),
|
||||
},
|
||||
token,
|
||||
storyOverlays
|
||||
);
|
||||
toast.success(t("posts.storyPublished"), { id: "story-upload" });
|
||||
router.push("/");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t("posts.storyPublishError"), {
|
||||
id: "story-upload",
|
||||
});
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,316 +1,343 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import RoundedDiv from "@/components/elements/RoundedDiv";
|
||||
import { selectionCardClass } from "@/lib/ui/buttonStyles";
|
||||
import { BASE_URL } from "@/components/main/BaseUrl";
|
||||
import UserInfo from "@/components/main/UserInfo";
|
||||
import ScoreRateStats from "@/components/main/ScoreRateStats";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { IOfferType, User } from "@/types/types";
|
||||
import axios from "axios";
|
||||
import { useFormik } from "formik";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import Cookies from "js-cookie";
|
||||
import * as Yup from "yup";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import WalletUsagePrompt from "@/components/payment/WalletUsagePrompt";
|
||||
type OfferTypeRow = IOfferType & {
|
||||
disabled?: boolean;
|
||||
unavailableReason?: string | null;
|
||||
};
|
||||
|
||||
interface ITicketChatProps {
|
||||
params: Promise<{ id: string; title: string }>;
|
||||
}
|
||||
|
||||
const FALLBACK_TYPES: OfferTypeRow[] = [
|
||||
{ _id: "normal", name: "normal", price: 50000 },
|
||||
{ _id: "free", name: "free", price: 0 },
|
||||
];
|
||||
|
||||
function sortOfferTypes(list: OfferTypeRow[]): OfferTypeRow[] {
|
||||
const order = ["normal", "special", "highlight", "free"];
|
||||
return [...list].sort((a, b) => {
|
||||
const ai = order.indexOf(a.name);
|
||||
const bi = order.indexOf(b.name);
|
||||
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
||||
});
|
||||
}
|
||||
|
||||
function TicketChat({ params }: ITicketChatProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const resolvedParams = React.use(params);
|
||||
const { request } = useAxios();
|
||||
const { id } = resolvedParams;
|
||||
const router = useRouter();
|
||||
const me = useUser();
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
selectedType: Yup.string().required(t("offerPage.typeRequired")),
|
||||
});
|
||||
|
||||
const [userDetail, setUserDetail] = useState<User>();
|
||||
const [selectedType, setSelectedType] = useState("normal");
|
||||
const [typeList, setTypeList] = useState<OfferTypeRow[] | null>(null);
|
||||
const [hasUsedFreeOffer, setHasUsedFreeOffer] = useState(false);
|
||||
const [walletBalance, setWalletBalance] = useState(0);
|
||||
const [showWalletPrompt, setShowWalletPrompt] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
const response = await request<{ user: User }>(
|
||||
"GET",
|
||||
`/users/get?user_id=${id}`
|
||||
);
|
||||
setUserDetail(response?.user);
|
||||
};
|
||||
void fetchUser();
|
||||
request<{ charged?: { available?: number } }>("GET", "/wallet/summary")
|
||||
.then((res) => setWalletBalance(res?.charged?.available || 0))
|
||||
.catch(() => setWalletBalance(0));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id, request]);
|
||||
|
||||
useEffect(() => {
|
||||
const monthly = Number(me?.monthly_free_offer ?? 1);
|
||||
setHasUsedFreeOffer(monthly <= 0);
|
||||
}, [me]);
|
||||
|
||||
const fetchOfferTypes = async () => {
|
||||
try {
|
||||
const response = await request<{
|
||||
offerTypes: OfferTypeRow[];
|
||||
monthly_free_offer?: number;
|
||||
}>("GET", "/offers/types");
|
||||
const list = sortOfferTypes(
|
||||
response?.offerTypes?.length ? response.offerTypes : FALLBACK_TYPES
|
||||
);
|
||||
setTypeList(list);
|
||||
if (typeof response?.monthly_free_offer === "number") {
|
||||
setHasUsedFreeOffer(response.monthly_free_offer <= 0);
|
||||
}
|
||||
// همیشه پیشفرض روی پولی (normal)
|
||||
const paid =
|
||||
list.find((item) => item.name === "normal" && !item.disabled) ||
|
||||
list.find((item) => item.name !== "free" && !item.disabled);
|
||||
setSelectedType(paid?.name || "normal");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
setTypeList(FALLBACK_TYPES);
|
||||
setSelectedType("normal");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void fetchOfferTypes();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const getToken = (): string => {
|
||||
if (typeof window !== "undefined") {
|
||||
const token = Cookies.get("token");
|
||||
return token ? `Bearer ${token}` : "";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const startPayment = async (offerType: string, useWallet: boolean) => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/offers/initiate-payment-web`,
|
||||
{
|
||||
offerType,
|
||||
receiverId: id,
|
||||
useWallet,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: getToken(),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const data: {
|
||||
authority?: string;
|
||||
paymentUrl?: string;
|
||||
type?: string;
|
||||
paidFromWallet?: boolean;
|
||||
} = response.data;
|
||||
|
||||
if (offerType === "free" || data.type === "free") {
|
||||
toast.success(t("offerPage.freeSuccess"));
|
||||
setHasUsedFreeOffer(true);
|
||||
router.push(`/offer/payment/success?userId=${id}`);
|
||||
} else if (data.paidFromWallet) {
|
||||
toast.success(t("wallet.paidFromWalletSuccess"));
|
||||
router.push(`/offer/payment/success?userId=${id}`);
|
||||
} else if (data.paymentUrl) {
|
||||
// window.open بعدِ await در سافاری (دسکتاپ/iOS) پاپآپبلاک میشه چون دیگه
|
||||
// synchronously به کلیکِ کاربر متصل نیست؛ ضمناً بازگشتِ ZarinPal (از طریق
|
||||
// offer/payment-web/route.js) کلِ همون تب رو به "/" ریدایرکت میکنه، نه تبِ
|
||||
// اصلی — پس با _blank دو تب باز میموند. window.location.href هم امنتره
|
||||
// هم با بقیهی مسیرهای پرداختِ این اپ یکی میمونه
|
||||
window.location.href = data.paymentUrl;
|
||||
} else if (data.authority) {
|
||||
window.location.href = `https://www.zarinpal.com/pg/StartPay/${data.authority}`;
|
||||
} else {
|
||||
toast.error(t("offerPage.paymentInfoError"));
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Payment initiation error:", err);
|
||||
toast.error(
|
||||
err?.response?.data?.error ||
|
||||
err?.response?.data?.message ||
|
||||
t("offerPage.paymentStartError")
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
selectedType: "normal",
|
||||
},
|
||||
validationSchema,
|
||||
enableReinitialize: true,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
const chosen = typeList?.find((t) => t.name === values.selectedType);
|
||||
if (
|
||||
chosen?.disabled ||
|
||||
(values.selectedType === "free" && hasUsedFreeOffer)
|
||||
) {
|
||||
toast.error(
|
||||
chosen?.unavailableReason || t("offerPage.freeAlreadyUsed")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (values.selectedType !== "free" && walletBalance > 0) {
|
||||
setShowWalletPrompt(true);
|
||||
return;
|
||||
}
|
||||
await startPayment(values.selectedType, false);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void formik.setFieldValue("selectedType", selectedType);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedType]);
|
||||
|
||||
const displayTypes = useMemo(
|
||||
() => sortOfferTypes(typeList || FALLBACK_TYPES),
|
||||
[typeList]
|
||||
);
|
||||
|
||||
const typeLabel = (name: string) => {
|
||||
if (name === "normal") return t("offerPage.types.normal");
|
||||
if (name === "free") return t("offerPage.types.free");
|
||||
if (name === "special") return t("offerPage.types.special");
|
||||
return t("offerPage.types.highlight");
|
||||
};
|
||||
|
||||
const userHeader = userDetail ? (
|
||||
<div className="flex w-full items-end justify-between">
|
||||
<UserInfo
|
||||
first_name={userDetail?.first_name}
|
||||
is_verified={userDetail?.verify_badge}
|
||||
userId={userDetail?._id}
|
||||
profile_image={userDetail?.profile_image}
|
||||
user_level={userDetail?.user_level}
|
||||
last_name={userDetail?.last_name}
|
||||
user_name={userDetail?.user_name}
|
||||
noLink
|
||||
trailingContent={
|
||||
<ScoreRateStats score={userDetail?.user_score} rate={userDetail?.rate} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="mx-3 mb-10">
|
||||
<PageTitle>{t("offerPage.paymentTitle")}</PageTitle>
|
||||
|
||||
<form
|
||||
className="flex w-full flex-col items-center gap-2 text-sm"
|
||||
onSubmit={formik.handleSubmit}
|
||||
>
|
||||
{displayTypes.map((item) => {
|
||||
const isFree = item.name === "free";
|
||||
const isDisabled =
|
||||
Boolean(item.disabled) || (isFree && hasUsedFreeOffer);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item?._id || item.name}
|
||||
className="mt-8 flex w-full max-w-md cursor-pointer flex-col items-center gap-4"
|
||||
onClick={() => {
|
||||
if (isDisabled) return;
|
||||
setSelectedType(item.name);
|
||||
void formik.setFieldValue("selectedType", item.name);
|
||||
}}
|
||||
>
|
||||
{userHeader}
|
||||
<RoundedDiv
|
||||
className={`${selectionCardClass(selectedType === item?.name)} ${
|
||||
isDisabled ? "cursor-not-allowed opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
{typeLabel(item.name)}
|
||||
{Number(item.price) !== 0
|
||||
? ": " +
|
||||
Number(item.price).toLocaleString() +
|
||||
t("offerPage.currencySuffix")
|
||||
: ""}
|
||||
</RoundedDiv>
|
||||
{isDisabled && item.unavailableReason ? (
|
||||
<p className="text-center text-[11px] text-red-500">
|
||||
{item.unavailableReason}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<RoundedButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="mt-4 px-8 py-2"
|
||||
disabled={formik.isSubmitting}
|
||||
>
|
||||
{t("offerPage.submitRequest")}
|
||||
</RoundedButton>
|
||||
</form>
|
||||
|
||||
<WalletUsagePrompt
|
||||
isOpen={showWalletPrompt}
|
||||
amount={
|
||||
Number(
|
||||
displayTypes.find((item) => item.name === selectedType)?.price
|
||||
) || 0
|
||||
}
|
||||
walletBalance={walletBalance}
|
||||
onAnswer={(useWallet) => {
|
||||
setShowWalletPrompt(false);
|
||||
void startPayment(selectedType, useWallet);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default TicketChat;
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { BASE_URL } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import VerificationBadge from "@/components/main/VerificationBadge";
|
||||
import UserInfoClient from "@/components/main/UserInfoClient";
|
||||
import ScoreRateStats from "@/components/main/ScoreRateStats";
|
||||
import { formatFullName } from "@/lib/formatFullName";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { IOfferType, User } from "@/types/types";
|
||||
import axios from "axios";
|
||||
import { useFormik } from "formik";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import Cookies from "js-cookie";
|
||||
import * as Yup from "yup";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import WalletUsagePrompt from "@/components/payment/WalletUsagePrompt";
|
||||
import CompleteSocialSignupModal from "@/components/auth/CompleteSocialSignupModal";
|
||||
type OfferTypeRow = IOfferType & {
|
||||
disabled?: boolean;
|
||||
unavailableReason?: string | null;
|
||||
};
|
||||
|
||||
interface ITicketChatProps {
|
||||
params: Promise<{ id: string; title: string }>;
|
||||
}
|
||||
|
||||
const FALLBACK_TYPES: OfferTypeRow[] = [
|
||||
{ _id: "normal", name: "normal", price: 50000 },
|
||||
{ _id: "free", name: "free", price: 0 },
|
||||
];
|
||||
|
||||
function sortOfferTypes(list: OfferTypeRow[]): OfferTypeRow[] {
|
||||
const order = ["normal", "special", "highlight", "free"];
|
||||
return [...list].sort((a, b) => {
|
||||
const ai = order.indexOf(a.name);
|
||||
const bi = order.indexOf(b.name);
|
||||
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
||||
});
|
||||
}
|
||||
|
||||
function TicketChat({ params }: ITicketChatProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const resolvedParams = React.use(params);
|
||||
const { request } = useAxios();
|
||||
const { id } = resolvedParams;
|
||||
const router = useRouter();
|
||||
const me = useUser();
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
selectedType: Yup.string().required(t("offerPage.typeRequired")),
|
||||
});
|
||||
|
||||
const [userDetail, setUserDetail] = useState<User>();
|
||||
const [selectedType, setSelectedType] = useState("normal");
|
||||
const [typeList, setTypeList] = useState<OfferTypeRow[] | null>(null);
|
||||
const [walletBalance, setWalletBalance] = useState(0);
|
||||
const [showWalletPrompt, setShowWalletPrompt] = useState(false);
|
||||
const [socialSignupOpen, setSocialSignupOpen] = useState(false);
|
||||
const pendingPaymentRetryRef = React.useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
const response = await request<{ user: User }>(
|
||||
"GET",
|
||||
`/users/get?user_id=${id}`
|
||||
);
|
||||
setUserDetail(response?.user);
|
||||
};
|
||||
void fetchUser();
|
||||
request<{ charged?: { available?: number } }>("GET", "/wallet/summary")
|
||||
.then((res) => setWalletBalance(res?.charged?.available || 0))
|
||||
.catch(() => setWalletBalance(0));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id, request]);
|
||||
|
||||
|
||||
const fetchOfferTypes = async () => {
|
||||
try {
|
||||
const response = await request<{
|
||||
offerTypes: OfferTypeRow[];
|
||||
}>("GET", `/offers/types?userId=${id}`);
|
||||
const list = sortOfferTypes(
|
||||
response?.offerTypes?.length ? response.offerTypes : FALLBACK_TYPES
|
||||
);
|
||||
setTypeList(list);
|
||||
// همیشه پیشفرض روی پولی (normal)
|
||||
const paid =
|
||||
list.find((item) => item.name === "normal" && !item.disabled) ||
|
||||
list.find((item) => item.name !== "free" && !item.disabled);
|
||||
setSelectedType(paid?.name || "normal");
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
setTypeList(FALLBACK_TYPES);
|
||||
setSelectedType("normal");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void fetchOfferTypes();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const getToken = (): string => {
|
||||
if (typeof window !== "undefined") {
|
||||
const token = Cookies.get("token");
|
||||
return token ? `Bearer ${token}` : "";
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const startPayment = async (offerType: string, useWallet: boolean) => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${BASE_URL}/offers/initiate-payment-web`,
|
||||
{
|
||||
offerType,
|
||||
receiverId: id,
|
||||
useWallet,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: getToken(),
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const data: {
|
||||
authority?: string;
|
||||
paymentUrl?: string;
|
||||
type?: string;
|
||||
paidFromWallet?: boolean;
|
||||
} = response.data;
|
||||
|
||||
if (offerType === "free" || data.type === "free") {
|
||||
toast.success(t("offerPage.freeSuccess"));
|
||||
router.push(`/offer/payment/success?userId=${id}`);
|
||||
} else if (data.paidFromWallet) {
|
||||
toast.success(t("wallet.paidFromWalletSuccess"));
|
||||
router.push(`/offer/payment/success?userId=${id}`);
|
||||
} else if (data.paymentUrl) {
|
||||
// window.open بعدِ await در سافاری (دسکتاپ/iOS) پاپآپبلاک میشه چون دیگه
|
||||
// synchronously به کلیکِ کاربر متصل نیست؛ ضمناً بازگشتِ ZarinPal (از طریق
|
||||
// offer/payment-web/route.js) کلِ همون تب رو به "/" ریدایرکت میکنه، نه تبِ
|
||||
// اصلی — پس با _blank دو تب باز میموند. window.location.href هم امنتره
|
||||
// هم با بقیهی مسیرهای پرداختِ این اپ یکی میمونه
|
||||
window.location.href = data.paymentUrl;
|
||||
} else if (data.authority) {
|
||||
window.location.href = `https://www.zarinpal.com/pg/StartPay/${data.authority}`;
|
||||
} else {
|
||||
toast.error(t("offerPage.paymentInfoError"));
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.response?.data?.type === "needs_username_password") {
|
||||
pendingPaymentRetryRef.current = () => {
|
||||
void startPayment(offerType, useWallet);
|
||||
};
|
||||
setSocialSignupOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Payment initiation error:", err);
|
||||
toast.error(
|
||||
err?.response?.data?.error ||
|
||||
err?.response?.data?.message ||
|
||||
t("offerPage.paymentStartError")
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
selectedType: "normal",
|
||||
},
|
||||
validationSchema,
|
||||
enableReinitialize: true,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
const chosen = typeList?.find((t) => t.name === values.selectedType);
|
||||
if (chosen?.disabled) {
|
||||
toast.error(
|
||||
chosen?.unavailableReason || t("offerPage.freeAlreadyUsed")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (values.selectedType !== "free" && walletBalance > 0) {
|
||||
setShowWalletPrompt(true);
|
||||
return;
|
||||
}
|
||||
await startPayment(values.selectedType, false);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void formik.setFieldValue("selectedType", selectedType);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedType]);
|
||||
|
||||
const displayTypes = useMemo(
|
||||
() => sortOfferTypes(typeList || FALLBACK_TYPES),
|
||||
[typeList]
|
||||
);
|
||||
|
||||
const typeLabel = (name: string) => {
|
||||
if (name === "normal") return t("offerPage.types.normal");
|
||||
if (name === "free") return t("offerPage.types.free");
|
||||
if (name === "special") return t("offerPage.types.special");
|
||||
return t("offerPage.types.highlight");
|
||||
};
|
||||
|
||||
// چیدمان این صفحه با UserInfo مشترک (استفادهشده توی نتایج جستجو/هدیه/...) فرق
|
||||
// داره — عین اندروید (OfferScreen.tsx): ستون نام+یوزرنیم کنار آواتار، ستون
|
||||
// سطح+ریت/امتیاز روبروش؛ بهجای تغییر کامپوننت مشترک، چیدمان مخصوص همین صفحهست
|
||||
const userHeader = userDetail ? (
|
||||
<div className="flex w-full items-center gap-3">
|
||||
<ProfileAvatar
|
||||
src={userDetail?.profile_image}
|
||||
alt={userDetail?.user_name || ""}
|
||||
size="md"
|
||||
rounded="2xl"
|
||||
userId={userDetail?._id}
|
||||
verifyBadge={userDetail?.verify_badge}
|
||||
/>
|
||||
<div className="flex flex-1 items-start justify-between gap-2 text-xs max-sm:text-[10px] md:text-sm font-semibold">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-semibold">
|
||||
{formatFullName(userDetail?.first_name, userDetail?.last_name)}
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{userDetail?.user_name}
|
||||
<VerificationBadge verifyBadge={userDetail?.verify_badge} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<UserInfoClient user_level={userDetail?.user_level} />
|
||||
<ScoreRateStats score={userDetail?.user_score} rate={userDetail?.rate} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="mx-3 mb-10">
|
||||
<PageTitle>{t("offerPage.paymentTitle")}</PageTitle>
|
||||
|
||||
<form
|
||||
className="flex w-full flex-col items-center gap-2 text-sm"
|
||||
onSubmit={formik.handleSubmit}
|
||||
>
|
||||
{displayTypes.map((item) => {
|
||||
const isDisabled = Boolean(item.disabled);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item?._id || item.name}
|
||||
className="mt-4 flex w-full max-w-md cursor-pointer flex-col items-center gap-4"
|
||||
onClick={() => {
|
||||
if (isDisabled) return;
|
||||
setSelectedType(item.name);
|
||||
void formik.setFieldValue("selectedType", item.name);
|
||||
}}
|
||||
>
|
||||
{userHeader ? <div className="w-full">{userHeader}</div> : null}
|
||||
<RoundedButton
|
||||
type="button"
|
||||
variant={selectedType === item?.name ? "primary" : "default"}
|
||||
disabled={isDisabled}
|
||||
className="w-full h-10"
|
||||
>
|
||||
{typeLabel(item.name)}
|
||||
{Number(item.price) !== 0
|
||||
? ": " +
|
||||
Number(item.price).toLocaleString() +
|
||||
t("offerPage.currencySuffix")
|
||||
: ""}
|
||||
</RoundedButton>
|
||||
{isDisabled && item.unavailableReason ? (
|
||||
<p className="text-center text-[11px] text-red-500">
|
||||
{item.unavailableReason}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="pointer-events-none fixed bottom-28 left-0 right-0 z-[54] flex justify-center px-4">
|
||||
<RoundedButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="pointer-events-auto h-10 w-[160px]"
|
||||
disabled={formik.isSubmitting}
|
||||
>
|
||||
{t("offerPage.submitRequest")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<WalletUsagePrompt
|
||||
isOpen={showWalletPrompt}
|
||||
amount={
|
||||
Number(
|
||||
displayTypes.find((item) => item.name === selectedType)?.price
|
||||
) || 0
|
||||
}
|
||||
walletBalance={walletBalance}
|
||||
onAnswer={(useWallet) => {
|
||||
setShowWalletPrompt(false);
|
||||
void startPayment(selectedType, useWallet);
|
||||
}}
|
||||
/>
|
||||
|
||||
<CompleteSocialSignupModal
|
||||
open={socialSignupOpen}
|
||||
hasUsername={Boolean(me?.user_name)}
|
||||
onClose={() => {
|
||||
setSocialSignupOpen(false);
|
||||
pendingPaymentRetryRef.current = null;
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setSocialSignupOpen(false);
|
||||
pendingPaymentRetryRef.current?.();
|
||||
pendingPaymentRetryRef.current = null;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default TicketChat;
|
||||
|
||||
@@ -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";
|
||||
@@ -43,12 +42,14 @@ import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import { optimizeMediaFile } from "@/lib/media";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import {
|
||||
E2EE_LOCKED_PREVIEW,
|
||||
decryptRoomContent,
|
||||
decryptRoomMessageList,
|
||||
encryptRoomContent,
|
||||
ensureIdentityKeys,
|
||||
ensureRoomAesKey,
|
||||
} from "@/lib/e2ee";
|
||||
import CompleteSocialSignupModal from "@/components/auth/CompleteSocialSignupModal";
|
||||
|
||||
type RoomMember = {
|
||||
_id: string;
|
||||
@@ -164,6 +165,12 @@ function RoomMessageRow({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { onDragStart: _, ...restDragProps } = dragProps;
|
||||
|
||||
// طبقِ خواستهی کاربر، E2EE باید کاملاً نامرئی باشه — پیامِ متنیِ خالصی که رمزگشایی
|
||||
// نشده (هویتِ قدیمی ازدسترفته) اصلاً نمایش داده نمیشه، نه یه بابلِ خالی/قفلشده
|
||||
const bubbleContent =
|
||||
msg.content === E2EE_LOCKED_PREVIEW ? "" : msg.content;
|
||||
if (!bubbleContent && !msg.file) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(el) => {
|
||||
@@ -203,7 +210,11 @@ function RoomMessageRow({
|
||||
<span className="font-semibold text-[#0095f6]">
|
||||
{msg.replyTo.senderName}
|
||||
</span>
|
||||
<p className="truncate">{msg.replyTo.content}</p>
|
||||
<p className="truncate">
|
||||
{msg.replyTo.content === E2EE_LOCKED_PREVIEW
|
||||
? ""
|
||||
: msg.replyTo.content}
|
||||
</p>
|
||||
</button>
|
||||
) : null}
|
||||
<div className="relative w-full overflow-hidden">
|
||||
@@ -288,9 +299,9 @@ function RoomMessageRow({
|
||||
<span>File</span>
|
||||
</a>
|
||||
) : null}
|
||||
{msg.content ? (
|
||||
{bubbleContent ? (
|
||||
<p className="chat-message-text select-text whitespace-pre-wrap break-words">
|
||||
{msg.content}
|
||||
{bubbleContent}
|
||||
</p>
|
||||
) : null}
|
||||
<span className="mt-1 block text-[10px] opacity-70">
|
||||
@@ -328,6 +339,8 @@ export default function ChatRoomThreadPage({
|
||||
const { request } = useAxios();
|
||||
const router = useRouter();
|
||||
const user = useUser();
|
||||
const [socialSignupOpen, setSocialSignupOpen] = useState(false);
|
||||
const pendingSendRetryRef = useRef<(() => void) | null>(null);
|
||||
const [room, setRoom] = useState<RoomDetail | null>(null);
|
||||
const [messages, setMessages] = useState<RoomMessage[]>([]);
|
||||
// این صفحه با تغییرِ فقط پارامترِ roomId دوباره mount نمیشه (رفتارِ App Router)،
|
||||
@@ -535,7 +548,12 @@ export default function ChatRoomThreadPage({
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
// وقتی سوکت وصله، پیامهای گروه real-time (newGroupMessage) میرسن؛ polling فقط
|
||||
// همگامسازیِ ایمنی با فاصلهی بیشتره، وگرنه همون ۸ ثانیهی قبلی
|
||||
let tick = 0;
|
||||
const poll = window.setInterval(() => {
|
||||
tick += 1;
|
||||
if (isChatSocketConnected() && tick % 8 !== 0) return;
|
||||
void loadMessages();
|
||||
void loadRoom();
|
||||
}, 8000);
|
||||
@@ -548,9 +566,10 @@ export default function ChatRoomThreadPage({
|
||||
|
||||
const processSendMessage = async (
|
||||
fileToUpload: File | null = null,
|
||||
fileType?: RoomMessage["fileType"]
|
||||
fileType?: RoomMessage["fileType"],
|
||||
contentOverride?: string
|
||||
) => {
|
||||
const text = newMessage.trim();
|
||||
const text = (contentOverride ?? newMessage).trim();
|
||||
if ((!text && !fileToUpload) || sending) return;
|
||||
|
||||
const senderId = String(user?._id ?? getStoredUserId());
|
||||
@@ -607,14 +626,17 @@ export default function ChatRoomThreadPage({
|
||||
}
|
||||
|
||||
try {
|
||||
let sendContent = text;
|
||||
try {
|
||||
await ensureIdentityKeys(senderId);
|
||||
const encrypted = await encryptRoomContent(senderId, roomId, text);
|
||||
if (encrypted) sendContent = encrypted;
|
||||
} catch {
|
||||
/* fallback plaintext */
|
||||
}
|
||||
// ارسالِ فایل/عکس/ویس/ویدیو توی روم هم فعلاً بدونِ e2ee میره — عینِ دلیلِ همین
|
||||
// تغییر توی چتِ مستقیم (chats/[username]/[id]/page.tsx): اگه encryptRoomContent
|
||||
// به هر دلیلی null برگردونه، قبلاً کلِ آپلودِ فایل هم با همین خطا شکست میخورد
|
||||
const sendContent = fileForUpload
|
||||
? text
|
||||
: await (async () => {
|
||||
await ensureIdentityKeys(senderId);
|
||||
const encrypted = await encryptRoomContent(senderId, roomId, text);
|
||||
if (encrypted === null) throw new Error("E2EE encryption failed");
|
||||
return encrypted;
|
||||
})();
|
||||
|
||||
let res: { message?: RoomMessage } | null = null;
|
||||
if (fileForUpload) {
|
||||
@@ -663,6 +685,17 @@ export default function ChatRoomThreadPage({
|
||||
if (replyPayload && savedReply) {
|
||||
setReplyingTo(savedReply);
|
||||
}
|
||||
|
||||
const errType = (err as { response?: { data?: { type?: string } } })
|
||||
?.response?.data?.type;
|
||||
if (errType === "needs_username_password") {
|
||||
pendingSendRetryRef.current = () => {
|
||||
void processSendMessage(fileToUpload, fileType, text);
|
||||
};
|
||||
setSocialSignupOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const message =
|
||||
(err as { response?: { data?: { error?: string; message?: string } } })
|
||||
?.response?.data?.error ||
|
||||
@@ -957,6 +990,20 @@ export default function ChatRoomThreadPage({
|
||||
existingMemberIds={room?.members?.map((member) => member._id) ?? []}
|
||||
onAdded={() => void loadRoom()}
|
||||
/>
|
||||
|
||||
<CompleteSocialSignupModal
|
||||
open={socialSignupOpen}
|
||||
hasUsername={Boolean(user?.user_name)}
|
||||
onClose={() => {
|
||||
setSocialSignupOpen(false);
|
||||
pendingSendRetryRef.current = null;
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setSocialSignupOpen(false);
|
||||
pendingSendRetryRef.current?.();
|
||||
pendingSendRetryRef.current = null;
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
|
||||
@@ -6,8 +6,7 @@ import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { SettingsListSkeleton } from "@/components/settings/EditPageSkeletons";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import Image from "next/image";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -17,6 +16,7 @@ type BlockedUser = {
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
profile_image?: string;
|
||||
verify_badge?: string;
|
||||
};
|
||||
|
||||
function BlockedUsersPage() {
|
||||
@@ -57,19 +57,13 @@ function BlockedUsersPage() {
|
||||
href={`/users/${u.user_name}`}
|
||||
className="flex items-center gap-3 rounded-2xl px-2 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-full bg-neutral-200">
|
||||
<Image
|
||||
src={
|
||||
u.profile_image
|
||||
? buildStorageUrl(u.profile_image)
|
||||
: "/images/fake-avatar.png"
|
||||
}
|
||||
alt=""
|
||||
fill
|
||||
sizes="44px"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<ProfileAvatar
|
||||
src={u.profile_image}
|
||||
userId={u._id}
|
||||
verifyBadge={u.verify_badge}
|
||||
sizePx={44}
|
||||
rounded="full"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-bold">{name}</p>
|
||||
<p className="truncate text-xs text-neutral-500">
|
||||
|
||||
@@ -16,6 +16,7 @@ const EDIT_NAV_KEY: Record<string, string> = {
|
||||
"/username": "username",
|
||||
"/password": "password",
|
||||
"/google-account": "googleAccount",
|
||||
"/register-mobile": "registerMobile",
|
||||
"/two-factor": "twoFactor",
|
||||
"/link-device": "linkDevice",
|
||||
"/avatar": "avatar",
|
||||
@@ -51,7 +52,8 @@ function EditPage() {
|
||||
{editUserNavLinks?.map((item) => (
|
||||
<Link
|
||||
className={`flex items-center gap-2 ${
|
||||
item.href === "/License" && isRegister === "false"
|
||||
(item.href === "/License" && isRegister === "false") ||
|
||||
(item.href === "/register-mobile" && Boolean(user?.mobile))
|
||||
? "hidden"
|
||||
: ""
|
||||
}`}
|
||||
|
||||
@@ -16,6 +16,7 @@ import BackButton from "@/components/ui/BackButton";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { establishIdentityAtLogin } from "@/lib/e2ee";
|
||||
|
||||
function PasswordPage() {
|
||||
const { t } = useTranslation("common");
|
||||
@@ -60,6 +61,11 @@ function PasswordPage() {
|
||||
...(user?.mobile ? { mobile: user.mobile } : {}),
|
||||
new_password: values.password,
|
||||
})) as IVerifyOtp;
|
||||
// رمزِ عبور عوض شد (یا اولینبار تنظیم شد) — کلیدِ E2EEِ همین مرورگر
|
||||
// (دستنخورده توی IndexedDB) رو زیرِ رمزِ جدید دوباره بکآپ بگیر
|
||||
if (user?._id) {
|
||||
// [E2EE] موقتاً غیرفعال — void establishIdentityAtLogin(user._id, values.password);
|
||||
}
|
||||
toast.success(
|
||||
isGoogleUser
|
||||
? t("settings.edit.password.setSuccess")
|
||||
|
||||
132
src/app/settings/edit/register-mobile/page.tsx
Normal file
132
src/app/settings/edit/register-mobile/page.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import AuthOtpInput from "@/components/auth/AuthOtpInput";
|
||||
import AuthOtpStatus from "@/components/auth/AuthOtpStatus";
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { markOtpSent } from "@/lib/auth/otpTimer";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function RegisterMobilePage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [step, setStep] = useState<"mobile" | "otp">("mobile");
|
||||
const [mobile, setMobile] = useState("");
|
||||
const [otp, setOtp] = useState("");
|
||||
const [otpError, setOtpError] = useState(false);
|
||||
const [otpExpired, setOtpExpired] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [resendLoading, setResendLoading] = useState(false);
|
||||
|
||||
const sendOtp = async () => {
|
||||
const cleaned = mobile.replace(/\D/g, "");
|
||||
if (!/^09[0-9]{9}$/.test(cleaned)) {
|
||||
toast.error(t("auth.invalidMobile"));
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
try {
|
||||
await request("POST", "/account/mobile/request", { mobile: cleaned });
|
||||
markOtpSent();
|
||||
setStep("otp");
|
||||
} catch {
|
||||
// toast از interceptor
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const verify = async () => {
|
||||
if (otp.length !== 6) return;
|
||||
setVerifying(true);
|
||||
try {
|
||||
await request("POST", "/account/mobile/verify", { otp });
|
||||
toast.success(t("settings.edit.registerMobile.success"));
|
||||
// نه router.push — صفحهی edit با useUser کششده هنوز mobile رو خالی نشون
|
||||
// میداد؛ یه ناوبریِ کامل تضمین میکنه که آیتم از لیست درست حذف بشه
|
||||
window.location.href = "/settings/edit";
|
||||
} catch {
|
||||
setOtpError(true);
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("settings.edit.nav.registerMobile")}</PageTitle>
|
||||
<div className="mt-6 flex flex-col items-center gap-4 text-sm">
|
||||
{step === "mobile" ? (
|
||||
<>
|
||||
<p className="text-center text-neutral-500">
|
||||
{t("settings.edit.registerMobile.hint")}
|
||||
</p>
|
||||
<RoundedInput
|
||||
value={mobile}
|
||||
onChange={(e) => setMobile(e.target.value)}
|
||||
placeholder="09xxxxxxxxx"
|
||||
dir="ltr"
|
||||
className="text-center"
|
||||
maxLength={11}
|
||||
/>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-11 w-full"
|
||||
disabled={sending}
|
||||
onClick={sendOtp}
|
||||
>
|
||||
{sending ? t("common.loading") : t("auth.sendCode")}
|
||||
</RoundedButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AuthOtpStatus
|
||||
mobile={mobile.replace(/\D/g, "")}
|
||||
resendLoading={resendLoading}
|
||||
onExpiredChange={setOtpExpired}
|
||||
onResend={async () => {
|
||||
setResendLoading(true);
|
||||
try {
|
||||
await request("POST", "/account/mobile/request", {
|
||||
mobile: mobile.replace(/\D/g, ""),
|
||||
});
|
||||
setOtp("");
|
||||
setOtpError(false);
|
||||
} finally {
|
||||
setResendLoading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<AuthOtpInput
|
||||
value={otp}
|
||||
onChange={(v) => {
|
||||
setOtp(v);
|
||||
setOtpError(false);
|
||||
}}
|
||||
onComplete={() => setTimeout(() => void verify(), 400)}
|
||||
error={otpError}
|
||||
disabled={verifying || otpExpired}
|
||||
/>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="mt-3 h-11 w-full"
|
||||
disabled={verifying || otp.length !== 6}
|
||||
onClick={() => void verify()}
|
||||
>
|
||||
{verifying ? t("common.loading") : t("auth.verifyCode")}
|
||||
</RoundedButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
@@ -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={() =>
|
||||
|
||||
@@ -13,6 +13,7 @@ import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppLanguage } from "@/contexts/LanguageProvider";
|
||||
import { isAppLanguage, type AppLanguage } from "@/lib/i18n/constants";
|
||||
import { isSubUserSession } from "@/lib/auth/session";
|
||||
// زبانهایی که آیتمهای آنالیتیکس/کاربران مسدود/بایگانی/فعالیت شما فقط توی اونا نمایش داده میشن
|
||||
const SETTINGS_ITEMS_VISIBLE_LANGUAGES: AppLanguage[] = ["fa", "ar", "en"];
|
||||
|
||||
@@ -215,6 +216,17 @@ export default function UserSettingsPage() {
|
||||
<span className="text-neutral-400">›</span>
|
||||
</Link>
|
||||
|
||||
{/* «مدیریت کاربران» فقط مالِ خودِ صاحبِ اکانته، نه کاربرِ زیرمجموعه */}
|
||||
{!isSubUserSession() && (
|
||||
<Link
|
||||
href="/settings/sub-users"
|
||||
className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
|
||||
>
|
||||
<span className="font-semibold">{t("settings.nav.subUsers")}</span>
|
||||
<span className="text-neutral-400">›</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href="/settings/booking"
|
||||
className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
|
||||
|
||||
291
src/app/settings/sub-users/[id]/page.tsx
Normal file
291
src/app/settings/sub-users/[id]/page.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { SUB_USER_PERMISSION_KEYS } from "@/constants/subUserPermissions";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import Image from "next/image";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type SubUser = {
|
||||
_id: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
user_name: string;
|
||||
profile_image?: string | null;
|
||||
sub_user_active: boolean;
|
||||
sub_user_permissions: string[];
|
||||
};
|
||||
|
||||
export default function SubUserDetailPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const params = useParams<{ id: string }>();
|
||||
const id = params?.id;
|
||||
|
||||
// نه router.back()/Link — چون توی این صفحه یه غیرفعال/فعالسازی یا ویرایش ممکنه
|
||||
// انجام شده باشه و لیستِ صفحهی قبل (که فقط با useEffect یهبار دیتاش رو میگیره)
|
||||
// بدونِ یه ناوبریِ کامل (نه soft) وضعیتِ تازه رو نشون نمیداد
|
||||
const backToList = () => {
|
||||
window.location.href = "/settings/sub-users";
|
||||
};
|
||||
|
||||
const [subUser, setSubUser] = useState<SubUser | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [toggling, setToggling] = useState(false);
|
||||
|
||||
// فرمِ ویرایش
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [userName, setUserName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [avatar, setAvatar] = useState<string | null>(null);
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [permissions, setPermissions] = useState<string[]>([]);
|
||||
|
||||
const load = () => {
|
||||
if (!id) return;
|
||||
request<{ subUser?: SubUser }>("GET", `/sub-users/${id}`, null, { noToast: true })
|
||||
.then((res) => setSubUser(res?.subUser ?? null))
|
||||
.catch(() => setSubUser(null));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
const startEditing = () => {
|
||||
if (!subUser) return;
|
||||
setFirstName(subUser.first_name || "");
|
||||
setLastName(subUser.last_name || "");
|
||||
setUserName(subUser.user_name || "");
|
||||
setPassword("");
|
||||
setAvatar(subUser.profile_image ? buildStorageUrl(subUser.profile_image) : null);
|
||||
setAvatarFile(null);
|
||||
setPermissions(subUser.sub_user_permissions || []);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const togglePermission = (key: string) => {
|
||||
setPermissions((prev) =>
|
||||
prev.includes(key) ? prev.filter((p) => p !== key) : [...prev, key]
|
||||
);
|
||||
};
|
||||
|
||||
const selectImage = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setAvatarFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setAvatar(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!id || !firstName.trim() || !userName.trim()) {
|
||||
toast.error(t("auth.usernamePasswordRequired"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("first_name", firstName.trim());
|
||||
formData.append("last_name", lastName.trim());
|
||||
formData.append("user_name", userName.trim());
|
||||
if (password) formData.append("password", password);
|
||||
formData.append("permissions", JSON.stringify(permissions));
|
||||
if (avatarFile) formData.append("profile_image", avatarFile);
|
||||
|
||||
await request("PATCH", `/sub-users/${id}`, formData);
|
||||
toast.success(t("subUsers.updateSuccess"));
|
||||
setEditing(false);
|
||||
load();
|
||||
} catch {
|
||||
// toast از interceptor
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleActive = async () => {
|
||||
if (!id || !subUser) return;
|
||||
setToggling(true);
|
||||
try {
|
||||
await request("PATCH", `/sub-users/${id}/active`, {
|
||||
active: !subUser.sub_user_active,
|
||||
});
|
||||
toast.success(
|
||||
t(subUser.sub_user_active ? "subUsers.disableSuccess" : "subUsers.enableSuccess")
|
||||
);
|
||||
load();
|
||||
} catch {
|
||||
// toast از interceptor
|
||||
} finally {
|
||||
setToggling(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!subUser) {
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle onBack={backToList}>{t("subUsers.editTitle")}</PageTitle>
|
||||
<p className="py-10 text-center text-gray-500">{t("common.loading")}</p>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const name = [subUser.first_name, subUser.last_name].filter(Boolean).join(" ") || subUser.user_name;
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle onBack={backToList}>{t("subUsers.editTitle")}</PageTitle>
|
||||
<div className="mt-6 flex flex-col items-center gap-5 text-xs md:text-sm">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="relative flex h-[110px] w-[110px] items-center justify-center overflow-hidden rounded-full border border-[#676767]">
|
||||
{avatar ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={avatar} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<label htmlFor="subUserAvatarEditInput" className="flex h-full w-full cursor-pointer items-center justify-center">
|
||||
<Image src="/images/icons/gallery-add.svg" width={40} height={40} alt="" />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
id="subUserAvatarEditInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={selectImage}
|
||||
/>
|
||||
<label htmlFor="subUserAvatarEditInput" className="cursor-pointer text-neutral-500 underline">
|
||||
{t("settings.edit.avatar.editImage")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-3">
|
||||
<div>
|
||||
<p className="mb-2 font-semibold">{t("subUsers.nameLabel")}</p>
|
||||
<RoundedInput value={firstName} onChange={(e) => setFirstName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 font-semibold">{t("subUsers.lastNameLabel")}</p>
|
||||
<RoundedInput value={lastName} onChange={(e) => setLastName(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 font-semibold">{t("subUsers.usernameLabel")}</p>
|
||||
<RoundedInput value={userName} onChange={(e) => setUserName(e.target.value)} dir="ltr" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 font-semibold">{t("subUsers.passwordLabel")}</p>
|
||||
<RoundedInput
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("subUsers.passwordEditHint")}
|
||||
type="password"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<p className="mb-3 font-semibold">{t("subUsers.permissionsLabel")}</p>
|
||||
<div className="space-y-2">
|
||||
{SUB_USER_PERMISSION_KEYS.map((key) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-3 rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={permissions.includes(key)}
|
||||
onChange={() => togglePermission(key)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span>{t(`subUsers.permissions.${key}`)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full gap-3">
|
||||
<RoundedButton className="h-11 flex-1" onClick={() => setEditing(false)} disabled={saving}>
|
||||
{t("ads.backAction")}
|
||||
</RoundedButton>
|
||||
<RoundedButton variant="primary" className="h-11 flex-1" onClick={save} disabled={saving}>
|
||||
{saving ? t("subUsers.saving") : t("subUsers.save")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle onBack={backToList}>{t("subUsers.editTitle")}</PageTitle>
|
||||
<div className="mt-6 flex flex-col items-center gap-4 text-xs md:text-sm">
|
||||
<ProfileAvatar src={subUser.profile_image} size="lg" rounded="full" />
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-bold">{name}</p>
|
||||
<p className="text-neutral-500">@{subUser.user_name}</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<p className="mb-3 font-semibold">{t("subUsers.permissionsTitle")}</p>
|
||||
<div className="space-y-3">
|
||||
{SUB_USER_PERMISSION_KEYS.map((key) => {
|
||||
const granted = subUser.sub_user_permissions?.includes(key);
|
||||
return (
|
||||
<div key={key}>
|
||||
<p className="mb-2 text-sm font-semibold">{t(`subUsers.permissions.${key}`)}</p>
|
||||
<div
|
||||
className={
|
||||
"flex min-h-[42px] w-full items-center rounded-2xl border px-4 py-2.5 text-sm " +
|
||||
(granted
|
||||
? "border-green-300 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950/30 dark:text-green-400"
|
||||
: "border-[#C3C3C3] bg-white/80 text-neutral-500 dark:border-neutral-600 dark:bg-neutral-900/80")
|
||||
}
|
||||
>
|
||||
{t(granted ? "settings.yes" : "settings.no")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full gap-3 pt-2">
|
||||
<RoundedButton className="h-11 flex-1" onClick={startEditing}>
|
||||
{t("subUsers.edit")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
variant={subUser.sub_user_active ? "default" : "primary"}
|
||||
className="h-11 flex-1"
|
||||
onClick={toggleActive}
|
||||
disabled={toggling}
|
||||
>
|
||||
{t(subUser.sub_user_active ? "subUsers.disable" : "subUsers.enable")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
172
src/app/settings/sub-users/new/page.tsx
Normal file
172
src/app/settings/sub-users/new/page.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import { SUB_USER_PERMISSION_KEYS } from "@/constants/subUserPermissions";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function NewSubUserPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
|
||||
const [firstName, setFirstName] = useState("");
|
||||
const [lastName, setLastName] = useState("");
|
||||
const [userName, setUserName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [avatar, setAvatar] = useState<string | null>(null);
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [permissions, setPermissions] = useState<string[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const togglePermission = (key: string) => {
|
||||
setPermissions((prev) =>
|
||||
prev.includes(key) ? prev.filter((p) => p !== key) : [...prev, key]
|
||||
);
|
||||
};
|
||||
|
||||
const selectImage = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setAvatarFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setAvatar(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!firstName.trim() || !userName.trim() || !password) {
|
||||
toast.error(t("auth.usernamePasswordRequired"));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("first_name", firstName.trim());
|
||||
formData.append("last_name", lastName.trim());
|
||||
formData.append("user_name", userName.trim());
|
||||
formData.append("password", password);
|
||||
formData.append("permissions", JSON.stringify(permissions));
|
||||
if (avatarFile) formData.append("profile_image", avatarFile);
|
||||
|
||||
await request("POST", "/sub-users", formData);
|
||||
toast.success(t("subUsers.createSuccess"));
|
||||
// router.push ساده کافی نیست — صفحهی لیست دیتاش رو فقط توی useEffect (یه بار
|
||||
// موقع mount) میگیره؛ چون Next.js موقعِ برگشت این صفحه رو از کشِ خودش (نه
|
||||
// واقعاً remount) نشون میده، useEffect دوباره اجرا نمیشه و کاربرِ تازهساخته
|
||||
// دیده نمیشه تا یه رفرشِ دستی. navigation کامل (نه soft) این رو تضمین میکنه.
|
||||
window.location.href = "/settings/sub-users";
|
||||
} catch {
|
||||
// خطا با toast interceptor خودِ useAxios نمایش داده میشه
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle>{t("subUsers.createTitle")}</PageTitle>
|
||||
|
||||
<div className="mt-6 flex flex-col items-center gap-5 text-xs md:text-sm">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="relative flex h-[110px] w-[110px] items-center justify-center overflow-hidden rounded-full border border-[#676767]">
|
||||
{avatar ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={avatar} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<label htmlFor="subUserAvatarInput" className="flex h-full w-full cursor-pointer items-center justify-center">
|
||||
<Image src="/images/icons/gallery-add.svg" width={40} height={40} alt="" />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
id="subUserAvatarInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={selectImage}
|
||||
/>
|
||||
<label htmlFor="subUserAvatarInput" className="cursor-pointer text-neutral-500 underline">
|
||||
{t("settings.edit.avatar.editImage")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-3">
|
||||
<div>
|
||||
<p className="mb-2 font-semibold">{t("subUsers.nameLabel")}</p>
|
||||
<RoundedInput
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
placeholder={t("subUsers.namePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 font-semibold">{t("subUsers.lastNameLabel")}</p>
|
||||
<RoundedInput
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
placeholder={t("subUsers.lastNamePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 font-semibold">{t("subUsers.usernameLabel")}</p>
|
||||
<RoundedInput
|
||||
value={userName}
|
||||
onChange={(e) => setUserName(e.target.value)}
|
||||
placeholder={t("subUsers.usernamePlaceholder")}
|
||||
autoCapitalize="none"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 font-semibold">{t("subUsers.passwordLabel")}</p>
|
||||
<RoundedInput
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("subUsers.passwordPlaceholder")}
|
||||
type="password"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<p className="mb-3 font-semibold">{t("subUsers.permissionsLabel")}</p>
|
||||
<div className="space-y-2">
|
||||
{SUB_USER_PERMISSION_KEYS.map((key) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-3 rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={permissions.includes(key)}
|
||||
onChange={() => togglePermission(key)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span>{t(`subUsers.permissions.${key}`)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="mt-2 h-11 w-full"
|
||||
disabled={submitting}
|
||||
onClick={submit}
|
||||
>
|
||||
{submitting ? t("subUsers.saving") : t("subUsers.save")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
95
src/app/settings/sub-users/page.tsx
Normal file
95
src/app/settings/sub-users/page.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import EmptyState from "@/components/elements/EmptyState";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import { staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type SubUserRow = {
|
||||
_id: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
user_name: string;
|
||||
profile_image?: string | null;
|
||||
sub_user_active: boolean;
|
||||
};
|
||||
|
||||
export default function SubUsersListPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const { request } = useAxios();
|
||||
const [subUsers, setSubUsers] = useState<SubUserRow[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ subUsers?: SubUserRow[] }>("GET", "/sub-users", null, { noToast: true })
|
||||
.then((res) => setSubUsers(res?.subUsers ?? []))
|
||||
.catch(() => setSubUsers([]));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<PageTitle
|
||||
action={
|
||||
<Link href="/settings/sub-users/new" aria-label={t("subUsers.createTitle")} className="p-2">
|
||||
<Image
|
||||
width={24}
|
||||
height={24}
|
||||
alt=""
|
||||
src={staticIconUrl("/images/icons/add.svg")}
|
||||
className="dark:brightness-0 dark:invert"
|
||||
/>
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{t("subUsers.listTitle")}
|
||||
</PageTitle>
|
||||
|
||||
<div className="mt-6 space-y-3 text-xs md:text-sm">
|
||||
{subUsers === null ? (
|
||||
<p className="py-10 text-center text-gray-500">{t("common.loading")}</p>
|
||||
) : subUsers.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="profile-2user"
|
||||
title={t("subUsers.emptyTitle")}
|
||||
subtitle={t("subUsers.emptyHint")}
|
||||
/>
|
||||
) : (
|
||||
subUsers.map((su) => {
|
||||
const name = [su.first_name, su.last_name].filter(Boolean).join(" ") || su.user_name;
|
||||
return (
|
||||
<Link
|
||||
key={su._id}
|
||||
href={`/settings/sub-users/${su._id}`}
|
||||
className="flex items-center gap-3 rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
|
||||
>
|
||||
<ProfileAvatar src={su.profile_image} size="sm" rounded="full" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-semibold">{name}</p>
|
||||
<p className="truncate text-neutral-500">@{su.user_name}</p>
|
||||
</div>
|
||||
<span
|
||||
className={
|
||||
su.sub_user_active
|
||||
? "shrink-0 text-green-600"
|
||||
: "shrink-0 text-neutral-400"
|
||||
}
|
||||
>
|
||||
{t(su.sub_user_active ? "subUsers.active" : "subUsers.inactive")}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
228
src/components/auth/CompleteSocialSignupModal.tsx
Normal file
228
src/components/auth/CompleteSocialSignupModal.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Modal from "@/components/elements/Modal";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { AxiosError } from "axios";
|
||||
import { getStoredUserId } from "@/lib/auth/session";
|
||||
import { establishIdentityAtLogin } from "@/lib/e2ee";
|
||||
|
||||
const USERNAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9._-]{5,29}$/;
|
||||
// باید دقیقا با STRONG_PASSWORD_REGEX سمت بکاند (utils/strongPasswordValidation.js) یکی باشد
|
||||
const STRONG_PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,64}$/;
|
||||
|
||||
type CompleteSocialSignupResponse = {
|
||||
message: string;
|
||||
user_name: string;
|
||||
};
|
||||
|
||||
type ErrorResponseData = {
|
||||
error?: boolean;
|
||||
field?: "user_name" | "password";
|
||||
message?: string;
|
||||
suggestions?: string[];
|
||||
};
|
||||
|
||||
type CompleteSocialSignupModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** بعد از ثبت موفق نامکاربری+رمز عبور صدا زده میشود تا اکشن اصلی (ارسال پیام/ثبت درخواست) دوباره اجرا شود */
|
||||
onSuccess: (userName: string) => void;
|
||||
/** اگر کاربر از قبل نامکاربری دارد فقط مرحلهی رمز عبور نشان داده میشود */
|
||||
hasUsername: boolean;
|
||||
};
|
||||
|
||||
export default function CompleteSocialSignupModal({
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
hasUsername,
|
||||
}: CompleteSocialSignupModalProps) {
|
||||
const { request } = useAxios();
|
||||
|
||||
const [step, setStep] = useState<"username" | "password">(
|
||||
hasUsername ? "password" : "username"
|
||||
);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [usernameError, setUsernameError] = useState<string | null>(null);
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const reset = () => {
|
||||
setStep(hasUsername ? "password" : "username");
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setConfirmPassword("");
|
||||
setUsernameError(null);
|
||||
setPasswordError(null);
|
||||
setSuggestions([]);
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleUsernameNext = () => {
|
||||
const trimmed = username.trim().toLowerCase();
|
||||
if (!USERNAME_REGEX.test(trimmed)) {
|
||||
setUsernameError(
|
||||
"نام کاربری باید ۶ تا ۳۰ کاراکتر و شامل حروف انگلیسی، عدد و . _ - باشد"
|
||||
);
|
||||
return;
|
||||
}
|
||||
setUsernameError(null);
|
||||
setUsername(trimmed);
|
||||
setStep("password");
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!STRONG_PASSWORD_REGEX.test(password)) {
|
||||
setPasswordError(
|
||||
"رمز عبور باید حداقل ۸ کاراکتر و شامل حروف بزرگ، حروف کوچک، عدد و یک کاراکتر خاص باشد"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setPasswordError("رمز عبور و تکرار آن یکسان نیستند");
|
||||
return;
|
||||
}
|
||||
|
||||
setPasswordError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const response = await request<CompleteSocialSignupResponse>(
|
||||
"POST",
|
||||
"/register/complete-social-signup",
|
||||
hasUsername ? { password } : { user_name: username, password }
|
||||
);
|
||||
// اولین باریه این کاربر رمزِ عبور داره — همین الان کلیدِ E2EE رو زیرش بکآپ بگیر
|
||||
const userId = getStoredUserId();
|
||||
// [E2EE] موقتاً غیرفعال — if (userId) void establishIdentityAtLogin(userId, password);
|
||||
reset();
|
||||
onSuccess(response.user_name);
|
||||
} catch (err) {
|
||||
const data = (err as AxiosError<ErrorResponseData>)?.response?.data;
|
||||
if (data?.field === "user_name") {
|
||||
setStep("username");
|
||||
setUsernameError(data.message || "نام کاربری نامعتبر است");
|
||||
setSuggestions(data.suggestions || []);
|
||||
} else {
|
||||
setPasswordError(data?.message || "خطایی رخ داد، دوباره تلاش کنید");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={open}
|
||||
onClose={handleClose}
|
||||
height="fit"
|
||||
elevated
|
||||
panelClassName="!p-0 max-h-[85dvh] w-full overflow-y-auto"
|
||||
>
|
||||
<div className="px-4 pb-6 pt-5">
|
||||
<h3 className="mb-2 text-center text-sm font-bold">
|
||||
تکمیل حساب کاربری
|
||||
</h3>
|
||||
<p className="mb-4 text-center text-xs text-neutral-500 dark:text-neutral-400">
|
||||
برای امنیت گفتگوهاتون، یک نام کاربری و رمز عبور تنظیم کنید
|
||||
</p>
|
||||
|
||||
{step === "username" ? (
|
||||
<div className="mx-auto w-full max-w-sm space-y-3">
|
||||
<RoundedInput
|
||||
type="text"
|
||||
placeholder="نام کاربری"
|
||||
dir="ltr"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
setUsernameError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleUsernameNext();
|
||||
}}
|
||||
/>
|
||||
{usernameError ? (
|
||||
<p className="text-center text-xs text-red-500">{usernameError}</p>
|
||||
) : null}
|
||||
{suggestions.length > 0 ? (
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{suggestions.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUsername(s);
|
||||
setUsernameError(null);
|
||||
setSuggestions([]);
|
||||
}}
|
||||
className="rounded-full border border-neutral-300 px-3 py-1 text-xs dark:border-neutral-700"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<RoundedButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="h-10 w-full"
|
||||
onClick={handleUsernameNext}
|
||||
disabled={!username.trim()}
|
||||
>
|
||||
ثبت
|
||||
</RoundedButton>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto w-full max-w-sm space-y-3">
|
||||
<RoundedInput
|
||||
type="password"
|
||||
placeholder="رمز عبور"
|
||||
dir="ltr"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setPasswordError(null);
|
||||
}}
|
||||
/>
|
||||
<RoundedInput
|
||||
type="password"
|
||||
placeholder="تکرار رمز عبور"
|
||||
dir="ltr"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => {
|
||||
setConfirmPassword(e.target.value);
|
||||
setPasswordError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleSubmit();
|
||||
}}
|
||||
/>
|
||||
{passwordError ? (
|
||||
<p className="text-center text-xs text-red-500">{passwordError}</p>
|
||||
) : null}
|
||||
<RoundedButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="h-10 w-full"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={submitting || !password || !confirmPassword}
|
||||
>
|
||||
{submitting ? "در حال ثبت..." : "تایید و ادامه"}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
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
|
||||
|
||||
@@ -6,6 +6,7 @@ import { motion, AnimatePresence } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { User } from "@/types/types";
|
||||
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import ReadReceipt from "./ReadReceipt";
|
||||
import VoiceMessagePlayer from "./VoiceMessagePlayer";
|
||||
import FileMessageBubble from "./FileMessageBubble";
|
||||
@@ -274,10 +275,13 @@ function parseForwarded(content: string): ChatMessage["forwardedFrom"] | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** متن قابل نمایش — پاکت E2EE را با regex خراب نکن */
|
||||
/** متن قابل نمایش — پاکت E2EE را با regex خراب نکن. طبقِ خواستهی کاربر، E2EE باید کاملاً
|
||||
* نامرئی باشه — یعنی حتی اگه یه پیامِ قدیمی (هویتِ ازدسترفته) رمزگشایی نشه، بهجای
|
||||
* نمایشِ متنِ «پیام رمزنگاریشده»، چیزی نشون داده نمیشه (رشتهی خالی؛ خودِ بابل هم
|
||||
* پایینتر، در صورتِ نبودِ فایل/موقعیت/اشتراکگذاری، کلاً رندر نمیشه) */
|
||||
function getBubbleTextContent(content: string | undefined): string {
|
||||
if (!content) return "";
|
||||
if (isE2eeEnvelope(content)) return E2EE_LOCKED_PREVIEW;
|
||||
if (isE2eeEnvelope(content) || content === E2EE_LOCKED_PREVIEW) return "";
|
||||
if (parseLocationContent(content)) return "";
|
||||
|
||||
const forwarded = parseForwarded(content);
|
||||
@@ -421,15 +425,29 @@ const ChatMessageCard = ({
|
||||
const hasLocation = message.fileType === "location" || !!locationData;
|
||||
const reactionGroups = groupReactions(message.reactions);
|
||||
|
||||
if (
|
||||
!message.content &&
|
||||
// پیامِ متنیِ خالص که رمزگشایی نشده (هویتِ قدیمی ازدسترفته) — بهجای یه بابلِ خالی یا
|
||||
// متنِ «رمزنگاریشده»، کلاً نمایش داده نمیشه؛ طبقِ خواستهی کاربر کاربر نباید هیچ اثری
|
||||
// از مکانیزمِ E2EE رو ببینه
|
||||
const isUndecryptedTextOnly =
|
||||
(isE2eeEnvelope(message.content) ||
|
||||
message.content === E2EE_LOCKED_PREVIEW) &&
|
||||
!message.file &&
|
||||
!hasLocation &&
|
||||
!sharedPost &&
|
||||
!sharedProduct &&
|
||||
!sharedGift &&
|
||||
!sharedStory &&
|
||||
!(message.viewOnce && isViewOnceMediaType(message.fileType))
|
||||
!sharedStory;
|
||||
|
||||
if (
|
||||
(!message.content &&
|
||||
!message.file &&
|
||||
!hasLocation &&
|
||||
!sharedPost &&
|
||||
!sharedProduct &&
|
||||
!sharedGift &&
|
||||
!sharedStory &&
|
||||
!(message.viewOnce && isViewOnceMediaType(message.fileType))) ||
|
||||
isUndecryptedTextOnly
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -596,7 +614,9 @@ const ChatMessageCard = ({
|
||||
<span className="font-semibold text-[#0095f6]">
|
||||
{message.replyTo.senderName}
|
||||
</span>
|
||||
<p className="truncate">{message.replyTo.content}</p>
|
||||
<p className="truncate">
|
||||
{getBubbleTextContent(message.replyTo.content)}
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -877,15 +897,12 @@ const ChatMessageCard = ({
|
||||
{!isSender && (
|
||||
<div className="flex w-7 shrink-0 items-end self-end">
|
||||
{showAvatar && partnerAvatar ? (
|
||||
<div className="relative h-7 w-7 overflow-hidden rounded-full bg-neutral-700">
|
||||
<Image
|
||||
src={buildStorageUrl(partnerAvatar)}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="28px"
|
||||
/>
|
||||
</div>
|
||||
<ProfileAvatar
|
||||
src={partnerAvatar}
|
||||
userId={message.senderId}
|
||||
sizePx={28}
|
||||
rounded="full"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-7 w-7" aria-hidden />
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -14,6 +13,7 @@ export type ChatRoomMember = {
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
profile_image?: string;
|
||||
verify_badge?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
@@ -22,13 +22,6 @@ type Props = {
|
||||
max?: number;
|
||||
};
|
||||
|
||||
function avatarSrc(profileImage?: string) {
|
||||
if (!profileImage) return null;
|
||||
return profileImage.startsWith("http")
|
||||
? profileImage
|
||||
: buildStorageUrl(profileImage);
|
||||
}
|
||||
|
||||
function displayName(user: ChatRoomMember) {
|
||||
return (
|
||||
[user.first_name, user.last_name].filter(Boolean).join(" ") || user.user_name
|
||||
@@ -118,19 +111,13 @@ export default function ChatRoomMemberPicker({
|
||||
onClick={() => addUser(user)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-right hover:bg-neutral-50 dark:hover:bg-neutral-800"
|
||||
>
|
||||
{avatarSrc(user.profile_image) ? (
|
||||
<Image
|
||||
src={avatarSrc(user.profile_image)!}
|
||||
alt=""
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-8 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-neutral-200 text-xs dark:bg-neutral-700">
|
||||
{user.user_name?.[0]?.toUpperCase() || "?"}
|
||||
</span>
|
||||
)}
|
||||
<ProfileAvatar
|
||||
src={user.profile_image}
|
||||
userId={user._id}
|
||||
verifyBadge={user.verify_badge}
|
||||
sizePx={32}
|
||||
rounded="full"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{displayName(user)}
|
||||
<span className="block text-[11px] text-neutral-500" dir="ltr">
|
||||
|
||||
@@ -1,176 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Modal from "@/components/elements/Modal";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import {
|
||||
IdentityRecoveryNeededError,
|
||||
createFreshIdentity,
|
||||
ensureIdentityKeys,
|
||||
recoverIdentityFromBackup,
|
||||
} from "@/lib/e2ee";
|
||||
|
||||
type GateState = { kind: "idle" } | { kind: "recovery"; hasBackup: boolean };
|
||||
import { useEffect } from "react";
|
||||
import { ensureIdentityKeys } from "@/lib/e2ee";
|
||||
|
||||
/**
|
||||
* موقتاً کلاً غیرفعال — چون رمزنگاریِ خودِ پیامها (encryptDmContent/encryptRoomContent)
|
||||
* موقتاً خاموشه (بهدرخواستِ کاربر، چون بعضی وقتها هنوز «پیام رمزنگاریشده» گیر
|
||||
* میکرد)، دیگه نیازی نیست کاربر رو با کارتِ بازیابیِ هویت معطل کنیم. برای
|
||||
* فعالسازیِ دوباره، فقط همین return null رو بردار.
|
||||
* هیچوقت چیزی نمایش نمیده — طبقِ خواستهی کاربر، E2EE باید کاملاً نامرئی باشه، نه
|
||||
* رمز/کلیدی ازش بپرسه، نه با قفلشدنِ پیامها نشونش بده. establishIdentityAtLogin
|
||||
* (lib/e2ee/keys.ts) معمولاً همون لحظهی لاگین این کار رو با رمزِ واردشده انجام میده؛
|
||||
* این کامپوننت فقط برای حالتِ نادرِ وسطِ سشنه (مثلاً IndexedDB بدونِ خروج از حساب پاک
|
||||
* شده) — فقط یهبار سعی میکنه کلیدِ محلی رو گرم کنه.
|
||||
*
|
||||
* توی ChatSocketProvider (سطحِ لایوت /settings/chats) mount میشه — جایی که
|
||||
* ensureIdentityKeys برای اولینبار صدا زده میشه.
|
||||
* عمداً دیگه اینجا createFreshIdentity صدا زده نمیشه: این کامپوننت هیچ رمزی در
|
||||
* اختیار نداره، پس اگه ensureIdentityKeys نیاز به بازیابی رو تشخیص بده، هیچ راهِ
|
||||
* امنی برای تاییدِ «این هویتِ قدیمی واقعاً غیرقابلبازگشته» وجود نداره — ساختِ
|
||||
* خودکارِ یه هویتِ تازه اینجا یعنی دورریختنِ بیصدای یه هویتِ کاملاً قابلبازیابی فقط
|
||||
* چون این لحظه رمز در دسترس نبود (خیلی مهمتر از زیبایی تجربهی کاربره که اطلاعاتِ
|
||||
* چت کاربر گم نشه). اگه نیاز به بازیابی باشه، ارسالِ پیام تا لاگینِ بعدی
|
||||
* (establishIdentityAtLogin، که رمز رو داره و تصمیمِ درست رو میگیره) موقتاً با خطا
|
||||
* مواجه میشه — این خطا بهمراتب بهتر از ازدسترفتنِ دائمیِ هویته
|
||||
*/
|
||||
export default function E2eeIdentityGate({ userId: _userId }: { userId: string }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function DisabledE2eeIdentityGate({ userId }: { userId: string }) {
|
||||
const { t } = useTranslation("common");
|
||||
const [state, setState] = useState<GateState>({ kind: "idle" });
|
||||
const [pin, setPin] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmFresh, setConfirmFresh] = useState(false);
|
||||
|
||||
const runEnsure = useCallback(
|
||||
async (cancelledRef?: { current: boolean }) => {
|
||||
try {
|
||||
await ensureIdentityKeys(userId);
|
||||
if (!cancelledRef?.current) setState({ kind: "idle" });
|
||||
} catch (err) {
|
||||
if (cancelledRef?.current) return;
|
||||
if (err instanceof IdentityRecoveryNeededError) {
|
||||
setState({ kind: "recovery", hasBackup: err.hasBackup });
|
||||
}
|
||||
}
|
||||
},
|
||||
[userId]
|
||||
);
|
||||
|
||||
export default function E2eeIdentityGate({ userId }: { userId: string }) {
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
const cancelledRef = { current: false };
|
||||
void runEnsure(cancelledRef);
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
};
|
||||
}, [userId, runEnsure]);
|
||||
|
||||
const handleRetryAuto = async () => {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await runEnsure();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecover = async () => {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await recoverIdentityFromBackup(userId, pin);
|
||||
setState({ kind: "idle" });
|
||||
setPin("");
|
||||
} catch {
|
||||
setError(t("chats.e2ee.wrongPin"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartFresh = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await createFreshIdentity(userId);
|
||||
setState({ kind: "idle" });
|
||||
setConfirmFresh(false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (state.kind === "recovery") {
|
||||
return (
|
||||
<Modal isOpen onClose={() => {}} height="fit" elevated>
|
||||
<div className="flex flex-col gap-3 py-2 text-center">
|
||||
<p className="font-bold">{t("chats.e2ee.recoveryTitle")}</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{state.hasBackup
|
||||
? t("chats.e2ee.recoveryHasBackupDesc")
|
||||
: t("chats.e2ee.recoveryNoBackupDesc")}
|
||||
</p>
|
||||
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 w-full max-w-[220px] mx-auto"
|
||||
disabled={busy}
|
||||
onClick={() => void handleRetryAuto()}
|
||||
>
|
||||
{busy ? "…" : t("chats.e2ee.retryButton")}
|
||||
</RoundedButton>
|
||||
|
||||
{state.hasBackup ? (
|
||||
<>
|
||||
<input
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
value={pin}
|
||||
onChange={(e) => setPin(e.target.value)}
|
||||
placeholder={t("chats.e2ee.pinPlaceholder")}
|
||||
className="w-full rounded-xl border border-neutral-300 bg-transparent px-4 py-2.5 text-center text-lg tracking-widest outline-none dark:border-neutral-600"
|
||||
/>
|
||||
<RoundedButton
|
||||
className="h-10 w-full max-w-[220px] mx-auto"
|
||||
disabled={busy || !pin.trim()}
|
||||
onClick={() => void handleRecover()}
|
||||
>
|
||||
{busy ? "…" : t("chats.e2ee.recoverButton")}
|
||||
</RoundedButton>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{confirmFresh ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs font-semibold text-red-500">
|
||||
{t("chats.e2ee.confirmFreshWarning")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<RoundedButton
|
||||
className="h-10 w-full"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirmFresh(false)}
|
||||
>
|
||||
{t("common.no")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 w-full"
|
||||
disabled={busy}
|
||||
onClick={() => void handleStartFresh()}
|
||||
>
|
||||
{busy ? "…" : t("common.yes")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmFresh(true)}
|
||||
className="text-xs text-neutral-500 underline"
|
||||
>
|
||||
{t("chats.e2ee.startFreshLink")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
void ensureIdentityKeys(userId).catch(() => {
|
||||
/* بیخطر نادیده گرفته میشه — رجوع کن به کامنتِ بالا */
|
||||
});
|
||||
}, [userId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import toast from "react-hot-toast";
|
||||
import IOSSpinner from "@/components/ui/IOSSpinner";
|
||||
@@ -97,17 +96,13 @@ export default function ForwardMessageModal({
|
||||
await ensureIdentityKeys(currentUserId);
|
||||
await Promise.all(
|
||||
targets.map(async (receiverId) => {
|
||||
let sendContent = content;
|
||||
try {
|
||||
const encrypted = await encryptDmContent(
|
||||
currentUserId,
|
||||
receiverId,
|
||||
content
|
||||
);
|
||||
if (encrypted) sendContent = encrypted;
|
||||
} catch {
|
||||
/* plaintext fallback */
|
||||
}
|
||||
const encrypted = await encryptDmContent(
|
||||
currentUserId,
|
||||
receiverId,
|
||||
content
|
||||
);
|
||||
if (encrypted === null) throw new Error("E2EE encryption failed");
|
||||
const sendContent = encrypted;
|
||||
return request("POST", "/chat", {
|
||||
content: sendContent,
|
||||
receiverId,
|
||||
@@ -161,17 +156,12 @@ export default function ForwardMessageModal({
|
||||
selected.has(u._id) ? "bg-[#0095f6]/15 ring-1 ring-[#0095f6]/40" : "hover:bg-black/5"
|
||||
}`}
|
||||
>
|
||||
{u.profile_image ? (
|
||||
<Image
|
||||
src={buildStorageUrl(u.profile_image)}
|
||||
width={40}
|
||||
height={40}
|
||||
alt=""
|
||||
className="rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded-full bg-neutral-200" />
|
||||
)}
|
||||
<ProfileAvatar
|
||||
src={u.profile_image}
|
||||
userId={u._id}
|
||||
sizePx={40}
|
||||
rounded="full"
|
||||
/>
|
||||
<span className="text-sm">
|
||||
{u.first_name} {u.last_name}
|
||||
</span>
|
||||
|
||||
@@ -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;
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { fetchPostById } from "@/api/fetchPostById";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface SharedPostPayload {
|
||||
@@ -32,6 +34,24 @@ export default function SharedPostBubble({ data }: { data: SharedPostPayload })
|
||||
const href = data.url || `/posts/${data.postId}`;
|
||||
const preview = data.preview ? buildStorageUrl(data.preview) : null;
|
||||
|
||||
// اگه پست بعد از ارسال حذف شده باشه، این کارت (که فقط یه اسنپشاتِ ذخیرهشده در
|
||||
// لحظهی ارساله) باید بجای پیشنمایشِ کهنه، وضعیتِ «حذفشده» رو نشون بده
|
||||
const { data: livePost, isLoading } = useQuery({
|
||||
queryKey: ["post-exists", data.postId],
|
||||
queryFn: () => fetchPostById(data.postId),
|
||||
staleTime: 5 * 60_000,
|
||||
enabled: Boolean(data.postId),
|
||||
});
|
||||
const isDeleted = !isLoading && livePost === null;
|
||||
|
||||
if (isDeleted) {
|
||||
return (
|
||||
<div className="flex min-w-[200px] max-w-[240px] items-center gap-2 rounded-2xl border border-white/20 bg-black/10 px-3 py-3 opacity-70">
|
||||
<p className="text-xs">{t("posts.postWasDeleted")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from "next/link";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { AcademyExploreItem } from "@/api/fetchAcademyExplore";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import { formatLikeDisplay } from "@/lib/formatLikeDisplay";
|
||||
import SharePostModal from "@/components/main/SharePostModal";
|
||||
@@ -315,16 +316,13 @@ export default function ReelsAcademyCard({
|
||||
href={item.user_name ? `/users/${item.user_name}` : "#"}
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5"
|
||||
>
|
||||
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-full bg-neutral-700 ring-2 ring-white/30">
|
||||
{item.profile_image ? (
|
||||
<Image
|
||||
src={buildStorageUrl(item.profile_image)}
|
||||
alt=""
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="shrink-0 overflow-hidden rounded-full ring-2 ring-white/30">
|
||||
<ProfileAvatar
|
||||
src={item.profile_image}
|
||||
userId={item.user_id}
|
||||
sizePx={40}
|
||||
rounded="full"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-white">
|
||||
<div className="truncate text-sm font-semibold">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Image from "next/image";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buildStorageUrl } from "./BaseUrl";
|
||||
import StoryRing, { verifyBadgeToTierColor, STORY_RING_PX } from "@/components/stories/StoryRing";
|
||||
import StoryRing, { verifyBadgeToTierColor, STORY_RING_TOTAL_INSET_PX } from "@/components/stories/StoryRing";
|
||||
import { useStoryMetaForUser } from "@/hooks/useStoryMetaForUser";
|
||||
|
||||
const sizeMap = {
|
||||
@@ -65,22 +65,42 @@ function ProfileAvatar({
|
||||
|
||||
const { hasStory, hasUnviewed } = useStoryMetaForUser(userId);
|
||||
|
||||
// Keep the total rendered footprint equal to resolvedSizePx in both states.
|
||||
// StoryRing only adds ring padding when hasStory is true, so when it *is* true we
|
||||
// pre-shrink the photo by the ring's width; when it's false we pass the full size
|
||||
// (StoryRing then renders it 1:1, matching a plain avatar exactly). Either way the
|
||||
// outer box never grows/shrinks relative to a plain avatar, so wrapping a row's
|
||||
// avatar with a ring never shifts that row's height or the next element's position.
|
||||
const avatarSizeForRing =
|
||||
userId && hasStory
|
||||
? Math.max(0, resolvedSizePx - STORY_RING_TOTAL_INSET_PX * 2)
|
||||
: resolvedSizePx;
|
||||
const cornerRadius = rounded === "full" ? avatarSizeForRing / 2 : rounded === "xl" ? 12 : 16;
|
||||
|
||||
// وقتی داخل StoryRing قرار میگیره، اندازهی واقعیِ خودِ عکس باید avatarSizeForRing
|
||||
// باشه (نه resolvedSizePx خام) — وگرنه وقتی hasStory=true باکسِ حلقه کوچیکتر میشه
|
||||
// ولی عکس همچنان با اندازهی بزرگترِ قبلی رندر میشه و از باکسِ overflow-hidden
|
||||
// بیرون میزنه (لبهی راست زیرِ قاب میره، گردیِ گوشهها هم با کانتینر همخون نمیشه)
|
||||
const imageSizePx = userId ? avatarSizeForRing : resolvedSizePx;
|
||||
|
||||
// When wrapped in a StoryRing, `className` (margins, shrink, size overrides) must land
|
||||
// on the ring's outer box, not this inner <Image> — the ring's middle layer is a fixed-size
|
||||
// overflow-hidden box, so any margin applied here gets silently clipped instead of creating
|
||||
// visible spacing (see the search-list "avatar stuck to text" regression). `sizePx` isn't
|
||||
// affected by that — it always drives the actual pixel size via inline style below.
|
||||
// visible spacing (see the search-list "avatar stuck to text" regression). The image's own
|
||||
// rounding is skipped too when ring-wrapped — StoryRing's content box already clips to the
|
||||
// correctly-computed radius (innerRadius), so applying a second, independent fixed radius
|
||||
// here (roundedClass) would fight it and show a mismatched sliver at the corners.
|
||||
const image = (
|
||||
<Image
|
||||
src={imageSrc}
|
||||
alt={alt}
|
||||
width={resolvedSizePx}
|
||||
height={resolvedSizePx}
|
||||
style={sizePx ? { width: sizePx, height: sizePx } : undefined}
|
||||
width={imageSizePx}
|
||||
height={imageSizePx}
|
||||
style={sizePx || userId ? { width: imageSizePx, height: imageSizePx } : undefined}
|
||||
className={cn(
|
||||
"profile-avatar aspect-square object-cover shrink-0",
|
||||
!sizePx && sizeMap[size],
|
||||
roundedClass,
|
||||
!sizePx && !userId && sizeMap[size],
|
||||
!userId && roundedClass,
|
||||
!userId && className
|
||||
)}
|
||||
/>
|
||||
@@ -90,17 +110,6 @@ function ProfileAvatar({
|
||||
return image;
|
||||
}
|
||||
|
||||
// Keep the total rendered footprint equal to resolvedSizePx in both states.
|
||||
// StoryRing only adds ring padding when hasStory is true, so when it *is* true we
|
||||
// pre-shrink the photo by the ring's width; when it's false we pass the full size
|
||||
// (StoryRing then renders it 1:1, matching a plain avatar exactly). Either way the
|
||||
// outer box never grows/shrinks relative to a plain avatar, so wrapping a row's
|
||||
// avatar with a ring never shifts that row's height or the next element's position.
|
||||
const avatarSizeForRing = hasStory
|
||||
? Math.max(0, resolvedSizePx - STORY_RING_PX * 2)
|
||||
: resolvedSizePx;
|
||||
const cornerRadius = rounded === "full" ? avatarSizeForRing / 2 : rounded === "xl" ? 12 : 16;
|
||||
|
||||
return (
|
||||
<StoryRing
|
||||
hasStory={hasStory}
|
||||
|
||||
@@ -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 برای پخش/توقف ویدیو بر اساس ویوپورت
|
||||
@@ -206,11 +215,17 @@ function MainModelCard({
|
||||
tierColor={verifyBadgeToTierColor(verify_badge)}
|
||||
rounded="xl"
|
||||
animated={false}
|
||||
avatarSize={storyMeta.hasStory ? 67 : 72}
|
||||
// ۷۲ - ۲×(۳حلقه+۲فاصله) = ۶۲ — نه ۶۷ (که فوتپرینتِ کلِ حلقه رو از
|
||||
// ۷۲ به ۷۷ میبرد و از باکسِ ثابتِ ۷۲×۷۲ بیرون میزد)
|
||||
avatarSize={storyMeta.hasStory ? 62 : 72}
|
||||
avatarRadius={12}
|
||||
>
|
||||
{/* بدونِ rounded-2xl مستقل اینجا — والد (StoryRing > innerRounded) خودش
|
||||
overflow-hidden + شعاعِ درستمحاسبهشده (avatarRadius منهای padding)
|
||||
داره؛ یه رادیوسِ ثابتِ دیگهی اینجا با اونش ناهمخون میشه و باعثِ
|
||||
نازک/نامنظم دیدهشدنِ گوشهها میشه */}
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-2xl ${
|
||||
className={`relative ${
|
||||
storyMeta.hasStory ? "h-full w-full" : "h-[72px] w-[72px]"
|
||||
}`}
|
||||
>
|
||||
@@ -223,7 +238,7 @@ function MainModelCard({
|
||||
alt={user_name}
|
||||
fill
|
||||
sizes="72px"
|
||||
className="aspect-square rounded-2xl object-cover"
|
||||
className="aspect-square object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
@@ -370,7 +385,7 @@ function MainModelCard({
|
||||
|
||||
<div
|
||||
className={`overflow-hidden rounded-2xl ${onMediaClick ? "cursor-pointer" : ""}`}
|
||||
onClick={onMediaClick}
|
||||
onClick={handleMediaTap}
|
||||
onKeyDown={
|
||||
onMediaClick
|
||||
? (e) => {
|
||||
@@ -395,14 +410,6 @@ function MainModelCard({
|
||||
{mediaType === "video" && videoSrc && (
|
||||
<div className="relative w-full rounded-2xl overflow-hidden bg-black">
|
||||
<video
|
||||
onClick={(e) => {
|
||||
if (onMediaClick) {
|
||||
e.stopPropagation();
|
||||
onMediaClick();
|
||||
return;
|
||||
}
|
||||
handleVideoClick(e);
|
||||
}}
|
||||
ref={videoRef}
|
||||
src={videoSrc}
|
||||
poster={videoPoster}
|
||||
@@ -412,18 +419,18 @@ function MainModelCard({
|
||||
onLoadedData={() => setImageLoading(false)}
|
||||
muted={muted}
|
||||
/>
|
||||
{ripple && (
|
||||
<span
|
||||
className="absolute rounded-full bg-white/50 animate-ripple"
|
||||
style={{
|
||||
left: ripple.x - 25,
|
||||
top: ripple.y - 25,
|
||||
width: 50,
|
||||
height: 50,
|
||||
}}
|
||||
key={ripple.key}
|
||||
/>
|
||||
)}
|
||||
{likeBurstKey ? (
|
||||
<div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center">
|
||||
<Image
|
||||
key={likeBurstKey}
|
||||
src="/images/icons/red-heart.svg"
|
||||
alt=""
|
||||
width={96}
|
||||
height={96}
|
||||
className="animate-ping"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{imageLoading && (
|
||||
<div className="absolute inset-0 flex justify-center items-center bg-black/30 z-10">
|
||||
<Image
|
||||
@@ -434,18 +441,23 @@ function MainModelCard({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="pointer-events-none absolute bottom-3 left-3 z-50 rounded-full bg-black/50 p-2 backdrop-blur-md">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleVideoMute}
|
||||
className="absolute bottom-3 left-3 z-50 flex h-9 w-9 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur-md"
|
||||
aria-label={videoMuted ? t("models.unmute") : t("models.mute")}
|
||||
>
|
||||
{videoMuted ? (
|
||||
<BoldIcon name="volume-slash" size={20} tinted className="text-white" aria-label={t("models.mute")} />
|
||||
<BoldIcon name="volume-slash" size={18} tinted className="text-white" />
|
||||
) : (
|
||||
<BoldIcon name="volume-high" size={20} tinted className="text-white" aria-label={t("models.unmute")} />
|
||||
<BoldIcon name="volume-high" size={18} tinted className="text-white" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MainModelCardActions postData={postData} />
|
||||
<MainModelCardActions postData={postData} social={social} />
|
||||
{caption?.includes("@") && (
|
||||
<div className="mx-2 mt-1 flex items-center gap-1 text-xs text-neutral-500">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
@@ -466,20 +478,6 @@ function MainModelCard({
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.animate-ripple {
|
||||
animation: ripple 600ms ease-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes ripple {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
transform: scale(4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,9 @@ import { useTranslation } from "react-i18next";
|
||||
import { getPostVideoSrc } from "@/lib/videoPreload";
|
||||
import { getVideoPoster } from "@/lib/explore/postMedia";
|
||||
import { resolvePostMediaType } from "@/lib/reelsMediaType";
|
||||
import StoryRing, { verifyBadgeToTierColor } from "@/components/stories/StoryRing";
|
||||
import StoryAvatarOpen from "@/components/stories/StoryAvatarOpen";
|
||||
import { useStoryMetaForUser } from "@/hooks/useStoryMetaForUser";
|
||||
|
||||
// ایجاد Context برای به اشتراک گذاشتن وضعیت muted بین همه کارتها
|
||||
export const MutedContext = createContext<{
|
||||
@@ -47,6 +50,7 @@ function MainModelCard({ postData }: { postData: Post }) {
|
||||
const verify_badge = user?.verify_badge;
|
||||
const user_score = user?.user_score;
|
||||
const rate = user?.rate;
|
||||
const storyMeta = useStoryMetaForUser(user_id);
|
||||
// console.log(user);
|
||||
|
||||
const [showFullCaption, setShowFullCaption] = useState(false);
|
||||
@@ -176,18 +180,39 @@ function MainModelCard({ postData }: { postData: Post }) {
|
||||
className="flex w-full text-sm justify-between py-2"
|
||||
>
|
||||
<div className="flex items-center w-full">
|
||||
<div className="relative max-w-[72px] min-w-[72px] max-h-[72px] min-h-[72px] ml-2">
|
||||
<Image
|
||||
src={
|
||||
profile_image
|
||||
? buildStorageUrl(profile_image)
|
||||
: "/images/fake-avatar.png"
|
||||
}
|
||||
alt={user_name}
|
||||
fill
|
||||
className="rounded-2xl object-cover aspect-square"
|
||||
priority
|
||||
/>
|
||||
<div className="relative ml-2 flex h-[72px] w-[72px] shrink-0 items-center justify-center">
|
||||
<StoryRing
|
||||
hasStory={storyMeta.hasStory}
|
||||
hasUnviewed={storyMeta.hasUnviewed}
|
||||
tierColor={verifyBadgeToTierColor(verify_badge)}
|
||||
rounded="xl"
|
||||
animated={false}
|
||||
// ۷۲ - ۲×(۳حلقه+۲فاصله) = ۶۲ — نه ۶۷ (که فوتپرینتِ کلِ حلقه رو از ۷۲
|
||||
// به ۷۷ میبرد و از باکسِ ثابتِ ۷۲×۷۲ بیرون میزد)
|
||||
avatarSize={storyMeta.hasStory ? 62 : 72}
|
||||
avatarRadius={12}
|
||||
>
|
||||
{/* بدونِ rounded-2xl مستقل — والد (StoryRing) خودش شعاعِ درستمحاسبهشده
|
||||
رو کلیپ میکنه، وگرنه گوشهها نازک/نامنظم دیده میشن */}
|
||||
<div
|
||||
className={`relative ${
|
||||
storyMeta.hasStory ? "h-full w-full" : "h-[72px] w-[72px]"
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={
|
||||
profile_image
|
||||
? buildStorageUrl(profile_image)
|
||||
: "/images/fake-avatar.png"
|
||||
}
|
||||
alt={user_name}
|
||||
fill
|
||||
sizes="72px"
|
||||
className="aspect-square object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</StoryRing>
|
||||
</div>
|
||||
<div className="font-semibold flex gap-2 flex-col-reverse ml-2 w-full">
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
|
||||
@@ -127,14 +127,23 @@ function ModelContentPosts({
|
||||
post.post_images?.[0] ||
|
||||
mediaFiles.find((f) => f.type === "image")?.src;
|
||||
|
||||
const isPending = post.status === "pending";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={post._id}
|
||||
className="relative aspect-square cursor-pointer overflow-hidden rounded-2xl group focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-500"
|
||||
className={`relative aspect-square cursor-pointer overflow-hidden rounded-2xl group focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-500 ${
|
||||
isPending ? "opacity-50" : ""
|
||||
}`}
|
||||
onClick={() => openPost(post)}
|
||||
aria-label={t("models.viewPost")}
|
||||
>
|
||||
{isPending ? (
|
||||
<span className="pointer-events-none absolute right-1.5 top-1.5 z-10 rounded-md bg-black/60 px-1.5 py-0.5 text-[10px] font-semibold text-white">
|
||||
{t("models.postPending")}
|
||||
</span>
|
||||
) : null}
|
||||
{post.type === "image" ? (
|
||||
<Image
|
||||
src={firstMedia}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -102,19 +101,12 @@ export default function ProfileUserListModal({
|
||||
className="flex items-center gap-3 rounded-2xl px-2 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="relative h-10 w-10 overflow-hidden rounded-full bg-neutral-200">
|
||||
<Image
|
||||
src={
|
||||
u.profile_image
|
||||
? buildStorageUrl(u.profile_image)
|
||||
: "/images/fake-avatar.png"
|
||||
}
|
||||
alt=""
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
<ProfileAvatar
|
||||
src={u.profile_image}
|
||||
userId={u._id}
|
||||
sizePx={40}
|
||||
rounded="full"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">{name}</p>
|
||||
<p className="truncate text-xs text-neutral-500">
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -20,6 +20,7 @@ export default function PostLocationNoticeModal({ isOpen, onNo, onYes }: Props)
|
||||
isOpen={isOpen}
|
||||
onClose={onNo}
|
||||
height="fit"
|
||||
elevated
|
||||
panelClassName="!p-5 max-w-sm"
|
||||
>
|
||||
<p className="text-center text-sm font-bold leading-relaxed text-neutral-900 dark:text-white">
|
||||
|
||||
@@ -159,6 +159,9 @@ export default function ReelsImageCarousel({
|
||||
sizes="100vw"
|
||||
className="object-contain select-none"
|
||||
priority={priority}
|
||||
// unoptimized: مثلِ ReelsPostCard — رد کردن از ریسایز/تبدیلِ next/image، دانلودِ
|
||||
// مستقیم از CDN عینِ FastImage در اندروید، برای حذفِ تاخیر و افتِ کیفیتِ تبدیل
|
||||
unoptimized
|
||||
draggable={false}
|
||||
onLoad={() => onLoadingComplete?.()}
|
||||
/>
|
||||
@@ -191,6 +194,8 @@ export default function ReelsImageCarousel({
|
||||
loading={
|
||||
priority && Math.abs(idx - index) <= 1 ? "eager" : "lazy"
|
||||
}
|
||||
// unoptimized: همون دلیلِ بالا — بدونِ هاپِ اضافهی ریسایز سرور
|
||||
unoptimized
|
||||
draggable={false}
|
||||
onLoad={() => {
|
||||
if (idx === index) onLoadingComplete?.();
|
||||
|
||||
@@ -13,6 +13,11 @@ import { useUserById } from "@/hooks/getUserById";
|
||||
import SharePostModal from "@/components/main/SharePostModal";
|
||||
import SendPostModal from "@/components/posts/SendPostModal";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import {
|
||||
verifyBadgeToTierColor,
|
||||
TIER_RING_COLORS,
|
||||
} from "@/components/stories/StoryRing";
|
||||
import { useStoryMetaForUser } from "@/hooks/useStoryMetaForUser";
|
||||
import { SocialStatButton, SocialIconButton } from "@/components/ui/SocialStatButton";
|
||||
import {
|
||||
POST_SOCIAL_ICONS,
|
||||
@@ -40,28 +45,74 @@ interface ReelsPostActionsProps {
|
||||
}
|
||||
|
||||
function ReelsProfileFollowButton({
|
||||
userId,
|
||||
userName,
|
||||
profileImage,
|
||||
verifyBadge,
|
||||
following,
|
||||
isOwnPost,
|
||||
onToggleFollow,
|
||||
}: {
|
||||
userId?: string;
|
||||
userName: string;
|
||||
profileImage?: string;
|
||||
verifyBadge?: string | null;
|
||||
following: boolean;
|
||||
isOwnPost: boolean;
|
||||
onToggleFollow: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
// طبق خواستهی کاربر: اندازهی کلِ آواتار (۴۸px) و اندازهی خودِ عکس هم برای کاربرِ
|
||||
// استوریدار و هم بدونِ استوری باید دقیقاً یکی باشه — هم برای اینکه عکسِ کاربرِ
|
||||
// بدونِ استوری بزرگتر دیده نشه، هم برای اینکه فاصلهش تا لبهی کادر (چون داخلِ
|
||||
// یه ستونِ ثابتعرضِ items-center جا میگیره) با کاربرِ استوریدار یکی بمونه. بهجای
|
||||
// یه قابِ اضافهی جداگانه برای حالتِ بدونِ استوری، همون لایهی حلقه رو نگه میداریم
|
||||
// ولی رنگش سفید میشه — پس هندسه (سایز کل/سایز عکس/فاصله از لبه) همیشه ثابته و
|
||||
// فقط رنگِ حلقه فرق میکنه
|
||||
const { hasStory, hasUnviewed } = useStoryMetaForUser(userId);
|
||||
const RING_TOTAL = 48;
|
||||
// عینِ اندروید (ReelsPostCard.tsx > FollowAvatarBadge: ringWidth={2}) — نه ۳پیکسلِ
|
||||
// پیشفرضِ سراسریِ STORY_RING_PX، چون اونجا ضخامتِ ظریفتری برای این آواتارِ خاص خواسته شده.
|
||||
// طبق خواستهی کاربر: فاصلهی خالیِ شفاف بینِ حلقه و عکس فقط برای کاربرِ استوریدار
|
||||
// میمونه؛ برای کاربرِ بدونِ استوری، قابِ سفید بدونِ فاصلهست و عکس مستقیم باهاش فیت میشه
|
||||
const RING_W = 2;
|
||||
const GAP_W = hasStory ? 2 : 0;
|
||||
const PHOTO = RING_TOTAL - (RING_W + GAP_W) * 2;
|
||||
const tierColor = verifyBadgeToTierColor(verifyBadge);
|
||||
const isViewed = hasStory && !hasUnviewed;
|
||||
const ringColor = !hasStory
|
||||
? "#ffffff"
|
||||
: isViewed
|
||||
? "#a3a3a3"
|
||||
: tierColor
|
||||
? TIER_RING_COLORS[tierColor]
|
||||
: "#FF107D";
|
||||
|
||||
const avatar = (
|
||||
<div className="relative h-12 w-12 overflow-hidden rounded-full border-2 border-white shadow-lg">
|
||||
<Image
|
||||
src={profileImage ? buildStorageUrl(profileImage) : "/images/fake-avatar.png"}
|
||||
alt={userName}
|
||||
fill
|
||||
sizes="48px"
|
||||
className="rounded-full object-cover"
|
||||
/>
|
||||
// بهجای یه دایرهی پُررنگِ زیرین + لایههای روش (که وسطش هرچی هم "شفاف" باشه بازم
|
||||
// رنگِ لایهی زیرین ازش رد میشه)، خودِ حلقه یه border ـه (نه fill) — پس فضای
|
||||
// خالیِ داخلش واقعاً شفافه و پسزمینهی واقعیِ پشتِ کامپوننت (نه رنگِ حلقه) دیده میشه
|
||||
<div
|
||||
className="relative shrink-0 rounded-full drop-shadow-lg"
|
||||
style={{
|
||||
width: RING_TOTAL,
|
||||
height: RING_TOTAL,
|
||||
boxSizing: "border-box",
|
||||
border: `${RING_W}px solid ${ringColor}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute overflow-hidden rounded-full"
|
||||
style={{ inset: GAP_W }}
|
||||
>
|
||||
<Image
|
||||
src={profileImage ? buildStorageUrl(profileImage) : "/images/fake-avatar.png"}
|
||||
alt={userName}
|
||||
fill
|
||||
sizes={`${PHOTO}px`}
|
||||
className="rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -132,7 +183,7 @@ function ReelsStoryShareButton({
|
||||
name="story"
|
||||
size={28}
|
||||
tinted
|
||||
className="text-white drop-shadow-lg"
|
||||
className="text-white"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
@@ -190,7 +241,11 @@ export default function ReelsPostActions({
|
||||
const postIconSize = "sm" as const;
|
||||
const reelsIconSize = "md" as const;
|
||||
const actionButtonClass = "text-white drop-shadow-md";
|
||||
const reelsActionButtonClass = "text-white brightness-125 drop-shadow-lg";
|
||||
// قبلاً drop-shadow-lg هم داشت — ولی چون این آیکونها (برخلافِ actionButtonClass که
|
||||
// افقی/جداگانهست) توی ستونِ ریلز با gap-4 پشتِ سرِ هم تلنبار میشن، سایهی هرکدوم با
|
||||
// بغلیاش قاطی میشد و یه «هاله»ی محوِ پیوسته پشتِ کل ستون میساخت — طبق خواستهی
|
||||
// کاربر کاملاً حذف شد (عینِ همین فیکس روی اندروید: ReelsPostCard.tsx > actionBtn)
|
||||
const reelsActionButtonClass = "text-white brightness-125";
|
||||
|
||||
const collaborationModals = showServicesModal &&
|
||||
(expertise === "مدلینگ" ? (
|
||||
@@ -305,8 +360,10 @@ export default function ReelsPostActions({
|
||||
/>
|
||||
) : null}
|
||||
<ReelsProfileFollowButton
|
||||
userId={user_id}
|
||||
userName={user_name}
|
||||
profileImage={user?.profile_image}
|
||||
verifyBadge={user?.verify_badge}
|
||||
following={social.following}
|
||||
isOwnPost={social.isOwnPost}
|
||||
onToggleFollow={social.toggleFollow}
|
||||
@@ -468,7 +525,7 @@ export default function ReelsPostActions({
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"inline-block h-7 w-7 shrink-0 bg-white drop-shadow-lg",
|
||||
"inline-block h-7 w-7 shrink-0 bg-white",
|
||||
expanded && "opacity-80"
|
||||
)}
|
||||
style={{
|
||||
|
||||
@@ -20,7 +20,7 @@ import ReelsImageCarousel from "./ReelsImageCarousel";
|
||||
import PostCommentsContent from "./PostCommentsContent";
|
||||
import CommentDrawer from "@/components/comments/CommentDrawer";
|
||||
import ExpandableCaption from "./ExpandableCaption";
|
||||
import StoryRing, { verifyBadgeToTierColor } from "@/components/stories/StoryRing";
|
||||
import { verifyBadgeToTierColor, TIER_RING_COLORS } from "@/components/stories/StoryRing";
|
||||
import StoryAvatarOpen from "@/components/stories/StoryAvatarOpen";
|
||||
import { useStoryMetaForUser } from "@/hooks/useStoryMetaForUser";
|
||||
import { MutedContext } from "@/components/models/ModelPage/MainModelCardPost";
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
reorderCaptionLinksToEnd,
|
||||
splitCaptionBodyAndLinks,
|
||||
} from "@/lib/postLinkCaption";
|
||||
import { REELS_BOTTOM_RESERVED } from "@/constants/reelsFeed";
|
||||
|
||||
const HOLD_MS = 220;
|
||||
const LONG_VIDEO_SEC = 10;
|
||||
@@ -96,9 +97,9 @@ export default function ReelsPostCard({
|
||||
const videoSrc = type === "video" ? getPostVideoSrc(postData) : undefined;
|
||||
|
||||
const videoPreload =
|
||||
preloadPriority === "active" || preloadPriority === "nearby"
|
||||
preloadPriority === "active"
|
||||
? "auto"
|
||||
: preloadPriority === "idle"
|
||||
: preloadPriority === "idle" || preloadPriority === "nearby"
|
||||
? "metadata"
|
||||
: "none";
|
||||
|
||||
@@ -152,6 +153,10 @@ export default function ReelsPostCard({
|
||||
const holdTriggeredRef = useRef(false);
|
||||
const singleTapTimerRef = useRef<number | null>(null);
|
||||
|
||||
const [commentsMountedOnce, setCommentsMountedOnce] = useState(false);
|
||||
useEffect(() => {
|
||||
if (commentsOpen) setCommentsMountedOnce(true);
|
||||
}, [commentsOpen]);
|
||||
const { muted, setMuted } = useContext(MutedContext);
|
||||
const [videoMuted, setVideoMuted] = useState(true);
|
||||
const exploreUi = useExploreReelsUi();
|
||||
@@ -226,57 +231,40 @@ export default function ReelsPostCard({
|
||||
holdTriggeredRef.current = false;
|
||||
}, [clearHoldTimer, exploreUi]);
|
||||
|
||||
const handleMediaClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (postPage || commentsOpen || !isActive) return;
|
||||
if (singleTapTimerRef.current) {
|
||||
window.clearTimeout(singleTapTimerRef.current);
|
||||
}
|
||||
singleTapTimerRef.current = window.setTimeout(() => {
|
||||
singleTapTimerRef.current = null;
|
||||
if (exploreUi?.chromeHidden) {
|
||||
exploreUi.setChromeHidden(false);
|
||||
return;
|
||||
}
|
||||
if (e.target === e.currentTarget) {
|
||||
// tap on empty overlay only
|
||||
}
|
||||
}, DOUBLE_TAP_MS);
|
||||
},
|
||||
[postPage, commentsOpen, isActive, exploreUi]
|
||||
);
|
||||
// یک ضربه = پخش/توقف (یا نمایش/مخفیکردنِ آیکونها)، دو ضربهی سریع = پسندیدن — عینِ
|
||||
// اندروید. با تایمر خودمون انجام میشه، نه dblclick مرورگر (که روی لمسی معمولاً اجرا نمیشه)
|
||||
const handleMediaTap = useCallback(() => {
|
||||
if (postPage || commentsOpen || !isActive || holdTriggeredRef.current) return;
|
||||
|
||||
const handleMediaDoubleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (postPage || commentsOpen || !isActive) return;
|
||||
e.stopPropagation();
|
||||
if (singleTapTimerRef.current) {
|
||||
window.clearTimeout(singleTapTimerRef.current);
|
||||
singleTapTimerRef.current = null;
|
||||
}
|
||||
if (singleTapTimerRef.current) {
|
||||
window.clearTimeout(singleTapTimerRef.current);
|
||||
singleTapTimerRef.current = null;
|
||||
if (!social.liked) {
|
||||
void social.toggleLike();
|
||||
}
|
||||
setLikeBurstKey(Date.now());
|
||||
window.setTimeout(() => setLikeBurstKey(null), 900);
|
||||
},
|
||||
[postPage, commentsOpen, isActive, social]
|
||||
);
|
||||
|
||||
const handleVideoClick = (e: React.MouseEvent<HTMLVideoElement>) => {
|
||||
if (holdTriggeredRef.current || e.detail > 1) return;
|
||||
e.stopPropagation();
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
if (v.paused) {
|
||||
void v.play().catch(() => {});
|
||||
setVideoPaused(false);
|
||||
} else {
|
||||
v.pause();
|
||||
setVideoPaused(true);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
singleTapTimerRef.current = window.setTimeout(() => {
|
||||
singleTapTimerRef.current = null;
|
||||
if (exploreUi?.chromeHidden) {
|
||||
exploreUi.setChromeHidden(false);
|
||||
return;
|
||||
}
|
||||
if (type !== "video") return;
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
if (v.paused) {
|
||||
void v.play().catch(() => {});
|
||||
setVideoPaused(false);
|
||||
} else {
|
||||
v.pause();
|
||||
setVideoPaused(true);
|
||||
}
|
||||
}, DOUBLE_TAP_MS);
|
||||
}, [postPage, commentsOpen, isActive, exploreUi, social, type]);
|
||||
|
||||
const toggleVideoMute = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -349,7 +337,7 @@ export default function ReelsPostCard({
|
||||
|
||||
useEffect(() => {
|
||||
if (type !== "video" || !videoSrc) return;
|
||||
if (preloadPriority === "active" || preloadPriority === "nearby") {
|
||||
if (preloadPriority === "active") {
|
||||
prefetchVideoUrl(videoSrc, "high");
|
||||
}
|
||||
}, [type, videoSrc, preloadPriority]);
|
||||
@@ -473,7 +461,11 @@ export default function ReelsPostCard({
|
||||
return (
|
||||
<article
|
||||
ref={containerRef}
|
||||
className="relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-black"
|
||||
// برخلافِ تلاشِ قبلی (نسبتِ ثابتِ ۹:۱۶ که روی گوشیهای واقعی فضای خالیِ خیلی زیادی
|
||||
// زیرش میذاشت)، اینجا فقط دقیقاً بهاندازهی ارتفاعِ نوارِ ناوبریِ شناور (+ safe-area)
|
||||
// از پایین کم میشه — تقریباً تمامصفحه، عینِ ریلزِ اندروید، نه letterbox
|
||||
style={{ height: `calc(100% - ${REELS_BOTTOM_RESERVED})` }}
|
||||
className="relative flex w-full flex-col overflow-hidden bg-black"
|
||||
>
|
||||
<motion.div
|
||||
className={cn(
|
||||
@@ -489,8 +481,7 @@ export default function ReelsPostCard({
|
||||
onPointerUp={handleMediaPointerUp}
|
||||
onPointerLeave={handleMediaPointerUp}
|
||||
onPointerCancel={handleMediaPointerUp}
|
||||
onClick={handleMediaClick}
|
||||
onDoubleClick={handleMediaDoubleClick}
|
||||
onClick={handleMediaTap}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{type !== "video" && imageFiles.length > 0 && (
|
||||
@@ -515,8 +506,14 @@ export default function ReelsPostCard({
|
||||
// دقیقاً روی لبهی پایینِ کادر بشینه (نه وسطچین) — تا صرفنظر از نسبتِ
|
||||
// ابعادِ صفحهنمایشِ گوشی، این خط ثابت بمونه؛ پوستر هم باید با همون
|
||||
// لنگرگاه نمایش داده بشه، وگرنه لحظهی لود شدنِ ویدیو یه پرش دیده میشه
|
||||
className="object-contain object-bottom"
|
||||
className="object-cover object-center"
|
||||
priority={isActive || preloadPriority === "nearby"}
|
||||
// unoptimized: این فقط یه پیشنمایشِ کوتاهمدته تا ویدیو آماده بشه — رد
|
||||
// کردنش از مسیرِ بهینهسازیِ next/image (ریسایز+تبدیل به avif/webp روی
|
||||
// سرور) دقیقاً همونیه که اندروید هم با FastImage انجام میده: دانلودِ مستقیمِ
|
||||
// فایلِ اصلی از CDN بدون یک هاپِ اضافه؛ همون هاپِ اضافه (و کیفیتِ کمشدهی
|
||||
// خروجیِ تبدیل) عامل اصلیِ «تار دیده شدن و کند لود شدنِ» پوستر نسبت به اندروید بود
|
||||
unoptimized
|
||||
draggable={!allowSave ? false : undefined}
|
||||
onContextMenu={
|
||||
!allowSave ? (e) => e.preventDefault() : undefined
|
||||
@@ -532,10 +529,9 @@ export default function ReelsPostCard({
|
||||
preload={videoPreload}
|
||||
controlsList="nodownload"
|
||||
className={cn(
|
||||
"h-full w-full object-contain object-bottom",
|
||||
"h-full w-full object-cover object-center",
|
||||
videoPoster && !videoReady && "opacity-0"
|
||||
)}
|
||||
onClick={handleVideoClick}
|
||||
// مرورگر نمیتونه از اسکرینشات واقعی جلوگیری کنه (هیچ API مرورگری برای این
|
||||
// وجود نداره)؛ این فقط راههای سادهی دانلود/ذخیره (کلیک راست، درگ) رو میبندد
|
||||
onContextMenu={
|
||||
@@ -588,6 +584,21 @@ export default function ReelsPostCard({
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "video" && isActive && !commentsOpen && !postPage && !videoPaused && !chromeHidden && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleVideoMute}
|
||||
className="absolute bottom-3 left-3 z-[26] flex h-8 w-8 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur-md"
|
||||
aria-label={videoMuted ? t("posts.unmute") : t("posts.mute")}
|
||||
>
|
||||
{videoMuted ? (
|
||||
<BoldIcon name="volume-slash" size={16} tinted className="text-white" />
|
||||
) : (
|
||||
<BoldIcon name="volume-high" size={16} tinted className="text-white" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{imageLoading && isActive && !commentsOpen && <ReelsMediaSkeleton />}
|
||||
|
||||
{likeBurstKey ? (
|
||||
@@ -611,7 +622,7 @@ export default function ReelsPostCard({
|
||||
</div>
|
||||
|
||||
{showLongVideoBar && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-30 px-3 pb-1">
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-30 px-3">
|
||||
<p className="mb-1 text-right text-[10px] tabular-nums text-white/85">
|
||||
{formatVideoTime(remaining)}
|
||||
</p>
|
||||
@@ -627,11 +638,11 @@ export default function ReelsPostCard({
|
||||
>
|
||||
<div className="relative h-0.5 overflow-hidden rounded-full bg-white/25">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-white/35"
|
||||
className="absolute inset-y-0 right-0 rounded-full bg-white/35"
|
||||
style={{ width: `${bufferPct}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-white"
|
||||
className="absolute inset-y-0 right-0 rounded-full bg-[#FF107D]"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -663,7 +674,7 @@ export default function ReelsPostCard({
|
||||
<BoldIcon name={videoPaused ? "play" : "pause"} size={16} tinted className="text-white" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-30 px-3 pb-1.5">
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-30 px-3">
|
||||
<div
|
||||
className="relative -my-3 cursor-pointer touch-none py-3 pointer-events-auto"
|
||||
onPointerDown={handleProgressPointerDown}
|
||||
@@ -673,11 +684,11 @@ export default function ReelsPostCard({
|
||||
>
|
||||
<div className="relative h-0.5 overflow-hidden rounded-full bg-white/25">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-white/35"
|
||||
className="absolute inset-y-0 right-0 rounded-full bg-white/35"
|
||||
style={{ width: `${bufferPct}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-white"
|
||||
className="absolute inset-y-0 right-0 rounded-full bg-[#FF107D]"
|
||||
style={{ width: `${progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -700,7 +711,7 @@ export default function ReelsPostCard({
|
||||
// justify-between (ریت/امتیاز) زیرِ RTL فیزیکاً همون سمت میشینه، این
|
||||
// فاصلهی اضافه (۶۴px، هماندازهی عرضِ ستونِ آیکونها) از افتادنِ
|
||||
// ریت/امتیاز زیرِ دکمهی جمعشونده جلوگیری میکنه
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/90 via-black/60 to-transparent pl-16 pr-4 pt-16",
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 z-20 pl-16 pr-4 pt-16",
|
||||
// وقتی نوار پیشرفت ویدیو نمایش داده میشه، این بلوک باید بالای خطِ اون
|
||||
// بشینه نه روش؛ وگرنه (تصویر/ویدیوی کوتاه) فقط فاصلهی پایینِ معمولی کافیه
|
||||
showLongVideoBar
|
||||
@@ -709,28 +720,57 @@ export default function ReelsPostCard({
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-auto flex items-center gap-3">
|
||||
{/* عینِ ReelsProfileFollowButton: بهجای دو حالتِ مجزا (حلقهی رنگی وقتی
|
||||
استوری داره / بدونِ هیچ قابی وقتی نداره)، یه لایهی حلقهی همیشگی
|
||||
با هندسهی ثابت (اندازهی کل ۴۸، عکس ۴۰، ضخامت ۲px) که فقط رنگش
|
||||
عوض میشه: سفید برای بدونِ استوری، رنگِ تیک/برند برای استوریدار */}
|
||||
<StoryAvatarOpen userId={user_id} className="shrink-0">
|
||||
<StoryRing
|
||||
hasStory={storyMeta.hasStory}
|
||||
hasUnviewed={storyMeta.hasUnviewed}
|
||||
tierColor={verifyBadgeToTierColor(verify_badge)}
|
||||
rounded="full"
|
||||
avatarSize={40}
|
||||
>
|
||||
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-full">
|
||||
<Image
|
||||
src={
|
||||
profile_image
|
||||
? buildStorageUrl(profile_image)
|
||||
: "/images/fake-avatar.png"
|
||||
}
|
||||
alt={user_name}
|
||||
fill
|
||||
sizes="40px"
|
||||
className="rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</StoryRing>
|
||||
{(() => {
|
||||
// فاصلهی خالیِ شفاف بینِ حلقه و عکس فقط برای کاربرِ استوریدار میمونه؛
|
||||
// برای کاربرِ بدونِ استوری، قابِ سفید بدونِ فاصلهست و عکس مستقیم باهاش
|
||||
// فیت میشه — ولی اندازهی کلِ حلقه (TOTAL) هر دو حالت یکیه
|
||||
const RING_W = 2;
|
||||
const TOTAL = 48;
|
||||
const GAP_W = storyMeta.hasStory ? 2 : 0;
|
||||
const PHOTO = TOTAL - (RING_W + GAP_W) * 2;
|
||||
const tierColor = verifyBadgeToTierColor(verify_badge);
|
||||
const isViewed = storyMeta.hasStory && !storyMeta.hasUnviewed;
|
||||
const ringColor = !storyMeta.hasStory
|
||||
? "#ffffff"
|
||||
: isViewed
|
||||
? "#a3a3a3"
|
||||
: tierColor
|
||||
? TIER_RING_COLORS[tierColor]
|
||||
: "#FF107D";
|
||||
return (
|
||||
<div
|
||||
className="relative shrink-0 rounded-full"
|
||||
style={{
|
||||
width: TOTAL,
|
||||
height: TOTAL,
|
||||
boxSizing: "border-box",
|
||||
border: `${RING_W}px solid ${ringColor}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute overflow-hidden rounded-full"
|
||||
style={{ inset: GAP_W }}
|
||||
>
|
||||
<Image
|
||||
src={
|
||||
profile_image
|
||||
? buildStorageUrl(profile_image)
|
||||
: "/images/fake-avatar.png"
|
||||
}
|
||||
alt={user_name}
|
||||
fill
|
||||
sizes={`${PHOTO}px`}
|
||||
className="rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</StoryAvatarOpen>
|
||||
<Link
|
||||
href={`/users/${user_name}`}
|
||||
@@ -802,7 +842,7 @@ export default function ReelsPostCard({
|
||||
/>
|
||||
)}
|
||||
|
||||
{useCommentsSheet && (
|
||||
{useCommentsSheet && (commentsOpen || commentsMountedOnce) && (
|
||||
<CommentDrawer
|
||||
isOpen={commentsOpen}
|
||||
onOpenChange={(open) => !open && setCommentsOpen(false)}
|
||||
|
||||
@@ -54,6 +54,8 @@ export default function ReelsPostOptionsMenu({
|
||||
const exploreUi = useExploreReelsUi();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [analyticsOpen, setAnalyticsOpen] = useState(false);
|
||||
const [reportReason, setReportReason] = useState("");
|
||||
const [reportText, setReportText] = useState("");
|
||||
@@ -139,6 +141,27 @@ export default function ReelsPostOptionsMenu({
|
||||
}
|
||||
};
|
||||
|
||||
const deletePost = async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await request("DELETE", `/posts/${postData._id}`, null, { noToast: true });
|
||||
toast.success(t("posts.postDeleted"));
|
||||
setDeleteConfirmOpen(false);
|
||||
setOpen(false);
|
||||
void queryClient.invalidateQueries({ queryKey: ["posts"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["archived-posts"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["home-posts"] });
|
||||
router.back();
|
||||
} catch (err: unknown) {
|
||||
const message = (
|
||||
err as { response?: { data?: { message?: string } } }
|
||||
)?.response?.data?.message;
|
||||
toast.error(message || t("posts.deleteFailed"));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleArchive = async () => {
|
||||
try {
|
||||
const res = await request<{ is_archived?: boolean; message?: string }>(
|
||||
@@ -326,6 +349,16 @@ export default function ReelsPostOptionsMenu({
|
||||
>
|
||||
{isArchived ? t("posts.publishPost") : t("posts.archivePost")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setDeleteConfirmOpen(true);
|
||||
}}
|
||||
className="px-5 py-3.5 text-right text-sm font-semibold text-red-500 active:bg-neutral-100 dark:active:bg-neutral-800"
|
||||
>
|
||||
{t("posts.deletePost")}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -370,6 +403,40 @@ export default function ReelsPostOptionsMenu({
|
||||
</RoundedButton>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={deleteConfirmOpen}
|
||||
onClose={() => setDeleteConfirmOpen(false)}
|
||||
height="fit"
|
||||
panelClassName="!p-5"
|
||||
>
|
||||
<p className="mb-2 text-center text-sm font-bold">
|
||||
{t("posts.deletePostConfirmTitle")}
|
||||
</p>
|
||||
<p className="mb-5 text-center text-xs text-neutral-500">
|
||||
{t("posts.deletePostConfirmBody")}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<RoundedButton
|
||||
type="button"
|
||||
variant="default"
|
||||
disabled={deleting}
|
||||
onClick={() => setDeleteConfirmOpen(false)}
|
||||
className="h-10 flex-1 text-sm"
|
||||
>
|
||||
{t("posts.cancel")}
|
||||
</RoundedButton>
|
||||
<RoundedButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={deleting}
|
||||
onClick={() => void deletePost()}
|
||||
className="h-10 flex-1 !bg-red-500 text-sm"
|
||||
>
|
||||
{deleting ? t("posts.deleting") : t("posts.deleteConfirm")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{analyticsOpen ? (
|
||||
<ReelsPostAnalyticsModal
|
||||
postData={postData}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import toast from "react-hot-toast";
|
||||
import IOSSpinner from "@/components/ui/IOSSpinner";
|
||||
@@ -171,21 +172,12 @@ export default function SendPostModal({
|
||||
checked ? "bg-pink-50 dark:bg-pink-950/30" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-full bg-neutral-200">
|
||||
{u.profile_image ? (
|
||||
<Image
|
||||
src={
|
||||
u.profile_image.startsWith("http")
|
||||
? u.profile_image
|
||||
: IMAGE_BASE_URL + u.profile_image
|
||||
}
|
||||
alt=""
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<ProfileAvatar
|
||||
src={u.profile_image}
|
||||
userId={u._id}
|
||||
sizePx={40}
|
||||
rounded="full"
|
||||
/>
|
||||
<span className="flex-1 text-sm font-medium">{name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -22,13 +21,6 @@ interface TagUsersPickerProps {
|
||||
max?: number;
|
||||
}
|
||||
|
||||
function avatarSrc(profileImage?: string) {
|
||||
if (!profileImage) return null;
|
||||
return profileImage.startsWith("http")
|
||||
? profileImage
|
||||
: buildStorageUrl(profileImage);
|
||||
}
|
||||
|
||||
function displayName(u: TaggedUser) {
|
||||
return (
|
||||
[u.first_name, u.last_name].filter(Boolean).join(" ") || u.user_name
|
||||
@@ -102,23 +94,17 @@ export default function TagUsersPicker({
|
||||
{selected.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{selected.map((u) => {
|
||||
const src = avatarSrc(u.profile_image);
|
||||
return (
|
||||
<span
|
||||
key={u._id}
|
||||
className="flex items-center gap-2 rounded-full bg-neutral-100 py-1 pe-2 ps-1 dark:bg-neutral-800"
|
||||
>
|
||||
<div className="relative h-7 w-7 shrink-0 overflow-hidden rounded-full bg-neutral-200">
|
||||
{src ? (
|
||||
<Image
|
||||
src={src}
|
||||
alt=""
|
||||
fill
|
||||
sizes="28px"
|
||||
className="object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<ProfileAvatar
|
||||
src={u.profile_image}
|
||||
userId={u._id}
|
||||
sizePx={28}
|
||||
rounded="full"
|
||||
/>
|
||||
<span className="text-xs font-medium">
|
||||
{displayName(u)}
|
||||
<span className="mx-1 text-neutral-400">·</span>
|
||||
@@ -163,7 +149,6 @@ export default function TagUsersPicker({
|
||||
)}
|
||||
<ul className="mt-2 max-h-52 space-y-1 overflow-y-auto">
|
||||
{results.map((u) => {
|
||||
const src = avatarSrc(u.profile_image);
|
||||
const name = displayName(u);
|
||||
return (
|
||||
<li key={u._id}>
|
||||
@@ -172,17 +157,12 @@ export default function TagUsersPicker({
|
||||
onClick={() => addUser(u)}
|
||||
className="flex w-full items-center gap-3 rounded-2xl px-2 py-2 text-right transition hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-full bg-neutral-200">
|
||||
{src ? (
|
||||
<Image
|
||||
src={src}
|
||||
alt=""
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<ProfileAvatar
|
||||
src={u.profile_image}
|
||||
userId={u._id}
|
||||
sizePx={40}
|
||||
rounded="full"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 text-right">
|
||||
<p className="truncate text-sm font-medium">{name}</p>
|
||||
<p className="truncate text-xs text-neutral-500">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
CarouselPrevious,
|
||||
type CarouselApi,
|
||||
} from "@/components/ui/carousel";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SuggestedFollowUser } from "@/components/posts/FollowingSuggestionsCarousel";
|
||||
@@ -75,7 +75,6 @@ function SpecialistCard({
|
||||
const media = user.previewPost
|
||||
? getPostMedia(user.previewPost as Post)
|
||||
: null;
|
||||
const profileUrl = user.profile_image ? buildStorageUrl(user.profile_image) : null;
|
||||
const profileHref = user.user_name ? `/users/${user.user_name}` : "#";
|
||||
|
||||
return (
|
||||
@@ -116,21 +115,13 @@ function SpecialistCard({
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/25 to-black/10" />
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 flex flex-col items-center px-4 pb-4 pt-8">
|
||||
<div className="relative mb-2 h-14 w-14 overflow-hidden rounded-full ring-2 ring-white/90">
|
||||
{profileUrl ? (
|
||||
<Image
|
||||
src={profileUrl}
|
||||
alt=""
|
||||
fill
|
||||
loading="lazy"
|
||||
className="object-cover"
|
||||
sizes="56px"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-neutral-700 text-white">
|
||||
{displayName.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-2 overflow-hidden rounded-full ring-2 ring-white/90">
|
||||
<ProfileAvatar
|
||||
src={user.profile_image}
|
||||
userId={user._id}
|
||||
sizePx={56}
|
||||
rounded="full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="line-clamp-1 text-center text-sm font-bold text-white">
|
||||
|
||||
@@ -9,6 +9,7 @@ import FilterModal from "@/components/models/FilterModal";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SuggestedFollowUser } from "@/components/posts/FollowingSuggestionsCarousel";
|
||||
@@ -56,9 +57,6 @@ function SpecialistCard({
|
||||
const displayName = getDisplayName(user, userFallback);
|
||||
const previewPath = user.previewPost?.files?.[0]?.path;
|
||||
const previewUrl = previewPath ? buildStorageUrl(previewPath) : null;
|
||||
const profileUrl = user.profile_image
|
||||
? buildStorageUrl(user.profile_image)
|
||||
: null;
|
||||
const isMain = variant === "main";
|
||||
const profileHref = user.user_name ? `/users/${user.user_name}` : "#";
|
||||
|
||||
@@ -115,25 +113,13 @@ function SpecialistCard({
|
||||
isMain ? "px-4 pb-5" : "px-2 pb-3"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative mb-2 overflow-hidden rounded-full ring-2 ring-white/90",
|
||||
isMain ? "h-16 w-16" : "h-9 w-9"
|
||||
)}
|
||||
>
|
||||
{profileUrl ? (
|
||||
<Image
|
||||
src={profileUrl}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes={isMain ? "64px" : "36px"}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-neutral-700 text-white">
|
||||
{displayName.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-2 overflow-hidden rounded-full ring-2 ring-white/90">
|
||||
<ProfileAvatar
|
||||
src={user.profile_image}
|
||||
userId={user._id}
|
||||
sizePx={isMain ? 64 : 36}
|
||||
rounded="full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isMain ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import {
|
||||
getNotificationActionMeta,
|
||||
getNotificationVisualKind,
|
||||
@@ -37,10 +36,6 @@ export default function NotificationRow({
|
||||
const visual = getNotificationVisualKind(item.type);
|
||||
const actor = item.actor;
|
||||
|
||||
const avatarSrc = actor?.profile_image
|
||||
? buildStorageUrl(actor.profile_image)
|
||||
: "/images/fake-avatar.png";
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
@@ -49,17 +44,17 @@ export default function NotificationRow({
|
||||
<div className="relative h-12 w-12 shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-12 w-12 overflow-hidden bg-neutral-200 dark:bg-neutral-800",
|
||||
"h-12 w-12 overflow-hidden bg-neutral-200 dark:bg-neutral-800",
|
||||
"rounded-full"
|
||||
)}
|
||||
>
|
||||
{visual === "user" ? (
|
||||
<Image
|
||||
src={avatarSrc}
|
||||
<ProfileAvatar
|
||||
src={actor?.profile_image}
|
||||
userId={actor?._id}
|
||||
alt={actor?.user_name || t("common.user")}
|
||||
fill
|
||||
sizes="48px"
|
||||
className="object-cover"
|
||||
sizePx={48}
|
||||
rounded="full"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
|
||||
@@ -8,6 +8,9 @@ type PageTitleProps = {
|
||||
/** Set false for root/bottom-tab pages that shouldn't offer a back action. */
|
||||
showBack?: boolean;
|
||||
backHref?: string;
|
||||
/** جایگزینِ router.back() پیشفرض — برای صفحاتی که باید هدفمند (مثلاً reload
|
||||
* کامل بهجای soft nav) برگردن، نه صرفاً یه قدم توی تاریخچه عقب برن */
|
||||
onBack?: () => void;
|
||||
/**
|
||||
* Optional right-side controls (e.g. a page-specific action icon). Pass content
|
||||
* here instead of wrapping <PageTitle> in an outer flex row — an outer wrapper
|
||||
@@ -26,6 +29,7 @@ const PageTitle: React.FC<PageTitleProps> = ({
|
||||
children,
|
||||
showBack = true,
|
||||
backHref,
|
||||
onBack,
|
||||
action,
|
||||
}) => {
|
||||
const titleText = typeof children === "string" ? children : null;
|
||||
@@ -34,7 +38,7 @@ const PageTitle: React.FC<PageTitleProps> = ({
|
||||
return (
|
||||
<div className="my-7 grid w-full grid-cols-[2.5rem_1fr_2.5rem] items-center gap-2">
|
||||
{showBack ? (
|
||||
<BackButton href={backHref} className="justify-self-start" />
|
||||
<BackButton href={backHref} onClick={onBack} className="justify-self-start" />
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
|
||||
@@ -10,17 +10,18 @@ import Suggestions from "@/components/settings/settings/Suggestions";
|
||||
import UserDetails from "@/components/settings/UserDetails";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import { staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import { canAccess } from "@/lib/auth/session";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const SETTINGS_NAV = [
|
||||
{ key: "wallet", href: "/wallet", icon: "card.svg" },
|
||||
{ key: "shop", href: "/shop", icon: "shop-add.svg" },
|
||||
{ key: "billboards", href: "/my-billboards", icon: "flash-circle.svg" },
|
||||
{ key: "academy", href: "/academy/Dashboard", icon: "academy.svg" },
|
||||
{ key: "shop", href: "/shop", icon: "shop-add.svg", permission: "create_shop" },
|
||||
{ key: "billboards", href: "/my-billboards", icon: "flash-circle.svg", permission: "billboards" },
|
||||
{ key: "academy", href: "/academy/Dashboard", icon: "academy.svg", permission: "academy_packages" },
|
||||
{ key: "workroom", href: "/workroom", icon: "box.svg" },
|
||||
{ key: "booking", href: "/booking", icon: "calendar.svg" },
|
||||
{ key: "booking", href: "/booking", icon: "calendar.svg", permission: "bookings" },
|
||||
{ key: "offers", href: "/offers", icon: "offer.svg" },
|
||||
{ key: "favorites", href: "/favorites", icon: "bookmark-post.svg" },
|
||||
{ key: "chats", href: "/chats", icon: "sms.svg" },
|
||||
@@ -32,6 +33,12 @@ const SETTINGS_NAV = [
|
||||
|
||||
export default function SettingsClient() {
|
||||
const { t } = useTranslation("common");
|
||||
// برای کاربرِ اصلی همهچی نمایش داده میشه؛ برای کاربرِ زیرمجموعه فقط
|
||||
// بخشهایی که براش پرمیژن داده شده
|
||||
const visibleNav = SETTINGS_NAV.filter((item) => {
|
||||
if ("permission" in item) return canAccess(item.permission);
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
@@ -41,7 +48,7 @@ export default function SettingsClient() {
|
||||
<UserDetails />
|
||||
<div className="my-10 flex flex-col gap-5 text-sm font-semibold md:gap-8">
|
||||
<ThemeSwitcher />
|
||||
{SETTINGS_NAV.map((item) => (
|
||||
{visibleNav.map((item) => (
|
||||
<Link
|
||||
className="flex items-center gap-2"
|
||||
key={item.key}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import { formatBubbleTime } from "@/lib/chat/formatMessageTime";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -22,26 +21,19 @@ type Props = {
|
||||
|
||||
export default function VisitorRow({ visitor }: Props) {
|
||||
const { t } = useTranslation("common");
|
||||
const avatarSrc = visitor.profile_image
|
||||
? buildStorageUrl(visitor.profile_image)
|
||||
: "/images/fake-avatar.png";
|
||||
|
||||
const visitedAt =
|
||||
visitor.visited_at_time ||
|
||||
(visitor.visited_at ? formatBubbleTime(visitor.visited_at) : "");
|
||||
|
||||
return (
|
||||
<div className="mb-3 flex w-full items-start gap-3 border-b border-border-primary-light pb-3 last:border-0">
|
||||
<Link
|
||||
href={`/users/${visitor.user_name}`}
|
||||
className="relative h-12 w-12 shrink-0 overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-800"
|
||||
>
|
||||
<Image
|
||||
src={avatarSrc}
|
||||
<Link href={`/users/${visitor.user_name}`} className="block shrink-0">
|
||||
<ProfileAvatar
|
||||
src={visitor.profile_image}
|
||||
userId={visitor._id}
|
||||
alt={visitor.user_name || t("common.user")}
|
||||
fill
|
||||
sizes="48px"
|
||||
className="object-cover"
|
||||
sizePx={48}
|
||||
rounded="full"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import toast from "react-hot-toast";
|
||||
import IOSSpinner from "@/components/ui/IOSSpinner";
|
||||
@@ -159,21 +160,12 @@ export default function SendProductModal({
|
||||
checked ? "bg-pink-50 dark:bg-pink-950/30" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-full bg-neutral-200">
|
||||
{u.profile_image ? (
|
||||
<Image
|
||||
src={
|
||||
u.profile_image.startsWith("http")
|
||||
? u.profile_image
|
||||
: IMAGE_BASE_URL + u.profile_image
|
||||
}
|
||||
alt=""
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<ProfileAvatar
|
||||
src={u.profile_image}
|
||||
userId={u._id}
|
||||
sizePx={40}
|
||||
rounded="full"
|
||||
/>
|
||||
<span className="flex-1 text-sm font-medium">{name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import toast from "react-hot-toast";
|
||||
import IOSSpinner from "@/components/ui/IOSSpinner";
|
||||
@@ -154,21 +155,12 @@ export default function SendShopModal({
|
||||
checked ? "bg-pink-50 dark:bg-pink-950/30" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-full bg-neutral-200">
|
||||
{u.profile_image ? (
|
||||
<Image
|
||||
src={
|
||||
u.profile_image.startsWith("http")
|
||||
? u.profile_image
|
||||
: IMAGE_BASE_URL + u.profile_image
|
||||
}
|
||||
alt=""
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<ProfileAvatar
|
||||
src={u.profile_image}
|
||||
userId={u._id}
|
||||
sizePx={40}
|
||||
rounded="full"
|
||||
/>
|
||||
<span className="flex-1 text-sm font-medium">{name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useCommentDrawer } from "@/components/comments/CommentDrawer";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import CommentsSkeleton from "@/components/ui/CommentsSkeleton";
|
||||
import { IMAGE_BASE_URL, staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { useRequireAuth } from "@/lib/auth/useRequireAuth";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -139,17 +140,12 @@ export default function ShopProductCommentsModal({
|
||||
<ul className="space-y-3">
|
||||
{comments.map((c) => (
|
||||
<li key={c._id} className="flex items-start gap-3">
|
||||
<div className="relative h-9 w-9 shrink-0 overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-800">
|
||||
{c.creator?.profile_image ? (
|
||||
<Image
|
||||
src={IMAGE_BASE_URL + c.creator.profile_image}
|
||||
alt=""
|
||||
fill
|
||||
sizes="36px"
|
||||
className="object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<ProfileAvatar
|
||||
src={c.creator?.profile_image}
|
||||
userId={c.creator?._id}
|
||||
sizePx={36}
|
||||
rounded="full"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-xs font-semibold">{c.creator?.user_name}</p>
|
||||
|
||||
@@ -120,6 +120,7 @@ export default function ShopProductGrid({
|
||||
? listing.shop
|
||||
: null;
|
||||
const aspect = hashAspect(listing._id);
|
||||
const isPending = listing.status !== "active";
|
||||
|
||||
return (
|
||||
<article key={listing._id} className="mb-2 break-inside-avoid">
|
||||
@@ -128,7 +129,9 @@ export default function ShopProductGrid({
|
||||
onClick={() =>
|
||||
navigateWithSavedScroll(router, scrollKey, getHref(listing))
|
||||
}
|
||||
className="gentle-transition block w-full text-right active:scale-[0.98]"
|
||||
className={`gentle-transition block w-full text-right active:scale-[0.98] ${
|
||||
isPending ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`relative w-full overflow-hidden rounded-2xl bg-neutral-200 dark:bg-neutral-900 ${aspect}`}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import toast from "react-hot-toast";
|
||||
import IOSSpinner from "@/components/ui/IOSSpinner";
|
||||
@@ -179,17 +180,12 @@ export default function SendStoryModal({
|
||||
: "hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
)}
|
||||
>
|
||||
<div className="relative h-10 w-10 overflow-hidden rounded-full bg-neutral-200">
|
||||
{u.profile_image ? (
|
||||
<Image
|
||||
src={buildStorageUrl(u.profile_image)}
|
||||
alt=""
|
||||
fill
|
||||
sizes="40px"
|
||||
className="object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<ProfileAvatar
|
||||
src={u.profile_image}
|
||||
userId={u._id}
|
||||
sizePx={40}
|
||||
rounded="full"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">
|
||||
{u.first_name} {u.last_name}
|
||||
|
||||
@@ -15,6 +15,7 @@ import StoryRing, {
|
||||
} from "@/components/stories/StoryRing";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PENDING_STORY_UPLOAD_KEY } from "@/lib/stories/pendingStoryUpload";
|
||||
|
||||
function StoryAvatar({
|
||||
profileImage,
|
||||
@@ -74,6 +75,15 @@ export default function StoriesBar({ token: serverToken = "" }: StoriesBarProps)
|
||||
const myStory = data?.my_story;
|
||||
const hasMyStory = Boolean(myStory?.stories?.length);
|
||||
|
||||
// پرچمِ سراسریِ «استوری در حال آپلود» — new-post/page.tsx مستقیم بعد از زدنِ دکمهی
|
||||
// اشتراکگذاری این رو ست میکنه، بدون اینکه منتظرِ mount شدنِ این کامپوننت باشه
|
||||
const { data: isUploadingMyStory } = useQuery<boolean>({
|
||||
queryKey: PENDING_STORY_UPLOAD_KEY,
|
||||
queryFn: () => false,
|
||||
initialData: false,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const openViewer = (index: number) => {
|
||||
setViewerStartIndex(index);
|
||||
setViewerOpen(true);
|
||||
@@ -100,16 +110,24 @@ export default function StoriesBar({ token: serverToken = "" }: StoriesBarProps)
|
||||
{/* Your story */}
|
||||
<div className="flex w-[80px] shrink-0 flex-col items-center gap-1">
|
||||
<div className="relative" style={{ width: STORY_RING_OUTER_SIZE, height: STORY_RING_OUTER_SIZE }}>
|
||||
{showDynamicFeed && hasMyStory ? (
|
||||
{showDynamicFeed && (hasMyStory || isUploadingMyStory) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// حین آپلود هنوز استوریِ واقعیای برای نمایش نیست — کلیک بیاثره
|
||||
if (isUploadingMyStory && !hasMyStory) return;
|
||||
const idx = feed.findIndex((f) => f.user._id === viewerId);
|
||||
openViewer(idx >= 0 ? idx : 0);
|
||||
}}
|
||||
className="block"
|
||||
>
|
||||
<StoryRing hasStory forceGradient avatarSize={STORY_AVATAR_SIZE}>
|
||||
<StoryRing
|
||||
hasStory={hasMyStory}
|
||||
forceGradient
|
||||
uploading={isUploadingMyStory}
|
||||
tierColor={verifyBadgeToTierColor(myStory?.user.verify_badge)}
|
||||
avatarSize={STORY_AVATAR_SIZE}
|
||||
>
|
||||
<StoryAvatar
|
||||
profileImage={myStory?.user.profile_image}
|
||||
userName={myStory?.user.user_name}
|
||||
@@ -130,7 +148,7 @@ export default function StoriesBar({ token: serverToken = "" }: StoriesBarProps)
|
||||
)}
|
||||
<Link
|
||||
href="/new-post?type=story"
|
||||
className="absolute -bottom-0.5 -right-0.5 z-20 flex h-5 w-5 items-center justify-center rounded-full border-2 border-background bg-[#0095f6] text-white shadow-sm"
|
||||
className="absolute bottom-0 left-0 z-20 flex h-5 w-5 items-center justify-center rounded-full border-2 border-background bg-[#0095f6] text-white shadow-sm"
|
||||
aria-label={t("stories.addStory")}
|
||||
>
|
||||
<BoldIcon name="add" size={14} tinted className="text-white" />
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export type MentionUser = {
|
||||
@@ -14,13 +13,6 @@ export type MentionUser = {
|
||||
profile_image?: string;
|
||||
};
|
||||
|
||||
function avatarSrc(profileImage?: string) {
|
||||
if (!profileImage) return null;
|
||||
return profileImage.startsWith("http")
|
||||
? profileImage
|
||||
: buildStorageUrl(profileImage);
|
||||
}
|
||||
|
||||
function displayName(u: MentionUser) {
|
||||
return [u.first_name, u.last_name].filter(Boolean).join(" ") || u.user_name;
|
||||
}
|
||||
@@ -65,7 +57,7 @@ export default function StoryMentionPicker({
|
||||
}, [query, request]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-end justify-center bg-black/60" onClick={onClose}>
|
||||
<div className="fixed inset-0 z-[60] flex items-end justify-center bg-transparent" onClick={onClose}>
|
||||
<div
|
||||
className="max-h-[70vh] w-full max-w-md overflow-y-auto rounded-t-3xl bg-white p-4 dark:bg-neutral-900"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -88,7 +80,6 @@ export default function StoryMentionPicker({
|
||||
)}
|
||||
<ul className="mt-2 space-y-1">
|
||||
{results.map((u) => {
|
||||
const src = avatarSrc(u.profile_image);
|
||||
return (
|
||||
<li key={u._id}>
|
||||
<button
|
||||
@@ -96,11 +87,12 @@ export default function StoryMentionPicker({
|
||||
onClick={() => onSelect(u)}
|
||||
className="flex w-full items-center gap-3 rounded-2xl px-2 py-2 text-right transition hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-full bg-neutral-200">
|
||||
{src ? (
|
||||
<Image src={src} alt="" fill sizes="40px" className="object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
<ProfileAvatar
|
||||
src={u.profile_image}
|
||||
userId={u._id}
|
||||
sizePx={40}
|
||||
rounded="full"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 text-right">
|
||||
<p className="truncate text-sm font-medium">{displayName(u)}</p>
|
||||
<p className="truncate text-xs text-neutral-500">@{u.user_name}</p>
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const STORY_RING_PX = 2.5;
|
||||
export const STORY_RING_PX = 3;
|
||||
// فاصلهی خالی بین حلقهی استوری و خودِ عکس پروفایل — عین حلقهی استوری اینستاگرام
|
||||
export const STORY_RING_GAP_PX = 2;
|
||||
export const STORY_RING_TOTAL_INSET_PX = STORY_RING_PX + STORY_RING_GAP_PX;
|
||||
export const STORY_AVATAR_SIZE = 56;
|
||||
export const STORY_RING_OUTER_SIZE = STORY_AVATAR_SIZE + STORY_RING_PX * 2;
|
||||
export const STORY_RING_OUTER_SIZE =
|
||||
STORY_AVATAR_SIZE + STORY_RING_TOTAL_INSET_PX * 2;
|
||||
|
||||
export const TIER_RING_COLORS = {
|
||||
blue: "#2196EB",
|
||||
@@ -28,6 +32,9 @@ interface StoryRingProps {
|
||||
forceGradient?: boolean;
|
||||
/** When set (blue/green/gold), overrides the default gradient/gray ring with a flat tier color. Gray/none tiers should pass null/undefined to keep default behavior. */
|
||||
tierColor?: StoryRingTierColor;
|
||||
/** استوری در حال آپلود — عین انیمیشن حلقهی ثبتِ استوری اینستاگرام: بهجای رنگ ثابت، یک
|
||||
* قوسِ رنگی میچرخه تا وقتی آپلود واقعاً تمام بشه (implies hasStory=true). */
|
||||
uploading?: boolean;
|
||||
rounded?: "full" | "xl";
|
||||
avatarSize?: number;
|
||||
avatarRadius?: number;
|
||||
@@ -42,6 +49,7 @@ export default function StoryRing({
|
||||
hasUnviewed = false,
|
||||
forceGradient = false,
|
||||
tierColor = null,
|
||||
uploading = false,
|
||||
rounded = "full",
|
||||
avatarSize,
|
||||
avatarRadius,
|
||||
@@ -49,41 +57,74 @@ export default function StoryRing({
|
||||
className,
|
||||
children,
|
||||
}: StoryRingProps) {
|
||||
const showRing = hasStory || uploading;
|
||||
const isXl = rounded === "xl";
|
||||
const resolvedAvatarSize =
|
||||
avatarSize ?? (isXl ? undefined : STORY_AVATAR_SIZE);
|
||||
const cornerRadius =
|
||||
avatarRadius ??
|
||||
(isXl ? 12 : resolvedAvatarSize ? resolvedAvatarSize / 2 : undefined);
|
||||
// برای دایره (rounded="full") همیشه از کلاس rounded-full استفاده میشه (نسبت به
|
||||
// اندازهی خودِ همون باکس ۵۰٪ گرد میکنه)، نه یک عددِ پیکسلیِ ثابت — وگرنه چون
|
||||
// لایهی میانی/داخلی سایزشون از باکسِ بیرونی کوچیکتره، همون شعاعِ باکسِ بیرونی
|
||||
// منهای inset دیگه دقیقاً نصفِ اندازهی خودشون نمیشه و حلقه بهجای دایره، مربعِ
|
||||
// گردشده (squircle) درمیاد. فقط برای rounded="xl" (مربعِ گردِ ثابت) تفریقِ عددی
|
||||
// معنا داره، چون اونجا شعاع یک مقدارِ ثابتِ concentric-rect هست، نه نصفِ عرض.
|
||||
const gapRadius =
|
||||
isXl && cornerRadius != null
|
||||
? Math.max(0, cornerRadius - (showRing ? STORY_RING_PX : 0))
|
||||
: undefined;
|
||||
const innerRadius =
|
||||
cornerRadius != null
|
||||
? Math.max(0, cornerRadius - (hasStory ? STORY_RING_PX : 0))
|
||||
isXl && cornerRadius != null
|
||||
? Math.max(0, cornerRadius - (showRing ? STORY_RING_TOTAL_INSET_PX : 0))
|
||||
: undefined;
|
||||
|
||||
const outerRounded = isXl ? "rounded-2xl" : "rounded-full";
|
||||
const innerRounded =
|
||||
innerRadius != null
|
||||
? undefined
|
||||
: isXl
|
||||
? "rounded-[calc(0.75rem-2.5px)]"
|
||||
: "rounded-full";
|
||||
// باگ: قبلاً این همیشه کلاسِ ثابتِ rounded-2xl (۱۶px) بود، صرفنظر از اینکه
|
||||
// cornerRadius واقعی چقدره — وقتی avatarRadius غیرِ ۱۶ پاس داده میشد (مثلاً ۱۲،
|
||||
// ProfileAvatar.tsx برای rounded="xl")، شعاعِ لایهی بیرونیِ حلقه با شعاعِ
|
||||
// محاسبهشدهی لایههای gap/inner (که از رویِ همون cornerRadius واقعی کم میکنن)
|
||||
// همخون نبود و گوشهها نامنظم/نازک دیده میشدن
|
||||
const outerRounded =
|
||||
isXl && cornerRadius == null ? "rounded-2xl" : !isXl ? "rounded-full" : undefined;
|
||||
const gapRounded = isXl
|
||||
? gapRadius == null
|
||||
? "rounded-[calc(0.75rem-3px)]"
|
||||
: undefined
|
||||
: "rounded-full";
|
||||
const innerRounded = isXl
|
||||
? innerRadius == null
|
||||
? "rounded-[calc(0.75rem-5px)]"
|
||||
: undefined
|
||||
: "rounded-full";
|
||||
|
||||
// When there's no story, the ring layer isn't drawn — keep the total footprint
|
||||
// exactly equal to resolvedAvatarSize (matching the old "render bare children"
|
||||
// behavior) instead of the ring-inclusive size, so call sites whose surrounding
|
||||
// layout was built around "no story = original size" don't grow unexpectedly.
|
||||
const ringInset = hasStory ? STORY_RING_PX : 0;
|
||||
const ringInset = showRing ? STORY_RING_PX : 0;
|
||||
const gapInset = showRing ? STORY_RING_GAP_PX : 0;
|
||||
const outerSize =
|
||||
resolvedAvatarSize != null
|
||||
? resolvedAvatarSize + (hasStory ? STORY_RING_PX * 2 : 0)
|
||||
? resolvedAvatarSize + (showRing ? STORY_RING_TOTAL_INSET_PX * 2 : 0)
|
||||
: undefined;
|
||||
|
||||
// رنگِ اصلیِ برند اپ (همون رنگِ آیکون قلبِ لایکشده در همهجای اپ) — قبلاً گرادیانِ
|
||||
// رنگینکمانیِ اینستاگرام بود، طبق درخواست جایگزین شد. طبق خواستهی بعدیِ کاربر،
|
||||
// تمایزِ دیدهشده/ندیدهشده (که دیدهشده رو خاکستری میکرد) هم حذف شد — حلقهی
|
||||
// «معمولی» (بدونِ تیکِ رنگی) همیشه همرنگِ لوگوئه
|
||||
// رنگینکمانیِ اینستاگرام بود، طبق درخواست جایگزین شد.
|
||||
const live = hasUnviewed || forceGradient;
|
||||
const ringBg = tierColor ? TIER_RING_COLORS[tierColor] : "#FF107D";
|
||||
// طبق خواستهی دوبارهی کاربر: استوریِ دیدهشده (نه در حالِ pulse/forceGradient)
|
||||
// حلقهش خاکستری کمرنگ بشه — حتی اگه کاربر تیکِ رنگی (آبی/سبز/طلایی) هم داشته باشه،
|
||||
// چون «دیده شدن» باید همیشه قابلتشخیص باشه
|
||||
const isViewed = hasStory && !live && !uploading;
|
||||
const ringBg = isViewed
|
||||
? "#a3a3a3"
|
||||
: tierColor
|
||||
? TIER_RING_COLORS[tierColor]
|
||||
: "#FF107D";
|
||||
// حین آپلود، بهجای پرکردنِ کاملِ حلقه با رنگ، فقط یه قوس نشون داده میشه که میچرخه —
|
||||
// عین انیمیشنِ «در حال ثبت استوری»ی اینستاگرام (یه لودینگِ چرخان دورِ آواتار)
|
||||
const ringBackground = uploading
|
||||
? `conic-gradient(${ringBg} 0deg 100deg, transparent 100deg 360deg)`
|
||||
: ringBg;
|
||||
|
||||
// Always render the same wrapper structure (same total size, same rounding)
|
||||
// regardless of hasStory — only the ring-color layer is conditional. This keeps
|
||||
@@ -98,35 +139,56 @@ export default function StoryRing({
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{hasStory && (
|
||||
{showRing && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0",
|
||||
outerRounded,
|
||||
!tierColor && live && animated && "story-ring-live"
|
||||
uploading
|
||||
? "story-ring-uploading"
|
||||
: !tierColor && live && animated && "story-ring-live"
|
||||
)}
|
||||
style={{ background: ringBg }}
|
||||
style={{
|
||||
background: ringBackground,
|
||||
...(isXl && cornerRadius != null ? { borderRadius: cornerRadius } : {}),
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{/* فاصلهی خالیِ بین حلقه و عکس (عین حلقهی استوری اینستاگرام) — پسزمینهش همرنگِ
|
||||
صفحهست تا حلقهی رنگی فقط دورِ این لایه دیده بشه، نه چسبیده به خودِ عکس */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-[1] flex items-center justify-center overflow-hidden bg-white leading-none dark:bg-neutral-950",
|
||||
innerRounded
|
||||
)}
|
||||
className={cn("relative z-[1] bg-white dark:bg-neutral-950", gapRounded)}
|
||||
style={{
|
||||
margin: ringInset,
|
||||
...(innerRadius != null ? { borderRadius: innerRadius } : {}),
|
||||
...(gapRadius != null ? { borderRadius: gapRadius } : {}),
|
||||
...(resolvedAvatarSize != null
|
||||
? {
|
||||
width: resolvedAvatarSize,
|
||||
height: resolvedAvatarSize,
|
||||
width: resolvedAvatarSize + gapInset * 2,
|
||||
height: resolvedAvatarSize + gapInset * 2,
|
||||
}
|
||||
: { width: "100%", height: "100%" }),
|
||||
}}
|
||||
>
|
||||
<div className="h-full w-full [&_img]:h-full [&_img]:w-full [&_img]:object-cover [&>*]:h-full [&>*]:w-full">
|
||||
{children}
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex h-full w-full items-center justify-center overflow-hidden leading-none",
|
||||
innerRounded
|
||||
)}
|
||||
style={{
|
||||
margin: gapInset,
|
||||
...(innerRadius != null ? { borderRadius: innerRadius } : {}),
|
||||
...(resolvedAvatarSize != null
|
||||
? {
|
||||
width: resolvedAvatarSize,
|
||||
height: resolvedAvatarSize,
|
||||
}
|
||||
: { width: "100%", height: "100%" }),
|
||||
}}
|
||||
>
|
||||
<div className="h-full w-full [&_img]:h-full [&_img]:w-full [&_img]:object-cover [&>*]:h-full [&>*]:w-full">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -68,6 +68,11 @@ export const editUserNavLinks = [
|
||||
href: "/google-account",
|
||||
icon: "google.png",
|
||||
},
|
||||
{
|
||||
labelKey: "settings.edit.nav.registerMobile",
|
||||
href: "/register-mobile",
|
||||
icon: "phone.svg",
|
||||
},
|
||||
{
|
||||
labelKey: "settings.edit.nav.twoFactor",
|
||||
href: "/two-factor",
|
||||
|
||||
@@ -7,9 +7,21 @@ export const REELS_PAGE_SIZE = 6;
|
||||
/** فقط کارتهای نزدیک پست فعال mount میشوند */
|
||||
export const REELS_RENDER_WINDOW = 5;
|
||||
|
||||
/** کلاس یکسان هر اسلات ریلز — snap + ارتفاع ثابت */
|
||||
/** فضای رزروشدهی پایینِ هر اسلات برای نوارِ ناوبریِ شناورِ پایینِ صفحه (TabNavigation.tsx)
|
||||
* که برخلافِ اندروید (که تبِ پایین رو کاملاً مخفی میکنه)، توی ریلزِ وب همیشه روی صفحه
|
||||
* میمونه — پس برخلافِ نسخهی اول (نسبتِ ۹:۱۶ کاملِ letterbox که فضای خالیِ خیلی زیادی
|
||||
* میذاشت)، اینجا فقط دقیقاً بهاندازهی ارتفاعِ خودِ همون نوار + safe-area رزرو میشه،
|
||||
* نه یه نسبتِ ابعادِ ثابت — تا پست مثلِ ریلزِ اندروید تقریباً تمامصفحه (edge-to-edge)
|
||||
* بمونه و فقط از زیرِ نوار بیرون نزنه */
|
||||
// مثل اندروید: فاصلهی ثابت ۵۰px از پایینِ صفحه، بدون وابستگی به safe-area،
|
||||
// تا کادرِ ریلز و خطِ پیشرفت روی همهی گوشیها یک شکل باشن
|
||||
export const REELS_BOTTOM_RESERVED = "50px";
|
||||
|
||||
/** کلاس یکسان هر اسلات ریلز — snap + ارتفاع ثابت. خودِ اسلات کاملارتفاعه (برای
|
||||
* فاصلهگذاریِ درستِ scroll-snap)؛ محتوای واقعی (ReelsPostCard) با REELS_BOTTOM_RESERVED
|
||||
* کوتاهتر میشه و از بالا (زیرِ status bar) شروع میشه، نه وسطچین */
|
||||
export const REELS_SLOT_CLASS =
|
||||
"reels-snap-slot h-[100dvh] w-full shrink-0 snap-start snap-always";
|
||||
"reels-snap-slot flex h-[100dvh] w-full shrink-0 snap-start snap-always items-start justify-center bg-black";
|
||||
|
||||
/** پوسته ریلز — همعرض منوبار */
|
||||
export const REELS_SHELL_CLASS = "mx-auto h-[100dvh] w-full max-w-xl overflow-hidden";
|
||||
|
||||
15
src/constants/subUserPermissions.ts
Normal file
15
src/constants/subUserPermissions.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// عین utils/subUserPermissions.js بکاند — کلیدها باید دقیقاً یکی باشن
|
||||
export const SUB_USER_PERMISSION_KEYS = [
|
||||
"posts_stories",
|
||||
"reply_dm",
|
||||
"reply_comments",
|
||||
"create_shop",
|
||||
"shop_products",
|
||||
"order_status",
|
||||
"billboards",
|
||||
"projects",
|
||||
"academy_packages",
|
||||
"bookings",
|
||||
] as const;
|
||||
|
||||
export type SubUserPermissionKey = (typeof SUB_USER_PERMISSION_KEYS)[number];
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -83,6 +83,10 @@ export function parseLoginResponse(raw: unknown): IVerifyOtp {
|
||||
typeof obj.registrationComplete === "boolean"
|
||||
? obj.registrationComplete
|
||||
: undefined,
|
||||
is_sub_user: obj.is_sub_user === true,
|
||||
sub_user_permissions: Array.isArray(obj.sub_user_permissions)
|
||||
? (obj.sub_user_permissions as string[])
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,6 +132,8 @@ export async function completeLogin(
|
||||
step: response.step,
|
||||
auth_provider: response.auth_provider,
|
||||
email: response.email ?? undefined,
|
||||
is_sub_user: response.is_sub_user,
|
||||
sub_user_permissions: response.sub_user_permissions,
|
||||
});
|
||||
|
||||
await pause(AUTH_SUCCESS_DELAY_MS);
|
||||
|
||||
@@ -15,6 +15,8 @@ export type AuthUserMeta = {
|
||||
step?: string;
|
||||
auth_provider?: string;
|
||||
email?: string;
|
||||
is_sub_user?: boolean;
|
||||
sub_user_permissions?: string[];
|
||||
};
|
||||
|
||||
const cookieOptions = (): Cookies.CookieAttributes => ({
|
||||
@@ -92,6 +94,35 @@ export async function setAuthSession(
|
||||
writeLocalStorage("auth_provider", meta.auth_provider);
|
||||
}
|
||||
if (meta?.email) writeLocalStorage("email", meta.email);
|
||||
writeLocalStorage("is_sub_user", meta?.is_sub_user ? "1" : "0");
|
||||
writeLocalStorage(
|
||||
"sub_user_permissions",
|
||||
JSON.stringify(meta?.sub_user_permissions || [])
|
||||
);
|
||||
}
|
||||
|
||||
/** آیا نشستِ فعلی مالِ یه کاربرِ زیرمجموعهست (نه خودِ صاحبِ اکانت) */
|
||||
export function isSubUserSession(): boolean {
|
||||
return readLocalStorage("is_sub_user") === "1";
|
||||
}
|
||||
|
||||
/** پرمیژنهای کاربرِ زیرمجموعهی فعلی — برای کاربرِ اصلی همیشه خالیه (چون
|
||||
* isSubUserSession false برمیگرده و جایی چک نمیشه) */
|
||||
export function getSubUserPermissions(): string[] {
|
||||
try {
|
||||
const raw = readLocalStorage("sub_user_permissions");
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** true اگه کاربرِ اصلی باشه (همیشه دسترسی کامل) یا زیرمجموعهای که این
|
||||
* پرمیژنِ خاص رو داره */
|
||||
export function canAccess(permissionKey: string): boolean {
|
||||
if (!isSubUserSession()) return true;
|
||||
return getSubUserPermissions().includes(permissionKey);
|
||||
}
|
||||
|
||||
/** پاک کردن کامل session */
|
||||
@@ -123,6 +154,8 @@ export async function clearAuthSession(): Promise<void> {
|
||||
removeLocalStorage("step");
|
||||
removeLocalStorage("auth_provider");
|
||||
removeLocalStorage("email");
|
||||
removeLocalStorage("is_sub_user");
|
||||
removeLocalStorage("sub_user_permissions");
|
||||
}
|
||||
|
||||
export function getStoredUserId(): string {
|
||||
|
||||
3
src/lib/e2ee/config.ts
Normal file
3
src/lib/e2ee/config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// موقتاً غیرفعال: کل پیامها (متن، فایل، ویس) بدون رمزنگاری ارسال/دریافت میشن.
|
||||
// برای برگرداندنِ e2ee فقط این مقدار را true کنید؛ کدِ اصلی دستنخورده باقی مونده.
|
||||
export const E2EE_ENABLED = false;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user