Compare commits
3 Commits
c879976c56
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76ef4e32a9 | ||
|
|
1ba6646bf9 | ||
|
|
0c6e1a7125 |
@@ -23,12 +23,6 @@ const nextConfig = {
|
||||
port: '',
|
||||
pathname: '/**',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'blog.modstagram.com', // اجازه دادن به نمایش تصاویر وبلاگ
|
||||
port: '',
|
||||
pathname: '/**',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'api.modstagram.com',
|
||||
@@ -96,20 +90,7 @@ const nextConfig = {
|
||||
];
|
||||
},
|
||||
|
||||
// ۴. فقط وبلاگ — API/storage از Route Handler پروکسی میشوند
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/blog',
|
||||
destination: 'https://blog.modstagram.com/blog',
|
||||
},
|
||||
{
|
||||
source: '/blog/:path*',
|
||||
destination: 'https://blog.modstagram.com/blog/:path*',
|
||||
},
|
||||
];
|
||||
},
|
||||
// ۵. تنظیمات هدرها
|
||||
// ۴. تنظیمات هدرها
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import BackButton from "@/components/ui/BackButton";
|
||||
import Container from "@/components/elements/Container";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import SpecialistCarousel from "@/components/projects/SpecialistCarousel";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -468,6 +469,18 @@ export default function SpecialistsPage() {
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{phase === "results" && !showFilter && users.length > 0 ? (
|
||||
<SpecialistCarousel
|
||||
items={users}
|
||||
title={t("projects.specialistCarousel.title", "متخصصین پیشنهادی")}
|
||||
description={t(
|
||||
"projects.specialistCarousel.description",
|
||||
"با استعدادهای برتر مطابق فیلترتان آشنا شوید"
|
||||
)}
|
||||
className="mt-8"
|
||||
/>
|
||||
) : null}
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import BillboardDetailContent from "@/components/billboards/BillboardDetailConte
|
||||
import BillboardNotFound from "@/components/billboards/BillboardNotFound";
|
||||
import { IAdvertising, IRate } from "@/types/types";
|
||||
import { cookies } from "next/headers";
|
||||
import { cache } from "react";
|
||||
import { Metadata } from "next";
|
||||
import { generatePageMetadata } from "@/utils/generatePageMetadata";
|
||||
import { getLocaleBundle } from "@/lib/i18n/resources";
|
||||
@@ -12,7 +13,8 @@ interface IBillboardProps {
|
||||
params: Promise<{ id: string; title: string }>;
|
||||
}
|
||||
|
||||
async function getBillboardData(id: string, token: string) {
|
||||
// Deduped per request — see loadUser in users/[username]/page.tsx for rationale.
|
||||
const getBillboardData = cache(async (id: string, token: string) => {
|
||||
const response = await fetch(`${BASE_URL}/advertising/get/web/${id}`, {
|
||||
next: { revalidate: 60 },
|
||||
headers: {
|
||||
@@ -21,7 +23,7 @@ async function getBillboardData(id: string, token: string) {
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
return await response.json();
|
||||
}
|
||||
});
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
|
||||
@@ -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"
|
||||
@@ -213,7 +213,7 @@ function BookingFlowPage() {
|
||||
{t("booking.noAvailableSlots")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="mx-auto flex w-full max-w-xs flex-col gap-2">
|
||||
{slots.map((slot) => (
|
||||
<button
|
||||
type="button"
|
||||
@@ -222,7 +222,7 @@ function BookingFlowPage() {
|
||||
setSelectedSlot(slot);
|
||||
setStep("confirm");
|
||||
}}
|
||||
className={`rounded-xl border px-3 py-2 text-xs ${
|
||||
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"
|
||||
@@ -270,7 +270,7 @@ function BookingFlowPage() {
|
||||
</div>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10"
|
||||
className="mx-auto h-10 w-full max-w-xs"
|
||||
disabled={loading}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
|
||||
@@ -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,8 +99,9 @@ export default async function RootLayout({
|
||||
<body className={`${iranSansFont.className} antialiased`}>
|
||||
<SiteJsonLd />
|
||||
<RegisterSW />
|
||||
<PushNotificationListener />
|
||||
<ClientErrorBoundary>
|
||||
<Layout>{children}</Layout>
|
||||
<Layout initialLanguage={lang}>{children}</Layout>
|
||||
</ClientErrorBoundary>
|
||||
{/* ====================== گوگل آنالیتیکس ====================== */}
|
||||
<Script
|
||||
|
||||
@@ -709,6 +709,7 @@ export default function NewPostPage() {
|
||||
<CountryProvinceCitySelect
|
||||
value={postGeoSelection}
|
||||
onChange={setPostGeoSelection}
|
||||
lockToUserCountry
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
@@ -63,7 +63,17 @@ export default function BookingListPage() {
|
||||
</p>
|
||||
) : (
|
||||
filteredConfigs.map((config) => (
|
||||
<BookingListCard key={config._id} config={config} />
|
||||
<BookingListCard
|
||||
key={config._id}
|
||||
config={config}
|
||||
onStatusChange={(id, status) =>
|
||||
setConfigs((prev) =>
|
||||
prev
|
||||
? prev.map((c) => (c._id === id ? { ...c, status } : c))
|
||||
: prev
|
||||
)
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -160,7 +160,7 @@ function BookingServicesPage() {
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full max-w-sm flex-col">
|
||||
<div className="flex w-full max-w-sm flex-col gap-2">
|
||||
{services.map((service) => {
|
||||
const id = service.id || service._id || "";
|
||||
const selected = selectedIds.has(id);
|
||||
@@ -173,22 +173,26 @@ function BookingServicesPage() {
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") toggle(id);
|
||||
}}
|
||||
className={`relative w-full cursor-pointer rounded-2xl ${
|
||||
selected ? "bg-[#0095f6]/10" : ""
|
||||
className={`flex w-full cursor-pointer items-center gap-2 rounded-2xl pe-2 ${
|
||||
selected
|
||||
? "bg-[#0095f6]/10 ring-1 ring-[#0095f6]/50 dark:bg-[#0095f6]/15"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute -right-1 top-1/2 z-10 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-full border-2 ${
|
||||
className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 ${
|
||||
selected
|
||||
? "border-[#0095f6] bg-[#0095f6]"
|
||||
: "border-neutral-300 bg-white dark:border-neutral-600 dark:bg-neutral-900"
|
||||
: "border-neutral-300 bg-white dark:border-neutral-500 dark:bg-neutral-900"
|
||||
}`}
|
||||
>
|
||||
{selected && (
|
||||
<span className="h-2 w-2 rounded-full bg-white" />
|
||||
)}
|
||||
</span>
|
||||
<ServiceItem service={service} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<ServiceItem service={service} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -728,6 +728,7 @@ function TicketChat({ params }: ITicketChatProps) {
|
||||
viewOnceMedia={viewOnceMedia}
|
||||
onViewOnceChange={setViewOnceMedia}
|
||||
onGiftClick={() => setShowGiftModal(true)}
|
||||
giftDisabled={userTwoDetail?.gift_enabled === false}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
288
src/app/settings/chats/find-partner/page.tsx
Normal file
288
src/app/settings/chats/find-partner/page.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import Container from "@/components/elements/Container";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import VerificationBadge from "@/components/main/VerificationBadge";
|
||||
import LocalePageShell from "@/components/i18n/LocalePageShell";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import BackButton from "@/components/ui/BackButton";
|
||||
import IOSSpinner from "@/components/ui/IOSSpinner";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { usePageTitle } from "@/hooks/usePageTitle";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatFullName } from "@/lib/formatFullName";
|
||||
import toast from "react-hot-toast";
|
||||
import FindPartnerFilterModal, {
|
||||
EMPTY_PARTNER_FILTERS,
|
||||
PartnerFilters,
|
||||
} from "@/components/chats/FindPartnerFilterModal";
|
||||
|
||||
type PartnerUser = {
|
||||
_id: string;
|
||||
user_name: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
profile_image?: string;
|
||||
is_verified?: string;
|
||||
verify_badge?: string;
|
||||
marital_status?: string | null;
|
||||
vehicle_type?: string | null;
|
||||
job?: string | null;
|
||||
birthday?: string | null;
|
||||
province?: { name?: string } | null;
|
||||
city?: { name?: string } | null;
|
||||
};
|
||||
|
||||
type PartnerAlert = { _id: string } | null;
|
||||
|
||||
function ageFromBirthday(birthday?: string | null): number | null {
|
||||
if (!birthday) return null;
|
||||
const diffMs = Date.now() - new Date(birthday).getTime();
|
||||
if (Number.isNaN(diffMs)) return null;
|
||||
return Math.floor(diffMs / (365.25 * 24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
function filtersToParams(filters: PartnerFilters): Record<string, string> {
|
||||
const params: Record<string, string> = {};
|
||||
const put = (key: string, value?: string) => {
|
||||
if (value) params[key] = value;
|
||||
};
|
||||
put("gender", filters.gender);
|
||||
put("ageMin", filters.ageMin);
|
||||
put("ageMax", filters.ageMax);
|
||||
put("heightMin", filters.heightMin);
|
||||
put("heightMax", filters.heightMax);
|
||||
put("weightMin", filters.weightMin);
|
||||
put("weightMax", filters.weightMax);
|
||||
put("sizeMin", filters.sizeMin);
|
||||
put("sizeMax", filters.sizeMax);
|
||||
put("hair_color", filters.hairColor);
|
||||
put("eye_color", filters.eyeColor);
|
||||
put("marital_status", filters.maritalStatus);
|
||||
put("residence_type", filters.residenceType);
|
||||
put("vehicle_type", filters.vehicleType);
|
||||
put("personal_style", filters.personalStyle);
|
||||
put("income_range", filters.incomeRange);
|
||||
put("job", filters.job);
|
||||
if (filters.geo.countryId) params.country = String(filters.geo.countryId);
|
||||
if (filters.geo.provinceId) params.province = String(filters.geo.provinceId);
|
||||
if (filters.geo.cityId) params.city = String(filters.geo.cityId);
|
||||
return params;
|
||||
}
|
||||
|
||||
export default function FindPartnerPage() {
|
||||
const { t } = useTranslation("common");
|
||||
usePageTitle(t("findPartner.title"));
|
||||
const { request } = useAxios();
|
||||
|
||||
const [filters, setFilters] = useState<PartnerFilters>(EMPTY_PARTNER_FILTERS);
|
||||
const [showFilterModal, setShowFilterModal] = useState(false);
|
||||
const [users, setUsers] = useState<PartnerUser[]>([]);
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [alert, setAlert] = useState<PartnerAlert>(null);
|
||||
const [savingAlert, setSavingAlert] = useState(false);
|
||||
|
||||
const runSearch = useCallback(
|
||||
async (activeFilters: PartnerFilters) => {
|
||||
setStatus("loading");
|
||||
try {
|
||||
const params = filtersToParams(activeFilters);
|
||||
const query = new URLSearchParams(params).toString();
|
||||
const res = await request<{ users?: PartnerUser[] }>(
|
||||
"GET",
|
||||
`/users/partner-search${query ? `?${query}` : ""}`,
|
||||
null,
|
||||
{ noToast: true }
|
||||
);
|
||||
setUsers(res?.users ?? []);
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
},
|
||||
[request]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void runSearch(EMPTY_PARTNER_FILTERS);
|
||||
request<{ alert: PartnerAlert }>("GET", "/users/partner-search/alerts", null, {
|
||||
noToast: true,
|
||||
})
|
||||
.then((res) => setAlert(res?.alert ?? null))
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleApplyFilters = () => {
|
||||
void runSearch(filters);
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setFilters(EMPTY_PARTNER_FILTERS);
|
||||
void runSearch(EMPTY_PARTNER_FILTERS);
|
||||
setShowFilterModal(false);
|
||||
};
|
||||
|
||||
const handleSaveSearch = async () => {
|
||||
setSavingAlert(true);
|
||||
try {
|
||||
const res = await request<{ alert: PartnerAlert }>(
|
||||
"POST",
|
||||
"/users/partner-search/alerts",
|
||||
filtersToParams(filters)
|
||||
);
|
||||
setAlert(res?.alert ?? null);
|
||||
toast.success(t("findPartner.searchSaved"));
|
||||
} catch {
|
||||
// خطا از طریق interceptor نمایش داده میشود
|
||||
} finally {
|
||||
setSavingAlert(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelSearch = async () => {
|
||||
if (!alert?._id) return;
|
||||
setSavingAlert(true);
|
||||
try {
|
||||
await request("DELETE", `/users/partner-search/alerts/${alert._id}`);
|
||||
setAlert(null);
|
||||
toast.success(t("findPartner.searchCancelled"));
|
||||
} catch {
|
||||
// خطا از طریق interceptor نمایش داده میشود
|
||||
} finally {
|
||||
setSavingAlert(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container className="pb-28">
|
||||
<div className="mb-4 flex items-center gap-3 pt-2">
|
||||
<BackButton href="/settings/chats" className="bg-neutral-100 dark:bg-neutral-800" />
|
||||
<h1 className="text-base font-bold md:text-lg">{t("findPartner.title")}</h1>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-center text-xs text-neutral-500">{t("findPartner.hint")}</p>
|
||||
|
||||
<RoundedButton
|
||||
type="button"
|
||||
onClick={() => setShowFilterModal(true)}
|
||||
className="mx-auto mb-5 h-9 w-full max-w-none !bg-neutral-100 text-neutral-600 dark:!bg-neutral-800 dark:text-neutral-300"
|
||||
>
|
||||
{t("findPartner.filterButton")}
|
||||
</RoundedButton>
|
||||
|
||||
{status === "loading" ? (
|
||||
<div className="flex flex-col items-center gap-3 py-10">
|
||||
<IOSSpinner />
|
||||
<p className="text-sm text-neutral-500">{t("findPartner.loading")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status === "error" ? (
|
||||
<div className="mx-auto max-w-sm rounded-2xl border border-red-200 p-5 text-center dark:border-red-900">
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{t("findPartner.loadError")}</p>
|
||||
<RoundedButton
|
||||
className="!border-transparent mt-4 h-9 w-full max-w-none !bg-sky-100 text-sky-600"
|
||||
onClick={() => runSearch(filters)}
|
||||
>
|
||||
{t("nearbyFriends.retryLocation")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status === "ready" ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="mb-2 text-center text-[11px] text-neutral-400">
|
||||
{t("findPartner.resultsFound", { count: users.length })}
|
||||
</p>
|
||||
|
||||
{users.length === 0 ? (
|
||||
<div className="py-6 text-center">
|
||||
<p className="text-sm text-neutral-500">{t("findPartner.empty")}</p>
|
||||
<p className="mt-2 text-xs text-neutral-400">{t("findPartner.emptyHint")}</p>
|
||||
</div>
|
||||
) : (
|
||||
users.map((item) => {
|
||||
const age = ageFromBirthday(item.birthday);
|
||||
const location = item.city?.name || item.province?.name || "";
|
||||
return (
|
||||
<Link
|
||||
key={item._id}
|
||||
href={`/offer/${item._id}`}
|
||||
className="flex items-center gap-3 py-3"
|
||||
>
|
||||
<ProfileAvatar
|
||||
src={item.profile_image}
|
||||
alt={item.user_name}
|
||||
size="sm"
|
||||
rounded="full"
|
||||
className="!h-12 !w-12 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<span className="truncate font-semibold" dir="ltr">
|
||||
{item.user_name}
|
||||
</span>
|
||||
<VerificationBadge verifyBadge={item.verify_badge} />
|
||||
</div>
|
||||
{(item.first_name || item.last_name) && (
|
||||
<p className="truncate text-xs text-neutral-500">
|
||||
{formatFullName(item.first_name, item.last_name)}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-0.5 truncate text-[11px] text-neutral-400">
|
||||
{[age != null ? t("findPartner.ageYears", { count: age }) : null, location, item.job]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex flex-col items-center gap-2 border-t border-neutral-200 pt-5 dark:border-neutral-700">
|
||||
{alert ? (
|
||||
<>
|
||||
<p className="text-center text-[11px] text-neutral-400">
|
||||
{t("findPartner.savedSearchActive")}
|
||||
</p>
|
||||
<RoundedButton
|
||||
type="button"
|
||||
disabled={savingAlert}
|
||||
onClick={handleCancelSearch}
|
||||
className="h-10 w-full max-w-[240px] text-sm"
|
||||
>
|
||||
{t("findPartner.cancelSavedSearch")}
|
||||
</RoundedButton>
|
||||
</>
|
||||
) : (
|
||||
<RoundedButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={savingAlert}
|
||||
onClick={handleSaveSearch}
|
||||
className="h-10 w-full max-w-[240px] text-sm"
|
||||
>
|
||||
{t("findPartner.saveSearch")}
|
||||
</RoundedButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Container>
|
||||
|
||||
<FindPartnerFilterModal
|
||||
isOpen={showFilterModal}
|
||||
onClose={() => setShowFilterModal(false)}
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
onApply={handleApplyFilters}
|
||||
onClear={handleClearFilters}
|
||||
/>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
@@ -179,6 +179,25 @@ function Chats() {
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/settings/chats/find-partner"
|
||||
className="flex items-center justify-between py-3"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-[#FC8EAC]/15">
|
||||
<BoldIcon name="heart-search" size={24} className="text-[#FC8EAC]" tinted />
|
||||
</div>
|
||||
<div className="min-w-0 flex flex-col gap-1">
|
||||
<span className="truncate font-semibold">
|
||||
{t("findPartner.title")}
|
||||
</span>
|
||||
<span className="truncate text-[11px] text-neutral-500">
|
||||
{t("findPartner.desc")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{isEmpty ? (
|
||||
<p className="py-8 text-center text-neutral-500">
|
||||
{t("chats.empty")}
|
||||
|
||||
@@ -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 && (
|
||||
@@ -80,15 +87,18 @@ export default function RegionPage() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
className="mt-8"
|
||||
loading={loading}
|
||||
disabled={loading || !selection.countryId}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t("settings.edit.save")}
|
||||
</AuthNextButton>
|
||||
<div className="mt-8 flex justify-center">
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
loading={loading}
|
||||
disabled={loading || !selection.countryId}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -151,11 +155,15 @@ function Expertise() {
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="p-4 flex flex-col items-center min-h-dvh justify-center">
|
||||
<div className="py-4 flex flex-col items-center min-h-dvh justify-center">
|
||||
<EditPageHeader title={t("settings.edit.nav.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>
|
||||
|
||||
@@ -16,14 +16,13 @@ const EDIT_NAV_KEY: Record<string, string> = {
|
||||
"/username": "username",
|
||||
"/password": "password",
|
||||
"/google-account": "googleAccount",
|
||||
"/instagram-import": "instagramImport",
|
||||
"/two-factor": "twoFactor",
|
||||
"/link-device": "linkDevice",
|
||||
"/analytics": "analytics",
|
||||
"/avatar": "avatar",
|
||||
"/Authentication": "authentication",
|
||||
"/expertise": "expertise",
|
||||
"/colors": "colors",
|
||||
"/personal-details": "personalDetails",
|
||||
"/services": "services",
|
||||
"/sizes": "sizes",
|
||||
"/License": "license",
|
||||
@@ -31,9 +30,6 @@ const EDIT_NAV_KEY: Record<string, string> = {
|
||||
"/public-relations": "publicRelations",
|
||||
"/shaba": "shaba",
|
||||
"/location": "location",
|
||||
"/blocked-users": "blockedUsers",
|
||||
"/archive": "archive",
|
||||
"/activity": "activity",
|
||||
};
|
||||
|
||||
function EditPage() {
|
||||
|
||||
293
src/app/settings/edit/personal-details/page.tsx
Normal file
293
src/app/settings/edit/personal-details/page.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
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";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type MaritalStatus = "single" | "divorced" | null;
|
||||
type ResidenceType = "owner" | "tenant" | null;
|
||||
type VehicleType = "none" | "car" | "motorcycle" | null;
|
||||
type PersonalStyle = "formal" | "sporty" | null;
|
||||
type IncomeRange = "under_5m" | "5m_10m" | "10m_20m" | "over_20m" | null;
|
||||
|
||||
function PersonalDetailsPage() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request, loading } = useAxios();
|
||||
const user = useUser();
|
||||
|
||||
const [maritalStatus, setMaritalStatus] = useState<MaritalStatus>(null);
|
||||
const [residenceType, setResidenceType] = useState<ResidenceType>(null);
|
||||
const [residenceArea, setResidenceArea] = useState("");
|
||||
const [vehicleType, setVehicleType] = useState<VehicleType>(null);
|
||||
const [vehicleDetails, setVehicleDetails] = useState("");
|
||||
const [personalStyle, setPersonalStyle] = useState<PersonalStyle>(null);
|
||||
const [job, setJob] = useState("");
|
||||
const [incomeRange, setIncomeRange] = useState<IncomeRange>(null);
|
||||
const [moralTraits, setMoralTraits] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
setMaritalStatus(user.marital_status ?? null);
|
||||
setResidenceType(user.residence_type ?? null);
|
||||
setResidenceArea(user.residence_area ?? "");
|
||||
setVehicleType(user.vehicle_type ?? null);
|
||||
setVehicleDetails(user.vehicle_details ?? "");
|
||||
setPersonalStyle(user.personal_style ?? null);
|
||||
setJob(user.job ?? "");
|
||||
setIncomeRange(user.income_range ?? null);
|
||||
setMoralTraits(user.moral_traits ?? "");
|
||||
}, [user]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await request("PATCH", "/verify/personal_details", {
|
||||
marital_status: maritalStatus,
|
||||
residence_type: residenceType,
|
||||
residence_area: residenceArea || null,
|
||||
vehicle_type: vehicleType,
|
||||
vehicle_details: vehicleType && vehicleType !== "none" ? vehicleDetails || null : null,
|
||||
personal_style: personalStyle,
|
||||
job: job || null,
|
||||
income_range: incomeRange,
|
||||
moral_traits: moralTraits || null,
|
||||
});
|
||||
toast.success(t("settings.edit.personalDetails.saveSuccess"));
|
||||
router.push("/settings/edit");
|
||||
} catch (err: unknown) {
|
||||
toast.error(
|
||||
(err as { message?: string })?.message || t("settings.edit.unknownError")
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LocalePageShell>
|
||||
<Container>
|
||||
<div className="flex flex-col items-center gap-6 p-4 pb-24">
|
||||
<EditPageHeader title={t("settings.edit.personalDetails.title")} />
|
||||
<p className="max-w-sm text-center text-xs text-neutral-500">
|
||||
{t("settings.edit.personalDetails.hint")}
|
||||
</p>
|
||||
|
||||
{!user ? (
|
||||
<EditFormSkeleton fields={6} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||
{/* وضعیت تاهل */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold">
|
||||
{t("settings.edit.personalDetails.maritalStatus")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(maritalStatus === "single", "h-10 flex-1")}
|
||||
onClick={() => setMaritalStatus("single")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.maritalStatusSingle")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(maritalStatus === "divorced", "h-10 flex-1")}
|
||||
onClick={() => setMaritalStatus("divorced")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.maritalStatusDivorced")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* نوع سکونت */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold">
|
||||
{t("settings.edit.personalDetails.residenceType")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(residenceType === "owner", "h-10 flex-1")}
|
||||
onClick={() => setResidenceType("owner")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.residenceTypeOwner")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(residenceType === "tenant", "h-10 flex-1")}
|
||||
onClick={() => setResidenceType("tenant")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.residenceTypeTenant")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* محل سکونت */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold">
|
||||
{t("settings.edit.personalDetails.residenceArea")}
|
||||
</p>
|
||||
<RoundedInput
|
||||
value={residenceArea}
|
||||
onChange={(e) => setResidenceArea(e.target.value)}
|
||||
placeholder={t("settings.edit.personalDetails.residenceAreaPlaceholder")}
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* وسیله نقلیه */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold">
|
||||
{t("settings.edit.personalDetails.vehicleType")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(vehicleType === "none", "h-10 flex-1 text-xs")}
|
||||
onClick={() => setVehicleType("none")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.vehicleTypeNone")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(vehicleType === "car", "h-10 flex-1 text-xs")}
|
||||
onClick={() => setVehicleType("car")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.vehicleTypeCar")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(vehicleType === "motorcycle", "h-10 flex-1 text-xs")}
|
||||
onClick={() => setVehicleType("motorcycle")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.vehicleTypeMotorcycle")}
|
||||
</button>
|
||||
</div>
|
||||
{vehicleType && vehicleType !== "none" && (
|
||||
<RoundedInput
|
||||
className="mt-2"
|
||||
value={vehicleDetails}
|
||||
onChange={(e) => setVehicleDetails(e.target.value)}
|
||||
placeholder={t("settings.edit.personalDetails.vehicleDetailsPlaceholder")}
|
||||
maxLength={255}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* استایل */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold">
|
||||
{t("settings.edit.personalDetails.personalStyle")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(personalStyle === "formal", "h-10 flex-1")}
|
||||
onClick={() => setPersonalStyle("formal")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.personalStyleFormal")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(personalStyle === "sporty", "h-10 flex-1")}
|
||||
onClick={() => setPersonalStyle("sporty")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.personalStyleSporty")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* شغل */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold">
|
||||
{t("settings.edit.personalDetails.job")}
|
||||
</p>
|
||||
<RoundedInput
|
||||
value={job}
|
||||
onChange={(e) => setJob(e.target.value)}
|
||||
placeholder={t("settings.edit.personalDetails.jobPlaceholder")}
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* درآمد */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold">
|
||||
{t("settings.edit.personalDetails.incomeRange")}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(incomeRange === "under_5m", "h-10 text-xs")}
|
||||
onClick={() => setIncomeRange("under_5m")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.incomeUnder5m")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(incomeRange === "5m_10m", "h-10 text-xs")}
|
||||
onClick={() => setIncomeRange("5m_10m")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.income5m10m")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(incomeRange === "10m_20m", "h-10 text-xs")}
|
||||
onClick={() => setIncomeRange("10m_20m")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.income10m20m")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={toggleBtnClass(incomeRange === "over_20m", "h-10 text-xs")}
|
||||
onClick={() => setIncomeRange("over_20m")}
|
||||
>
|
||||
{t("settings.edit.personalDetails.incomeOver20m")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* خصوصیات اخلاقی */}
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-semibold">
|
||||
{t("settings.edit.personalDetails.moralTraits")}
|
||||
</p>
|
||||
<textarea
|
||||
value={moralTraits}
|
||||
onChange={(e) => setMoralTraits(e.target.value)}
|
||||
placeholder={t("settings.edit.personalDetails.moralTraitsPlaceholder")}
|
||||
maxLength={1024}
|
||||
rows={4}
|
||||
className="w-full rounded-3xl border border-neutral-950 bg-white px-4 py-3 font-medium text-neutral-900 dark:border-neutral-400 dark:bg-neutral-950 dark:text-neutral-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AuthNextButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
className="mt-4 h-10 w-full max-w-[200px]"
|
||||
>
|
||||
{t("settings.edit.save")}
|
||||
</AuthNextButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</LocalePageShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default PersonalDetailsPage;
|
||||
@@ -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;
|
||||
@@ -120,6 +120,7 @@ function Notifications() {
|
||||
end_project: workroomPath,
|
||||
"reject-project": workroomPath,
|
||||
invite: `/users/${item?.project_post_id}`,
|
||||
partner_match: `/offer/${item?.project_post_id}`,
|
||||
vitrine: `/settings/my-billboards/${item?.project_post_id}/b`,
|
||||
"vitrine-comment": `/settings/my-billboards/${item?.project_post_id}/b`,
|
||||
"ticket-message": `/settings/tickets/${encodeURIComponent(
|
||||
@@ -132,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";
|
||||
@@ -10,14 +12,19 @@ import Link from "next/link";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppLanguage } from "@/contexts/LanguageProvider";
|
||||
import { isAppLanguage } from "@/lib/i18n/constants";
|
||||
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;
|
||||
preferred_language?: "fa" | "en";
|
||||
gift_enabled?: boolean;
|
||||
offer_payment_enabled?: boolean;
|
||||
preferred_language?: AppLanguage;
|
||||
};
|
||||
|
||||
type PrivacyResponse = {
|
||||
@@ -26,7 +33,9 @@ type PrivacyResponse = {
|
||||
show_location: boolean;
|
||||
post_location_enabled: boolean;
|
||||
is_private: boolean;
|
||||
preferred_language?: "fa" | "en";
|
||||
gift_enabled: boolean;
|
||||
offer_payment_enabled: boolean;
|
||||
preferred_language?: AppLanguage;
|
||||
};
|
||||
|
||||
export default function UserSettingsPage() {
|
||||
@@ -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,22 +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>
|
||||
|
||||
<label className="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800">
|
||||
<div>
|
||||
<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.resetAlgorithm")}</span>
|
||||
<span className="text-neutral-400">›</span>
|
||||
</button>
|
||||
|
||||
<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")}
|
||||
@@ -203,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")}
|
||||
@@ -221,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")}
|
||||
@@ -237,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")}
|
||||
@@ -253,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")}
|
||||
@@ -269,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>
|
||||
);
|
||||
|
||||
@@ -164,7 +164,7 @@ export default function WalletSettingsPage() {
|
||||
{summary.shop.pending.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<div className="mt-4 flex flex-col items-center gap-2">
|
||||
<RoundedInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
@@ -174,6 +174,7 @@ export default function WalletSettingsPage() {
|
||||
/>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 w-full max-w-[200px]"
|
||||
disabled={loading}
|
||||
onClick={handleWithdraw}
|
||||
>
|
||||
@@ -196,7 +197,7 @@ export default function WalletSettingsPage() {
|
||||
amount: summary.gift.minWithdrawal.toLocaleString(),
|
||||
})}
|
||||
</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<div className="mt-4 flex flex-col items-center gap-2">
|
||||
<RoundedInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
@@ -206,6 +207,7 @@ export default function WalletSettingsPage() {
|
||||
/>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 w-full max-w-[200px]"
|
||||
disabled={loading || summary.gift.available < summary.gift.minWithdrawal}
|
||||
onClick={handleWithdraw}
|
||||
>
|
||||
@@ -223,7 +225,7 @@ export default function WalletSettingsPage() {
|
||||
{summary.academy.available.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<div className="mt-4 flex flex-col items-center gap-2">
|
||||
<RoundedInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
@@ -233,6 +235,7 @@ export default function WalletSettingsPage() {
|
||||
/>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 w-full max-w-[200px]"
|
||||
disabled={loading}
|
||||
onClick={handleWithdraw}
|
||||
>
|
||||
@@ -250,7 +253,7 @@ export default function WalletSettingsPage() {
|
||||
{summary.project.available.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<div className="mt-4 flex flex-col items-center gap-2">
|
||||
<RoundedInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
@@ -260,6 +263,7 @@ export default function WalletSettingsPage() {
|
||||
/>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 w-full max-w-[200px]"
|
||||
disabled={loading}
|
||||
onClick={handleWithdraw}
|
||||
>
|
||||
@@ -277,7 +281,7 @@ export default function WalletSettingsPage() {
|
||||
{summary.booking.available.toLocaleString()} {t("settings.toman")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<div className="mt-4 flex flex-col items-center gap-2">
|
||||
<RoundedInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
@@ -287,6 +291,7 @@ export default function WalletSettingsPage() {
|
||||
/>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 w-full max-w-[200px]"
|
||||
disabled={loading}
|
||||
onClick={handleWithdraw}
|
||||
>
|
||||
@@ -305,7 +310,7 @@ export default function WalletSettingsPage() {
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-neutral-500">{t("shops.chargeBonusHint")}</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<div className="mt-4 flex flex-col items-center gap-2">
|
||||
<RoundedInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
@@ -313,7 +318,12 @@ export default function WalletSettingsPage() {
|
||||
value={chargeAmount}
|
||||
onChange={(e) => setChargeAmount(e.target.value)}
|
||||
/>
|
||||
<RoundedButton variant="primary" disabled={loading} onClick={handleCharge}>
|
||||
<RoundedButton
|
||||
variant="primary"
|
||||
className="h-10 w-full max-w-[200px]"
|
||||
disabled={loading}
|
||||
onClick={handleCharge}
|
||||
>
|
||||
{t("shops.chargeWallet")}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cache } from "react";
|
||||
import { fetchApiJson } from "@/lib/api/fetchApiJson";
|
||||
import { generatePageMetadata } from "@/utils/generatePageMetadata";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
@@ -21,10 +22,11 @@ function uniqueValues(values: (string | null | undefined)[]): string[] {
|
||||
return Array.from(new Set(values.filter((v): v is string => Boolean(v))));
|
||||
}
|
||||
|
||||
async function loadListing(listingId: string) {
|
||||
// Deduped per request — see loadUser in users/[username]/page.tsx for rationale.
|
||||
const loadListing = cache(async (listingId: string) => {
|
||||
const { data } = await fetchApiJson<ListingResponse>(`/shop-products/${listingId}`);
|
||||
return data?.listing ?? null;
|
||||
}
|
||||
});
|
||||
|
||||
export async function generateMetadata({ params }: IListingPageProps): Promise<Metadata> {
|
||||
const { listingId } = await params;
|
||||
|
||||
@@ -143,6 +143,7 @@ function ShopLocationPage() {
|
||||
<CountryProvinceCitySelect
|
||||
value={geoSelection}
|
||||
onChange={setGeoSelection}
|
||||
lockToUserCountry
|
||||
/>
|
||||
|
||||
<div className="mt-2 w-full overflow-hidden rounded-2xl">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cache } from "react";
|
||||
import { fetchApiJson } from "@/lib/api/fetchApiJson";
|
||||
import { generatePageMetadata } from "@/utils/generatePageMetadata";
|
||||
import { getServerLanguage } from "@/lib/i18n/server";
|
||||
@@ -23,10 +24,13 @@ type PublicShop = {
|
||||
rating?: { average: number; count: number } | null;
|
||||
};
|
||||
|
||||
async function loadShop(shopId: string) {
|
||||
// Deduped per request — generateMetadata() and the page component both need
|
||||
// this shop; cache() keeps them to a single backend fetch (see loadUser in
|
||||
// users/[username]/page.tsx for the full rationale).
|
||||
const loadShop = cache(async (shopId: string) => {
|
||||
const { data } = await fetchApiJson<{ shop?: PublicShop }>(`/shops/public/${shopId}`);
|
||||
return data?.shop ?? null;
|
||||
}
|
||||
});
|
||||
|
||||
export async function generateMetadata({ params }: IShopProfilePageProps): Promise<Metadata> {
|
||||
const { shopId } = await params;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import React from "react";
|
||||
import React, { cache } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import { User } from "@/types/types";
|
||||
import Container from "@/components/elements/Container";
|
||||
@@ -25,7 +25,12 @@ type UserWebResponse = { user?: User };
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function loadUser(username: string, token: string) {
|
||||
// Deduped per request: generateMetadata() and the page component both need this
|
||||
// user, and without cache() they'd fire two separate backend fetches, slowing
|
||||
// metadata resolution enough that Next.js streams the <title> in later instead
|
||||
// of including it in the initial <head> — which is what SEO checker tools that
|
||||
// only read the initial HTML (not the fully-streamed/hydrated page) were missing.
|
||||
const loadUser = cache(async (username: string, token: string) => {
|
||||
const { data } = await fetchApiJson<UserWebResponse>(
|
||||
`/users/get/web?user_name=${encodeURIComponent(username)}`,
|
||||
{
|
||||
@@ -33,7 +38,7 @@ async function loadUser(username: string, token: string) {
|
||||
}
|
||||
);
|
||||
return data?.user ?? null;
|
||||
}
|
||||
});
|
||||
|
||||
export async function generateMetadata({ params }: IUserProps): Promise<Metadata> {
|
||||
const { username } = await params;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import "@/lib/i18n";
|
||||
import { LanguageProvider } from "@/contexts/LanguageProvider";
|
||||
import type { AppLanguage } from "@/lib/i18n/registry";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
import { ReactQueryProvider } from "@/providers/ReactQueryProvider";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
@@ -16,11 +16,12 @@ import NotificationBridge from "@/components/notifications/NotificationBridge";
|
||||
|
||||
interface ILayoutProps {
|
||||
children: React.ReactNode;
|
||||
initialLanguage: AppLanguage;
|
||||
}
|
||||
function Layout({ children }: ILayoutProps) {
|
||||
function Layout({ children, initialLanguage }: ILayoutProps) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<LanguageProvider>
|
||||
<LanguageProvider initialLanguage={initialLanguage}>
|
||||
<ReactQueryProvider>
|
||||
<PwaHead />
|
||||
<PwaBootSplash />
|
||||
|
||||
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
@@ -3,7 +3,6 @@
|
||||
import { IExpertise } from "@/types/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function gridColsClass(count: number): string {
|
||||
@@ -20,6 +19,16 @@ function gridMaxWidth(count: number): number {
|
||||
return cols * 108;
|
||||
}
|
||||
|
||||
/** زیرتخصصها و تخصص اصلی انتخابی همیشه حداکثر ۳ ستونهاند، صرفنظر از تعداد گزینهها —
|
||||
* قبلاً زیرتخصصها از gridColsClass استفاده میکردن که برای شمارشهای خاص (مثلاً دقیقاً ۵ گزینه)
|
||||
* ۵ ستونه میشد و با بقیه دستهها ناهماهنگ بود. */
|
||||
function fixedThreeColGridClass(count: number): string {
|
||||
if (count <= 1) return "grid-cols-1";
|
||||
if (count === 2) return "grid-cols-2";
|
||||
return "grid-cols-3";
|
||||
}
|
||||
|
||||
|
||||
type ExpertisePickerProps = {
|
||||
expertiseList: IExpertise[] | null;
|
||||
expertise: string;
|
||||
@@ -55,7 +64,7 @@ export default function ExpertisePicker({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex w-full flex-col items-center px-2">
|
||||
<div className="mt-4 flex w-full flex-col items-center">
|
||||
<div
|
||||
className={cn("grid w-full gap-2", gridColsClass(mainCount))}
|
||||
style={{ maxWidth: gridMaxWidth(mainCount) }}
|
||||
@@ -84,60 +93,61 @@ export default function ExpertisePicker({
|
||||
{t("auth.subExpertisePostHint")}
|
||||
</p>
|
||||
<div
|
||||
className={cn("grid w-full gap-2", gridColsClass(subCount))}
|
||||
className={cn("grid w-full gap-2", fixedThreeColGridClass(subCount))}
|
||||
style={{ maxWidth: gridMaxWidth(subCount) }}
|
||||
>
|
||||
{selectedExpertise.sub_expertise.map((item) => {
|
||||
const isSelected = subExpertise.includes(item.name);
|
||||
const isDisplay =
|
||||
Boolean(displaySubExpertise) &&
|
||||
displaySubExpertise.trim() === item.name.trim();
|
||||
|
||||
return (
|
||||
<div key={item._id} className="flex items-stretch gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("auth.displayInPostsAria", {
|
||||
name: item.name,
|
||||
})}
|
||||
disabled={!isSelected}
|
||||
className={cn(
|
||||
"flex h-10 w-8 shrink-0 items-center justify-center rounded-lg border transition-colors",
|
||||
!isSelected && "cursor-not-allowed opacity-40",
|
||||
isDisplay && isSelected
|
||||
? "border-pink-500 bg-pink-500 text-white"
|
||||
: "border-neutral-300 bg-white dark:bg-neutral-900"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isSelected) return;
|
||||
onDisplaySubExpertiseChange(item.name);
|
||||
}}
|
||||
>
|
||||
{isDisplay && isSelected ? (
|
||||
<BoldIcon
|
||||
name="tick-circle"
|
||||
size={14}
|
||||
tinted
|
||||
className="text-white"
|
||||
/>
|
||||
) : (
|
||||
<span className="h-3.5 w-3.5 rounded-sm border border-neutral-300 dark:border-neutral-600" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
toggleBtnClass(isSelected),
|
||||
"min-h-10 flex-1 p-2 text-xs leading-tight"
|
||||
)}
|
||||
onClick={() => onSubExpertiseToggle(item.name)}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
key={item._id}
|
||||
type="button"
|
||||
className={cn(
|
||||
toggleBtnClass(isSelected),
|
||||
"min-h-10 p-2 text-xs leading-tight"
|
||||
)}
|
||||
onClick={() => onSubExpertiseToggle(item.name)}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{subExpertise.length > 0 && (
|
||||
<div className="mt-6 flex w-full flex-col items-center border-t border-neutral-200 pt-4 dark:border-neutral-800">
|
||||
<p className="mb-2 text-center text-xs text-gray-500">
|
||||
{t("auth.selectPrimarySubExpertise")}
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
"grid w-full gap-2",
|
||||
fixedThreeColGridClass(subExpertise.length)
|
||||
)}
|
||||
style={{ maxWidth: gridMaxWidth(subCount) }}
|
||||
>
|
||||
{subExpertise.map((name) => {
|
||||
const isDisplay =
|
||||
displaySubExpertise != null &&
|
||||
displaySubExpertise.trim() === name.trim();
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
aria-label={t("auth.displayInPostsAria", { name })}
|
||||
className={cn(
|
||||
toggleBtnClass(isDisplay),
|
||||
"min-h-10 p-2 text-xs leading-tight"
|
||||
)}
|
||||
onClick={() => onDisplaySubExpertiseChange(name)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -62,6 +62,7 @@ const LocationSelector: React.FC<LocationSelectorProps> = ({ formik }) => {
|
||||
value={geoSelection}
|
||||
onChange={handleGeoChange}
|
||||
className="w-full max-w-full"
|
||||
lockToUserCountry
|
||||
/>
|
||||
{formik.touched.stateId && formik.errors.stateId && (
|
||||
<small className="text-red-500 block text-center">
|
||||
|
||||
@@ -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,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { staticIconUrl } from "@/components/main/BaseUrl";
|
||||
import { saveBookingWizardId } from "@/hooks/useBookingWizardId";
|
||||
import { cn } from "@/lib/utils";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import ToggleSwitch from "@/components/shops/ToggleSwitch";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -14,6 +17,7 @@ type BookingListCardProps = {
|
||||
services: { title: string }[];
|
||||
status: "draft" | "active" | "inactive";
|
||||
};
|
||||
onStatusChange?: (id: string, status: "active" | "inactive") => void;
|
||||
};
|
||||
|
||||
const STATUS_TEXT_COLOR: Record<string, string> = {
|
||||
@@ -22,20 +26,42 @@ const STATUS_TEXT_COLOR: Record<string, string> = {
|
||||
draft: "text-neutral-400",
|
||||
};
|
||||
|
||||
export default function BookingListCard({ config }: BookingListCardProps) {
|
||||
export default function BookingListCard({ config, onStatusChange }: BookingListCardProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const { request } = useAxios();
|
||||
const [toggling, setToggling] = useState(false);
|
||||
|
||||
const openConfig = () => {
|
||||
saveBookingWizardId(config._id);
|
||||
router.push("/settings/booking/new/services?edit=1");
|
||||
};
|
||||
|
||||
const toggleStatus = async (checked: boolean) => {
|
||||
if (toggling) return;
|
||||
const nextStatus = checked ? "active" : "inactive";
|
||||
setToggling(true);
|
||||
try {
|
||||
await request("PATCH", `/bookings/${config._id}/status`, {
|
||||
status: nextStatus,
|
||||
});
|
||||
onStatusChange?.(config._id, nextStatus);
|
||||
} catch {
|
||||
/* toast handled by useAxios */
|
||||
} finally {
|
||||
setToggling(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={openConfig}
|
||||
className="gentle-transition flex w-full items-center gap-3 rounded-[28px] border border-neutral-200 p-3 text-right active:scale-[0.99] dark:border-neutral-700"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") openConfig();
|
||||
}}
|
||||
className="gentle-transition flex w-full cursor-pointer items-center gap-3 rounded-[28px] border border-neutral-200 p-3 text-right active:scale-[0.99] dark:border-neutral-700"
|
||||
>
|
||||
<span className="flex h-20 w-20 shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-neutral-100 dark:bg-neutral-800">
|
||||
<Image
|
||||
@@ -50,18 +76,32 @@ export default function BookingListCard({ config }: BookingListCardProps) {
|
||||
<span className="flex min-w-0 flex-1 flex-col items-end gap-1.5 text-right">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="line-clamp-1 text-base font-bold">{config.name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs font-semibold",
|
||||
STATUS_TEXT_COLOR[config.status] || STATUS_TEXT_COLOR.draft
|
||||
)}
|
||||
>
|
||||
{config.status === "active"
|
||||
? t("booking.statusActive")
|
||||
: config.status === "inactive"
|
||||
? t("booking.statusInactive")
|
||||
: t("booking.statusDraft")}
|
||||
</span>
|
||||
{config.status === "draft" ? (
|
||||
<span className={cn("shrink-0 text-xs font-semibold", STATUS_TEXT_COLOR.draft)}>
|
||||
{t("booking.statusDraft")}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="flex shrink-0 items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-semibold",
|
||||
STATUS_TEXT_COLOR[config.status] || STATUS_TEXT_COLOR.draft
|
||||
)}
|
||||
>
|
||||
{config.status === "active"
|
||||
? t("booking.statusActive")
|
||||
: t("booking.statusInactive")}
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
checked={config.status === "active"}
|
||||
onChange={toggleStatus}
|
||||
ariaLabel={t("booking.statusActive")}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
@@ -71,6 +111,6 @@ export default function BookingListCard({ config }: BookingListCardProps) {
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user