full update

This commit is contained in:
payacom
2026-07-18 14:29:05 +03:30
parent 17f334d0f1
commit af5b27b610
239 changed files with 10283 additions and 3041 deletions

View File

@@ -0,0 +1,32 @@
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import { Post } from "@/types/types";
export async function fetchBookmarkPosts(
page: number,
limit: number,
token: string
): Promise<{ posts: Post[]; hasMore: boolean; totalItems: number }> {
const base = getApiBaseUrl();
const res = await fetch(
`${base}/posts/bookmarks?page=${page}&limit=${limit}`,
{
cache: "no-store",
headers: { Authorization: `Bearer ${token}` },
}
);
if (!res.ok) {
return { posts: [], hasMore: false, totalItems: 0 };
}
const data = await res.json();
const posts = (data?.posts ?? []) as Post[];
const totalItems = data?.totalItems ?? posts.length;
const totalPages = data?.totalPages ?? 1;
return {
posts,
hasMore: page < totalPages,
totalItems,
};
}

View File

@@ -11,7 +11,7 @@ export async function fetchPosts(
userLevel?: string;
rateFilter?: string;
_id?: string;
type?: string;
type?: "image" | "video";
exploreFilter?: string;
subExpertise?: string;
lat?: string;
@@ -19,6 +19,17 @@ export async function fetchPosts(
feedMode?: "grid" | "reels";
seedPostId?: string;
sort?: "latest";
reelsTab?: "for_you" | "following" | "saved";
heightMin?: string;
heightMax?: string;
weightMin?: string;
weightMax?: string;
sizeMin?: string;
sizeMax?: string;
hair_color?: string;
eye_color?: string;
q?: string;
hashtag?: string;
},
token: string
) {
@@ -53,5 +64,16 @@ export async function fetchPosts(
throw new Error(`Network response was not ok: ${response.status}`);
}
return response.json();
return response.json() as Promise<{
posts: unknown[];
totalPages?: number;
totalItems?: number;
feedMeta?: {
isColdStart?: boolean;
emptyFollowing?: boolean;
emptySaved?: boolean;
requiresAuth?: boolean;
suggestedUsers?: unknown[];
};
}>;
}

23
src/api/fetchProject.ts Normal file
View File

@@ -0,0 +1,23 @@
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import { Project } from "@/types/types";
export async function fetchProject(
id: string,
token = ""
): Promise<Project | null> {
try {
const response = await fetch(`${getApiBaseUrl()}/projects/get/web/${id}`, {
cache: "no-store",
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
});
if (!response.ok) return null;
const data = await response.json();
return data?.project ?? null;
} catch {
return null;
}
}

View File

@@ -19,6 +19,8 @@ export type StoryItem = {
createdAt?: string;
expires_at?: string;
viewed?: boolean;
liked?: boolean;
likes_count?: number;
};
export type StoryFeedUser = {
@@ -103,3 +105,64 @@ export async function updateStoryOverlays(
throw new Error(json?.message || "خطا در ویرایش استوری");
}
}
export type StoryViewerUser = {
_id: string;
user_name: string;
first_name: string;
last_name: string;
profile_image?: string;
viewed_at?: string;
};
export async function likeStory(
storyId: string,
token: string
): Promise<{ liked: boolean; already?: boolean }> {
const res = await fetch(`${getApiBaseUrl()}/stories/like`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ storyId }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json?.message || "خطا در لایک استوری");
}
return res.json();
}
export async function commentOnStory(
storyId: string,
comment: string,
token: string
): Promise<void> {
const res = await fetch(`${getApiBaseUrl()}/stories/comment`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ storyId, comment }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json?.message || "خطا در ارسال کامنت");
}
}
export async function fetchStoryViewers(
storyId: string,
token: string
): Promise<{ viewers: StoryViewerUser[]; total: number }> {
const res = await fetch(`${getApiBaseUrl()}/stories/${storyId}/viewers`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json?.message || "خطا در دریافت بازدیدکنندگان");
}
return res.json();
}

View File

@@ -8,7 +8,7 @@ import AuthPasswordInput from "@/components/auth/AuthPasswordInput";
import Container from "@/components/elements/Container";
import AuthRules from "@/components/auth/AuthRules";
import Link from "next/link";
import React, { useEffect, useState } from "react";
import React, { useState } from "react";
import useAxios from "@/hooks/useAxios";
import { useFormik } from "formik";
import * as yup from "yup";
@@ -19,8 +19,8 @@ import {
getAxiosErrorMessage,
getSafeRedirectPath,
} from "@/lib/auth/postLogin";
import { clearAuthSession } from "@/lib/auth/session";
import toast from "react-hot-toast";
import { useAuthSessionRedirect } from "@/hooks/useAuthSessionRedirect";
const schema = yup.object().shape({
username: yup.string().required("نام کاربری الزامی است"),
@@ -38,10 +38,7 @@ function LoginWithUsername() {
const [errorMessage, setErrorMessage] = useState("");
const { request, loading } = useAxios();
const redirectPath = getSafeRedirectPath();
useEffect(() => {
void clearAuthSession();
}, []);
useAuthSessionRedirect();
const formik = useFormik({
initialValues: {

View File

@@ -24,11 +24,13 @@ const schema = yup.object({
import { getSafeRedirectPath } from "@/lib/auth/postLogin";
import GoogleSignInButton from "@/components/auth/GoogleSignInButton";
import { MOBILE_NUMERIC_INPUT_PROPS } from "@/lib/auth/inputProps";
import { useAuthSessionRedirect } from "@/hooks/useAuthSessionRedirect";
function Login() {
const router = useRouter();
const [isModalOpen, setModalOpen] = useState(false);
const { request, loading } = useAxios();
useAuthSessionRedirect();
const redirectPath = getSafeRedirectPath();
const usernameLoginHref =
redirectPath !== "/"

View File

@@ -14,9 +14,10 @@ import { useFormik } from "formik";
import * as yup from "yup";
import { useRouter } from "next/navigation";
import { IVerifyOtp } from "@/types/types";
import { setAuthSession } from "@/lib/auth/session";
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
import { getRegistrationRoute } from "@/lib/auth/registrationRoutes";
import {
completeLogin,
getSafeRedirectPath,
} from "@/lib/auth/postLogin";
// Validation schema using Yup
const schema = yup.object({
@@ -57,26 +58,14 @@ function VerifyOtp() {
switch (response?.page) {
case "home":
await setAuthSession(response.token, {
id: response.id,
user_type: response.user_type,
step: response.step,
auth_provider: response.auth_provider,
email: response.email ?? undefined,
});
setIsSuccess(true);
await pause(AUTH_SUCCESS_DELAY_MS);
router.refresh();
router.push("/");
await completeLogin(router, response, {
redirectTo: getSafeRedirectPath(),
});
break;
case "auth-page":
await setAuthSession(response.token, {
id: response.id,
step: response.step,
});
setIsSuccess(true);
await pause(AUTH_SUCCESS_DELAY_MS);
router.push(getRegistrationRoute(response.step));
await completeLogin(router, response);
break;
default:
break;

View File

@@ -15,9 +15,7 @@ import { useFormik } from "formik";
import * as yup from "yup";
import { IVerifyOtp } from "@/types/types";
import { useRouter } from "next/navigation";
import { setAuthSession } from "@/lib/auth/session";
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
import { getRegistrationRoute } from "@/lib/auth/registrationRoutes";
import { completeLogin } from "@/lib/auth/postLogin";
// Validation schema using Yup
const schema = yup.object({
@@ -56,22 +54,8 @@ function RegisterOtp() {
otp: values.otp.trim(),
})) as IVerifyOtp;
await localStorage.setItem("otp", values.otp);
await setAuthSession(response.token, {
id: response.id,
step: response.step,
user_type: response.user_type,
auth_provider: response.auth_provider,
email: response.email ?? undefined,
});
setIsSuccess(true);
await pause(AUTH_SUCCESS_DELAY_MS);
if (response.page === "home") {
router.push("/");
return;
}
router.push(getRegistrationRoute(response.step));
await completeLogin(router, response);
} catch (err: any) {
setOtpError(true);
setIsSuccess(false);

View File

@@ -54,7 +54,7 @@ function RegisterCompletePage() {
<AuthNextButton
type="button"
onClick={() => finishRegistration("continue")}
className="!border-[#0C8002] !text-[#0C8002] w-full sm:w-auto"
className=" w-full sm:w-auto"
disabled={loading}
loading={loading && action === "continue"}
>

View File

@@ -152,7 +152,7 @@ function FullNamePage() {
<AuthNextButton
type="button"
onClick={() => finishRegistration("continue")}
className="!border-[#0C8002] !text-[#0C8002] w-full sm:w-auto"
className=" w-full sm:w-auto"
disabled={Boolean(choiceLoading)}
loading={choiceLoading === "continue"}
>

View File

@@ -1,11 +1,16 @@
"use client";
import { GoogleAuthRootProvider } from "@/components/auth/GoogleSignInButton";
import RegisterRouteGuard from "@/components/auth/RegisterRouteGuard";
export default function AuthProviders({
children,
}: {
children: React.ReactNode;
}) {
return <GoogleAuthRootProvider>{children}</GoogleAuthRootProvider>;
return (
<GoogleAuthRootProvider>
<RegisterRouteGuard>{children}</RegisterRouteGuard>
</GoogleAuthRootProvider>
);
}

View File

@@ -4,7 +4,6 @@ import AuthProviders from "./AuthProviders";
export const metadata: Metadata = {
title: {
default: "ورود و ثبت‌نام | مدستاگرام",
template: "%s | مدستاگرام",
},
description: "ورود یا ثبت‌نام در مدستاگرام",
robots: { index: false, follow: false },

View File

@@ -5,7 +5,7 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import useAxios from "@/hooks/useAxios";
import * as yup from "yup";
import { useRouter } from "next/navigation";
@@ -28,14 +28,16 @@ function Colors() {
const { request, loading } = useAxios();
const mobile =
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
const [expertise] = useState<string>(
typeof window !== "undefined" ? localStorage.getItem("expertise") || "" : ""
);
if (expertise !== "مدل") {
router.push("/verify/public-relations");
const [expertise] = useState<string>(
typeof window !== "undefined" ? localStorage.getItem("expertise") || "" : ""
);
useEffect(() => {
if (expertise && expertise !== "مدل") {
router.replace("/verify/public-relations");
}
}, [expertise, router]);
const handleCheck = async () => {
try {
await schema.validate({ selectedHairImage, selectedEyeImage });
@@ -105,7 +107,7 @@ function Colors() {
<button
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
selectedButton === 1
? "bg-[#FC8EAC] border-[#FC8EAC]"
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => handleButtonPress(1)}
@@ -115,7 +117,7 @@ function Colors() {
<button
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
selectedButton === 2
? "bg-[#FC8EAC] border-[#FC8EAC]"
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => handleButtonPress(2)}

View File

@@ -69,7 +69,7 @@ function CooperationType() {
onClick={() => formik.setFieldValue("selectedButton", true)}
className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${
formik.values.selectedButton === true
? "!bg-[#FC8EAC] !border-[#FC8EAC] !text-white"
? "btn-modern--selected text-white"
: ""
}`}
>
@@ -80,7 +80,7 @@ function CooperationType() {
onClick={() => formik.setFieldValue("selectedButton", false)}
className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${
formik.values.selectedButton === false
? "!bg-[#FC8EAC] !border-[#FC8EAC] !text-white"
? "btn-modern--selected text-white"
: ""
}`}
>

View File

@@ -71,7 +71,7 @@ function Gender() {
onClick={() => formik.setFieldValue("selectedButton", "female")}
className={`mt-5 w-full max-w-[250px] !border-[#FC8EAC] flex items-center justify-center flex-row-reverse gap-2 ${
formik.values.selectedButton === "female"
? "!bg-[#FC8EAC] !text-white"
? "btn-modern--selected text-white"
: ""
}`}
>

View File

@@ -5,7 +5,7 @@ import AuthPageLayout, {
AuthFormFooter,
AuthPageContent,
} from "@/components/auth/AuthPageLayout";
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import useAxios from "@/hooks/useAxios";
import * as yup from "yup";
import { useRouter } from "next/navigation";
@@ -37,9 +37,11 @@ function Sizes() {
typeof window !== "undefined" ? localStorage.getItem("expertise") || "" : ""
);
if (expertise !== "مدل") {
router.push("/verify/public-relations");
}
useEffect(() => {
if (expertise && expertise !== "مدل") {
router.replace("/verify/public-relations");
}
}, [expertise, router]);
const handleCheck = async () => {
try {
@@ -90,7 +92,7 @@ function Sizes() {
<button
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
selectedButton === 1
? "bg-[#FC8EAC] border-[#FC8EAC]"
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark text-black border-gray-400"
}`}
onClick={() => handleButtonPress(1)}
@@ -107,7 +109,7 @@ function Sizes() {
<button
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
selectedButton === 2
? "bg-[#FC8EAC] border-[#FC8EAC]"
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => handleButtonPress(2)}
@@ -124,7 +126,7 @@ function Sizes() {
<button
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
selectedButton === 3
? "bg-[#FC8EAC] border-[#FC8EAC]"
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => handleButtonPress(3)}
@@ -167,7 +169,7 @@ function Sizes() {
key={sizeOption}
className={`p-2 py-1 rounded-full border text-sm full ${
size === sizeOption
? "bg-[#FC8EAC] text-white border-[#FC8EAC]"
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => handleSizeSelection(sizeOption)}

View File

@@ -5,8 +5,12 @@ import { Metadata } from "next";
import AcademyFilter from "@/components/academy/AcademyFilter";
import { pageSeo } from "@/config/pageSeo";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import {
buildAcademyListSeo,
AcademyListFilters,
} from "@/lib/buildAcademyListSeo";
interface IProjectsProps {
interface IAcademyListProps {
params: Promise<{ slug?: string[] }>;
searchParams: Promise<{
search?: string;
@@ -15,26 +19,47 @@ interface IProjectsProps {
maxPrice?: string;
hasOffer?: string;
type?: string;
isFree?: string;
sortBy?: string;
sortOrder?: string;
}>;
};
function normalizeAcademyFilters(
filters: Awaited<IAcademyListProps["searchParams"]>
): AcademyListFilters {
let sortBy = filters.sortBy || "createdAt";
let sortOrder = filters.sortOrder || "desc";
if (sortBy.includes("_")) {
const [field, order] = sortBy.split("_");
sortBy = field;
sortOrder = order || "desc";
}
return {
search: filters.search || "",
category: filters.category || "",
type: filters.type || "",
isFree: filters.isFree === "true" || filters.type === "free",
sortBy,
sortOrder,
};
}
export async function generateMetadata({
searchParams,
}: IProjectsProps): Promise<Metadata> {
const filters = await searchParams;
}: IAcademyListProps): Promise<Metadata> {
const filters = normalizeAcademyFilters(await searchParams);
const hasFilters = Boolean(
filters.category ||
filters.type ||
filters.isFree ||
filters.search ||
(filters.sortBy && filters.sortBy !== "createdAt")
);
const isDefaultAcademy =
!filters.category &&
!filters.type &&
!filters.search &&
!filters.minPrice &&
!filters.maxPrice &&
!filters.hasOffer &&
!filters.sortBy;
if (isDefaultAcademy) {
if (!hasFilters) {
return generatePageMetadata({
title: pageSeo.academy.title,
description: pageSeo.academy.description,
@@ -43,41 +68,22 @@ export async function generateMetadata({
});
}
const categoryLabel = filters.category || "حوزه زیبایی";
const typeLabel = filters.type ? ` به صورت ${filters.type}` : "";
const descTypeLabel = filters.type ? ` بصورت ${filters.type}` : "";
const title = `آموزش ${categoryLabel}${typeLabel} | آموزشگاه مدستاگرام`;
const description = `آموزش تخصصی و حرفه‌ای ${categoryLabel}${descTypeLabel} در آموزشگاه مدستاگرام. پکیج‌های آموزشی زیبایی، میکاپ، مدلینگ و عکاسی.`;
const queryParams = new URLSearchParams();
if (filters.category) queryParams.append("category", filters.category);
if (filters.type) queryParams.append("type", filters.type);
const queryString = queryParams.toString();
const path = queryString
? `${pageSeo.academy.path}?${queryString}`
: pageSeo.academy.path;
return generatePageMetadata({ title, description, path });
const seo = buildAcademyListSeo(filters);
return generatePageMetadata({
title: seo.title,
description: seo.description,
path: seo.path,
keywords: [...pageSeo.academy.keywords],
});
}
export default async function Projects({
export default async function AcademyListPage({
searchParams,
}: {
searchParams: Promise<{
search?: string;
category?: string;
minPrice?: string;
maxPrice?: string;
hasOffer?: string;
type?: string;
sortBy?: string;
sortOrder?: string;
}>;
}) {
}: IAcademyListProps) {
const params = await searchParams;
const cookieStore = await cookies();
const token = cookieStore.get("token")?.value || "";
const seo = buildAcademyListSeo(normalizeAcademyFilters(params));
const filters = {
search: params.search || "",
@@ -85,16 +91,23 @@ export default async function Projects({
minPrice: params.minPrice ? Number(params.minPrice) : undefined,
maxPrice: params.maxPrice ? Number(params.maxPrice) : undefined,
hasOffer: params.hasOffer === "true",
type: params.type || "",
sortBy: params.sortBy || "createdAt",
sortOrder: params.sortOrder || "desc",
type: params.type === "free" ? "" : params.type || "",
isFree: params.isFree === "true" || params.type === "free",
...(() => {
let sortBy = params.sortBy || "createdAt";
let sortOrder = params.sortOrder || "desc";
if (sortBy.includes("_")) {
const [field, order] = sortBy.split("_");
sortBy = field;
sortOrder = order || "desc";
}
return { sortBy, sortOrder };
})(),
};
return (
<Container>
<h1 className="sr-only">
{pageSeo.academy.title.replace(" | مدستاگرام", "")}
</h1>
<h1 className="sr-only">{seo.h1}</h1>
<AcademyFilter />
<InfinitePosts filters={filters} token={token} />
</Container>

View File

@@ -16,7 +16,10 @@ import {
Tag,
FileVideo,
Star,
Phone,
} from "lucide-react";
import { btnPrimary, btnDefault } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
import { Course } from "@/types/types";
import useAxios from "@/hooks/useAxios";
import { Comments } from "@/components/academy/CommentsModal";
@@ -25,6 +28,8 @@ import VerificationBadge from "@/components/main/VerificationBadge";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import { usePathname } from "next/navigation";
import Cookies from "js-cookie";
import BillboardContactInfoModal from "@/components/billboards/BillboardPage/BillboardContactInfoModal";
import { AcademyCourseDetailSkeleton } from "@/components/academy/AcademySkeletons";
// ==================== دیتای فیک ====================
const MOCK_COURSE = {
@@ -36,7 +41,6 @@ const MOCK_COURSE = {
caption: "در حال بارگذاری...",
course_time: "در حال بارگذاری...",
teacher_name: "در حال بارگذاری...",
teacher_number: "در حال بارگذاری...",
course_image: "در حال بارگذاری...",
number_of_course_content: "0",
averageRate: 0,
@@ -96,6 +100,7 @@ export default function CourseDetail({
const [selectedVideo, setSelectedVideo] = useState(MOCK_VIDEOS[0]);
const [isPurchased, setIsPurchased] = useState(initialIsPurchased);
const [purchasing, setPurchasing] = useState(false);
const [showContactInfoModal, setShowContactInfoModal] = useState(false);
const [videos] = useState(MOCK_VIDEOS);
const [course] = useState(MOCK_COURSE);
@@ -104,6 +109,7 @@ export default function CourseDetail({
const id = match ? match[1] : null;
const [courses, setCourses] = useState<Course[]>([]);
const [courseLoading, setCourseLoading] = useState(true);
const { request } = useAxios();
const _id = courses[0]?._id;
@@ -337,6 +343,7 @@ export default function CourseDetail({
useEffect(() => {
const getAcademyCourses = async () => {
setCourseLoading(true);
try {
const response = await request(
"GET",
@@ -357,6 +364,8 @@ export default function CourseDetail({
console.log("get error:", err);
toast.error("خطا در دریافت اطلاعات");
setCourses([]);
} finally {
setCourseLoading(false);
}
};
@@ -397,6 +406,60 @@ export default function CourseDetail({
// };
const handleBuyCourse = async () => {
const token = Cookies.get("token");
if (!token) {
toast.error("لطفاً ابتدا وارد حساب کاربری خود شوید");
return;
}
setPurchasing(true);
const loadingToastId = toast.loading(
courses[0]?.is_free
? "در حال ثبت پکیج رایگان..."
: "در حال اتصال به درگاه پرداخت..."
);
try {
const response = await request("POST", "/academy/academy/course/payment-web", {
courseId: id,
});
toast.dismiss(loadingToastId);
if (response?.free) {
setIsPurchased(true);
toast.success(response?.message || "پکیج رایگان با موفقیت ثبت شد");
return;
}
const paymentUrl =
response?.paymentUrl ||
response?.data?.paymentUrl ||
(response?.authority
? `https://www.zarinpal.com/pg/StartPay/${response.authority}`
: response?.data?.authority
? `https://www.zarinpal.com/pg/StartPay/${response.data.authority}`
: null);
if (paymentUrl) {
window.location.href = paymentUrl;
return;
}
toast.error(
response?.message || response?.data?.message || "خطا در اتصال به درگاه پرداخت"
);
} catch (err: unknown) {
toast.dismiss(loadingToastId);
console.error("خطا در خرید:", err);
toast.error("خطا در شروع پرداخت");
} finally {
setPurchasing(false);
}
};
const handlePurchase = async (plan: {
duration: string;
label: string;
@@ -524,9 +587,19 @@ export default function CourseDetail({
toast.error("دانلود این ویدیو امکان پذیر نیست");
};
const finalPrice = courses[0]?.offerNumber > 0
? courses[0]?.price * (1 - courses[0]?.offerNumber / 100)
: course.price;
const finalPrice = courses[0]?.is_free
? 0
: courses[0]?.offerNumber > 0
? courses[0]?.price * (1 - courses[0]?.offerNumber / 100)
: course.price;
const courseContactInfo = courses[0]?.contactInfo;
const hasContactInfo = Boolean(
courseContactInfo?.mobile ||
courseContactInfo?.telegramLink ||
courseContactInfo?.whatsappNumber ||
courseContactInfo?.instagramLink
);
const freeVideosCount = videos.filter((v) => v.is_free).length;
const paidVideosCount = videos.filter((v) => !v.is_free).length;
@@ -558,6 +631,10 @@ useEffect(() => {
}
}, []);
if (courseLoading) {
return <AcademyCourseDetailSkeleton />;
}
return (
<div className="min-h-screen bg-white dark:bg-neutral-950">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
@@ -590,11 +667,14 @@ useEffect(() => {
برای مشاهده تمام ویدیوها دوره را تهیه کنید
</p>
<button
onClick={handlePurchase}
className="bg-blue-500 hover:bg-blue-600 text-white px-8 py-3 rounded-lg flex items-center gap-2 font-bold transition-colors"
type="button"
onClick={handleBuyCourse}
className={cn(btnPrimary, "flex items-center gap-2 px-8 py-3")}
>
<ShoppingCart className="w-5 h-5" />
خرید دوره با {Math.round(finalPrice).toLocaleString()} تومان
{courses[0]?.is_free
? "دریافت رایگان پکیج"
: `خرید دوره با ${Math.round(finalPrice).toLocaleString()} تومان`}
</button>
</div>
)}
@@ -754,7 +834,9 @@ useEffect(() => {
{/* قیمت */}
<div className="mb-4">
{courses[0]?.offerNumber > 0 ? (
{courses[0]?.is_free ? (
<span className="text-3xl font-bold text-green-600">رایگان</span>
) : courses[0]?.offerNumber > 0 ? (
<div className="space-y-2">
<div className="flex items-center gap-3 flex-wrap">
<span className="text-3xl font-bold text-[#FF107D]">
@@ -786,12 +868,29 @@ useEffect(() => {
</div>
) : (
<button
onClick={handlePurchase}
onClick={handleBuyCourse}
disabled={purchasing}
className="w-full bg-[#FF107D] hover:bg-[#FF107D80] text-white py-3 rounded-lg font-bold flex items-center justify-center gap-2 transition-colors disabled:opacity-50"
>
<ShoppingCart className="w-5 h-5" />
{purchasing ? "در حال اتصال به درگاه..." : "خرید دوره"}
{purchasing
? courses[0]?.is_free
? "در حال ثبت..."
: "در حال اتصال به درگاه..."
: courses[0]?.is_free
? "دریافت رایگان پکیج"
: "خرید دوره"}
</button>
)}
{hasContactInfo && courseContactInfo && (
<button
type="button"
onClick={() => setShowContactInfoModal(true)}
className="w-full mt-3 border border-[#FF107D] text-[#FF107D] py-3 rounded-lg font-bold flex items-center justify-center gap-2 transition-colors hover:bg-[#FF107D10]"
>
<Phone className="w-5 h-5" />
اطلاعات تماس
</button>
)}
@@ -905,6 +1004,20 @@ useEffect(() => {
</div>
</div>
</div>
{hasContactInfo && courseContactInfo && (
<BillboardContactInfoModal
showContactInfoModal={showContactInfoModal}
setShowContactInfoModal={setShowContactInfoModal}
contactInfo={{
mobile: courseContactInfo.mobile,
telegramLink: courseContactInfo.telegramLink,
whatsappNumber: courseContactInfo.whatsappNumber,
instagramLink: courseContactInfo.instagramLink,
saveInfoForNextAds: false,
}}
/>
)}
</div>
);
}

View File

@@ -1,6 +1,8 @@
import { Metadata } from "next";
import CourseDetail from "./CourseClient";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import { buildCourseDetailSeo } from "@/lib/buildCourseDetailSeo";
type Props = {
params: Promise<{ id: string; title: string }>;
@@ -11,7 +13,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
try {
const res = await fetch(
`https://app.modstagram.ir/api/v1/academy/academy/get/curses?page=1&limit=20&status=accept&_id=${id}`,
`${getApiBaseUrl()}/academy/academy/get/curses?page=1&limit=20&status=accept&_id=${id}`,
{ cache: "no-store" }
);
@@ -22,19 +24,12 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
response?.[0];
if (course) {
const courseName = course.cuorse_name || "دوره آموزشی";
const category = course.category || "دسته‌بندی نامشخص";
const teacher = course.teacher_name || "مدرس نامشخص";
const rawCaption = course.caption || "";
const cleanCaption = rawCaption
.replace(/<[^>]*>?/gm, "")
.substring(0, 150);
const seo = buildCourseDetailSeo(course, id);
return generatePageMetadata({
title: `${courseName} | ${category} | آموزشگاه مدستاگرام`,
description: `${cleanCaption || courseName}. دسته: ${category}. مدرس: ${teacher}. پکیج آموزشی تخصصی در آموزشگاه مدستاگرام.`,
path: `/academy/${id}/${encodeURIComponent(slugTitle || courseName)}`,
title: seo.title,
description: seo.description,
path: seo.path,
type: "article",
});
}
@@ -43,8 +38,8 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
}
return generatePageMetadata({
title: "جزئیات دوره | آموزشگاه مدستاگرام",
description: "مشاهده جزئیات پکیج آموزشی در آموزشگاه مدستاگرام.",
title: "جزئیات پکیج | مدستاگرام",
description: "مشاهده جزئیات پکیج آموزشی در مدستاگرام.",
path: `/academy/${id}/${encodeURIComponent(slugTitle || "course")}`,
});
}

View File

@@ -107,7 +107,7 @@ function FailedProject() {
onClick={() => {
payHandler();
}}
className="w-32 h-9 !border-[#0C8002] text-[#0C8002]"
variant="primary" className="w-32 h-9"
>
پرداخت مجدد
</RoundedButton>

View File

@@ -80,7 +80,7 @@ function SuccessProject() {
پروژه شما پس از بررسی توسط کارشناسان ما منتشر خواهد شد
</p>
<Link href={"/settings/workroom"}>
<RoundedButton className="w-32 h-9 !border-[#0C8002] text-[#0C8002]">
<RoundedButton variant="primary" className="w-32 h-9">
اتاق کار
</RoundedButton>
</Link>

View File

@@ -0,0 +1,333 @@
"use client";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import VerificationBadge from "@/components/main/VerificationBadge";
import AcademyPackageItem from "@/components/academy/AcademyPackageItem";
import {
AcademyPackageListSkeleton,
AcademyProfileHeadSkeleton,
} from "@/components/academy/AcademySkeletons";
import Container from "@/components/elements/Container";
import RoundedButton from "@/components/elements/RoundedButton";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import useAxios from "@/hooks/useAxios";
import { useUserById } from "@/hooks/getUserById";
import { Course, IContactInfo } from "@/types/types";
import Image from "next/image";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
interface AcademyStats {
packagesCount: number;
soldCount: number;
totalVideos: number;
}
interface AcademyOwner {
_id: string;
user_name?: string;
first_name?: string;
last_name?: string;
is_verified?: string;
is_Register?: string | boolean;
user_score?: string | number;
}
interface AcademyData {
_id: string;
userId?: string;
academy_name?: string;
academy_image?: string;
bio?: string;
tag?: string;
rate?: number;
number_of_rate?: number;
stats?: AcademyStats;
owner?: AcademyOwner;
contactInfo?: IContactInfo;
}
function openContact(
type: "mobile" | "whatsapp" | "telegram" | "instagram",
contact?: IContactInfo
) {
if (!contact) {
toast.error("اطلاعات تماس ثبت نشده است.");
return;
}
if (type === "mobile") {
const phone = contact.mobile || contact.phone;
if (!phone) {
toast.error("شماره موبایل ثبت نشده است.");
return;
}
window.open(`tel:${phone}`, "_self");
return;
}
if (type === "whatsapp") {
if (!contact.whatsappNumber) {
toast.error("شماره واتساپ ثبت نشده است.");
return;
}
window.open(
`https://wa.me/${contact.whatsappNumber.replace(/\D/g, "")}`,
"_blank"
);
return;
}
if (type === "telegram") {
if (!contact.telegramLink) {
toast.error("آیدی تلگرام ثبت نشده است.");
return;
}
window.open(`https://t.me/${contact.telegramLink.replace("@", "")}`, "_blank");
return;
}
if (!contact.instagramLink) {
toast.error("آیدی اینستاگرام ثبت نشده است.");
return;
}
window.open(
`https://instagram.com/${contact.instagramLink.replace("@", "")}`,
"_blank"
);
}
export default function AcademyProfileClient() {
const params = useParams();
const academyId = params?.academyId as string;
const { request } = useAxios();
const [academy, setAcademy] = useState<AcademyData | null>(null);
const [courses, setCourses] = useState<Course[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingCourses, setIsLoadingCourses] = useState(true);
const ownerUserId = academy?.owner?._id || academy?.userId || "";
const ownerFromApi = useUserById(ownerUserId);
useEffect(() => {
const fetchAcademy = async () => {
if (!academyId) {
setIsLoading(false);
return;
}
setIsLoading(true);
try {
const response = await request("POST", "/academy/findAcademyById", {
_id: academyId,
});
const academyData =
response?.academy || response?.data?.academy || response;
setAcademy(academyData);
} catch {
toast.error("خطا در دریافت اطلاعات آموزشگاه");
} finally {
setIsLoading(false);
}
};
void fetchAcademy();
}, [academyId, request]);
useEffect(() => {
const fetchCourses = async () => {
if (!academyId) return;
setIsLoadingCourses(true);
try {
const response = await request(
"GET",
`/academy/academy/get/getAcademyCourse/${academyId}?page=1&limit=50&status=accept`
);
const list =
response?.data?.courses || response?.courses || [];
setCourses(list);
} catch {
toast.error("خطا در دریافت پکیج‌ها");
setCourses([]);
} finally {
setIsLoadingCourses(false);
}
};
if (academyId) void fetchCourses();
}, [academyId, request]);
if (isLoading) {
return (
<Container>
<AcademyProfileHeadSkeleton />
<AcademyPackageListSkeleton />
</Container>
);
}
if (!academy) {
return (
<Container>
<div className="py-20 text-center text-neutral-500">آموزشگاه یافت نشد.</div>
</Container>
);
}
const academyName = academy.academy_name || "آموزشگاه";
const owner = academy.owner;
const displayUserName = owner?.user_name || ownerFromApi?.user_name;
const displayUserScore =
owner?.user_score ?? ownerFromApi?.user_score ?? 0;
const isVerified = owner?.is_verified ?? ownerFromApi?.is_verified;
const isRegister = owner?.is_Register ?? ownerFromApi?.is_Register;
const stats = academy.stats;
const shareUrl = `modstagram.com/academy/profile/${academyId}`;
return (
<Container className="pb-28">
<div className="w-full">
<div
className="flex items-center justify-end cursor-pointer gap-1 px-4 py-2 text-xs md:text-sm font-semibold"
onClick={() => {
navigator.clipboard.writeText(shareUrl);
toast.success("لینک آموزشگاه کپی شد");
}}
>
<Image
width={22}
height={22}
alt="copy icon"
src="/images/icons/copy.svg"
className="dark:invert"
/>
<span>{shareUrl}</span>
</div>
<div className="flex w-full items-center justify-between px-4 py-2">
<div className="flex flex-col items-center text-xs md:text-sm">
<div className="flex gap-5 mt-2">
<div className="font-semibold flex flex-col items-center max-sm:text-[10px]">
<span>{stats?.totalVideos ?? 0}</span>
<span>کل ویدئوها</span>
</div>
<div className="font-semibold flex flex-col items-center text-[#0C8002] max-sm:text-[10px]">
<span>{stats?.packagesCount ?? 0}</span>
<span>تعداد پکیجها</span>
</div>
<div className="font-semibold flex flex-col items-center text-[#3A59A9] max-sm:text-[10px]">
<span>{stats?.soldCount ?? 0}</span>
<span>تعداد فروخته شده</span>
</div>
</div>
</div>
<ProfileAvatar
src={
academy.academy_image
? `${IMAGE_BASE_URL}${academy.academy_image}`
: undefined
}
alt={academyName}
size="md"
rounded="xl"
className="md:h-[120px] md:w-[120px]"
/>
</div>
<div className="flex justify-between items-end px-4 py-2 w-full text-xs md:text-sm font-semibold">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<span>{Number(academy.rate || 0).toFixed(1)}</span>
<Image
width={22}
height={22}
alt="امتیاز"
src="/images/icons/star1.svg"
/>
</div>
<div className="flex items-center gap-1">
<span>{displayUserScore || 0}</span>
<Image
width={22}
height={22}
alt="امتیاز کاربر"
src="/images/icons/medal-star.svg"
/>
</div>
</div>
<div className="flex flex-col items-end text-right">
<h3 className="text-base md:text-lg font-bold">{academyName}</h3>
{displayUserName ? (
<Link
href={`/users/${displayUserName}`}
className="mt-1 flex items-center gap-1 flex-row-reverse font-bold text-[#3A59A9] hover:underline"
>
{displayUserName}
<VerificationBadge
isVerified={isVerified}
isRegister={isRegister}
/>
</Link>
) : null}
</div>
</div>
<div className="px-4 py-2 w-full text-xs md:text-sm font-semibold">
<p className="leading-relaxed text-neutral-700 dark:text-neutral-300">
{academy.bio || "توضیحاتی برای این آموزشگاه ثبت نشده است."}
</p>
</div>
<div className="px-4 py-2 w-full text-xs md:text-sm font-semibold">
<div className="grid grid-cols-2 md:grid-cols-4 gap-1 md:gap-4">
<button
type="button"
onClick={() => openContact("mobile", academy.contactInfo)}
className="text-[12px] md:text-sm h-7 md:h-8 max-sm:text-[8px] rounded-xl border border-neutral-200 bg-white px-2 dark:border-neutral-700 dark:bg-neutral-900 active:opacity-80"
>
موبایل
</button>
<button
type="button"
onClick={() => openContact("whatsapp", academy.contactInfo)}
className="text-[12px] md:text-sm h-7 md:h-8 max-sm:text-[8px] rounded-xl border border-neutral-200 bg-white px-2 dark:border-neutral-700 dark:bg-neutral-900 active:opacity-80"
>
واتساپ
</button>
<button
type="button"
onClick={() => openContact("telegram", academy.contactInfo)}
className="text-[12px] md:text-sm h-7 md:h-8 max-sm:text-[8px] rounded-xl border border-neutral-200 bg-white px-2 dark:border-neutral-700 dark:bg-neutral-900 active:opacity-80"
>
تلگرام
</button>
<RoundedButton
onClick={() => openContact("instagram", academy.contactInfo)}
className="text-[9px] md:text-sm h-7 md:h-8 max-sm:text-[8px]"
>
اینستاگرام
</RoundedButton>
</div>
</div>
</div>
<div className="px-4 mt-6">
<h2 className="font-bold text-base mb-4">پکیجهای آموزشی</h2>
{isLoadingCourses ? (
<AcademyPackageListSkeleton count={3} />
) : courses.length === 0 ? (
<p className="text-center text-gray-500 py-10">
هنوز پکیجی برای این آموزشگاه ثبت نشده است.
</p>
) : (
courses.map((course) => (
<AcademyPackageItem key={course._id} course={course} />
))
)}
</div>
</Container>
);
}

View File

@@ -1,381 +1,43 @@
"use client";
import { Metadata } from "next";
import AcademyProfileClient from "./AcademyProfileClient";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import { getApiBaseUrl } from "@/components/main/BaseUrl";
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import React, { useEffect, useState } from "react";
import { Academy, Course } from "@/types/types";
import Container from "@/components/elements/Container";
import useAxios from "@/hooks/useAxios";
import { useParams, useRouter } from "next/navigation";
import toast from "react-hot-toast";
import Image from "next/image";
import Link from "next/link";
import { motion } from "framer-motion";
type Props = {
params: Promise<{ academyId: string }>;
};
interface Tag {
value: string;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { academyId } = await params;
interface PaginationType {
currentPage: number;
totalPages: number;
totalItems: number;
hasNextPage: boolean;
hasPrevPage: boolean;
}
function AcademyPage() {
const params = useParams();
const academyId = params?.academyId as string;
const { request } = useAxios();
const [academy, setAcademy] = useState<Academy | null>(null);
const [courses, setCourses] = useState<Course[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingCourses, setIsLoadingCourses] = useState(false);
const [pagination, setPagination] = useState<PaginationType>({
currentPage: 1,
totalPages: 1,
totalItems: 0,
hasNextPage: false,
hasPrevPage: false,
try {
const res = await fetch(`${getApiBaseUrl()}/academy/findAcademyById`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ _id: academyId }),
cache: "no-store",
});
const data = await res.json();
const academy = data?.academy;
if (academy?.academy_name) {
return generatePageMetadata({
title: `${academy.academy_name} | مدستاگرام`,
description: academy.bio || academy.academy_name,
path: `/academy/profile/${academyId}`,
});
}
} catch {
// fallback below
}
return generatePageMetadata({
title: "آموزشگاه | مدستاگرام",
description: "صفحه آموزشگاه در مدستاگرام",
path: `/academy/profile/${academyId}`,
});
const router = useRouter();
// دریافت اطلاعات آکادمی
useEffect(() => {
const fetchAcademy = async () => {
if (!academyId) {
console.error("academyId یافت نشد");
setIsLoading(false);
return;
}
setIsLoading(true);
try {
const response = await request("POST", `/academy/findAcademyById`, {
_id: academyId
});
let academyData = null;
if (response?.academy) {
academyData = response.academy;
} else if (response?.data?.academy) {
academyData = response.data.academy;
} else {
academyData = response;
}
setAcademy(academyData);
} catch (error) {
console.error("خطا در دریافت اطلاعات:", error);
toast.error("خطا در دریافت اطلاعات آکادمی");
} finally {
setIsLoading(false);
}
};
fetchAcademy();
}, [academyId]);
// دریافت دوره‌های آکادمی
const fetchCourses = async (pageNum: number = 1) => {
if (!academyId) return;
setIsLoadingCourses(true);
try {
const response = await request(
"GET",
`/academy/academy/get/getAcademyCourse/${academyId}?page=${pageNum}&limit=6&status=accept`
);
console.log("دوره‌های آکادمی:", response);
if (response?.success && response?.data?.courses) {
setCourses(response.data.courses);
if (response.data.pagination) {
setPagination({
currentPage: response.data.pagination.currentPage,
totalPages: response.data.pagination.totalPages,
totalItems: response.data.pagination.totalItems,
hasNextPage: response.data.pagination.hasNextPage,
hasPrevPage: response.data.pagination.hasPrevPage,
});
}
} else if (response?.data?.courses) {
setCourses(response.data.courses);
} else {
setCourses([]);
}
} catch (err) {
console.error("خطا در دریافت دوره‌ها:", err);
toast.error("خطا در دریافت دوره‌های آکادمی");
setCourses([]);
} finally {
setIsLoadingCourses(false);
}
};
// بارگذاری اولیه دوره‌ها بعد از دریافت آکادمی
useEffect(() => {
if (academyId) {
fetchCourses(1);
}
}, [academyId]);
// تغییر صفحه
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= pagination.totalPages) {
fetchCourses(newPage);
window.scrollTo({ top: 600, behavior: "smooth" });
}
};
// پردازش تگ‌ها
const parseTags = (tagString: string): Tag[] => {
if (!tagString) return [];
try {
return JSON.parse(tagString);
} catch {
return [];
}
};
const tags = parseTags(academy?.tag as string || "[]");
if (isLoading) {
return (
<Container>
<div className="flex justify-center items-center min-h-[60vh]">
<div className="relative w-24 h-24">
<div className="absolute inset-0 rounded-full border-4 border-gray-200 dark:border-gray-700"></div>
<div className="absolute inset-0 rounded-full border-4 border-t-pink-500 border-r-transparent border-b-transparent border-l-transparent animate-spin"></div>
</div>
<p className="mr-4 text-gray-600 dark:text-gray-400">در حال بارگذاری...</p>
</div>
</Container>
);
}
if (!academy && !isLoading) {
return (
<Container>
<div className="py-20 text-center">
<div className="text-6xl mb-4">🏫</div>
<h2 className="text-2xl font-bold text-gray-800 dark:text-gray-200 mb-2">
آکادمی یافت نشد
</h2>
<p className="text-gray-600 dark:text-gray-400">
متأسفیم، آکادمی مورد نظر شما وجود ندارد یا حذف شده است.
</p>
<button
onClick={() => router.push("/")}
className="mt-6 px-6 py-2 bg-pink-500 text-white rounded-lg hover:bg-pink-600 transition-colors"
>
بازگشت به صفحه اصلی
</button>
</div>
</Container>
);
}
const academyName = academy?.academy_name || "آکادمی";
const academyImage = academy?.academy_image
? `${IMAGE_BASE_URL}${academy.academy_image}`
: "/images/default-academy.jpg";
const academyBio = academy?.bio || "این آکادمی هنوز توضیحاتی اضافه نکرده است.";
const academyRate = academy?.rate || 0;
const numberOfRate = academy?.number_of_rate || 0;
const totalCourses = pagination.totalItems;
const createdAt = academy?.createdAt ? new Date(academy.createdAt).toLocaleDateString("fa-IR") : "نامشخص";
return (
<Container >
{/* هدر حرفه‌ای کامل با تمام اطلاعات */}
<div className="relative mb-8 ">
{/* Background Cover with Gradient */}
<div className="absolute inset-0 rounded-2xl overflow-hidden">
<div className="absolute inset-0 "></div>
<div className="absolute inset-0 bg-[url('/images/pattern.png')] opacity-10"></div>
</div>
{/* Content */}
<div className="relative p-6 md:p-8">
<div className="flex flex-col md:flex-row items-center gap-6 md:gap-8">
{/* لوگو آکادمی - سمت راست در دسکتاپ */}
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.5 }}
className="flex-shrink-0"
>
<div className="relative w-28 h-28 md:w-32 md:h-32 rounded-2xl overflow-hidden shadow-2xl ">
<Image
src={academyImage}
alt={academyName}
fill
className="object-cover"
onError={(e) => {
(e.target as HTMLImageElement).src =
"/images/default-academy.jpg";
}}
/>
</div>
</motion.div>
{/* اطلاعات آکادمی - سمت چپ در دسکتاپ */}
<motion.div
initial={{ x: -20, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="flex-1 text-center md:text-right"
>
<h1 className="text-2xl md:text-3xl lg:text-4xl font-bold text-white mb-2">
{academyName}
</h1>
{/* توضیحات آکادمی */}
<p className="text-white/90 leading-relaxed text-sm md:text-base mb-3 max-w-2xl">
{academyBio}
</p>
{/* تگ‌ها */}
{tags.length > 0 && (
<div className="flex flex-wrap justify-center md:justify-start gap-2">
{tags.map((tag, index) => (
<span
key={index}
className="px-2 py-0.5 bg-white/20 backdrop-blur-sm rounded-full text-xs text-white"
>
#{tag.value}
</span>
))}
</div>
)}
</motion.div>
</div>
</div>
</div>
{/* کارت‌های آمار جزئی */}
<motion.div
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="grid p-3 grid-cols-2 md:grid-cols-4 gap-4 mb-8 -mt-6 relative z-10"
>
<div className="bg-white dark:bg-neutral-800 rounded-xl p-4 shadow-lg text-center hover:shadow-xl transition-shadow">
<div className="text-3xl mb-2">📚</div>
<div className="text-2xl font-bold text-gray-800 dark:text-gray-100">
{totalCourses}
</div>
<div className="text-sm text-gray-500">دوره آموزشی</div>
</div>
<div className="bg-white dark:bg-neutral-800 rounded-xl p-4 shadow-lg text-center hover:shadow-xl transition-shadow">
<div className="text-3xl mb-2">📅</div>
<div className="text-2xl font-bold text-gray-800 dark:text-gray-100">
{createdAt || "جدید"}
</div>
<div className="text-sm text-gray-500">تاسیس</div>
</div>
</motion.div>
{/* لیست دوره‌ها */}
<motion.section
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 0.3 }}
className="p-3"
>
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold text-gray-800 dark:text-gray-100">
📚 دورههای آموزشی
</h2>
<span className="text-sm text-gray-500">{totalCourses} دوره</span>
</div>
{courses.length === 0 ? (
<div className="text-center py-12 bg-gray-50 dark:bg-gray-800/50 rounded-2xl">
<div className="text-6xl mb-4">📚</div>
<p className="text-gray-600 dark:text-gray-400">
هنوز دورهای برای این آکادمی ثبت نشده است.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{courses.map((course, index) => (
<motion.div
key={course._id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
>
<Link
href={`/academy/${course._id}/course`}
className="group block bg-white dark:bg-neutral-800 rounded-xl overflow-hidden shadow-md hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1"
>
<div className="relative h-48 w-full overflow-hidden">
<Image
src={
course.course_image
? `${IMAGE_BASE_URL}${course.course_image}`
: "/images/default-course.jpg"
}
alt={course.cuorse_name}
fill
className="object-cover group-hover:scale-110 transition-transform duration-500"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
{course.offer && parseInt(course.offer) > 0 && (
<div className="absolute top-2 right-2 bg-red-500 text-white text-xs font-bold px-2 py-1 rounded-full z-10">
🔥 {course.offer}% تخفیف
</div>
)}
</div>
<div className="p-4">
<h3 className="font-bold text-lg text-gray-800 dark:text-gray-100 mb-1 line-clamp-1">
{course.cuorse_name}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3 line-clamp-1">
{course.caption}
</p>
<div className="flex justify-between items-center mt-3 pt-3 border-t border-gray-100 dark:border-gray-700">
<div className="flex items-center gap-1 text-sm text-gray-500">
<span>👨🏫</span>
<span>{course.teacher_name}</span>
</div>
<div className="text-lg font-bold text-pink-500">
{course.offer && parseInt(course.offer) > 0 ? (
<>
<span className="line-through text-xs text-gray-400 ml-1">
{parseInt(course.price).toLocaleString()}
</span>
{(
(parseInt(course.price) *
(100 - parseInt(course.offer))) /
100
).toLocaleString()}
</>
) : (
parseInt(course.price).toLocaleString()
)}
<span className="text-xs"> تومان</span>
</div>
</div>
</div>
</Link>
</motion.div>
))}
</div>
)}
</motion.section>
</Container>
);
}
export default AcademyPage;
export default function AcademyProfilePage() {
return <AcademyProfileClient />;
}

View File

@@ -1,8 +1,11 @@
import { Metadata } from "next";
import { Suspense } from "react";
import { cookies } from "next/headers";
import ProjectDetailClient from "./ProjectDetailClient";
import PageLoader from "@/components/ui/PageLoader";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import { fetchProject } from "@/api/fetchProject";
import { buildProjectDetailSeo } from "@/lib/buildProjectDetailSeo";
type ProjectDetailPageProps = {
params: Promise<{ id: string; title: string }>;
@@ -12,11 +15,23 @@ export async function generateMetadata({
params,
}: ProjectDetailPageProps): Promise<Metadata> {
const { id, title } = await params;
const decodedTitle = decodeURIComponent(title || "پروژه");
const token = (await cookies()).get("token")?.value || "";
const project = await fetchProject(id, token);
if (project) {
const seo = buildProjectDetailSeo(project);
return generatePageMetadata({
title: seo.title,
description: seo.description,
path: seo.path,
type: "article",
});
}
const decodedTitle = decodeURIComponent(title || "پروژه");
return generatePageMetadata({
title: `${decodedTitle} | پروژه‌های مدستاگرام`,
description: `جزئیات پروژه «${decodedTitle}» در مدستاگرام. مشاهده شرایط همکاری، بودجه و ثبت پیشنهاد.`,
title: `${decodedTitle} - مدستاگرام`,
description: `جزئیات پروژه «${decodedTitle}» در مدستاگرام.`,
path: `/projects/${id}/${encodeURIComponent(decodedTitle)}`,
});
}

View File

@@ -6,13 +6,10 @@ import { cookies } from "next/headers";
import { Metadata } from "next";
import { pageSeo } from "@/config/pageSeo";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
export const metadata: Metadata = generatePageMetadata({
title: pageSeo.projects.title,
description: pageSeo.projects.description,
path: pageSeo.projects.path,
keywords: [...pageSeo.projects.keywords],
});
import {
buildProjectsListSeo,
ProjectListFilters,
} from "@/lib/buildProjectsListSeo";
type ProjectsPageProps = {
searchParams: Promise<{
@@ -24,27 +21,56 @@ type ProjectsPageProps = {
}>;
};
export default async function ProjectsPage({ searchParams }: ProjectsPageProps) {
const filters = await searchParams;
const cookieStore = await cookies();
const token = cookieStore.get("token")?.value || "";
const projectFilters = {
function normalizeProjectFilters(
filters: Awaited<ProjectsPageProps["searchParams"]>
): ProjectListFilters {
return {
expertise: filters.expertise || "",
most_requests: filters.most_requests || "",
most_price: filters.most_price || "",
age: filters.age || "",
gender: filters.gender || "",
};
}
export async function generateMetadata({
searchParams,
}: ProjectsPageProps): Promise<Metadata> {
const filters = normalizeProjectFilters(await searchParams);
const hasFilters = Object.values(filters).some(Boolean);
if (!hasFilters) {
return generatePageMetadata({
title: pageSeo.projects.title,
description: pageSeo.projects.description,
path: pageSeo.projects.path,
keywords: [...pageSeo.projects.keywords],
});
}
const seo = buildProjectsListSeo(filters);
return generatePageMetadata({
title: seo.title,
description: seo.description,
path: seo.path,
keywords: [...pageSeo.projects.keywords],
});
}
export default async function ProjectsPage({ searchParams }: ProjectsPageProps) {
const rawFilters = await searchParams;
const projectFilters = normalizeProjectFilters(rawFilters);
const seo = buildProjectsListSeo(projectFilters);
const cookieStore = await cookies();
const token = cookieStore.get("token")?.value || "";
const initialData = await fetchProjects(1, 10, projectFilters, token);
return (
<Container className="pb-28">
<h1 className="sr-only">
{pageSeo.projects.title.replace(" | مدستاگرام", "")}
</h1>
<ProjectsFilter expertise={filters.expertise || ""} />
<h1 className="sr-only">{seo.h1}</h1>
<ProjectsFilter expertise={rawFilters.expertise || ""} />
<InfiniteProjects
initialData={initialData}
filters={projectFilters}

View File

@@ -13,6 +13,7 @@ import { generatePageMetadata } from "@/utils/generatePageMetadata";
import provinces from "@/data/provinces.json";
import cities from "@/data/cities.json";
import { normalizeUserLevel, parseUserLevelFromSlug } from "@/lib/userLevel";
import { parseExpertiseFromSlug } from "@/lib/expertiseColors";
interface IModelsProps {
params: Promise<{ slug?: string[] }>;
@@ -22,7 +23,15 @@ interface IModelsProps {
city?: string;
userLevel?: string;
rateFilter?: string;
hashtag?: string; // این خط را اضافه کنید
hashtag?: string;
heightMin?: string;
heightMax?: string;
weightMin?: string;
weightMax?: string;
sizeMin?: string;
sizeMax?: string;
hair_color?: string;
eye_color?: string;
}>;
}
@@ -123,11 +132,10 @@ export default async function Models({ params, searchParams }: IModelsProps) {
if (urlParams.slug && urlParams.slug.length > 0) {
const fullText = decodeURIComponent(urlParams.slug[0]).replace(/-/g, ' ');
// استخراج تخصص (فقط اگر در متن موجود باشد)
if (fullText.includes("مدل")) expertise = "مدل";
else if (fullText.includes("عکاس")) expertise = "عکاس";
else if (fullText.includes("آرایشگر")) expertise = "آرایشگر";
else if (fullText.includes("متخصصین")) expertise = ""; // حالت بدون تخصص
// استخراج تخصص از slug (مدل، عکاس، پوشاک و هر تخصص جدید)
const parsedExpertise = parseExpertiseFromSlug(fullText);
if (parsedExpertise) expertise = parsedExpertise;
else if (fullText.includes("متخصصین")) expertise = "";
const parsedLevel = parseUserLevelFromSlug(fullText);
if (parsedLevel) userLevel = parsedLevel;
@@ -163,7 +171,7 @@ export default async function Models({ params, searchParams }: IModelsProps) {
return (
<>
<Header />
<Container>
<Container shell>
{/* H1 مخفی برای بهبود سئو بر اساس آدرس صفحه */}
<h1 className="sr-only">
{(!urlParams.slug || urlParams.slug.length === 0) &&
@@ -184,14 +192,21 @@ export default async function Models({ params, searchParams }: IModelsProps) {
<InfinitePosts
filters={{
...filters,
hashtag: filters.hashtag, // این خط را اضافه کن
hashtag: filters.hashtag,
province: provinceId,
city: cityId,
userLevel: userLevel || normalizeUserLevel(filters.userLevel),
expertise: expertise || filters.expertise
expertise: expertise || filters.expertise,
heightMin: filters.heightMin,
heightMax: filters.heightMax,
weightMin: filters.weightMin,
weightMax: filters.weightMax,
sizeMin: filters.sizeMin,
sizeMax: filters.sizeMax,
hair_color: filters.hair_color,
eye_color: filters.eye_color,
}}
token={token}
// مقدار typeFilter را حذف کنید تا همه انواع نمایش داده شود
/>
</Container>
<TabNavigation currentPage="/" />

View File

@@ -101,8 +101,9 @@ function BillboardPayment({ params }: IBillboardProps) {
مبلغ قابل پرداخت: {Number(price).toLocaleString()} تومان
</RoundedDiv>
<RoundedButton
variant="primary"
onClick={payHandler}
className="w-32 h-9 !border-[#0C8002] text-[#0C8002]"
className="h-9 w-32"
>
پرداخت
</RoundedButton>

View File

@@ -137,7 +137,7 @@ function FailedBillboard() {
payHandler();
}
}}
className="w-32 h-9 !border-[#0C8002] text-[#0C8002]"
variant="primary" className="w-32 h-9"
>
پرداخت مجدد
</RoundedButton>

View File

@@ -81,7 +81,7 @@ function SuccessBillboard() {
خواهد شد.
</p>
<Link href={"/settings/my-billboards"}>
<RoundedButton className="w-32 h-9 !border-[#0C8002] text-[#0C8002]">
<RoundedButton variant="primary" className="w-32 h-9">
بیلورد من
</RoundedButton>
</Link>

View File

@@ -0,0 +1,54 @@
"use client";
import { Suspense } from "react";
import { useSearchParams } from "next/navigation";
import ExploreReelsView from "@/components/explore/ExploreReelsView";
import PostFeedView, { PostFeedSearchParams } from "@/components/posts/PostFeedView";
import PageLoader from "@/components/ui/PageLoader";
function toExploreFeedParams(
searchParams: URLSearchParams
): PostFeedSearchParams {
return {
feed: "explore",
filter: searchParams.get("filter") || undefined,
lat: searchParams.get("lat") || undefined,
lng: searchParams.get("lng") || undefined,
q: searchParams.get("q") || undefined,
hashtag: searchParams.get("hashtag") || undefined,
};
}
function ExploreReelContent({ id }: { id: string }) {
const searchParams = useSearchParams();
const typeParam = searchParams.get("type");
if (typeParam === "academy") {
return (
<ExploreReelsView
initialId={id}
initialType="academy"
showClose
/>
);
}
return (
<PostFeedView
initialPostId={id}
showClose
feedMode="explore"
searchParams={toExploreFeedParams(searchParams)}
/>
);
}
export default function ExploreReelClient({ id }: { id: string }) {
return (
<main className="min-h-[100dvh]">
<Suspense fallback={<PageLoader className="min-h-[100dvh]" />}>
<ExploreReelContent id={id} />
</Suspense>
</main>
);
}

View File

@@ -1,36 +1,42 @@
"use client";
import { Metadata } from "next";
import { fetchPostById } from "@/api/fetchPostById";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import {
buildPostPath,
buildPostSeoDescription,
buildPostSeoTitle,
} from "@/lib/postSlug";
import ExploreReelClient from "./ExploreReelClient";
import { Suspense, use } from "react";
import { useSearchParams } from "next/navigation";
import ExploreReelsView from "@/components/explore/ExploreReelsView";
import PageLoader from "@/components/ui/PageLoader";
function ExploreReelContent({ id }: { id: string }) {
const searchParams = useSearchParams();
const typeParam = searchParams.get("type");
const initialType = typeParam === "academy" ? "academy" : "post";
return (
<ExploreReelsView
initialId={id}
initialType={initialType}
showClose
/>
);
}
export default function ExploreReelPage({
params,
}: {
type Props = {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
};
return (
<main className="min-h-[100dvh]">
<Suspense fallback={<PageLoader className="min-h-[100dvh]" />}>
<ExploreReelContent id={id} />
</Suspense>
</main>
);
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const post = await fetchPostById(id);
const title = post ? buildPostSeoTitle(post) : "پست | مدستاگرام";
const description = post
? buildPostSeoDescription(post)
: "مشاهده پست در اکسپلور مدستاگرام";
const ogImage = post?.files?.[0]?.path
? buildStorageUrl(post.files[0].path)
: undefined;
return generatePageMetadata({
title,
description,
path: buildPostPath(id, post ?? undefined),
type: "article",
imageUrl: ogImage,
imageAlt: post?.caption?.slice(0, 80) || "پست مدستاگرام",
publishedTime: post?.createdAt,
modifiedTime: post?.updatedAt,
});
}
export default async function ExploreReelPage({ params }: Props) {
const { id } = await params;
return <ExploreReelClient id={id} />;
}

View File

@@ -10,12 +10,33 @@ import { pageSeo } from "@/config/pageSeo";
import { useCallback, useState } from "react";
import toast from "react-hot-toast";
function parseSearchQuery(input: string): { q?: string; hashtag?: string } {
const trimmed = input.trim();
if (!trimmed) return {};
if (trimmed.startsWith("#")) {
const tag = trimmed.slice(1).trim();
return tag ? { hashtag: tag } : {};
}
return { q: trimmed };
}
export default function ExplorePage() {
const [activeFilter, setActiveFilter] = useState<ExploreFilterId>("all");
const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(
null
);
const [loadingLocation, setLoadingLocation] = useState(false);
const [searchInput, setSearchInput] = useState("");
const [searchQuery, setSearchQuery] = useState<{
q?: string;
hashtag?: string;
}>({});
const handleSearchSubmit = useCallback((value?: string) => {
const nextValue = value ?? searchInput;
if (value !== undefined) setSearchInput(value);
setSearchQuery(parseSearchQuery(nextValue));
}, [searchInput]);
const handleFilterChange = useCallback((filter: ExploreFilterId) => {
if (filter === "near_me") {
@@ -62,8 +83,15 @@ export default function ExplorePage() {
activeFilter={activeFilter}
onFilterChange={handleFilterChange}
loadingLocation={loadingLocation}
searchValue={searchInput}
onSearchChange={setSearchInput}
onSearchSubmit={handleSearchSubmit}
/>
<ExploreGrid
activeFilter={activeFilter}
coords={coords}
searchQuery={searchQuery}
/>
<ExploreGrid activeFilter={activeFilter} coords={coords} />
</Container>
<TabNavigation currentPage="/explore" />
</>

View File

@@ -404,6 +404,37 @@ select {
transition: all 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
/* ─── Reels / post feed vertical scroll (Instagram-like snap) ─── */
.reels-vertical-scroll {
scroll-snap-type: y mandatory;
scroll-behavior: auto;
overscroll-behavior-y: contain;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.reels-vertical-scroll > * {
scroll-snap-align: start;
scroll-snap-stop: always;
}
.reels-vertical-scroll::-webkit-scrollbar {
display: none;
}
.reels-horizontal-scroll {
scroll-snap-type: x mandatory;
scroll-behavior: smooth;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.reels-horizontal-scroll > * {
scroll-snap-align: start;
scroll-snap-stop: always;
}
.reels-horizontal-scroll::-webkit-scrollbar {
display: none;
}
/* ─── Chat scrollbar ─── */
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
@@ -656,4 +687,88 @@ select {
.profile-avatar {
aspect-ratio: 1 / 1;
object-fit: cover;
}
/* ─── Design system: buttons & modals ─── */
:root {
--btn-border-color: #C3C3C3;
--btn-accent: #FC8EAC;
--btn-accent-hover: #f07898;
--btn-accent-text: #ffffff;
}
.btn-modern {
border: 1px solid var(--btn-border-color);
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.btn-modern:hover:not(:disabled) {
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.1);
}
.btn-modern:active:not(:disabled) {
transform: scale(0.98);
}
.dark .btn-modern {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28);
}
.dark .btn-modern:hover:not(:disabled) {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.38);
}
.btn-modern--primary {
background: var(--btn-accent);
color: var(--btn-accent-text);
border-color: var(--btn-border-color);
}
.btn-modern--primary:hover:not(:disabled) {
background: var(--btn-accent-hover);
}
.btn-modern--selected {
background: var(--btn-accent);
color: var(--btn-accent-text);
border-color: var(--btn-accent);
}
.btn-modern--selected:hover:not(:disabled) {
background: var(--btn-accent-hover);
border-color: var(--btn-accent-hover);
}
.glass-modal-overlay {
background: rgba(8, 8, 10, 0.52);
backdrop-filter: blur(20px) saturate(120%);
-webkit-backdrop-filter: blur(20px) saturate(120%);
}
.dark .glass-modal-overlay {
background: rgba(0, 0, 0, 0.62);
backdrop-filter: blur(22px) saturate(110%);
-webkit-backdrop-filter: blur(22px) saturate(110%);
}
.glass-modal-overlay--dark {
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(24px) saturate(110%);
-webkit-backdrop-filter: blur(24px) saturate(110%);
}
.dark .glass-modal-overlay--dark {
background: rgba(0, 0, 0, 0.82);
}
.glass-modal-panel {
background: rgba(255, 255, 255, 0.78);
backdrop-filter: blur(28px) saturate(180%);
-webkit-backdrop-filter: blur(28px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.5);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.14);
}
.dark .glass-modal-panel {
background: rgba(28, 28, 30, 0.84);
border-color: rgba(255, 255, 255, 0.14);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.48);
}
.glass-modal-panel--sheet {
border-radius: 1.5rem 1.5rem 0 0;
}
.glass-modal-panel--center {
border-radius: 1.5rem;
}

BIN
src/app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -17,15 +17,13 @@ const defaultTitle = pageSeo.home.title;
export const metadata: Metadata = {
title: {
default: defaultTitle,
template: "%s",
},
description: pageSeo.home.description,
metadataBase: new URL("https://modstagram.com"),
keywords: siteKeywords,
authors: [{ name: "Modstagram", url: "https://modstagram.com" }],
creator: "Modstagram",
alternates: {
canonical: "/",
},
manifest: "/manifest.json",
icons: {
icon: [{ url: "/favicon.ico", type: "image/x-icon" }],
@@ -40,19 +38,16 @@ export const metadata: Metadata = {
statusBarStyle: "black",
},
openGraph: {
title: defaultSEOConfig.openGraph?.title || defaultTitle,
description: defaultSEOConfig.openGraph?.description || "",
url: defaultSEOConfig.openGraph?.url || "",
siteName: defaultSEOConfig.openGraph?.site_name || "",
siteName: defaultSEOConfig.openGraph?.site_name || "مدستاگرام",
locale: "fa_IR",
type: "website",
images: defaultSEOConfig.openGraph?.images || [],
},
robots: "index, follow",
twitter: {
card: "summary_large_image",
title: defaultTitle,
description: defaultSEOConfig.description || "",
creator: "@modstagram",
site: "@modstagram",
images: defaultSEOConfig.openGraph?.images?.map((img) => img.url) || [],
},
verification: {

View File

@@ -4,6 +4,7 @@
import Container from "@/components/elements/Container";
import RoundedButton from "@/components/elements/RoundedButton";
import RoundedDiv from "@/components/elements/RoundedDiv";
import { selectionCardClass } from "@/lib/ui/buttonStyles";
import { BASE_URL } from "@/components/main/BaseUrl";
import UserInfo from "@/components/main/UserInfo";
import PageTitle from "@/components/settings/PageTitle";
@@ -189,9 +190,9 @@ function TicketChat({ params }: ITicketChatProps) {
</div>
</div>
<RoundedDiv
className={`w-full max-w-full p-2 ${
selectedType === item?.name ? "bg-[#FC8EAC]" : ""
} ${isDisabled ? "opacity-50 cursor-not-allowed" : ""}`}
className={`${selectionCardClass(selectedType === item?.name)} ${
isDisabled ? "cursor-not-allowed opacity-50" : ""
}`}
>
{item.name === "normal"
? "درخواست همکاری"
@@ -208,7 +209,7 @@ function TicketChat({ params }: ITicketChatProps) {
);
})}
<RoundedButton type="submit" className="p-2 px-8 rounded mt-4">
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
ثبت درخواست
</RoundedButton>
</form>

View File

@@ -30,7 +30,7 @@ function FailedBillboard() {
className="flex items-center flex-col mt-8"
href={`/offer/${userId}`}
>
<RoundedButton className="w-32 h-9 !border-[#0C8002] text-[#0C8002]">
<RoundedButton variant="primary" className="w-32 h-9">
پرداخت مجدد
</RoundedButton>
</Link>

View File

@@ -47,12 +47,12 @@ function SuccessBillboard() {
</p>
<div className="grid grid-cols-2 gap-4">
<Link href={`/settings/chats/${userDetail?.user_name}/${userId}`}>
<RoundedButton className="w-32 h-9 !border-[#0C8002] text-[#0C8002]">
<RoundedButton variant="primary" className="w-32 h-9">
ارسال پیام
</RoundedButton>
</Link>
<Link href={"/"}>
<RoundedButton className="w-32 h-9 !border-[#0C8002] text-[#0C8002]">
<RoundedButton variant="primary" className="w-32 h-9">
بعدا
</RoundedButton>
</Link>

View File

@@ -0,0 +1,98 @@
import { Metadata } from "next";
import { fetchPostById } from "@/api/fetchPostById";
import PostFeedView, { PostFeedSearchParams } from "@/components/posts/PostFeedView";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import {
buildPostPath,
buildPostPublicUrl,
buildPostSeoDescription,
buildPostSeoTitle,
} from "@/lib/postSlug";
type Props = {
params: Promise<{ id: string; slug?: string[] }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
function pickQuery(
sp: Record<string, string | string[] | undefined>,
key: string
): string | undefined {
const v = sp[key];
return typeof v === "string" ? v : undefined;
}
function toFeedSearchParams(
sp: Record<string, string | string[] | undefined>
): PostFeedSearchParams {
return {
feed: pickQuery(sp, "feed"),
tab: pickQuery(sp, "tab"),
userId: pickQuery(sp, "userId"),
expertise: pickQuery(sp, "expertise"),
province: pickQuery(sp, "province"),
city: pickQuery(sp, "city"),
userLevel: pickQuery(sp, "userLevel"),
rateFilter: pickQuery(sp, "rateFilter"),
hashtag: pickQuery(sp, "hashtag"),
exploreFilter: pickQuery(sp, "exploreFilter"),
subExpertise: pickQuery(sp, "subExpertise"),
filter: pickQuery(sp, "filter"),
lat: pickQuery(sp, "lat"),
lng: pickQuery(sp, "lng"),
q: pickQuery(sp, "q"),
};
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const post = await fetchPostById(id);
const title = post ? buildPostSeoTitle(post) : "پست | مدستاگرام";
const description = post
? buildPostSeoDescription(post)
: "مشاهده پست در مدستاگرام — پلتفرم تخصصی حوزه زیبایی، مدلینگ و عکاسی";
const ogImage = post?.files?.[0]?.path
? buildStorageUrl(post.files[0].path)
: undefined;
const authorName =
post &&
([post.first_name, post.last_name].filter(Boolean).join(" ") ||
post.user_name);
return generatePageMetadata({
title,
description,
path: buildPostPath(id, post ?? undefined),
type: "article",
imageUrl: ogImage,
imageAlt: post?.caption?.slice(0, 80) || "پست مدستاگرام",
keywords: [
"مدستاگرام",
post?.expertise || "",
authorName || "",
"پست",
"مدلینگ",
"زیبایی",
].filter(Boolean),
publishedTime: post?.createdAt,
modifiedTime: post?.updatedAt,
});
}
/** بدون await روی API — نمایش فوری از sessionStorage در PostFeedView */
export default async function PostPage({ params, searchParams }: Props) {
const { id } = await params;
const sp = await searchParams;
const feedSearchParams = toFeedSearchParams(sp);
return (
<main className="min-h-[100dvh]">
<PostFeedView
initialPostId={id}
showClose
searchParams={feedSearchParams}
/>
</main>
);
}

View File

@@ -1,69 +0,0 @@
import { Metadata } from "next";
import { fetchPostById } from "@/api/fetchPostById";
import PostFeedView from "@/components/posts/PostFeedView";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import { Suspense } from "react";
import PageLoader from "@/components/ui/PageLoader";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
type Props = { params: Promise<{ id: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const post = await fetchPostById(id);
const title = post?.caption
? `${post.caption.slice(0, 60)} | مدستاگرام`
: "پست | مدستاگرام";
const description =
post?.caption?.slice(0, 160) ||
"مشاهده پست در مدستاگرام — پلتفرم تخصصی حوزه زیبایی، مدلینگ و عکاسی";
const ogImage = post?.files?.[0]?.path
? buildStorageUrl(post.files[0].path)
: undefined;
return generatePageMetadata({
title,
description,
path: `/posts/${id}`,
type: "article",
imageUrl: ogImage,
imageAlt: post?.caption?.slice(0, 80) || "پست مدستاگرام",
});
}
export default async function PostPage({ params }: Props) {
const { id } = await params;
const post = await fetchPostById(id);
const userId = post?.userId || post?.user_id;
return (
<main className="min-h-[100dvh]">
{post && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "SocialMediaPosting",
headline: post.caption?.slice(0, 110) || "پست مدستاگرام",
url: `https://modstagram.com/posts/${id}`,
datePublished: post.createdAt,
author: {
"@type": "Person",
name: `${post.first_name || ""} ${post.last_name || ""}`.trim(),
},
}),
}}
/>
)}
<Suspense fallback={<PageLoader className="min-h-[100dvh]" />}>
<PostFeedView
initialPostId={id}
initialPost={post}
userId={userId}
showClose
/>
</Suspense>
</main>
);
}

View File

@@ -1,5 +1,7 @@
"use client";
import { AcademyListSkeleton } from "@/components/academy/AcademySkeletons";
import useAxios from "@/hooks/useAxios";
import React, { useEffect, useState, useCallback } from "react";
import MainModelCard from "@/components/academy/MainModelCard";
@@ -80,16 +82,8 @@ export default function PurchasedCoursesPage() {
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="relative w-24 h-24 mx-auto">
<div className="absolute inset-0 rounded-full border-4 border-gray-200 dark:border-gray-700"></div>
<div className="absolute inset-0 rounded-full border-4 border-t-blue-500 border-r-purple-500 border-b-pink-500 border-l-transparent animate-spin"></div>
</div>
<p className="mt-4 text-gray-600 dark:text-gray-300 font-medium">
در حال بارگذاری دورههای شما...
</p>
</div>
<div className="min-h-screen container mx-auto px-4 py-8">
<AcademyListSkeleton count={4} />
</div>
);
}

View File

@@ -29,6 +29,8 @@ import { useRouter } from "next/navigation";
import useAxios from "@/hooks/useAxios";
import { Academy } from "@/types/types";
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import { AcademyProfileHeadSkeleton } from "@/components/academy/AcademySkeletons";
import { Skeleton } from "@/components/ui/skeleton";
// Zod schema for store settings
const storeSchema = z.object({
@@ -197,8 +199,13 @@ export default function StoreSettings() {
// نمایش لودینگ در حین دریافت دیتا
if (isFetching) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
<div className="w-full p-6 max-w-4xl mx-auto mb-32">
<AcademyProfileHeadSkeleton />
<div className="space-y-4 mt-6">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-32 w-full" />
</div>
</div>
);
}

View File

@@ -2,6 +2,7 @@
import React, { useState, useEffect } from "react";
import { ThemeProvider, useTheme } from "next-themes";
import { Skeleton } from "@/components/ui/skeleton";
import {
Card,
@@ -211,8 +212,13 @@ const WalletDashboard = () => {
if (isLoading) {
return (
<div className="min-h-screen flex justify-center items-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-pink-500"></div>
<div className="container mx-auto p-6 space-y-4">
<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>
);
}

View File

@@ -10,6 +10,7 @@ import {
} from "@/lib/chat/socketClient";
import MessageInput from "@/components/chat/MessageInput";
import MultiImageModal from "@/components/chat/MultiImageModal";
import MediaPreviewModal from "@/components/chat/MediaPreviewModal";
import ChatActionBar from "@/components/chat/ChatActionBar";
import ChatDeleteBar from "@/components/chat/ChatDeleteBar";
import { useUser } from "@/hooks/useUser";
@@ -26,7 +27,6 @@ import { chatThreadQueryKey } from "@/lib/chat/queryKeys";
import { normalizeThreadId, upsertMessageInThreadCache } from "@/lib/chat/threadCache";
import { useStableMessageKeys } from "@/hooks/useStableMessageKeys";
import { getStoredUserId } from "@/lib/auth/session";
import { buildExpiresAtIso } from "@/lib/chat/timedMessages";
import { isViewOnceMediaType } from "@/lib/chat/viewOnce";
import {
findPendingMatchForServer,
@@ -52,6 +52,10 @@ function TicketChat({ params }: ITicketChatProps) {
const [userTwoDetail, setUserTwoDetail] = useState<User>();
const [pendingImages, setPendingImages] = useState<File[]>([]);
const [showMultiModal, setShowMultiModal] = useState(false);
const [pendingMediaPreview, setPendingMediaPreview] = useState<{
file: File;
kind: "video" | "file";
} | null>(null);
const [pendingMessages, setPendingMessages] = useState<ChatMessage[]>([]);
const [replyingTo, setReplyingTo] = useState<ChatMessage | null>(null);
const [forwardMessage, setForwardMessage] = useState<ChatMessage | null>(null);
@@ -170,12 +174,12 @@ function TicketChat({ params }: ITicketChatProps) {
);
if (videos.length === 1 && images.length === 0 && others.length === 0) {
void processSendMessage(videos[0], "video");
setPendingMediaPreview({ file: videos[0], kind: "video" });
return;
}
if (others.length === 1 && images.length === 0 && videos.length === 0) {
void processSendMessage(others[0], "file");
setPendingMediaPreview({ file: others[0], kind: "file" });
return;
}
@@ -248,6 +252,11 @@ function TicketChat({ params }: ITicketChatProps) {
let textContent = contentOverride ?? newMessage;
if (textContent.trim() === "" && !fileToUpload) return;
if (userTwoDetail?.blocked_you || userTwoDetail?.is_blocked) {
toast.error("امکان ارسال پیام وجود ندارد.");
return;
}
const tempId = `temp-${Date.now()}-${Math.random()}`;
const replyPayload = buildReplyPayload(replySource);
@@ -265,7 +274,7 @@ function TicketChat({ params }: ITicketChatProps) {
senderId: currentUserId,
receiverId: targetReceiverId,
createdAt: new Date().toISOString(),
expiresAt: buildExpiresAtIso(selfDestructSeconds),
selfDestructSeconds: selfDestructSeconds || undefined,
status: "pending",
file: fileToUpload ? URL.createObjectURL(fileToUpload) : "",
fileType: fileType,
@@ -293,7 +302,6 @@ function TicketChat({ params }: ITicketChatProps) {
let messagePayload: Record<string, unknown> = {
content: textContent,
receiverId: targetReceiverId,
senderId: currentUserId,
};
if (replySource?._id && !replySource._id.startsWith("temp")) {
@@ -425,7 +433,7 @@ function TicketChat({ params }: ITicketChatProps) {
queryClient.setQueryData(
chatThreadQueryKey(
normalizeThreadId(user._id),
normalizeThreadId(chatPartnerId)
normalizeThreadId(userTwoDetail?._id ?? receiverId ?? chatPartnerId)
),
(oldData: { pages: { messages: ChatMessage[] }[] } | undefined) => {
if (!oldData) return oldData;
@@ -511,7 +519,7 @@ function TicketChat({ params }: ITicketChatProps) {
onLocationShare={handleLocationShare}
onTyping={handleTyping}
isChatThread
blocked_you={userTwoDetail?.blocked_you}
blocked_you={userTwoDetail?.blocked_you || userTwoDetail?.is_blocked}
replyingTo={replyingTo}
onCancelReply={() => {
setReplyingTo(null);
@@ -550,6 +558,22 @@ function TicketChat({ params }: ITicketChatProps) {
selfDestructSeconds={selfDestructSeconds}
onSelfDestructChange={setSelfDestructSeconds}
/>
<MediaPreviewModal
isOpen={!!pendingMediaPreview}
file={pendingMediaPreview?.file ?? null}
kind={pendingMediaPreview?.kind ?? "file"}
onCancel={() => setPendingMediaPreview(null)}
onConfirm={() => {
if (!pendingMediaPreview) return;
const { file, kind } = pendingMediaPreview;
setPendingMediaPreview(null);
void processSendMessage(file, kind === "video" ? "video" : "file");
}}
viewOnceMedia={viewOnceMedia}
onViewOnceChange={setViewOnceMedia}
selfDestructSeconds={selfDestructSeconds}
onSelfDestructChange={setSelfDestructSeconds}
/>
{forwardMessage && user?._id && (
<ForwardMessageModal
open={!!forwardMessage}

View File

@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
export const metadata: Metadata = generatePageMetadata({
title: "پیام‌ها | مدستاگرام",
description: "گفتگوها و پیام‌های خصوصی در مدستاگرام",
path: "/settings/chats",
});
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -30,7 +30,8 @@ export interface IMessage {
unread_messages_count?: number;
is_blocked: boolean | null;
blocked_you: boolean | null;
last_message_date?: string | null;
display_name?: string;
account_deactivated?: boolean;
}
function Chats() {
@@ -116,14 +117,15 @@ function Chats() {
<div className="h-12 w-12 rounded-2xl bg-neutral-200" />
)}
<div className="flex flex-col gap-1">
<span className="font-semibold">
{item.first_name} {item.last_name}
<span className="flex items-center gap-1 font-semibold">
{item.display_name ||
`${item.first_name} ${item.last_name}`.trim()}
<VerificationBadge isVerified={item.is_verified} />
</span>
<span className="text-neutral-500">{item.user_name}</span>
</div>
</div>
<div className="flex flex-col items-end gap-1">
<VerificationBadge isVerified={item.is_verified} />
{item.unread_messages_count ? (
<span className="rounded-full bg-[#387E65] px-2 py-0.5 text-xs text-white">
{item.unread_messages_count}

View File

@@ -93,7 +93,7 @@ function Colors() {
<button
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
selectedButton === 1
? "bg-[#FC8EAC] border-[#FC8EAC]"
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => handleButtonPress(1)}
@@ -103,7 +103,7 @@ function Colors() {
<button
className={`p-1 rounded-full w-full border text-sm flex items-center justify-center gap-1 ${
selectedButton === 2
? "bg-[#FC8EAC] border-[#FC8EAC]"
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => handleButtonPress(2)}

View File

@@ -63,7 +63,7 @@ function CooperationType() {
onClick={() => formik.setFieldValue("selectedButton", true)}
className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${
formik.values.selectedButton === true
? "!bg-[#FC8EAC] !border-[#FC8EAC] !text-white"
? "btn-modern--selected text-white"
: ""
}`}
>
@@ -74,7 +74,7 @@ function CooperationType() {
onClick={() => formik.setFieldValue("selectedButton", false)}
className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${
formik.values.selectedButton === false
? "!bg-[#FC8EAC] !border-[#FC8EAC] !text-white"
? "btn-modern--selected text-white"
: ""
}`}
>

View File

@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
export const metadata: Metadata = generatePageMetadata({
title: "ویرایش پروفایل | مدستاگرام",
description: "ویرایش اطلاعات و تنظیمات پروفایل در مدستاگرام",
path: "/settings/edit",
});
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -69,7 +69,7 @@ function PublicRelations() {
onClick={() => formik.setFieldValue("selectedButton", true)}
className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${
formik.values.selectedButton === true
? "!bg-[#FC8EAC] !border-[#FC8EAC] !text-white"
? "btn-modern--selected text-white"
: ""
}`}
>
@@ -80,7 +80,7 @@ function PublicRelations() {
onClick={() => formik.setFieldValue("selectedButton", false)}
className={`mt-5 text-xs w-full max-w-[250px] flex items-center justify-center flex-row-reverse gap-2 px-1 ${
formik.values.selectedButton === false
? "!bg-[#FC8EAC] !border-[#FC8EAC] !text-white"
? "btn-modern--selected text-white"
: ""
}`}
>

View File

@@ -82,7 +82,7 @@ function Sizes() {
<button
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
selectedButton === 1
? "bg-[#FC8EAC] border-[#FC8EAC]"
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark text-black border-gray-400"
}`}
onClick={() => handleButtonPress(1)}
@@ -99,7 +99,7 @@ function Sizes() {
<button
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
selectedButton === 2
? "bg-[#FC8EAC] border-[#FC8EAC]"
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => handleButtonPress(2)}
@@ -116,7 +116,7 @@ function Sizes() {
<button
className={`p-1 rounded-full w-1/3 border text-sm flex items-center justify-center gap-1 ${
selectedButton === 3
? "bg-[#FC8EAC] border-[#FC8EAC]"
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => handleButtonPress(3)}
@@ -159,7 +159,7 @@ function Sizes() {
key={sizeOption}
className={`p-2 py-1 rounded-full border text-sm full ${
size === sizeOption
? "bg-[#FC8EAC] text-white border-[#FC8EAC]"
? "btn-modern--selected text-white"
: "bg-tertiary-light dark:bg-tertiary-dark border-gray-400"
}`}
onClick={() => handleSizeSelection(sizeOption)}

View File

@@ -0,0 +1,20 @@
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import FavoritesGrid from "@/components/settings/FavoritesGrid";
import { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
export const metadata: Metadata = generatePageMetadata({
title: "علاقمندی‌ها | مدستاگرام",
description: "پست‌های ذخیره‌شده در علاقمندی‌های شما در مدستاگرام",
path: "/settings/favorites",
});
export default function FavoritesPage() {
return (
<Container>
<PageTitle>علاقمندیها</PageTitle>
<FavoritesGrid />
</Container>
);
}

View File

@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
export const metadata: Metadata = generatePageMetadata({
title: "امور مالی | مدستاگرام",
description: "تراکنش‌ها و امور مالی حساب کاربری در مدستاگرام",
path: "/settings/financial",
});
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -3,8 +3,7 @@ import type { Metadata } from "next";
export const metadata: Metadata = {
title: {
default: "تنظیمات",
template: "%s | مدستاگرام",
default: "تنظیمات | مدستاگرام",
},
description: "تنظیمات حساب کاربری در مدستاگرام",
robots: { index: false, follow: false },

View File

@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
export const metadata: Metadata = generatePageMetadata({
title: "بیلبوردهای من | مدستاگرام",
description: "مدیریت بیلبوردهای تبلیغاتی شما در مدستاگرام",
path: "/settings/my-billboards",
});
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
export const metadata: Metadata = generatePageMetadata({
title: "اعلان‌ها | مدستاگرام",
description: "اعلان‌ها و پیام‌های سیستمی حساب کاربری در مدستاگرام",
path: "/settings/notifications",
});
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -9,6 +9,50 @@ import Image from "next/image";
import Link from "next/link";
import React, { useEffect, useState } from "react";
const LIKE_NOTIFICATION_TYPES = new Set([
"post_like",
"academy_like",
"billboard_like",
]);
const COMMENT_NOTIFICATION_TYPES = new Set([
"post_comment",
"profile_comment",
"user_comment",
"academy_comment",
"billboard_comment",
"vitrine-comment",
]);
const RATING_NOTIFICATION_TYPES = new Set([
"post_rating",
"profile_rating",
"academy_rating",
"billboard_rating",
]);
function getNotificationTitle(item: INotification): string {
if (LIKE_NOTIFICATION_TYPES.has(item.type)) return "پست شما لایک شد";
if (COMMENT_NOTIFICATION_TYPES.has(item.type)) {
return "یک کامنت برای شما ثبت شد";
}
if (RATING_NOTIFICATION_TYPES.has(item.type)) {
return "یک امتیاز برای شما ثبت شد";
}
return item.title;
}
function getNotificationDescription(item: INotification): string {
if (LIKE_NOTIFICATION_TYPES.has(item.type)) return "پست شما لایک شد";
if (COMMENT_NOTIFICATION_TYPES.has(item.type)) {
return "یک کامنت برای شما ثبت شد";
}
if (RATING_NOTIFICATION_TYPES.has(item.type)) {
return "یک امتیاز برای شما ثبت شد";
}
return item.description;
}
export interface INotification {
_id: string;
userId: string;
@@ -61,9 +105,14 @@ function Notifications() {
academy_like: `/academy/${item?.project_post_id}/course`,
billboard_like: `/settings/my-billboards/${item?.project_post_id}/b`,
post_comment: `/posts/${item?.project_post_id}`,
post_rating: `/posts/${item?.project_post_id}`,
post_tag: `/posts/${item?.project_post_id}`,
profile_comment: "/settings/profile",
profile_rating: "/settings/profile",
user_comment: "/settings/profile",
academy_comment: `/academy/${item?.project_post_id}/course`,
academy_rating: `/academy/${item?.project_post_id}/course`,
academy_purchase: `/academy/${item?.project_post_id}/course`,
billboard_comment: `/settings/my-billboards/${item?.project_post_id}/b`,
billboard_rating: `/settings/my-billboards/${item?.project_post_id}/b`,
"reject-user": `/tickets/new/${
@@ -115,11 +164,11 @@ function Notifications() {
) : (
""
)}
<p>{item?.title}</p>
<p>{getNotificationTitle(item)}</p>
</div>
<span>{item?.createdAt}</span>
</div>
<p className="mt-4">{item?.description}</p>
<p className="mt-4">{getNotificationDescription(item)}</p>
</Link>
))}
</React.Fragment>

View File

@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
export const metadata: Metadata = generatePageMetadata({
title: "درخواست‌های همکاری | مدستاگرام",
description: "مدیریت درخواست‌ها و پیشنهادهای همکاری در مدستاگرام",
path: "/settings/offers",
});
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -10,6 +10,8 @@ import UserDetails from "@/components/settings/UserDetails";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { IOffers } from "@/types/types";
import React, { useState } from "react";
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
function Offers() {
const [filter, setFilter] = useState<string>("درخواست");
@@ -41,17 +43,13 @@ function Offers() {
<UserDetails />
<div className="grid grid-cols-2 w-full gap-4 mx-auto max-w-md mt-8">
<RoundedButton
className={`h-9 !border-[#0C8002] ${
filter == "درخواست" ? "bg-[#0C8002] text-white" : ""
}`}
className={cn(toggleBtnClass(filter == "درخواست"), "h-9")}
onClick={() => setFilter("درخواست")}
>
درخواست
</RoundedButton>
<RoundedButton
className={`h-9 !border-[#FF0000] ${
filter == "دریافت" ? "bg-[#FF000077] text-white" : ""
}`}
className={cn(toggleBtnClass(filter == "دریافت"), "h-9")}
onClick={() => setFilter("دریافت")}
>
دریافت
@@ -68,6 +66,7 @@ function Offers() {
<OffersItem
key={item?._id}
item={item}
mode={filter === "دریافت" ? "received" : "sent"}
actionHandler={actionHandler}
/>
))}
@@ -88,6 +87,7 @@ function Offers() {
isOpen={showConfirmModal}
onClose={() => setShowConfirmModal(false)}
item={itemToAction}
mode="received"
refetch={refetch}
/>
)}

View File

@@ -9,6 +9,14 @@ import { staticIconUrl } from "@/components/main/BaseUrl";
import Image from "next/image";
import Link from "next/link";
import React from "react";
import type { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
export const metadata: Metadata = generatePageMetadata({
title: "تنظیمات | مدستاگرام",
description: "تنظیمات حساب کاربری در مدستاگرام",
path: "/settings",
});
function Settings() {
return (

View File

@@ -0,0 +1,46 @@
"use client";
import React, { useEffect, useState } from "react";
import Container from "@/components/elements/Container";
import ModelHead from "@/components/models/ModelPage/ModelHead";
import useAxios from "@/hooks/useAxios";
import ModelContent from "@/components/models/ModelPage/ModelContent";
import { User } from "@/types/types";
export default function ProfileClient() {
const [user, setUser] = useState<User | null>(null);
const { request, loading } = useAxios();
const fetchUser = async () => {
try {
const response = await request<{ user: User }>("GET", "/profile");
setUser(response?.user ?? null);
} catch (err) {
console.error("خطا در دریافت اطلاعات کاربر:", err);
}
};
useEffect(() => {
fetchUser();
}, []);
if (loading || !user) {
return (
<div className="flex h-screen items-center justify-center" />
);
}
return (
<Container>
<ModelHead
item2={{
postsCount: user.postsCount ?? 0,
followersCount: user.followersCount ?? 0,
followingCount: user.followingCount ?? 0,
}}
user={user}
/>
<ModelContent user={user} searchParams={{}} />
</Container>
);
}

View File

@@ -24,13 +24,13 @@ function ProfileInfo() {
<ModelHead
item2={{
postsCount: user?.postsCount ?? 0,
allProjectsCount: user?.allProjectsCount ?? 0,
successfulProjectsCount: user?.successfulProjectsCount ?? 0,
followersCount: user?.followersCount ?? 0,
followingCount: user?.followingCount ?? 0,
}}
user={user ?? ({} as User)}
/>
<ProfileContent user={user} />
<ProfileContent user={user} onRefresh={fetchStates} />
</Container>
);
}

View File

@@ -1,21 +1,7 @@
export const metadata = {
title: "پروفایل کاربر - مدستاگرام",
description: "مشاهده بیو، تخصص، و نمونه کارهای کاربر در پلتفرم مدستاگرام.",
other: {
"script:type": "application/ld+json",
"script:data": JSON.stringify({
"@context": "https://schema.org",
"@type": "Person",
"name": "نام کاربر",
"jobTitle": "تخصص کاربر",
"description": "بیوگرافی، تخصص و سطح فعالیت در مدستاگرام.",
"image": "https://modstagram.com/images/user-avatar.jpg",
"inLanguage": "fa"
})
}
};
export default function ProfileLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
export default function ProfileLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@@ -1,57 +1,52 @@
"use client";
import React, { useEffect, useState } from "react";
import { cookies } from "next/headers";
import { Metadata } from "next";
import ProfileClient from "./ProfileClient";
import { BASE_URL } from "@/components/main/BaseUrl";
import { User } from "@/types/types";
import Container from "@/components/elements/Container";
import ModelHead from "@/components/models/ModelPage/ModelHead";
import useAxios from "@/hooks/useAxios";
import ModelContent from "@/components/models/ModelPage/ModelContent";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
function Profile() {
const [user, setUser] = useState<User | null>(null);
const { request, loading } = useAxios();
const fetchUser = async () => {
try {
const response = await request<{ user: User }>("GET", "/profile");
setUser(response?.user ?? null);
} catch (err) {
console.error("خطا در دریافت اطلاعات کاربر:", err);
export async function generateMetadata(): Promise<Metadata> {
const token = (await cookies()).get("token")?.value || "";
try {
const res = await fetch(`${BASE_URL}/profile`, {
cache: "no-store",
headers: { Authorization: token ? `Bearer ${token}` : "" },
});
const data = await res.json();
const user = data?.user as User;
if (!user) {
return generatePageMetadata({
title: "پروفایل | مدستاگرام",
description: "پروفایل کاربری در مدستاگرام",
path: "/settings/profile",
});
}
};
const fullName = `${user.first_name || ""} ${user.last_name || ""}`.trim();
const expertise = user.expertise || "";
const city = user.city?.name || "";
const bio = user.bio || "";
const username = user.user_name || "";
useEffect(() => {
fetchUser();
}, []);
const title = [username, fullName, expertise, city, "مدستاگرام"]
.filter(Boolean)
.join(" ");
const description = [fullName, expertise, bio].filter(Boolean).join(" ");
// لودینگ
if (loading || !user) {
return (
<div className="flex justify-center items-center h-screen">
</div>
);
return generatePageMetadata({
title,
description,
path: "/settings/profile",
type: "profile",
});
} catch {
return generatePageMetadata({
title: "پروفایل | مدستاگرام",
description: "پروفایل کاربری در مدستاگرام",
path: "/settings/profile",
});
}
return (
<Container>
<ModelHead
item2={{
postsCount: user.postsCount ?? 0,
allProjectsCount: user.allProjectsCount ?? 0,
successfulProjectsCount: user.successfulProjectsCount ?? 0,
}}
user={user}
/>
<ModelContent user={user} searchParams={{}} />
</Container>
);
}
export default Profile;
export default function ProfilePage() {
return <ProfileClient />;
}

View File

@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
export const metadata: Metadata = generatePageMetadata({
title: "تنظیمات پروفایل | مدستاگرام",
description: "تنظیمات نمایش و حریم خصوصی پروفایل در مدستاگرام",
path: "/settings/profile/user-settings",
});
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -0,0 +1,144 @@
"use client";
import React, { useEffect, useState } from "react";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import useAxios from "@/hooks/useAxios";
import Link from "next/link";
import toast from "react-hot-toast";
export default function UserSettingsPage() {
const { request } = useAxios();
const [allowSave, setAllowSave] = useState(true);
const [ghostMode, setGhostMode] = useState(false);
const [loading, setLoading] = useState(true);
const [accountStatus, setAccountStatus] = useState<string>("active");
const [reactivationLockedUntil, setReactivationLockedUntil] = useState<string | null>(null);
const [reactivating, setReactivating] = useState(false);
useEffect(() => {
(async () => {
try {
const res = await request<{
account: {
allow_save_posts?: boolean;
ghost_mode?: boolean;
account_status?: string;
reactivation_locked_until?: string;
};
}>("GET", "/account/status", null, { noToast: true });
setAllowSave(res?.account?.allow_save_posts !== false);
setGhostMode(Boolean(res?.account?.ghost_mode));
setAccountStatus(res?.account?.account_status || "active");
setReactivationLockedUntil(res?.account?.reactivation_locked_until || null);
} finally {
setLoading(false);
}
})();
}, [request]);
const savePrivacy = async (patch: { allow_save_posts?: boolean; ghost_mode?: boolean }) => {
try {
const res = await request<{ allow_save_posts: boolean; ghost_mode: boolean }>(
"PATCH",
"/account/privacy",
patch,
{ noToast: true }
);
setAllowSave(res.allow_save_posts);
setGhostMode(res.ghost_mode);
toast.success("ذخیره شد");
} catch {
toast.error("خطا در ذخیره");
}
};
const reactivate = async () => {
setReactivating(true);
try {
await request("POST", "/account/reactivate", {});
setAccountStatus("active");
toast.success("حساب کاربری فعال شد");
} catch (err) {
toast.error(err instanceof Error ? err.message : "امکان فعال‌سازی نیست");
} finally {
setReactivating(false);
}
};
const lockActive =
accountStatus === "deactivated" &&
reactivationLockedUntil &&
new Date(reactivationLockedUntil) > new Date();
return (
<Container>
<PageTitle>تنظیمات کاربر</PageTitle>
<div className="space-y-4 px-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">
<p className="font-semibold text-amber-800 dark:text-amber-200">
حساب شما غیرفعال است
</p>
{lockActive ? (
<p className="mt-1 text-xs text-amber-700 dark:text-amber-300">
تا{" "}
{new Date(reactivationLockedUntil!).toLocaleDateString("fa-IR")}{" "}
امکان فعالسازی مجدد وجود ندارد.
</p>
) : (
<button
type="button"
disabled={reactivating}
onClick={reactivate}
className="mt-3 rounded-lg bg-[#0095f6] px-4 py-2 text-xs font-bold text-white disabled:opacity-50"
>
{reactivating ? "…" : "فعال‌سازی مجدد حساب"}
</button>
)}
</div>
)}
<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"
>
<span className="font-semibold">بازدیدکنندگان</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>
<p className="font-semibold">ذخیره پستها</p>
<p className="mt-0.5 text-xs text-neutral-500">
اجازه ذخیره و دانلود پستهای شما برای دیگران
</p>
</div>
<input
type="checkbox"
checked={allowSave}
disabled={loading}
onChange={(e) => savePrivacy({ allow_save_posts: e.target.checked })}
className="h-5 w-5 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>
<p className="font-semibold">حالت روح</p>
<p className="mt-0.5 text-xs text-neutral-500">
پنهان کردن وضعیت آنلاین و بازدید پروفایل
</p>
</div>
<input
type="checkbox"
checked={ghostMode}
disabled={loading}
onChange={(e) => savePrivacy({ ghost_mode: e.target.checked })}
className="h-5 w-5 accent-[#0095f6]"
/>
</label>
</div>
</Container>
);
}

View File

@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
export const metadata: Metadata = generatePageMetadata({
title: "بازدیدکنندگان پروفایل | مدستاگرام",
description: "لیست بازدیدکنندگان پروفایل شما در مدستاگرام",
path: "/settings/profile/visitors",
});
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -0,0 +1,88 @@
"use client";
import React, { useEffect, useState } from "react";
import Container from "@/components/elements/Container";
import PageTitle from "@/components/settings/PageTitle";
import useAxios from "@/hooks/useAxios";
import Image from "next/image";
import Link from "next/link";
import { buildStorageUrl } from "@/components/main/BaseUrl";
interface Visitor {
_id: string;
user_name: string;
first_name: string;
last_name: string;
profile_image?: string;
visited_at?: string;
}
export default function ProfileVisitorsPage() {
const { request } = useAxios();
const [visitors, setVisitors] = useState<Visitor[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
try {
const res = await request<{ visitors: Visitor[] }>(
"GET",
"/account/visitors?limit=50",
null,
{ noToast: true }
);
setVisitors(res?.visitors || []);
} finally {
setLoading(false);
}
})();
}, [request]);
return (
<Container>
<PageTitle>بازدیدکنندگان پروفایل</PageTitle>
<div className="px-4 pb-8">
{loading ? (
<p className="py-8 text-center text-sm text-neutral-500">در حال بارگذاری</p>
) : visitors.length === 0 ? (
<p className="py-8 text-center text-sm text-neutral-500">
هنوز بازدیدی ثبت نشده
</p>
) : (
<ul className="space-y-1">
{visitors.map((v) => {
const name =
[v.first_name, v.last_name].filter(Boolean).join(" ") || v.user_name;
return (
<li key={`${v._id}-${v.visited_at}`}>
<Link
href={`/users/${v.user_name}`}
className="flex items-center gap-3 rounded-xl px-2 py-2 hover:bg-neutral-100 dark:hover:bg-neutral-900"
>
<div className="relative h-11 w-11 overflow-hidden rounded-full bg-neutral-200">
<Image
src={
v.profile_image
? buildStorageUrl(v.profile_image)
: "/images/fake-avatar.png"
}
alt=""
fill
className="object-cover"
unoptimized
/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{name}</p>
<p className="truncate text-xs text-neutral-500">@{v.user_name}</p>
</div>
</Link>
</li>
);
})}
</ul>
)}
</div>
</Container>
);
}

View File

@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
export const metadata: Metadata = generatePageMetadata({
title: "تیکت‌های پشتیبانی | مدستاگرام",
description: "ارسال و پیگیری تیکت‌های پشتیبانی مدستاگرام",
path: "/settings/tickets",
});
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
export const metadata: Metadata = generatePageMetadata({
title: "اتاق کار | مدستاگرام",
description: "مدیریت پروژه‌ها و اتاق کار در مدستاگرام",
path: "/settings/workroom",
});
export default function Layout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -1,9 +1,23 @@
import { MetadataRoute } from 'next'
const API_BASE = 'https://api.modstagram.ir/api/v1'
async function fetchJsonSafe<T>(url: string): Promise<T | null> {
try {
const res = await fetch(url, { next: { revalidate: 3600 } })
const contentType = res.headers.get('content-type') || ''
if (!res.ok || !contentType.includes('application/json')) {
return null
}
return (await res.json()) as T
} catch {
return null
}
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = 'https://modstagram.com'
// ۱. صفحات اصلی و کلیدی
const staticRoutes = ['', '/videos', '/billboards', '/users', '/posts'].map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date(),
@@ -11,86 +25,42 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
priority: route === '' ? 1.0 : 0.9,
}))
try {
// ۲. واکشی بیلبوردها (هماهنگ با منطق جدید URL اسلاگ)
const billboardsRes = await fetch('https://api.modstagram.ir/advertising/get/all/web', {
next: { revalidate: 3600 }
})
const billboardData = await billboardsRes.json()
const billboards = billboardData?.advertisings || []
const billboardRoutes = billboards.map((b: any) => {
// ساخت اسلاگ دقیقاً مشابه منطق استفاده شده در صفحه جزییات بیلبورد
const titleParts = [
b.title,
b.category,
b.province?.name,
b.city?.name,
b.neighbourhood !== b.city?.name ? b.neighbourhood : null,
].filter(Boolean);
const slug = titleParts.join(" ").trim().replace(/\s+/g, '-').replace(/-+/g, '-');
const billboardData = await fetchJsonSafe<{ advertisings?: Array<Record<string, unknown>> }>(
`${API_BASE}/advertising/web?page=1&limit=200`
)
const billboards = billboardData?.advertisings || []
return {
url: `${baseUrl}/billboards/${b._id}/${encodeURIComponent(slug)}`,
lastModified: new Date(b.updatedAt || new Date()),
priority: 0.8,
}
})
const billboardRoutes = billboards.map((b) => {
const titleParts = [
b.title,
b.category,
(b.province as { name?: string } | undefined)?.name,
(b.city as { name?: string } | undefined)?.name,
b.neighbourhood !== (b.city as { name?: string } | undefined)?.name ? b.neighbourhood : null,
].filter(Boolean)
// ۳. واکشی کاربران برای ایندکس شدن پروفایل‌ها
const usersRes = await fetch('https://api.modstagram.ir/users/get/all/web', {
next: { revalidate: 3600 }
})
const userData = await usersRes.json()
const users = userData?.users || []
const slug = String(titleParts.join(' '))
.trim()
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
const userRoutes = users.map((u: any) => ({
url: `${baseUrl}/users/${u.username}`,
lastModified: new Date(), // یا تاریخ آخرین فعالیت کاربر
priority: 0.6,
}))
// ۴. واکشی ویدیوها (اگر API جداگانه دارند)
const videosRes = await fetch('https://api.modstagram.ir/videos/get/all/web', {
next: { revalidate: 3600 }
}).catch(() => null)
let videoRoutes: any[] = []
if (videosRes) {
const videoData = await videosRes.json()
const videos = videoData?.videos || []
videoRoutes = videos.map((v: any) => ({
url: `${baseUrl}/videos/${v._id}/${encodeURIComponent(v.title.replace(/\s+/g, '-'))}`,
lastModified: new Date(v.updatedAt || new Date()),
priority: 0.7,
}))
return {
url: `${baseUrl}/billboards/${b._id}/${encodeURIComponent(slug)}`,
lastModified: new Date(String(b.updatedAt || new Date())),
priority: 0.8,
}
})
// ۵. پست‌ها — از فید عمومی
let postRoutes: MetadataRoute.Sitemap = []
try {
const postsRes = await fetch(
'https://app.modstagram.ir/api/v1/users/web?page=1&limit=100',
{ next: { revalidate: 3600 } }
)
if (postsRes.ok) {
const postsData = await postsRes.json()
const posts = postsData?.posts || []
postRoutes = posts.map((p: { _id: string; updatedAt?: string }) => ({
url: `${baseUrl}/posts/${p._id}`,
lastModified: new Date(p.updatedAt || new Date()),
changeFrequency: 'weekly' as const,
priority: 0.65,
}))
}
} catch {
/* optional */
}
const postsData = await fetchJsonSafe<{ posts?: Array<{ _id: string; updatedAt?: string }> }>(
`${API_BASE}/users/web?page=1&limit=100`
)
const posts = postsData?.posts || []
const postRoutes = posts.map((p) => ({
url: `${baseUrl}/posts/${p._id}`,
lastModified: new Date(p.updatedAt || new Date()),
changeFrequency: 'weekly' as const,
priority: 0.65,
}))
return [...staticRoutes, ...billboardRoutes, ...userRoutes, ...videoRoutes, ...postRoutes]
} catch (error) {
console.error("Sitemap error:", error)
return staticRoutes
}
}
return [...staticRoutes, ...billboardRoutes, ...postRoutes]
}

View File

@@ -4,5 +4,5 @@ type RouteContext = { params: Promise<{ path: string[] }> };
export async function GET(req: Request, context: RouteContext) {
const { path } = await context.params;
return proxyToUpstream(req, `/storage/${path.join("/")}`);
return proxyToUpstream(req, `/storage/${path.join("/")}`, { cacheable: true });
}

View File

@@ -1,4 +1,4 @@
import { BASE_URL, IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import React from "react";
import { cookies } from "next/headers";
import { User } from "@/types/types";
@@ -7,32 +7,50 @@ import ModelHead from "@/components/models/ModelPage/ModelHead";
import ModelContent from "@/components/models/ModelPage/ModelContent";
import ProfileVisitTracker from "@/components/explore/ProfileVisitTracker";
import { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import { fetchApiJson } from "@/lib/api/fetchApiJson";
interface IUserProps {
params: Promise<{ username: string }>;
}
type UserWebResponse = { user?: User };
export const dynamic = "force-dynamic";
async function loadUser(username: string, token: string) {
const { data } = await fetchApiJson<UserWebResponse>(
`/users/get/web?user_name=${encodeURIComponent(username)}`,
{
headers: token ? { Authorization: `Bearer ${token}` } : {},
}
);
return data?.user ?? null;
}
export async function generateMetadata({ params }: IUserProps): Promise<Metadata> {
const { username } = await params;
const token = (await cookies()).get("token")?.value || "";
try {
const res = await fetch(`${BASE_URL}/users/get/web?user_name=${username}`, {
cache: "no-store",
headers: { Authorization: token ? `Bearer ${token}` : "" },
});
const data = await res.json();
const user = data?.user as User;
const user = await loadUser(username, token);
if (!user) {
return { title: `${username} | مدستاگرام`, robots: "noindex" };
return generatePageMetadata({
title: `${username} | مدستاگرام`,
description: `پروفایل ${username} در مدستاگرام`,
path: `/users/${username}`,
type: "profile",
});
}
if (user.blocked_you) {
return {
return generatePageMetadata({
title: "پروفایل در دسترس نیست | مدستاگرام",
robots: "noindex, nofollow",
};
description: "این پروفایل در دسترس نیست.",
path: `/users/${username}`,
type: "profile",
});
}
const fullName = `${user.first_name || ""} ${user.last_name || ""}`.trim();
@@ -40,47 +58,27 @@ export async function generateMetadata({ params }: IUserProps): Promise<Metadata
const expertise = user.expertise || "متخصص";
const bio = user.bio || "پروفایل و نمونه‌کارهای حرفه‌ای در مدستاگرام";
const title = `${fullName} - ${username} - ${expertise} - ${city} - مدستاگرام`;
const description = `${expertise} - ${city} - ${bio}`.slice(0, 160);
const title = [username, fullName, expertise, city, "مدستاگرام"]
.filter(Boolean)
.join(" ");
const description = [fullName, expertise, bio].filter(Boolean).join(" ");
const profileImage = user.profile_image ? buildStorageUrl(user.profile_image) : "/images/logo.png";
return {
title, // حفظ تایتل درخواستی شما
description, // حفظ دیسکریپشن درخواستی شما
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-image-preview": "large",
"max-snippet": -1,
},
},
alternates: {
canonical: `https://modstagram.com/users/${username}`,
},
openGraph: {
title,
description,
url: `https://modstagram.com/users/${username}`,
siteName: "مدستاگرام",
images: [{ url: profileImage, width: 800, height: 600, alt: fullName }],
locale: "fa_IR",
type: "profile",
firstName: user.first_name || undefined,
lastName: user.last_name || undefined,
username: username,
},
twitter: {
card: "summary_large_image",
title,
description,
images: [profileImage],
},
};
} catch (e) {
return { title: `${username} | مدستاگرام` };
return generatePageMetadata({
title,
description,
path: `/users/${username}`,
type: "profile",
imageUrl: profileImage,
imageAlt: fullName || username,
});
} catch {
return generatePageMetadata({
title: `${username} | مدستاگرام`,
description: `پروفایل ${username} در مدستاگرام`,
path: `/users/${username}`,
type: "profile",
});
}
}
@@ -88,18 +86,7 @@ async function UserPage({ params }: IUserProps) {
const { username } = await params;
const token = (await cookies()).get("token")?.value || "";
const response = await fetch(
`${BASE_URL}/users/get/web?user_name=${username}`,
{
cache: "no-store",
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
}
);
const data = await response.json();
const user = data.user as User;
const user = await loadUser(username, token);
if (!user) {
return (
@@ -162,8 +149,8 @@ async function UserPage({ params }: IUserProps) {
<ModelHead
item2={{
postsCount: user?.postsCount,
allProjectsCount: user?.allProjectsCount,
successfulProjectsCount: user?.successfulProjectsCount,
followersCount: user?.followersCount,
followingCount: user?.followingCount,
}}
user={user}
/>

View File

@@ -5,6 +5,8 @@ import { ReactQueryProvider } from "@/providers/ReactQueryProvider";
import { Toaster } from "react-hot-toast";
import BackgroundPrefetch from "@/components/BackgroundPrefetch";
import TitleGuardian from "@/components/TitleGuardian";
import AuthSessionSync from "@/components/auth/AuthSessionSync";
import PwaInstallPrompt from "@/components/pwa/PwaInstallPrompt";
interface ILayoutProps {
children: React.ReactNode;
@@ -15,6 +17,8 @@ function Layout({ children }: ILayoutProps) {
<ReactQueryProvider>
<TitleGuardian />
<Toaster />
<AuthSessionSync />
<PwaInstallPrompt />
<BackgroundPrefetch />
{children}
</ReactQueryProvider>

View File

@@ -132,7 +132,7 @@ console.log(categories);
{formik.touched.images && formik.errors.images && (
<p className="text-red-500 text-xs">{formik.errors.images}</p>
)}
<RoundedButton type="submit" className="p-2 px-8 rounded mt-4">
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
ثبت و ادامه
</RoundedButton>
</form>

View File

@@ -62,7 +62,7 @@ const Step2 = ({ nextStep }: { nextStep: () => void }) => {
)} */}
<hr />
<div className="flex gap-4 mb-4 ">
<RoundedButton type="submit" className="w-32 rounded h-9">
<RoundedButton type="submit" variant="primary" className="h-9 w-32">
ثبت و ادامه
</RoundedButton>
<RoundedButton

View File

@@ -164,7 +164,7 @@ const Step3 = ({ nextStep }: { nextStep: () => void }) => {
typeof formik.errors.selectedFeatures === "string" && (
<p className="text-red-500">{formik.errors.selectedFeatures}</p>
)}
<RoundedButton type="submit" className="w-32 rounded h-9 mt-4">
<RoundedButton type="submit" variant="primary" className="mt-4 h-9 w-32">
ثبت و ادامه
</RoundedButton>
</form>

View File

@@ -181,7 +181,7 @@ const Step4 = ({ nextStep }: { nextStep: () => void }) => {
</label>
<hr />
<RoundedButton type="submit" className="p-2 px-8 rounded mt-4">
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
ثبت و ادامه
</RoundedButton>
</form>

View File

@@ -10,6 +10,7 @@ import useAxios from "@/hooks/useAxios";
import { IAdvertisingType } from "@/types/types";
import { sampleAds, sampleAdsHighlight, sampleAdsSpecial } from "@/constants";
import RoundedDiv from "@/components/elements/RoundedDiv";
import { selectionCardClass } from "@/lib/ui/buttonStyles";
import { useRouter } from "next/navigation";
import MainBillboardCard from "../MainBillboardCard/MainBillboardCard";
import { dataURLtoBlob } from "@/helpers/helpers";
@@ -247,9 +248,7 @@ const Step5 = () => {
}
/>
<RoundedDiv
className={`w-full max-w-full p-2 ${
selectedType == item?.name ? "bg-[#FC8EAC]" : ""
}`}
className={selectionCardClass(selectedType == item?.name)}
>
{item.name === "normal"
? "نمایش ساده"
@@ -271,7 +270,7 @@ const Step5 = () => {
{formik.touched.selectedType && formik.errors.selectedType && (
<p className="text-red-500">{formik.errors.selectedType}</p>
)}
<RoundedButton type="submit" className="p-2 px-8 rounded mt-4">
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
ثبت درخواست
</RoundedButton>
</form>

View File

@@ -4,6 +4,7 @@ import Link from "next/link";
import { useEffect, useState } from "react";
import BoldIcon from "@/components/ui/BoldIcon";
import { cn } from "@/lib/utils";
import { PAGE_SHELL_CLASS } from "@/constants/pageLayout";
const tabs = [
{ href: "/", icon: "home-2", label: "خانه" },
@@ -43,8 +44,13 @@ export default function TabNavigation({ currentPage }: TabNavigationProps) {
if (!mounted) return null;
return (
<nav className="fixed bottom-2 left-1/2 z-50 w-full max-w-xl -translate-x-1/2 px-4 pb-2">
<div className="glass-panel gentle-transition flex justify-evenly rounded-full border border-white/40 p-2.5 shadow-lg dark:border-white/10">
<nav
className={cn(
"fixed bottom-2 left-1/2 z-50 -translate-x-1/2 pb-2",
PAGE_SHELL_CLASS
)}
>
<div className="glass-panel gentle-transition flex justify-evenly rounded-full border border-[#C3C3C3]/60 p-2.5 shadow-lg dark:border-white/10">
{tabs.map((tab) => {
const isActive =
currentPage === tab.href ||
@@ -58,11 +64,7 @@ export default function TabNavigation({ currentPage }: TabNavigationProps) {
<Link
key={tab.href}
href={tab.href}
className={cn(
"gentle-transition flex flex-col items-center gap-0.5 rounded-2xl px-2 py-1",
"hover:bg-black/5 active:scale-95 dark:hover:bg-white/10",
isActive && "tab-active-glass scale-105"
)}
className="gentle-transition flex flex-col items-center gap-0.5 px-2 py-1 active:scale-95"
>
<BoldIcon
name={tab.icon}

View File

@@ -2,13 +2,16 @@
import React, { useState, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { motion } from "framer-motion";
import { Filter, X } from "lucide-react";
import Image from "next/image";
import toast from "react-hot-toast";
import BoldIcon from "@/components/ui/BoldIcon";
import useAxios from "@/hooks/useAxios";
import { getCreateAcademyPath } from "@/lib/getCreateContentPath";
import AcademyFilterModal, {
AcademyFilterValues,
} from "./AcademyFilterModal";
interface AcademyFilterProps {
onFilterChange?: (filters: any) => void;
onFilterChange?: (filters: Record<string, unknown>) => void;
}
interface IAcademyCategory {
@@ -33,6 +36,10 @@ interface CategoryOption {
color?: string;
}
const TOOLBAR_ICON_SIZE = 25;
const toolbarIconButtonClass =
"inline-flex h-[25px] w-[25px] items-center justify-center shrink-0 p-0 leading-none";
const sortOptions = [
{ value: "createdAt_desc", label: "جدیدترین" },
{ value: "createdAt_asc", label: "قدیمی‌ترین" },
@@ -45,32 +52,68 @@ const courseTypes = [
{ value: "", label: "همه دوره‌ها" },
{ value: "normal", label: "معمولی" },
{ value: "pro", label: "پرو" },
{ value: "free", label: "رایگان" },
];
const emptyFilters: AcademyFilterValues = {
search: "",
category: "",
minPrice: "",
maxPrice: "",
hasOffer: false,
type: "",
sortBy: "createdAt_desc",
};
function buildFinalFilters(filters: AcademyFilterValues) {
let sortBy = "createdAt";
let sortOrder = "desc";
if (filters.sortBy === "createdAt_asc") {
sortBy = "createdAt";
sortOrder = "asc";
} else if (filters.sortBy === "price_asc") {
sortBy = "price";
sortOrder = "asc";
} else if (filters.sortBy === "price_desc") {
sortBy = "price";
sortOrder = "desc";
} else if (filters.sortBy === "likes_desc") {
sortBy = "likes";
sortOrder = "desc";
}
return {
search: filters.search,
category: filters.category,
minPrice: filters.minPrice ? Number(filters.minPrice) : undefined,
maxPrice: filters.maxPrice ? Number(filters.maxPrice) : undefined,
hasOffer: filters.hasOffer,
type: filters.type,
sortBy,
sortOrder,
};
}
export default function AcademyFilter({ onFilterChange }: AcademyFilterProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [isOpen, setIsOpen] = useState(false);
const [filters, setFilters] = useState({
search: "",
category: "",
minPrice: "",
maxPrice: "",
hasOffer: false,
type: "",
sortBy: "createdAt_desc",
});
const [showFilterModal, setShowFilterModal] = useState(false);
const [filters, setFilters] = useState<AcademyFilterValues>(emptyFilters);
const [categories, setCategories] = useState<CategoryOption[]>([
{ value: "", label: "همه دسته‌ها" }
{ value: "", label: "همه دسته‌ها" },
]);
const [categoriesLoading, setCategoriesLoading] = useState(false);
const [userType, setUserType] = useState<string | null>(null);
const { request } = useAxios();
// ==========================================
// دریافت دسته‌بندی‌های آکادمی از API جدید
// ==========================================
useEffect(() => {
if (typeof window !== "undefined") {
setUserType(localStorage.getItem("usertype"));
}
}, []);
useEffect(() => {
const fetchAcademyCategories = async () => {
setCategoriesLoading(true);
@@ -79,9 +122,9 @@ export default function AcademyFilter({ onFilterChange }: AcademyFilterProps) {
"GET",
"/academy/categories?status=active&limit=100"
);
let categoriesList: IAcademyCategory[] = [];
if (response?.data?.docs) {
categoriesList = response.data.docs;
} else if (response?.data && Array.isArray(response.data)) {
@@ -89,35 +132,28 @@ export default function AcademyFilter({ onFilterChange }: AcademyFilterProps) {
} else if (Array.isArray(response)) {
categoriesList = response;
}
console.log("دسته‌بندی‌های آکادمی:", categoriesList);
// ==========================================
// ✅ تغییر مهم: استفاده از title به عنوان value
// ==========================================
const formattedCategories: CategoryOption[] = [
{ value: "", label: "همه دسته‌ها" },
...categoriesList.map((cat) => ({
value: cat.title, // استفاده از عنوان دسته‌بندی
value: cat.title,
label: cat.title,
icon: cat.icon,
color: cat.color,
}))
})),
];
setCategories(formattedCategories);
console.log("دسته‌بندی فرمت شده:", formattedCategories);
} catch (err) {
console.log("خطا در دریافت دسته‌بندی آکادمی:", err);
} finally {
setCategoriesLoading(false);
}
};
fetchAcademyCategories();
}, []);
// خواندن فیلترها از URL
useEffect(() => {
setFilters({
search: searchParams.get("search") || "",
@@ -128,11 +164,11 @@ export default function AcademyFilter({ onFilterChange }: AcademyFilterProps) {
type: searchParams.get("type") || "",
sortBy: searchParams.get("sortBy") || "createdAt_desc",
});
}, []);
}, [searchParams]);
const updateURL = (newFilters: any) => {
const updateURL = (newFilters: AcademyFilterValues) => {
const params = new URLSearchParams();
if (newFilters.search) params.set("search", newFilters.search);
if (newFilters.category) params.set("category", newFilters.category);
if (newFilters.minPrice) params.set("minPrice", newFilters.minPrice);
@@ -142,227 +178,89 @@ export default function AcademyFilter({ onFilterChange }: AcademyFilterProps) {
if (newFilters.sortBy && newFilters.sortBy !== "createdAt_desc") {
params.set("sortBy", newFilters.sortBy);
}
const queryString = params.toString();
const newUrl = queryString ? `/academy?${queryString}` : "/academy";
router.push(newUrl, { scroll: false });
};
const handleInputChange = (name: string, value: any) => {
const newFilters = { ...filters, [name]: value };
setFilters(newFilters);
let sortBy = "createdAt";
let sortOrder = "desc";
if (newFilters.sortBy === "createdAt_asc") {
sortBy = "createdAt";
sortOrder = "asc";
} else if (newFilters.sortBy === "price_asc") {
sortBy = "price";
sortOrder = "asc";
} else if (newFilters.sortBy === "price_desc") {
sortBy = "price";
sortOrder = "desc";
} else if (newFilters.sortBy === "likes_desc") {
sortBy = "likes";
sortOrder = "desc";
}
const finalFilters = {
search: newFilters.search,
category: newFilters.category,
minPrice: newFilters.minPrice ? Number(newFilters.minPrice) : undefined,
maxPrice: newFilters.maxPrice ? Number(newFilters.maxPrice) : undefined,
hasOffer: newFilters.hasOffer,
type: newFilters.type,
sortBy,
sortOrder,
};
updateURL(newFilters);
if (onFilterChange) onFilterChange(finalFilters);
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
handleInputChange("search", filters.search);
const applyFilters = () => {
updateURL(filters);
onFilterChange?.(buildFinalFilters(filters));
setShowFilterModal(false);
};
const clearFilters = () => {
const emptyFilters = {
search: "",
category: "",
minPrice: "",
maxPrice: "",
hasOffer: false,
type: "",
sortBy: "createdAt_desc",
};
setFilters(emptyFilters);
router.push("/academy", { scroll: false });
if (onFilterChange) onFilterChange({});
onFilterChange?.({});
setShowFilterModal(false);
};
const activeFiltersCount = Object.values(filters).filter(v =>
v !== "" && v !== false && v !== "createdAt_desc"
const handleCreateAcademy = () => {
if (!userType) {
toast.error("لطفا ابتدا در سایت وارد شوید یا ثبت نام کنید.");
return;
}
router.push(getCreateAcademyPath());
};
const activeFiltersCount = Object.values(filters).filter(
(v) => v !== "" && v !== false && v !== "createdAt_desc"
).length;
const selectedCategory = categories.find(cat => cat.value === filters.category);
return (
<div className="z-30 backdrop-blur-lg border-b border-gray-200 dark:border-gray-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
{/* نوار جستجو و فیلتر */}
<div className="flex flex-col md:flex-row gap-4">
<form onSubmit={handleSearch} className="flex-1">
<div className="relative">
<input
type="text"
value={filters.search}
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
placeholder="جستجوی دوره، مدرس، ..."
className="w-full px-4 py-2 pr-10 border border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-transparent"
/>
<Image
src="/images/icons/search-normal.svg"
alt=""
width={20}
height={20}
className="absolute left-3 top-2.5 opacity-60 dark:invert"
/>
</div>
</form>
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex items-center gap-2 px-4 py-2 rounded-xl transition-all duration-200 ${
isOpen || activeFiltersCount > 0
? "bg-pink-500 text-white"
: "bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700"
}`}
>
<Filter className="w-5 h-5" />
<span>فیلترها</span>
{activeFiltersCount > 0 && (
<span className="bg-white text-pink-500 text-xs rounded-full px-2 py-0.5">
{activeFiltersCount}
</span>
)}
</button>
</div>
<motion.div
initial={false}
animate={{ height: isOpen ? "auto" : 0, opacity: isOpen ? 1 : 0 }}
transition={{ duration: 0.3 }}
className="overflow-hidden"
<div>
<div className="flex items-center justify-end gap-3 py-1 text-sm">
<button
type="button"
onClick={handleCreateAcademy}
aria-label="افزودن آموزشگاه"
className={toolbarIconButtonClass}
>
<div className="pt-4 pb-2 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* دسته بندی آکادمی */}
<div>
<label className="block text-sm font-medium mb-1">دسته بندی</label>
<select
value={filters.category}
onChange={(e) => handleInputChange("category", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-pink-500"
disabled={categoriesLoading}
>
{categoriesLoading ? (
<option value="" disabled>در حال بارگذاری...</option>
) : (
categories.map((cat) => (
<option key={cat.value} value={cat.value}>
{cat.icon && `${cat.icon} `} {cat.label}
</option>
))
)}
</select>
{selectedCategory && selectedCategory.value !== "" && (
<div className="mt-1 text-xs text-gray-500 flex items-center gap-1">
<span>انتخاب شده:</span>
{selectedCategory.icon && <span>{selectedCategory.icon}</span>}
<span style={{ color: selectedCategory.color }}>
{selectedCategory.label}
</span>
</div>
)}
</div>
{/* نوع دوره */}
<div>
<label className="block text-sm font-medium mb-1">نوع دوره</label>
<select
value={filters.type}
onChange={(e) => handleInputChange("type", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-pink-500"
>
{courseTypes.map((type) => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</div>
{/* محدوده قیمت */}
<div>
<label className="block text-sm font-medium mb-1">محدوده قیمت (تومان)</label>
<div className="flex gap-2">
<input
type="number"
placeholder="از"
value={filters.minPrice}
onChange={(e) => handleInputChange("minPrice", e.target.value)}
className="w-1/2 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-pink-500"
/>
<input
type="number"
placeholder="تا"
value={filters.maxPrice}
onChange={(e) => handleInputChange("maxPrice", e.target.value)}
className="w-1/2 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-pink-500"
/>
</div>
</div>
{/* مرتب سازی */}
<div>
<label className="block text-sm font-medium mb-1">مرتب سازی</label>
<select
value={filters.sortBy}
onChange={(e) => handleInputChange("sortBy", e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-pink-500"
>
{sortOptions.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="hasOffer"
checked={filters.hasOffer}
onChange={(e) => handleInputChange("hasOffer", e.target.checked)}
className="w-4 h-4 text-pink-500 rounded focus:ring-pink-500"
/>
<label htmlFor="hasOffer" className="text-sm">فقط دورههای دارای تخفیف</label>
</div>
{activeFiltersCount > 0 && (
<button
onClick={clearFilters}
className="flex items-center gap-1 text-sm text-pink-500 hover:text-pink-600 transition"
>
<X className="w-4 h-4" />
پاک کردن همه فیلترها
</button>
)}
</div>
</motion.div>
<BoldIcon
name="add-square"
size={TOOLBAR_ICON_SIZE}
className="block"
/>
</button>
<button
type="button"
onClick={() => router.push("/search?type=user")}
aria-label="جستجو"
className={toolbarIconButtonClass}
>
<BoldIcon
name="search-normal"
size={TOOLBAR_ICON_SIZE}
className="block"
/>
</button>
<button
type="button"
onClick={() => setShowFilterModal(true)}
aria-label="فیلتر"
className={toolbarIconButtonClass}
>
<BoldIcon name="candle" size={TOOLBAR_ICON_SIZE} className="block" />
</button>
</div>
{showFilterModal && (
<AcademyFilterModal
showFilterModal={showFilterModal}
setShowFilterModal={setShowFilterModal}
filters={filters}
setFilters={setFilters}
categories={categories}
categoriesLoading={categoriesLoading}
sortOptions={sortOptions}
courseTypes={courseTypes}
onApply={applyFilters}
onClear={clearFilters}
activeFiltersCount={activeFiltersCount}
/>
)}
</div>
);
}
}

View File

@@ -0,0 +1,199 @@
"use client";
import React from "react";
import { X } from "lucide-react";
import Modal from "../elements/Modal";
import RoundedButton from "../elements/RoundedButton";
import { cn } from "@/lib/utils";
interface CategoryOption {
value: string;
label: string;
icon?: string;
color?: string;
}
export interface AcademyFilterValues {
search: string;
category: string;
minPrice: string;
maxPrice: string;
hasOffer: boolean;
type: string;
sortBy: string;
}
interface AcademyFilterModalProps {
showFilterModal: boolean;
setShowFilterModal: (open: boolean) => void;
filters: AcademyFilterValues;
setFilters: React.Dispatch<React.SetStateAction<AcademyFilterValues>>;
categories: CategoryOption[];
categoriesLoading: boolean;
sortOptions: { value: string; label: string }[];
courseTypes: { value: string; label: string }[];
onApply: () => void;
onClear: () => void;
activeFiltersCount: number;
}
export default function AcademyFilterModal({
showFilterModal,
setShowFilterModal,
filters,
setFilters,
categories,
categoriesLoading,
sortOptions,
courseTypes,
onApply,
onClear,
activeFiltersCount,
}: AcademyFilterModalProps) {
const selectedCategory = categories.find((cat) => cat.value === filters.category);
const handleFieldChange = (name: keyof AcademyFilterValues, value: string | boolean) => {
setFilters((prev) => ({ ...prev, [name]: value }));
};
return (
<Modal
isOpen={showFilterModal}
onClose={() => setShowFilterModal(false)}
height="auto"
panelClassName="max-h-[min(88vh,640px)] overflow-y-auto rounded-t-3xl sm:rounded-3xl"
>
<div className="mx-auto flex w-full max-w-md flex-col pb-4">
<span className="mb-4 text-center font-semibold">فیلتر دورهها</span>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium">دسته بندی</label>
<select
value={filters.category}
onChange={(e) => handleFieldChange("category", e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-pink-500 dark:border-gray-600 dark:bg-gray-800"
disabled={categoriesLoading}
>
{categoriesLoading ? (
<option value="" disabled>
در حال بارگذاری...
</option>
) : (
categories.map((cat) => (
<option key={cat.value} value={cat.value}>
{cat.icon && `${cat.icon} `}
{cat.label}
</option>
))
)}
</select>
{selectedCategory && selectedCategory.value !== "" && (
<div className="mt-1 flex items-center gap-1 text-xs text-gray-500">
<span>انتخاب شده:</span>
{selectedCategory.icon && <span>{selectedCategory.icon}</span>}
<span style={{ color: selectedCategory.color }}>
{selectedCategory.label}
</span>
</div>
)}
</div>
<div>
<label className="mb-1 block text-sm font-medium">نوع دوره</label>
<select
value={filters.type}
onChange={(e) => handleFieldChange("type", e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-pink-500 dark:border-gray-600 dark:bg-gray-800"
>
{courseTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium">
محدوده قیمت (تومان)
</label>
<div className="flex gap-2">
<input
type="number"
placeholder="از"
value={filters.minPrice}
onChange={(e) => handleFieldChange("minPrice", e.target.value)}
className="w-1/2 rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-pink-500 dark:border-gray-600 dark:bg-gray-800"
/>
<input
type="number"
placeholder="تا"
value={filters.maxPrice}
onChange={(e) => handleFieldChange("maxPrice", e.target.value)}
className="w-1/2 rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-pink-500 dark:border-gray-600 dark:bg-gray-800"
/>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium">مرتب سازی</label>
<select
value={filters.sortBy}
onChange={(e) => handleFieldChange("sortBy", e.target.value)}
className="w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-pink-500 dark:border-gray-600 dark:bg-gray-800"
>
{sortOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="academy-hasOffer"
checked={filters.hasOffer}
onChange={(e) => handleFieldChange("hasOffer", e.target.checked)}
className="h-4 w-4 rounded text-pink-500 focus:ring-pink-500"
/>
<label htmlFor="academy-hasOffer" className="text-sm">
فقط دورههای دارای تخفیف
</label>
</div>
{activeFiltersCount > 0 && (
<button
type="button"
onClick={onClear}
className="flex items-center gap-1 text-sm text-pink-500 transition hover:text-pink-600"
>
<X className="h-4 w-4" />
پاک کردن همه فیلترها
</button>
)}
</div>
<div className="mt-6 flex w-full flex-col items-center gap-3 border-t border-neutral-200 pt-5 dark:border-neutral-700">
<RoundedButton
type="button"
variant="primary"
onClick={onApply}
className="h-10 w-full max-w-[200px] text-sm"
>
اعمال فیلتر
</RoundedButton>
<RoundedButton
type="button"
onClick={() => setShowFilterModal(false)}
className={cn("h-10 w-full max-w-[200px] text-sm")}
>
انصراف
</RoundedButton>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import { Course } from "@/types/types";
import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import { formatCoursePriceLabel } from "@/lib/formatCoursePriceLabel";
import Image from "next/image";
import Link from "next/link";
import React from "react";
function AcademyPackageItem({
course,
}: {
course: Course;
}) {
const imageSrc = course.course_image
? `${IMAGE_BASE_URL}${course.course_image}`
: "/images/default-course.jpg";
return (
<Link
href={`/academy/${course._id}/${encodeURIComponent(course.cuorse_name || "course")}`}
className="mb-4 p-4 border rounded-3xl border-border-primary-light w-full block font-semibold"
>
<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">
<Image
src={imageSrc}
alt={course.cuorse_name}
fill
className="object-cover"
/>
</div>
<div className="min-w-0">
<p className="font-bold truncate">{course.cuorse_name}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 mt-1">
{course.caption}
</p>
</div>
</div>
<div className="w-full h-[1px] max-[350px]:flex hidden dark:bg-white/30 bg-black/30" />
<div className="flex max-[350px]:flex-row max-[350px]:w-full max-[350px]:justify-between max-[350px]:items-center flex-col items-end max-sm:text-[11px] max-sm:items-center shrink-0">
<span className="text-sm">{course.teacher_name}</span>
<div className="flex items-center gap-1 mt-3 max-[350px]:mt-0">
<span
className={
course.is_free ? "text-[#008D0E]" : "text-[#3A59A9]"
}
>
{formatCoursePriceLabel(course)}
</span>
</div>
</div>
</div>
</Link>
);
}
export default AcademyPackageItem;

View File

@@ -0,0 +1,96 @@
import { Skeleton } from "@/components/ui/skeleton";
export function AcademyListSkeleton({ count = 3 }: { count?: number }) {
return (
<div className="mt-5 space-y-6 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mb-24">
<Skeleton className="h-4 w-32" />
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="space-y-3">
<div className="flex items-center gap-3">
<Skeleton className="h-12 w-12 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3 w-1/4" />
</div>
</div>
<Skeleton className="h-64 w-full rounded-2xl" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
))}
</div>
);
}
export function AcademyProfileHeadSkeleton() {
return (
<div className="w-full px-4 py-2 space-y-4">
<div className="flex justify-end">
<Skeleton className="h-4 w-48" />
</div>
<div className="flex items-center justify-between">
<div className="flex gap-5">
<Skeleton className="h-10 w-14" />
<Skeleton className="h-10 w-14" />
<Skeleton className="h-10 w-14" />
</div>
<Skeleton className="h-[120px] w-[120px] rounded-xl" />
</div>
<div className="grid grid-cols-3 gap-4">
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
</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" />
</div>
</div>
);
}
export function AcademyPackageListSkeleton({ count = 4 }: { count?: number }) {
return (
<div className="px-4 space-y-4 mt-4">
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className="p-4 border rounded-3xl border-border-primary-light w-full"
>
<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" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-3 w-full" />
</div>
</div>
<div className="space-y-2 shrink-0">
<Skeleton className="h-3 w-16 ml-auto" />
<Skeleton className="h-4 w-20 ml-auto" />
</div>
</div>
</div>
))}
</div>
);
}
export function AcademyCourseDetailSkeleton() {
return (
<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" />
</div>
<div className="space-y-4">
<Skeleton className="h-96 w-full rounded-xl" />
</div>
</div>
</div>
);
}

View File

@@ -7,7 +7,7 @@ import RoundedInput from "@/components/elements/RoundedInput";
import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import Rate from "rc-rate";
import "rc-rate/assets/index.css";
import "rc-rate/assets/index.css";
import "@/styles/rc-rate-custom.css";
import { useUser } from "@/hooks/useUser";
import { Course } from "@/types/types";
@@ -227,10 +227,6 @@ function CommentsModal({
toast.error("کامنت نمی‌تواند بیش از 500 کاراکتر باشد.");
return;
}
if (!hasRatedCourse && rating === 0) {
toast.error("برای اولین نظر، ثبت امتیاز الزامی است.");
return;
}
try {
const payload: Record<string, unknown> = {
@@ -470,7 +466,7 @@ function CommentsModal({
<button
onClick={sendMessageHandler}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
disabled={!newMessage.trim() || (!hasRatedCourse && rating === 0)}
disabled={!newMessage.trim()}
>
<Image
width={24}

View File

@@ -7,6 +7,9 @@ import Image from "next/image";
import useAxios from "@/hooks/useAxios";
import toast from "react-hot-toast";
import { motion, AnimatePresence } from "framer-motion";
import { AcademyListSkeleton } from "./AcademySkeletons";
import { btnPrimary } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
interface InfinitePostsProps {
token: string;
@@ -25,6 +28,7 @@ interface InfinitePostsProps {
hasOffer?: boolean;
teacherName?: string;
type?: string;
isFree?: boolean;
sortBy?: string;
sortOrder?: string;
};
@@ -72,6 +76,9 @@ export default function InfinitePosts({ filters, token }: InfinitePostsProps) {
if (filters?.type && filters.type !== "") {
params.append("type", filters.type);
}
if (filters?.isFree) {
params.append("isFree", "true");
}
if (filters?.sortBy && filters.sortBy !== "createdAt") {
params.append("sortBy", filters.sortBy);
}
@@ -173,6 +180,7 @@ export default function InfinitePosts({ filters, token }: InfinitePostsProps) {
filters?.hasOffer,
filters?.teacherName,
filters?.type,
filters?.isFree,
filters?.sortBy,
filters?.sortOrder,
]);
@@ -208,14 +216,7 @@ export default function InfinitePosts({ filters, token }: InfinitePostsProps) {
]);
if (isInitialCoursesLoading) {
return (
<div className="flex justify-center items-center min-h-[400px]">
<div className="relative w-24 h-24">
<div className="absolute inset-0 rounded-full border-4 border-gray-200 dark:border-gray-700"></div>
<div className="absolute inset-0 rounded-full border-4 border-t-pink-500 border-r-transparent border-b-transparent border-l-transparent animate-spin"></div>
</div>
</div>
);
return <AcademyListSkeleton count={3} />;
}
if (courses.length === 0 && !isInitialCoursesLoading) {
@@ -262,17 +263,13 @@ export default function InfinitePosts({ filters, token }: InfinitePostsProps) {
className="flex justify-center items-center py-4"
>
{isLoadingMoreCourses ? (
<div className="flex justify-center items-center py-4 gap-2">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-pink-500"></div>
<span className="text-sm text-gray-500">
در حال بارگذاری دورههای بیشتر...
</span>
</div>
<AcademyListSkeleton count={1} />
) : (
<div className="text-center py-4">
<button
type="button"
onClick={() => fetchCourses(coursesPage + 1, true)}
className="px-4 py-2 bg-pink-500 text-white rounded-lg hover:bg-pink-600 transition-colors text-sm"
className={cn(btnPrimary, "px-4 py-2 text-sm")}
>
بارگذاری بیشتر
</button>

View File

@@ -20,6 +20,7 @@ import "slick-carousel/slick/slick-theme.css";
import useAxios from "@/hooks/useAxios";
import { useRouter } from "next/navigation";
import { forEach } from "lodash";
import { formatCoursePriceLabel } from "@/lib/formatCoursePriceLabel";
// ایجاد Context برای به اشتراک گذاشتن وضعیت muted بین همه کارت‌ها
const MutedContext = createContext<{
@@ -403,26 +404,12 @@ function MainModelCard({ postData }: { postData: Course }) {
</div>
<div className="flex justify-between items-center">
<span className="text-[#0090ff] text-xl font-semibold">
{offer && parseInt(offer) > 0 ? (
<>
{/* <span className="line-through text-muted-foreground text-xs">
{parseInt(price).toLocaleString()}
</span>{" "}
<span className=" text-muted-foreground text-xs">
تومان
</span> */}
{(
(parseInt(price) * (100 - parseInt(offer))) /
100
).toLocaleString()}
</>
) : (
parseInt(price).toLocaleString()
)}
<span className="text-[#0090ff] text-xl font-semibold">تومان</span>
<span className="text-[#ff0000] text-xl font-semibold">
{offer && Number(offer) > 0 ? `%${offer}` : ""}
</span>
{formatCoursePriceLabel(postData)}
{!postData.is_free && postData.offer && Number(postData.offer) > 0 ? (
<span className="text-[#ff0000] text-xl font-semibold mr-2">
%{postData.offer}
</span>
) : null}
</span>
</div>
<div className="flex items-center gap-1 mt-1">
@@ -453,20 +440,19 @@ function MainModelCard({ postData }: { postData: Course }) {
</div>
</Link>
<div onClick={() => router.push(`/academy/${postData._id}/course`)} className=" overflow-hidden rounded-2xl">
<div
className="w-full flex-shrink-0 flex justify-center items-center p-2"
style={{ width: "100%" }}
>
<Image
src={IMAGE_BASE_URL + course_image}
alt={`media-${course_image}`}
width={1000}
height={1000}
loading={"lazy"}
className="object-contain w-full min-h-[30vh] rounded-xl shadow-lg transition-opacity duration-300"
onLoadingComplete={() => setImageLoading?.(false)}
/>
<div onClick={() => router.push(`/academy/${postData._id}/course`)} className="overflow-hidden rounded-2xl">
<div className="w-full flex-shrink-0 flex justify-center items-center p-2">
<div className="w-full rounded-2xl overflow-hidden shadow-[0_8px_30px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_30px_rgba(0,0,0,0.45)]">
<Image
src={IMAGE_BASE_URL + course_image}
alt={`media-${course_image}`}
width={1000}
height={1000}
loading={"lazy"}
className="object-contain w-full min-h-[30vh] rounded-xl shadow-lg transition-opacity duration-300"
onLoadingComplete={() => setImageLoading?.(false)}
/>
</div>
</div>
</div>

View File

@@ -15,6 +15,7 @@ import { AxiosError } from "axios";
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
import { useRouter } from "next/navigation";
import { useRequireAuth } from "@/lib/auth/useRequireAuth";
import { SocialIconButton, SocialStatButton } from "@/components/ui/SocialStatButton";
function MainModelCardActions({ postData }: { postData: Course }) {
const { _id, user_id, likes } = postData;
@@ -169,29 +170,26 @@ function MainModelCardActions({ postData }: { postData: Course }) {
>
مشاهده پکیج
</RoundedButton>
<div className="flex items-center gap-5 md:gap-10">
<button
<div className="flex items-center gap-5 md:gap-8">
<SocialStatButton
count={totalComments}
onClick={() => setShowCommentsModal(!showCommentsModal)}
className="flex flex-col items-center text-xs font-semibold gap-1"
>
<Image
width={20}
height={20}
alt="message-text.svg"
src="/images/icons/message-text.svg"
className="dark:invert"
/>
<span>
{totalComments}
<span className="mr-1 max-sm:text-xs">کامنت</span>
</span>
</button>
<button
aria-label="کامنت"
icon={
<Image
width={20}
height={20}
alt=""
src="/images/icons/message-text.svg"
className="dark:invert"
/>
}
/>
<SocialIconButton
onClick={(e) => {
e.preventDefault();
const url = `${window.location.origin}/academy/${_id}/course`;
// روش اول: استفاده از Web Share API (بهترین برای موبایل)
if (navigator.share) {
navigator
.share({
@@ -201,45 +199,41 @@ function MainModelCardActions({ postData }: { postData: Course }) {
})
.catch((err) => {
console.log("Error sharing:", err);
// fallback
copyToClipboard(url);
});
} else {
// روش دوم: کپی در کلیپ‌بورد (برای دسکتاپ)
copyToClipboard(url);
}
}}
className="flex flex-col items-center text-xs font-semibold gap-1"
>
<Image
width={20}
height={20}
alt="share.svg"
src="/images/icons/share.svg"
className="dark:invert"
/>
<span className="max-sm:text-xs">اشتراکگذاری</span>
</button>
<button
aria-label="اشتراک‌گذاری"
icon={
<Image
width={20}
height={20}
alt=""
src="/images/icons/share.svg"
className="dark:invert"
/>
}
/>
<SocialStatButton
likeCount={likesCountState || 0}
onClick={toggleLike}
className="flex flex-col items-center text-xs font-semibold gap-1"
>
<Image
width={20}
height={20}
alt="heart.svg"
src={
liked
? "/images/icons/red-heart.svg"
: "/images/icons/heart.svg"
}
className="dark:invert"
/>
<span>
{likesCountState || 0}{" "}
<span className="max-sm:text-xs">لایک</span>
</span>
</button>
aria-label="لایک"
icon={
<Image
width={20}
height={20}
alt=""
src={
liked
? "/images/icons/red-heart.svg"
: "/images/icons/heart.svg"
}
className={liked ? "" : "dark:invert"}
/>
}
/>
</div>
</div>
</>

View File

@@ -1,5 +1,6 @@
import React, { ComponentProps } from "react";
import { cn } from "@/lib/utils";
import { btnPrimary } from "@/lib/ui/buttonStyles";
type TButton = ComponentProps<"button"> & {
loading?: boolean;
@@ -18,7 +19,8 @@ function AuthButton({
return (
<button
className={cn(
"w-full dir-ltr max-w-[290px] p-3 text-center text-white rounded-2xl bg-[#FC8EAC] hover:bg-[#f85e87] font-bold duration-300",
"dir-ltr w-full max-w-[290px] p-3 text-center",
btnPrimary,
loading && "cursor-wait opacity-80",
className
)}

View File

@@ -1,9 +1,13 @@
import React, { ComponentProps } from "react";
import { cn } from "@/lib/utils";
import { btnDefault, btnPrimary } from "@/lib/ui/buttonStyles";
type ButtonVariant = "default" | "primary";
type TButton = ComponentProps<"button"> & {
loading?: boolean;
loadingText?: string;
variant?: ButtonVariant;
};
function AuthNextButton({
@@ -13,12 +17,14 @@ function AuthNextButton({
loading = false,
loadingText = "در حال ارسال...",
disabled,
variant = "default",
...rest
}: TButton) {
return (
<button
className={cn(
"border dir-ltr p-7 py-2 text-center text-border-secondary-light dark:text-border-secondary-dark border-border-secondary-light dark:border-border-secondary-dark rounded-3xl bg-tertiary-light dark:bg-tertiary-dark font-medium duration-300 hover:opacity-70 text-sm",
"dir-ltr px-4 py-2 text-center text-sm font-semibold",
variant === "primary" ? btnPrimary : btnDefault,
loading && "cursor-wait opacity-70",
className
)}

View File

@@ -0,0 +1,13 @@
"use client";
import { useEffect } from "react";
import { ensureAuthSessionSynced } from "@/lib/auth/session";
/** همگام‌سازی کوکی کلاینت با middleware در هر بارگذاری اپ */
export default function AuthSessionSync() {
useEffect(() => {
void ensureAuthSessionSynced();
}, []);
return null;
}

View File

@@ -1,5 +1,6 @@
import { IExpertise } from "@/types/types";
import { cn } from "@/lib/utils";
import { toggleBtnClass } from "@/lib/ui/buttonStyles";
function gridColsClass(count: number): string {
if (count <= 1) return "grid-cols-1";
@@ -45,9 +46,9 @@ export default function ExpertisePicker({
}
return (
<div className="w-full flex flex-col items-center mt-4 px-2">
<div className="mt-4 flex w-full flex-col items-center px-2">
<div
className={cn("grid gap-2 w-full", gridColsClass(mainCount))}
className={cn("grid w-full gap-2", gridColsClass(mainCount))}
style={{ maxWidth: gridMaxWidth(mainCount) }}
>
{expertiseList?.map((item) => (
@@ -55,10 +56,8 @@ export default function ExpertisePicker({
key={item._id}
type="button"
className={cn(
"p-2 min-h-10 rounded-3xl border-2 text-xs leading-tight transition-colors",
expertise === item.expertise
? "bg-yellow-500 text-white border-yellow-500"
: "bg-white text-black border-gray-400 dark:bg-white dark:text-black"
toggleBtnClass(expertise === item.expertise),
"min-h-10 p-2 text-xs leading-tight"
)}
onClick={() => onExpertiseSelect(item.expertise)}
>
@@ -68,12 +67,12 @@ export default function ExpertisePicker({
</div>
{selectedExpertise && subCount > 0 && (
<div className="w-full mt-6 flex flex-col items-center">
<p className="text-xs text-gray-500 mb-2 text-center">
<div className="mt-6 flex w-full flex-col items-center">
<p className="mb-2 text-center text-xs text-gray-500">
زیرمجموعه {selectedExpertise.expertise}
</p>
<div
className={cn("grid gap-2 w-full", gridColsClass(subCount))}
className={cn("grid w-full gap-2", gridColsClass(subCount))}
style={{ maxWidth: gridMaxWidth(subCount) }}
>
{selectedExpertise.sub_expertise.map((item) => (
@@ -81,10 +80,8 @@ export default function ExpertisePicker({
key={item._id}
type="button"
className={cn(
"p-2 min-h-10 rounded-3xl border-2 text-xs leading-tight transition-colors",
subExpertise.includes(item.name)
? "bg-pink-500 text-white border-pink-500"
: "bg-white text-black border-gray-400 dark:bg-white dark:text-black"
toggleBtnClass(subExpertise.includes(item.name)),
"min-h-10 p-2 text-xs leading-tight"
)}
onClick={() => onSubExpertiseToggle(item.name)}
>

View File

@@ -0,0 +1,120 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import {
buildLoginRedirect,
isRegistrationPath,
REGISTRATION_ENTRY_PATH,
REGISTRATION_OTP_PATH,
requiresRegistrationToken,
resolveRegistrationRedirect,
getRegistrationRoute,
} from "@/lib/auth/registrationRoutes";
import { fetchRegisterStatus } from "@/lib/auth/fetchRegisterStatus";
import { getAuthToken } from "@/lib/auth/session";
export default function RegisterRouteGuard({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
async function guard() {
if (!pathname || !isRegistrationPath(pathname)) {
if (!cancelled) setReady(true);
return;
}
const normalized = pathname.split("?")[0];
if (normalized === REGISTRATION_ENTRY_PATH) {
const token = getAuthToken();
if (token) {
const status = await fetchRegisterStatus();
if (cancelled) return;
if (status?.page === "home" || status?.registrationComplete) {
router.replace("/");
return;
}
if (status?.step) {
router.replace(getRegistrationRoute(status.step));
return;
}
}
if (!cancelled) setReady(true);
return;
}
if (normalized === REGISTRATION_OTP_PATH) {
const mobile =
typeof window !== "undefined"
? localStorage.getItem("mobile")
: null;
if (!mobile) {
router.replace(REGISTRATION_ENTRY_PATH);
return;
}
if (!cancelled) setReady(true);
return;
}
if (requiresRegistrationToken(normalized)) {
const token = getAuthToken();
if (!token) {
router.replace(buildLoginRedirect(normalized));
return;
}
const status = await fetchRegisterStatus();
if (cancelled) return;
if (!status) {
router.replace(buildLoginRedirect(normalized));
return;
}
if (status.page === "home" || status.registrationComplete) {
router.replace("/");
return;
}
const redirect = resolveRegistrationRedirect(normalized, status.step);
if (redirect) {
router.replace(redirect);
return;
}
}
if (!cancelled) setReady(true);
}
setReady(false);
void guard();
return () => {
cancelled = true;
};
}, [pathname, router]);
if (!ready && pathname && isRegistrationPath(pathname)) {
return (
<div className="flex min-h-[40vh] items-center justify-center text-sm text-neutral-500">
در حال بررسی وضعیت ثبتنام
</div>
);
}
return children;
}

View File

@@ -1,7 +1,7 @@
"use client";
import React from "react";
import { filterChipClass } from "@/lib/ui/buttonStyles";
interface UsernameSuggestionsProps {
suggestions: string[];
onSelect: (username: string) => void;
@@ -20,7 +20,7 @@ export default function UsernameSuggestions({
key={username}
type="button"
onClick={() => onSelect(username)}
className="px-3 py-1.5 text-sm font-medium rounded-full border border-[#387E65] text-[#387E65] bg-white hover:bg-[#387E65]/10 transition-colors dir-ltr"
className={filterChipClass(false, "dir-ltr px-3 py-1.5 text-sm")}
>
{username}
</button>

View File

@@ -4,6 +4,8 @@ import useAxios from "@/hooks/useAxios";
import Modal from "@/components/elements/Modal";
import SelectBox from "@/components/elements/SelectBox";
import RoundedButton from "@/components/elements/RoundedButton";
import { btnHeightMd, btnHeightSm, toggleBtnClass } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
interface IFilterProps {
setShowFilterModal: (e: boolean) => void;
@@ -77,6 +79,8 @@ function FilterModal({
}
}, [stateId]);
const discountActive = sort === "mostDiscount";
return (
<Modal
isOpen={showFilterModal}
@@ -84,7 +88,7 @@ function FilterModal({
height="500px"
>
<div className="flex flex-col items-center">
<span className="font-semibold mb-5">مرتب سازی</span>
<span className="mb-5 font-semibold">مرتب سازی</span>
<SelectBox
className="mt-4 max-w-sm"
value={stateId}
@@ -129,33 +133,32 @@ function FilterModal({
</SelectBox>
<RoundedButton
onClick={() => {
if (sort !== "mostDiscount") {
setSort("mostDiscount");
} else {
if (discountActive) {
setSort("");
} else {
setSort("mostDiscount");
}
}}
className={`h-9 w-40 mt-4 ${
sort && sort == "mostDiscount" ? "bg-[#23bace]" : ""
}`}
className={cn(toggleBtnClass(discountActive), btnHeightSm, "mt-4 w-40")}
>
بیشترین تخفیف
</RoundedButton>
<button
<RoundedButton
variant="primary"
onClick={() => {
handleFilterChange();
setShowFilterModal(false);
}}
className={`border p-1 w-full rounded-full duration-300 max-w-[150px] mt-5`}
className={cn(btnHeightMd, "mt-5 w-full max-w-[200px]")}
>
اعمال فیلتر
</button>
<button
</RoundedButton>
<RoundedButton
onClick={() => clearFilters()}
className={`border p-1 w-full rounded-full duration-300 max-w-[150px] mt-5`}
className={cn(btnHeightMd, "mt-3 w-full max-w-[200px]")}
>
پاک کردن
</button>
</RoundedButton>
</div>
</Modal>
);

View File

@@ -1,4 +1,5 @@
"use client";
import { btnPrimary, btnDefault } from "@/lib/ui/buttonStyles";
import { cn } from "@/lib/utils";
import React, { useMemo, useRef } from "react";
import dynamic from "next/dynamic";
@@ -42,12 +43,13 @@ function BillboardLocationModal({
if (!mapboxToken) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg shadow-lg max-w-md w-full">
<div className="glass-modal-overlay fixed inset-0 z-50 flex items-center justify-center">
<div className="glass-modal-panel glass-modal-panel--center w-full max-w-md p-6 shadow-lg">
<h2 className="text-lg font-bold mb-4">خطا</h2>
<p className="text-red-500">توکن Mapbox تنظیم نشده است.</p>
<button
className="mt-4 bg-blue-500 text-white px-4 py-2 rounded"
type="button"
className={cn(btnDefault, "mt-4 px-4 py-2")}
onClick={() => setShowLocationModal(false)}
>
بستن
@@ -59,11 +61,11 @@ function BillboardLocationModal({
return (
<div
className={`fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 transition-opacity ${
className={`glass-modal-overlay fixed inset-0 flex items-center justify-center z-50 transition-opacity ${
showLocationModal ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
>
<div className="bg-white dark:bg-neutral-800 p-6 rounded-lg shadow-lg max-w-md w-full">
<div className="glass-modal-panel glass-modal-panel--center w-full max-w-md p-6 shadow-lg">
<h2 className="text-lg font-bold mb-4">آدرس و لوکیشن</h2>
{address && <p className="mb-4 text-sm">{address}</p>}
<div style={{ height: "300px", width: "100%", position: "relative" }}>
@@ -91,12 +93,13 @@ function BillboardLocationModal({
href={navigationLink}
target="_blank"
rel="noopener noreferrer"
className="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600"
className={cn(btnPrimary, "px-4 py-2")}
>
مسیریابی با Google Maps
</a>
<button
className="bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600"
type="button"
className={cn(btnDefault, "px-4 py-2")}
onClick={() => setShowLocationModal(false)}
>
بستن

View File

@@ -4,6 +4,8 @@ import useAxios from "@/hooks/useAxios";
import Image from "next/image";
import React, { useEffect, useState } from "react";
import BillboardCommentsModal from "./BillboardCommentsModal";
import { SocialStatButton, socialStatButtonClass } from "@/components/ui/SocialStatButton";
import { cn } from "@/lib/utils";
function MainBillboardCardActions({
_id,
@@ -69,62 +71,55 @@ function MainBillboardCardActions({
advertisingId={_id}
/>
)}
<button
<SocialStatButton
likeCount={likesCount ?? 0}
onClick={(e) => {
e.stopPropagation();
toggleLike();
}}
className="flex flex-col items-center text-xs font-semibold gap-1"
>
<Image
width={22}
height={22}
alt={"heart svg"}
src={
!liked ? `/images/icons/heart.svg` : `/images/icons/red-heart.svg`
}
className={!liked ? `dark:invert` : ""}
/>
<span>
{likesCount ? likesCount : "0"} <span>لایک</span>
</span>
</button>
<button
aria-label="لایک"
icon={
<Image
width={22}
height={22}
alt=""
src={
!liked ? `/images/icons/heart.svg` : `/images/icons/red-heart.svg`
}
className={!liked ? `dark:invert` : ""}
/>
}
/>
<SocialStatButton
count={commentsCount}
onClick={(e) => {
e.stopPropagation();
setShowCommentsModal(!showCommentsModal);
}}
className="flex flex-col items-center text-xs font-semibold gap-1"
aria-label="کامنت"
icon={
<Image
width={22}
height={22}
alt=""
src={`/images/icons/message-text.svg`}
className="dark:invert"
/>
}
/>
<div
className={cn(socialStatButtonClass, isDetail ? "absolute left-0" : "")}
aria-label="بازدید"
>
<Image
width={22}
height={22}
alt={"message-text.svg"}
src={`/images/icons/message-text.svg`}
className="dark:invert"
/>
<span>
{commentsCount}
<span className="mr-1">کامنت</span>
</span>
</button>
<button
className={`flex flex-col items-center text-xs font-semibold gap-1 ${
isDetail ? "absolute left-0" : ""
}`}
>
<Image
width={22}
height={22}
alt={"eye.svg"}
alt=""
src={`/images/icons/eye.svg`}
className="dark:invert"
/>
<span>
{viewCount}
<span className="mr-1">نمایش</span>
</span>
</button>
<span>{viewCount}</span>
</div>
</div>
);
}

View File

@@ -132,7 +132,7 @@ console.log(categories);
{formik.touched.images && formik.errors.images && (
<p className="text-red-500 text-xs">{formik.errors.images}</p>
)}
<RoundedButton type="submit" className="p-2 px-8 rounded mt-4">
<RoundedButton type="submit" variant="primary" className="mt-4 px-8 py-2">
ثبت و ادامه
</RoundedButton>
</form>

View File

@@ -62,7 +62,7 @@ const Step2 = ({ nextStep }: { nextStep: () => void }) => {
)} */}
<hr />
<div className="flex gap-4 mb-4 ">
<RoundedButton type="submit" className="w-32 rounded h-9">
<RoundedButton type="submit" variant="primary" className="h-9 w-32">
ثبت و ادامه
</RoundedButton>
<RoundedButton

View File

@@ -164,7 +164,7 @@ const Step3 = ({ nextStep }: { nextStep: () => void }) => {
typeof formik.errors.selectedFeatures === "string" && (
<p className="text-red-500">{formik.errors.selectedFeatures}</p>
)}
<RoundedButton type="submit" className="w-32 rounded h-9 mt-4">
<RoundedButton type="submit" variant="primary" className="mt-4 h-9 w-32">
ثبت و ادامه
</RoundedButton>
</form>

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