order
This commit is contained in:
966
package-lock.json
generated
966
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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 = [
|
||||
"/",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -728,6 +728,7 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
viewOnceMedia={viewOnceMedia}
|
||||
onViewOnceChange={setViewOnceMedia}
|
||||
onGiftClick={() => setShowGiftModal(true)}
|
||||
giftDisabled={userTwoDetail?.gift_enabled === false}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" && (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
57
src/app/settings/edit/verification-guide/page.tsx
Normal file
57
src/app/settings/edit/verification-guide/page.tsx
Normal 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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
57
src/components/PushNotificationListener.tsx
Normal file
57
src/components/PushNotificationListener.tsx
Normal 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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -52,7 +52,7 @@ function ProfileAvatar({
|
||||
rounded === "full"
|
||||
? "rounded-full"
|
||||
: rounded === "xl"
|
||||
? "rounded-xl"
|
||||
? "rounded-2xl"
|
||||
: "rounded-2xl";
|
||||
|
||||
const imageSrc = src ? buildStorageUrl(src) : fallback;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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] || [];
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user