Compare commits

...

3 Commits

Author SHA1 Message Date
payacom
76ef4e32a9 order 2026-09-01 13:51:50 +03:30
payacom
1ba6646bf9 project 2026-08-18 20:22:07 +03:30
payacom
0c6e1a7125 lang full 2026-08-16 11:42:39 +03:30
251 changed files with 577098 additions and 9910 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
);

View File

@@ -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,

View File

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

View File

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

View File

@@ -193,7 +193,7 @@ function BookingFlowPage() {
type="button"
key={date}
onClick={() => setSelectedDate(date)}
className={`flex-shrink-0 rounded-xl border px-3 py-2 text-xs ${
className={`flex-shrink-0 rounded-2xl border px-3 py-2 text-xs ${
selectedDate === date
? "border-[#0095f6] bg-[#0095f6]/10 font-bold"
: "border-neutral-200 dark:border-neutral-700"
@@ -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}
>

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import Script from 'next/script';
import "./globals.css";
import Layout from "@/components/Layout";
import RegisterSW from "@/components/RegisterSW";
import PushNotificationListener from "@/components/PushNotificationListener";
import ClientErrorBoundary from "@/components/ClientErrorBoundary";
const iranSansFont = localFont({
@@ -98,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

View File

@@ -709,6 +709,7 @@ export default function NewPostPage() {
<CountryProvinceCitySelect
value={postGeoSelection}
onChange={setPostGeoSelection}
lockToUserCountry
/>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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>

View File

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

View File

@@ -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>
);
})}

View File

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

View File

@@ -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>
);
}

View File

@@ -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")}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ import {
} from "@/lib/subExpertiseDisplay";
import LocalePageShell from "@/components/i18n/LocalePageShell";
import EditPageHeader from "@/components/settings/EditPageHeader";
import { EditFormSkeleton } from "@/components/settings/EditPageSkeletons";
import { useTranslation } from "react-i18next";
function Expertise() {
@@ -42,6 +43,7 @@ function Expertise() {
const [displaySubExpertise, setDisplaySubExpertise] = useState<string | null>(
null
);
const [loadingList, setLoadingList] = useState(true);
const user = useUser();
const hydratedFromProfile = useRef(false);
@@ -71,6 +73,8 @@ function Expertise() {
setExpertiseList(response?.expertises ?? []);
} catch (err) {
console.log(err);
} finally {
setLoadingList(false);
}
};
@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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() {

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

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

View File

@@ -2,6 +2,8 @@
import React, { useEffect, useState } from "react";
import Container from "@/components/elements/Container";
import Modal from "@/components/elements/Modal";
import RoundedButton from "@/components/elements/RoundedButton";
import PageTitle from "@/components/settings/PageTitle";
import LanguageSettingRow from "@/components/settings/LanguageSettingRow";
import LocalePageShell from "@/components/i18n/LocalePageShell";
@@ -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>
);

View File

@@ -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>

View File

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

View File

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

View File

@@ -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;

View File

@@ -143,6 +143,7 @@ function ShopLocationPage() {
<CountryProvinceCitySelect
value={geoSelection}
onChange={setGeoSelection}
lockToUserCountry
/>
<div className="mt-2 w-full overflow-hidden rounded-2xl">

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;

View File

@@ -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;

View File

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

View File

@@ -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 />

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">

View File

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

View File

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

View File

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

View File

@@ -1,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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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