Compare commits

...

1 Commits

Author SHA1 Message Date
payacom
76ef4e32a9 order 2026-09-01 13:51:50 +03:30
153 changed files with 10612 additions and 8768 deletions

966
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -52,6 +52,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"embla-carousel-react": "^8.6.0",
"firebase": "^12.18.0",
"formik": "^2.4.6",
"framer-motion": "^12.23.12",
"i18next": "^25.3.6",

View File

@@ -1,6 +1,49 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
const CACHE_NAME = "pwa-cache-v10";
// عین google-services.json/pushToken.ts اندروید — این مقادیر عمومی و امن برای
// مرورگرند. سرویس‌ورکرِ استاتیک نمی‌تونه process.env بخونه، پس مستقیم اینجا نوشته
// شدن (دقیقاً همون چیزی که NEXT_PUBLIC_FIREBASE_* توی باندلِ کلاینت هم inline می‌شه)
importScripts("https://www.gstatic.com/firebasejs/10.13.2/firebase-app-compat.js");
importScripts("https://www.gstatic.com/firebasejs/10.13.2/firebase-messaging-compat.js");
firebase.initializeApp({
apiKey: "AIzaSyDpUh8AlafwyM931o519oX3z7JYnXZJyBg",
authDomain: "modstagram-com.firebaseapp.com",
projectId: "modstagram-com",
storageBucket: "modstagram-com.firebasestorage.app",
messagingSenderId: "767614062479",
appId: "1:767614062479:web:ec2aea23cc8ce17dfe45fa",
});
// پیامِ پوش وقتی تب بسته/پس‌زمینه‌ست — عین رفتار پیش‌فرضِ اندروید (نوتیفِ سیستمی).
// وقتی تب باز و فعاله، Firebase این رویداد رو صدا نمی‌زنه — اونجا onMessage
// (pushNotifications.ts سمتِ کلاینت) با toast جایگزینش می‌کنه
const messaging = firebase.messaging.isSupported() ? firebase.messaging() : null;
if (messaging) {
messaging.onBackgroundMessage((payload) => {
const title = payload.notification?.title || "modstagram";
self.registration.showNotification(title, {
body: payload.notification?.body,
icon: "/images/icons/192x192.png",
badge: "/images/icons/192x192.png",
data: payload.data || {},
});
});
}
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = "/settings/notifications";
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientsArr) => {
const existing = clientsArr.find((c) => c.url.includes(url));
if (existing) return existing.focus();
return self.clients.openWindow(url);
})
);
});
const CACHE_NAME = "pwa-cache-v11";
const PRECACHE_URLS = [
"/",

View File

@@ -93,7 +93,7 @@ function AvatarPage() {
headers: { "Content-Type": "multipart/form-data" },
});
toast.success(t("auth.imageSavedSuccess"));
navigateHandler();
await navigateHandler();
} catch (err) {
console.log("Upload error:", err);
toast.error(t("auth.imageUploadError"));
@@ -101,12 +101,22 @@ function AvatarPage() {
}
};
const navigateHandler = () => {
if (userDetail?.user_type === "user") {
router.push("/verify/expertise");
} else {
router.push("/verify/gender");
// userDetail از یه fetchData جدا (useEffect موقع mount) پر می‌شه، بدون هیچ
// تضمینی که قبل از اینکه کاربر آپلود آواتار رو تموم کنه resolve بشه — اگه کاربر
// سریع‌تر از اون GET /profile عکس رو آپلود کنه، userDetail هنوز null بود، چک
// user_type==="user" شکست می‌خورد و مستقیم می‌رفت verify/gender، از verify/expertise
// کامل رد می‌شد. اینجا مستقل از اون state، مستقیم و تازه دوباره می‌گیریم
const navigateHandler = async () => {
try {
const response = await request<{ user: IProfileData }>("GET", "/profile");
if (response?.user?.user_type === "user") {
router.push("/verify/expertise");
return;
}
} catch (err) {
console.log(err);
}
router.push("/verify/gender");
};
return (

View File

@@ -1,167 +1,167 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import Container from "@/components/elements/Container";
import React, { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import Modal from "@/components/elements/Modal";
import Link from "next/link";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import VerificationBadge from "@/components/main/VerificationBadge";
import { useUser } from "@/hooks/useUser";
import { formatFullName } from "@/lib/formatFullName";
import useAxios from "@/hooks/useAxios";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
function Confirm() {
const { t } = useTranslation("common");
const user = useUser();
const { request } = useAxios();
const router = useRouter();
const [showModal, setShowModal] = useState<boolean>(false);
const [navigating, setNavigating] = useState(false);
const [verifiedStatus, setVerifiedStatus] = useState<string | null>(null);
useEffect(() => {
const markComplete = async () => {
try {
const response = await request<{ verify_badge?: string }>(
"POST",
"/verify/complete",
{}
);
setVerifiedStatus(response?.verify_badge ?? "none");
} catch {
setVerifiedStatus(user?.verify_badge ?? "none");
}
};
markComplete();
}, []);
const confirmHandler = () => {
setNavigating(true);
setShowModal(true);
setNavigating(false);
};
return (
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center justify-center address-page ">
<span className="text-xl font-bold text-[#238800]">
{t("auth.registrationComplete")}
</span>
<small className="font-bold mt-4 mb-4">
{t("auth.welcomeMessage")}
</small>
<div className="flex flex-col justify-center items-center">
<ProfileAvatar
src={user?.profile_image}
alt={user?.user_name || "user profile"}
size="xl"
rounded="2xl"
userId={user?._id}
verifyBadge={verifiedStatus ?? user?.verify_badge}
/>
<div className="flex flex-col justify-center items-center gap-1 mt-1">
<span>{formatFullName(user?.first_name, user?.last_name)}</span>
<div className="flex items-center gap-1 mt-1">
<span className="inline-flex items-center gap-1">
{user?.user_name}
<VerificationBadge
verifyBadge={verifiedStatus ?? user?.verify_badge ?? "none"}
/>
</span>
</div>
</div>
</div>
<small className="font-bold mt-10 mb-4 text-center">
{t("auth.verificationBadgeHint")}
</small>
<div className=" flex">
<div className="">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
opacity="0.4"
d="M35.8331 8.16689C38.1331 6.20023 41.8998 6.20023 44.2331 8.16689L49.4998 12.7002C50.4998 13.5669 52.3664 14.2669 53.6998 14.2669H59.3664C62.8998 14.2669 65.7998 17.1669 65.7998 20.7002V26.3669C65.7998 27.6669 66.4998 29.5669 67.3664 30.5669L71.8998 35.8336C73.8664 38.1336 73.8664 41.9002 71.8998 44.2336L67.3664 49.5002C66.4998 50.5002 65.7998 52.3669 65.7998 53.7002V59.3669C65.7998 62.9002 62.8998 65.8002 59.3664 65.8002H53.6998C52.3998 65.8002 50.4998 66.5002 49.4998 67.3669L44.2331 71.9002C41.9331 73.8669 38.1664 73.8669 35.8331 71.9002L30.5664 67.3669C29.5664 66.5002 27.6998 65.8002 26.3664 65.8002H20.5998C17.0664 65.8002 14.1664 62.9002 14.1664 59.3669V53.6669C14.1664 52.3669 13.4664 50.5002 12.6331 49.5002L8.13311 44.2002C6.19977 41.9002 6.19977 38.1669 8.13311 35.8669L12.6331 30.5669C13.4664 29.5669 14.1664 27.7002 14.1664 26.4002V20.6669C14.1664 17.1336 17.0664 14.2336 20.5998 14.2336H26.3664C27.6664 14.2336 29.5664 13.5336 30.5664 12.6669L35.8331 8.16689Z"
fill="#0066FF"
/>
<path
d="M35.9665 50.5668C35.2999 50.5668 34.6665 50.3001 34.1999 49.8334L26.1332 41.7668C25.1665 40.8001 25.1665 39.2001 26.1332 38.2334C27.0999 37.2668 28.6999 37.2668 29.6665 38.2334L35.9665 44.5334L50.2999 30.2001C51.2665 29.2334 52.8665 29.2334 53.8332 30.2001C54.7999 31.1668 54.7999 32.7668 53.8332 33.7334L37.7332 49.8334C37.2665 50.3001 36.6332 50.5668 35.9665 50.5668Z"
fill="#0066FF"
/>
</svg>
</div>
<div className="">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
opacity="0.4"
d="M35.8331 8.16689C38.1331 6.20023 41.8998 6.20023 44.2331 8.16689L49.4998 12.7002C50.4998 13.5669 52.3664 14.2669 53.6998 14.2669H59.3664C62.8998 14.2669 65.7998 17.1669 65.7998 20.7002V26.3669C65.7998 27.6669 66.4998 29.5669 67.3664 30.5669L71.8998 35.8336C73.8664 38.1336 73.8664 41.9002 71.8998 44.2336L67.3664 49.5002C66.4998 50.5002 65.7998 52.3669 65.7998 53.7002V59.3669C65.7998 62.9002 62.8998 65.8002 59.3664 65.8002H53.6998C52.3998 65.8002 50.4998 66.5002 49.4998 67.3669L44.2331 71.9002C41.9331 73.8669 38.1664 73.8669 35.8331 71.9002L30.5664 67.3669C29.5664 66.5002 27.6998 65.8002 26.3664 65.8002H20.5998C17.0664 65.8002 14.1664 62.9002 14.1664 59.3669V53.6669C14.1664 52.3669 13.4664 50.5002 12.6331 49.5002L8.13311 44.2002C6.19977 41.9002 6.19977 38.1669 8.13311 35.8669L12.6331 30.5669C13.4664 29.5669 14.1664 27.7002 14.1664 26.4002V20.6669C14.1664 17.1336 17.0664 14.2336 20.5998 14.2336H26.3664C27.6664 14.2336 29.5664 13.5336 30.5664 12.6669L35.8331 8.16689Z"
fill="#dbd40b"
/>
<path
d="M35.9665 50.5668C35.2999 50.5668 34.6665 50.3001 34.1999 49.8334L26.1332 41.7668C25.1665 40.8001 25.1665 39.2001 26.1332 38.2334C27.0999 37.2668 28.6999 37.2668 29.6665 38.2334L35.9665 44.5334L50.2999 30.2001C51.2665 29.2334 52.8665 29.2334 53.8332 30.2001C54.7999 31.1668 54.7999 32.7668 53.8332 33.7334L37.7332 49.8334C37.2665 50.3001 36.6332 50.5668 35.9665 50.5668Z"
fill="#f5f507"
/>
</svg>
</div>
</div>
<AuthNextButton
onClick={confirmHandler}
className="mt-20"
loading={navigating}
disabled={navigating}
>
{t("auth.saveAndContinue")}
</AuthNextButton>
<small className="font-bold mt-10 mb-1 text-center">
{t("auth.verificationTimeOffice")}
</small>
<small className="font-bold text-center">
{t("auth.verificationTimeOffHours")}
</small>
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
height="200px"
>
<p className="font-bold text-center mt-4">
{t("auth.shareWorkHint")}
</p>
<div className="flex w-full justify-center items-center mt-10 gap-5">
<Link
href={"/new-post"}
className="bg-sky-200 py-2 px-5 rounded-xl min-w-[120px] text-sky-600 text-center"
>
{t("auth.createPost")}
</Link>
<Link
href={"/"}
className="bg-sky-200 py-2 px-5 rounded-xl min-w-[120px] text-sky-600 text-center"
>
{t("auth.later")}
</Link>
</div>
</Modal>
</div>
</Container>
</LocalePageShell>
);
}
export default Confirm;
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import Container from "@/components/elements/Container";
import React, { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import Modal from "@/components/elements/Modal";
import Link from "next/link";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import VerificationBadge from "@/components/main/VerificationBadge";
import { useUser } from "@/hooks/useUser";
import { formatFullName } from "@/lib/formatFullName";
import useAxios from "@/hooks/useAxios";
import { useTranslation } from "react-i18next";
import LocalePageShell from "@/components/i18n/LocalePageShell";
function Confirm() {
const { t } = useTranslation("common");
const user = useUser();
const { request } = useAxios();
const router = useRouter();
const [showModal, setShowModal] = useState<boolean>(false);
const [navigating, setNavigating] = useState(false);
const [verifiedStatus, setVerifiedStatus] = useState<string | null>(null);
useEffect(() => {
const markComplete = async () => {
try {
const response = await request<{ verify_badge?: string }>(
"POST",
"/verify/complete",
{}
);
setVerifiedStatus(response?.verify_badge ?? "none");
} catch {
setVerifiedStatus(user?.verify_badge ?? "none");
}
};
markComplete();
}, []);
const confirmHandler = () => {
setNavigating(true);
setShowModal(true);
setNavigating(false);
};
return (
<LocalePageShell>
<Container>
<div className="p-4 flex flex-col items-center justify-center address-page ">
<span className="text-xl font-bold text-[#238800]">
{t("auth.registrationComplete")}
</span>
<small className="font-bold mt-4 mb-4">
{t("auth.welcomeMessage")}
</small>
<div className="flex flex-col justify-center items-center">
<ProfileAvatar
src={user?.profile_image}
alt={user?.user_name || "user profile"}
size="xl"
rounded="2xl"
userId={user?._id}
verifyBadge={verifiedStatus ?? user?.verify_badge}
/>
<div className="flex flex-col justify-center items-center gap-1 mt-1">
<span>{formatFullName(user?.first_name, user?.last_name)}</span>
<div className="flex items-center gap-1 mt-1">
<span className="inline-flex items-center gap-1">
{user?.user_name}
<VerificationBadge
verifyBadge={verifiedStatus ?? user?.verify_badge ?? "none"}
/>
</span>
</div>
</div>
</div>
<small className="font-bold mt-10 mb-4 text-center">
{t("auth.verificationBadgeHint")}
</small>
<div className=" flex">
<div className="">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
opacity="0.4"
d="M35.8331 8.16689C38.1331 6.20023 41.8998 6.20023 44.2331 8.16689L49.4998 12.7002C50.4998 13.5669 52.3664 14.2669 53.6998 14.2669H59.3664C62.8998 14.2669 65.7998 17.1669 65.7998 20.7002V26.3669C65.7998 27.6669 66.4998 29.5669 67.3664 30.5669L71.8998 35.8336C73.8664 38.1336 73.8664 41.9002 71.8998 44.2336L67.3664 49.5002C66.4998 50.5002 65.7998 52.3669 65.7998 53.7002V59.3669C65.7998 62.9002 62.8998 65.8002 59.3664 65.8002H53.6998C52.3998 65.8002 50.4998 66.5002 49.4998 67.3669L44.2331 71.9002C41.9331 73.8669 38.1664 73.8669 35.8331 71.9002L30.5664 67.3669C29.5664 66.5002 27.6998 65.8002 26.3664 65.8002H20.5998C17.0664 65.8002 14.1664 62.9002 14.1664 59.3669V53.6669C14.1664 52.3669 13.4664 50.5002 12.6331 49.5002L8.13311 44.2002C6.19977 41.9002 6.19977 38.1669 8.13311 35.8669L12.6331 30.5669C13.4664 29.5669 14.1664 27.7002 14.1664 26.4002V20.6669C14.1664 17.1336 17.0664 14.2336 20.5998 14.2336H26.3664C27.6664 14.2336 29.5664 13.5336 30.5664 12.6669L35.8331 8.16689Z"
fill="#0066FF"
/>
<path
d="M35.9665 50.5668C35.2999 50.5668 34.6665 50.3001 34.1999 49.8334L26.1332 41.7668C25.1665 40.8001 25.1665 39.2001 26.1332 38.2334C27.0999 37.2668 28.6999 37.2668 29.6665 38.2334L35.9665 44.5334L50.2999 30.2001C51.2665 29.2334 52.8665 29.2334 53.8332 30.2001C54.7999 31.1668 54.7999 32.7668 53.8332 33.7334L37.7332 49.8334C37.2665 50.3001 36.6332 50.5668 35.9665 50.5668Z"
fill="#0066FF"
/>
</svg>
</div>
<div className="">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
opacity="0.4"
d="M35.8331 8.16689C38.1331 6.20023 41.8998 6.20023 44.2331 8.16689L49.4998 12.7002C50.4998 13.5669 52.3664 14.2669 53.6998 14.2669H59.3664C62.8998 14.2669 65.7998 17.1669 65.7998 20.7002V26.3669C65.7998 27.6669 66.4998 29.5669 67.3664 30.5669L71.8998 35.8336C73.8664 38.1336 73.8664 41.9002 71.8998 44.2336L67.3664 49.5002C66.4998 50.5002 65.7998 52.3669 65.7998 53.7002V59.3669C65.7998 62.9002 62.8998 65.8002 59.3664 65.8002H53.6998C52.3998 65.8002 50.4998 66.5002 49.4998 67.3669L44.2331 71.9002C41.9331 73.8669 38.1664 73.8669 35.8331 71.9002L30.5664 67.3669C29.5664 66.5002 27.6998 65.8002 26.3664 65.8002H20.5998C17.0664 65.8002 14.1664 62.9002 14.1664 59.3669V53.6669C14.1664 52.3669 13.4664 50.5002 12.6331 49.5002L8.13311 44.2002C6.19977 41.9002 6.19977 38.1669 8.13311 35.8669L12.6331 30.5669C13.4664 29.5669 14.1664 27.7002 14.1664 26.4002V20.6669C14.1664 17.1336 17.0664 14.2336 20.5998 14.2336H26.3664C27.6664 14.2336 29.5664 13.5336 30.5664 12.6669L35.8331 8.16689Z"
fill="#dbd40b"
/>
<path
d="M35.9665 50.5668C35.2999 50.5668 34.6665 50.3001 34.1999 49.8334L26.1332 41.7668C25.1665 40.8001 25.1665 39.2001 26.1332 38.2334C27.0999 37.2668 28.6999 37.2668 29.6665 38.2334L35.9665 44.5334L50.2999 30.2001C51.2665 29.2334 52.8665 29.2334 53.8332 30.2001C54.7999 31.1668 54.7999 32.7668 53.8332 33.7334L37.7332 49.8334C37.2665 50.3001 36.6332 50.5668 35.9665 50.5668Z"
fill="#f5f507"
/>
</svg>
</div>
</div>
<AuthNextButton
onClick={confirmHandler}
className="mt-20"
loading={navigating}
disabled={navigating}
>
{t("auth.saveAndContinue")}
</AuthNextButton>
<small className="font-bold mt-10 mb-1 text-center">
{t("auth.verificationTimeOffice")}
</small>
<small className="font-bold text-center">
{t("auth.verificationTimeOffHours")}
</small>
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
height="200px"
>
<p className="font-bold text-center mt-4">
{t("auth.shareWorkHint")}
</p>
<div className="flex w-full justify-center items-center mt-10 gap-5">
<Link
href={"/new-post"}
className="bg-sky-200 py-2 px-5 rounded-2xl min-w-[120px] text-sky-600 text-center"
>
{t("auth.createPost")}
</Link>
<Link
href={"/"}
className="bg-sky-200 py-2 px-5 rounded-2xl min-w-[120px] text-sky-600 text-center"
>
{t("auth.later")}
</Link>
</div>
</Modal>
</div>
</Container>
</LocalePageShell>
);
}
export default Confirm;

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,8 @@ import useAxios from "@/hooks/useAxios";
import React, { useEffect, useState } from "react";
import RoundedDiv from "@/components/elements/RoundedDiv";
import RoundedButton from "@/components/elements/RoundedButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import BackButton from "@/components/ui/BackButton";
import { useRouter, useSearchParams } from "next/navigation";
import Image from "next/image";
import MainProjectCard from "@/components/projects/MainProjectCard";
@@ -88,7 +90,9 @@ function FailedProject() {
};
return (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-[#FF0000] ">
{t("academy.payment.failed")}
</h6>
@@ -127,6 +131,7 @@ function FailedProject() {
</div>
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -6,6 +6,8 @@ import useAxios from "@/hooks/useAxios";
import React, { useEffect, useState } from "react";
import RoundedDiv from "@/components/elements/RoundedDiv";
import RoundedButton from "@/components/elements/RoundedButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import BackButton from "@/components/ui/BackButton";
import { useSearchParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
@@ -66,7 +68,9 @@ function SuccessProject() {
}, [project, typeList]);
return (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="font-bold md:text-xl text-lg text-center mt-2 line-clamp-2 mb-4 text-[#17A600] ">
{t("academy.payment.success")}
</h6>
@@ -95,6 +99,7 @@ function SuccessProject() {
</div>
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -8,6 +8,7 @@ import {
AcademyProfileHeadSkeleton,
} from "@/components/academy/AcademySkeletons";
import Container from "@/components/elements/Container";
import BackButton from "@/components/ui/BackButton";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import { useUserById } from "@/hooks/getUserById";
@@ -179,6 +180,7 @@ export default function AcademyProfileClient() {
if (isLoading) {
return (
<Container>
<BackButton />
<AcademyProfileHeadSkeleton />
<AcademyPackageListSkeleton />
</Container>
@@ -188,6 +190,7 @@ export default function AcademyProfileClient() {
if (!academy) {
return (
<Container>
<BackButton />
<div className="py-20 text-center text-neutral-500">
{t("academy.profile.notFound")}
</div>
@@ -208,6 +211,7 @@ export default function AcademyProfileClient() {
return (
<Container className="pb-28">
<BackButton />
<div className="w-full">
<button
type="button"

View File

@@ -1,19 +1,22 @@
"use client";
import Container from "@/components/elements/Container";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import MultiStepForm from "@/components/projects/NewProject/MultiStepForm";
import { ProjectFormProvider } from "@/contexts/ProjectFormContext";
import React, { Suspense } from "react";
function NewProject() {
return (
<ProjectFormProvider>
<Container>
<Suspense fallback={null}>
<MultiStepForm />
</Suspense>
</Container>
</ProjectFormProvider>
<LocalePageShell>
<ProjectFormProvider>
<Container>
<Suspense fallback={null}>
<MultiStepForm />
</Suspense>
</Container>
</ProjectFormProvider>
</LocalePageShell>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import Container from "@/components/elements/Container";
import { IAdvertising, IAdvertisingType } from "@/types/types";
import { IAdvertising } from "@/types/types";
import useAxios from "@/hooks/useAxios";
import React, { useEffect, useState } from "react";
import MainBillboardCard from "@/components/billboards/MainBillboardCard/MainBillboardCard";
@@ -21,10 +21,12 @@ function FailedBillboard() {
const searchParams = useSearchParams();
const projectId = searchParams.get("projectId");
const type = searchParams.get("type");
// بک‌اند (paymentControllerWeb.js) خودش مبلغ نهایی (با احتساب تخفیف/بازنشر) رو
// توی همین query param محاسبه و می‌فرسته — دیگه نیازی به واکشی typeList و
// تطبیق دستی نیست (همون چیزی که باعث می‌شد بعضی وقتا ۰ تومان نشون بده)
const price = searchParams.get("price") || "0";
const [advertising, setAdvertising] = useState<IAdvertising>();
const [typeList, setTypeList] = useState<IAdvertisingType[] | null>(null);
const [price, setPrice] = useState("");
useEffect(() => {
const fetchAd = async () => {
@@ -34,18 +36,7 @@ function FailedBillboard() {
);
setAdvertising(response?.advertising);
};
const fetchStates = async () => {
try {
const response = await request<{
advertisingTypes: IAdvertisingType[];
}>("GET", "/advertising/types");
setTypeList(response?.advertisingTypes || null);
} catch (err) {
console.log(err);
}
};
fetchAd();
fetchStates();
}, [projectId, request]);
const payHandler = async () => {
@@ -96,15 +87,6 @@ function FailedBillboard() {
}
};
useEffect(() => {
if (advertising?.type && typeList) {
const foundType = typeList.find((item) => item.name === advertising.type);
if (foundType) {
setPrice(String(foundType.price));
}
}
}, [advertising, typeList]);
return (
<LocalePageShell>
<Container>

View File

@@ -1,7 +1,7 @@
"use client";
import Container from "@/components/elements/Container";
import { IAdvertising, IAdvertisingType } from "@/types/types";
import { IAdvertising } from "@/types/types";
import useAxios from "@/hooks/useAxios";
import React, { useEffect, useState } from "react";
import MainBillboardCard from "@/components/billboards/MainBillboardCard/MainBillboardCard";
@@ -20,10 +20,12 @@ function SuccessBillboard() {
const { request } = useAxios();
const searchParams = useSearchParams();
const projectId = searchParams.get("projectId");
// بک‌اند (paymentControllerWeb.js) خودش مبلغ نهایی (با احتساب تخفیف/بازنشر) رو
// توی همین query param محاسبه و می‌فرسته — دیگه نیازی به واکشی typeList و
// تطبیق دستی نیست (همون چیزی که باعث می‌شد بعضی وقتا ۰ تومان نشون بده)
const price = searchParams.get("price") || "0";
const [advertising, setAdvertising] = useState<IAdvertising>();
const [typeList, setTypeList] = useState<IAdvertisingType[] | null>(null);
const [price, setPrice] = useState("");
useEffect(() => {
const fetchAd = async () => {
@@ -33,29 +35,9 @@ function SuccessBillboard() {
);
setAdvertising(response?.advertising);
};
const fetchStates = async () => {
try {
const response = await request<{
advertisingTypes: IAdvertisingType[];
}>("GET", "/advertising/types");
setTypeList(response?.advertisingTypes || null);
} catch (err) {
console.log(err);
}
};
fetchAd();
fetchStates();
}, [projectId, request]);
useEffect(() => {
if (advertising?.type && typeList) {
const foundType = typeList.find((item) => item.name === advertising.type);
if (foundType) {
setPrice(String(foundType.price));
}
}
}, [advertising, typeList]);
return (
<LocalePageShell>
<Container>

View File

@@ -193,7 +193,7 @@ function BookingFlowPage() {
type="button"
key={date}
onClick={() => setSelectedDate(date)}
className={`flex-shrink-0 rounded-xl border px-3 py-2 text-xs ${
className={`flex-shrink-0 rounded-2xl border px-3 py-2 text-xs ${
selectedDate === date
? "border-[#0095f6] bg-[#0095f6]/10 font-bold"
: "border-neutral-200 dark:border-neutral-700"
@@ -222,7 +222,7 @@ function BookingFlowPage() {
setSelectedSlot(slot);
setStep("confirm");
}}
className={`w-full rounded-xl border px-3 py-2.5 text-sm ${
className={`w-full rounded-2xl border px-3 py-2.5 text-sm ${
selectedSlot?.start_time === slot.start_time
? "border-[#0095f6] bg-[#0095f6]/10 font-bold"
: "border-neutral-200 dark:border-neutral-700"

View File

@@ -2,6 +2,8 @@
import Container from "@/components/elements/Container";
import RoundedButton from "@/components/elements/RoundedButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import BackButton from "@/components/ui/BackButton";
import { useSearchParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
@@ -13,7 +15,9 @@ function GiftPaymentFailed() {
const receiverId = searchParams.get("receiverId");
return (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="mb-10 mt-20 line-clamp-2 text-center text-lg font-bold text-[#FF0000] md:text-xl">
{t("giftPage.paymentFailed.title")}
</h6>
@@ -35,6 +39,7 @@ function GiftPaymentFailed() {
</Link>
) : null}
</Container>
</LocalePageShell>
);
}

View File

@@ -2,6 +2,8 @@
import Container from "@/components/elements/Container";
import RoundedButton from "@/components/elements/RoundedButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import BackButton from "@/components/ui/BackButton";
import { useSearchParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
@@ -14,7 +16,9 @@ function GiftPaymentSuccess() {
const receiverUsername = searchParams.get("receiverUsername");
return (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="mb-4 mt-10 line-clamp-2 text-center text-lg font-bold text-[#17A600] md:text-xl">
{t("giftPage.paymentSuccess.title")}
</h6>
@@ -45,6 +49,7 @@ function GiftPaymentSuccess() {
</Link>
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -29,7 +29,7 @@ export default function GlobalError({
<button
type="button"
onClick={() => reset()}
className="rounded-xl bg-[#0095f6] px-5 py-2.5 text-sm font-bold text-white"
className="rounded-2xl bg-[#0095f6] px-5 py-2.5 text-sm font-bold text-white"
>
{t("errors.retry")}
</button>

View File

@@ -9,6 +9,7 @@ import Script from 'next/script';
import "./globals.css";
import Layout from "@/components/Layout";
import RegisterSW from "@/components/RegisterSW";
import PushNotificationListener from "@/components/PushNotificationListener";
import ClientErrorBoundary from "@/components/ClientErrorBoundary";
const iranSansFont = localFont({
@@ -98,6 +99,7 @@ export default async function RootLayout({
<body className={`${iranSansFont.className} antialiased`}>
<SiteJsonLd />
<RegisterSW />
<PushNotificationListener />
<ClientErrorBoundary>
<Layout initialLanguage={lang}>{children}</Layout>
</ClientErrorBoundary>

View File

@@ -3,6 +3,8 @@
import Container from "@/components/elements/Container";
import React from "react";
import RoundedButton from "@/components/elements/RoundedButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import BackButton from "@/components/ui/BackButton";
import { useSearchParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
@@ -14,7 +16,9 @@ function FailedBillboard() {
const userId = searchParams.get("userId");
return (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="font-bold md:text-xl text-lg text-center mt-20 line-clamp-2 mb-10 text-[#FF0000] ">
{t("offerPage.paymentFailed.title")}
</h6>
@@ -37,6 +41,7 @@ function FailedBillboard() {
</RoundedButton>
</Link>
</Container>
</LocalePageShell>
);
}

View File

@@ -5,6 +5,8 @@ import { User } from "@/types/types";
import useAxios from "@/hooks/useAxios";
import React, { useEffect, useState } from "react";
import RoundedButton from "@/components/elements/RoundedButton";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import BackButton from "@/components/ui/BackButton";
import { useSearchParams } from "next/navigation";
import Image from "next/image";
import Link from "next/link";
@@ -29,7 +31,9 @@ function SuccessBillboard() {
}, []);
return (
<LocalePageShell>
<Container>
<BackButton />
<h6 className="font-bold md:text-xl text-lg text-center mt-10 line-clamp-2 mb-4 text-[#17A600] ">
{t("offerPage.paymentSuccess.title")}
</h6>
@@ -64,6 +68,7 @@ function SuccessBillboard() {
</div>
</div>
</Container>
</LocalePageShell>
);
}

View File

@@ -1,19 +1,19 @@
import Title from "@/Components/ui/Title";
import React, { ReactNode } from "react";
interface BoxProps {
children: ReactNode;
text: string;
}
export default function Box({ children, text }: BoxProps) {
return (
<div className="flex justify-center mt-5 items-center mx-2.5">
<div className="px-3 py-7 rounded-xl bg-white max-w-[1500px] dark:bg-neutral-900 w-full shadow-xl">
<Title text={text} className="text-left mb-8 mx-3.5 " />
{children}
</div>
</div>
);
}
import Title from "@/Components/ui/Title";
import React, { ReactNode } from "react";
interface BoxProps {
children: ReactNode;
text: string;
}
export default function Box({ children, text }: BoxProps) {
return (
<div className="flex justify-center mt-5 items-center mx-2.5">
<div className="px-3 py-7 rounded-2xl bg-white max-w-[1500px] dark:bg-neutral-900 w-full shadow-xl">
<Title text={text} className="text-left mb-8 mx-3.5 " />
{children}
</div>
</div>
);
}

View File

@@ -1,103 +1,103 @@
"use client";
import { Separator } from "@/Components/ui/separator";
import React from "react";
import BoldIcon from "@/components/ui/BoldIcon";
import { useTranslation } from "react-i18next";
import Image from "next/image";
interface OrderItem {
id: string;
date: string;
orderCode: string;
amount: string;
statusLabel: string;
statusDetail?: string;
images: string[];
rating: number;
invoiceUrl?: string;
};
export default function OrdersCart({ order }: { order: OrderItem }) {
const { t } = useTranslation("common");
return (
<div className="w-full border-2 gap-y-3.5 rounded-xl p-3.5 flex flex-col items-center">
<div className="flex items-center justify-between w-full">
<div className="flex justify-center items-center gap-2.5">
<span className="">
<BoldIcon name="task" size={28} tinted className="text-cyan-600 dark:text-neutral-600" />
</span>
<span className="font-bold text-neutral-600 dark:text-neutral-300">
{order.statusLabel}
</span>
</div>
<span className="ltr:rotate-180">
<BoldIcon name="arrow-right-2" size={24} tinted className="text-neutral-600 dark:text-neutral-300" />
</span>
</div>
<div className=" text-sm max-sm:text-xs w-full flex gap-3.5">
<div className="flex gap-2 flex-wrap">
<span className="font-medium text-neutral-400">
{t("orderCart.date")}:{" "}
</span>
<span className="font-bold text-neutral-800 dark:text-neutral-200">
{order.date}
</span>
</div>
<div className="flex gap-2 flex-wrap">
<span className="font-medium text-neutral-400">
{t("orderCart.Order_code")}:{" "}
</span>
<span className="font-bold text-neutral-800 dark:text-neutral-200">
{order.orderCode}
</span>
</div>
<div className="flex gap-2 flex-wrap">
<span className="font-medium text-neutral-400">
{t("orderCart.Amount")}:{" "}
</span>
<span className="font-bold text-neutral-800 dark:text-neutral-200">
{order.amount}
</span>
</div>
</div>
<div className=" max-sm:text-sm w-full flex gap-3.5">
<div className="flex gap-2 flex-wrap">
<span className="font-medium text-neutral-400">
{t("orderCart.Order_status")}:{" "}
</span>
<span className="font-bold text-cyan-600 dark:text-neutral-600">
{order.statusDetail}
</span>
</div>
</div>
<div className="w-full flex gap-2 flex-wrap">
{order.images.map((src, idx) => (
<Image key={idx} alt={`img-${idx}`} src={src} height={70} width={70} className="object-cover object-center" />
))}
</div>
<Separator />
<div className="flex items-center justify-between w-full">
<div className="flex justify-center items-center gap-2.5">
<span className="font-medium max-sm:hidden text-neutral-400">
{t("orderCart.Your_rating_for_this_order")}:{" "}
</span>
<span className="font-bold flex justify-center items-center gap-0.5 flex-row-reverse text-neutral-600 dark:text-neutral-300">
{order.rating} <BoldIcon name="star" size={20} tinted className="text-amber-400" />
</span>
</div>
<div className="flex cursor-pointer justify-center items-center gap-2.5">
<span className="">
<BoldIcon name="document-text" size={20} tinted className="text-cyan-800 dark:text-neutral-200" />
</span>
<span className="font-bold flex justify-center items-center text-cyan-800 dark:text-neutral-200">
{t("orderCart.View_invoice")} <BoldIcon name="arrow-right-2" size={20} tinted className="text-cyan-800 dark:text-neutral-200 rtl:rotate-180" />
</span>
</div>
</div>
</div>
);
}
"use client";
import { Separator } from "@/Components/ui/separator";
import React from "react";
import BoldIcon from "@/components/ui/BoldIcon";
import { useTranslation } from "react-i18next";
import Image from "next/image";
interface OrderItem {
id: string;
date: string;
orderCode: string;
amount: string;
statusLabel: string;
statusDetail?: string;
images: string[];
rating: number;
invoiceUrl?: string;
};
export default function OrdersCart({ order }: { order: OrderItem }) {
const { t } = useTranslation("common");
return (
<div className="w-full border-2 gap-y-3.5 rounded-2xl p-3.5 flex flex-col items-center">
<div className="flex items-center justify-between w-full">
<div className="flex justify-center items-center gap-2.5">
<span className="">
<BoldIcon name="task" size={28} tinted className="text-cyan-600 dark:text-neutral-600" />
</span>
<span className="font-bold text-neutral-600 dark:text-neutral-300">
{order.statusLabel}
</span>
</div>
<span className="ltr:rotate-180">
<BoldIcon name="arrow-right-2" size={24} tinted className="text-neutral-600 dark:text-neutral-300" />
</span>
</div>
<div className=" text-sm max-sm:text-xs w-full flex gap-3.5">
<div className="flex gap-2 flex-wrap">
<span className="font-medium text-neutral-400">
{t("orderCart.date")}:{" "}
</span>
<span className="font-bold text-neutral-800 dark:text-neutral-200">
{order.date}
</span>
</div>
<div className="flex gap-2 flex-wrap">
<span className="font-medium text-neutral-400">
{t("orderCart.Order_code")}:{" "}
</span>
<span className="font-bold text-neutral-800 dark:text-neutral-200">
{order.orderCode}
</span>
</div>
<div className="flex gap-2 flex-wrap">
<span className="font-medium text-neutral-400">
{t("orderCart.Amount")}:{" "}
</span>
<span className="font-bold text-neutral-800 dark:text-neutral-200">
{order.amount}
</span>
</div>
</div>
<div className=" max-sm:text-sm w-full flex gap-3.5">
<div className="flex gap-2 flex-wrap">
<span className="font-medium text-neutral-400">
{t("orderCart.Order_status")}:{" "}
</span>
<span className="font-bold text-cyan-600 dark:text-neutral-600">
{order.statusDetail}
</span>
</div>
</div>
<div className="w-full flex gap-2 flex-wrap">
{order.images.map((src, idx) => (
<Image key={idx} alt={`img-${idx}`} src={src} height={70} width={70} className="object-cover object-center" />
))}
</div>
<Separator />
<div className="flex items-center justify-between w-full">
<div className="flex justify-center items-center gap-2.5">
<span className="font-medium max-sm:hidden text-neutral-400">
{t("orderCart.Your_rating_for_this_order")}:{" "}
</span>
<span className="font-bold flex justify-center items-center gap-0.5 flex-row-reverse text-neutral-600 dark:text-neutral-300">
{order.rating} <BoldIcon name="star" size={20} tinted className="text-amber-400" />
</span>
</div>
<div className="flex cursor-pointer justify-center items-center gap-2.5">
<span className="">
<BoldIcon name="document-text" size={20} tinted className="text-cyan-800 dark:text-neutral-200" />
</span>
<span className="font-bold flex justify-center items-center text-cyan-800 dark:text-neutral-200">
{t("orderCart.View_invoice")} <BoldIcon name="arrow-right-2" size={20} tinted className="text-cyan-800 dark:text-neutral-200 rtl:rotate-180" />
</span>
</div>
</div>
</div>
);
}

View File

@@ -1,446 +1,446 @@
"use client";
import { AcademyListSkeleton } from "@/components/academy/AcademySkeletons";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import React, { useEffect, useState } from "react";
import MainModelCard from "@/components/academy/MainModelCard";
import { Course } from "@/types/types";
import Image from "next/image";
import toast from "react-hot-toast";
import { motion, AnimatePresence } from "framer-motion";
import { useTranslation } from "react-i18next";
import BackButton from "@/components/ui/BackButton";
interface PaginationData {
page: number;
limit: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPrevPage: boolean;
}
export default function PurchasedCoursesPage() {
const { request } = useAxios();
const { t } = useTranslation("common");
const [courses, setCourses] = useState<Course[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [pagination, setPagination] = useState<PaginationData>({
page: 1,
limit: 5,
total: 0,
totalPages: 1,
hasNextPage: false,
hasPrevPage: false,
});
const [currentPage, setCurrentPage] = useState(1);
const fetchCourses = async (page: number) => {
setIsLoading(true);
try {
const response = await request(
"GET",
`/academy/course/getUserPurchasedCoursesWithPopulate?page=${page}&limit=6`
);
console.log("Response:", response);
if (response?.success && response?.data?.courses) {
setCourses(response.data.courses);
if (response.data.pagination) {
setPagination({
page: response.data.pagination.page,
limit: response.data.pagination.limit,
total: response.data.pagination.total,
totalPages: response.data.pagination.totalPages,
hasNextPage: response.data.pagination.hasNextPage,
hasPrevPage: response.data.pagination.hasPrevPage,
});
}
} else {
setCourses([]);
toast.error(t("settings.academy.myCourses.fetchError"));
}
} catch (err) {
console.log("get error:", err);
toast.error(t("settings.academy.myCourses.fetchPurchasedError"));
setCourses([]);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchCourses(currentPage);
}, [currentPage]);
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= pagination.totalPages) {
setCurrentPage(newPage);
window.scrollTo({ top: 0, behavior: "smooth" });
}
};
if (isLoading) {
return (
<LocalePageShell>
<div className="min-h-dvh container mx-auto px-4 py-8 lg:px-6">
<AcademyListSkeleton count={4} />
</div>
</LocalePageShell>
);
}
return (
<LocalePageShell>
<div className="min-h-dvh bg-white dark:bg-neutral-950">
<div className="relative bg-gradient-to-r from-purple-400 via-purple-600 to-pink-600 dark:from-purple-600 dark:via-purple-800 dark:to-pink-800">
<div className="absolute inset-0 bg-black/10"></div>
<div className="relative container mx-auto px-4 py-12 lg:px-6 md:py-16">
<BackButton tinted className="absolute left-4 top-4 text-white sm:left-6 sm:top-6" />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="text-center"
>
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-4">
{t("settings.academy.myCourses.title")}
</h1>
<p className="text-lg text-white/90 max-w-2xl mx-auto">
{t("settings.academy.myCourses.subtitle")}
</p>
<div className="inline-flex items-center gap-2 mt-6 px-4 py-2 bg-white/20 backdrop-blur-sm rounded-full">
<svg
className="w-5 h-5 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
<span className="text-white font-medium">
{t("settings.academy.myCourses.purchasedCount", {
count: pagination.total,
})}
</span>
</div>
</motion.div>
</div>
<div className="absolute bottom-0 left-0 right-0">
<svg
className="w-full h-12 text-white dark:text-neutral-950"
preserveAspectRatio="none"
viewBox="0 0 1440 54"
fill="currentColor"
>
<path d="M0 22L120 16.7C240 11 480 0 720 0C960 0 1200 11 1320 16.7L1440 22V54H1320C1200 54 960 54 720 54C480 54 240 54 120 54H0V22Z" />
</svg>
</div>
</div>
<div className="container mx-auto px-4 py-12 lg:px-6">
{courses.length === 0 ? (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5 }}
className="text-center py-20"
>
<div className="relative w-48 h-48 mx-auto mb-8">
<Image
src="/images/empty-courses.svg"
alt={t("settings.academy.myCourses.emptyAlt")}
fill
className="object-contain"
onError={(e) => {
(e.target as HTMLImageElement).src = "/images/empty-box.png";
}}
/>
</div>
<h3 className="text-2xl font-bold text-gray-800 dark:text-gray-200 mb-3">
{t("settings.academy.myCourses.emptyTitle")}
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-8">
{t("settings.academy.myCourses.emptyDescription")}
</p>
<a
href="/academy"
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-xl hover:shadow-lg transition-all duration-300 transform hover:scale-105"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
{t("settings.academy.myCourses.browseCourses")}
</a>
</motion.div>
) : (
<>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8"
>
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-md border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t("settings.academy.myCourses.totalCourses")}
</p>
<p className="text-2xl font-bold text-gray-800 dark:text-gray-200">
{pagination.total}
</p>
</div>
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-blue-600 dark:text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-md border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t("settings.academy.myCourses.currentPage")}
</p>
<p className="text-2xl font-bold text-gray-800 dark:text-gray-200">
{pagination.page} / {pagination.totalPages}
</p>
</div>
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900 rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-purple-600 dark:text-purple-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-md border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t("settings.academy.myCourses.perPage")}
</p>
<p className="text-2xl font-bold text-gray-800 dark:text-gray-200">
{pagination.limit}
</p>
</div>
<div className="w-12 h-12 bg-pink-100 dark:bg-pink-900 rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-pink-600 dark:text-pink-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</div>
</div>
</div>
</motion.div>
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="space-y-6"
>
{courses.map((course, index) => (
<motion.div
key={course._id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
>
<MainModelCard postData={course} />
</motion.div>
))}
</motion.div>
</AnimatePresence>
{pagination.totalPages > 1 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="mt-12 flex justify-center"
>
<div className="flex items-center gap-2 bg-white dark:bg-gray-800 rounded-xl shadow-lg p-2 border border-gray-200 dark:border-gray-700">
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={!pagination.hasPrevPage}
className={`
px-4 py-2 rounded-lg transition-all duration-200
${
pagination.hasPrevPage
? "hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
: "opacity-50 cursor-not-allowed text-gray-400 dark:text-gray-600"
}
`}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
<div className="flex items-center gap-1">
{Array.from(
{ length: pagination.totalPages },
(_, i) => i + 1
).map((page) => {
if (
page === 1 ||
page === pagination.totalPages ||
(page >= currentPage - 1 && page <= currentPage + 1)
) {
return (
<button
key={page}
onClick={() => handlePageChange(page)}
className={`
min-w-[40px] h-10 rounded-lg font-medium transition-all duration-200
${
currentPage === page
? "bg-gradient-to-r from-blue-500 to-purple-600 text-white shadow-md"
: "hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
}
`}
>
{page}
</button>
);
}
if (
page === currentPage - 2 ||
page === currentPage + 2
) {
return (
<span
key={page}
className="w-10 h-10 flex items-center justify-center text-gray-400"
>
...
</span>
);
}
return null;
})}
</div>
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={!pagination.hasNextPage}
className={`
px-4 py-2 rounded-lg transition-all duration-200
${
pagination.hasNextPage
? "hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
: "opacity-50 cursor-not-allowed text-gray-400 dark:text-gray-600"
}
`}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</button>
</div>
</motion.div>
)}
{courses.length > 3 && (
<button
onClick={() =>
window.scrollTo({ top: 0, behavior: "smooth" })
}
className="fixed bottom-8 right-8 bg-gradient-to-r from-blue-500 to-purple-600 text-white p-3 rounded-full shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-110 z-50"
aria-label={t("backToTop.aria")}
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 10l7-7m0 0l7 7m-7-7v18"
/>
</svg>
</button>
)}
</>
)}
</div>
</div>
</LocalePageShell>
);
}
"use client";
import { AcademyListSkeleton } from "@/components/academy/AcademySkeletons";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import React, { useEffect, useState } from "react";
import MainModelCard from "@/components/academy/MainModelCard";
import { Course } from "@/types/types";
import Image from "next/image";
import toast from "react-hot-toast";
import { motion, AnimatePresence } from "framer-motion";
import { useTranslation } from "react-i18next";
import BackButton from "@/components/ui/BackButton";
interface PaginationData {
page: number;
limit: number;
total: number;
totalPages: number;
hasNextPage: boolean;
hasPrevPage: boolean;
}
export default function PurchasedCoursesPage() {
const { request } = useAxios();
const { t } = useTranslation("common");
const [courses, setCourses] = useState<Course[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [pagination, setPagination] = useState<PaginationData>({
page: 1,
limit: 5,
total: 0,
totalPages: 1,
hasNextPage: false,
hasPrevPage: false,
});
const [currentPage, setCurrentPage] = useState(1);
const fetchCourses = async (page: number) => {
setIsLoading(true);
try {
const response = await request(
"GET",
`/academy/course/getUserPurchasedCoursesWithPopulate?page=${page}&limit=6`
);
console.log("Response:", response);
if (response?.success && response?.data?.courses) {
setCourses(response.data.courses);
if (response.data.pagination) {
setPagination({
page: response.data.pagination.page,
limit: response.data.pagination.limit,
total: response.data.pagination.total,
totalPages: response.data.pagination.totalPages,
hasNextPage: response.data.pagination.hasNextPage,
hasPrevPage: response.data.pagination.hasPrevPage,
});
}
} else {
setCourses([]);
toast.error(t("settings.academy.myCourses.fetchError"));
}
} catch (err) {
console.log("get error:", err);
toast.error(t("settings.academy.myCourses.fetchPurchasedError"));
setCourses([]);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchCourses(currentPage);
}, [currentPage]);
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= pagination.totalPages) {
setCurrentPage(newPage);
window.scrollTo({ top: 0, behavior: "smooth" });
}
};
if (isLoading) {
return (
<LocalePageShell>
<div className="min-h-dvh container mx-auto px-4 py-8 lg:px-6">
<AcademyListSkeleton count={4} />
</div>
</LocalePageShell>
);
}
return (
<LocalePageShell>
<div className="min-h-dvh bg-white dark:bg-neutral-950">
<div className="relative bg-gradient-to-r from-purple-400 via-purple-600 to-pink-600 dark:from-purple-600 dark:via-purple-800 dark:to-pink-800">
<div className="absolute inset-0 bg-black/10"></div>
<div className="relative container mx-auto px-4 py-12 lg:px-6 md:py-16">
<BackButton tinted className="absolute left-4 top-4 text-white sm:left-6 sm:top-6" />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="text-center"
>
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-white mb-4">
{t("settings.academy.myCourses.title")}
</h1>
<p className="text-lg text-white/90 max-w-2xl mx-auto">
{t("settings.academy.myCourses.subtitle")}
</p>
<div className="inline-flex items-center gap-2 mt-6 px-4 py-2 bg-white/20 backdrop-blur-sm rounded-full">
<svg
className="w-5 h-5 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
<span className="text-white font-medium">
{t("settings.academy.myCourses.purchasedCount", {
count: pagination.total,
})}
</span>
</div>
</motion.div>
</div>
<div className="absolute bottom-0 left-0 right-0">
<svg
className="w-full h-12 text-white dark:text-neutral-950"
preserveAspectRatio="none"
viewBox="0 0 1440 54"
fill="currentColor"
>
<path d="M0 22L120 16.7C240 11 480 0 720 0C960 0 1200 11 1320 16.7L1440 22V54H1320C1200 54 960 54 720 54C480 54 240 54 120 54H0V22Z" />
</svg>
</div>
</div>
<div className="container mx-auto px-4 py-12 lg:px-6">
{courses.length === 0 ? (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5 }}
className="text-center py-20"
>
<div className="relative w-48 h-48 mx-auto mb-8">
<Image
src="/images/empty-courses.svg"
alt={t("settings.academy.myCourses.emptyAlt")}
fill
className="object-contain"
onError={(e) => {
(e.target as HTMLImageElement).src = "/images/empty-box.png";
}}
/>
</div>
<h3 className="text-2xl font-bold text-gray-800 dark:text-gray-200 mb-3">
{t("settings.academy.myCourses.emptyTitle")}
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-8">
{t("settings.academy.myCourses.emptyDescription")}
</p>
<a
href="/academy"
className="inline-flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-2xl hover:shadow-lg transition-all duration-300 transform hover:scale-105"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
{t("settings.academy.myCourses.browseCourses")}
</a>
</motion.div>
) : (
<>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8"
>
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-md border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t("settings.academy.myCourses.totalCourses")}
</p>
<p className="text-2xl font-bold text-gray-800 dark:text-gray-200">
{pagination.total}
</p>
</div>
<div className="w-12 h-12 bg-blue-100 dark:bg-blue-900 rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-blue-600 dark:text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-md border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t("settings.academy.myCourses.currentPage")}
</p>
<p className="text-2xl font-bold text-gray-800 dark:text-gray-200">
{pagination.page} / {pagination.totalPages}
</p>
</div>
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900 rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-purple-600 dark:text-purple-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-md border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 dark:text-gray-400 text-sm">
{t("settings.academy.myCourses.perPage")}
</p>
<p className="text-2xl font-bold text-gray-800 dark:text-gray-200">
{pagination.limit}
</p>
</div>
<div className="w-12 h-12 bg-pink-100 dark:bg-pink-900 rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-pink-600 dark:text-pink-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</div>
</div>
</div>
</motion.div>
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="space-y-6"
>
{courses.map((course, index) => (
<motion.div
key={course._id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
>
<MainModelCard postData={course} />
</motion.div>
))}
</motion.div>
</AnimatePresence>
{pagination.totalPages > 1 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="mt-12 flex justify-center"
>
<div className="flex items-center gap-2 bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-2 border border-gray-200 dark:border-gray-700">
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={!pagination.hasPrevPage}
className={`
px-4 py-2 rounded-lg transition-all duration-200
${
pagination.hasPrevPage
? "hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
: "opacity-50 cursor-not-allowed text-gray-400 dark:text-gray-600"
}
`}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
<div className="flex items-center gap-1">
{Array.from(
{ length: pagination.totalPages },
(_, i) => i + 1
).map((page) => {
if (
page === 1 ||
page === pagination.totalPages ||
(page >= currentPage - 1 && page <= currentPage + 1)
) {
return (
<button
key={page}
onClick={() => handlePageChange(page)}
className={`
min-w-[40px] h-10 rounded-lg font-medium transition-all duration-200
${
currentPage === page
? "bg-gradient-to-r from-blue-500 to-purple-600 text-white shadow-md"
: "hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
}
`}
>
{page}
</button>
);
}
if (
page === currentPage - 2 ||
page === currentPage + 2
) {
return (
<span
key={page}
className="w-10 h-10 flex items-center justify-center text-gray-400"
>
...
</span>
);
}
return null;
})}
</div>
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={!pagination.hasNextPage}
className={`
px-4 py-2 rounded-lg transition-all duration-200
${
pagination.hasNextPage
? "hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
: "opacity-50 cursor-not-allowed text-gray-400 dark:text-gray-600"
}
`}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</button>
</div>
</motion.div>
)}
{courses.length > 3 && (
<button
onClick={() =>
window.scrollTo({ top: 0, behavior: "smooth" })
}
className="fixed bottom-8 right-8 bg-gradient-to-r from-blue-500 to-purple-600 text-white p-3 rounded-full shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-110 z-50"
aria-label={t("backToTop.aria")}
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 10l7-7m0 0l7 7m-7-7v18"
/>
</svg>
</button>
)}
</>
)}
</div>
</div>
</LocalePageShell>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,428 +1,428 @@
"use client";
import React, { useState, useEffect } from "react";
import { ThemeProvider, useTheme } from "next-themes";
import { Skeleton } from "@/components/ui/skeleton";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { motion } from "framer-motion";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import useAxios from "@/hooks/useAxios";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { useAppLanguage } from "@/contexts/LanguageProvider";
import EditPageHeader from "@/components/settings/EditPageHeader";
interface Payment {
_id: string;
price: number;
course_name: string;
course_id: any;
academy_id: string;
status: string;
createdAt: string;
updatedAt: string;
payment_authority: string;
payment_ref_id: string;
discountAmount: number;
taxAmount: number;
taxRate: number;
user_id: {
_id: string;
user_name: string;
profile_image: string;
};
}
interface Transaction {
id: string;
type: "deposit" | "withdraw" | "expense";
currency: string;
amount: number;
date: string;
description: string;
status: string;
refId: string;
}
const WalletDashboard = () => {
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [filterType, setFilterType] = useState<"all" | "deposit" | "withdraw" | "expense">("all");
const [totalBalance, setTotalBalance] = useState(0);
const [totalSettled, setTotalSettled] = useState(0);
const [totalPending, setTotalPending] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const { request } = useAxios();
const { t } = useTranslation("common");
const { language } = useAppLanguage();
const dateLocale = language === "fa" ? "fa-IR" : "en-US";
// دریافت پرداخت‌های موفق (فروش‌های انجام شده)
const fetchSuccessfulPayments = async () => {
try {
const response = await request(
"GET",
`/academy/course/getAllAcademyPayments?status=success`
);
if (response?.data?.payments && Array.isArray(response.data.payments)) {
const payments: Payment[] = response.data.payments;
// محاسبه مجموع فروش
const total = payments.reduce((sum, p) => sum + p.taxAmount, 0);
setTotalPending(total);
// تبدیل به تراکنش‌های فروش ( expense type )
const salesTransactions: Transaction[] = payments.map((payment) => ({
id: payment._id,
type: "expense",
currency: t("settings.toman"),
amount: payment.taxAmount,
date: new Date(payment.createdAt).toLocaleDateString(dateLocale),
description: payment.course_name,
status: "pending",
refId: payment.payment_ref_id,
}));
return salesTransactions;
}
return [];
} catch (err) {
console.error("error fetching successful payments:", err);
toast.error(t("settings.academy.wallet.fetchSalesError"));
return [];
}
};
// دریافت پرداخت‌های تسویه شده
const fetchSettledPayments = async () => {
try {
const response = await request(
"GET",
`/academy/course/getAllAcademyPayments?status=settled`
);
if (response?.data?.payments && Array.isArray(response.data.payments)) {
const payments: Payment[] = response.data.payments;
// محاسبه مجموع تسویه شده
const total = payments.reduce((sum, p) => sum + p.taxAmount, 0);
setTotalSettled(total);
// تبدیل به تراکنش‌های پرداخت شده ( withdraw type )
const settledTransactions: Transaction[] = payments.map((payment) => ({
id: payment._id,
type: "withdraw",
currency: t("settings.toman"),
amount: payment.taxAmount,
date: new Date(payment.updatedAt).toLocaleDateString(dateLocale),
description: t("settings.academy.wallet.settlementDescription", {
course: payment.course_name,
}),
status: "settled",
refId: payment.payment_ref_id,
}));
return settledTransactions;
}
return [];
} catch (err) {
console.error("error fetching settled payments:", err);
toast.error(t("settings.academy.wallet.fetchSettledError"));
return [];
}
};
// محاسبه موجودی کل (فروش - تسویه شده)
const calculateBalance = (sales: number, settled: number) => {
return sales - settled;
};
// بارگذاری تمام داده‌ها
const fetchAllData = async () => {
setIsLoading(true);
try {
const [salesTransactions, settledTransactions] = await Promise.all([
fetchSuccessfulPayments(),
fetchSettledPayments(),
]);
// ترکیب تراکنش‌ها
const allTransactions = [...salesTransactions, ...settledTransactions];
// مرتب‌سازی بر اساس تاریخ (جدیدترین اول)
allTransactions.sort((a, b) => {
const dateA = new Date(a.date.split("/").reverse().join("/"));
const dateB = new Date(b.date.split("/").reverse().join("/"));
return dateB.getTime() - dateA.getTime();
});
setTransactions(allTransactions);
// محاسبه موجودی
const balance = calculateBalance(totalPending, totalSettled);
setTotalBalance(balance);
} catch (err) {
console.error("error fetching all data:", err);
toast.error(t("settings.academy.wallet.fetchError"));
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchAllData();
}, []);
// فیلتر تراکنش‌ها
const filteredTransactions = transactions.filter(
(t) => filterType === "all" || t.type === filterType
);
// آمار کلی
const stats = {
sales: transactions.filter(t => t.type === "expense").reduce((sum, t) => sum + t.amount, 0),
settled: transactions.filter(t => t.type === "withdraw").reduce((sum, t) => sum + t.amount, 0),
pending: transactions.filter(t => t.type === "expense" && t.status === "pending").reduce((sum, t) => sum + t.amount, 0),
};
if (isLoading) {
return (
<div className="container mx-auto space-y-4 px-4 py-6 lg:px-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Skeleton className="h-28 w-full rounded-xl" />
<Skeleton className="h-28 w-full rounded-xl" />
<Skeleton className="h-28 w-full rounded-xl" />
</div>
<Skeleton className="h-64 w-full rounded-xl" />
</div>
);
}
return (
<LocalePageShell>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<div className="min-h-dvh text-foreground">
<div className="container mx-auto space-y-8 px-4 py-6 lg:px-6">
<EditPageHeader title={t("settings.nav.wallet")} className="my-0" />
{/* کارت‌های آمار - 3 کارت */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* کل فروش */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
>
<Card className="text-white">
<CardHeader>
<CardTitle className="text-lg">{t("settings.academy.wallet.totalSales")}</CardTitle>
<CardDescription className="dark:text-green-100 text-green-500">
{t("settings.academy.wallet.totalSalesDesc")}
</CardDescription>
</CardHeader>
<CardContent >
<div className="text-3xl font-bold dark:text-white text-neutral-700">
{(stats.sales + stats.settled).toLocaleString(dateLocale)} {t("settings.toman")}
</div>
</CardContent>
</Card>
</motion.div>
{/* تسویه شده */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<Card className="text-white">
<CardHeader>
<CardTitle className="text-lg">{t("settings.academy.wallet.settled")}</CardTitle>
<CardDescription className="dark:text-purple-100 text-purple-500">
{t("settings.academy.wallet.settledDesc")}
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold dark:text-white text-neutral-700">
{stats.settled.toLocaleString(dateLocale)} {t("settings.toman")}
</div>
</CardContent>
</Card>
</motion.div>
</div>
{/* جداول تراکنش‌ها */}
<Card>
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-4">
<CardTitle>{t("settings.academy.wallet.transactionsReport")}</CardTitle>
<div className="flex gap-2">
<Select
value={filterType}
onValueChange={(value) =>
setFilterType(value as "all" | "deposit" | "withdraw" | "expense")
}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder={t("settings.academy.wallet.filterByType")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("settings.academy.wallet.all")}</SelectItem>
<SelectItem value="expense">{t("settings.academy.wallet.sales")}</SelectItem>
<SelectItem value="withdraw">{t("settings.academy.wallet.paidOut")}</SelectItem>
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent>
{filteredTransactions.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t("settings.academy.wallet.noTransactions")}
</div>
) : (
<div className="w-full">
{/* نمای دسکتاپ - جدول */}
<div className="hidden md:block overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="text-right">{t("settings.academy.wallet.description")}</TableHead>
<TableHead className="text-right">{t("settings.academy.wallet.amount")}</TableHead>
<TableHead className="text-right">{t("settings.academy.wallet.date")}</TableHead>
<TableHead className="text-right">{t("settings.status")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredTransactions.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8">
{t("settings.academy.wallet.noTransactions")}
</TableCell>
</TableRow>
) : (
filteredTransactions.map((transaction) => (
<TableRow key={transaction.id} className="border-b">
<TableCell className="font-medium text-right">
{transaction.description}
</TableCell>
<TableCell className="text-right">
<span
className={`font-bold ${
transaction.type === "expense"
? "text-green-600"
: "text-red-600"
}`}
>
{transaction.type === "expense" ? "+" : "-"}
{transaction.amount.toLocaleString()}
</span>
</TableCell>
<TableCell className="text-right">{transaction.date}</TableCell>
<TableCell className="text-right">
<Badge
variant="outline"
className={`${
transaction.type === "expense"
? "bg-yellow-500/10 text-yellow-600 border-yellow-500/30"
: "bg-green-500/10 text-green-600 border-green-500/30"
}`}
>
{transaction.type === "expense"
? t("settings.academy.wallet.pendingSettlement")
: t("settings.academy.wallet.settlementDone")}
</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* نمای موبایل - کارت‌ها */}
<div className="md:hidden space-y-3">
{filteredTransactions.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t("settings.academy.wallet.noTransactions")}
</div>
) : (
filteredTransactions.map((transaction) => (
<Card key={transaction.id} className="p-4">
<div className="flex justify-between items-start mb-2">
<div className="flex-1">
<p className="font-medium text-sm">{transaction.description}</p>
<p className="text-xs text-muted-foreground mt-1">
{transaction.date}
</p>
</div>
<div className="text-right">
<p
className={`font-bold text-lg ${
transaction.type === "expense"
? "text-green-600"
: "text-red-600"
}`}
>
{transaction.type === "expense" ? "+" : "-"}
{transaction.amount.toLocaleString()}
</p>
<Badge
variant="outline"
className={`mt-1 text-xs ${
transaction.type === "expense"
? "bg-yellow-500/10 text-yellow-600"
: "bg-green-500/10 text-green-600"
}`}
>
{transaction.type === "expense"
? t("settings.academy.wallet.pendingSettlement")
: t("settings.academy.wallet.settlementDone")}
</Badge>
</div>
</div>
</Card>
))
)}
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</ThemeProvider>
</LocalePageShell>
);
};
"use client";
import React, { useState, useEffect } from "react";
import { ThemeProvider, useTheme } from "next-themes";
import { Skeleton } from "@/components/ui/skeleton";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { motion } from "framer-motion";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import useAxios from "@/hooks/useAxios";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { useAppLanguage } from "@/contexts/LanguageProvider";
import EditPageHeader from "@/components/settings/EditPageHeader";
interface Payment {
_id: string;
price: number;
course_name: string;
course_id: any;
academy_id: string;
status: string;
createdAt: string;
updatedAt: string;
payment_authority: string;
payment_ref_id: string;
discountAmount: number;
taxAmount: number;
taxRate: number;
user_id: {
_id: string;
user_name: string;
profile_image: string;
};
}
interface Transaction {
id: string;
type: "deposit" | "withdraw" | "expense";
currency: string;
amount: number;
date: string;
description: string;
status: string;
refId: string;
}
const WalletDashboard = () => {
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [filterType, setFilterType] = useState<"all" | "deposit" | "withdraw" | "expense">("all");
const [totalBalance, setTotalBalance] = useState(0);
const [totalSettled, setTotalSettled] = useState(0);
const [totalPending, setTotalPending] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const { request } = useAxios();
const { t } = useTranslation("common");
const { language } = useAppLanguage();
const dateLocale = language === "fa" ? "fa-IR" : "en-US";
// دریافت پرداخت‌های موفق (فروش‌های انجام شده)
const fetchSuccessfulPayments = async () => {
try {
const response = await request(
"GET",
`/academy/course/getAllAcademyPayments?status=success`
);
if (response?.data?.payments && Array.isArray(response.data.payments)) {
const payments: Payment[] = response.data.payments;
// محاسبه مجموع فروش
const total = payments.reduce((sum, p) => sum + p.taxAmount, 0);
setTotalPending(total);
// تبدیل به تراکنش‌های فروش ( expense type )
const salesTransactions: Transaction[] = payments.map((payment) => ({
id: payment._id,
type: "expense",
currency: t("settings.toman"),
amount: payment.taxAmount,
date: new Date(payment.createdAt).toLocaleDateString(dateLocale),
description: payment.course_name,
status: "pending",
refId: payment.payment_ref_id,
}));
return salesTransactions;
}
return [];
} catch (err) {
console.error("error fetching successful payments:", err);
toast.error(t("settings.academy.wallet.fetchSalesError"));
return [];
}
};
// دریافت پرداخت‌های تسویه شده
const fetchSettledPayments = async () => {
try {
const response = await request(
"GET",
`/academy/course/getAllAcademyPayments?status=settled`
);
if (response?.data?.payments && Array.isArray(response.data.payments)) {
const payments: Payment[] = response.data.payments;
// محاسبه مجموع تسویه شده
const total = payments.reduce((sum, p) => sum + p.taxAmount, 0);
setTotalSettled(total);
// تبدیل به تراکنش‌های پرداخت شده ( withdraw type )
const settledTransactions: Transaction[] = payments.map((payment) => ({
id: payment._id,
type: "withdraw",
currency: t("settings.toman"),
amount: payment.taxAmount,
date: new Date(payment.updatedAt).toLocaleDateString(dateLocale),
description: t("settings.academy.wallet.settlementDescription", {
course: payment.course_name,
}),
status: "settled",
refId: payment.payment_ref_id,
}));
return settledTransactions;
}
return [];
} catch (err) {
console.error("error fetching settled payments:", err);
toast.error(t("settings.academy.wallet.fetchSettledError"));
return [];
}
};
// محاسبه موجودی کل (فروش - تسویه شده)
const calculateBalance = (sales: number, settled: number) => {
return sales - settled;
};
// بارگذاری تمام داده‌ها
const fetchAllData = async () => {
setIsLoading(true);
try {
const [salesTransactions, settledTransactions] = await Promise.all([
fetchSuccessfulPayments(),
fetchSettledPayments(),
]);
// ترکیب تراکنش‌ها
const allTransactions = [...salesTransactions, ...settledTransactions];
// مرتب‌سازی بر اساس تاریخ (جدیدترین اول)
allTransactions.sort((a, b) => {
const dateA = new Date(a.date.split("/").reverse().join("/"));
const dateB = new Date(b.date.split("/").reverse().join("/"));
return dateB.getTime() - dateA.getTime();
});
setTransactions(allTransactions);
// محاسبه موجودی
const balance = calculateBalance(totalPending, totalSettled);
setTotalBalance(balance);
} catch (err) {
console.error("error fetching all data:", err);
toast.error(t("settings.academy.wallet.fetchError"));
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchAllData();
}, []);
// فیلتر تراکنش‌ها
const filteredTransactions = transactions.filter(
(t) => filterType === "all" || t.type === filterType
);
// آمار کلی
const stats = {
sales: transactions.filter(t => t.type === "expense").reduce((sum, t) => sum + t.amount, 0),
settled: transactions.filter(t => t.type === "withdraw").reduce((sum, t) => sum + t.amount, 0),
pending: transactions.filter(t => t.type === "expense" && t.status === "pending").reduce((sum, t) => sum + t.amount, 0),
};
if (isLoading) {
return (
<div className="container mx-auto space-y-4 px-4 py-6 lg:px-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Skeleton className="h-28 w-full rounded-2xl" />
<Skeleton className="h-28 w-full rounded-2xl" />
<Skeleton className="h-28 w-full rounded-2xl" />
</div>
<Skeleton className="h-64 w-full rounded-2xl" />
</div>
);
}
return (
<LocalePageShell>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<div className="min-h-dvh text-foreground">
<div className="container mx-auto space-y-8 px-4 py-6 lg:px-6">
<EditPageHeader title={t("settings.nav.wallet")} className="my-0" />
{/* کارت‌های آمار - 3 کارت */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* کل فروش */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
>
<Card className="text-white">
<CardHeader>
<CardTitle className="text-lg">{t("settings.academy.wallet.totalSales")}</CardTitle>
<CardDescription className="dark:text-green-100 text-green-500">
{t("settings.academy.wallet.totalSalesDesc")}
</CardDescription>
</CardHeader>
<CardContent >
<div className="text-3xl font-bold dark:text-white text-neutral-700">
{(stats.sales + stats.settled).toLocaleString(dateLocale)} {t("settings.toman")}
</div>
</CardContent>
</Card>
</motion.div>
{/* تسویه شده */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<Card className="text-white">
<CardHeader>
<CardTitle className="text-lg">{t("settings.academy.wallet.settled")}</CardTitle>
<CardDescription className="dark:text-purple-100 text-purple-500">
{t("settings.academy.wallet.settledDesc")}
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold dark:text-white text-neutral-700">
{stats.settled.toLocaleString(dateLocale)} {t("settings.toman")}
</div>
</CardContent>
</Card>
</motion.div>
</div>
{/* جداول تراکنش‌ها */}
<Card>
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-4">
<CardTitle>{t("settings.academy.wallet.transactionsReport")}</CardTitle>
<div className="flex gap-2">
<Select
value={filterType}
onValueChange={(value) =>
setFilterType(value as "all" | "deposit" | "withdraw" | "expense")
}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder={t("settings.academy.wallet.filterByType")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("settings.academy.wallet.all")}</SelectItem>
<SelectItem value="expense">{t("settings.academy.wallet.sales")}</SelectItem>
<SelectItem value="withdraw">{t("settings.academy.wallet.paidOut")}</SelectItem>
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent>
{filteredTransactions.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t("settings.academy.wallet.noTransactions")}
</div>
) : (
<div className="w-full">
{/* نمای دسکتاپ - جدول */}
<div className="hidden md:block overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="text-right">{t("settings.academy.wallet.description")}</TableHead>
<TableHead className="text-right">{t("settings.academy.wallet.amount")}</TableHead>
<TableHead className="text-right">{t("settings.academy.wallet.date")}</TableHead>
<TableHead className="text-right">{t("settings.status")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredTransactions.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8">
{t("settings.academy.wallet.noTransactions")}
</TableCell>
</TableRow>
) : (
filteredTransactions.map((transaction) => (
<TableRow key={transaction.id} className="border-b">
<TableCell className="font-medium text-right">
{transaction.description}
</TableCell>
<TableCell className="text-right">
<span
className={`font-bold ${
transaction.type === "expense"
? "text-green-600"
: "text-red-600"
}`}
>
{transaction.type === "expense" ? "+" : "-"}
{transaction.amount.toLocaleString()}
</span>
</TableCell>
<TableCell className="text-right">{transaction.date}</TableCell>
<TableCell className="text-right">
<Badge
variant="outline"
className={`${
transaction.type === "expense"
? "bg-yellow-500/10 text-yellow-600 border-yellow-500/30"
: "bg-green-500/10 text-green-600 border-green-500/30"
}`}
>
{transaction.type === "expense"
? t("settings.academy.wallet.pendingSettlement")
: t("settings.academy.wallet.settlementDone")}
</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* نمای موبایل - کارت‌ها */}
<div className="md:hidden space-y-3">
{filteredTransactions.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{t("settings.academy.wallet.noTransactions")}
</div>
) : (
filteredTransactions.map((transaction) => (
<Card key={transaction.id} className="p-4">
<div className="flex justify-between items-start mb-2">
<div className="flex-1">
<p className="font-medium text-sm">{transaction.description}</p>
<p className="text-xs text-muted-foreground mt-1">
{transaction.date}
</p>
</div>
<div className="text-right">
<p
className={`font-bold text-lg ${
transaction.type === "expense"
? "text-green-600"
: "text-red-600"
}`}
>
{transaction.type === "expense" ? "+" : "-"}
{transaction.amount.toLocaleString()}
</p>
<Badge
variant="outline"
className={`mt-1 text-xs ${
transaction.type === "expense"
? "bg-yellow-500/10 text-yellow-600"
: "bg-green-500/10 text-green-600"
}`}
>
{transaction.type === "expense"
? t("settings.academy.wallet.pendingSettlement")
: t("settings.academy.wallet.settlementDone")}
</Badge>
</div>
</div>
</Card>
))
)}
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</ThemeProvider>
</LocalePageShell>
);
};
export default WalletDashboard;

View File

@@ -234,7 +234,7 @@ function BookingSchedulePage() {
value={weeklyRepeatCount}
onChange={(e) => setWeeklyRepeatCount(e.target.value)}
placeholder={t("booking.weeklyRepeatCountPlaceholder")}
className="mt-2 w-24 rounded-xl border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-50"
className="mt-2 w-24 rounded-2xl border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-50"
/>
</div>
@@ -247,12 +247,12 @@ function BookingSchedulePage() {
type="date"
value={newHoliday}
onChange={(e) => setNewHoliday(e.target.value)}
className="flex-1 rounded-xl border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-50"
className="flex-1 rounded-2xl border border-neutral-300 bg-white px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-50"
/>
<button
type="button"
onClick={addHoliday}
className="rounded-xl bg-neutral-100 px-3 py-2 text-sm font-semibold dark:bg-neutral-800"
className="rounded-2xl bg-neutral-100 px-3 py-2 text-sm font-semibold dark:bg-neutral-800"
>
{t("common.add")}
</button>

View File

@@ -728,6 +728,7 @@ function TicketChat({ params }: ITicketChatProps) {
viewOnceMedia={viewOnceMedia}
onViewOnceChange={setViewOnceMedia}
onGiftClick={() => setShowGiftModal(true)}
giftDisabled={userTwoDetail?.gift_enabled === false}
/>
)}
</AnimatePresence>

View File

@@ -244,7 +244,7 @@ function RoomMessageRow({
: buildStorageUrl(msg.file)
}
alt=""
className="mb-1 max-h-64 w-full rounded-xl object-cover"
className="mb-1 max-h-64 w-full rounded-2xl object-cover"
/>
) : null}
{msg.file && msg.fileType === "video" ? (
@@ -256,7 +256,7 @@ function RoomMessageRow({
}
controls
playsInline
className="mb-1 max-h-64 w-full rounded-xl"
className="mb-1 max-h-64 w-full rounded-2xl"
/>
) : null}
{msg.file && msg.fileType === "voice" ? (

View File

@@ -16,6 +16,7 @@ import Cookies from "js-cookie";
import axios from "axios";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import EditPageHeader from "@/components/settings/EditPageHeader";
import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons";
import { useTranslation } from "react-i18next";
function LicensePage() {
@@ -27,6 +28,7 @@ function LicensePage() {
const [avatar, setAvatar] = useState<string | null>(null);
const [license, setLicense] = useState<any>(null);
const [loadingUpload, setLoadingUpload] = useState(false);
const [loadingLicense, setLoadingLicense] = useState(true);
// دریافت اطلاعات License هنگام لود صفحه
useEffect(() => {
@@ -37,6 +39,7 @@ function LicensePage() {
const fetchLicense = async () => {
setLoadingLicense(true);
try {
// گرفتن توکن از کوکی
const token = Cookies.get("token");
@@ -57,9 +60,11 @@ function LicensePage() {
setAvatar(response.data.licenseImg);
}
} catch (error) {
console.error(error);
} finally {
setLoadingLicense(false);
}
};
@@ -184,6 +189,10 @@ function LicensePage() {
<div className="p-4 flex flex-col items-center justify-center address-page">
<EditPageHeader title={t("settings.edit.license.title")} className="mb-9" />
{(!user || loadingLicense) ? (
<EditFormSkeleton fields={2} />
) : (
<>
<div className="flex flex-col justify-center items-center">
<ProfileAvatar
src={user?.profile_image}
@@ -282,6 +291,8 @@ function LicensePage() {
<small className="font-bold mt-10 mb-1 text-center">
{t("settings.edit.license.reviewTime")}
</small>
</>
)}
</div>
</Container>
</LocalePageShell>

View File

@@ -5,6 +5,7 @@ import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import { SettingsListSkeleton } from "@/components/settings/EditPageSkeletons";
import Link from "next/link";
import { useTranslation } from "react-i18next";
@@ -38,14 +39,7 @@ function AcademyCommentsPage() {
<PageTitle>{t("settings.activity.items.academyComments")}</PageTitle>
<div className="my-6">
{loading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="h-14 animate-pulse rounded-xl bg-neutral-200 dark:bg-neutral-800"
/>
))}
</div>
<SettingsListSkeleton count={5} />
) : items.length === 0 ? (
<p className="py-10 text-center text-sm text-neutral-500">
{t("models.nothingFound")}
@@ -58,7 +52,7 @@ function AcademyCommentsPage() {
<li key={item._id}>
<Link
href={`/explore/${item.course_id?._id}`}
className="block rounded-xl px-2 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-800"
className="block rounded-2xl px-2 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-800"
>
<p className="truncate text-sm font-bold">
{item.course_id?.cuorse_name}

View File

@@ -5,6 +5,7 @@ import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import { SettingsListSkeleton } from "@/components/settings/EditPageSkeletons";
import Link from "next/link";
import { useTranslation } from "react-i18next";
@@ -37,14 +38,7 @@ function AcademyLikesPage() {
<PageTitle>{t("settings.activity.items.academyLikes")}</PageTitle>
<div className="my-6">
{loading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="h-12 animate-pulse rounded-xl bg-neutral-200 dark:bg-neutral-800"
/>
))}
</div>
<SettingsListSkeleton count={5} />
) : items.length === 0 ? (
<p className="py-10 text-center text-sm text-neutral-500">
{t("models.nothingFound")}
@@ -57,7 +51,7 @@ function AcademyLikesPage() {
<li key={item._id}>
<Link
href={`/explore/${item.course_id?._id}`}
className="flex items-center justify-between rounded-xl px-2 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-800"
className="flex items-center justify-between rounded-2xl px-2 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-800"
>
<span className="truncate text-sm font-bold">
{item.course_id?.cuorse_name}

View File

@@ -5,6 +5,7 @@ import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import { SettingsListSkeleton } from "@/components/settings/EditPageSkeletons";
import Link from "next/link";
import { useTranslation } from "react-i18next";
@@ -38,14 +39,7 @@ function BillboardCommentsPage() {
<PageTitle>{t("settings.activity.items.billboardComments")}</PageTitle>
<div className="my-6">
{loading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="h-14 animate-pulse rounded-xl bg-neutral-200 dark:bg-neutral-800"
/>
))}
</div>
<SettingsListSkeleton count={5} />
) : items.length === 0 ? (
<p className="py-10 text-center text-sm text-neutral-500">
{t("models.nothingFound")}
@@ -58,7 +52,7 @@ function BillboardCommentsPage() {
<li key={item._id}>
<Link
href={`/billboards/${item.advertisingId?._id}/x`}
className="block rounded-xl px-2 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-800"
className="block rounded-2xl px-2 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-800"
>
<p className="truncate text-sm font-bold">
{item.advertisingId?.title}

View File

@@ -5,6 +5,7 @@ import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import useAxios from "@/hooks/useAxios";
import { SettingsListSkeleton } from "@/components/settings/EditPageSkeletons";
import Link from "next/link";
import { useTranslation } from "react-i18next";
@@ -37,14 +38,7 @@ function BillboardLikesPage() {
<PageTitle>{t("settings.activity.items.billboardLikes")}</PageTitle>
<div className="my-6">
{loading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="h-12 animate-pulse rounded-xl bg-neutral-200 dark:bg-neutral-800"
/>
))}
</div>
<SettingsListSkeleton count={5} />
) : items.length === 0 ? (
<p className="py-10 text-center text-sm text-neutral-500">
{t("models.nothingFound")}
@@ -57,7 +51,7 @@ function BillboardLikesPage() {
<li key={item._id}>
<Link
href={`/billboards/${item.advertisingId?._id}/x`}
className="flex items-center justify-between rounded-xl px-2 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-800"
className="flex items-center justify-between rounded-2xl px-2 py-3 hover:bg-neutral-100 dark:hover:bg-neutral-800"
>
<span className="truncate text-sm font-bold">
{item.advertisingId?.title}

View File

@@ -3,6 +3,7 @@
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import { useParams } from "next/navigation";
@@ -79,9 +80,8 @@ export default function PostAnalyticsDetailPage() {
return (
<LocalePageShell>
<Container>
<p className="py-16 text-center text-sm text-neutral-500">
{t("common.loading")}
</p>
<PageTitle>{t("settings.edit.postsAnalytics.detailTitle")}</PageTitle>
<EditFormSkeleton fields={3} />
</Container>
</LocalePageShell>
);
@@ -91,6 +91,7 @@ export default function PostAnalyticsDetailPage() {
return (
<LocalePageShell>
<Container>
<PageTitle>{t("settings.edit.postsAnalytics.detailTitle")}</PageTitle>
<p className="py-16 text-center text-sm text-neutral-500">
{t("settings.edit.postsAnalytics.notFound")}
</p>

View File

@@ -4,6 +4,7 @@ import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import AccountAnalyticsWidget from "@/components/settings/AccountAnalyticsWidget";
import { SettingsListSkeleton } from "@/components/settings/EditPageSkeletons";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import Link from "next/link";
@@ -50,9 +51,7 @@ export default function PostsAnalyticsPage() {
</h3>
{posts === null ? (
<p className="py-8 text-center text-sm text-neutral-500">
{t("common.loading")}
</p>
<SettingsListSkeleton count={6} />
) : posts.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-500">
{t("settings.edit.postsAnalytics.empty")}
@@ -65,7 +64,7 @@ export default function PostsAnalyticsPage() {
href={`/settings/edit/analytics/posts/${post._id}`}
className="flex items-center gap-3 rounded-2xl border border-neutral-200 p-2 dark:border-neutral-700"
>
<span className="h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-neutral-100 dark:bg-neutral-800">
<span className="h-14 w-14 shrink-0 overflow-hidden rounded-2xl bg-neutral-100 dark:bg-neutral-800">
{post.files?.[0] && (
// eslint-disable-next-line @next/next/no-img-element
<img

View File

@@ -9,6 +9,7 @@ import CountryProvinceCitySelect, {
findCountry,
} from "@/components/ui/CountryProvinceCitySelect";
import useAxios from "@/hooks/useAxios";
import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -29,6 +30,7 @@ export default function RegionPage() {
provinceId: null,
cityId: null,
});
const [pageLoading, setPageLoading] = useState(true);
useEffect(() => {
request<ProfileResponse>("GET", "/profile", null, { noToast: true })
@@ -42,7 +44,8 @@ export default function RegionPage() {
});
}
})
.catch(() => {});
.catch(() => {})
.finally(() => setPageLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -70,6 +73,10 @@ export default function RegionPage() {
{t("settings.edit.region.hint")}
</p>
{pageLoading ? (
<EditFormSkeleton fields={1} />
) : (
<>
<CountryProvinceCitySelect value={selection} onChange={setSelection} />
{selectedCountry && (
@@ -90,6 +97,8 @@ export default function RegionPage() {
{t("settings.edit.save")}
</AuthNextButton>
</div>
</>
)}
</div>
</Container>
</LocalePageShell>

View File

@@ -5,6 +5,7 @@ import Container from "@/components/elements/Container";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import EditPageHeader from "@/components/settings/EditPageHeader";
import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons";
import useAxios from "@/hooks/useAxios";
import { useUser } from "@/hooks/useUser";
import Image from "next/image";
@@ -70,6 +71,10 @@ function AvatarPage() {
<Container>
<div className="p-4 flex flex-col items-center h-dvh justify-center">
<EditPageHeader title={t("settings.edit.nav.avatar")} />
{!user ? (
<EditFormSkeleton fields={1} />
) : (
<>
<div className="flex flex-col items-center mb-4">
<div className="relative aspect-square w-[250px] flex items-center justify-center text-white rounded-3xl border border-[#676767] overflow-hidden mt-10">
{avatar ? (
@@ -107,6 +112,8 @@ function AvatarPage() {
>
{t("settings.edit.save")}
</AuthNextButton>
</>
)}
</div>
</Container>
</LocalePageShell>

View File

@@ -10,6 +10,7 @@ import * as yup from "yup";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import EditPageHeader from "@/components/settings/EditPageHeader";
import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons";
import toast from "react-hot-toast";
import { useUser } from "@/hooks/useUser";
import { useTranslation } from "react-i18next";
@@ -60,6 +61,9 @@ function BioPage() {
<div className="p-4 flex flex-col items-center h-dvh justify-center">
<EditPageHeader title={t("settings.edit.nav.bio")} />
<div className="mt-5"></div>
{!user ? (
<EditFormSkeleton fields={1} />
) : (
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
@@ -90,6 +94,7 @@ function BioPage() {
{t("settings.edit.save")}
</AuthNextButton>
</form>
)}
</div>
</Container>
</LocalePageShell>

View File

@@ -5,6 +5,7 @@ import Container from "@/components/elements/Container";
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 Link from "next/link";
@@ -39,20 +40,7 @@ function BlockedUsersPage() {
<PageTitle>{t("settings.edit.nav.blockedUsers")}</PageTitle>
<div className="my-6">
{loading ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="flex animate-pulse items-center gap-3 rounded-xl px-2 py-2"
>
<div className="h-11 w-11 shrink-0 rounded-full bg-neutral-200 dark:bg-neutral-800" />
<div className="flex-1 space-y-2">
<div className="h-3 w-1/3 rounded bg-neutral-200 dark:bg-neutral-800" />
<div className="h-3 w-1/4 rounded bg-neutral-200 dark:bg-neutral-800" />
</div>
</div>
))}
</div>
<SettingsListSkeleton count={5} />
) : users.length === 0 ? (
<p className="py-10 text-center text-sm text-neutral-500">
{t("models.nothingFound")}
@@ -67,7 +55,7 @@ function BlockedUsersPage() {
<li key={u._id}>
<Link
href={`/users/${u.user_name}`}
className="flex items-center gap-3 rounded-xl px-2 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-800"
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

View File

@@ -8,6 +8,7 @@ import * as yup from "yup";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import EditPageHeader from "@/components/settings/EditPageHeader";
import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons";
import toast from "react-hot-toast";
import Image from "next/image";
import { useUser } from "@/hooks/useUser";
@@ -95,6 +96,10 @@ function Colors() {
<EditPageHeader title={t("settings.edit.nav.colors")} />
<p className="mt-8 text-center text-sm font-bold">{t("settings.edit.sizes.appearance")}</p>
{!user ? (
<EditFormSkeleton fields={2} />
) : (
<>
{/* Buttons for categories */}
<div className="flex justify-center gap-2 mt-4 w-full max-w-[320px]">
<button
@@ -170,6 +175,8 @@ function Colors() {
>
{t("settings.edit.save")}
</AuthNextButton>
</>
)}
</div>
</Container>
</LocalePageShell>

View File

@@ -10,6 +10,7 @@ import * as yup from "yup";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import EditPageHeader from "@/components/settings/EditPageHeader";
import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons";
import toast from "react-hot-toast";
import { useUser } from "@/hooks/useUser";
import { useTranslation } from "react-i18next";
@@ -58,6 +59,9 @@ function CooperationType() {
<p className="my-10 text-sm font-bold">
{t("settings.edit.cooperationType.question")}
</p>
{!user ? (
<EditFormSkeleton fields={2} />
) : (
<form onSubmit={formik.handleSubmit} className="w-full max-w-sm flex flex-col items-center">
<div className="flex w-full gap-2">
<AuthNextButton
@@ -86,6 +90,7 @@ function CooperationType() {
{t("settings.edit.save")}
</AuthNextButton>
</form>
)}
</div>
</Container>
</LocalePageShell>

View File

@@ -17,6 +17,7 @@ import {
} from "@/lib/subExpertiseDisplay";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import EditPageHeader from "@/components/settings/EditPageHeader";
import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons";
import { useTranslation } from "react-i18next";
function Expertise() {
@@ -42,6 +43,7 @@ function Expertise() {
const [displaySubExpertise, setDisplaySubExpertise] = useState<string | null>(
null
);
const [loadingList, setLoadingList] = useState(true);
const user = useUser();
const hydratedFromProfile = useRef(false);
@@ -71,6 +73,8 @@ function Expertise() {
setExpertiseList(response?.expertises ?? []);
} catch (err) {
console.log(err);
} finally {
setLoadingList(false);
}
};
@@ -156,6 +160,10 @@ function Expertise() {
<p className="mt-8 text-center">{t("settings.edit.expertise.question")}</p>
{!user || loadingList ? (
<EditFormSkeleton fields={3} />
) : (
<>
<ExpertisePicker
expertiseList={expertiseList}
expertise={expertise}
@@ -179,6 +187,8 @@ function Expertise() {
>
{t("settings.edit.save")}
</AuthNextButton>
</>
)}
</div>
</Container>
</LocalePageShell>

View File

@@ -4,6 +4,7 @@ import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { GoogleSignInButton } from "@/app/(auth)/AuthProviders";
import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons";
import useAxios from "@/hooks/useAxios";
import React, { useCallback, useEffect, useState } from "react";
import toast from "react-hot-toast";
@@ -43,7 +44,7 @@ export default function GoogleAccountSettingsPage() {
<PageTitle>{t("settings.edit.nav.googleAccount")}</PageTitle>
<div className="mx-auto flex max-w-md flex-col items-center py-8 text-center">
{loading ? (
<p className="text-sm text-neutral-500">{t("common.loading")}</p>
<EditFormSkeleton fields={1} />
) : status?.linked ? (
<>
<p className="text-sm text-neutral-600 dark:text-neutral-300">

View File

@@ -4,6 +4,7 @@ import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import InstagramSignInButton from "@/components/auth/InstagramSignInButton";
import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons";
import useAxios from "@/hooks/useAxios";
import Link from "next/link";
import React, { useCallback, useEffect, useState } from "react";
@@ -110,7 +111,7 @@ export default function InstagramImportSettingsPage() {
<PageTitle>{t("settings.edit.nav.instagramImport")}</PageTitle>
<div className="mx-auto flex max-w-md flex-col items-center py-8 text-center">
{loading ? (
<p className="text-sm text-neutral-500">{t("common.loading")}</p>
<EditFormSkeleton fields={1} />
) : status?.connected ? (
<>
<p className="text-sm text-neutral-600 dark:text-neutral-300">

View File

@@ -3,6 +3,7 @@
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons";
import useAxios from "@/hooks/useAxios";
import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
@@ -88,9 +89,7 @@ export default function InstagramImportSelectPage() {
<PageTitle>{t("settings.edit.instagramImport.selectPageTitle")}</PageTitle>
{loading ? (
<p className="py-8 text-center text-sm text-neutral-500">
{t("common.loading")}
</p>
<EditFormSkeleton fields={1} />
) : media.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-500">
{t("settings.edit.instagramImport.noMediaFound")}

View File

@@ -3,6 +3,7 @@
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons";
import { getStoredUserId, isAuthenticated } from "@/lib/auth/session";
import { ensureIdentityKeys } from "@/lib/e2ee/keys";
import {
@@ -66,9 +67,7 @@ export default function ApproveLinkDevicePage() {
<Container>
<PageTitle>{t("settings.edit.linkDevice.approveTitle")}</PageTitle>
<div className="mx-auto flex max-w-md flex-col items-center py-8 text-center">
{phase === "loading" && (
<p className="text-sm text-neutral-500">{t("common.loading")}</p>
)}
{phase === "loading" && <EditFormSkeleton fields={1} />}
{phase === "invalid" && (
<p className="text-sm text-red-500">

View File

@@ -3,6 +3,7 @@
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons";
import { getStoredUserId } from "@/lib/auth/session";
import {
startDeviceLinkSession,
@@ -108,9 +109,7 @@ export default function LinkDevicePage() {
<Container>
<PageTitle>{t("settings.edit.nav.linkDevice")}</PageTitle>
<div className="mx-auto flex max-w-md flex-col items-center py-8 text-center">
{phase === "loading" && (
<p className="text-sm text-neutral-500">{t("common.loading")}</p>
)}
{phase === "loading" && <EditFormSkeleton fields={1} />}
{phase === "error" && (
<>

View File

@@ -14,6 +14,7 @@ import CountryProvinceCitySelect, {
countryHasProvinces,
} from "@/components/ui/CountryProvinceCitySelect";
import EditPageHeader from "@/components/settings/EditPageHeader";
import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons";
import Map, { GeolocateControl, Marker } from "react-map-gl";
import "mapbox-gl/dist/mapbox-gl.css";
@@ -137,6 +138,9 @@ function LocationPage() {
<small className="font-bold mt-10 mb-4">
{t("settings.edit.location.question")}
</small>
{!user ? (
<EditFormSkeleton fields={2} />
) : (
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
@@ -206,6 +210,7 @@ function LocationPage() {
{t("settings.edit.save")}
</AuthNextButton>
</form>
)}
</div>
</Container>
</LocalePageShell>

View File

@@ -5,6 +5,7 @@ import LocalePageShell from "@/components/i18n/LocalePageShell";
import RoundedInput from "@/components/elements/RoundedInput";
import AuthNextButton from "@/components/auth/AuthNextButton";
import EditPageHeader from "@/components/settings/EditPageHeader";
import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons";
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
import useAxios from "@/hooks/useAxios";
import { useUser } from "@/hooks/useUser";
@@ -79,6 +80,10 @@ function PersonalDetailsPage() {
{t("settings.edit.personalDetails.hint")}
</p>
{!user ? (
<EditFormSkeleton fields={6} />
) : (
<>
<div className="flex w-full max-w-sm flex-col gap-6">
{/* وضعیت تاهل */}
<div>
@@ -277,6 +282,8 @@ function PersonalDetailsPage() {
>
{t("settings.edit.save")}
</AuthNextButton>
</>
)}
</div>
</Container>
</LocalePageShell>

View File

@@ -10,6 +10,7 @@ import * as yup from "yup";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import EditPageHeader from "@/components/settings/EditPageHeader";
import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons";
import toast from "react-hot-toast";
import { useUser } from "@/hooks/useUser";
import { useTranslation } from "react-i18next";
@@ -61,6 +62,9 @@ function PublicRelations() {
<Container>
<div className="p-4 flex flex-col items-center h-dvh justify-center">
<EditPageHeader title={t("settings.edit.nav.publicRelations")} />
{!user ? (
<EditFormSkeleton fields={1} />
) : (
<form onSubmit={formik.handleSubmit} className="w-full max-w-sm flex flex-col items-center">
<small className="font-bold mt-10">{t("settings.edit.bio.aboutYou")}</small>
<textarea
@@ -89,6 +93,7 @@ function PublicRelations() {
{t("settings.edit.save")}
</AuthNextButton>
</form>
)}
</div>
</Container>
</LocalePageShell>

View File

@@ -9,6 +9,7 @@ import Container from "@/components/elements/Container";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import AuthNextButton from "@/components/auth/AuthNextButton";
import EditPageHeader from "@/components/settings/EditPageHeader";
import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons";
import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import { dataURLtoBlob } from "@/helpers/helpers";
@@ -22,6 +23,7 @@ const ServicesPage: React.FC = () => {
const [services, setServices] = useState<Service[]>([]);
const [isModalOpen, setModalOpen] = useState(false);
const [user, setUser] = useState<User>();
const [loadingUser, setLoadingUser] = useState(true);
const fetchUser = async () => {
try {
@@ -29,6 +31,8 @@ const ServicesPage: React.FC = () => {
setUser(response?.user);
} catch (error) {
console.error("Failed to fetch user:", error);
} finally {
setLoadingUser(false);
}
};
@@ -81,6 +85,10 @@ const ServicesPage: React.FC = () => {
<Container>
<div className="p-4 flex flex-col items-center h-dvh justify-center">
<EditPageHeader title={t("settings.edit.nav.services")} />
{loadingUser ? (
<EditFormSkeleton fields={2} />
) : (
<>
<div className="mt-5 w-full max-w-md">
<AuthUserDetails />
</div>
@@ -113,6 +121,8 @@ const ServicesPage: React.FC = () => {
isModalOpen={isModalOpen}
/>
)}
</>
)}
</div>
</Container>
</LocalePageShell>

View File

@@ -10,6 +10,7 @@ import * as yup from "yup";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import EditPageHeader from "@/components/settings/EditPageHeader";
import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons";
import toast from "react-hot-toast";
import AuthInput from "@/components/auth/AuthInput";
import DatePicker from "react-multi-date-picker";
@@ -131,6 +132,9 @@ function ShabaPage() {
<Container>
<div className="p-4 flex flex-col items-center min-h-dvh justify-center auth-page">
<EditPageHeader title={t("settings.edit.nav.shaba")} />
{!user ? (
<EditFormSkeleton fields={3} />
) : (
<form
onSubmit={formik.handleSubmit}
className="w-full max-w-sm flex flex-col items-center"
@@ -254,6 +258,7 @@ function ShabaPage() {
{t("settings.edit.shaba.saveButton")}
</AuthNextButton>
</form>
)}
</div>
</Container>
</LocalePageShell>

View File

@@ -9,6 +9,7 @@ import * as yup from "yup";
import { useRouter } from "next/navigation";
import AuthNextButton from "@/components/auth/AuthNextButton";
import EditPageHeader from "@/components/settings/EditPageHeader";
import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons";
import toast from "react-hot-toast";
import Image from "next/image";
import { useUser } from "@/hooks/useUser";
@@ -71,6 +72,10 @@ function Sizes() {
<p className="mt-8 text-center text-sm font-bold mb-4">
{t("settings.edit.sizes.appearance")}
</p>
{!user ? (
<EditFormSkeleton fields={2} />
) : (
<>
<div className="flex gap-2 mt-4 w-full max-w-[320px]">
<button
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
@@ -148,6 +153,8 @@ function Sizes() {
<AuthNextButton type="button" onClick={handleCheck} className="mt-10" loading={loading} disabled={loading}>
{t("settings.edit.save")}
</AuthNextButton>
</>
)}
</div>
</Container>
</LocalePageShell>

View File

@@ -5,6 +5,7 @@ import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import AuthOtpInput from "@/components/auth/AuthOtpInput";
import AuthNextButton from "@/components/auth/AuthNextButton";
import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons";
import useAxios from "@/hooks/useAxios";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
@@ -127,7 +128,7 @@ export default function TwoFactorSettingsPage() {
<PageTitle>{t("settings.edit.nav.twoFactor")}</PageTitle>
<div className="mx-auto flex max-w-md flex-col items-center py-8">
{pageLoading ? (
<p className="text-sm text-neutral-500">{t("common.loading")}</p>
<EditFormSkeleton fields={1} />
) : status?.enabled ? (
<>
<p className="mb-6 text-center text-sm text-neutral-600 dark:text-neutral-300">

View File

@@ -0,0 +1,57 @@
"use client";
import Container from "@/components/elements/Container";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import EditPageHeader from "@/components/settings/EditPageHeader";
import RoundedButton from "@/components/elements/RoundedButton";
import VerificationBadge, {
type VerifyBadgeTier,
} from "@/components/main/VerificationBadge";
import Link from "next/link";
import { useTranslation } from "react-i18next";
const TIERS: { key: Exclude<VerifyBadgeTier, "none">; href: string }[] = [
{ key: "gray", href: "/settings/edit/Authentication" },
{ key: "blue", href: "/settings/edit/Authentication" },
{ key: "green", href: "/settings/edit/License" },
{ key: "gold", href: "/settings/tickets/new" },
];
function VerificationGuidePage() {
const { t } = useTranslation("common");
return (
<LocalePageShell>
<Container>
<div className="flex flex-col items-center p-4">
<EditPageHeader title={t("settings.edit.nav.verificationGuide")} />
<div className="mt-6 flex w-full max-w-sm flex-col gap-6 pb-10">
{TIERS.map((tier) => (
<div
key={tier.key}
className="flex flex-col gap-3 rounded-3xl border border-border-primary-light p-4 dark:border-border-primary-dark"
>
<div className="flex items-center gap-2">
<VerificationBadge verifyBadge={tier.key} size={22} />
<span className="font-bold">
{t(`settings.verificationGuide.${tier.key}.name`)}
</span>
</div>
<p className="text-xs leading-6 text-neutral-600 dark:text-neutral-300">
{t(`settings.verificationGuide.${tier.key}.description`)}
</p>
<Link href={tier.href} className="self-center">
<RoundedButton variant="primary" className="h-10 w-full max-w-[200px]">
{t("settings.verificationGuide.buttonLabel")}
</RoundedButton>
</Link>
</div>
))}
</div>
</div>
</Container>
</LocalePageShell>
);
}
export default VerificationGuidePage;

View File

@@ -133,6 +133,10 @@ function Notifications() {
post_comment: `/posts/${item?.project_post_id}`,
post_rating: `/posts/${item?.project_post_id}`,
post_tag: `/posts/${item?.project_post_id}`,
// نوتیف‌های هوشمند (SmartNotificationsCron.js) — همیشه project_post_id یه پست معموله
trending_post_3day: `/posts/${item?.project_post_id}`,
weekly_new_trend: `/posts/${item?.project_post_id}`,
inactivity_reengagement: `/posts/${item?.project_post_id}`,
profile_comment: "/settings/profile",
profile_rating: "/settings/profile",
user_comment: "/settings/profile",

View File

@@ -90,7 +90,7 @@ export default function SelectLanguagePage() {
type="button"
dir={def.direction}
onClick={() => void handleSelect(def.code as AppLanguage)}
className={`flex items-center justify-between rounded-xl border px-4 py-3 text-right transition-colors ${
className={`flex items-center justify-between rounded-2xl border px-4 py-3 text-right transition-colors ${
active
? "border-[#0095f6] bg-[#0095f6]/10"
: "border-neutral-200 dark:border-neutral-800"

View File

@@ -2,6 +2,8 @@
import React, { useEffect, useState } from "react";
import Container from "@/components/elements/Container";
import Modal from "@/components/elements/Modal";
import RoundedButton from "@/components/elements/RoundedButton";
import PageTitle from "@/components/settings/PageTitle";
import LanguageSettingRow from "@/components/settings/LanguageSettingRow";
import LocalePageShell from "@/components/i18n/LocalePageShell";
@@ -11,12 +13,17 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useAppLanguage } from "@/contexts/LanguageProvider";
import { isAppLanguage, type AppLanguage } from "@/lib/i18n/constants";
// زبان‌هایی که آیتم‌های آنالیتیکس/کاربران مسدود/بایگانی/فعالیت شما فقط توی اونا نمایش داده می‌شن
const SETTINGS_ITEMS_VISIBLE_LANGUAGES: AppLanguage[] = ["fa", "ar", "en"];
type PrivacyPatch = {
allow_save_posts?: boolean;
ghost_mode?: boolean;
show_location?: boolean;
post_location_enabled?: boolean;
is_private?: boolean;
gift_enabled?: boolean;
offer_payment_enabled?: boolean;
preferred_language?: AppLanguage;
};
@@ -26,6 +33,8 @@ type PrivacyResponse = {
show_location: boolean;
post_location_enabled: boolean;
is_private: boolean;
gift_enabled: boolean;
offer_payment_enabled: boolean;
preferred_language?: AppLanguage;
};
@@ -38,10 +47,15 @@ export default function UserSettingsPage() {
const [showLocation, setShowLocation] = useState(false);
const [postLocationEnabled, setPostLocationEnabled] = useState(false);
const [isPrivate, setIsPrivate] = useState(false);
const [giftEnabled, setGiftEnabled] = useState(true);
const [offerPaymentEnabled, setOfferPaymentEnabled] = useState(true);
const [loading, setLoading] = useState(true);
const [accountStatus, setAccountStatus] = useState<string>("active");
const [reactivationLockedUntil, setReactivationLockedUntil] = useState<string | null>(null);
const [reactivating, setReactivating] = useState(false);
const [blockedByCount, setBlockedByCount] = useState(0);
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
const [resettingAlgorithm, setResettingAlgorithm] = useState(false);
const applyPrivacyState = (data: Partial<PrivacyResponse>) => {
if (typeof data.allow_save_posts === "boolean") {
@@ -59,6 +73,12 @@ export default function UserSettingsPage() {
if (typeof data.is_private === "boolean") {
setIsPrivate(data.is_private);
}
if (typeof data.gift_enabled === "boolean") {
setGiftEnabled(data.gift_enabled);
}
if (typeof data.offer_payment_enabled === "boolean") {
setOfferPaymentEnabled(data.offer_payment_enabled);
}
if (isAppLanguage(data.preferred_language)) {
void setLanguage(data.preferred_language);
}
@@ -79,16 +99,36 @@ export default function UserSettingsPage() {
show_location: res?.account?.show_location === true,
post_location_enabled: res?.account?.post_location_enabled === true,
is_private: res?.account?.is_private === true,
gift_enabled: res?.account?.gift_enabled !== false,
offer_payment_enabled: res?.account?.offer_payment_enabled !== false,
preferred_language: res?.account?.preferred_language,
});
setAccountStatus(res?.account?.account_status || "active");
setReactivationLockedUntil(res?.account?.reactivation_locked_until || null);
setBlockedByCount(
(res?.account as { blocked_by_count?: number })?.blocked_by_count ?? 0
);
} finally {
setLoading(false);
}
})();
}, [request]); // eslint-disable-line react-hooks/exhaustive-deps
const resetAlgorithm = async () => {
setResettingAlgorithm(true);
try {
await request("POST", "/account/reset-algorithm", {}, { noToast: true });
toast.success(t("settings.resetAlgorithmDone"));
setResetConfirmOpen(false);
} catch (err) {
toast.error(
err instanceof Error ? err.message : t("settings.resetAlgorithmError")
);
} finally {
setResettingAlgorithm(false);
}
};
const savePrivacy = async (patch: PrivacyPatch) => {
try {
const res = await request<PrivacyResponse>(
@@ -132,7 +172,7 @@ export default function UserSettingsPage() {
<PageTitle>{t("settings.userSettingsTitle")}</PageTitle>
<div className="space-y-4 pb-8 text-sm">
{accountStatus === "deactivated" && (
<div className="rounded-xl border border-amber-300 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950/30">
<div className="rounded-2xl border border-amber-300 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950/30">
<p className="font-semibold text-amber-800 dark:text-amber-200">
{t("settings.accountDeactivated")}
</p>
@@ -161,7 +201,7 @@ export default function UserSettingsPage() {
<Link
href="/settings/edit/analytics/region"
className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
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.edit.region.title")}</span>
<span className="text-neutral-400"></span>
@@ -169,7 +209,7 @@ export default function UserSettingsPage() {
<Link
href="/settings/profile/visitors"
className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
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.profileVisitors")}</span>
<span className="text-neutral-400"></span>
@@ -177,46 +217,74 @@ export default function UserSettingsPage() {
<Link
href="/settings/booking"
className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
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.booking")}</span>
<span className="text-neutral-400"></span>
</Link>
{/* آنالیتیکس/کاربران مسدود/بایگانی/فعالیت شما و محتوای این آیتم‌ها فقط توی
فارسی/عربی/انگلیسی نمایش داده می‌شن */}
{SETTINGS_ITEMS_VISIBLE_LANGUAGES.includes(language) ? (
<>
<Link
href="/settings/edit/analytics"
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.edit.nav.analytics")}</span>
<span className="text-neutral-400"></span>
</Link>
<Link
href="/settings/edit/blocked-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.edit.nav.blockedUsers")}</span>
<span className="text-neutral-400"></span>
</Link>
<div 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.blockedByCount")}</span>
<span className="text-neutral-400">{blockedByCount}</span>
</div>
<Link
href="/settings/edit/archive"
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.edit.nav.archive")}</span>
<span className="text-neutral-400"></span>
</Link>
<Link
href="/settings/edit/activity"
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.edit.nav.activity")}</span>
<span className="text-neutral-400"></span>
</Link>
</>
) : null}
<Link
href="/settings/edit/analytics"
className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
href="/settings/edit/verification-guide"
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.edit.nav.analytics")}</span>
<span className="font-semibold">{t("settings.edit.nav.verificationGuide")}</span>
<span className="text-neutral-400"></span>
</Link>
<Link
href="/settings/edit/blocked-users"
className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
<button
type="button"
onClick={() => setResetConfirmOpen(true)}
className="flex w-full items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 text-start dark:border-neutral-800"
>
<span className="font-semibold">{t("settings.edit.nav.blockedUsers")}</span>
<span className="font-semibold">{t("settings.resetAlgorithm")}</span>
<span className="text-neutral-400"></span>
</Link>
</button>
<Link
href="/settings/edit/archive"
className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<span className="font-semibold">{t("settings.edit.nav.archive")}</span>
<span className="text-neutral-400"></span>
</Link>
<Link
href="/settings/edit/activity"
className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<span className="font-semibold">{t("settings.edit.nav.activity")}</span>
<span className="text-neutral-400"></span>
</Link>
<label className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800">
<div>
<label className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800">
<div className="min-w-0 pe-3">
<p className="font-semibold">{t("settings.allowSavePosts")}</p>
<p className="mt-0.5 text-xs text-neutral-500">
{t("settings.allowSavePostsDesc")}
@@ -227,12 +295,12 @@ export default function UserSettingsPage() {
checked={allowSave}
disabled={loading}
onChange={(e) => savePrivacy({ allow_save_posts: e.target.checked })}
className="h-5 w-5 accent-[#0095f6]"
className="h-5 w-5 shrink-0 accent-[#0095f6]"
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800">
<div>
<label className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800">
<div className="min-w-0 pe-3">
<p className="font-semibold">{t("settings.postLocation")}</p>
<p className="mt-0.5 text-xs text-neutral-500">
{t("settings.postLocationDesc")}
@@ -245,12 +313,12 @@ export default function UserSettingsPage() {
onChange={(e) =>
savePrivacy({ post_location_enabled: e.target.checked })
}
className="h-5 w-5 accent-[#0095f6]"
className="h-5 w-5 shrink-0 accent-[#0095f6]"
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800">
<div>
<label className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800">
<div className="min-w-0 pe-3">
<p className="font-semibold">{t("settings.showLocation")}</p>
<p className="mt-0.5 text-xs text-neutral-500">
{t("settings.showLocationDesc")}
@@ -261,12 +329,12 @@ export default function UserSettingsPage() {
checked={showLocation}
disabled={loading}
onChange={(e) => savePrivacy({ show_location: e.target.checked })}
className="h-5 w-5 accent-[#0095f6]"
className="h-5 w-5 shrink-0 accent-[#0095f6]"
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800">
<div>
<label className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800">
<div className="min-w-0 pe-3">
<p className="font-semibold">{t("settings.privateProfile")}</p>
<p className="mt-0.5 text-xs text-neutral-500">
{t("settings.privateProfileDesc")}
@@ -277,12 +345,12 @@ export default function UserSettingsPage() {
checked={isPrivate}
disabled={loading}
onChange={(e) => savePrivacy({ is_private: e.target.checked })}
className="h-5 w-5 accent-[#0095f6]"
className="h-5 w-5 shrink-0 accent-[#0095f6]"
/>
</label>
<label className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800">
<div>
<label className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800">
<div className="min-w-0 pe-3">
<p className="font-semibold">{t("settings.ghostMode")}</p>
<p className="mt-0.5 text-xs text-neutral-500">
{t("settings.ghostModeDesc")}
@@ -293,11 +361,77 @@ export default function UserSettingsPage() {
checked={ghostMode}
disabled={loading}
onChange={(e) => savePrivacy({ ghost_mode: e.target.checked })}
className="h-5 w-5 accent-[#0095f6]"
className="h-5 w-5 shrink-0 accent-[#0095f6]"
/>
</label>
<p className="pt-2 font-bold">{t("settings.walletSettingsTitle")}</p>
<label className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800">
<div className="min-w-0 pe-3">
<p className="font-semibold">{t("settings.giftEnabled")}</p>
<p className="mt-0.5 text-xs text-neutral-500">
{t("settings.giftEnabledDesc")}
</p>
</div>
<input
type="checkbox"
checked={giftEnabled}
disabled={loading}
onChange={(e) => savePrivacy({ gift_enabled: e.target.checked })}
className="h-5 w-5 shrink-0 accent-[#0095f6]"
/>
</label>
<label className="flex items-center justify-between rounded-2xl border border-neutral-200 px-4 py-3 dark:border-neutral-800">
<div className="min-w-0 pe-3">
<p className="font-semibold">{t("settings.offerPaymentEnabled")}</p>
<p className="mt-0.5 text-xs text-neutral-500">
{t("settings.offerPaymentEnabledDesc")}
</p>
</div>
<input
type="checkbox"
checked={offerPaymentEnabled}
disabled={loading}
onChange={(e) =>
savePrivacy({ offer_payment_enabled: e.target.checked })
}
className="h-5 w-5 shrink-0 accent-[#0095f6]"
/>
</label>
</div>
<Modal
isOpen={resetConfirmOpen}
onClose={() => setResetConfirmOpen(false)}
height="fit"
>
<div className="flex flex-col items-center gap-4 py-2 text-center">
<p className="font-bold">{t("settings.resetAlgorithm")}</p>
<p className="text-sm text-neutral-500">
{t("settings.resetAlgorithmWarning")}
</p>
<div className="grid w-full grid-cols-2 gap-4">
<RoundedButton
className="h-10 w-full"
disabled={resettingAlgorithm}
onClick={() => setResetConfirmOpen(false)}
>
{t("common.no")}
</RoundedButton>
<RoundedButton
variant="primary"
className="h-10 w-full"
disabled={resettingAlgorithm}
onClick={resetAlgorithm}
>
{resettingAlgorithm ? "…" : t("common.yes")}
</RoundedButton>
</div>
</div>
</Modal>
</Container>
</LocalePageShell>
);

View File

@@ -1,6 +1,7 @@
"use client";
import Container from "@/components/elements/Container";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import MultiStepForm from "@/components/projects/NewProject/MultiStepForm";
import { useProjectForm } from "@/contexts/ProjectFormContext";
import useAxios from "@/hooks/useAxios";
@@ -83,9 +84,11 @@ export default function EditProjectWrapper({ params }: IEditProps) {
}, [id]);
return (
<Container>
{fetched && <MultiStepForm />}
<div></div>
</Container>
<LocalePageShell>
<Container>
{fetched && <MultiStepForm />}
<div></div>
</Container>
</LocalePageShell>
);
}

View File

@@ -87,7 +87,7 @@ export default function CheckoutPage() {
<div className="mx-auto flex max-w-md flex-col gap-4">
<div className="flex items-center gap-3 rounded-2xl border border-neutral-200 p-2 dark:border-neutral-700">
{primaryImage && (
<div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-neutral-200 dark:bg-neutral-800">
<div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-2xl bg-neutral-200 dark:bg-neutral-800">
<Image
src={IMAGE_BASE_URL + primaryImage}
alt={order.listing.title}

View File

@@ -10,6 +10,7 @@ import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import { cn } from "@/lib/utils";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import Map, { Marker } from "react-map-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import toast from "react-hot-toast";
@@ -24,15 +25,18 @@ const METHOD_ICON: Record<string, string> = {
own_vehicle: "smart-car",
};
// آیکون‌ها عین همون‌هایی که توی اپ اندروید برای اطلاعات تماس فروشگاه استفاده می‌شن
// (ShopProfileScreen.tsx وب: call/call/send/message-text/instagram) — برای یکسان بودن
// ظاهر بین وب و اپ، نه آیکون‌های برندیِ قبلی هر کانال
const CONTACT_CHANNELS: {
key: "mobile" | "landline" | "telegram" | "whatsapp" | "instagram";
icon: string;
color: string;
}[] = [
{ key: "mobile", icon: "/images/icons/vuesax/bold/mobile.svg", color: "bg-green-500" },
{ key: "mobile", icon: "/images/icons/vuesax/bold/call.svg", color: "bg-green-500" },
{ key: "landline", icon: "/images/icons/vuesax/bold/call.svg", color: "bg-orange-500" },
{ key: "telegram", icon: "/images/icons/telegram.svg", color: "bg-sky-500" },
{ key: "whatsapp", icon: "/images/icons/vuesax/bold/whatsapp.svg", color: "bg-emerald-600" },
{ key: "telegram", icon: "/images/icons/vuesax/bold/send.svg", color: "bg-sky-500" },
{ key: "whatsapp", icon: "/images/icons/vuesax/bold/message-text.svg", color: "bg-emerald-600" },
{
key: "instagram",
icon: "/images/icons/vuesax/bold/instagram.svg",
@@ -134,7 +138,7 @@ function ShopPreviewPage() {
<img
src={IMAGE_BASE_URL + shop.logo}
alt={shop.name}
className="h-32 w-32 rounded-2xl object-cover shadow-sm"
className="h-44 w-44 rounded-2xl object-cover shadow-sm"
/>
)}
@@ -211,10 +215,10 @@ function ShopPreviewPage() {
<div className="flex flex-col gap-1.5 rounded-2xl border border-neutral-200 p-3 dark:border-neutral-700">
{shop.response_schedule.map((item) => (
<div key={item.day} className="flex items-center justify-between text-xs">
<span className="font-medium">{t(`shops.days.${item.day}`)}</span>
<span className="text-neutral-500" dir="ltr">
{item.start_time || "--:--"} - {item.end_time || "--:--"}
</span>
<span className="font-medium">{t(`shops.days.${item.day}`)}</span>
</div>
))}
</div>
@@ -243,9 +247,11 @@ function ShopPreviewPage() {
</div>
)}
{hasMap && (
// ارتفاع و پین سفارشی عین نقشه‌ی لوکیشن پروفایل کاربر
// (settings/edit/location/page.tsx) — برای یکدستی ظاهر نقشه‌ها توی اپ
<div className="w-full overflow-hidden rounded-2xl">
<Map
style={{ height: "150px" }}
style={{ height: "220px" }}
initialViewState={{
longitude: Number(shop.lng),
latitude: Number(shop.lat),
@@ -255,7 +261,9 @@ function ShopPreviewPage() {
mapStyle="mapbox://styles/mapbox/streets-v11"
interactive={false}
>
<Marker latitude={Number(shop.lat)} longitude={Number(shop.lng)} />
<Marker latitude={Number(shop.lat)} longitude={Number(shop.lng)}>
<Image alt="location icon" width={25} height={25} src="/images/icons/location.svg" />
</Marker>
</Map>
</div>
)}

View File

@@ -6,6 +6,8 @@ import Header from "@/components/main/Header";
import TabNavigation from "@/components/TabNavigation";
import PageTitle from "@/components/settings/PageTitle";
import AuthNextButton from "@/components/auth/AuthNextButton";
import Modal from "@/components/elements/Modal";
import RoundedButton from "@/components/elements/RoundedButton";
import RoundedInput from "@/components/elements/RoundedInput";
import BuyerInfoModal from "@/components/shops/orders/BuyerInfoModal";
import ShopInfoModal from "@/components/shops/orders/ShopInfoModal";
@@ -84,6 +86,8 @@ export default function OrderDetailPage() {
const { request, loading } = useAxios();
const user = useUser();
const [repurchasing, setRepurchasing] = useState(false);
const [cancelling, setCancelling] = useState(false);
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
const [order, setOrder] = useState<OrderDetail | null>(null);
const [report, setReport] = useState<OrderReport | null>(null);
@@ -186,6 +190,24 @@ export default function OrderDetailPage() {
}
};
const handleCancelOrder = async () => {
if (!order) return;
setCancelling(true);
try {
await request("PATCH", `/orders/${order._id}/cancel`, {});
toast.success(t("shops.orderCancelled"));
setShowCancelConfirm(false);
loadOrder();
} catch (err: unknown) {
const message =
(err as { response?: { data?: { message?: string } } })?.response
?.data?.message || t("shops.unknownError");
toast.error(message);
} finally {
setCancelling(false);
}
};
const handleSubmitRating = async () => {
if (!order || ratingScore < 1) return;
try {
@@ -313,7 +335,7 @@ export default function OrderDetailPage() {
<div className="mx-auto flex max-w-md flex-col gap-4">
<div className="flex items-center gap-3 rounded-2xl border border-neutral-200 p-2 dark:border-neutral-700">
{primaryImage && (
<div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-neutral-200 dark:bg-neutral-800">
<div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-2xl bg-neutral-200 dark:bg-neutral-800">
<Image
src={IMAGE_BASE_URL + primaryImage}
alt={order.listing.title}
@@ -514,6 +536,16 @@ export default function OrderDetailPage() {
</AuthNextButton>
)}
{isBuyer && order.status === "on_hold" && (
<AuthNextButton
type="button"
onClick={() => setShowCancelConfirm(true)}
className="!bg-red-500 !border-red-500"
>
{t("shops.cancelOrder")}
</AuthNextButton>
)}
{isBuyer && order.status !== "pending_payment" && (
<>
{canConfirmReceipt && (
@@ -713,6 +745,35 @@ export default function OrderDetailPage() {
onClose={() => setShowShopInfo(false)}
shop={order.shop}
/>
<Modal
isOpen={showCancelConfirm}
onClose={() => setShowCancelConfirm(false)}
height="fit"
>
<div className="flex flex-col items-center gap-4 py-2 text-center">
<p className="font-bold">{t("shops.cancelOrderConfirmTitle")}</p>
<p className="text-sm text-neutral-500">
{t("shops.cancelOrderConfirmDesc")}
</p>
<div className="grid w-full grid-cols-2 gap-4">
<RoundedButton
className="h-10 w-full"
disabled={cancelling}
onClick={() => setShowCancelConfirm(false)}
>
{t("common.no")}
</RoundedButton>
<RoundedButton
variant="primary"
className="h-10 w-full"
disabled={cancelling}
onClick={handleCancelOrder}
>
{cancelling ? "…" : t("common.yes")}
</RoundedButton>
</div>
</div>
</Modal>
</>
);
}

View File

@@ -78,7 +78,7 @@ export default function ProductComparisonPage() {
className="flex w-full items-center gap-3 rounded-2xl border border-neutral-200 p-2 text-right active:scale-[0.98] dark:border-neutral-700"
>
{primaryImage && (
<div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-xl bg-neutral-200 dark:bg-neutral-800">
<div className="relative h-16 w-16 shrink-0 overflow-hidden rounded-2xl bg-neutral-200 dark:bg-neutral-800">
<Image
src={IMAGE_BASE_URL + primaryImage}
alt={listing.title}

View File

@@ -330,7 +330,7 @@ export default function ShopProfileClient() {
onClick={() =>
router.push(`/shops/profile/${shopId}/reel/${tileId}`)
}
className="relative aspect-square overflow-hidden rounded-md bg-neutral-100 sm:rounded-xl dark:bg-neutral-800"
className="relative aspect-square overflow-hidden rounded-md bg-neutral-100 sm:rounded-2xl dark:bg-neutral-800"
>
{listing.isTaggedPost && (
<span className="absolute right-1 top-1 z-10 rounded-full bg-black/50 px-1.5 py-0.5 text-[9px] font-semibold text-white">

View File

@@ -35,7 +35,7 @@ export default class ClientErrorBoundary extends React.Component<Props, State> {
<button
type="button"
onClick={() => window.location.reload()}
className="rounded-xl bg-[#0095f6] px-5 py-2.5 text-sm font-bold text-white"
className="rounded-2xl bg-[#0095f6] px-5 py-2.5 text-sm font-bold text-white"
>
{t("errors.retry")}
</button>

View File

@@ -0,0 +1,57 @@
"use client";
import { useEffect, useRef } from "react";
import { usePathname, useRouter } from "next/navigation";
import Cookies from "js-cookie";
import toast from "react-hot-toast";
import { registerPushToken, subscribeForegroundPush } from "@/lib/pushNotifications";
// عین PushNotificationListener.tsx اندروید: (۱) ثبت توکنِ پوش وقتی کاربر لاگینه،
// (۲) وقتی تب باز و فعاله و پیامی می‌رسه، خودِ مرورگر هیچ نوتیفِ سیستمی‌ای نشون
// نمی‌ده — با toast جایگزین می‌شه. ری‌لود کامل روی لاگین/خروج (window.location.href
// در useAxios.tsx) نیست، پس با تغییرِ pathname هم دوباره چک می‌شه تا لاگینِ بدون
// ری‌لودِ کامل هم توکن رو ثبت کنه
export default function PushNotificationListener() {
const pathname = usePathname();
const router = useRouter();
const registeredRef = useRef(false);
useEffect(() => {
const hasToken = Boolean(Cookies.get("token"));
if (hasToken && !registeredRef.current) {
registeredRef.current = true;
void registerPushToken();
} else if (!hasToken) {
registeredRef.current = false;
}
}, [pathname]);
useEffect(() => {
const unsubscribe = subscribeForegroundPush((payload) => {
const title = payload.notification?.title;
if (!title) return;
const body = payload.notification?.body;
toast(
(t) => (
<button
type="button"
onClick={() => {
toast.dismiss(t.id);
router.push("/settings/notifications");
}}
className="flex flex-col items-start gap-0.5 text-start"
>
<span className="text-sm font-bold">{title}</span>
{body ? (
<span className="text-xs text-neutral-500">{body}</span>
) : null}
</button>
),
{ duration: 6000 },
);
});
return unsubscribe;
}, [router]);
return null;
}

View File

@@ -26,7 +26,7 @@ function AcademyPackageItem({
>
<div className="flex w-full items-center justify-between max-[350px]:flex-col max-sm:gap-5">
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-xl shadow-md">
<div className="relative h-12 w-12 shrink-0 overflow-hidden rounded-2xl shadow-md">
<Image
src={imageSrc}
alt={course.cuorse_name}

View File

@@ -34,7 +34,7 @@ export function AcademyProfileHeadSkeleton() {
<Skeleton className="h-10 w-14" />
<Skeleton className="h-10 w-14" />
</div>
<Skeleton className="h-[120px] w-[120px] rounded-xl" />
<Skeleton className="h-[120px] w-[120px] rounded-2xl" />
</div>
<div className="grid grid-cols-3 gap-4">
<Skeleton className="h-6 w-full" />
@@ -43,10 +43,10 @@ export function AcademyProfileHeadSkeleton() {
</div>
<Skeleton className="h-16 w-full" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
<Skeleton className="h-8 w-full rounded-xl" />
<Skeleton className="h-8 w-full rounded-xl" />
<Skeleton className="h-8 w-full rounded-xl" />
<Skeleton className="h-8 w-full rounded-xl" />
<Skeleton className="h-8 w-full rounded-2xl" />
<Skeleton className="h-8 w-full rounded-2xl" />
<Skeleton className="h-8 w-full rounded-2xl" />
<Skeleton className="h-8 w-full rounded-2xl" />
</div>
</div>
);
@@ -62,7 +62,7 @@ export function AcademyPackageListSkeleton({ count = 4 }: { count?: number }) {
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1">
<Skeleton className="h-12 w-12 rounded-xl shrink-0" />
<Skeleton className="h-12 w-12 rounded-2xl shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-full" />
@@ -84,11 +84,11 @@ export function AcademyCourseDetailSkeleton() {
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
<Skeleton className="h-72 w-full rounded-xl" />
<Skeleton className="h-32 w-full rounded-xl" />
<Skeleton className="h-72 w-full rounded-2xl" />
<Skeleton className="h-32 w-full rounded-2xl" />
</div>
<div className="space-y-4">
<Skeleton className="h-96 w-full rounded-xl" />
<Skeleton className="h-96 w-full rounded-2xl" />
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -80,7 +80,7 @@ export default function OtherLanguagesModal({
type="button"
dir={def.direction}
onClick={() => void handleSelect(def.code as AppLanguage)}
className={`flex items-center justify-between rounded-xl border px-4 py-3 text-right transition-colors ${
className={`flex items-center justify-between rounded-2xl border px-4 py-3 text-right transition-colors ${
active
? "border-[#0095f6] bg-[#0095f6]/10"
: "border-neutral-200 dark:border-neutral-800"

View File

@@ -110,7 +110,7 @@ export default function BillboardDetailContent({
trackView
/>
<div className="flex flex-wrap gap-4 mt-6 p-4 bg-gray-50 dark:bg-zinc-900 rounded-xl">
<div className="flex flex-wrap gap-4 mt-6 p-4 bg-gray-50 dark:bg-zinc-900 rounded-2xl">
<div className="flex gap-1 text-gray-500">
<span>{t("billboards.provinceLabel")}</span>
<span className="text-black dark:text-white">

View File

@@ -1,162 +1,162 @@
"use client";
import RoundedButton from "@/components/elements/RoundedButton";
import { IContactInfo, IFeature, Service } from "@/types/types";
import React, { useState } from "react";
import Rate from "rc-rate";
import "rc-rate/assets/index.css";
import "@/styles/rc-rate-custom.css";
import Image from "next/image";
import useAxios from "@/hooks/useAxios";
import { useTranslation } from "react-i18next";
import BillboardServicesModal from "./BillboardServicesModal";
import BillboardFeaturesModal from "./BillboardFeaturesModal";
import BillboardContactInfoModal from "./BillboardContactInfoModal";
import BillboardDescriptionModal from "./BillboardDescriptionModal";
import ShowMap from "@/components/main/ShowMap";
import DirectionMapModal from "@/components/main/DirectionMapModal";
function BillboardDetails({
services,
features,
contactInfo,
description,
lat,
lng,
_id,
noDirection,
}: {
services?: Service[];
features?: IFeature[];
contactInfo?: IContactInfo;
description?: string | null;
lat?: string | number | undefined;
lng?: string | number | undefined;
_id: string;
noDirection?: boolean;
}) {
const { t } = useTranslation("common");
const { request } = useAxios();
const [rating, setRating] = useState<number>(0);
const [showServicesModal, setShowServicesModal] = useState<boolean>(false);
const [showFeaturesModal, setShowFeaturesModal] = useState<boolean>(false);
const [showContactInfoModal, setShowContactInfoModal] =
useState<boolean>(false);
const [showDescriptionModal, setShowDescriptionModal] =
useState<boolean>(false);
const [showNavigationModal, setShowNavigationModal] =
useState<boolean>(false);
const submitRateHandler = async () => {
try {
await request("POST", "/advertising/rate", {
rating: rating,
advertisingId: _id,
});
} catch (error: unknown) {
console.log(error);
}
};
const correctedLat = Number(lng);
const correctedLng = Number(lat);
return (
<div className="flex flex-col mt-6">
<div className="flex items-center justify-center gap-4">
<Rate
value={rating}
onChange={(value) => setRating(value)}
count={5}
style={{ fontSize: "28px" }}
/>
<div onClick={submitRateHandler}>
<Image
width={24}
height={24}
alt={"submit.svg"}
src={`/images/icons/submit.svg`}
className="dark:invert"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<RoundedButton
onClick={() => setShowServicesModal(true)}
className="text-[#00C1C2] border-[#00C1C2] h-9 mx-auto w-full"
>
{t("billboards.details.servicesList")}
</RoundedButton>
<RoundedButton
onClick={() => setShowFeaturesModal(true)}
className="text-[#00C1C2] border-[#00C1C2] h-9 mx-auto w-full"
>
{t("billboards.details.features")}
</RoundedButton>
<RoundedButton
onClick={() => setShowContactInfoModal(true)}
className="text-[#00C1C2] border-[#00C1C2] h-9 mx-auto w-full"
>
{t("billboards.details.showContact")}
</RoundedButton>
<RoundedButton
onClick={() => setShowDescriptionModal(true)}
className="text-[#00C1C2] border-[#00C1C2] h-9 mx-auto w-full"
>
{t("billboards.details.description")}
</RoundedButton>
</div>
{showServicesModal && services && (
<BillboardServicesModal
showServicesModal={showServicesModal}
setShowServicesModal={setShowServicesModal}
services={services}
/>
)}
{showFeaturesModal && features && (
<BillboardFeaturesModal
showFeaturesModal={showFeaturesModal}
setShowFeaturesModal={setShowFeaturesModal}
features={features}
/>
)}
{showContactInfoModal && contactInfo && (
<BillboardContactInfoModal
showContactInfoModal={showContactInfoModal}
setShowContactInfoModal={setShowContactInfoModal}
contactInfo={contactInfo}
/>
)}
{showDescriptionModal && (
<BillboardDescriptionModal
showDescriptionModal={showDescriptionModal}
setShowDescriptionModal={setShowDescriptionModal}
description={description}
/>
)}
{lat && lng && lat !== "undefined" && lng !== "undefined" && (
<div className="my-5 rounded-xl overflow-hidden">
<ShowMap lat={lat} lng={lng} height="200px" />
</div>
)}
{showNavigationModal && (
<DirectionMapModal
showNavigationModal={showNavigationModal}
setShowNavigationModal={setShowNavigationModal}
correctedLat={correctedLat}
correctedLng={correctedLng}
/>
)}
{!noDirection && (
<RoundedButton
onClick={() => setShowNavigationModal(true)}
className="h-9 mx-auto px-10"
>
{t("billboards.details.directions")}
</RoundedButton>
)}
</div>
);
}
export default BillboardDetails;
"use client";
import RoundedButton from "@/components/elements/RoundedButton";
import { IContactInfo, IFeature, Service } from "@/types/types";
import React, { useState } from "react";
import Rate from "rc-rate";
import "rc-rate/assets/index.css";
import "@/styles/rc-rate-custom.css";
import Image from "next/image";
import useAxios from "@/hooks/useAxios";
import { useTranslation } from "react-i18next";
import BillboardServicesModal from "./BillboardServicesModal";
import BillboardFeaturesModal from "./BillboardFeaturesModal";
import BillboardContactInfoModal from "./BillboardContactInfoModal";
import BillboardDescriptionModal from "./BillboardDescriptionModal";
import ShowMap from "@/components/main/ShowMap";
import DirectionMapModal from "@/components/main/DirectionMapModal";
function BillboardDetails({
services,
features,
contactInfo,
description,
lat,
lng,
_id,
noDirection,
}: {
services?: Service[];
features?: IFeature[];
contactInfo?: IContactInfo;
description?: string | null;
lat?: string | number | undefined;
lng?: string | number | undefined;
_id: string;
noDirection?: boolean;
}) {
const { t } = useTranslation("common");
const { request } = useAxios();
const [rating, setRating] = useState<number>(0);
const [showServicesModal, setShowServicesModal] = useState<boolean>(false);
const [showFeaturesModal, setShowFeaturesModal] = useState<boolean>(false);
const [showContactInfoModal, setShowContactInfoModal] =
useState<boolean>(false);
const [showDescriptionModal, setShowDescriptionModal] =
useState<boolean>(false);
const [showNavigationModal, setShowNavigationModal] =
useState<boolean>(false);
const submitRateHandler = async () => {
try {
await request("POST", "/advertising/rate", {
rating: rating,
advertisingId: _id,
});
} catch (error: unknown) {
console.log(error);
}
};
const correctedLat = Number(lng);
const correctedLng = Number(lat);
return (
<div className="flex flex-col mt-6">
<div className="flex items-center justify-center gap-4">
<Rate
value={rating}
onChange={(value) => setRating(value)}
count={5}
style={{ fontSize: "28px" }}
/>
<div onClick={submitRateHandler}>
<Image
width={24}
height={24}
alt={"submit.svg"}
src={`/images/icons/submit.svg`}
className="dark:invert"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<RoundedButton
onClick={() => setShowServicesModal(true)}
className="text-[#00C1C2] border-[#00C1C2] h-9 mx-auto w-full"
>
{t("billboards.details.servicesList")}
</RoundedButton>
<RoundedButton
onClick={() => setShowFeaturesModal(true)}
className="text-[#00C1C2] border-[#00C1C2] h-9 mx-auto w-full"
>
{t("billboards.details.features")}
</RoundedButton>
<RoundedButton
onClick={() => setShowContactInfoModal(true)}
className="text-[#00C1C2] border-[#00C1C2] h-9 mx-auto w-full"
>
{t("billboards.details.showContact")}
</RoundedButton>
<RoundedButton
onClick={() => setShowDescriptionModal(true)}
className="text-[#00C1C2] border-[#00C1C2] h-9 mx-auto w-full"
>
{t("billboards.details.description")}
</RoundedButton>
</div>
{showServicesModal && services && (
<BillboardServicesModal
showServicesModal={showServicesModal}
setShowServicesModal={setShowServicesModal}
services={services}
/>
)}
{showFeaturesModal && features && (
<BillboardFeaturesModal
showFeaturesModal={showFeaturesModal}
setShowFeaturesModal={setShowFeaturesModal}
features={features}
/>
)}
{showContactInfoModal && contactInfo && (
<BillboardContactInfoModal
showContactInfoModal={showContactInfoModal}
setShowContactInfoModal={setShowContactInfoModal}
contactInfo={contactInfo}
/>
)}
{showDescriptionModal && (
<BillboardDescriptionModal
showDescriptionModal={showDescriptionModal}
setShowDescriptionModal={setShowDescriptionModal}
description={description}
/>
)}
{lat && lng && lat !== "undefined" && lng !== "undefined" && (
<div className="my-5 rounded-2xl overflow-hidden">
<ShowMap lat={lat} lng={lng} height="200px" />
</div>
)}
{showNavigationModal && (
<DirectionMapModal
showNavigationModal={showNavigationModal}
setShowNavigationModal={setShowNavigationModal}
correctedLat={correctedLat}
correctedLng={correctedLng}
/>
)}
{!noDirection && (
<RoundedButton
onClick={() => setShowNavigationModal(true)}
className="h-9 mx-auto px-10"
>
{t("billboards.details.directions")}
</RoundedButton>
)}
</div>
);
}
export default BillboardDetails;

View File

@@ -1,43 +1,43 @@
"use client";
import React from "react";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import { Pagination } from "swiper/modules";
import Image from "next/image";
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
interface BillboardImageSliderProps {
images: string[];
}
const BillboardImageSlider: React.FC<BillboardImageSliderProps> = ({
images,
}) => {
return (
<div className="my-8">
<Swiper
modules={[Pagination]}
navigation
spaceBetween={30}
slidesPerView={1.3}
centeredSlides={true}
className="w-full max-w-lg overflow-visible"
>
{images.map((src, index) => (
<SwiperSlide key={index} className="flex justify-center">
<Image
src={IMAGE_BASE_URL + src}
alt={`Slide ${index + 1}`}
width={400}
height={400}
className="rounded-xl object-cover"
/>
</SwiperSlide>
))}
</Swiper>
</div>
);
};
export default BillboardImageSlider;
"use client";
import React from "react";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import { Pagination } from "swiper/modules";
import Image from "next/image";
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
interface BillboardImageSliderProps {
images: string[];
}
const BillboardImageSlider: React.FC<BillboardImageSliderProps> = ({
images,
}) => {
return (
<div className="my-8">
<Swiper
modules={[Pagination]}
navigation
spaceBetween={30}
slidesPerView={1.3}
centeredSlides={true}
className="w-full max-w-lg overflow-visible"
>
{images.map((src, index) => (
<SwiperSlide key={index} className="flex justify-center">
<Image
src={IMAGE_BASE_URL + src}
alt={`Slide ${index + 1}`}
width={400}
height={400}
className="rounded-2xl object-cover"
/>
</SwiperSlide>
))}
</Swiper>
</div>
);
};
export default BillboardImageSlider;

View File

@@ -17,6 +17,7 @@ function BillboardsFilter() {
const searchParams = useSearchParams();
const [showFilterModal, setShowFilterModal] = useState<boolean>(false);
const [showSearchBox, setShowSearchBox] = useState<boolean>(false);
const [stateId, setStateId] = useState<string>("");
const [cityId, setCityId] = useState<string>("");
const [category, setCategory] = useState<string>("");
@@ -57,7 +58,9 @@ function BillboardsFilter() {
}
}
setSearchText(searchParams.get("search") || "");
const initialSearch = searchParams.get("search") || "";
setSearchText(initialSearch);
if (initialSearch) setShowSearchBox(true);
setSort(searchParams.get("sort") || "");
}, [
params,
@@ -115,14 +118,44 @@ function BillboardsFilter() {
return (
<div>
<div className="flex items-center justify-between relative text-sm gap-3">
<div className="w-full relative">
<div className="flex items-center justify-end gap-3 text-sm">
<Link href="/billboards/new" aria-label={t("billboards.createAria")}>
<BoldIcon
name="add-square"
size={25}
tinted
className="text-neutral-700 dark:text-neutral-200 min-w-[25px]"
/>
</Link>
<button onClick={() => setShowSearchBox((v) => !v)} aria-label={t("billboards.search")}>
<Image
width={25}
height={25}
alt="search icon"
src="/images/icons/search-normal.svg"
className="dark:invert min-w-[25px]"
/>
</button>
<button onClick={() => setShowFilterModal(true)} aria-label={t("billboards.filter")}>
<Image
width={25}
height={25}
alt="filter icon"
src="/images/icons/candle.svg"
className="dark:invert min-w-[25px]"
/>
</button>
</div>
{showSearchBox && (
<div className="relative mt-3">
<RoundedInput
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleFilterChange()}
placeholder={t("billboards.search")}
className="w-full"
autoFocus
/>
<Image
onClick={() => handleFilterChange()}
@@ -130,29 +163,10 @@ function BillboardsFilter() {
height={25}
alt="search icon"
src="/images/icons/search-normal.svg"
className=" min-w-[25px] absolute left-3 top-[7px] cursor-pointer"
className="min-w-[25px] absolute left-3 top-[7px] cursor-pointer"
/>
</div>
<div className="flex items-center gap-3">
<button onClick={() => setShowFilterModal(true)}>
<Image
width={25}
height={25}
alt="filter icon"
src="/images/icons/candle.svg"
className="dark:invert min-w-[25px]"
/>
</button>
<Link href="/billboards/new" aria-label={t("billboards.createAria")}>
<BoldIcon
name="add-square"
size={25}
tinted
className="text-neutral-700 dark:text-neutral-200 min-w-[25px]"
/>
</Link>
</div>
</div>
)}
{showFilterModal && (
<FilterModal
setShowFilterModal={setShowFilterModal}

View File

@@ -10,6 +10,7 @@ import BillboardFeaturesModal from "../BillboardPage/BillboardFeaturesModal";
import BillboardContactInfoModal from "../BillboardPage/BillboardContactInfoModal";
import BillboardLocationModal from "../MainBillboardCard/BillboardLocationModal";
import { useTranslation } from "react-i18next";
import { localizedGeoName } from "@/utils/geoName";
function AdProfileContent({ profile }: { profile: IAdvertisingProfile }) {
const { t } = useTranslation("common");
@@ -24,7 +25,7 @@ function AdProfileContent({ profile }: { profile: IAdvertisingProfile }) {
<div className="px-4 py-2 w-full text-xs md:text-sm font-semibold">
<div className="grid grid-cols-3 gap-1 md:gap-4">
<RoundedDiv className="text-[12px] md:text-sm h-7 md:h-8">
{profile?.city?.name ? profile?.city?.name : t("billboards.city")}
{profile?.city ? localizedGeoName(profile.city) : t("billboards.city")}
</RoundedDiv>
<RoundedDiv className="text-[12px] md:text-sm h-7 md:h-8">
{profile?.neighbourhood ? profile?.neighbourhood : t("billboards.neighbourhood")}

View File

@@ -1,79 +1,79 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import React, { useState } from "react";
import Modal from "react-modal";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import "swiper/css/navigation";
import { Navigation } from "swiper/modules";
Modal.setAppElement("body");
function AdProfileContentPosts({ allImages }: { allImages: string[] }) {
const [modalIsOpen, setModalIsOpen] = useState(false);
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
const images = allImages?.map((post: any) => post);
const openModal = (index: number) => {
setSelectedImageIndex(index);
setModalIsOpen(true);
};
const closeModal = () => {
setModalIsOpen(false);
};
return (
<div className="grid grid-cols-3 overflow-y-auto max-h-[500px] my-3 gap-2 no-scrollbar">
{allImages.map((item: string, index: number) => (
<div
key={index}
className="relative aspect-square w-full rounded-xl cursor-pointer overflow-hidden"
onClick={() => openModal(index)}
>
<img
alt="post"
src={`${IMAGE_BASE_URL}${item}`}
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
))}
<Modal
isOpen={modalIsOpen}
onRequestClose={closeModal}
overlayClassName="glass-modal-overlay glass-modal-overlay--dark fixed inset-0 z-[9999]"
className="fixed inset-0 flex items-center justify-center px-4 outline-none"
>
<div className="w-full max-w-2xl">
<Swiper
navigation
modules={[Navigation]}
initialSlide={selectedImageIndex}
>
{images.map((image, index) => (
<SwiperSlide key={index}>
<img
className="w-full h-auto rounded-xl"
src={`${IMAGE_BASE_URL}${image}`}
alt="Selected post"
/>
</SwiperSlide>
))}
</Swiper>
<button
className="absolute top-5 right-5 text-white mt-20 text-3xl"
onClick={closeModal}
>
×
</button>
</div>
</Modal>
</div>
);
}
export default AdProfileContentPosts;
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import React, { useState } from "react";
import Modal from "react-modal";
import { Swiper, SwiperSlide } from "swiper/react";
import "swiper/css";
import "swiper/css/navigation";
import { Navigation } from "swiper/modules";
Modal.setAppElement("body");
function AdProfileContentPosts({ allImages }: { allImages: string[] }) {
const [modalIsOpen, setModalIsOpen] = useState(false);
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
const images = allImages?.map((post: any) => post);
const openModal = (index: number) => {
setSelectedImageIndex(index);
setModalIsOpen(true);
};
const closeModal = () => {
setModalIsOpen(false);
};
return (
<div className="grid grid-cols-3 overflow-y-auto max-h-[500px] my-3 gap-2 no-scrollbar">
{allImages.map((item: string, index: number) => (
<div
key={index}
className="relative aspect-square w-full rounded-2xl cursor-pointer overflow-hidden"
onClick={() => openModal(index)}
>
<img
alt="post"
src={`${IMAGE_BASE_URL}${item}`}
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
))}
<Modal
isOpen={modalIsOpen}
onRequestClose={closeModal}
overlayClassName="glass-modal-overlay glass-modal-overlay--dark fixed inset-0 z-[9999]"
className="fixed inset-0 flex items-center justify-center px-4 outline-none"
>
<div className="w-full max-w-2xl">
<Swiper
navigation
modules={[Navigation]}
initialSlide={selectedImageIndex}
>
{images.map((image, index) => (
<SwiperSlide key={index}>
<img
className="w-full h-auto rounded-2xl"
src={`${IMAGE_BASE_URL}${image}`}
alt="Selected post"
/>
</SwiperSlide>
))}
</Swiper>
<button
className="absolute top-5 right-5 text-white mt-20 text-3xl"
onClick={closeModal}
>
×
</button>
</div>
</Modal>
</div>
);
}
export default AdProfileContentPosts;

View File

@@ -1,115 +1,115 @@
"use client";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import { IAdvertisingProfile } from "@/types/types";
import Image from "next/image";
import React, { useState } from "react";
import AdProfileHeadRowTwo from "@/components/billboards/Profile/AdProfileHeadRowTwo";
import { useTranslation } from "react-i18next";
interface AdProfileHeadProps {
profile: IAdvertisingProfile;
id: string | null;
}
function AdProfileHead({ profile, id }: AdProfileHeadProps) {
const { t } = useTranslation("common");
const { profile_image, is_self, adTotalRatings, neighbourhood, adAverageRating, about_us } =
profile;
const [expanded, setExpanded] = useState(false);
const maxLength = 100;
const toggleExpanded = () => setExpanded(!expanded);
const statsBlock = (
<div className="flex gap-5 mt-2">
<div className="font-semibold flex flex-col items-center">
<span>{profile?.allImages?.length}</span>
<span>{t("billboards.profile.images")}</span>
</div>
<div className="font-semibold flex flex-col items-center text-[#0C8002]">
<span>{profile?.totalLikesCount}</span>
<span>{t("billboards.profile.likes")}</span>
</div>
<div className="font-semibold flex flex-col items-center text-[#3A59A9]">
<span>{profile?.totalCommentsCount}</span>
<span>{t("billboards.profile.comments")}</span>
</div>
</div>
);
const avatarBlock = profile_image ? (
<ProfileAvatar
src={profile_image}
alt={profile?.vitrine_name || "Name"}
size="md"
rounded="xl"
className="md:h-[120px] md:w-[120px]"
/>
) : (
<div className="border dark:border-border-primary-dark rounded-3xl p-2">
<Image
className="rounded-xl object-cover h-[65px] w-[65px] md:h-[120px] md:w-[120px] invert dark:invert-0"
width={100}
height={100}
alt={profile?.vitrine_name || "Name"}
src={`/images/icons/select-img.svg`}
/>
</div>
);
if (!about_us)
return (
<div className="w-full">
<div className="flex w-full items-center justify-between px-4 py-2">
<div className="flex flex-col text-xs md:text-sm ">
<h1 className="text-xl font-bold ">{neighbourhood}</h1>
{statsBlock}
</div>
{avatarBlock}
</div>
<AdProfileHeadRowTwo
is_self={is_self}
adTotalRatings={adTotalRatings}
adAverageRating={adAverageRating}
id={id}
/>
</div>
);
return (
<div className="w-full">
<div className="flex w-full items-center justify-between px-4 py-2">
<div className="flex flex-col text-xs md:text-sm ">
<h1 className="text-xl font-bold ">{neighbourhood}</h1>
{statsBlock}
</div>
{avatarBlock}
</div>
<AdProfileHeadRowTwo
is_self={is_self}
adTotalRatings={adTotalRatings}
adAverageRating={adAverageRating}
id={id}
/>
<div className="text-sm leading-relaxed mx-4">
{about_us.length <= maxLength ? (
about_us
) : (
<>
{expanded ? about_us : about_us.slice(0, maxLength) + "..."}
<button
onClick={toggleExpanded}
className="text-blue-500 hover:underline ml-2"
>
{expanded ? t("billboards.profile.less") : t("billboards.profile.more")}
</button>
</>
)}
</div>
</div>
);
}
export default AdProfileHead;
"use client";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import { IAdvertisingProfile } from "@/types/types";
import Image from "next/image";
import React, { useState } from "react";
import AdProfileHeadRowTwo from "@/components/billboards/Profile/AdProfileHeadRowTwo";
import { useTranslation } from "react-i18next";
interface AdProfileHeadProps {
profile: IAdvertisingProfile;
id: string | null;
}
function AdProfileHead({ profile, id }: AdProfileHeadProps) {
const { t } = useTranslation("common");
const { profile_image, is_self, adTotalRatings, neighbourhood, adAverageRating, about_us } =
profile;
const [expanded, setExpanded] = useState(false);
const maxLength = 100;
const toggleExpanded = () => setExpanded(!expanded);
const statsBlock = (
<div className="flex gap-5 mt-2">
<div className="font-semibold flex flex-col items-center">
<span>{profile?.allImages?.length}</span>
<span>{t("billboards.profile.images")}</span>
</div>
<div className="font-semibold flex flex-col items-center text-[#0C8002]">
<span>{profile?.totalLikesCount}</span>
<span>{t("billboards.profile.likes")}</span>
</div>
<div className="font-semibold flex flex-col items-center text-[#3A59A9]">
<span>{profile?.totalCommentsCount}</span>
<span>{t("billboards.profile.comments")}</span>
</div>
</div>
);
const avatarBlock = profile_image ? (
<ProfileAvatar
src={profile_image}
alt={profile?.vitrine_name || "Name"}
size="md"
rounded="xl"
className="md:h-[120px] md:w-[120px]"
/>
) : (
<div className="border dark:border-border-primary-dark rounded-3xl p-2">
<Image
className="rounded-2xl object-cover h-[65px] w-[65px] md:h-[120px] md:w-[120px] invert dark:invert-0"
width={100}
height={100}
alt={profile?.vitrine_name || "Name"}
src={`/images/icons/select-img.svg`}
/>
</div>
);
if (!about_us)
return (
<div className="w-full">
<div className="flex w-full items-center justify-between px-4 py-2">
<div className="flex flex-col text-xs md:text-sm ">
<h1 className="text-xl font-bold ">{neighbourhood}</h1>
{statsBlock}
</div>
{avatarBlock}
</div>
<AdProfileHeadRowTwo
is_self={is_self}
adTotalRatings={adTotalRatings}
adAverageRating={adAverageRating}
id={id}
/>
</div>
);
return (
<div className="w-full">
<div className="flex w-full items-center justify-between px-4 py-2">
<div className="flex flex-col text-xs md:text-sm ">
<h1 className="text-xl font-bold ">{neighbourhood}</h1>
{statsBlock}
</div>
{avatarBlock}
</div>
<AdProfileHeadRowTwo
is_self={is_self}
adTotalRatings={adTotalRatings}
adAverageRating={adAverageRating}
id={id}
/>
<div className="text-sm leading-relaxed mx-4">
{about_us.length <= maxLength ? (
about_us
) : (
<>
{expanded ? about_us : about_us.slice(0, maxLength) + "..."}
<button
onClick={toggleExpanded}
className="text-blue-500 hover:underline ml-2"
>
{expanded ? t("billboards.profile.less") : t("billboards.profile.more")}
</button>
</>
)}
</div>
</div>
);
}
export default AdProfileHead;

View File

@@ -1,451 +1,451 @@
"use client";
import * as React from "react";
import { useState, useEffect } from "react";
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
import { useIsMobile } from "@/hooks/use-mobile";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
interface Payment {
_id: string;
price: number;
course_name: string;
course_id: any;
academy_id: string;
status: string; // "success" = پرداخت نشده, "settled" = پرداخت شده
createdAt: string;
updatedAt: string;
payment_authority: string;
payment_ref_id: string;
discountAmount: number;
taxAmount: number;
taxRate: number;
user_id: any;
}
interface ChartDataItem {
date: string;
amount: number;
count: number;
formattedDate: string;
persianDate: string;
jalaliDate: string;
}
const chartConfig = {
amount: {
label: "amount",
color: "#ff107d",
},
count: {
label: "count",
color: "#10b981",
},
} satisfies ChartConfig;
// تبدیل تاریخ میلادی به شمسی
const toJalaliDate = (date: Date): string => {
return new Intl.DateTimeFormat("fa-IR", {
year: "numeric",
month: "numeric",
day: "numeric",
}).format(date);
};
// نمایش ساده برای محور X
const getJalaliDayMonth = (date: Date): string => {
return new Intl.DateTimeFormat("fa-IR", {
month: "numeric",
day: "numeric",
}).format(date);
};
// تابع برای تولید تمام روزهای بازه زمانی
const generateDateRange = (startDate: Date, endDate: Date): string[] => {
const dates: string[] = [];
const currentDate = new Date(startDate);
while (currentDate <= endDate) {
dates.push(currentDate.toISOString().split("T")[0]);
currentDate.setDate(currentDate.getDate() + 1);
}
return dates;
};
export function ChartAreaInteractive() {
const { t } = useTranslation("common");
const isMobile = useIsMobile();
const [timeRange, setTimeRange] = useState("90d");
const [isLoading, setIsLoading] = useState(false);
const [chartData, setChartData] = useState<ChartDataItem[]>([]);
const [totalSales, setTotalSales] = useState(0); // مجموع فروش‌های موفق (تسویه نشده)
const [totalCount, setTotalCount] = useState(0);
const [averageAmount, setAverageAmount] = useState(0);
const [settledAmount, setSettledAmount] = useState(0); // مبلغ تسویه شده (پرداخت شده)
const { request } = useAxios();
useEffect(() => {
if (isMobile) {
setTimeRange("30d");
}
}, [isMobile]);
// محاسبه تاریخ شروع بر اساس بازه زمانی
const getStartDate = (range: string, endDate: Date): Date => {
const start = new Date(endDate);
switch (range) {
case "7d":
start.setDate(start.getDate() - 6);
break;
case "30d":
start.setDate(start.getDate() - 29);
break;
case "90d":
start.setDate(start.getDate() - 89);
break;
default:
start.setDate(start.getDate() - 89);
}
start.setHours(0, 0, 0, 0);
return start;
};
// پردازش داده‌ها و تکمیل روزهای بدون فروش
const processPaymentsData = (payments: Payment[], range: string) => {
const now = new Date();
now.setHours(23, 59, 59, 999);
const startDate = getStartDate(range, now);
// گروه‌بندی پرداخت‌های موجود بر اساس تاریخ
const groupedByDate: { [key: string]: { amount: number; count: number } } =
{};
let settledTotal = 0; // مجموع مبالغ تسویه شده (پرداخت شده)
payments.forEach((payment) => {
const date = new Date(payment.createdAt);
const dateKey = date.toISOString().split("T")[0];
// مبلغ: price یا taxAmount
const amount = payment.taxAmount || 0;
// محاسبه مبالغ تسویه شده (status = settled)
if (payment.status === "settled") {
settledTotal += amount;
}
// فقط پرداخت‌های داخل بازه زمانی را برای چارت در نظر بگیر
// برای چارت، فقط فروش‌های موفق (success) را نشان می‌دهیم
if (payment.status === "success" && date >= startDate && date <= now) {
if (!groupedByDate[dateKey]) {
groupedByDate[dateKey] = { amount: 0, count: 0 };
}
groupedByDate[dateKey].amount += amount;
groupedByDate[dateKey].count += 1;
}
if (payment.status === "settled" && date >= startDate && date <= now) {
if (!groupedByDate[dateKey]) {
groupedByDate[dateKey] = { amount: 0, count: 0 };
}
groupedByDate[dateKey].amount += amount;
groupedByDate[dateKey].count += 1;
}
});
setSettledAmount(settledTotal);
// تولید تمام روزهای بازه زمانی
const allDates = generateDateRange(startDate, now);
// ساخت آرایه نهایی با تمام روزها (روزهای بدون فروش مقدار صفر دارند)
const result: ChartDataItem[] = allDates.map((date) => {
const existing = groupedByDate[date];
const currentDate = new Date(date);
return {
date: date,
amount: existing?.amount || 0,
count: existing?.count || 0,
formattedDate: currentDate.toLocaleDateString("fa-IR", {
month: "numeric",
day: "numeric",
}),
persianDate: toJalaliDate(currentDate),
jalaliDate: getJalaliDayMonth(currentDate),
};
});
// محاسبه مجموع کل فروش در بازه زمانی (فقط success ها)
const total = result.reduce((sum, item) => sum + item.amount, 0);
const count = result.reduce((sum, item) => sum + item.count, 0);
const avg = count > 0 ? Math.round(total / count) : 0;
setTotalSales(total);
setTotalCount(count);
setAverageAmount(avg);
return result;
};
const fetchCoursesPayment = async () => {
setIsLoading(true);
try {
// دریافت تمام پرداخت‌ها (هم success و هم settled)
const response = await request(
"GET",
`/academy/course/getAllAcademyPayments`
);
console.log("تمامی پرداخت‌ها:", response?.data?.payments);
if (response?.data?.payments && Array.isArray(response.data.payments)) {
const processedData = processPaymentsData(
response.data.payments,
timeRange
);
setChartData(processedData);
} else {
// اگر داده‌ای نبود، یک بازه خالی با تمام روزها و مقدار صفر ایجاد کن
const now = new Date();
const startDate = getStartDate(timeRange, now);
const allDates = generateDateRange(startDate, now);
const emptyData: ChartDataItem[] = allDates.map((date) => ({
date: date,
amount: 0,
count: 0,
formattedDate: new Date(date).toLocaleDateString("fa-IR", {
month: "numeric",
day: "numeric",
}),
persianDate: toJalaliDate(new Date(date)),
jalaliDate: getJalaliDayMonth(new Date(date)),
}));
setChartData(emptyData);
setTotalSales(0);
setTotalCount(0);
setAverageAmount(0);
setSettledAmount(0);
}
} catch (err) {
console.log("get error:", err);
toast.error(t("settings.academy.chart.fetchError"));
setChartData([]);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchCoursesPayment();
}, []);
useEffect(() => {
if (chartData.length > 0) {
fetchCoursesPayment();
}
}, [timeRange]);
if (isLoading) {
return (
<Card className="@container/card">
<CardHeader>
<CardTitle>{t("settings.academy.chart.salesAmount")}</CardTitle>
<CardDescription>{t("settings.academy.chart.loadingData")}</CardDescription>
</CardHeader>
<CardContent className="flex justify-center items-center h-[250px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-pink-500"></div>
</CardContent>
</Card>
);
}
// فرمتر محور X با تاریخ شمسی
const formatXAxis = (value: string) => {
const date = new Date(value);
return getJalaliDayMonth(date);
};
return (
<Card className="@container/card">
<CardHeader>
<div className="flex justify-between items-start flex-wrap gap-4">
<div>
<CardTitle className="text-xl font-bold">
{t("settings.academy.chart.salesStats")}
</CardTitle>
</div>
<CardAction>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={setTimeRange}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
>
<ToggleGroupItem value="90d">{t("settings.academy.chart.days90")}</ToggleGroupItem>
<ToggleGroupItem value="30d">{t("settings.academy.chart.days30")}</ToggleGroupItem>
<ToggleGroupItem value="7d">{t("settings.academy.chart.days7")}</ToggleGroupItem>
</ToggleGroup>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
size="sm"
aria-label="Select a value"
>
<SelectValue placeholder={t("settings.academy.chart.selectTimeRange")} />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="90d" className="rounded-lg">
{t("settings.academy.chart.days90")}
</SelectItem>
<SelectItem value="30d" className="rounded-lg">
{t("settings.academy.chart.days30")}
</SelectItem>
<SelectItem value="7d" className="rounded-lg">
{t("settings.academy.chart.days7")}
</SelectItem>
</SelectContent>
</Select>
</CardAction>
</div>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
{chartData.length === 0 ? (
<div className="flex justify-center items-center h-[250px] text-muted-foreground">
<div className="text-center">
<svg
className="w-16 h-16 mx-auto mb-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
<p>{t("settings.academy.chart.noData")}</p>
<p className="text-sm mt-2">{t("settings.academy.chart.noSalesYet")}</p>
</div>
</div>
) : (
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={chartData}>
<defs>
<linearGradient id="fillAmount" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-amount)"
stopOpacity={1.0}
/>
<stop
offset="95%"
stopColor="var(--color-amount)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillCount" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-count)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-count)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={timeRange === "7d" ? 40 : 60}
tickFormatter={formatXAxis}
interval={
timeRange === "7d" ? 0 : Math.floor(chartData.length / 10)
}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(value) => {
const date = new Date(value);
return toJalaliDate(date);
}}
formatter={(value, name) => {
if (name === "amount") {
return [
`${Number(value).toLocaleString("fa-IR")}`,
t("settings.academy.chart.salesAmountLabel"),
];
}
if (name === "count") {
return [`${value}`, t("settings.academy.chart.salesCountLabel")];
}
return [value, name];
}}
indicator="dot"
/>
}
/>
<Area
dataKey="count"
type="monotone"
fill="url(#fillCount)"
stroke="var(--color-count)"
strokeWidth={2}
name="count"
isAnimationActive={false}
/>
<Area
dataKey="amount"
type="monotone"
fill="url(#fillAmount)"
stroke="var(--color-amount)"
strokeWidth={2}
name="amount"
isAnimationActive={false}
/>
</AreaChart>
</ChartContainer>
)}
</CardContent>
</Card>
);
}
"use client";
import * as React from "react";
import { useState, useEffect } from "react";
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
import { useIsMobile } from "@/hooks/use-mobile";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
interface Payment {
_id: string;
price: number;
course_name: string;
course_id: any;
academy_id: string;
status: string; // "success" = پرداخت نشده, "settled" = پرداخت شده
createdAt: string;
updatedAt: string;
payment_authority: string;
payment_ref_id: string;
discountAmount: number;
taxAmount: number;
taxRate: number;
user_id: any;
}
interface ChartDataItem {
date: string;
amount: number;
count: number;
formattedDate: string;
persianDate: string;
jalaliDate: string;
}
const chartConfig = {
amount: {
label: "amount",
color: "#ff107d",
},
count: {
label: "count",
color: "#10b981",
},
} satisfies ChartConfig;
// تبدیل تاریخ میلادی به شمسی
const toJalaliDate = (date: Date): string => {
return new Intl.DateTimeFormat("fa-IR", {
year: "numeric",
month: "numeric",
day: "numeric",
}).format(date);
};
// نمایش ساده برای محور X
const getJalaliDayMonth = (date: Date): string => {
return new Intl.DateTimeFormat("fa-IR", {
month: "numeric",
day: "numeric",
}).format(date);
};
// تابع برای تولید تمام روزهای بازه زمانی
const generateDateRange = (startDate: Date, endDate: Date): string[] => {
const dates: string[] = [];
const currentDate = new Date(startDate);
while (currentDate <= endDate) {
dates.push(currentDate.toISOString().split("T")[0]);
currentDate.setDate(currentDate.getDate() + 1);
}
return dates;
};
export function ChartAreaInteractive() {
const { t } = useTranslation("common");
const isMobile = useIsMobile();
const [timeRange, setTimeRange] = useState("90d");
const [isLoading, setIsLoading] = useState(false);
const [chartData, setChartData] = useState<ChartDataItem[]>([]);
const [totalSales, setTotalSales] = useState(0); // مجموع فروش‌های موفق (تسویه نشده)
const [totalCount, setTotalCount] = useState(0);
const [averageAmount, setAverageAmount] = useState(0);
const [settledAmount, setSettledAmount] = useState(0); // مبلغ تسویه شده (پرداخت شده)
const { request } = useAxios();
useEffect(() => {
if (isMobile) {
setTimeRange("30d");
}
}, [isMobile]);
// محاسبه تاریخ شروع بر اساس بازه زمانی
const getStartDate = (range: string, endDate: Date): Date => {
const start = new Date(endDate);
switch (range) {
case "7d":
start.setDate(start.getDate() - 6);
break;
case "30d":
start.setDate(start.getDate() - 29);
break;
case "90d":
start.setDate(start.getDate() - 89);
break;
default:
start.setDate(start.getDate() - 89);
}
start.setHours(0, 0, 0, 0);
return start;
};
// پردازش داده‌ها و تکمیل روزهای بدون فروش
const processPaymentsData = (payments: Payment[], range: string) => {
const now = new Date();
now.setHours(23, 59, 59, 999);
const startDate = getStartDate(range, now);
// گروه‌بندی پرداخت‌های موجود بر اساس تاریخ
const groupedByDate: { [key: string]: { amount: number; count: number } } =
{};
let settledTotal = 0; // مجموع مبالغ تسویه شده (پرداخت شده)
payments.forEach((payment) => {
const date = new Date(payment.createdAt);
const dateKey = date.toISOString().split("T")[0];
// مبلغ: price یا taxAmount
const amount = payment.taxAmount || 0;
// محاسبه مبالغ تسویه شده (status = settled)
if (payment.status === "settled") {
settledTotal += amount;
}
// فقط پرداخت‌های داخل بازه زمانی را برای چارت در نظر بگیر
// برای چارت، فقط فروش‌های موفق (success) را نشان می‌دهیم
if (payment.status === "success" && date >= startDate && date <= now) {
if (!groupedByDate[dateKey]) {
groupedByDate[dateKey] = { amount: 0, count: 0 };
}
groupedByDate[dateKey].amount += amount;
groupedByDate[dateKey].count += 1;
}
if (payment.status === "settled" && date >= startDate && date <= now) {
if (!groupedByDate[dateKey]) {
groupedByDate[dateKey] = { amount: 0, count: 0 };
}
groupedByDate[dateKey].amount += amount;
groupedByDate[dateKey].count += 1;
}
});
setSettledAmount(settledTotal);
// تولید تمام روزهای بازه زمانی
const allDates = generateDateRange(startDate, now);
// ساخت آرایه نهایی با تمام روزها (روزهای بدون فروش مقدار صفر دارند)
const result: ChartDataItem[] = allDates.map((date) => {
const existing = groupedByDate[date];
const currentDate = new Date(date);
return {
date: date,
amount: existing?.amount || 0,
count: existing?.count || 0,
formattedDate: currentDate.toLocaleDateString("fa-IR", {
month: "numeric",
day: "numeric",
}),
persianDate: toJalaliDate(currentDate),
jalaliDate: getJalaliDayMonth(currentDate),
};
});
// محاسبه مجموع کل فروش در بازه زمانی (فقط success ها)
const total = result.reduce((sum, item) => sum + item.amount, 0);
const count = result.reduce((sum, item) => sum + item.count, 0);
const avg = count > 0 ? Math.round(total / count) : 0;
setTotalSales(total);
setTotalCount(count);
setAverageAmount(avg);
return result;
};
const fetchCoursesPayment = async () => {
setIsLoading(true);
try {
// دریافت تمام پرداخت‌ها (هم success و هم settled)
const response = await request(
"GET",
`/academy/course/getAllAcademyPayments`
);
console.log("تمامی پرداخت‌ها:", response?.data?.payments);
if (response?.data?.payments && Array.isArray(response.data.payments)) {
const processedData = processPaymentsData(
response.data.payments,
timeRange
);
setChartData(processedData);
} else {
// اگر داده‌ای نبود، یک بازه خالی با تمام روزها و مقدار صفر ایجاد کن
const now = new Date();
const startDate = getStartDate(timeRange, now);
const allDates = generateDateRange(startDate, now);
const emptyData: ChartDataItem[] = allDates.map((date) => ({
date: date,
amount: 0,
count: 0,
formattedDate: new Date(date).toLocaleDateString("fa-IR", {
month: "numeric",
day: "numeric",
}),
persianDate: toJalaliDate(new Date(date)),
jalaliDate: getJalaliDayMonth(new Date(date)),
}));
setChartData(emptyData);
setTotalSales(0);
setTotalCount(0);
setAverageAmount(0);
setSettledAmount(0);
}
} catch (err) {
console.log("get error:", err);
toast.error(t("settings.academy.chart.fetchError"));
setChartData([]);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchCoursesPayment();
}, []);
useEffect(() => {
if (chartData.length > 0) {
fetchCoursesPayment();
}
}, [timeRange]);
if (isLoading) {
return (
<Card className="@container/card">
<CardHeader>
<CardTitle>{t("settings.academy.chart.salesAmount")}</CardTitle>
<CardDescription>{t("settings.academy.chart.loadingData")}</CardDescription>
</CardHeader>
<CardContent className="flex justify-center items-center h-[250px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-pink-500"></div>
</CardContent>
</Card>
);
}
// فرمتر محور X با تاریخ شمسی
const formatXAxis = (value: string) => {
const date = new Date(value);
return getJalaliDayMonth(date);
};
return (
<Card className="@container/card">
<CardHeader>
<div className="flex justify-between items-start flex-wrap gap-4">
<div>
<CardTitle className="text-xl font-bold">
{t("settings.academy.chart.salesStats")}
</CardTitle>
</div>
<CardAction>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={setTimeRange}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
>
<ToggleGroupItem value="90d">{t("settings.academy.chart.days90")}</ToggleGroupItem>
<ToggleGroupItem value="30d">{t("settings.academy.chart.days30")}</ToggleGroupItem>
<ToggleGroupItem value="7d">{t("settings.academy.chart.days7")}</ToggleGroupItem>
</ToggleGroup>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
size="sm"
aria-label="Select a value"
>
<SelectValue placeholder={t("settings.academy.chart.selectTimeRange")} />
</SelectTrigger>
<SelectContent className="rounded-2xl">
<SelectItem value="90d" className="rounded-lg">
{t("settings.academy.chart.days90")}
</SelectItem>
<SelectItem value="30d" className="rounded-lg">
{t("settings.academy.chart.days30")}
</SelectItem>
<SelectItem value="7d" className="rounded-lg">
{t("settings.academy.chart.days7")}
</SelectItem>
</SelectContent>
</Select>
</CardAction>
</div>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
{chartData.length === 0 ? (
<div className="flex justify-center items-center h-[250px] text-muted-foreground">
<div className="text-center">
<svg
className="w-16 h-16 mx-auto mb-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
<p>{t("settings.academy.chart.noData")}</p>
<p className="text-sm mt-2">{t("settings.academy.chart.noSalesYet")}</p>
</div>
</div>
) : (
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<AreaChart data={chartData}>
<defs>
<linearGradient id="fillAmount" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-amount)"
stopOpacity={1.0}
/>
<stop
offset="95%"
stopColor="var(--color-amount)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillCount" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-count)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-count)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={timeRange === "7d" ? 40 : 60}
tickFormatter={formatXAxis}
interval={
timeRange === "7d" ? 0 : Math.floor(chartData.length / 10)
}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(value) => {
const date = new Date(value);
return toJalaliDate(date);
}}
formatter={(value, name) => {
if (name === "amount") {
return [
`${Number(value).toLocaleString("fa-IR")}`,
t("settings.academy.chart.salesAmountLabel"),
];
}
if (name === "count") {
return [`${value}`, t("settings.academy.chart.salesCountLabel")];
}
return [value, name];
}}
indicator="dot"
/>
}
/>
<Area
dataKey="count"
type="monotone"
fill="url(#fillCount)"
stroke="var(--color-count)"
strokeWidth={2}
name="count"
isAnimationActive={false}
/>
<Area
dataKey="amount"
type="monotone"
fill="url(#fillAmount)"
stroke="var(--color-amount)"
strokeWidth={2}
name="amount"
isAnimationActive={false}
/>
</AreaChart>
</ChartContainer>
)}
</CardContent>
</Card>
);
}

View File

@@ -1,91 +1,91 @@
"use client";
import { useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import ChatBoldIcon, { type ChatBoldIconName } from "./ChatBoldIcon";
import { useTranslation } from "react-i18next";
export type AttachmentType = "image" | "video" | "file" | "location" | "camera";
interface AttachmentMenuProps {
open: boolean;
onClose: () => void;
onSelect: (type: AttachmentType) => void;
anchorRef?: React.RefObject<HTMLElement | null>;
}
const attachmentConfig: {
type: AttachmentType;
labelKey: string;
icon: ChatBoldIconName;
color: string;
}[] = [
{ type: "camera", labelKey: "chats.attachment.camera", icon: "camera", color: "bg-red-500" },
{ type: "image", labelKey: "chats.attachment.gallery", icon: "gallery", color: "bg-purple-500" },
{ type: "video", labelKey: "chats.attachment.video", icon: "video", color: "bg-blue-500" },
{ type: "file", labelKey: "chats.attachment.file", icon: "file", color: "bg-orange-500" },
{ type: "location", labelKey: "chats.attachment.location", icon: "location", color: "bg-green-500" },
];
export default function AttachmentMenu({
open,
onClose,
onSelect,
anchorRef,
}: AttachmentMenuProps) {
const { t } = useTranslation("common");
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
const target = e.target as Node;
if (
menuRef.current &&
!menuRef.current.contains(target) &&
!anchorRef?.current?.contains(target)
) {
onClose();
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open, onClose, anchorRef]);
return (
<AnimatePresence>
{open && (
<motion.div
ref={menuRef}
initial={{ opacity: 0, y: 12, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.95 }}
transition={{ duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] }}
className="glass-panel absolute bottom-full left-0 z-[60] mb-3 min-w-[200px] rounded-2xl p-2 shadow-xl"
>
{attachmentConfig.map((item, i) => (
<motion.button
key={item.type}
type="button"
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.04 }}
onClick={() => {
onSelect(item.type);
onClose();
}}
className="gentle-transition flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-right hover:bg-black/5 active:scale-[0.98] dark:hover:bg-white/10"
>
<span
className={`flex h-10 w-10 items-center justify-center rounded-full text-white ${item.color}`}
>
<ChatBoldIcon name={item.icon} size={22} className="text-white" />
</span>
<span className="text-sm font-medium">{t(item.labelKey)}</span>
</motion.button>
))}
</motion.div>
)}
</AnimatePresence>
);
}
"use client";
import { useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import ChatBoldIcon, { type ChatBoldIconName } from "./ChatBoldIcon";
import { useTranslation } from "react-i18next";
export type AttachmentType = "image" | "video" | "file" | "location" | "camera";
interface AttachmentMenuProps {
open: boolean;
onClose: () => void;
onSelect: (type: AttachmentType) => void;
anchorRef?: React.RefObject<HTMLElement | null>;
}
const attachmentConfig: {
type: AttachmentType;
labelKey: string;
icon: ChatBoldIconName;
color: string;
}[] = [
{ type: "camera", labelKey: "chats.attachment.camera", icon: "camera", color: "bg-red-500" },
{ type: "image", labelKey: "chats.attachment.gallery", icon: "gallery", color: "bg-purple-500" },
{ type: "video", labelKey: "chats.attachment.video", icon: "video", color: "bg-blue-500" },
{ type: "file", labelKey: "chats.attachment.file", icon: "file", color: "bg-orange-500" },
{ type: "location", labelKey: "chats.attachment.location", icon: "location", color: "bg-green-500" },
];
export default function AttachmentMenu({
open,
onClose,
onSelect,
anchorRef,
}: AttachmentMenuProps) {
const { t } = useTranslation("common");
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handleClick = (e: MouseEvent) => {
const target = e.target as Node;
if (
menuRef.current &&
!menuRef.current.contains(target) &&
!anchorRef?.current?.contains(target)
) {
onClose();
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open, onClose, anchorRef]);
return (
<AnimatePresence>
{open && (
<motion.div
ref={menuRef}
initial={{ opacity: 0, y: 12, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.95 }}
transition={{ duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] }}
className="glass-panel absolute bottom-full left-0 z-[60] mb-3 min-w-[200px] rounded-2xl p-2 shadow-xl"
>
{attachmentConfig.map((item, i) => (
<motion.button
key={item.type}
type="button"
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.04 }}
onClick={() => {
onSelect(item.type);
onClose();
}}
className="gentle-transition flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-right hover:bg-black/5 active:scale-[0.98] dark:hover:bg-white/10"
>
<span
className={`flex h-10 w-10 items-center justify-center rounded-full text-white ${item.color}`}
>
<ChatBoldIcon name={item.icon} size={22} className="text-white" />
</span>
<span className="text-sm font-medium">{t(item.labelKey)}</span>
</motion.button>
))}
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -110,7 +110,7 @@ export default function ChatRoomMemberPicker({
) : null}
{results.length > 0 ? (
<ul className="mt-2 max-h-40 overflow-y-auto rounded-xl border border-neutral-200 dark:border-neutral-700">
<ul className="mt-2 max-h-40 overflow-y-auto rounded-2xl border border-neutral-200 dark:border-neutral-700">
{results.map((user) => (
<li key={user._id}>
<button

View File

@@ -1,111 +1,111 @@
"use client";
import { useState } from "react";
import BoldIcon from "@/components/ui/BoldIcon";
import IOSSpinner from "@/components/ui/IOSSpinner";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
interface FileMessageBubbleProps {
url: string;
fileName?: string;
isOutgoing?: boolean;
}
export default function FileMessageBubble({
url,
fileName,
isOutgoing = false,
}: FileMessageBubbleProps) {
const { t } = useTranslation("common");
const resolvedFileName = fileName ?? t("chats.fileAttachment");
const [progress, setProgress] = useState(0);
const [downloading, setDownloading] = useState(false);
const isPending = url.startsWith("blob:");
const handleDownload = async () => {
if (downloading) return;
setDownloading(true);
setProgress(0);
try {
const xhr = new XMLHttpRequest();
xhr.open("GET", url, true);
xhr.responseType = "blob";
xhr.onprogress = (e) => {
if (e.lengthComputable) {
setProgress(Math.round((e.loaded / e.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status === 200) {
const a = document.createElement("a");
a.href = URL.createObjectURL(xhr.response);
a.download = resolvedFileName;
a.click();
setProgress(100);
}
setDownloading(false);
};
xhr.onerror = () => {
window.open(url, "_blank");
setDownloading(false);
};
xhr.send();
} catch {
window.open(url, "_blank");
setDownloading(false);
}
};
return (
<div
className={cn(
"flex min-w-[200px] max-w-[260px] items-center gap-3 rounded-2xl p-3",
isOutgoing
? "bg-[#2aabee]/10"
: "bg-neutral-50 dark:bg-neutral-800/80"
)}
>
<div
className={cn(
"flex h-11 w-11 shrink-0 items-center justify-center rounded-xl",
isOutgoing ? "bg-[#2aabee]/20 text-[#2aabee]" : "bg-blue-500/15 text-blue-500"
)}
>
{isPending || downloading ? (
<IOSSpinner size={22} color={isOutgoing ? "#2aabee" : "#007aff"} />
) : (
<BoldIcon name="document" size={22} tinted className="text-current" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{resolvedFileName}</p>
{(isPending || downloading) && (
<div className="mt-1.5 h-1 overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-700">
<div
className="gentle-transition h-full rounded-full bg-[#2aabee]"
style={{ width: `${isPending ? 40 : progress}%` }}
/>
</div>
)}
<span className="text-[10px] text-neutral-500">
{isPending
? t("chats.fileStatus.uploading")
: downloading
? `${progress}%`
: t("chats.file")}
</span>
</div>
{!isPending && (
<button
type="button"
onClick={handleDownload}
className="gentle-transition rounded-full p-2 text-neutral-500 hover:bg-neutral-200/80 active:scale-90 dark:hover:bg-neutral-700"
aria-label={t("chats.aria.download")}
>
<BoldIcon name="document-download" size={20} tinted className="text-current" />
</button>
)}
</div>
);
}
"use client";
import { useState } from "react";
import BoldIcon from "@/components/ui/BoldIcon";
import IOSSpinner from "@/components/ui/IOSSpinner";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
interface FileMessageBubbleProps {
url: string;
fileName?: string;
isOutgoing?: boolean;
}
export default function FileMessageBubble({
url,
fileName,
isOutgoing = false,
}: FileMessageBubbleProps) {
const { t } = useTranslation("common");
const resolvedFileName = fileName ?? t("chats.fileAttachment");
const [progress, setProgress] = useState(0);
const [downloading, setDownloading] = useState(false);
const isPending = url.startsWith("blob:");
const handleDownload = async () => {
if (downloading) return;
setDownloading(true);
setProgress(0);
try {
const xhr = new XMLHttpRequest();
xhr.open("GET", url, true);
xhr.responseType = "blob";
xhr.onprogress = (e) => {
if (e.lengthComputable) {
setProgress(Math.round((e.loaded / e.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status === 200) {
const a = document.createElement("a");
a.href = URL.createObjectURL(xhr.response);
a.download = resolvedFileName;
a.click();
setProgress(100);
}
setDownloading(false);
};
xhr.onerror = () => {
window.open(url, "_blank");
setDownloading(false);
};
xhr.send();
} catch {
window.open(url, "_blank");
setDownloading(false);
}
};
return (
<div
className={cn(
"flex min-w-[200px] max-w-[260px] items-center gap-3 rounded-2xl p-3",
isOutgoing
? "bg-[#2aabee]/10"
: "bg-neutral-50 dark:bg-neutral-800/80"
)}
>
<div
className={cn(
"flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl",
isOutgoing ? "bg-[#2aabee]/20 text-[#2aabee]" : "bg-blue-500/15 text-blue-500"
)}
>
{isPending || downloading ? (
<IOSSpinner size={22} color={isOutgoing ? "#2aabee" : "#007aff"} />
) : (
<BoldIcon name="document" size={22} tinted className="text-current" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{resolvedFileName}</p>
{(isPending || downloading) && (
<div className="mt-1.5 h-1 overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-700">
<div
className="gentle-transition h-full rounded-full bg-[#2aabee]"
style={{ width: `${isPending ? 40 : progress}%` }}
/>
</div>
)}
<span className="text-[10px] text-neutral-500">
{isPending
? t("chats.fileStatus.uploading")
: downloading
? `${progress}%`
: t("chats.file")}
</span>
</div>
{!isPending && (
<button
type="button"
onClick={handleDownload}
className="gentle-transition rounded-full p-2 text-neutral-500 hover:bg-neutral-200/80 active:scale-90 dark:hover:bg-neutral-700"
aria-label={t("chats.aria.download")}
>
<BoldIcon name="document-download" size={20} tinted className="text-current" />
</button>
)}
</div>
);
}

View File

@@ -1,204 +1,204 @@
"use client";
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 useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import IOSSpinner from "@/components/ui/IOSSpinner";
import { useTranslation } from "react-i18next";
import { encryptDmContent, ensureIdentityKeys } from "@/lib/e2ee";
export interface ChatUserItem {
_id: string;
user_name: string;
first_name: string;
last_name: string;
profile_image?: string;
}
interface ForwardMessageModalProps {
open: boolean;
onClose: () => void;
message: {
content: string;
file?: string;
fileType?: string;
forwardedFrom?: {
userId: string;
userName: string;
displayName: string;
};
};
currentUserId: string;
currentReceiverId?: string;
}
export default function ForwardMessageModal({
open,
onClose,
message,
currentUserId,
}: ForwardMessageModalProps) {
const { t } = useTranslation("common");
const { request } = useAxios();
const [users, setUsers] = useState<ChatUserItem[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [sending, setSending] = useState(false);
const loadUsers = async () => {
setLoading(true);
try {
const res = await request<{ filteredUsersData: ChatUserItem[] }>(
"GET",
"/messages?limit=100",
null,
{ noToast: true }
);
setUsers(
(res?.filteredUsersData || []).filter(
(u) => u._id !== currentUserId && Boolean(u.user_name)
)
);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (open) loadUsers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else if (next.size < 50) next.add(id);
else toast.error(t("chats.toast.maxForwardUsers"));
return next;
});
};
const forward = async () => {
if (selected.size === 0) return;
setSending(true);
const fwdMeta = message.forwardedFrom
? JSON.stringify({ forwardedFrom: message.forwardedFrom })
: "";
const content =
(fwdMeta ? fwdMeta + "\n" : "") +
(message.content || t("chats.forwardedMessage"));
try {
const targets = Array.from(selected);
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 */
}
return request("POST", "/chat", {
content: sendContent,
receiverId,
senderId: currentUserId,
forwardedFrom: message.forwardedFrom,
});
})
);
toast.success(t("chats.toast.forwardSent", { count: selected.size }));
onClose();
setSelected(new Set());
} catch {
toast.error(t("chats.toast.forwardFailed"));
} finally {
setSending(false);
}
};
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="glass-modal-overlay fixed inset-0 z-[200] flex items-end justify-center sm:items-center"
onClick={onClose}
>
<motion.div
initial={{ y: 40 }}
animate={{ y: 0 }}
exit={{ y: 40 }}
onClick={(e) => e.stopPropagation()}
className="glass-modal-panel max-h-[70vh] w-full max-w-md overflow-hidden rounded-t-3xl sm:rounded-3xl"
>
<div className="border-b border-white/20 p-4 text-center font-semibold">
{t("chats.modal.forwardTo", { selected: selected.size })}
</div>
<div className="max-h-[50vh] overflow-y-auto p-2">
{loading ? (
<div className="flex justify-center py-8">
<IOSSpinner size={24} />
</div>
) : (
users.map((u) => (
<button
key={u._id}
type="button"
onClick={() => toggle(u._id)}
className={`gentle-transition mb-1 flex w-full items-center gap-3 rounded-xl p-3 ${
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" />
)}
<span className="text-sm">
{u.first_name} {u.last_name}
</span>
</button>
))
)}
</div>
<div className="flex gap-2 border-t border-white/20 p-4">
<button
type="button"
onClick={onClose}
className="flex-1 rounded-full py-2.5 text-sm text-neutral-500"
>
{t("chats.actions.cancel")}
</button>
<button
type="button"
disabled={selected.size === 0 || sending}
onClick={forward}
className="ig-dm-send-btn flex-1 rounded-full py-2.5 text-sm text-white disabled:opacity-50"
>
{sending ? t("chats.actions.sending") : t("chats.actions.send")}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
"use client";
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 useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import IOSSpinner from "@/components/ui/IOSSpinner";
import { useTranslation } from "react-i18next";
import { encryptDmContent, ensureIdentityKeys } from "@/lib/e2ee";
export interface ChatUserItem {
_id: string;
user_name: string;
first_name: string;
last_name: string;
profile_image?: string;
}
interface ForwardMessageModalProps {
open: boolean;
onClose: () => void;
message: {
content: string;
file?: string;
fileType?: string;
forwardedFrom?: {
userId: string;
userName: string;
displayName: string;
};
};
currentUserId: string;
currentReceiverId?: string;
}
export default function ForwardMessageModal({
open,
onClose,
message,
currentUserId,
}: ForwardMessageModalProps) {
const { t } = useTranslation("common");
const { request } = useAxios();
const [users, setUsers] = useState<ChatUserItem[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [sending, setSending] = useState(false);
const loadUsers = async () => {
setLoading(true);
try {
const res = await request<{ filteredUsersData: ChatUserItem[] }>(
"GET",
"/messages?limit=100",
null,
{ noToast: true }
);
setUsers(
(res?.filteredUsersData || []).filter(
(u) => u._id !== currentUserId && Boolean(u.user_name)
)
);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (open) loadUsers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else if (next.size < 50) next.add(id);
else toast.error(t("chats.toast.maxForwardUsers"));
return next;
});
};
const forward = async () => {
if (selected.size === 0) return;
setSending(true);
const fwdMeta = message.forwardedFrom
? JSON.stringify({ forwardedFrom: message.forwardedFrom })
: "";
const content =
(fwdMeta ? fwdMeta + "\n" : "") +
(message.content || t("chats.forwardedMessage"));
try {
const targets = Array.from(selected);
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 */
}
return request("POST", "/chat", {
content: sendContent,
receiverId,
senderId: currentUserId,
forwardedFrom: message.forwardedFrom,
});
})
);
toast.success(t("chats.toast.forwardSent", { count: selected.size }));
onClose();
setSelected(new Set());
} catch {
toast.error(t("chats.toast.forwardFailed"));
} finally {
setSending(false);
}
};
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="glass-modal-overlay fixed inset-0 z-[200] flex items-end justify-center sm:items-center"
onClick={onClose}
>
<motion.div
initial={{ y: 40 }}
animate={{ y: 0 }}
exit={{ y: 40 }}
onClick={(e) => e.stopPropagation()}
className="glass-modal-panel max-h-[70vh] w-full max-w-md overflow-hidden rounded-t-3xl sm:rounded-3xl"
>
<div className="border-b border-white/20 p-4 text-center font-semibold">
{t("chats.modal.forwardTo", { selected: selected.size })}
</div>
<div className="max-h-[50vh] overflow-y-auto p-2">
{loading ? (
<div className="flex justify-center py-8">
<IOSSpinner size={24} />
</div>
) : (
users.map((u) => (
<button
key={u._id}
type="button"
onClick={() => toggle(u._id)}
className={`gentle-transition mb-1 flex w-full items-center gap-3 rounded-2xl p-3 ${
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" />
)}
<span className="text-sm">
{u.first_name} {u.last_name}
</span>
</button>
))
)}
</div>
<div className="flex gap-2 border-t border-white/20 p-4">
<button
type="button"
onClick={onClose}
className="flex-1 rounded-full py-2.5 text-sm text-neutral-500"
>
{t("chats.actions.cancel")}
</button>
<button
type="button"
disabled={selected.size === 0 || sending}
onClick={forward}
className="ig-dm-send-btn flex-1 rounded-full py-2.5 text-sm text-white disabled:opacity-50"
>
{sending ? t("chats.actions.sending") : t("chats.actions.send")}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -33,6 +33,8 @@ interface MessageInputProps {
viewOnceMedia?: boolean;
onViewOnceChange?: (value: boolean) => void;
onGiftClick?: () => void;
/** طرف مقابل چت گزینه‌ی «دریافت هدیه» رو غیرفعال کرده — دکمه‌ی هدیه غیرفعال می‌شه */
giftDisabled?: boolean;
}
const formatTime = (seconds: number) => {
@@ -59,6 +61,7 @@ const MessageInput = ({
viewOnceMedia = false,
onViewOnceChange,
onGiftClick,
giftDisabled = false,
}: MessageInputProps) => {
const { t } = useTranslation("common");
const [isRecording, setIsRecording] = useState(false);
@@ -328,7 +331,7 @@ const MessageInput = ({
{onGiftClick && (
<button
type="button"
disabled={blocked_you || isRecording}
disabled={blocked_you || isRecording || giftDisabled}
onClick={onGiftClick}
className={`${chatHeaderCircleBtn} gentle-transition disabled:opacity-40`}
style={chatHeaderGlassStyle}

View File

@@ -35,7 +35,7 @@ export default function SharedPostBubble({ data }: { data: SharedPostPayload })
return (
<Link
href={href}
className="block overflow-hidden rounded-xl border border-white/20 bg-black/10"
className="block overflow-hidden rounded-2xl border border-white/20 bg-black/10"
>
{preview && (
<div className="relative aspect-[4/3] w-full min-w-[200px] max-w-[240px] bg-neutral-900">

View File

@@ -34,7 +34,7 @@ export default function SharedProductBubble({ data }: { data: SharedProductPaylo
return (
<Link
href={href}
className="block overflow-hidden rounded-xl border border-white/20 bg-black/10"
className="block overflow-hidden rounded-2xl border border-white/20 bg-black/10"
>
{preview && (
<div className="relative aspect-[4/3] w-full min-w-[200px] max-w-[240px] bg-neutral-900">

View File

@@ -46,7 +46,7 @@ export default function SharedStoryBubble({
const isLike = data.reactionType === "like";
return (
<div className="block overflow-hidden rounded-xl border border-white/20 bg-black/10">
<div className="block overflow-hidden rounded-2xl border border-white/20 bg-black/10">
<div className="flex items-center gap-1.5 px-2 pt-2 text-[10px] font-semibold opacity-80">
<BoldIcon name="video-vertical" size={14} tinted className="text-current" />
<span>

View File

@@ -78,7 +78,7 @@ export default function TimedMessagePicker({
setOpen(false);
}}
className={cn(
"flex w-full items-center justify-between rounded-xl px-2.5 py-2 text-right text-sm transition-colors",
"flex w-full items-center justify-between rounded-2xl px-2.5 py-2 text-right text-sm transition-colors",
value === opt.value
? "bg-[#2aabee]/12 font-semibold text-[#248cc8] dark:text-[#2aabee]"
: "text-foreground hover:bg-black/5 dark:hover:bg-white/8"

File diff suppressed because it is too large Load Diff

View File

@@ -52,7 +52,7 @@ function ProfileAvatar({
rounded === "full"
? "rounded-full"
: rounded === "xl"
? "rounded-xl"
? "rounded-2xl"
: "rounded-2xl";
const imageSrc = src ? buildStorageUrl(src) : fallback;

View File

@@ -64,7 +64,7 @@ export default function AppearanceColorSelect({
onChange("");
setOpen(false);
}}
className="mb-2 w-full rounded-xl border border-dashed border-neutral-300 py-1.5 text-xs text-neutral-500 dark:border-neutral-600"
className="mb-2 w-full rounded-2xl border border-dashed border-neutral-300 py-1.5 text-xs text-neutral-500 dark:border-neutral-600"
>
{t("models.noFilter")}
</button>
@@ -83,7 +83,7 @@ export default function AppearanceColorSelect({
>
<div
className={cn(
"relative h-14 w-14 overflow-hidden rounded-xl border-4 transition-colors",
"relative h-14 w-14 overflow-hidden rounded-2xl border-4 transition-colors",
isActive
? "border-[#FC8EAC]"
: "border-transparent ring-1 ring-neutral-200 dark:ring-neutral-700"

View File

@@ -16,6 +16,7 @@ import { isModelExpertise } from "@/lib/isModelExpertise";
import RoundedButton from "../elements/RoundedButton";
import { filterChipClass } from "@/lib/ui/buttonStyles";
import { useDefaultCountryId } from "@/hooks/useDefaultCountryId";
import { localizedGeoName } from "@/utils/geoName";
interface IFilterProps {
setShowFilterModal: (e: boolean) => void;
@@ -319,7 +320,7 @@ function FilterModal({
</option>
{allStates?.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
{localizedGeoName(item)}
</option>
))}
</SelectBox>
@@ -333,7 +334,7 @@ function FilterModal({
</option>
{cities?.map((city) => (
<option key={city.id} value={city.id}>
{city.name}
{localizedGeoName(city)}
</option>
))}
</SelectBox>

File diff suppressed because it is too large Load Diff

View File

@@ -38,6 +38,11 @@ function MainModelCardActions({ postData }: { postData: Post }) {
const social = usePostSocialState(postData);
const iconSize = "sm" as const;
// عین allowSave توی ReelsPostCard.tsx/ReelsPostActions.tsx — این کارت (فید خانه) این
// چک رو نداشت و دکمه‌ی ذخیره همیشه نمایش داده می‌شد، حتی وقتی نویسنده allow_save_posts
// رو غیرفعال کرده بود (بک‌اند خودِ toggleBookmark درخواست رو رد می‌کرد، ولی دکمه هنوز
// قابل‌لمس بود و کاربر فقط با خطا مواجه می‌شد)
const allowSave = postData.author_allow_save !== false;
return (
<>
@@ -132,17 +137,19 @@ function MainModelCardActions({ postData }: { postData: Post }) {
<PostSocialIcon {...POST_SOCIAL_ICONS.share(iconSize)} />
}
/>
<SocialStatButton
layout="horizontal"
count={social.savesCount}
onClick={social.toggleSave}
aria-label={social.saved ? t("models.actions.favoriteRemoveAria") : t("models.actions.favoriteAria")}
icon={
<PostSocialIcon
{...POST_SOCIAL_ICONS.save(social.saved, iconSize)}
/>
}
/>
{allowSave ? (
<SocialStatButton
layout="horizontal"
count={social.savesCount}
onClick={social.toggleSave}
aria-label={social.saved ? t("models.actions.favoriteRemoveAria") : t("models.actions.favoriteAria")}
icon={
<PostSocialIcon
{...POST_SOCIAL_ICONS.save(social.saved, iconSize)}
/>
}
/>
) : null}
<SocialStatButton
layout="horizontal"
count={social.commentCount}

View File

@@ -1,188 +1,188 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import Image from "next/image";
import BoldIcon from "@/components/ui/BoldIcon";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
interface MediaFile {
src: string;
type?: "image" | "video";
}
interface SwipeImageSliderProps {
mediaFiles: MediaFile[];
setImageLoading?: React.Dispatch<React.SetStateAction<boolean>>;
}
const SwipeImageSlider: React.FC<SwipeImageSliderProps> = ({
mediaFiles,
setImageLoading,
}) => {
const { t } = useTranslation("common");
const [currentIndex, setCurrentIndex] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const startX = useRef<number | null>(null);
const sliderRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setCurrentIndex(0);
}, [mediaFiles.length]);
const goToPrev = () => {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : mediaFiles.length - 1));
};
const goToNext = () => {
setCurrentIndex((prev) => (prev < mediaFiles.length - 1 ? prev + 1 : 0));
};
const handleStart = (clientX: number) => {
startX.current = clientX;
setIsDragging(true);
};
const handleEnd = (clientX: number) => {
if (!startX.current || !isDragging) return;
const diff = clientX - startX.current;
if (Math.abs(diff) > 50) {
if (diff > 0) {
goToPrev();
} else {
goToNext();
}
}
startX.current = null;
setIsDragging(false);
};
const handleMouseDown = (e: React.MouseEvent) => handleStart(e.clientX);
const handleMouseUp = (e: React.MouseEvent) => handleEnd(e.clientX);
const handleTouchStart = (e: React.TouchEvent) =>
handleStart(e.touches[0].clientX);
const handleTouchEnd = (e: React.TouchEvent) =>
handleEnd(e.changedTouches[0].clientX);
if (!mediaFiles || mediaFiles.length === 0) {
return (
<div className="flex h-64 w-full items-center justify-center rounded-2xl bg-neutral-200 dark:bg-neutral-800">
<p className="text-neutral-500">{t("models.noImage")}</p>
</div>
);
}
const navButtonClass =
"absolute top-1/2 z-20 flex h-10 w-10 -translate-y-1/2 items-center justify-center transition-transform active:scale-90 drop-shadow-[0_1px_4px_rgba(0,0,0,0.45)]";
return (
<div className="group relative w-full select-none">
<div
ref={sliderRef}
className="flex flex-row-reverse overflow-hidden rounded-2xl bg-neutral-100 dark:bg-neutral-900"
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseLeave={() => setIsDragging(false)}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
style={{ cursor: isDragging ? "grabbing" : "grab" }}
>
<div
className="flex flex-row-reverse transition-transform duration-500 ease-out"
style={{
transform: `translateX(-${currentIndex * 100}%)`,
width: `${mediaFiles.length * 100}%`,
}}
>
{mediaFiles.map((file, idx) => (
<div
key={idx}
className="flex w-full shrink-0 items-center justify-center p-2"
style={{ width: "100%" }}
>
{file.type === "video" ? (
<video
src={file.src}
controls
className="max-h-[60vh] w-auto rounded-xl shadow-lg"
onLoadedData={() => setImageLoading?.(false)}
/>
) : (
<Image
src={file.src}
alt={`media-${idx}`}
width={1000}
height={1000}
priority={idx === 0}
loading={idx === 0 ? "eager" : "lazy"}
className="min-h-[30vh] w-full rounded-xl object-contain shadow-lg transition-opacity duration-300"
onLoadingComplete={() => setImageLoading?.(false)}
unoptimized
/>
)}
</div>
))}
</div>
</div>
{mediaFiles.length > 1 && (
<>
<button
type="button"
onClick={goToPrev}
className={cn(
navButtonClass,
"left-3 opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
)}
aria-label={t("models.prevImage")}
>
<BoldIcon
name="arrow-circle-left"
size={36}
tinted
className="text-white"
/>
</button>
<button
type="button"
onClick={goToNext}
className={cn(
navButtonClass,
"right-3 opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
)}
aria-label={t("models.nextImage")}
>
<BoldIcon
name="arrow-circle-right"
size={36}
tinted
className="text-white"
/>
</button>
</>
)}
{mediaFiles.length > 1 && (
<div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 flex-row-reverse gap-1">
{mediaFiles.map((_, idx) => (
<button
key={idx}
type="button"
onClick={() => setCurrentIndex(idx)}
className={`h-1.5 rounded-full transition-all duration-300 ${
idx === currentIndex
? "w-6 bg-white"
: "w-1.5 bg-white/50 hover:bg-white/80"
}`}
aria-label={t("models.goToSlide", { n: idx + 1 })}
/>
))}
</div>
)}
</div>
);
};
export default SwipeImageSlider;
"use client";
import React, { useState, useRef, useEffect } from "react";
import Image from "next/image";
import BoldIcon from "@/components/ui/BoldIcon";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
interface MediaFile {
src: string;
type?: "image" | "video";
}
interface SwipeImageSliderProps {
mediaFiles: MediaFile[];
setImageLoading?: React.Dispatch<React.SetStateAction<boolean>>;
}
const SwipeImageSlider: React.FC<SwipeImageSliderProps> = ({
mediaFiles,
setImageLoading,
}) => {
const { t } = useTranslation("common");
const [currentIndex, setCurrentIndex] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const startX = useRef<number | null>(null);
const sliderRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setCurrentIndex(0);
}, [mediaFiles.length]);
const goToPrev = () => {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : mediaFiles.length - 1));
};
const goToNext = () => {
setCurrentIndex((prev) => (prev < mediaFiles.length - 1 ? prev + 1 : 0));
};
const handleStart = (clientX: number) => {
startX.current = clientX;
setIsDragging(true);
};
const handleEnd = (clientX: number) => {
if (!startX.current || !isDragging) return;
const diff = clientX - startX.current;
if (Math.abs(diff) > 50) {
if (diff > 0) {
goToPrev();
} else {
goToNext();
}
}
startX.current = null;
setIsDragging(false);
};
const handleMouseDown = (e: React.MouseEvent) => handleStart(e.clientX);
const handleMouseUp = (e: React.MouseEvent) => handleEnd(e.clientX);
const handleTouchStart = (e: React.TouchEvent) =>
handleStart(e.touches[0].clientX);
const handleTouchEnd = (e: React.TouchEvent) =>
handleEnd(e.changedTouches[0].clientX);
if (!mediaFiles || mediaFiles.length === 0) {
return (
<div className="flex h-64 w-full items-center justify-center rounded-2xl bg-neutral-200 dark:bg-neutral-800">
<p className="text-neutral-500">{t("models.noImage")}</p>
</div>
);
}
const navButtonClass =
"absolute top-1/2 z-20 flex h-10 w-10 -translate-y-1/2 items-center justify-center transition-transform active:scale-90 drop-shadow-[0_1px_4px_rgba(0,0,0,0.45)]";
return (
<div className="group relative w-full select-none">
<div
ref={sliderRef}
className="flex flex-row-reverse overflow-hidden rounded-2xl bg-neutral-100 dark:bg-neutral-900"
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseLeave={() => setIsDragging(false)}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
style={{ cursor: isDragging ? "grabbing" : "grab" }}
>
<div
className="flex flex-row-reverse transition-transform duration-500 ease-out"
style={{
transform: `translateX(-${currentIndex * 100}%)`,
width: `${mediaFiles.length * 100}%`,
}}
>
{mediaFiles.map((file, idx) => (
<div
key={idx}
className="flex w-full shrink-0 items-center justify-center p-2"
style={{ width: "100%" }}
>
{file.type === "video" ? (
<video
src={file.src}
controls
className="max-h-[60vh] w-auto rounded-2xl shadow-lg"
onLoadedData={() => setImageLoading?.(false)}
/>
) : (
<Image
src={file.src}
alt={`media-${idx}`}
width={1000}
height={1000}
priority={idx === 0}
loading={idx === 0 ? "eager" : "lazy"}
className="min-h-[30vh] w-full rounded-2xl object-contain shadow-lg transition-opacity duration-300"
onLoadingComplete={() => setImageLoading?.(false)}
unoptimized
/>
)}
</div>
))}
</div>
</div>
{mediaFiles.length > 1 && (
<>
<button
type="button"
onClick={goToPrev}
className={cn(
navButtonClass,
"left-3 opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
)}
aria-label={t("models.prevImage")}
>
<BoldIcon
name="arrow-circle-left"
size={36}
tinted
className="text-white"
/>
</button>
<button
type="button"
onClick={goToNext}
className={cn(
navButtonClass,
"right-3 opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
)}
aria-label={t("models.nextImage")}
>
<BoldIcon
name="arrow-circle-right"
size={36}
tinted
className="text-white"
/>
</button>
</>
)}
{mediaFiles.length > 1 && (
<div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 flex-row-reverse gap-1">
{mediaFiles.map((_, idx) => (
<button
key={idx}
type="button"
onClick={() => setCurrentIndex(idx)}
className={`h-1.5 rounded-full transition-all duration-300 ${
idx === currentIndex
? "w-6 bg-white"
: "w-1.5 bg-white/50 hover:bg-white/80"
}`}
aria-label={t("models.goToSlide", { n: idx + 1 })}
/>
))}
</div>
)}
</div>
);
};
export default SwipeImageSlider;

View File

@@ -5,6 +5,7 @@ import BoldIcon from "@/components/ui/BoldIcon";
import { btnPrimary, btnDefault } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import { localizedGeoName } from "@/utils/geoName";
interface LocationModalProps {
isOpen: boolean;
@@ -13,8 +14,8 @@ interface LocationModalProps {
address?: string;
lat?: string;
lng?: string;
city?: { name: string };
province?: { name: string };
city?: { name: string; name_fa?: string };
province?: { name: string; name_fa?: string };
};
}
@@ -61,8 +62,8 @@ export default function LocationModal({
<div className="mb-6 text-sm dark:text-neutral-300 text-gray-600">
<p>
<strong className="dark:text-neutral-100 text-gray-800">{t("models.location.city")} </strong>
{location?.city?.name || t("models.location.unknown")}،{" "}
{location?.province?.name || t("models.location.unknown")}
{localizedGeoName(location?.city) || t("models.location.unknown")}،{" "}
{localizedGeoName(location?.province) || t("models.location.unknown")}
</p>
{location?.address && location.address !== "hn y" && (
<p className="mt-3">

View File

@@ -1,446 +1,446 @@
"use client";
import React, { useState, useRef, useEffect, useContext, createContext } from "react";
import { Post } from "@/types/types";
import Image from "next/image";
import MainModelCardActions from "../MainModelCard/MainModelCardActions";
import Link from "next/link";
import CaptionWithMentions from "@/components/posts/CaptionWithMentions";
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import VerificationBadge from "@/components/main/VerificationBadge";
import ScoreRateStats from "@/components/main/ScoreRateStats";
import { useUserById } from "@/hooks/getUserById";
import Slider from "react-slick";
import "slick-carousel/slick/slick.css";
import "slick-carousel/slick/slick-theme.css";
import { useTranslation } from "react-i18next";
import { getPostVideoSrc } from "@/lib/videoPreload";
import { getVideoPoster } from "@/lib/explore/postMedia";
import { resolvePostMediaType } from "@/lib/reelsMediaType";
// ایجاد Context برای به اشتراک گذاشتن وضعیت muted بین همه کارت‌ها
export const MutedContext = createContext<{
muted: boolean;
setMuted: React.Dispatch<React.SetStateAction<boolean>>;
}>({
muted: true,
setMuted: () => {},
});
function MainModelCard({ postData }: { postData: Post }) {
const {
user_id,
user_name,
first_name,
last_name,
expertise,
city,
user_level,
caption,
files,
type,
} = postData;
const { t } = useTranslation("common");
const user = useUserById(user_id);
const profile_image = user?.profile_image;
const verify_badge = user?.verify_badge;
const user_score = user?.user_score;
const rate = user?.rate;
// console.log(user);
const [showFullCaption, setShowFullCaption] = useState(false);
const [currentSlide, setCurrentSlide] = useState(0);
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);
const displayCaption = showFullCaption ? caption : caption?.slice(0, 40);
const subExpertise = user?.sub_expertise || [];
const keywords = ["نمونه کار دارم", "آرایشگر سیار", "عکاس سیار"];
const keywords2 = ["آموزشگاه دارم"];
const keywords3 = ["سالن دارم", "آتیله دارم"];
const matchedExpertise = subExpertise.filter((item) =>
keywords.some((keyword) => item.includes(keyword))
);
const matchedExpertise2 = subExpertise.filter((item) =>
keywords2.some((keyword) => item.includes(keyword))
);
const matchedExpertise3 = subExpertise.filter((item) =>
keywords3.some((keyword) => item.includes(keyword))
);
const mediaFiles =
files?.map((file) => ({
...file,
src: buildStorageUrl(file.path),
})) || [];
const mediaType = resolvePostMediaType(postData) ?? type;
const videoSrc =
mediaType === "video" ? getPostVideoSrc(postData) : undefined;
const videoPoster =
mediaType === "video" ? getVideoPoster(postData) : undefined;
const sliderSettings = {
dots: mediaFiles.length > 1,
infinite: mediaFiles.length > 1,
speed: 500,
slidesToShow: 1,
slidesToScroll: 1,
arrows: true,
autoplay: false,
adaptiveHeight: false,
lazyLoad: "progressive" as const,
afterChange: (index: number) => setCurrentSlide(index),
};
// تابع برای ایجاد افکت ripple و تغییر وضعیت صدا
const handleVideoClick = (e: React.MouseEvent<HTMLVideoElement>) => {
const v = videoRef.current;
if (!v) return;
// گرفتن مختصات کلیک نسبت به ویدیو
const rect = v.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// تنظیم افکت ripple
setRipple({ x, y, key: Date.now() });
// تغییر وضعیت صدا (گلوبال)
const newMuted = !muted;
setMuted(newMuted);
v.muted = newMuted;
if (!newMuted) {
v.play().catch(() => {
/* ignore */
});
}
// حذف افکت بعد از اتمام انیمیشن
setTimeout(() => {
setRipple(null);
}, 600); // مدت زمان انیمیشن (600ms)
};
// استفاده از IntersectionObserver برای پخش/توقف ویدیو بر اساس ویوپورت
useEffect(() => {
const video = videoRef.current;
if (!video || type !== "video") return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// ویدیو در ویوپورت است: پخش کن
video.play().catch(() => {
/* ignore: ممکن است به دلیل محدودیت‌های مرورگر */
});
video.muted = muted; // اعمال وضعیت گلوبال
} else {
// ویدیو خارج از ویوپورت: توقف کن
video.pause();
}
});
},
{
threshold: 0.5, // حداقل 50% ویدیو باید در ویوپورت باشد
}
);
observer.observe(video);
return () => {
observer.unobserve(video);
};
}, [muted, type]);
// اعمال وضعیت muted گلوبال هنگام mount یا تغییر muted
useEffect(() => {
const video = videoRef.current;
if (video) {
video.muted = muted;
}
}, [muted]);
return (
<div className="w-full z-[999] min-h-[85vh] max-h-[90vh] flex flex-col px-4 mx-auto max-w-[992px] mb-6 overflow-hidden">
<Link
href={`/users/${user_name}`}
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-xl object-cover aspect-square"
priority
/>
</div>
<div className="font-semibold flex gap-2 flex-col-reverse ml-2 w-full">
<div className="flex items-center gap-1 mt-1">
<span className="max-sm:text-xs inline-flex items-center gap-1">
{user_name}
<VerificationBadge verifyBadge={verify_badge} />
</span>
<ScoreRateStats score={user_score} rate={rate} />
</div>
<div className="flex justify-between items-center">
<div className="max-sm:text-xs">
{first_name} {last_name}
</div>
<div className="hidden">
<div className="text-[#15a9d6] pt-1 flex gap-5 mt-3 font-semibold">
{matchedExpertise2.length > 0 && (
<span>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.99953 5.17031L4.02953 6.46031C2.09953 7.72031 2.09953 10.5403 4.02953 11.8003L10.0495 15.7303C11.1295 16.4403 12.9095 16.4403 13.9895 15.7303L19.9795 11.8003C21.8995 10.5403 21.8995 7.73031 19.9795 6.47031L13.9895 2.54031C12.9095 1.83031 11.1295 1.83031 10.0495 2.54031"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M5.63012 13.0801L5.62012 17.7701C5.62012 19.0401 6.60012 20.4001 7.80012 20.8001L10.9901 21.8601C11.5401 22.0401 12.4501 22.0401 13.0101 21.8601L16.2001 20.8001C17.4001 20.4001 18.3801 19.0401 18.3801 17.7701V13.1301"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M21.4004 15V9"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
)}
{matchedExpertise3.length > 0 && (
<span>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 22H22"
stroke="currentColor"
strokeWidth="1.5"
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 2C13.6 2.64 15.4 2.64 17 2V5C15.4 5.64 13.6 5.64 12 5V2Z"
stroke="currentColor"
strokeWidth="1.5"
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 5V8"
stroke="currentColor"
strokeWidth="1.5"
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M4 15.91V22H20V11C20 9 19 8 17 8H7C5 8 4 9 4 11V12"
stroke="currentColor"
strokeWidth="1.5"
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M4 12H19.42"
stroke="currentColor"
strokeWidth="1.5"
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7.98999 12V22"
stroke="currentColor"
strokeWidth="1.5"
strokeMiterlimit="10"
strokeLinejoin="round"
/>
<path
d="M11.99 12V22"
stroke="currentColor"
strokeWidth="1.5"
strokeMiterlimit="10"
strokeLinejoin="round"
/>
<path
d="M15.99 12V22"
stroke="currentColor"
strokeWidth="1.5"
strokeMiterlimit="10"
strokeLinejoin="round"
/>
</svg>
</span>
)}
</div>
</div>
</div>
<div className="max-sm:text-xs items-center text-[#387E65] flex w-full justify-between">
{user_level || t("models.newcomer")}
<div className="text-[#387E65] pt-1 flex gap-5 font-semibold">
<span>{expertise}</span>
<span className="text-[#387E65] font-semibold">
{city?.name}
</span>
{matchedExpertise.map((item, index) => (
<span key={index} className="text-[#387E65] font-semibold">
{item}
</span>
))}
</div>
</div>
</div>
</div>
</Link>
<div className="relative flex-grow overflow-hidden rounded-2xl">
{type === "image" && mediaFiles.length > 0 && (
<div className="relative w-full h-full">
<Slider {...sliderSettings}>
{mediaFiles.map((file, idx) => (
<div
key={idx}
className="relative w-full h-[calc(100dvh-290px)] overflow-hidden"
>
<Image
src={file.src}
alt={`post-image-${idx}`}
fill
className="object-contain h-full"
onLoadingComplete={() => setImageLoading(false)}
loading={idx === 0 ? "eager" : "lazy"}
/>
{imageLoading && idx === currentSlide && (
<div className="absolute inset-0 flex justify-center items-center bg-black/30 z-10">
<div className="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
</div>
)}
</div>
))}
</Slider>
{mediaFiles.length > 1 && (
<div className="absolute bottom-2 right-2 bg-black/40 text-white px-3 py-1 rounded-full text-sm">
{currentSlide + 1} / {mediaFiles.length}
</div>
)}
</div>
)}
{mediaType === "video" && videoSrc && (
<div className="relative w-full h-[calc(100dvh-150px)] overflow-hidden">
<video
onClick={handleVideoClick}
ref={videoRef}
src={videoSrc}
poster={videoPoster}
loop
playsInline
className="w-full h-full object-contain"
onLoadedData={() => setImageLoading(false)}
muted={muted} // این مقدار همیشه از گلوبال گرفته می‌شود
// autoPlay حذف شده، چون حالا با observer مدیریت می‌شود
/>
{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}
/>
)}
{imageLoading && (
<div className="absolute inset-0 flex justify-center items-center bg-black/30 z-10">
<div className="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
</div>
)}
</div>
)}
</div>
<MainModelCardActions postData={postData} />
<p className="text-sm font-medium mt-2 mx-2 leading-6 whitespace-pre-wrap">
<CaptionWithMentions
text={displayCaption || ""}
fullText={showFullCaption ? undefined : caption}
/>
{caption?.length && caption.length > 40 && (
<span
className="text-blue-500 hover:underline cursor-pointer ml-1 transition-colors duration-200"
onClick={() => setShowFullCaption(!showFullCaption)}
>
{showFullCaption ? t("posts.less") : t("posts.more")}
</span>
)}
</p>
<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>
);
}
// Provider برای به اشتراک گذاشتن وضعیت muted
export const MutedProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [muted, setMuted] = useState(true);
return (
<MutedContext.Provider value={{ muted, setMuted }}>
{children}
</MutedContext.Provider>
);
};
export default MainModelCard;
"use client";
import React, { useState, useRef, useEffect, useContext, createContext } from "react";
import { Post } from "@/types/types";
import Image from "next/image";
import MainModelCardActions from "../MainModelCard/MainModelCardActions";
import Link from "next/link";
import CaptionWithMentions from "@/components/posts/CaptionWithMentions";
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import VerificationBadge from "@/components/main/VerificationBadge";
import ScoreRateStats from "@/components/main/ScoreRateStats";
import { useUserById } from "@/hooks/getUserById";
import Slider from "react-slick";
import "slick-carousel/slick/slick.css";
import "slick-carousel/slick/slick-theme.css";
import { useTranslation } from "react-i18next";
import { getPostVideoSrc } from "@/lib/videoPreload";
import { getVideoPoster } from "@/lib/explore/postMedia";
import { resolvePostMediaType } from "@/lib/reelsMediaType";
// ایجاد Context برای به اشتراک گذاشتن وضعیت muted بین همه کارت‌ها
export const MutedContext = createContext<{
muted: boolean;
setMuted: React.Dispatch<React.SetStateAction<boolean>>;
}>({
muted: true,
setMuted: () => {},
});
function MainModelCard({ postData }: { postData: Post }) {
const {
user_id,
user_name,
first_name,
last_name,
expertise,
city,
user_level,
caption,
files,
type,
} = postData;
const { t } = useTranslation("common");
const user = useUserById(user_id);
const profile_image = user?.profile_image;
const verify_badge = user?.verify_badge;
const user_score = user?.user_score;
const rate = user?.rate;
// console.log(user);
const [showFullCaption, setShowFullCaption] = useState(false);
const [currentSlide, setCurrentSlide] = useState(0);
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);
const displayCaption = showFullCaption ? caption : caption?.slice(0, 40);
const subExpertise = user?.sub_expertise || [];
const keywords = ["نمونه کار دارم", "آرایشگر سیار", "عکاس سیار"];
const keywords2 = ["آموزشگاه دارم"];
const keywords3 = ["سالن دارم", "آتیله دارم"];
const matchedExpertise = subExpertise.filter((item) =>
keywords.some((keyword) => item.includes(keyword))
);
const matchedExpertise2 = subExpertise.filter((item) =>
keywords2.some((keyword) => item.includes(keyword))
);
const matchedExpertise3 = subExpertise.filter((item) =>
keywords3.some((keyword) => item.includes(keyword))
);
const mediaFiles =
files?.map((file) => ({
...file,
src: buildStorageUrl(file.path),
})) || [];
const mediaType = resolvePostMediaType(postData) ?? type;
const videoSrc =
mediaType === "video" ? getPostVideoSrc(postData) : undefined;
const videoPoster =
mediaType === "video" ? getVideoPoster(postData) : undefined;
const sliderSettings = {
dots: mediaFiles.length > 1,
infinite: mediaFiles.length > 1,
speed: 500,
slidesToShow: 1,
slidesToScroll: 1,
arrows: true,
autoplay: false,
adaptiveHeight: false,
lazyLoad: "progressive" as const,
afterChange: (index: number) => setCurrentSlide(index),
};
// تابع برای ایجاد افکت ripple و تغییر وضعیت صدا
const handleVideoClick = (e: React.MouseEvent<HTMLVideoElement>) => {
const v = videoRef.current;
if (!v) return;
// گرفتن مختصات کلیک نسبت به ویدیو
const rect = v.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// تنظیم افکت ripple
setRipple({ x, y, key: Date.now() });
// تغییر وضعیت صدا (گلوبال)
const newMuted = !muted;
setMuted(newMuted);
v.muted = newMuted;
if (!newMuted) {
v.play().catch(() => {
/* ignore */
});
}
// حذف افکت بعد از اتمام انیمیشن
setTimeout(() => {
setRipple(null);
}, 600); // مدت زمان انیمیشن (600ms)
};
// استفاده از IntersectionObserver برای پخش/توقف ویدیو بر اساس ویوپورت
useEffect(() => {
const video = videoRef.current;
if (!video || type !== "video") return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// ویدیو در ویوپورت است: پخش کن
video.play().catch(() => {
/* ignore: ممکن است به دلیل محدودیت‌های مرورگر */
});
video.muted = muted; // اعمال وضعیت گلوبال
} else {
// ویدیو خارج از ویوپورت: توقف کن
video.pause();
}
});
},
{
threshold: 0.5, // حداقل 50% ویدیو باید در ویوپورت باشد
}
);
observer.observe(video);
return () => {
observer.unobserve(video);
};
}, [muted, type]);
// اعمال وضعیت muted گلوبال هنگام mount یا تغییر muted
useEffect(() => {
const video = videoRef.current;
if (video) {
video.muted = muted;
}
}, [muted]);
return (
<div className="w-full z-[999] min-h-[85vh] max-h-[90vh] flex flex-col px-4 mx-auto max-w-[992px] mb-6 overflow-hidden">
<Link
href={`/users/${user_name}`}
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>
<div className="font-semibold flex gap-2 flex-col-reverse ml-2 w-full">
<div className="flex items-center gap-1 mt-1">
<span className="max-sm:text-xs inline-flex items-center gap-1">
{user_name}
<VerificationBadge verifyBadge={verify_badge} />
</span>
<ScoreRateStats score={user_score} rate={rate} />
</div>
<div className="flex justify-between items-center">
<div className="max-sm:text-xs">
{first_name} {last_name}
</div>
<div className="hidden">
<div className="text-[#15a9d6] pt-1 flex gap-5 mt-3 font-semibold">
{matchedExpertise2.length > 0 && (
<span>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.99953 5.17031L4.02953 6.46031C2.09953 7.72031 2.09953 10.5403 4.02953 11.8003L10.0495 15.7303C11.1295 16.4403 12.9095 16.4403 13.9895 15.7303L19.9795 11.8003C21.8995 10.5403 21.8995 7.73031 19.9795 6.47031L13.9895 2.54031C12.9095 1.83031 11.1295 1.83031 10.0495 2.54031"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M5.63012 13.0801L5.62012 17.7701C5.62012 19.0401 6.60012 20.4001 7.80012 20.8001L10.9901 21.8601C11.5401 22.0401 12.4501 22.0401 13.0101 21.8601L16.2001 20.8001C17.4001 20.4001 18.3801 19.0401 18.3801 17.7701V13.1301"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M21.4004 15V9"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
)}
{matchedExpertise3.length > 0 && (
<span>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2 22H22"
stroke="currentColor"
strokeWidth="1.5"
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 2C13.6 2.64 15.4 2.64 17 2V5C15.4 5.64 13.6 5.64 12 5V2Z"
stroke="currentColor"
strokeWidth="1.5"
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M12 5V8"
stroke="currentColor"
strokeWidth="1.5"
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M4 15.91V22H20V11C20 9 19 8 17 8H7C5 8 4 9 4 11V12"
stroke="currentColor"
strokeWidth="1.5"
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M4 12H19.42"
stroke="currentColor"
strokeWidth="1.5"
strokeMiterlimit="10"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7.98999 12V22"
stroke="currentColor"
strokeWidth="1.5"
strokeMiterlimit="10"
strokeLinejoin="round"
/>
<path
d="M11.99 12V22"
stroke="currentColor"
strokeWidth="1.5"
strokeMiterlimit="10"
strokeLinejoin="round"
/>
<path
d="M15.99 12V22"
stroke="currentColor"
strokeWidth="1.5"
strokeMiterlimit="10"
strokeLinejoin="round"
/>
</svg>
</span>
)}
</div>
</div>
</div>
<div className="max-sm:text-xs items-center text-[#387E65] flex w-full justify-between">
{user_level || t("models.newcomer")}
<div className="text-[#387E65] pt-1 flex gap-5 font-semibold">
<span>{expertise}</span>
<span className="text-[#387E65] font-semibold">
{city?.name}
</span>
{matchedExpertise.map((item, index) => (
<span key={index} className="text-[#387E65] font-semibold">
{item}
</span>
))}
</div>
</div>
</div>
</div>
</Link>
<div className="relative flex-grow overflow-hidden rounded-2xl">
{type === "image" && mediaFiles.length > 0 && (
<div className="relative w-full h-full">
<Slider {...sliderSettings}>
{mediaFiles.map((file, idx) => (
<div
key={idx}
className="relative w-full h-[calc(100dvh-290px)] overflow-hidden"
>
<Image
src={file.src}
alt={`post-image-${idx}`}
fill
className="object-contain h-full"
onLoadingComplete={() => setImageLoading(false)}
loading={idx === 0 ? "eager" : "lazy"}
/>
{imageLoading && idx === currentSlide && (
<div className="absolute inset-0 flex justify-center items-center bg-black/30 z-10">
<div className="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
</div>
)}
</div>
))}
</Slider>
{mediaFiles.length > 1 && (
<div className="absolute bottom-2 right-2 bg-black/40 text-white px-3 py-1 rounded-full text-sm">
{currentSlide + 1} / {mediaFiles.length}
</div>
)}
</div>
)}
{mediaType === "video" && videoSrc && (
<div className="relative w-full h-[calc(100dvh-150px)] overflow-hidden">
<video
onClick={handleVideoClick}
ref={videoRef}
src={videoSrc}
poster={videoPoster}
loop
playsInline
className="w-full h-full object-contain"
onLoadedData={() => setImageLoading(false)}
muted={muted} // این مقدار همیشه از گلوبال گرفته می‌شود
// autoPlay حذف شده، چون حالا با observer مدیریت می‌شود
/>
{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}
/>
)}
{imageLoading && (
<div className="absolute inset-0 flex justify-center items-center bg-black/30 z-10">
<div className="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
</div>
)}
</div>
)}
</div>
<MainModelCardActions postData={postData} />
<p className="text-sm font-medium mt-2 mx-2 leading-6 whitespace-pre-wrap">
<CaptionWithMentions
text={displayCaption || ""}
fullText={showFullCaption ? undefined : caption}
/>
{caption?.length && caption.length > 40 && (
<span
className="text-blue-500 hover:underline cursor-pointer ml-1 transition-colors duration-200"
onClick={() => setShowFullCaption(!showFullCaption)}
>
{showFullCaption ? t("posts.less") : t("posts.more")}
</span>
)}
</p>
<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>
);
}
// Provider برای به اشتراک گذاشتن وضعیت muted
export const MutedProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [muted, setMuted] = useState(true);
return (
<MutedContext.Provider value={{ muted, setMuted }}>
{children}
</MutedContext.Provider>
);
};
export default MainModelCard;

View File

@@ -335,7 +335,11 @@ function ModelContent({ user, searchParams }: ModelContentProps) {
<button
type="button"
onClick={goToGift}
className={profileActionBtnClass}
disabled={!isOwnProfile && user?.gift_enabled === false}
className={cn(
profileActionBtnClass,
!isOwnProfile && user?.gift_enabled === false && "cursor-not-allowed opacity-40"
)}
>
{t("models.actions.specialGift")}
</button>

View File

@@ -131,7 +131,7 @@ function ModelContentPosts({
<button
type="button"
key={post._id}
className="relative aspect-square cursor-pointer overflow-hidden rounded-md sm:rounded-xl 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"
onClick={() => openPost(post)}
aria-label={t("models.viewPost")}
>

View File

@@ -99,7 +99,7 @@ export default function ProfileUserListModal({
<li key={u._id}>
<Link
href={`/users/${u.user_name}`}
className="flex items-center gap-3 rounded-xl px-2 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-800"
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">

View File

@@ -128,7 +128,7 @@ function LinkSection({
<p className="text-xs font-semibold text-neutral-600 dark:text-neutral-300">
{label} ({t("posts.optional")})
</p>
<p className="rounded-xl border border-dashed border-neutral-200 px-3 py-2 text-xs text-neutral-400 dark:border-neutral-700">
<p className="rounded-2xl border border-dashed border-neutral-200 px-3 py-2 text-xs text-neutral-400 dark:border-neutral-700">
{emptyText}
</p>
</div>
@@ -140,7 +140,7 @@ function LinkSection({
<p className="text-xs font-semibold text-neutral-600 dark:text-neutral-300">
{label} ({t("posts.optionalMulti")})
</p>
<ul className="max-h-36 space-y-1.5 overflow-y-auto rounded-xl border border-neutral-200 p-2 dark:border-neutral-700">
<ul className="max-h-36 space-y-1.5 overflow-y-auto rounded-2xl border border-neutral-200 p-2 dark:border-neutral-700">
{items.map((item) => (
<LinkListItem
key={item._id}
@@ -224,7 +224,7 @@ function ShopLinkSection({
<p className="text-xs font-semibold text-neutral-600 dark:text-neutral-300">
{t("posts.shop")} ({t("posts.optional")})
</p>
<p className="rounded-xl border border-dashed border-neutral-200 px-3 py-2 text-xs text-neutral-400 dark:border-neutral-700">
<p className="rounded-2xl border border-dashed border-neutral-200 px-3 py-2 text-xs text-neutral-400 dark:border-neutral-700">
{t("posts.noShop")}
</p>
</div>
@@ -236,7 +236,7 @@ function ShopLinkSection({
<p className="text-xs font-semibold text-neutral-600 dark:text-neutral-300">
{t("posts.shop")} ({t("posts.optionalMulti")})
</p>
<ul className="max-h-64 space-y-1.5 overflow-y-auto rounded-xl border border-neutral-200 p-2 dark:border-neutral-700">
<ul className="max-h-64 space-y-1.5 overflow-y-auto rounded-2xl border border-neutral-200 p-2 dark:border-neutral-700">
{shops.map((shop) => {
const isExpanded = expandedShopId === shop._id;
const products = productsByShop[shop._id] || [];

View File

@@ -141,7 +141,7 @@ export default function ReelsPostAnalyticsModal({
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="h-10 animate-pulse rounded-xl bg-neutral-100 dark:bg-neutral-800"
className="h-10 animate-pulse rounded-2xl bg-neutral-100 dark:bg-neutral-800"
/>
))}
</div>

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