full update
This commit is contained in:
32
src/api/fetchBookmarkPosts.ts
Normal file
32
src/api/fetchBookmarkPosts.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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
23
src/api/fetchProject.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 !== "/"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"}
|
||||
>
|
||||
|
||||
@@ -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"}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import AuthProviders from "./AuthProviders";
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "ورود و ثبتنام | مدستاگرام",
|
||||
template: "%s | مدستاگرام",
|
||||
},
|
||||
description: "ورود یا ثبتنام در مدستاگرام",
|
||||
robots: { index: false, follow: false },
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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)}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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="/" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -137,7 +137,7 @@ function FailedBillboard() {
|
||||
payHandler();
|
||||
}
|
||||
}}
|
||||
className="w-32 h-9 !border-[#0C8002] text-[#0C8002]"
|
||||
variant="primary" className="w-32 h-9"
|
||||
>
|
||||
پرداخت مجدد
|
||||
</RoundedButton>
|
||||
|
||||
@@ -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>
|
||||
|
||||
54
src/app/explore/[id]/ExploreReelClient.tsx
Normal file
54
src/app/explore/[id]/ExploreReelClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
</>
|
||||
|
||||
@@ -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
BIN
src/app/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
98
src/app/posts/[id]/[[...slug]]/page.tsx
Normal file
98
src/app/posts/[id]/[[...slug]]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
12
src/app/settings/chats/layout.tsx
Normal file
12
src/app/settings/chats/layout.tsx
Normal 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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
|
||||
12
src/app/settings/edit/layout.tsx
Normal file
12
src/app/settings/edit/layout.tsx
Normal 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;
|
||||
}
|
||||
@@ -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"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
20
src/app/settings/favorites/page.tsx
Normal file
20
src/app/settings/favorites/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/app/settings/financial/layout.tsx
Normal file
12
src/app/settings/financial/layout.tsx
Normal 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;
|
||||
}
|
||||
@@ -3,8 +3,7 @@ import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "تنظیمات",
|
||||
template: "%s | مدستاگرام",
|
||||
default: "تنظیمات | مدستاگرام",
|
||||
},
|
||||
description: "تنظیمات حساب کاربری در مدستاگرام",
|
||||
robots: { index: false, follow: false },
|
||||
|
||||
12
src/app/settings/my-billboards/layout.tsx
Normal file
12
src/app/settings/my-billboards/layout.tsx
Normal 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;
|
||||
}
|
||||
12
src/app/settings/notifications/layout.tsx
Normal file
12
src/app/settings/notifications/layout.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
12
src/app/settings/offers/layout.tsx
Normal file
12
src/app/settings/offers/layout.tsx
Normal 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;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
46
src/app/settings/profile/ProfileClient.tsx
Normal file
46
src/app/settings/profile/ProfileClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
12
src/app/settings/profile/user-settings/layout.tsx
Normal file
12
src/app/settings/profile/user-settings/layout.tsx
Normal 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;
|
||||
}
|
||||
144
src/app/settings/profile/user-settings/page.tsx
Normal file
144
src/app/settings/profile/user-settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/app/settings/profile/visitors/layout.tsx
Normal file
12
src/app/settings/profile/visitors/layout.tsx
Normal 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;
|
||||
}
|
||||
88
src/app/settings/profile/visitors/page.tsx
Normal file
88
src/app/settings/profile/visitors/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/app/settings/tickets/layout.tsx
Normal file
12
src/app/settings/tickets/layout.tsx
Normal 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;
|
||||
}
|
||||
12
src/app/settings/workroom/layout.tsx
Normal file
12
src/app/settings/workroom/layout.tsx
Normal 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;
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
199
src/components/academy/AcademyFilterModal.tsx
Normal file
199
src/components/academy/AcademyFilterModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
src/components/academy/AcademyPackageItem.tsx
Normal file
59
src/components/academy/AcademyPackageItem.tsx
Normal 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;
|
||||
96
src/components/academy/AcademySkeletons.tsx
Normal file
96
src/components/academy/AcademySkeletons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
13
src/components/auth/AuthSessionSync.tsx
Normal file
13
src/components/auth/AuthSessionSync.tsx
Normal 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;
|
||||
}
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
120
src/components/auth/RegisterRouteGuard.tsx
Normal file
120
src/components/auth/RegisterRouteGuard.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
بستن
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user