Initial commit
This commit is contained in:
@@ -11,11 +11,17 @@ const nextConfig = {
|
||||
// ۲. تنظیمات تصاویر
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'api.modstagram.ir',
|
||||
port: '',
|
||||
pathname: '/**',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'app.modstagram.ir',
|
||||
port: '',
|
||||
pathname: '/**',
|
||||
pathname: '/**',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
@@ -26,7 +32,7 @@ const nextConfig = {
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'localhost',
|
||||
port: '',
|
||||
port: '3004',
|
||||
pathname: '/**',
|
||||
},
|
||||
],
|
||||
@@ -36,7 +42,7 @@ const nextConfig = {
|
||||
minimumCacheTTL: 31536000,
|
||||
},
|
||||
|
||||
// ۳. هدایت ترافیک سابفولدر به وردپرس (بخش جدید)
|
||||
// ۳. فقط وبلاگ — API/storage از Route Handler پروکسی میشوند
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
|
||||
68
src/api/fetchAcademyExplore.ts
Normal file
68
src/api/fetchAcademyExplore.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { getApiBaseUrl } from "@/components/main/BaseUrl";
|
||||
|
||||
export type AcademyExploreItem = {
|
||||
_id: string;
|
||||
courseId: string;
|
||||
type: "image" | "video";
|
||||
files?: { path?: string; type?: string }[];
|
||||
course_video?: string | null;
|
||||
course_images?: string[];
|
||||
course_image?: string;
|
||||
course_name?: string;
|
||||
teacher_name?: string;
|
||||
file_name?: string;
|
||||
caption?: string;
|
||||
user_id?: string;
|
||||
user_name?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
profile_image?: string;
|
||||
likesCount?: number;
|
||||
createdAt?: string;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
type AcademyExploreResponse = {
|
||||
success?: boolean;
|
||||
data?: {
|
||||
items: AcademyExploreItem[];
|
||||
pagination?: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalItems: number;
|
||||
hasNextPage: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export async function fetchAcademyExplore(
|
||||
page: number,
|
||||
limit: number,
|
||||
token: string
|
||||
): Promise<{
|
||||
items: AcademyExploreItem[];
|
||||
hasNextPage: boolean;
|
||||
}> {
|
||||
const apiUrl = `${getApiBaseUrl()}/academy/academy/explore/free-content?page=${page}&limit=${limit}`;
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
return { items: [], hasNextPage: false };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Academy explore failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const json = (await response.json()) as AcademyExploreResponse;
|
||||
const items = json.data?.items ?? [];
|
||||
const hasNextPage = json.data?.pagination?.hasNextPage ?? false;
|
||||
|
||||
return { items, hasNextPage };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { getApiBaseUrl } from "@/components/main/BaseUrl";
|
||||
|
||||
export async function fetchBillboards(
|
||||
page: number,
|
||||
@@ -27,7 +27,7 @@ export async function fetchBillboards(
|
||||
timestamp: Date.now().toString(), // اضافه کردن timestamp برای جلوگیری از کش
|
||||
}).toString();
|
||||
|
||||
const apiUrl = `${BASE_URL}/advertising/web?${queryParams}`;
|
||||
const apiUrl = `${getApiBaseUrl()}/advertising/web?${queryParams}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 90_000);
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
import { BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { getApiBaseUrl } from "@/components/main/BaseUrl";
|
||||
import { Post } from "@/types/types";
|
||||
|
||||
/** Fetch a single post by id (tries dedicated endpoint, then list fallback) */
|
||||
/** Fetch a single post by id */
|
||||
export async function fetchPostById(
|
||||
id: string,
|
||||
token?: string
|
||||
): Promise<Post | null> {
|
||||
const base = getApiBaseUrl();
|
||||
const headers: HeadersInit = token
|
||||
? { Authorization: `Bearer ${token}` }
|
||||
: {};
|
||||
|
||||
const tryUrls = [
|
||||
`${BASE_URL}/posts/${id}`,
|
||||
`${BASE_URL}/posts/web/${id}`,
|
||||
`${BASE_URL}/posts/get/${id}`,
|
||||
const primaryUrl = `${base}/posts/web/${id}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(primaryUrl, {
|
||||
cache: "no-store",
|
||||
headers,
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const post = data?.post ?? data?.data ?? data;
|
||||
if (post?._id) return post as Post;
|
||||
}
|
||||
} catch {
|
||||
/* try fallbacks */
|
||||
}
|
||||
|
||||
const fallbackUrls = [
|
||||
`${base}/posts/${id}`,
|
||||
`${base}/posts/get/${id}`,
|
||||
];
|
||||
|
||||
for (const url of tryUrls) {
|
||||
for (const url of fallbackUrls) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
cache: "no-store",
|
||||
@@ -31,19 +47,5 @@ export async function fetchPostById(
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${BASE_URL}/users/web?page=1&limit=50&_id=${id}`,
|
||||
{ cache: "no-store", headers }
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const found = (data?.posts as Post[])?.find((p) => p._id === id);
|
||||
if (found) return found;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { getApiBaseUrl } from "@/components/main/BaseUrl";
|
||||
import { normalizeUserLevel } from "@/lib/userLevel";
|
||||
|
||||
export async function fetchPosts(
|
||||
@@ -12,6 +12,12 @@ export async function fetchPosts(
|
||||
rateFilter?: string;
|
||||
_id?: string;
|
||||
type?: string;
|
||||
exploreFilter?: string;
|
||||
subExpertise?: string;
|
||||
lat?: string;
|
||||
lng?: string;
|
||||
feedMode?: "grid" | "reels";
|
||||
seedPostId?: string;
|
||||
},
|
||||
token: string
|
||||
) {
|
||||
@@ -33,7 +39,7 @@ export async function fetchPosts(
|
||||
...validFilters,
|
||||
}).toString();
|
||||
|
||||
const apiUrl = `${BASE_URL}/users/web?${queryParams}`;
|
||||
const apiUrl = `${getApiBaseUrl()}/users/web?${queryParams}`;
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
cache: "no-store",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { getApiBaseUrl } from "@/components/main/BaseUrl";
|
||||
|
||||
export async function fetchProjects(
|
||||
page: number,
|
||||
@@ -28,7 +28,7 @@ export async function fetchProjects(
|
||||
timestamp: Date.now().toString(), // اضافه کردن timestamp برای جلوگیری از کش
|
||||
}).toString();
|
||||
|
||||
const apiUrl = `${BASE_URL}/projects?${queryParams}`;
|
||||
const apiUrl = `${getApiBaseUrl()}/projects?${queryParams}`;
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
cache: "no-store", // غیرفعال کردن کش
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import { BASE_URL } from "@/components/main/BaseUrl";
|
||||
import { getApiBaseUrl } from "@/components/main/BaseUrl";
|
||||
|
||||
export async function fetchSearch(
|
||||
page: number,
|
||||
@@ -25,7 +25,7 @@ export async function fetchSearch(
|
||||
timestamp: Date.now().toString(), // اضافه کردن timestamp برای جلوگیری از کش
|
||||
}).toString();
|
||||
|
||||
const apiUrl = `${BASE_URL}/search-web?${queryParams}`;
|
||||
const apiUrl = `${getApiBaseUrl()}/search-web?${queryParams}`;
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
cache: 'no-store', // غیرفعال کردن کش
|
||||
|
||||
73
src/api/fetchStories.ts
Normal file
73
src/api/fetchStories.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { getApiBaseUrl } from "@/components/main/BaseUrl";
|
||||
|
||||
export type StoryItem = {
|
||||
_id: string;
|
||||
media_path: string;
|
||||
media_type: "image" | "video";
|
||||
createdAt?: string;
|
||||
expires_at?: string;
|
||||
viewed?: boolean;
|
||||
};
|
||||
|
||||
export type StoryFeedUser = {
|
||||
user: {
|
||||
_id: string;
|
||||
user_name: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
profile_image?: string;
|
||||
};
|
||||
stories: StoryItem[];
|
||||
has_unviewed: boolean;
|
||||
};
|
||||
|
||||
export type StoriesFeedResponse = {
|
||||
feed: StoryFeedUser[];
|
||||
my_story: StoryFeedUser | null;
|
||||
viewer_id: string | null;
|
||||
};
|
||||
|
||||
export async function fetchStoriesFeed(
|
||||
token: string
|
||||
): Promise<StoriesFeedResponse> {
|
||||
const res = await fetch(`${getApiBaseUrl()}/stories/feed`, {
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
Authorization: token ? `Bearer ${token}` : "",
|
||||
},
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to load stories");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function markStoryViewed(
|
||||
storyId: string,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
await fetch(`${getApiBaseUrl()}/stories/view`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ storyId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createStoryBase64(
|
||||
file: { name: string; type: string; data: string },
|
||||
token: string
|
||||
): Promise<void> {
|
||||
const res = await fetch(`${getApiBaseUrl()}/stories/create-base64`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ file }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => ({}));
|
||||
throw new Error(json?.message || "خطا در انتشار استوری");
|
||||
}
|
||||
}
|
||||
39
src/api/trackExploreInteraction.ts
Normal file
39
src/api/trackExploreInteraction.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { getApiBaseUrl } from "@/components/main/BaseUrl";
|
||||
|
||||
export type ExploreInteractionPayload = {
|
||||
targetType: "post" | "profile" | "academy";
|
||||
targetId: string;
|
||||
authorId?: string;
|
||||
contentType?: "image" | "video" | "academy";
|
||||
action?:
|
||||
| "view"
|
||||
| "profile_visit"
|
||||
| "share"
|
||||
| "like"
|
||||
| "comment"
|
||||
| "offer"
|
||||
| "watch_complete"
|
||||
| "skip"
|
||||
| "dwell";
|
||||
dwellMs?: number;
|
||||
};
|
||||
|
||||
export async function trackExploreInteraction(
|
||||
payload: ExploreInteractionPayload,
|
||||
token: string
|
||||
): Promise<void> {
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
await fetch(`${getApiBaseUrl()}/users/explore/interaction`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} catch {
|
||||
/* non-blocking */
|
||||
}
|
||||
}
|
||||
@@ -8,26 +8,26 @@ 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, { useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
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 {
|
||||
completeLogin,
|
||||
getAxiosErrorMessage,
|
||||
getSafeRedirectPath,
|
||||
} from "@/lib/auth/postLogin";
|
||||
import { clearAuthSession } from "@/lib/auth/session";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object().shape({
|
||||
username: yup.string().required("نام کاربری الزامی است"),
|
||||
password: yup
|
||||
.string()
|
||||
.required("کلمه عبور نمی\u200Cتواند خالی باشد")
|
||||
.min(8, "کلمه عبور نباید کوتاه تر از 8 کاراکتر باشد")
|
||||
.matches(
|
||||
/^(?=.*[a-zA-Zآ-ی])(?=.*\d).{8,}$/,
|
||||
"کلمه عبور باید شامل حروف و اعداد باشد"
|
||||
),
|
||||
username: yup.string().required("نام کاربری الزامی است"),
|
||||
.min(8, "کلمه عبور نباید کوتاه تر از 8 کاراکتر باشد"),
|
||||
});
|
||||
|
||||
function LoginWithUsername() {
|
||||
@@ -35,9 +35,13 @@ function LoginWithUsername() {
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const [authError, setAuthError] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const { request, loading } = useAxios();
|
||||
const mobile =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mobile") : null;
|
||||
const redirectPath = getSafeRedirectPath();
|
||||
|
||||
useEffect(() => {
|
||||
void clearAuthSession();
|
||||
}, []);
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
@@ -46,40 +50,39 @@ function LoginWithUsername() {
|
||||
},
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values) => {
|
||||
setAuthError(false);
|
||||
setErrorMessage("");
|
||||
|
||||
try {
|
||||
const response = (await request("POST", "/login/login_username", {
|
||||
mobile,
|
||||
user_name: values.username,
|
||||
password: values.password,
|
||||
})) as IVerifyOtp;
|
||||
switch (response?.page) {
|
||||
case "home":
|
||||
await setAuthSession(response.token, {
|
||||
id: response.id,
|
||||
user_type: response.user_type,
|
||||
step: response.step,
|
||||
});
|
||||
setIsSuccess(true);
|
||||
await pause(AUTH_SUCCESS_DELAY_MS);
|
||||
router.refresh();
|
||||
router.push("/");
|
||||
break;
|
||||
case "auth-page":
|
||||
await setAuthSession(response.token, {
|
||||
id: response.id,
|
||||
step: response.step,
|
||||
});
|
||||
setIsSuccess(true);
|
||||
await pause(AUTH_SUCCESS_DELAY_MS);
|
||||
router.push("/auth");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
const response = (await request(
|
||||
"POST",
|
||||
"/login/login_username",
|
||||
{
|
||||
user_name: values.username.trim(),
|
||||
password: values.password,
|
||||
},
|
||||
{ noToast: true }
|
||||
)) as IVerifyOtp;
|
||||
|
||||
const loggedIn = await completeLogin(router, response, {
|
||||
redirectTo: redirectPath,
|
||||
});
|
||||
|
||||
if (!loggedIn) {
|
||||
const message = "پاسخ سرور نامعتبر بود. دوباره تلاش کنید.";
|
||||
setAuthError(true);
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSuccess(true);
|
||||
} catch (err: any) {
|
||||
const message = getAxiosErrorMessage(err);
|
||||
setAuthError(true);
|
||||
setIsSuccess(false);
|
||||
console.log("Unhandled error:", err?.message);
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -97,10 +100,14 @@ function LoginWithUsername() {
|
||||
type="text"
|
||||
placeholder="نام کاربری"
|
||||
success={isSuccess}
|
||||
error={authError || Boolean(formik.touched.username && formik.errors.username)}
|
||||
error={
|
||||
authError ||
|
||||
Boolean(formik.touched.username && formik.errors.username)
|
||||
}
|
||||
value={formik.values.username}
|
||||
onChange={(e) => {
|
||||
setAuthError(false);
|
||||
setErrorMessage("");
|
||||
setIsSuccess(false);
|
||||
formik.handleChange(e);
|
||||
}}
|
||||
@@ -117,10 +124,14 @@ function LoginWithUsername() {
|
||||
placeholder="کلمه عبور"
|
||||
wrapperClassName="mt-4"
|
||||
success={isSuccess}
|
||||
error={authError || Boolean(formik.touched.password && formik.errors.password)}
|
||||
error={
|
||||
authError ||
|
||||
Boolean(formik.touched.password && formik.errors.password)
|
||||
}
|
||||
value={formik.values.password}
|
||||
onChange={(e) => {
|
||||
setAuthError(false);
|
||||
setErrorMessage("");
|
||||
setIsSuccess(false);
|
||||
formik.handleChange(e);
|
||||
}}
|
||||
@@ -132,7 +143,17 @@ function LoginWithUsername() {
|
||||
{formik.errors.password}
|
||||
</small>
|
||||
)}
|
||||
<AuthButton type="submit" className="mt-5" loading={loading} disabled={loading || isSuccess}>
|
||||
{authError && errorMessage && (
|
||||
<small className="text-red-500 mt-2 block text-center">
|
||||
{errorMessage}
|
||||
</small>
|
||||
)}
|
||||
<AuthButton
|
||||
type="submit"
|
||||
className="mt-5"
|
||||
loading={loading}
|
||||
disabled={loading || isSuccess}
|
||||
>
|
||||
ورود
|
||||
</AuthButton>
|
||||
</form>
|
||||
|
||||
@@ -21,10 +21,17 @@ const schema = yup.object({
|
||||
.required("شماره موبایل الزامی است"),
|
||||
});
|
||||
|
||||
import { getSafeRedirectPath } from "@/lib/auth/postLogin";
|
||||
|
||||
function Login() {
|
||||
const router = useRouter();
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const { request, loading } = useAxios();
|
||||
const redirectPath = getSafeRedirectPath();
|
||||
const usernameLoginHref =
|
||||
redirectPath !== "/"
|
||||
? `/login-with-username?redirect=${encodeURIComponent(redirectPath)}`
|
||||
: "/login-with-username";
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
@@ -76,7 +83,7 @@ function Login() {
|
||||
ارسال کد
|
||||
</AuthButton>
|
||||
</form>
|
||||
<Link href={"/login-with-username"}>
|
||||
<Link href={usernameLoginHref}>
|
||||
<small className="text-[#FF5C00] font-bold text-xs mt-8 block">
|
||||
با نام کاربری و کلمه عبور خود وارد شوید
|
||||
</small>
|
||||
|
||||
@@ -18,8 +18,6 @@ function Confirm() {
|
||||
const router = useRouter();
|
||||
const [showModal, setShowModal] = useState<boolean>(false);
|
||||
const [navigating, setNavigating] = useState(false);
|
||||
const userType: string | null =
|
||||
typeof window !== "undefined" ? localStorage.getItem("usertype") : null;
|
||||
|
||||
const [verifiedStatus, setVerifiedStatus] = useState<string | null>(null);
|
||||
|
||||
@@ -41,12 +39,8 @@ function Confirm() {
|
||||
|
||||
const confirmHandler = () => {
|
||||
setNavigating(true);
|
||||
if (userType !== "employer") {
|
||||
setShowModal(true);
|
||||
setNavigating(false);
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
setShowModal(true);
|
||||
setNavigating(false);
|
||||
};
|
||||
return (
|
||||
<Container>
|
||||
|
||||
@@ -6,7 +6,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
<section className="pb-24">
|
||||
<Header />
|
||||
{children}
|
||||
<TabNavigation currentPage="/new-project" />
|
||||
<TabNavigation currentPage="/projects" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import MainProjectCard from "@/components/projects/MainProjectCard";
|
||||
import ProjectRequestForm from "@/components/projects/ProjectPage/ProjectRequestForm";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { Project } from "@/types/types";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function ProjectDetailClient({ id }: { id: string }) {
|
||||
const { request } = useAxios();
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadProject = async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
try {
|
||||
const response = await request<{ project: Project }>(
|
||||
"GET",
|
||||
`/projects/get/web/${id}`,
|
||||
null,
|
||||
{ noToast: true }
|
||||
);
|
||||
if (!cancelled) setProject(response?.project ?? null);
|
||||
} catch {
|
||||
if (!cancelled) setError(true);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadProject();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id, request]);
|
||||
|
||||
if (loading) return <PageLoader className="min-h-[50vh]" />;
|
||||
|
||||
if (error || !project) {
|
||||
return (
|
||||
<Container>
|
||||
<p className="py-20 text-center text-neutral-500">پروژه یافت نشد.</p>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="pb-28">
|
||||
<MainProjectCard project={project} full />
|
||||
<ProjectRequestForm
|
||||
projectId={id}
|
||||
creatorId={
|
||||
(project as Project & { creator_id?: string }).creator_id ||
|
||||
project.creator?._id
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
32
src/app/(projects)/projects/[id]/[title]/page.tsx
Normal file
32
src/app/(projects)/projects/[id]/[title]/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import ProjectDetailClient from "./ProjectDetailClient";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
|
||||
type ProjectDetailPageProps = {
|
||||
params: Promise<{ id: string; title: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: ProjectDetailPageProps): Promise<Metadata> {
|
||||
const { title } = await params;
|
||||
const decodedTitle = decodeURIComponent(title || "پروژه");
|
||||
|
||||
return {
|
||||
title: `${decodedTitle} | پروژهها | مدستاگرام`,
|
||||
description: `جزئیات پروژه ${decodedTitle} در مدستاگرام`,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProjectDetailPage({
|
||||
params,
|
||||
}: ProjectDetailPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
return (
|
||||
<Suspense fallback={<PageLoader className="min-h-[50vh]" />}>
|
||||
<ProjectDetailClient id={id} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
16
src/app/(projects)/projects/layout.tsx
Normal file
16
src/app/(projects)/projects/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
|
||||
export default function ProjectsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="pb-24">
|
||||
<Header />
|
||||
{children}
|
||||
<TabNavigation currentPage="/projects" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
50
src/app/(projects)/projects/page.tsx
Normal file
50
src/app/(projects)/projects/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import Container from "@/components/elements/Container";
|
||||
import InfiniteProjects from "@/components/projects/InfiniteProjects";
|
||||
import ProjectsFilter from "@/components/projects/ProjectsFilter";
|
||||
import { fetchProjects } from "@/api/fetchProjects";
|
||||
import { cookies } from "next/headers";
|
||||
import { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "پروژهها | مدستاگرام",
|
||||
description:
|
||||
"لیست پروژههای مد، زیبایی و عکاسی در مدستاگرام. پروژه مناسب خود را پیدا کنید.",
|
||||
};
|
||||
|
||||
type ProjectsPageProps = {
|
||||
searchParams: Promise<{
|
||||
expertise?: string;
|
||||
most_requests?: string;
|
||||
most_price?: string;
|
||||
age?: string;
|
||||
gender?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function ProjectsPage({ searchParams }: ProjectsPageProps) {
|
||||
const filters = await searchParams;
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("token")?.value || "";
|
||||
|
||||
const projectFilters = {
|
||||
expertise: filters.expertise || "",
|
||||
most_requests: filters.most_requests || "",
|
||||
most_price: filters.most_price || "",
|
||||
age: filters.age || "",
|
||||
gender: filters.gender || "",
|
||||
};
|
||||
|
||||
const initialData = await fetchProjects(1, 10, projectFilters, token);
|
||||
|
||||
return (
|
||||
<Container className="pb-28">
|
||||
<h1 className="sr-only">پروژههای مد و زیبایی در مدستاگرام</h1>
|
||||
<ProjectsFilter expertise={filters.expertise || ""} />
|
||||
<InfiniteProjects
|
||||
initialData={initialData}
|
||||
filters={projectFilters}
|
||||
token={token}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import Container from "@/components/elements/Container";
|
||||
import ModelsFilter from "@/components/models/ModelsFilter";
|
||||
import { cookies } from "next/headers";
|
||||
import InfinitePosts from "@/components/models/InfinitePosts";
|
||||
import StoriesBar from "@/components/stories/StoriesBar";
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import { Metadata } from "next";
|
||||
@@ -143,7 +144,9 @@ export default async function Models({ params, searchParams }: IModelsProps) {
|
||||
|
||||
{/* کامپوننت فیلتر با مقدار تخصص فعلی */}
|
||||
<ModelsFilter expertise={expertise || filters.expertise || ""} />
|
||||
|
||||
|
||||
<StoriesBar />
|
||||
|
||||
{/* نمایش پستها با تمام فیلترهای استخراج شده */}
|
||||
<InfinitePosts
|
||||
filters={{
|
||||
|
||||
20
src/app/api/v1/[...path]/route.ts
Normal file
20
src/app/api/v1/[...path]/route.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import {
|
||||
isPublicAuthPath,
|
||||
proxyToUpstream,
|
||||
} from "@/lib/api/upstreamProxy";
|
||||
|
||||
type RouteContext = { params: Promise<{ path: string[] }> };
|
||||
|
||||
async function handle(req: Request, context: RouteContext) {
|
||||
const { path } = await context.params;
|
||||
const upstreamPath = `/api/v1/${path.join("/")}`;
|
||||
return proxyToUpstream(req, upstreamPath, {
|
||||
stripAuth: isPublicAuthPath(path),
|
||||
});
|
||||
}
|
||||
|
||||
export const GET = handle;
|
||||
export const POST = handle;
|
||||
export const PUT = handle;
|
||||
export const PATCH = handle;
|
||||
export const DELETE = handle;
|
||||
@@ -1,21 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, use } from "react";
|
||||
import PostFeedView from "@/components/posts/PostFeedView";
|
||||
import Container from "@/components/elements/Container";
|
||||
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,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
|
||||
return (
|
||||
<Container className="!px-0">
|
||||
<main className="min-h-[100dvh]">
|
||||
<Suspense fallback={<PageLoader className="min-h-[100dvh]" />}>
|
||||
<PostFeedView initialPostId={id} videoOnly showClose />
|
||||
<ExploreReelContent id={id} />
|
||||
</Suspense>
|
||||
</Container>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,25 +2,64 @@
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import ExploreFilterBar from "@/components/explore/ExploreFilterBar";
|
||||
import ExploreGrid from "@/components/explore/ExploreGrid";
|
||||
import Link from "next/link";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import Header from "@/components/main/Header";
|
||||
import { ExploreFilterId } from "@/constants/exploreFilters";
|
||||
import { useCallback, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
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 handleFilterChange = useCallback((filter: ExploreFilterId) => {
|
||||
if (filter === "near_me") {
|
||||
if (!navigator.geolocation) {
|
||||
toast.error("مرورگر شما از موقعیت مکانی پشتیبانی نمیکند.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingLocation(true);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
setCoords({
|
||||
lat: position.coords.latitude,
|
||||
lng: position.coords.longitude,
|
||||
});
|
||||
setActiveFilter("near_me");
|
||||
setLoadingLocation(false);
|
||||
},
|
||||
() => {
|
||||
setLoadingLocation(false);
|
||||
toast.error(
|
||||
"برای نمایش پستهای نزدیک، دسترسی به موقعیت مکانی لازم است."
|
||||
);
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 15000 }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveFilter(filter);
|
||||
if (filter !== "near_me") {
|
||||
setCoords(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Container className="pb-28">
|
||||
<header className="glass-panel sticky top-0 z-40 mb-2 flex items-center gap-2 border-b border-white/30 px-3 py-3 dark:border-white/10">
|
||||
<Link
|
||||
href="/"
|
||||
className="gentle-transition flex h-9 w-9 items-center justify-center rounded-full text-[#0095f6] active:scale-90"
|
||||
aria-label="بازگشت"
|
||||
>
|
||||
<BoldIcon name="arrow-right-2" size={22} tinted className="text-[#0095f6]" />
|
||||
</Link>
|
||||
<h1 className="text-lg font-bold">اکسپلور</h1>
|
||||
</header>
|
||||
<ExploreGrid />
|
||||
<ExploreFilterBar
|
||||
activeFilter={activeFilter}
|
||||
onFilterChange={handleFilterChange}
|
||||
loadingLocation={loadingLocation}
|
||||
/>
|
||||
<ExploreGrid activeFilter={activeFilter} coords={coords} />
|
||||
</Container>
|
||||
<TabNavigation currentPage="/explore" />
|
||||
</>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import Header from "@/components/main/Header";
|
||||
import TabNavigation from "@/components/TabNavigation";
|
||||
import { Suspense } from "react";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="pb-24">
|
||||
<Header />
|
||||
{children}
|
||||
<Suspense fallback={<PageLoader className="min-h-[40vh]" />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
<TabNavigation currentPage="/new-post" />
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -2,346 +2,351 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import Cookies from "js-cookie";
|
||||
import { optimizeImageToWebP, optimizeVideoToMp4 } from "@/lib/media";
|
||||
import { createStoryBase64 } from "@/api/fetchStories";
|
||||
import TagUsersPicker, { TaggedUser } from "@/components/posts/TagUsersPicker";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import Cookies from "js-cookie";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
function NewPost() {
|
||||
type CreateMode = "image" | "video" | "story";
|
||||
|
||||
const MODE_LABELS: Record<CreateMode, string> = {
|
||||
image: "عکس",
|
||||
video: "ویدئو",
|
||||
story: "استوری",
|
||||
};
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
});
|
||||
}
|
||||
|
||||
export default function NewPostPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { request, loading } = useAxios();
|
||||
const [postType, setPostType] = useState<"image" | "video" | null>("image");
|
||||
const [postFiles, setPostFiles] = useState<(File | null)[]>([null]);
|
||||
const [previews, setPreviews] = useState<string[]>([""]);
|
||||
const [description, setDescription] = useState<string>("");
|
||||
|
||||
const initialMode = (searchParams.get("type") as CreateMode) || "image";
|
||||
const [mode, setMode] = useState<CreateMode>(
|
||||
["image", "video", "story"].includes(initialMode) ? initialMode : "image"
|
||||
);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState<string>("");
|
||||
const [extraImages, setExtraImages] = useState<(File | null)[]>([null]);
|
||||
const [extraPreviews, setExtraPreviews] = useState<string[]>([""]);
|
||||
const [description, setDescription] = useState("");
|
||||
const [taggedUsers, setTaggedUsers] = useState<TaggedUser[]>([]);
|
||||
|
||||
const fileToBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = (error) => reject(error);
|
||||
});
|
||||
useEffect(() => {
|
||||
const t = searchParams.get("type") as CreateMode;
|
||||
if (t && ["image", "video", "story"].includes(t)) setMode(t);
|
||||
}, [searchParams]);
|
||||
|
||||
const resetMedia = () => {
|
||||
if (preview) URL.revokeObjectURL(preview);
|
||||
extraPreviews.forEach((p) => p && URL.revokeObjectURL(p));
|
||||
setFile(null);
|
||||
setPreview("");
|
||||
setExtraImages([null]);
|
||||
setExtraPreviews([""]);
|
||||
};
|
||||
|
||||
const selectFiles = (index: number) => async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.files && event.target.files[0]) {
|
||||
const file = event.target.files[0];
|
||||
const isValid = postType === "video" ? file.type.startsWith('video/') : file.type.startsWith('image/');
|
||||
const switchMode = (next: CreateMode) => {
|
||||
resetMedia();
|
||||
setMode(next);
|
||||
};
|
||||
|
||||
if (!isValid) {
|
||||
toast.error(postType === "video" ? "فقط ویدئو مجاز است" : "فقط تصویر مجاز است");
|
||||
return;
|
||||
}
|
||||
const acceptAttr = useMemo(() => {
|
||||
if (mode === "video" || mode === "story") return "video/*,image/*";
|
||||
return "image/*";
|
||||
}, [mode]);
|
||||
|
||||
if (postType === "video" && postFiles.length > 0) {
|
||||
toast.error("فقط یک ویدئو مجاز است");
|
||||
return;
|
||||
}
|
||||
const onPickMain = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const picked = e.target.files?.[0];
|
||||
if (!picked) return;
|
||||
|
||||
const newFiles = [...postFiles];
|
||||
const newPreviews = [...previews];
|
||||
newFiles[index] = file;
|
||||
newPreviews[index] = URL.createObjectURL(file);
|
||||
setPostFiles(newFiles);
|
||||
setPreviews(newPreviews);
|
||||
if (mode === "video" && !picked.type.startsWith("video/")) {
|
||||
toast.error("فقط ویدئو مجاز است");
|
||||
return;
|
||||
}
|
||||
if (mode === "image" && !picked.type.startsWith("image/")) {
|
||||
toast.error("فقط تصویر مجاز است");
|
||||
return;
|
||||
}
|
||||
|
||||
if (postType === "image" && newFiles.filter((f) => f !== null).length < 10) {
|
||||
setPostFiles((prev) => [...prev, null]);
|
||||
setPreviews((prev) => [...prev, ""]);
|
||||
|
||||
}
|
||||
if (preview) URL.revokeObjectURL(preview);
|
||||
setFile(picked);
|
||||
setPreview(URL.createObjectURL(picked));
|
||||
};
|
||||
|
||||
const onPickExtra = (index: number) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const picked = e.target.files?.[0];
|
||||
if (!picked || !picked.type.startsWith("image/")) {
|
||||
toast.error("فقط تصویر مجاز است");
|
||||
return;
|
||||
}
|
||||
const files = [...extraImages];
|
||||
const previews = [...extraPreviews];
|
||||
files[index] = picked;
|
||||
previews[index] = URL.createObjectURL(picked);
|
||||
setExtraImages(files);
|
||||
setExtraPreviews(previews);
|
||||
if (files.filter(Boolean).length < 10 && index === files.length - 1) {
|
||||
setExtraImages((prev) => [...prev, null]);
|
||||
setExtraPreviews((prev) => [...prev, ""]);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadPost = async () => {
|
||||
const validFiles = postFiles.filter((file) => file !== null);
|
||||
if (validFiles.length === 0) {
|
||||
toast.error("انتخاب تصویر یا ویدئو الزامی است");
|
||||
const publish = async () => {
|
||||
const token = Cookies.get("token");
|
||||
if (!token) {
|
||||
toast.error("لطفاً وارد حساب کاربری شوید");
|
||||
return;
|
||||
}
|
||||
if (!description) {
|
||||
toast.error("وارد کردن توضیحات الزامی است");
|
||||
|
||||
if (mode === "story") {
|
||||
if (!file) {
|
||||
toast.error("انتخاب عکس یا ویدئو برای استوری الزامی است");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
toast.loading("در حال انتشار استوری…", { id: "story-upload" });
|
||||
const optimized =
|
||||
file.type.startsWith("video/")
|
||||
? await optimizeVideoToMp4(file)
|
||||
: await optimizeImageToWebP(file);
|
||||
await createStoryBase64(
|
||||
{
|
||||
name: optimized.name,
|
||||
type: optimized.type,
|
||||
data: await fileToBase64(optimized),
|
||||
},
|
||||
token
|
||||
);
|
||||
toast.success("استوری منتشر شد", { id: "story-upload" });
|
||||
router.push("/");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در انتشار استوری", {
|
||||
id: "story-upload",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (postType === "video" && validFiles.length > 1) {
|
||||
toast.error("برای ویدئو فقط یک فایل مجاز است");
|
||||
|
||||
const imageFiles =
|
||||
mode === "image"
|
||||
? [...(file ? [file] : []), ...extraImages.filter(Boolean)] as File[]
|
||||
: file
|
||||
? [file]
|
||||
: [];
|
||||
|
||||
if (!imageFiles.length) {
|
||||
toast.error("انتخاب مدیا الزامی است");
|
||||
return;
|
||||
}
|
||||
if (postType === "image" && validFiles.length > 10) {
|
||||
toast.error("حداکثر 10 تصویر مجاز است");
|
||||
if (!description.trim()) {
|
||||
toast.error("نوشتن کپشن الزامی است");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
toast.loading("در حال بهینهسازی مدیا…", { id: "upload-opt" });
|
||||
toast.loading("در حال آپلود…", { id: "post-upload" });
|
||||
const optimizedFiles = await Promise.all(
|
||||
validFiles.map(async (file) => {
|
||||
if (postType === "video") {
|
||||
return optimizeVideoToMp4(file, (p) =>
|
||||
toast.loading(`فشردهسازی ویدئو ${p}%`, { id: "upload-opt" })
|
||||
);
|
||||
}
|
||||
return optimizeImageToWebP(file);
|
||||
})
|
||||
imageFiles.map(async (f) =>
|
||||
f.type.startsWith("video/")
|
||||
? optimizeVideoToMp4(f)
|
||||
: optimizeImageToWebP(f)
|
||||
)
|
||||
);
|
||||
toast.dismiss("upload-opt");
|
||||
|
||||
const filesBase64 = await Promise.all(
|
||||
optimizedFiles.map(async (file) => ({
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
data: await fileToBase64(file),
|
||||
optimizedFiles.map(async (f) => ({
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
data: await fileToBase64(f),
|
||||
}))
|
||||
);
|
||||
const tagLine = taggedUsers.map((u) => `@${u.user_name}`).join(" ");
|
||||
const caption = [description, tagLine].filter(Boolean).join("\n");
|
||||
|
||||
const tagLine = taggedUsers
|
||||
.map((u) => `@${u.user_name}`)
|
||||
.join(" ");
|
||||
const caption =
|
||||
[description, tagLine].filter(Boolean).join("\n") || description;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
await request("POST", "/posts/create-base64", {
|
||||
files: filesBase64,
|
||||
caption,
|
||||
tagged_user_ids: taggedUsers.map((u) => u._id),
|
||||
};
|
||||
|
||||
console.log("DEBUG: Payload to send:", JSON.stringify(payload, null, 2));
|
||||
console.log("DEBUG: Authorization header:", Cookies.get('token') ? `Bearer ${Cookies.get('token')}` : 'No token');
|
||||
|
||||
const response = await request("POST", "/posts/create-base64", payload, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
console.log("DEBUG: Response from server:", response);
|
||||
toast.success("پست با موفقیت آپلود شد");
|
||||
router.push("/settings/profile");
|
||||
} catch (err: unknown) {
|
||||
// اگر err یک Error معمولی باشه
|
||||
if (err instanceof Error) {
|
||||
console.error("DEBUG: Upload error:", err.message);
|
||||
toast.error("خطا در آپلود پست: " + err.message);
|
||||
}
|
||||
// اگر err یه ساختار مشابه Axios error باشه
|
||||
else if (
|
||||
typeof err === "object" &&
|
||||
err !== null &&
|
||||
"response" in err &&
|
||||
typeof (err as { response?: { data?: { message?: string } } }).response?.data?.message === "string"
|
||||
) {
|
||||
const msg = (err as { response: { data: { message: string } } }).response.data.message;
|
||||
console.error("DEBUG: Upload error:", msg);
|
||||
toast.error("خطا در آپلود پست: " + msg);
|
||||
} else {
|
||||
// حالت پیش فرض
|
||||
console.error("DEBUG: Upload error:", err);
|
||||
toast.error("خطا در ارتباط با سرور");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
const newFiles = [...postFiles];
|
||||
const newPreviews = [...previews];
|
||||
|
||||
// آزادسازی URL برای فایل حذف شده
|
||||
if (newPreviews[index]) {
|
||||
URL.revokeObjectURL(newPreviews[index]);
|
||||
}
|
||||
|
||||
newFiles.splice(index, 1);
|
||||
newPreviews.splice(index, 1);
|
||||
|
||||
// اگر نوع پست image است و تعداد فایلها کمتر از 10 شد، یک slot خالی اضافه میکنیم
|
||||
if (postType === "image" && newFiles.filter((f) => f !== null).length < 10) {
|
||||
newFiles.push(null);
|
||||
newPreviews.push("");
|
||||
}
|
||||
|
||||
setPostFiles(newFiles);
|
||||
setPreviews(newPreviews);
|
||||
};
|
||||
|
||||
|
||||
const selectPostType = (type: "image" | "video" | null) => {
|
||||
setPostType(type);
|
||||
|
||||
// آزادسازی همه previews قدیمی
|
||||
previews.forEach((url) => {
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
if (type === "image") {
|
||||
setPostFiles([null]);
|
||||
setPreviews([""]);
|
||||
} else {
|
||||
setPostFiles([]);
|
||||
setPreviews([]);
|
||||
toast.success("پست منتشر شد", { id: "post-upload" });
|
||||
router.push("/");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در آپلود", {
|
||||
id: "post-upload",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const isStory = mode === "story";
|
||||
|
||||
return (
|
||||
<Container >
|
||||
<div className="flex flex-col items-center mb-4 px-3">
|
||||
<span className="text-xl font-bold mt-5">ثبت پست</span>
|
||||
|
||||
<div className="flex gap-4 mt-10 ">
|
||||
<RoundedButton onClick={() => selectPostType("image")} className="w-36 h-10 active:bg-white focus:bg-slate-800">
|
||||
ثبت تصاویر
|
||||
</RoundedButton>
|
||||
<RoundedButton onClick={() => selectPostType("video")} className="w-36 h-10 active:bg-white focus:bg-slate-800">
|
||||
ثبت ویدئو
|
||||
</RoundedButton>
|
||||
</div>
|
||||
|
||||
<>
|
||||
<div className="w-full max-w-md grid grid-cols-2 gap-4 mt-10">
|
||||
{postType === "video" && previews.length === 0 ? (
|
||||
<div className="w-full h-40 rounded-3xl border border-[#676767] flex items-center justify-center">
|
||||
<label
|
||||
htmlFor="fileInput-video"
|
||||
className="cursor-pointer w-full h-full flex items-center justify-center text-white"
|
||||
>
|
||||
<Image
|
||||
src="/images/icons/gallery-add.svg"
|
||||
width={80}
|
||||
height={80}
|
||||
alt="افزودن ویدئو"
|
||||
/>
|
||||
</label>
|
||||
<input
|
||||
id="fileInput-video"
|
||||
type="file"
|
||||
accept="video/*"
|
||||
className="hidden"
|
||||
onChange={selectFiles(0)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
previews.map((preview, index) => (
|
||||
<div key={index} className="relative w-full h-40 rounded-3xl border border-[#676767] overflow-hidden flex items-center justify-center">
|
||||
{preview ? (
|
||||
<>
|
||||
{postFiles[index]?.type?.startsWith('video/') ? (
|
||||
<video
|
||||
src={preview}
|
||||
className="w-full h-full object-cover"
|
||||
controls
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={preview}
|
||||
alt={`Preview ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeFile(index)}
|
||||
className="p-2 absolute top-0 right-0"
|
||||
>
|
||||
<Image
|
||||
src="/images/icons/close-circle.svg"
|
||||
width={25}
|
||||
height={25}
|
||||
alt="حذف فایل"
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<label
|
||||
htmlFor={`fileInput-${index}`}
|
||||
className="cursor-pointer w-full h-full flex items-center justify-center text-white"
|
||||
>
|
||||
<Image
|
||||
src="/images/icons/gallery-add.svg"
|
||||
width={80}
|
||||
height={80}
|
||||
alt="افزودن تصویر"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
id={`fileInput-${index}`}
|
||||
type="file"
|
||||
accept={postType === "video" ? "video/*" : "image/*"}
|
||||
className="hidden"
|
||||
onChange={selectFiles(index)}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
</>
|
||||
|
||||
</div>
|
||||
<div className="flex flex-col items-center max-w-md mt-8 mx-auto px-3">
|
||||
<textarea
|
||||
placeholder="توضیحات"
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
if (e.target.value.length <= 1000) setDescription(e.target.value);
|
||||
}}
|
||||
className="w-full p-4 h-20 rounded-3xl border border-border-secondary-light dark:border-border-secondary-dark bg-secondary-light dark:bg-secondary-dark font-medium"
|
||||
/>
|
||||
<span className="text-xs text-gray-500 mt-1">
|
||||
{description.length}/1000
|
||||
<div className="mx-auto flex min-h-[calc(100dvh-8rem)] max-w-lg flex-col bg-background">
|
||||
{/* Header — Instagram style */}
|
||||
<header className="sticky top-0 z-10 flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="flex h-9 w-9 items-center justify-center"
|
||||
aria-label="بازگشت"
|
||||
>
|
||||
<BoldIcon name="arrow-right-3" size={22} className="block dark:invert" />
|
||||
</button>
|
||||
<span className="text-base font-semibold">
|
||||
{isStory ? "استوری جدید" : "پست جدید"}
|
||||
</span>
|
||||
<div className="mt-6 w-full">
|
||||
<TagUsersPicker
|
||||
selected={taggedUsers}
|
||||
onChange={setTaggedUsers}
|
||||
max={50}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<RoundedButton
|
||||
onClick={uploadPost}
|
||||
className="mt-10 w-40 h-10"
|
||||
disabled={loading || !postType}
|
||||
<button
|
||||
type="button"
|
||||
onClick={publish}
|
||||
disabled={loading}
|
||||
className="text-sm font-semibold text-[#0095f6] disabled:opacity-40"
|
||||
>
|
||||
{loading ? "…" : "اشتراک"}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Mode tabs */}
|
||||
<div className="flex border-b border-neutral-200 dark:border-neutral-800">
|
||||
{(["image", "video", "story"] as CreateMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => switchMode(m)}
|
||||
className={`flex-1 py-3 text-sm font-semibold transition ${
|
||||
mode === m
|
||||
? "border-b-2 border-neutral-900 text-neutral-900 dark:border-white dark:text-white"
|
||||
: "text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center">
|
||||
<svg
|
||||
className="animate-spin h-5 w-5 mr-2 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
در حال آپلود...
|
||||
</div>
|
||||
) : (
|
||||
"ثبت پست"
|
||||
)}
|
||||
</RoundedButton>
|
||||
</div>
|
||||
{MODE_LABELS[m]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
{/* Media area */}
|
||||
<div
|
||||
className={`relative flex flex-1 flex-col items-center justify-center bg-neutral-100 dark:bg-neutral-950 ${
|
||||
isStory ? "aspect-[9/16] max-h-[55vh]" : "min-h-[280px]"
|
||||
}`}
|
||||
>
|
||||
{preview ? (
|
||||
<>
|
||||
{file?.type.startsWith("video/") ? (
|
||||
<video
|
||||
src={preview}
|
||||
className="max-h-[55vh] w-full object-contain"
|
||||
controls
|
||||
playsInline
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={preview}
|
||||
alt="preview"
|
||||
className={`w-full object-contain ${isStory ? "max-h-[55vh]" : "max-h-[360px]"}`}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetMedia}
|
||||
className="absolute left-3 top-3 rounded-full bg-black/50 p-2"
|
||||
>
|
||||
<BoldIcon name="close-circle" size={20} tinted className="text-white" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<label className="flex cursor-pointer flex-col items-center gap-3 p-8 text-neutral-500">
|
||||
<BoldIcon name="gallery-add" size={48} className="block opacity-60 dark:invert" />
|
||||
<span className="text-sm">
|
||||
{isStory
|
||||
? "عکس یا ویدئو استوری را انتخاب کنید"
|
||||
: mode === "video"
|
||||
? "ویدئو را انتخاب کنید"
|
||||
: "عکس را انتخاب کنید"}
|
||||
</span>
|
||||
<span className="rounded-lg bg-[#0095f6] px-4 py-2 text-sm font-semibold text-white">
|
||||
انتخاب از گالری
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept={acceptAttr}
|
||||
className="hidden"
|
||||
onChange={onPickMain}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{preview && (
|
||||
<label className="absolute bottom-4 rounded-lg bg-black/60 px-4 py-2 text-xs text-white">
|
||||
تغییر فایل
|
||||
<input type="file" accept={acceptAttr} className="hidden" onChange={onPickMain} />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Extra images for photo post */}
|
||||
{mode === "image" && !isStory && (
|
||||
<div className="grid grid-cols-4 gap-2 border-t border-neutral-200 p-3 dark:border-neutral-800">
|
||||
{[...(file ? [preview] : []), ...extraPreviews.filter(Boolean)]
|
||||
.slice(0, 4)
|
||||
.map((p, i) => (
|
||||
<div key={i} className="relative aspect-square overflow-hidden rounded-md bg-neutral-200">
|
||||
{p && <img src={p} alt="" className="h-full w-full object-cover" />}
|
||||
</div>
|
||||
))}
|
||||
{extraPreviews.map((p, index) =>
|
||||
!p ? (
|
||||
<label
|
||||
key={`slot-${index}`}
|
||||
className="flex aspect-square cursor-pointer items-center justify-center rounded-md border border-dashed border-neutral-300 dark:border-neutral-700"
|
||||
>
|
||||
<BoldIcon name="add" size={20} className="block dark:invert" />
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={onPickExtra(index)}
|
||||
/>
|
||||
</label>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Caption — not for story */}
|
||||
{!isStory && (
|
||||
<div className="space-y-3 border-t border-neutral-200 p-4 dark:border-neutral-800">
|
||||
<textarea
|
||||
placeholder="کپشن بنویسید…"
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
if (e.target.value.length <= 1000) setDescription(e.target.value);
|
||||
}}
|
||||
rows={3}
|
||||
className="w-full resize-none bg-transparent text-sm outline-none placeholder:text-neutral-400"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-neutral-400">
|
||||
<span>{description.length}/1000</span>
|
||||
</div>
|
||||
<TagUsersPicker selected={taggedUsers} onChange={setTaggedUsers} max={50} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isStory && (
|
||||
<p className="px-4 pb-6 text-center text-xs text-neutral-500">
|
||||
استوری شما ۲۴ ساعت نمایش داده میشود و سپس بهطور خودکار حذف میگردد.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NewPost;
|
||||
@@ -71,6 +71,7 @@ export default async function PostPage({ params }: Props) {
|
||||
<Suspense fallback={<PageLoader className="min-h-[100dvh]" />}>
|
||||
<PostFeedView
|
||||
initialPostId={id}
|
||||
initialPost={post}
|
||||
userId={userId}
|
||||
showClose
|
||||
/>
|
||||
|
||||
@@ -3,23 +3,16 @@
|
||||
import Container from "@/components/elements/Container";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import UserDetails from "@/components/settings/UserDetails";
|
||||
import { editEmployerNavLinks, editUserNavLinks } from "@/constants";
|
||||
import { editUserNavLinks } from "@/constants";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
function EditPage() {
|
||||
const [usertype, setUsertype] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setUsertype(localStorage.getItem("usertype"));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [isRegister, setIsRegister] = useState<string | undefined>("false");
|
||||
const user = useUser();
|
||||
const [isRegister, setIsRegister] = useState<string | undefined>("false");
|
||||
|
||||
useEffect(() => {
|
||||
setIsRegister(user?.is_Register);
|
||||
}, [user]);
|
||||
@@ -29,50 +22,25 @@ function EditPage() {
|
||||
<PageTitle>ویرایش</PageTitle>
|
||||
<div className="px-4">
|
||||
<UserDetails />
|
||||
<div className="flex flex-col gap-5 md:gap-8 my-10 text-sm font-semibold">
|
||||
{usertype
|
||||
? usertype === "user"
|
||||
? editUserNavLinks?.map((item) => {
|
||||
return (
|
||||
<Link
|
||||
className={`flex items-center gap-2 ${
|
||||
item?.title === "مجوز" && isRegister === "false"
|
||||
? "hidden"
|
||||
: ""
|
||||
}`}
|
||||
key={item?.title}
|
||||
href={`/settings/edit${item?.href}`}
|
||||
>
|
||||
<Image
|
||||
width={24}
|
||||
height={24}
|
||||
alt={item?.title}
|
||||
src={`/images/icons/${item?.icon}`}
|
||||
className="dark:invert"
|
||||
/>
|
||||
<span>{item?.title}</span>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
: editEmployerNavLinks?.map((item) => {
|
||||
return (
|
||||
<Link
|
||||
className="flex items-center gap-2"
|
||||
key={item?.title}
|
||||
href={`/settings/edit${item?.href}`}
|
||||
>
|
||||
<Image
|
||||
width={24}
|
||||
height={24}
|
||||
alt={item?.title}
|
||||
src={`/images/icons/${item?.icon}`}
|
||||
className="dark:invert"
|
||||
/>
|
||||
<span>{item?.title}</span>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
: ""}
|
||||
<div className="my-10 flex flex-col gap-5 text-sm font-semibold md:gap-8">
|
||||
{editUserNavLinks?.map((item) => (
|
||||
<Link
|
||||
className={`flex items-center gap-2 ${
|
||||
item?.title === "مجوز" && isRegister === "false" ? "hidden" : ""
|
||||
}`}
|
||||
key={item?.title}
|
||||
href={`/settings/edit${item?.href}`}
|
||||
>
|
||||
<Image
|
||||
width={24}
|
||||
height={24}
|
||||
alt={item?.title}
|
||||
src={`/images/icons/${item?.icon}`}
|
||||
className="dark:invert"
|
||||
/>
|
||||
<span>{item?.title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
@@ -11,6 +11,7 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
|
||||
function MyBillboard() {
|
||||
const router = useRouter();
|
||||
@@ -70,13 +71,12 @@ function MyBillboard() {
|
||||
className="dark:invert min-w-[25px]"
|
||||
/>
|
||||
</button>
|
||||
<Link href={"/billboards/new"}>
|
||||
<Image
|
||||
width={28}
|
||||
height={28}
|
||||
alt="add icon"
|
||||
src="/images/icons/add.svg"
|
||||
className="dark:invert min-w-[25px]"
|
||||
<Link href="/billboards/new" aria-label="ثبت بیلبورد">
|
||||
<BoldIcon
|
||||
name="add-square"
|
||||
size={25}
|
||||
tinted
|
||||
className="min-w-[25px] text-neutral-700 dark:text-neutral-200"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Container from "@/components/elements/Container";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import MainProjectCard from "@/components/projects/MainProjectCard";
|
||||
import PageTitle from "@/components/settings/PageTitle";
|
||||
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { Project } from "@/types/types";
|
||||
import { statusMap } from "@/constants";
|
||||
|
||||
const Workroom = () => {
|
||||
const router = useRouter();
|
||||
const user = useUser();
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [userType, setUserType] = useState<string | null>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setUserType(localStorage.getItem("usertype"));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { data, isFetchingNextPage } = useInfiniteScroll({
|
||||
endpoint: "/workroom",
|
||||
@@ -34,68 +29,56 @@ const Workroom = () => {
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
{ type: "user", label: "دریافتی", value: "دریافتی", color: "#B89FFF" },
|
||||
{
|
||||
type: "employer",
|
||||
label: "پرداخت نشده",
|
||||
value: "پرداخت نشده",
|
||||
color: "#ddd",
|
||||
},
|
||||
{
|
||||
type: "employer",
|
||||
label: "در دست بررسی",
|
||||
value: "در دست بررسی",
|
||||
color: "#E0D891",
|
||||
},
|
||||
{ type: "user", label: "ارسال شده", value: "ارسال شده", color: "#ADF5FF" },
|
||||
{
|
||||
type: "employer",
|
||||
label: "منتشر شده",
|
||||
value: "منتشر شده",
|
||||
color: "#ADF5FF",
|
||||
},
|
||||
{
|
||||
type: "both",
|
||||
label: "در دست اقدام",
|
||||
value: "در دست اقدام",
|
||||
color: "#E0D891",
|
||||
},
|
||||
{
|
||||
type: "both",
|
||||
label: "اتمام پروژه",
|
||||
value: "اتمام پروژه",
|
||||
color: "#B7FFB6",
|
||||
},
|
||||
{ type: "both", label: "کنسل شده", value: "کنسل شده", color: "#FFC5C5" },
|
||||
{ label: "دریافتی", value: "دریافتی", color: "#B89FFF" },
|
||||
{ label: "پرداخت نشده", value: "پرداخت نشده", color: "#ddd" },
|
||||
{ label: "در دست بررسی", value: "در دست بررسی", color: "#E0D891" },
|
||||
{ label: "ارسال شده", value: "ارسال شده", color: "#ADF5FF" },
|
||||
{ label: "منتشر شده", value: "منتشر شده", color: "#ADF5FF" },
|
||||
{ label: "در دست اقدام", value: "در دست اقدام", color: "#E0D891" },
|
||||
{ label: "اتمام پروژه", value: "اتمام پروژه", color: "#B7FFB6" },
|
||||
{ label: "کنسل شده", value: "کنسل شده", color: "#FFC5C5" },
|
||||
];
|
||||
|
||||
const openProject = (item: Project) => {
|
||||
const creatorId =
|
||||
typeof item.creator_id === "string"
|
||||
? item.creator_id
|
||||
: item.creator?._id;
|
||||
const isCreator =
|
||||
user?._id && creatorId && String(user._id) === String(creatorId);
|
||||
|
||||
router.push(
|
||||
isCreator
|
||||
? `/settings/workroom/${item._id}`
|
||||
: `/settings/workroom/user/${item._id}`
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<PageTitle>اتاق کار</PageTitle>
|
||||
<PageTitle>پروژههای من</PageTitle>
|
||||
<div className="px-4 text-xs md:text-sm">
|
||||
<div className="px-4 text-sm grid grid-cols-3 gap-2">
|
||||
{statusOptions
|
||||
.filter(({ type }) => type === "both" || type === userType)
|
||||
.map(({ label, value, color }) => (
|
||||
<RoundedButton
|
||||
key={value}
|
||||
onClick={() => setStatusFilter(value)}
|
||||
className={`p-1 text-xs sm:p-2 md:text-sm ${
|
||||
statusFilter === value ? ` text-primary-dark` : ""
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: statusFilter === value ? color : "",
|
||||
borderColor: statusFilter === value ? color : "",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</RoundedButton>
|
||||
))}
|
||||
<div className="grid grid-cols-3 gap-2 px-4 text-sm">
|
||||
{statusOptions.map(({ label, value, color }) => (
|
||||
<RoundedButton
|
||||
key={value}
|
||||
onClick={() => setStatusFilter(value)}
|
||||
className={`p-1 text-xs sm:p-2 md:text-sm ${
|
||||
statusFilter === value ? " text-primary-dark" : ""
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: statusFilter === value ? color : "",
|
||||
borderColor: statusFilter === value ? color : "",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</RoundedButton>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full mt-10">
|
||||
<div className="mt-10 w-full">
|
||||
{data?.pages.length === 0 ||
|
||||
(data?.pages[0]?.projects?.length === 0 && !isFetchingNextPage) ? (
|
||||
<p className="text-center text-gray-500">پروژه ای ثبت نشده است.</p>
|
||||
<p className="text-center text-gray-500">پروژهای ثبت نشده است.</p>
|
||||
) : (
|
||||
data?.pages?.map((page, pageIndex) => (
|
||||
<div key={pageIndex}>
|
||||
@@ -105,13 +88,7 @@ const Workroom = () => {
|
||||
<div
|
||||
key={item?._id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
if (userType == "user") {
|
||||
router.push(`/settings/workroom/user/${item?._id}`);
|
||||
} else {
|
||||
router.push(`/settings/workroom/${item?._id}`);
|
||||
}
|
||||
}}
|
||||
onClick={() => openProject(item)}
|
||||
>
|
||||
<MainProjectCard
|
||||
statusType={label}
|
||||
|
||||
8
src/app/storage/[...path]/route.ts
Normal file
8
src/app/storage/[...path]/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { proxyToUpstream } from "@/lib/api/upstreamProxy";
|
||||
|
||||
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("/")}`);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { User } from "@/types/types";
|
||||
import Container from "@/components/elements/Container";
|
||||
import ModelHead from "@/components/models/ModelPage/ModelHead";
|
||||
import ModelContent from "@/components/models/ModelPage/ModelContent";
|
||||
import ProfileVisitTracker from "@/components/explore/ProfileVisitTracker";
|
||||
import { Metadata } from "next";
|
||||
|
||||
interface IUserProps {
|
||||
@@ -152,6 +153,7 @@ async function UserPage({ params }: IUserProps) {
|
||||
/>
|
||||
|
||||
<article>
|
||||
{user._id ? <ProfileVisitTracker profileUserId={user._id} /> : null}
|
||||
{/* H1 پنهان برای تقویت کلمات کلیدی بدون تغییر ظاهر */}
|
||||
<h1 className="sr-only">
|
||||
{fullName} - {user.expertise} در {user.city?.name}
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const tabs = [
|
||||
{ href: "/", icon: "home-2", label: "خانه" },
|
||||
{ href: "/academy", icon: "teacher", label: "آموزشگاه" },
|
||||
{ href: "/new-post", icon: "add-square", label: "پست ها" },
|
||||
{ href: "/projects", icon: "briefcase", label: "پروژهها" },
|
||||
{ href: "/billboards", icon: "flash-circle", label: "بیلبورد" },
|
||||
{ href: "/settings", icon: "setting", label: "تنظیمات" },
|
||||
] as const;
|
||||
@@ -19,24 +18,11 @@ type TabNavigationProps = {
|
||||
};
|
||||
|
||||
export default function TabNavigation({ currentPage }: TabNavigationProps) {
|
||||
const [createTab, setCreateTab] = useState("/new-post");
|
||||
const [userTypeS, setUserTypeS] = useState<string | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
if (typeof window !== "undefined") {
|
||||
const userType = localStorage.getItem("usertype");
|
||||
setUserTypeS(userType);
|
||||
setCreateTab(
|
||||
userType === "user"
|
||||
? "/new-post"
|
||||
: userType === "employer"
|
||||
? "/new-project"
|
||||
: "/new-post"
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -59,27 +45,24 @@ export default function TabNavigation({ currentPage }: TabNavigationProps) {
|
||||
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">
|
||||
{tabs.map((tab, index) => {
|
||||
const href = index === 2 ? createTab : tab.href;
|
||||
{tabs.map((tab) => {
|
||||
const isActive =
|
||||
currentPage === href ||
|
||||
(href === "/settings" && currentPage.startsWith("/settings"));
|
||||
currentPage === tab.href ||
|
||||
(tab.href === "/settings" && currentPage.startsWith("/settings")) ||
|
||||
(tab.href === "/projects" &&
|
||||
(currentPage.startsWith("/projects") ||
|
||||
currentPage === "/new-project")) ||
|
||||
(tab.href === "/academy" && currentPage.startsWith("/academy"));
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={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"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (index === 2 && !userTypeS) {
|
||||
e.preventDefault();
|
||||
toast.error("لطفا ابتدا در سایت وارد شوید یا ثبت نام کنید.");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<BoldIcon
|
||||
name={tab.icon}
|
||||
|
||||
@@ -8,6 +8,7 @@ import useAxios from "@/hooks/useAxios";
|
||||
import toast from "react-hot-toast";
|
||||
import Rate from "rc-rate";
|
||||
import "rc-rate/assets/index.css";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { Course } from "@/types/types";
|
||||
|
||||
export interface Comments {
|
||||
@@ -58,6 +59,8 @@ function CommentsModal({
|
||||
|
||||
const { request } = useAxios();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const currentUser = useUser();
|
||||
const [hasRatedCourse, setHasRatedCourse] = useState(false);
|
||||
|
||||
// تابع دریافت اطلاعات کاربر با caching
|
||||
const fetchUserInfo = async (userId: string) => {
|
||||
@@ -197,6 +200,19 @@ function CommentsModal({
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser?._id) return;
|
||||
const alreadyRated = comments.some((item) => {
|
||||
const commentUserId =
|
||||
typeof item.user_id === "string" ? item.user_id : item.user_id?._id;
|
||||
return (
|
||||
String(commentUserId) === String(currentUser._id) &&
|
||||
Number(item.rate) > 0
|
||||
);
|
||||
});
|
||||
setHasRatedCourse(alreadyRated);
|
||||
}, [comments, currentUser?._id]);
|
||||
|
||||
// ارسال کامنت جدید
|
||||
const sendMessageHandler = async () => {
|
||||
if (!newMessage.trim()) {
|
||||
@@ -207,22 +223,29 @@ function CommentsModal({
|
||||
toast.error("کامنت نمیتواند بیش از 500 کاراکتر باشد.");
|
||||
return;
|
||||
}
|
||||
if (rating === 0) {
|
||||
toast.error("ثبت امتیاز الزامی است.");
|
||||
if (!hasRatedCourse && rating === 0) {
|
||||
toast.error("برای اولین نظر، ثبت امتیاز الزامی است.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await request("POST", "/academy/comment/create", {
|
||||
const payload: Record<string, unknown> = {
|
||||
course_id: _id,
|
||||
comment: newMessage,
|
||||
rate: rating,
|
||||
});
|
||||
};
|
||||
if (!hasRatedCourse && rating > 0) {
|
||||
payload.rate = rating;
|
||||
}
|
||||
|
||||
const response = await request("POST", "/academy/comment/create", payload);
|
||||
|
||||
if (response?.success) {
|
||||
toast.success("نظر شما با موفقیت ثبت شد");
|
||||
setNewMessage("");
|
||||
setRating(0);
|
||||
if (!hasRatedCourse && rating > 0) {
|
||||
setHasRatedCourse(true);
|
||||
}
|
||||
|
||||
// ریست کردن صفحه و دریافت مجدد کامنتها
|
||||
setPage(1);
|
||||
@@ -431,17 +454,19 @@ function CommentsModal({
|
||||
{/* بخش ارسال نظر */}
|
||||
<div className="border-t dark:border-gray-700 pt-4 pb-2 px-4 bg-white dark:bg-black">
|
||||
<div className="flex flex-col items-center mb-20 gap-3 max-w-md mx-auto">
|
||||
<Rate
|
||||
value={rating}
|
||||
onChange={(value) => setRating(value)}
|
||||
count={5}
|
||||
style={{ fontSize: "28px" }}
|
||||
/>
|
||||
{!hasRatedCourse && (
|
||||
<Rate
|
||||
value={rating}
|
||||
onChange={(value) => setRating(value)}
|
||||
count={5}
|
||||
style={{ fontSize: "28px" }}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center w-full gap-2">
|
||||
<button
|
||||
onClick={sendMessageHandler}
|
||||
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
disabled={!newMessage.trim() || rating === 0}
|
||||
disabled={!newMessage.trim() || (!hasRatedCourse && rating === 0)}
|
||||
>
|
||||
<Image
|
||||
width={24}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter, useParams, useSearchParams } from "next/navigation"; // اض
|
||||
import React, { useState, useEffect } from "react";
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import FilterModal from "./FilterModal";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import Link from "next/link";
|
||||
|
||||
// وارد کردن دیتاها برای تبدیل نام فارسی به ID جهت روشن ماندن فیلترها
|
||||
@@ -130,13 +131,12 @@ function BillboardsFilter() {
|
||||
className="dark:invert min-w-[25px]"
|
||||
/>
|
||||
</button>
|
||||
<Link href={"/billboards/new"}>
|
||||
<Image
|
||||
width={25}
|
||||
height={25}
|
||||
alt="add icon"
|
||||
src="/images/icons/add.svg"
|
||||
className="dark:invert min-w-[25px]"
|
||||
<Link href="/billboards/new" aria-label="ثبت بیلبورد">
|
||||
<BoldIcon
|
||||
name="add-square"
|
||||
size={25}
|
||||
tinted
|
||||
className="text-neutral-700 dark:text-neutral-200 min-w-[25px]"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
129
src/components/explore/ExploreAcademyCard.tsx
Normal file
129
src/components/explore/ExploreAcademyCard.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { AcademyExploreItem } from "@/api/fetchAcademyExplore";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import ExploreAuthor from "@/components/explore/ExploreAuthor";
|
||||
import { ExploreFilterId } from "@/constants/exploreFilters";
|
||||
|
||||
const ASPECT_RATIOS = [
|
||||
"aspect-[2/3]",
|
||||
"aspect-[3/4]",
|
||||
"aspect-[4/5]",
|
||||
"aspect-[5/6]",
|
||||
"aspect-square",
|
||||
"aspect-[3/5]",
|
||||
] as const;
|
||||
|
||||
function hashAspect(id: string): (typeof ASPECT_RATIOS)[number] {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
hash = (hash + id.charCodeAt(i)) % ASPECT_RATIOS.length;
|
||||
}
|
||||
return ASPECT_RATIOS[hash];
|
||||
}
|
||||
|
||||
function getAcademyMedia(item: AcademyExploreItem): {
|
||||
kind: "video" | "image";
|
||||
src: string;
|
||||
} | null {
|
||||
const file = item.files?.[0];
|
||||
if (file?.path) {
|
||||
const src = buildStorageUrl(file.path);
|
||||
const isVideo = item.type === "video" || file.type === "video";
|
||||
return { kind: isVideo ? "video" : "image", src };
|
||||
}
|
||||
|
||||
if (item.course_video) {
|
||||
return { kind: "video", src: buildStorageUrl(item.course_video) };
|
||||
}
|
||||
|
||||
const imagePath = item.course_images?.[0] || item.course_image;
|
||||
if (imagePath) {
|
||||
return { kind: "image", src: buildStorageUrl(imagePath) };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function ExploreAcademyCard({
|
||||
item,
|
||||
activeFilter = "all",
|
||||
coords = null,
|
||||
}: {
|
||||
item: AcademyExploreItem;
|
||||
activeFilter?: ExploreFilterId;
|
||||
coords?: { lat: number; lng: number } | null;
|
||||
}) {
|
||||
const media = getAcademyMedia(item);
|
||||
if (!media || !item.courseId) return null;
|
||||
|
||||
const aspect = hashAspect(item._id);
|
||||
const displayName =
|
||||
[item.first_name, item.last_name].filter(Boolean).join(" ") ||
|
||||
item.teacher_name ||
|
||||
item.user_name ||
|
||||
"آموزشگاه";
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set("type", "academy");
|
||||
params.set("filter", activeFilter);
|
||||
if (coords) {
|
||||
params.set("lat", String(coords.lat));
|
||||
params.set("lng", String(coords.lng));
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="mb-2 break-inside-avoid">
|
||||
<Link
|
||||
href={`/explore/${item._id}?${params.toString()}`}
|
||||
className="gentle-transition block active:scale-[0.98]"
|
||||
>
|
||||
<div
|
||||
className={`relative w-full overflow-hidden rounded-lg bg-neutral-900 ${aspect}`}
|
||||
>
|
||||
{media.kind === "video" ? (
|
||||
<video
|
||||
src={media.src}
|
||||
preload="metadata"
|
||||
muted
|
||||
playsInline
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={media.src}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="33vw"
|
||||
/>
|
||||
)}
|
||||
<span className="absolute left-2 top-2 rounded-md bg-emerald-600/90 px-1.5 py-0.5 text-[9px] font-medium text-white backdrop-blur-sm">
|
||||
رایگان
|
||||
</span>
|
||||
{media.kind === "video" && (
|
||||
<span className="absolute bottom-2 left-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/55 text-[10px] text-white backdrop-blur-sm">
|
||||
▶
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 px-0.5 py-1.5">
|
||||
<ExploreAuthor
|
||||
userId={item.user_id || ""}
|
||||
userName={item.user_name}
|
||||
displayName={displayName}
|
||||
profileImage={item.profile_image}
|
||||
/>
|
||||
<span className="flex shrink-0 items-center gap-1 text-[10px] text-neutral-500">
|
||||
<BoldIcon name="heart" size={14} tinted className="text-[#FC8EAC]" />
|
||||
{item.likesCount ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
48
src/components/explore/ExploreAuthor.tsx
Normal file
48
src/components/explore/ExploreAuthor.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import ProfileAvatar from "@/components/main/ProfileAvatar";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import { User } from "@/types/types";
|
||||
|
||||
type ExploreAuthorProps = {
|
||||
userId: string;
|
||||
userName?: string;
|
||||
displayName: string;
|
||||
profileImage?: string;
|
||||
};
|
||||
|
||||
export default function ExploreAuthor({
|
||||
userId,
|
||||
userName,
|
||||
displayName,
|
||||
profileImage,
|
||||
}: ExploreAuthorProps) {
|
||||
const { request } = useAxios();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["explore-author", userId],
|
||||
queryFn: () =>
|
||||
request<{ user: User }>("GET", `/profile/${userId}`, null, {
|
||||
noToast: true,
|
||||
}),
|
||||
enabled: Boolean(userId) && !profileImage,
|
||||
staleTime: 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
const avatarSrc = profileImage || data?.user?.profile_image;
|
||||
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<ProfileAvatar
|
||||
src={avatarSrc}
|
||||
alt={userName || displayName}
|
||||
size="xxs"
|
||||
rounded="full"
|
||||
/>
|
||||
<span className="truncate text-[10px] font-medium text-neutral-600 dark:text-neutral-300">
|
||||
{userName ? `@${userName}` : displayName}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
46
src/components/explore/ExploreFilterBar.tsx
Normal file
46
src/components/explore/ExploreFilterBar.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
EXPLORE_FILTERS,
|
||||
ExploreFilterId,
|
||||
} from "@/constants/exploreFilters";
|
||||
|
||||
type ExploreFilterBarProps = {
|
||||
activeFilter: ExploreFilterId;
|
||||
onFilterChange: (filter: ExploreFilterId) => void;
|
||||
loadingLocation?: boolean;
|
||||
};
|
||||
|
||||
export default function ExploreFilterBar({
|
||||
activeFilter,
|
||||
onFilterChange,
|
||||
loadingLocation = false,
|
||||
}: ExploreFilterBarProps) {
|
||||
return (
|
||||
<div className="sticky top-0 z-20 -mx-2 mb-3 bg-background/95 px-2 pb-2 pt-1 backdrop-blur-sm">
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-none">
|
||||
{EXPLORE_FILTERS.map((item) => {
|
||||
const isActive = activeFilter === item.id;
|
||||
const isNearMeLoading = item.id === "near_me" && loadingLocation;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
disabled={isNearMeLoading}
|
||||
onClick={() => onFilterChange(item.id)}
|
||||
className="shrink-0 rounded-full border px-4 py-1.5 text-xs font-medium transition-colors disabled:opacity-60"
|
||||
style={{
|
||||
borderColor: item.color,
|
||||
backgroundColor: isActive ? item.color : "transparent",
|
||||
color: isActive ? "#fff" : undefined,
|
||||
}}
|
||||
>
|
||||
{isNearMeLoading ? "در حال دریافت موقعیت..." : item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,28 +4,238 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { fetchPosts } from "@/api/fetchPosts";
|
||||
import {
|
||||
AcademyExploreItem,
|
||||
fetchAcademyExplore,
|
||||
} from "@/api/fetchAcademyExplore";
|
||||
import { Post } from "@/types/types";
|
||||
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import Cookies from "js-cookie";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import ExploreAuthor from "@/components/explore/ExploreAuthor";
|
||||
import ExploreAcademyCard from "@/components/explore/ExploreAcademyCard";
|
||||
import { ExploreFilterId } from "@/constants/exploreFilters";
|
||||
import { buildExplorePostFilters } from "@/lib/explore/buildPostFilters";
|
||||
|
||||
function getThumb(post: Post) {
|
||||
const file = post.files?.[0];
|
||||
if (!file?.path) return null;
|
||||
return `${IMAGE_BASE_URL}${file.path.replace("/root/modstagram-back/storage", "")}`;
|
||||
const ASPECT_RATIOS = [
|
||||
"aspect-[2/3]",
|
||||
"aspect-[3/4]",
|
||||
"aspect-[4/5]",
|
||||
"aspect-[5/6]",
|
||||
"aspect-square",
|
||||
"aspect-[3/5]",
|
||||
] as const;
|
||||
|
||||
type ExploreFeedItem =
|
||||
| { kind: "post"; id: string; sortAt: number; post: Post }
|
||||
| { kind: "academy"; id: string; sortAt: number; academy: AcademyExploreItem };
|
||||
|
||||
type ExploreGridProps = {
|
||||
activeFilter: ExploreFilterId;
|
||||
coords?: { lat: number; lng: number } | null;
|
||||
};
|
||||
|
||||
function hashAspect(postId: string): (typeof ASPECT_RATIOS)[number] {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < postId.length; i++) {
|
||||
hash = (hash + postId.charCodeAt(i)) % ASPECT_RATIOS.length;
|
||||
}
|
||||
return ASPECT_RATIOS[hash];
|
||||
}
|
||||
|
||||
export default function ExploreGrid() {
|
||||
function getPostMedia(post: Post): {
|
||||
kind: "video" | "image";
|
||||
src: string;
|
||||
} | null {
|
||||
const file = post.files?.[0];
|
||||
if (!file?.path) return null;
|
||||
|
||||
const src = buildStorageUrl(file.path);
|
||||
const isVideo = post.type === "video" || file.type === "video";
|
||||
|
||||
return { kind: isVideo ? "video" : "image", src };
|
||||
}
|
||||
|
||||
function toSortTime(value?: string): number {
|
||||
if (!value) return 0;
|
||||
const time = Date.parse(value);
|
||||
return Number.isNaN(time) ? 0 : time;
|
||||
}
|
||||
|
||||
function interleaveAcademyGrid<T extends { sortAt: number }>(
|
||||
posts: T[],
|
||||
academy: T[],
|
||||
page: number
|
||||
): T[] {
|
||||
if (!academy.length) return posts;
|
||||
|
||||
const result: T[] = [];
|
||||
let academyIdx = 0;
|
||||
const interval = 4;
|
||||
|
||||
posts.forEach((post, index) => {
|
||||
result.push(post);
|
||||
const globalIndex = (page - 1) * posts.length + index;
|
||||
if ((globalIndex + 1) % interval === 0 && academyIdx < academy.length) {
|
||||
result.push(academy[academyIdx]);
|
||||
academyIdx += 1;
|
||||
}
|
||||
});
|
||||
|
||||
while (academyIdx < academy.length) {
|
||||
result.push(academy[academyIdx]);
|
||||
academyIdx += 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function ExploreCard({
|
||||
post,
|
||||
activeFilter,
|
||||
coords,
|
||||
}: {
|
||||
post: Post;
|
||||
activeFilter: ExploreFilterId;
|
||||
coords?: { lat: number; lng: number } | null;
|
||||
}) {
|
||||
const media = getPostMedia(post);
|
||||
if (!media) return null;
|
||||
|
||||
const aspect = hashAspect(post._id);
|
||||
const displayName =
|
||||
[post.first_name, post.last_name].filter(Boolean).join(" ") ||
|
||||
post.user_name ||
|
||||
"کاربر";
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set("filter", activeFilter);
|
||||
if (coords) {
|
||||
params.set("lat", String(coords.lat));
|
||||
params.set("lng", String(coords.lng));
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="mb-2 break-inside-avoid">
|
||||
<Link
|
||||
href={`/explore/${post._id}?${params.toString()}`}
|
||||
className="gentle-transition block active:scale-[0.98]"
|
||||
>
|
||||
<div
|
||||
className={`relative w-full overflow-hidden rounded-lg bg-neutral-900 ${aspect}`}
|
||||
>
|
||||
{media.kind === "video" ? (
|
||||
<video
|
||||
src={media.src}
|
||||
preload="metadata"
|
||||
muted
|
||||
playsInline
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={media.src}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="33vw"
|
||||
/>
|
||||
)}
|
||||
{media.kind === "video" && (
|
||||
<span className="absolute bottom-2 left-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/55 text-[10px] text-white backdrop-blur-sm">
|
||||
▶
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 px-0.5 py-1.5">
|
||||
<ExploreAuthor
|
||||
userId={post.user_id}
|
||||
userName={post.user_name}
|
||||
displayName={displayName}
|
||||
profileImage={post.profile_image}
|
||||
/>
|
||||
<span className="flex shrink-0 items-center gap-1 text-[10px] text-neutral-500">
|
||||
<BoldIcon name="heart" size={14} tinted className="text-[#FC8EAC]" />
|
||||
{post.likesCount ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_MESSAGES: Partial<Record<ExploreFilterId, string>> = {
|
||||
academy: "آموزش رایگانی برای نمایش وجود ندارد.",
|
||||
near_me: "پستی در فاصله ۲ کیلومتری شما یافت نشد.",
|
||||
makeup: "پست میکاپی برای نمایش وجود ندارد.",
|
||||
trending: "پست ترندی برای نمایش وجود ندارد.",
|
||||
best_month: "پست برتر این ماه یافت نشد.",
|
||||
};
|
||||
|
||||
export default function ExploreGrid({
|
||||
activeFilter,
|
||||
coords = null,
|
||||
}: ExploreGridProps) {
|
||||
const token = Cookies.get("token") || "";
|
||||
const postFilters = buildExplorePostFilters(activeFilter, coords);
|
||||
const academyOnly = activeFilter === "academy";
|
||||
const includeAcademy = activeFilter === "all" || academyOnly;
|
||||
const needsLocation = activeFilter === "near_me" && !coords;
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["explore-videos"],
|
||||
queryFn: ({ pageParam = 1 }) =>
|
||||
fetchPosts(pageParam as number, 12, { type: "video" }, token),
|
||||
getNextPageParam: (lastPage, allPages) =>
|
||||
lastPage.posts.length > 0 ? allPages.length + 1 : undefined,
|
||||
queryKey: [
|
||||
"explore-feed",
|
||||
activeFilter,
|
||||
coords?.lat,
|
||||
coords?.lng,
|
||||
postFilters,
|
||||
],
|
||||
enabled: !needsLocation,
|
||||
queryFn: async ({ pageParam = 1 }) => {
|
||||
const page = pageParam as number;
|
||||
|
||||
if (academyOnly) {
|
||||
const academyResult = await fetchAcademyExplore(page, 15, token);
|
||||
return {
|
||||
page,
|
||||
posts: [],
|
||||
postsHasMore: false,
|
||||
academyItems: academyResult.items,
|
||||
academyHasMore: academyResult.hasNextPage,
|
||||
};
|
||||
}
|
||||
|
||||
const postsPromise = fetchPosts(
|
||||
page,
|
||||
15,
|
||||
{ ...postFilters, feedMode: "grid" },
|
||||
token
|
||||
);
|
||||
const academyPromise = includeAcademy
|
||||
? fetchAcademyExplore(page, 15, token)
|
||||
: Promise.resolve({ items: [], hasNextPage: false });
|
||||
|
||||
const [postsResult, academyResult] = await Promise.all([
|
||||
postsPromise,
|
||||
academyPromise,
|
||||
]);
|
||||
|
||||
return {
|
||||
page,
|
||||
posts: postsResult.posts ?? [],
|
||||
postsHasMore: (postsResult.posts?.length ?? 0) > 0,
|
||||
academyItems: academyResult.items,
|
||||
academyHasMore: academyResult.hasNextPage,
|
||||
};
|
||||
},
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
const hasMore = lastPage.postsHasMore || lastPage.academyHasMore;
|
||||
return hasMore ? allPages.length + 1 : undefined;
|
||||
},
|
||||
initialPageParam: 1,
|
||||
});
|
||||
|
||||
@@ -43,39 +253,107 @@ export default function ExploreGrid() {
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, [handleScroll]);
|
||||
|
||||
const videos =
|
||||
data?.pages.flatMap((p) => p.posts).filter((p) => p.status === "accept") ?? [];
|
||||
const items = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const merged: ExploreFeedItem[] = [];
|
||||
|
||||
for (const page of data?.pages ?? []) {
|
||||
const pagePosts: ExploreFeedItem[] = [];
|
||||
const pageAcademy: ExploreFeedItem[] = [];
|
||||
|
||||
for (const post of page.posts) {
|
||||
if (post.status !== "accept" || !getPostMedia(post)) continue;
|
||||
const id = `post-${post._id}`;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
pagePosts.push({
|
||||
kind: "post",
|
||||
id,
|
||||
sortAt: toSortTime(post.createdAt),
|
||||
post,
|
||||
});
|
||||
}
|
||||
|
||||
if (includeAcademy) {
|
||||
for (const academy of page.academyItems) {
|
||||
if (academy.status && academy.status !== "accept") continue;
|
||||
const id = `academy-${academy._id}`;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
pageAcademy.push({
|
||||
kind: "academy",
|
||||
id,
|
||||
sortAt: toSortTime(academy.createdAt),
|
||||
academy,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const orderedPageItems =
|
||||
activeFilter === "all"
|
||||
? interleaveAcademyGrid(pagePosts, pageAcademy, page.page)
|
||||
: activeFilter === "trending" || activeFilter === "best_month"
|
||||
? [...pagePosts, ...pageAcademy].sort((a, b) => {
|
||||
const scoreA =
|
||||
a.kind === "post"
|
||||
? (a.post.likesCount ?? 0) + (a.post.commentsCount ?? 0)
|
||||
: a.academy.likesCount ?? 0;
|
||||
const scoreB =
|
||||
b.kind === "post"
|
||||
? (b.post.likesCount ?? 0) + (b.post.commentsCount ?? 0)
|
||||
: b.academy.likesCount ?? 0;
|
||||
return scoreB - scoreA;
|
||||
})
|
||||
: [...pagePosts, ...pageAcademy];
|
||||
|
||||
merged.push(...orderedPageItems);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}, [data, activeFilter, includeAcademy]);
|
||||
|
||||
if (needsLocation) {
|
||||
return (
|
||||
<p className="py-16 text-center text-sm text-neutral-500">
|
||||
برای مشاهده پستهای نزدیک، روی «نزدیک من» بزنید و دسترسی موقعیت را
|
||||
فعال کنید.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) return <PageLoader className="min-h-[40vh]" />;
|
||||
|
||||
if (!videos.length) {
|
||||
if (!items.length) {
|
||||
return (
|
||||
<p className="py-16 text-center text-sm text-neutral-500">
|
||||
ویدئویی برای نمایش وجود ندارد.
|
||||
{EMPTY_MESSAGES[activeFilter] ?? "محتوایی برای نمایش وجود ندارد."}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-1.5 p-2 sm:gap-2 sm:p-3">
|
||||
{videos.map((post) => {
|
||||
const src = getThumb(post);
|
||||
if (!src) return null;
|
||||
return (
|
||||
<Link
|
||||
key={post._id}
|
||||
href={`/explore/${post._id}`}
|
||||
className="gentle-transition relative aspect-[3/4] overflow-hidden rounded-2xl active:scale-[0.98]"
|
||||
>
|
||||
<Image src={src} alt="" fill className="object-cover" sizes="33vw" />
|
||||
<span className="absolute bottom-2 left-2 rounded-full bg-black/50 px-2 py-0.5 text-[10px] text-white backdrop-blur-sm">
|
||||
▶
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<div className="px-2 pb-2">
|
||||
<div className="columns-3 gap-2">
|
||||
{items.map((item) =>
|
||||
item.kind === "post" ? (
|
||||
<ExploreCard
|
||||
key={item.id}
|
||||
post={item.post}
|
||||
activeFilter={activeFilter}
|
||||
coords={coords}
|
||||
/>
|
||||
) : (
|
||||
<ExploreAcademyCard
|
||||
key={item.id}
|
||||
item={item.academy}
|
||||
activeFilter={activeFilter}
|
||||
coords={coords}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{isFetchingNextPage && (
|
||||
<div className="col-span-3 py-4">
|
||||
<div className="py-4">
|
||||
<PageLoader className="min-h-[80px]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
312
src/components/explore/ExploreReelsView.tsx
Normal file
312
src/components/explore/ExploreReelsView.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import Cookies from "js-cookie";
|
||||
import { fetchPosts } from "@/api/fetchPosts";
|
||||
import {
|
||||
AcademyExploreItem,
|
||||
fetchAcademyExplore,
|
||||
} from "@/api/fetchAcademyExplore";
|
||||
import { trackExploreInteraction } from "@/api/trackExploreInteraction";
|
||||
import { Post } from "@/types/types";
|
||||
import { MutedProvider } from "@/components/models/ModelPage/MainModelCardPost";
|
||||
import ReelsPostCard from "@/components/posts/ReelsPostCard";
|
||||
import ReelsAcademyCard from "@/components/explore/ReelsAcademyCard";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
import { ExploreFilterId } from "@/constants/exploreFilters";
|
||||
import { buildExplorePostFilters } from "@/lib/explore/buildPostFilters";
|
||||
|
||||
export type ExploreReelItem =
|
||||
| { kind: "post"; key: string; post: Post }
|
||||
| { kind: "academy"; key: string; academy: AcademyExploreItem };
|
||||
|
||||
type ExploreReelsViewProps = {
|
||||
initialId: string;
|
||||
initialType?: "post" | "academy";
|
||||
showClose?: boolean;
|
||||
};
|
||||
|
||||
function interleaveAcademy(
|
||||
posts: ExploreReelItem[],
|
||||
academy: ExploreReelItem[],
|
||||
page: number
|
||||
): ExploreReelItem[] {
|
||||
if (!academy.length) return posts;
|
||||
const result: ExploreReelItem[] = [];
|
||||
let academyIdx = 0;
|
||||
const interval = 5;
|
||||
|
||||
posts.forEach((post, index) => {
|
||||
result.push(post);
|
||||
const globalIndex = (page - 1) * posts.length + index;
|
||||
if ((globalIndex + 1) % interval === 0 && academyIdx < academy.length) {
|
||||
result.push(academy[academyIdx]);
|
||||
academyIdx += 1;
|
||||
}
|
||||
});
|
||||
|
||||
while (academyIdx < academy.length) {
|
||||
result.push(academy[academyIdx]);
|
||||
academyIdx += 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default function ExploreReelsView({
|
||||
initialId,
|
||||
initialType = "post",
|
||||
showClose = true,
|
||||
}: ExploreReelsViewProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const token = Cookies.get("token") || "";
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const scrolledRef = useRef(false);
|
||||
|
||||
const filterParam = (searchParams.get("filter") ||
|
||||
"all") as ExploreFilterId;
|
||||
const lat = searchParams.get("lat");
|
||||
const lng = searchParams.get("lng");
|
||||
const coords =
|
||||
lat && lng ? { lat: parseFloat(lat), lng: parseFloat(lng) } : null;
|
||||
|
||||
const postFilters = buildExplorePostFilters(filterParam, coords);
|
||||
const includeAcademy =
|
||||
filterParam === "all" || filterParam === "academy";
|
||||
const academyOnly = filterParam === "academy";
|
||||
|
||||
const initialKey =
|
||||
initialType === "academy" ? `academy-${initialId}` : `post-${initialId}`;
|
||||
const [activeKey, setActiveKey] = useState(initialKey);
|
||||
|
||||
const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: [
|
||||
"explore-reels",
|
||||
filterParam,
|
||||
coords?.lat,
|
||||
coords?.lng,
|
||||
postFilters,
|
||||
academyOnly,
|
||||
],
|
||||
enabled: !(filterParam === "near_me" && !coords),
|
||||
queryFn: async ({ pageParam = 1 }) => {
|
||||
const page = pageParam as number;
|
||||
|
||||
if (academyOnly) {
|
||||
const academyResult = await fetchAcademyExplore(page, 10, token);
|
||||
return {
|
||||
page,
|
||||
posts: [] as Post[],
|
||||
academyItems: academyResult.items,
|
||||
hasMore: academyResult.hasNextPage,
|
||||
};
|
||||
}
|
||||
|
||||
const [postsResult, academyResult] = await Promise.all([
|
||||
fetchPosts(
|
||||
page,
|
||||
10,
|
||||
{
|
||||
...postFilters,
|
||||
feedMode: "reels",
|
||||
seedPostId: page === 1 ? initialId : undefined,
|
||||
},
|
||||
token
|
||||
),
|
||||
includeAcademy
|
||||
? fetchAcademyExplore(page, 10, token)
|
||||
: Promise.resolve({ items: [], hasNextPage: false }),
|
||||
]);
|
||||
|
||||
return {
|
||||
page,
|
||||
posts: postsResult.posts ?? [],
|
||||
academyItems: academyResult.items,
|
||||
hasMore:
|
||||
(postsResult.posts?.length ?? 0) > 0 ||
|
||||
academyResult.hasNextPage,
|
||||
};
|
||||
},
|
||||
getNextPageParam: (lastPage, pages) =>
|
||||
lastPage.hasMore ? pages.length + 1 : undefined,
|
||||
initialPageParam: 1,
|
||||
});
|
||||
|
||||
const items: ExploreReelItem[] = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const merged: ExploreReelItem[] = [];
|
||||
|
||||
for (const page of data?.pages ?? []) {
|
||||
const pagePosts: ExploreReelItem[] = [];
|
||||
const pageAcademy: ExploreReelItem[] = [];
|
||||
|
||||
for (const post of page.posts) {
|
||||
if (post.status !== "accept") continue;
|
||||
const key = `post-${post._id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
pagePosts.push({ kind: "post", key, post });
|
||||
}
|
||||
|
||||
if (includeAcademy) {
|
||||
for (const academy of page.academyItems) {
|
||||
if (academy.status && academy.status !== "accept") continue;
|
||||
const key = `academy-${academy._id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
pageAcademy.push({ kind: "academy", key, academy });
|
||||
}
|
||||
}
|
||||
|
||||
const pageItems =
|
||||
filterParam === "all" || filterParam === "academy"
|
||||
? interleaveAcademy(pagePosts, pageAcademy, page.page)
|
||||
: [...pagePosts, ...pageAcademy];
|
||||
|
||||
merged.push(...pageItems);
|
||||
}
|
||||
|
||||
if (filterParam === "trending" || filterParam === "best_month") {
|
||||
return merged.sort((a, b) => {
|
||||
const scoreA =
|
||||
a.kind === "post"
|
||||
? (a.post.likesCount ?? 0) + (a.post.commentsCount ?? 0)
|
||||
: a.academy.likesCount ?? 0;
|
||||
const scoreB =
|
||||
b.kind === "post"
|
||||
? (b.post.likesCount ?? 0) + (b.post.commentsCount ?? 0)
|
||||
: b.academy.likesCount ?? 0;
|
||||
return scoreB - scoreA;
|
||||
});
|
||||
}
|
||||
|
||||
return merged;
|
||||
}, [data, filterParam, includeAcademy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrolledRef.current || !items.length || !scrollRef.current) return;
|
||||
const idx = items.findIndex((item) => item.key === initialKey);
|
||||
const targetIdx = idx >= 0 ? idx : 0;
|
||||
scrollRef.current.scrollTo({
|
||||
top: targetIdx * window.innerHeight,
|
||||
behavior: "auto",
|
||||
});
|
||||
scrolledRef.current = true;
|
||||
setActiveKey(items[targetIdx]?.key || initialKey);
|
||||
}, [items, initialKey]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || !items.length) return;
|
||||
|
||||
const index = Math.round(el.scrollTop / window.innerHeight);
|
||||
const item = items[index];
|
||||
if (item && item.key !== activeKey) {
|
||||
setActiveKey(item.key);
|
||||
}
|
||||
|
||||
if (
|
||||
el.scrollTop + el.clientHeight >=
|
||||
el.scrollHeight - window.innerHeight * 0.5 &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [items, activeKey, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", handleScroll);
|
||||
}, [handleScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
const item = items.find((i) => i.key === activeKey);
|
||||
if (!item || item.kind !== "academy") return;
|
||||
|
||||
trackExploreInteraction(
|
||||
{
|
||||
targetType: "academy",
|
||||
targetId: item.academy._id,
|
||||
authorId: item.academy.user_id,
|
||||
contentType: "academy",
|
||||
action: "view",
|
||||
},
|
||||
token
|
||||
);
|
||||
}, [activeKey, items, token]);
|
||||
|
||||
if (filterParam === "near_me" && !coords) {
|
||||
return (
|
||||
<p className="py-20 text-center text-neutral-500">
|
||||
موقعیت مکانی برای اکسپلور نزدیک من یافت نشد.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader className="min-h-[100dvh]" />;
|
||||
}
|
||||
|
||||
if (isError || !items.length) {
|
||||
return (
|
||||
<p className="py-20 text-center text-neutral-500">محتوایی یافت نشد.</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MutedProvider>
|
||||
<div className="relative min-h-[100dvh] bg-black">
|
||||
{showClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="gentle-transition fixed left-4 top-[calc(1rem+env(safe-area-inset-top))] z-[100] flex h-10 w-10 items-center justify-center rounded-full bg-black/40 text-white backdrop-blur-md active:scale-90"
|
||||
aria-label="بستن"
|
||||
>
|
||||
<BoldIcon name="close-circle" size={22} tinted className="text-white" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="h-[100dvh] snap-y snap-mandatory overflow-y-auto overscroll-y-contain scroll-smooth"
|
||||
>
|
||||
{items.map((item) =>
|
||||
item.kind === "post" ? (
|
||||
<ReelsPostCard
|
||||
key={item.key}
|
||||
postData={item.post}
|
||||
isActive={item.key === activeKey}
|
||||
/>
|
||||
) : (
|
||||
<ReelsAcademyCard
|
||||
key={item.key}
|
||||
item={item.academy}
|
||||
isActive={item.key === activeKey}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex h-24 snap-start items-center justify-center">
|
||||
<PageLoader className="min-h-[80px]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</MutedProvider>
|
||||
);
|
||||
}
|
||||
28
src/components/explore/ProfileVisitTracker.tsx
Normal file
28
src/components/explore/ProfileVisitTracker.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import { trackExploreInteraction } from "@/api/trackExploreInteraction";
|
||||
|
||||
export default function ProfileVisitTracker({
|
||||
profileUserId,
|
||||
}: {
|
||||
profileUserId: string;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const token = Cookies.get("token") || "";
|
||||
if (!token || !profileUserId) return;
|
||||
|
||||
trackExploreInteraction(
|
||||
{
|
||||
targetType: "profile",
|
||||
targetId: profileUserId,
|
||||
authorId: profileUserId,
|
||||
action: "profile_visit",
|
||||
},
|
||||
token
|
||||
);
|
||||
}, [profileUserId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
243
src/components/explore/ReelsAcademyCard.tsx
Normal file
243
src/components/explore/ReelsAcademyCard.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useEffect, useContext, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { AcademyExploreItem } from "@/api/fetchAcademyExplore";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import SharePostModal from "@/components/main/SharePostModal";
|
||||
import { MutedContext } from "@/components/models/ModelPage/MainModelCardPost";
|
||||
import { ReelsCollaborationButton } from "@/components/posts/ReelsPostActions";
|
||||
|
||||
function getAcademyMedia(item: AcademyExploreItem): {
|
||||
kind: "video" | "image";
|
||||
src: string;
|
||||
} | null {
|
||||
const file = item.files?.[0];
|
||||
if (file?.path) {
|
||||
const src = buildStorageUrl(file.path);
|
||||
const isVideo = item.type === "video" || file.type === "video";
|
||||
return { kind: isVideo ? "video" : "image", src };
|
||||
}
|
||||
if (item.course_video) {
|
||||
return { kind: "video", src: buildStorageUrl(item.course_video) };
|
||||
}
|
||||
const imagePath = item.course_images?.[0] || item.course_image;
|
||||
if (imagePath) {
|
||||
return { kind: "image", src: buildStorageUrl(imagePath) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ReelsAcademyActions({
|
||||
item,
|
||||
shareUrl,
|
||||
}: {
|
||||
item: AcademyExploreItem;
|
||||
shareUrl: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
const actionButtonClass =
|
||||
"flex flex-col items-center gap-1 text-white drop-shadow-md";
|
||||
|
||||
return (
|
||||
<>
|
||||
<SharePostModal
|
||||
open={showShareModal}
|
||||
onClose={() => setShowShareModal(false)}
|
||||
url={shareUrl}
|
||||
/>
|
||||
<div className="absolute left-3 bottom-36 z-30 flex flex-col items-center">
|
||||
<AnimatePresence mode="wait">
|
||||
{!expanded ? (
|
||||
<motion.button
|
||||
key="trigger"
|
||||
type="button"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={() => setExpanded(true)}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full bg-black/40 backdrop-blur-md"
|
||||
aria-label="نمایش عملیات"
|
||||
>
|
||||
<BoldIcon name="grid-3" size={24} className="block dark:invert" />
|
||||
</motion.button>
|
||||
) : (
|
||||
<motion.div
|
||||
key="actions"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 12 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="flex flex-col items-center gap-5"
|
||||
>
|
||||
<div className={actionButtonClass}>
|
||||
<Image
|
||||
width={28}
|
||||
height={28}
|
||||
alt="heart"
|
||||
src="/images/icons/heart.svg"
|
||||
className="invert drop-shadow-lg"
|
||||
/>
|
||||
<span className="text-xs font-semibold">
|
||||
{item.likesCount ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => shareUrl && setShowShareModal(true)}
|
||||
className={actionButtonClass}
|
||||
aria-label="اشتراکگذاری"
|
||||
>
|
||||
<Image
|
||||
width={28}
|
||||
height={28}
|
||||
alt="share"
|
||||
src="/images/icons/share.svg"
|
||||
className="invert drop-shadow-lg"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(false)}
|
||||
className="mt-1 flex h-9 w-9 items-center justify-center rounded-full bg-black/40 backdrop-blur-md"
|
||||
aria-label="بستن"
|
||||
>
|
||||
<BoldIcon name="close-circle" size={20} tinted className="text-white" />
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReelsAcademyCard({
|
||||
item,
|
||||
isActive = false,
|
||||
}: {
|
||||
item: AcademyExploreItem;
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const media = getAcademyMedia(item);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const { muted, setMuted } = useContext(MutedContext);
|
||||
const [videoMuted, setVideoMuted] = useState(true);
|
||||
|
||||
const displayName =
|
||||
[item.first_name, item.last_name].filter(Boolean).join(" ") ||
|
||||
item.teacher_name ||
|
||||
item.user_name ||
|
||||
"آموزشگاه";
|
||||
|
||||
const shareUrl = item.courseId
|
||||
? `https://modstagram.com/academy/${item.courseId}/course`
|
||||
: "";
|
||||
|
||||
const collaborationPost = {
|
||||
_id: item._id,
|
||||
user_id: item.user_id || "",
|
||||
user_name: item.user_name || "",
|
||||
first_name: item.first_name || "",
|
||||
last_name: item.last_name || "",
|
||||
likesCount: item.likesCount ?? 0,
|
||||
is_liked: false,
|
||||
commentsCount: 0,
|
||||
files: [],
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || media?.kind !== "video") return;
|
||||
if (isActive) {
|
||||
video.play().catch(() => {});
|
||||
video.muted = muted;
|
||||
setVideoMuted(muted);
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}, [isActive, muted, media?.kind]);
|
||||
|
||||
if (!media) return null;
|
||||
|
||||
return (
|
||||
<article className="relative h-[100dvh] w-full snap-start snap-always overflow-hidden bg-black">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{media.kind === "video" ? (
|
||||
<>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={media.src}
|
||||
loop
|
||||
playsInline
|
||||
className="h-full w-full object-contain"
|
||||
muted={muted}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
v.muted = !v.muted;
|
||||
setVideoMuted(v.muted);
|
||||
setMuted(v.muted);
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute right-3 top-[calc(3.5rem+env(safe-area-inset-top))] z-20 rounded-full bg-black/50 p-2 backdrop-blur-md">
|
||||
{videoMuted ? (
|
||||
<BoldIcon name="volume-slash" size={20} tinted className="text-white" />
|
||||
) : (
|
||||
<BoldIcon name="volume-high" size={20} tinted className="text-white" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Image src={media.src} alt="" fill className="object-contain" priority={isActive} />
|
||||
)}
|
||||
<span className="absolute right-3 top-[calc(3.5rem+env(safe-area-inset-top))] z-10 rounded-md bg-emerald-600/90 px-2 py-1 text-[10px] font-medium text-white">
|
||||
آموزش رایگان
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ReelsAcademyActions item={item} shareUrl={shareUrl} />
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/85 via-black/50 to-transparent px-4 pb-[calc(1.25rem+env(safe-area-inset-bottom))] pt-16">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href={item.user_name ? `/users/${item.user_name}` : "#"}
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5"
|
||||
>
|
||||
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-full bg-neutral-700 ring-2 ring-white/30">
|
||||
{item.profile_image ? (
|
||||
<Image
|
||||
src={buildStorageUrl(item.profile_image)}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-white">
|
||||
<div className="truncate text-sm font-semibold">
|
||||
{item.user_name || displayName}
|
||||
</div>
|
||||
<p className="truncate text-xs text-white/80">{displayName}</p>
|
||||
</div>
|
||||
</Link>
|
||||
{item.user_id ? (
|
||||
<ReelsCollaborationButton postData={collaborationPost as never} />
|
||||
) : null}
|
||||
</div>
|
||||
{(item.caption || item.course_name) && (
|
||||
<p className="mt-2 line-clamp-2 text-sm leading-relaxed text-white/95">
|
||||
{item.caption || item.course_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,42 @@
|
||||
// لوکال: NEXT_PUBLIC_BASE_URL=http://localhost:3002/api/v1
|
||||
// export const SOCKET_URL = "http://localhost:8000";
|
||||
// export const BASE_URL = "http://localhost:8000/api/v1";
|
||||
// export const IMAGE_BASE_URL = "http://localhost:8000/storage";
|
||||
const REMOTE_API = "https://api.modstagram.ir";
|
||||
const DEV_PORT = process.env.PORT ?? "3004";
|
||||
|
||||
/** آدرس API — در مرورگر همیشه relative (پروکسی Next) */
|
||||
export function getApiBaseUrl(): string {
|
||||
if (typeof window !== "undefined") {
|
||||
return process.env.NEXT_PUBLIC_BASE_URL || "/api/v1";
|
||||
}
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
return (
|
||||
process.env.SERVER_API_BASE_URL ||
|
||||
`http://127.0.0.1:${DEV_PORT}/api/v1`
|
||||
);
|
||||
}
|
||||
return process.env.NEXT_PUBLIC_BASE_URL || `${REMOTE_API}/api/v1`;
|
||||
}
|
||||
|
||||
/** آدرس storage — در مرورگر relative */
|
||||
export function getStorageBaseUrl(): string {
|
||||
if (typeof window !== "undefined") {
|
||||
return process.env.NEXT_PUBLIC_IMAGE_BASE_URL || "/storage";
|
||||
}
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
return (
|
||||
process.env.SERVER_STORAGE_BASE_URL ||
|
||||
`http://127.0.0.1:${DEV_PORT}/storage`
|
||||
);
|
||||
}
|
||||
return process.env.NEXT_PUBLIC_IMAGE_BASE_URL || `${REMOTE_API}/storage`;
|
||||
}
|
||||
|
||||
/** app.modstagram.ir — SSL معتبر برای fetch سمت سرور Next.js */
|
||||
export const SOCKET_URL =
|
||||
process.env.NEXT_PUBLIC_SOCKET_URL ?? "https://app.modstagram.ir";
|
||||
export const BASE_URL =
|
||||
process.env.NEXT_PUBLIC_BASE_URL ?? "https://app.modstagram.ir/api/v1";
|
||||
export const IMAGE_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_IMAGE_BASE_URL ?? "https://app.modstagram.ir/storage";
|
||||
process.env.NEXT_PUBLIC_SOCKET_URL ?? REMOTE_API;
|
||||
|
||||
/** @deprecated از getApiBaseUrl() استفاده کنید */
|
||||
export const BASE_URL = getApiBaseUrl();
|
||||
|
||||
/** @deprecated از getStorageBaseUrl() استفاده کنید */
|
||||
export const IMAGE_BASE_URL = getStorageBaseUrl();
|
||||
|
||||
/** ساخت URL کامل فایل storage — مسیرهای DB را یکسان میکند */
|
||||
export function buildStorageUrl(path: string | null | undefined): string {
|
||||
@@ -26,5 +53,5 @@ export function buildStorageUrl(path: string | null | undefined): string {
|
||||
}
|
||||
|
||||
if (!normalized.startsWith("/")) normalized = `/${normalized}`;
|
||||
return `${IMAGE_BASE_URL.replace(/\/$/, "")}${normalized}`;
|
||||
return `${getStorageBaseUrl().replace(/\/$/, "")}${normalized}`;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cn } from "@/lib/utils";
|
||||
import { buildStorageUrl } from "./BaseUrl";
|
||||
|
||||
const sizeMap = {
|
||||
xxs: "h-5 w-5",
|
||||
xs: "h-8 w-8",
|
||||
sm: "h-10 w-10",
|
||||
chat: "h-14 w-14",
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import MainModelCard from "@/components/models/MainModelCard/MainModelCard";
|
||||
import { fetchPosts } from "@/api/fetchPosts";
|
||||
import { Post } from "@/types/types";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cacheReelsSeedPost } from "@/lib/reelsSeedPost";
|
||||
|
||||
interface InfinitePostsProps {
|
||||
token: string;
|
||||
@@ -32,6 +34,21 @@ export default function InfinitePosts({
|
||||
token,
|
||||
typeFilter,
|
||||
}: InfinitePostsProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const openPostInReels = useCallback(
|
||||
(post: Post) => {
|
||||
cacheReelsSeedPost(post);
|
||||
const params = new URLSearchParams();
|
||||
params.set("feed", "home");
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value) params.set(key, String(value));
|
||||
});
|
||||
router.push(`/posts/${post._id}?${params.toString()}`);
|
||||
},
|
||||
[filters, router]
|
||||
);
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
@@ -43,7 +60,12 @@ export default function InfinitePosts({
|
||||
} = useInfiniteQuery<PageData, Error>({
|
||||
queryKey: ["posts", { ...filters, type: typeFilter }],
|
||||
queryFn: ({ pageParam = 1 }) =>
|
||||
fetchPosts(pageParam as number, 4, { ...filters }, token),
|
||||
fetchPosts(
|
||||
pageParam as number,
|
||||
4,
|
||||
{ ...filters, feedMode: "grid" },
|
||||
token
|
||||
),
|
||||
getNextPageParam: (lastPage, allPages) =>
|
||||
lastPage.posts.length > 0 ? allPages.length + 1 : undefined,
|
||||
initialPageParam: 1,
|
||||
@@ -96,7 +118,10 @@ export default function InfinitePosts({
|
||||
<div className="mt-5 space-y-4 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mb-24">
|
||||
{posts.map((post) => (
|
||||
<div key={post._id} className="space-y-2">
|
||||
<MainModelCard postData={post} />
|
||||
<MainModelCard
|
||||
postData={post}
|
||||
onMediaClick={() => openPostInReels(post)}
|
||||
/>
|
||||
<hr className="border-gray-200 dark:border-gray-700" />
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -28,7 +28,13 @@ const MutedContext = createContext<{
|
||||
setMuted: () => {},
|
||||
});
|
||||
|
||||
function MainModelCard({ postData }: { postData: Post }) {
|
||||
function MainModelCard({
|
||||
postData,
|
||||
onMediaClick,
|
||||
}: {
|
||||
postData: Post;
|
||||
onMediaClick?: () => void;
|
||||
}) {
|
||||
const {
|
||||
user_id,
|
||||
user_name,
|
||||
@@ -337,7 +343,23 @@ function MainModelCard({ postData }: { postData: Post }) {
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className=" overflow-hidden rounded-2xl">
|
||||
<div
|
||||
className={`overflow-hidden rounded-2xl ${onMediaClick ? "cursor-pointer" : ""}`}
|
||||
onClick={onMediaClick}
|
||||
onKeyDown={
|
||||
onMediaClick
|
||||
? (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onMediaClick();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
role={onMediaClick ? "button" : undefined}
|
||||
tabIndex={onMediaClick ? 0 : undefined}
|
||||
aria-label={onMediaClick ? "مشاهده تمامصفحه" : undefined}
|
||||
>
|
||||
{type === "image" && mediaFiles.length > 0 && (
|
||||
<SwipeImageSlider
|
||||
mediaFiles={mediaFiles}
|
||||
@@ -348,7 +370,14 @@ function MainModelCard({ postData }: { postData: Post }) {
|
||||
{type === "video" && mediaFiles.length > 0 && (
|
||||
<div className="relative w-full rounded-2xl overflow-hidden bg-black">
|
||||
<video
|
||||
onClick={handleVideoClick}
|
||||
onClick={(e) => {
|
||||
if (onMediaClick) {
|
||||
e.stopPropagation();
|
||||
onMediaClick();
|
||||
return;
|
||||
}
|
||||
handleVideoClick(e);
|
||||
}}
|
||||
ref={videoRef}
|
||||
src={mediaFiles[0].src}
|
||||
loop
|
||||
|
||||
@@ -13,7 +13,7 @@ import "slick-carousel/slick/slick.css";
|
||||
import "slick-carousel/slick/slick-theme.css";
|
||||
|
||||
// ایجاد Context برای به اشتراک گذاشتن وضعیت muted بین همه کارتها
|
||||
const MutedContext = createContext<{
|
||||
export const MutedContext = createContext<{
|
||||
muted: boolean;
|
||||
setMuted: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}>({
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
import Image from "next/image";
|
||||
import { useRouter, usePathname } from "next/navigation"; // اضافه شدن usePathname برای تشخیص صفحه
|
||||
import React, { useState } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import FilterModal from "./FilterModal";
|
||||
|
||||
import { getCreatePostPath } from "@/lib/getCreateContentPath";
|
||||
// وارد کردن دیتاها برای تبدیل ID به نام فارسی جهت ساخت URL
|
||||
import provinces from "@/data/provinces.json";
|
||||
import cities from "@/data/cities.json";
|
||||
@@ -14,6 +15,9 @@ const expertiseList = [
|
||||
{ name: "عکاس", color: "#dace63" },
|
||||
];
|
||||
|
||||
const TOOLBAR_ICON_SIZE = 25;
|
||||
const toolbarIconButtonClass =
|
||||
"inline-flex h-[25px] w-[25px] items-center justify-center shrink-0 p-0 leading-none";
|
||||
interface ModelsFilterProps {
|
||||
expertise: string;
|
||||
}
|
||||
@@ -26,6 +30,21 @@ function ModelsFilter({ expertise }: ModelsFilterProps) {
|
||||
const [cityId, setCityId] = useState<string>("");
|
||||
const [rateFilter, setRateFilter] = useState<string>("");
|
||||
const [userLevel, setUserLevel] = useState<string>("");
|
||||
const [userType, setUserType] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setUserType(localStorage.getItem("usertype"));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCreatePost = () => {
|
||||
if (!userType) {
|
||||
toast.error("لطفا ابتدا در سایت وارد شوید یا ثبت نام کنید.");
|
||||
return;
|
||||
}
|
||||
router.push(getCreatePostPath());
|
||||
};
|
||||
|
||||
// تابع اصلی برای ساخت URL فارسی و سئو شده
|
||||
const handleFilterChange = (filter?: string) => {
|
||||
@@ -98,40 +117,38 @@ function ModelsFilter({ expertise }: ModelsFilterProps) {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" onClick={() => setShowFilterModal(true)}>
|
||||
<Image
|
||||
width={25}
|
||||
height={25}
|
||||
alt="filter icon"
|
||||
src="/images/icons/candle.svg"
|
||||
className="dark:invert"
|
||||
/>
|
||||
<div className="flex items-center gap-3 shrink-0 self-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreatePost}
|
||||
aria-label="ثبت پست"
|
||||
className={toolbarIconButtonClass}
|
||||
>
|
||||
<BoldIcon name="add-square" size={TOOLBAR_ICON_SIZE} className="block" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/search?type=user")}
|
||||
aria-label="جستجوی کاربر"
|
||||
className={toolbarIconButtonClass}
|
||||
>
|
||||
<Image
|
||||
width={25}
|
||||
height={25}
|
||||
alt="جستجوی کاربر"
|
||||
src="/images/icons/search-normal.svg"
|
||||
/>
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/explore")}
|
||||
aria-label="اکسپلور"
|
||||
className={toolbarIconButtonClass}
|
||||
>
|
||||
<Image
|
||||
width={25}
|
||||
height={25}
|
||||
alt="اکسپلور"
|
||||
src="/images/icons/grid-3.svg"
|
||||
className="dark:invert"
|
||||
/>
|
||||
<BoldIcon name="grid-3" size={TOOLBAR_ICON_SIZE} className="block" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,43 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { fetchPosts } from "@/api/fetchPosts";
|
||||
import { fetchPostById } from "@/api/fetchPostById";
|
||||
import { Post } from "@/types/types";
|
||||
import MainModelCard from "@/components/models/MainModelCard/MainModelCard";
|
||||
import { MutedProvider } from "@/components/models/ModelPage/MainModelCardPost";
|
||||
import PageLoader from "@/components/ui/PageLoader";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import Cookies from "js-cookie";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import ReelsPostCard from "./ReelsPostCard";
|
||||
import { normalizeUserLevel } from "@/lib/userLevel";
|
||||
import { readReelsSeedPost } from "@/lib/reelsSeedPost";
|
||||
import { reorderColdStartFeed } from "@/lib/coldStartFeed";
|
||||
|
||||
interface PostFeedViewProps {
|
||||
initialPostId: string;
|
||||
initialPost?: Post | null;
|
||||
userId?: string;
|
||||
showClose?: boolean;
|
||||
videoOnly?: boolean;
|
||||
exploreMode?: boolean;
|
||||
feedMode?: "user" | "home" | "explore";
|
||||
}
|
||||
|
||||
export default function PostFeedView({
|
||||
initialPostId,
|
||||
initialPost: initialPostProp,
|
||||
userId: userIdProp,
|
||||
showClose = true,
|
||||
videoOnly = false,
|
||||
exploreMode = false,
|
||||
feedMode: feedModeProp,
|
||||
}: PostFeedViewProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const userIdFromQuery = searchParams.get("userId") || "";
|
||||
const feedFromQuery = searchParams.get("feed");
|
||||
const token = Cookies.get("token") || "";
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const scrolledRef = useRef(false);
|
||||
const [activePostId, setActivePostId] = useState(initialPostId);
|
||||
const [seedPost, setSeedPost] = useState<Post | null>(
|
||||
initialPostProp || readReelsSeedPost(initialPostId) || null
|
||||
);
|
||||
const [seedLoading, setSeedLoading] = useState(!seedPost);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
scrolledRef.current = false;
|
||||
setActivePostId(initialPostId);
|
||||
|
||||
const cached = readReelsSeedPost(initialPostId);
|
||||
if (cached) {
|
||||
setSeedPost(cached);
|
||||
setSeedLoading(false);
|
||||
} else if (initialPostProp) {
|
||||
setSeedPost(initialPostProp);
|
||||
setSeedLoading(false);
|
||||
} else {
|
||||
setSeedLoading(true);
|
||||
}
|
||||
|
||||
fetchPostById(initialPostId, token).then((post) => {
|
||||
if (cancelled) return;
|
||||
if (post) {
|
||||
setSeedPost(post);
|
||||
}
|
||||
setSeedLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [initialPostId, initialPostProp, token]);
|
||||
|
||||
const feedMode =
|
||||
feedModeProp ||
|
||||
(feedFromQuery === "home"
|
||||
? "home"
|
||||
: exploreMode
|
||||
? "explore"
|
||||
: "user");
|
||||
|
||||
const feedFilters = {
|
||||
expertise: searchParams.get("expertise") || "",
|
||||
province: searchParams.get("province") || "",
|
||||
city: searchParams.get("city") || "",
|
||||
userLevel: normalizeUserLevel(searchParams.get("userLevel") || ""),
|
||||
rateFilter: searchParams.get("rateFilter") || "",
|
||||
hashtag: searchParams.get("hashtag") || "",
|
||||
exploreFilter: searchParams.get("exploreFilter") || "",
|
||||
subExpertise: searchParams.get("subExpertise") || "",
|
||||
};
|
||||
|
||||
const [resolvedUserId, setResolvedUserId] = useState(
|
||||
userIdProp || userIdFromQuery || ""
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (feedMode !== "user") {
|
||||
return;
|
||||
}
|
||||
if (userIdProp || userIdFromQuery) {
|
||||
setResolvedUserId(userIdProp || userIdFromQuery);
|
||||
return;
|
||||
@@ -46,46 +112,144 @@ export default function PostFeedView({
|
||||
const uid = post?.userId || post?.user_id;
|
||||
if (uid) setResolvedUserId(uid);
|
||||
});
|
||||
}, [initialPostId, userIdProp, userIdFromQuery]);
|
||||
}, [initialPostId, userIdProp, userIdFromQuery, feedMode]);
|
||||
|
||||
const { data, isLoading, isError } = useInfiniteQuery({
|
||||
queryKey: ["post-feed", resolvedUserId, initialPostId, videoOnly],
|
||||
queryFn: ({ pageParam = 1 }) =>
|
||||
fetchPosts(
|
||||
pageParam as number,
|
||||
20,
|
||||
{
|
||||
_id: resolvedUserId,
|
||||
...(videoOnly ? { type: "video" } : {}),
|
||||
},
|
||||
token
|
||||
),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, pages) =>
|
||||
lastPage.posts?.length ? pages.length + 1 : undefined,
|
||||
enabled: !!resolvedUserId || !!token,
|
||||
});
|
||||
const queryFilters =
|
||||
feedMode === "user"
|
||||
? { ...(resolvedUserId ? { _id: resolvedUserId } : {}) }
|
||||
: feedMode === "home"
|
||||
? Object.fromEntries(
|
||||
Object.entries(feedFilters).filter(([, v]) => v)
|
||||
)
|
||||
: {};
|
||||
|
||||
const posts: Post[] =
|
||||
data?.pages
|
||||
?.flatMap((p) => p.posts)
|
||||
?.filter(
|
||||
(p) =>
|
||||
p.status === "accept" && (!videoOnly || p.type === "video")
|
||||
) ?? [];
|
||||
const algorithmFilters =
|
||||
feedMode === "home" || feedMode === "explore"
|
||||
? {
|
||||
feedMode: "reels" as const,
|
||||
seedPostId: initialPostId,
|
||||
}
|
||||
: feedMode === "user"
|
||||
? {
|
||||
feedMode: "reels" as const,
|
||||
seedPostId: initialPostId,
|
||||
}
|
||||
: {};
|
||||
|
||||
const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: [
|
||||
"post-feed",
|
||||
feedMode,
|
||||
resolvedUserId,
|
||||
initialPostId,
|
||||
videoOnly,
|
||||
queryFilters,
|
||||
algorithmFilters,
|
||||
],
|
||||
queryFn: ({ pageParam = 1 }) =>
|
||||
fetchPosts(
|
||||
pageParam as number,
|
||||
10,
|
||||
{
|
||||
...queryFilters,
|
||||
...(pageParam === 1
|
||||
? algorithmFilters
|
||||
: feedMode === "home" || feedMode === "explore" || feedMode === "user"
|
||||
? { feedMode: "reels" as const }
|
||||
: {}),
|
||||
...(videoOnly ? { type: "video" } : {}),
|
||||
},
|
||||
token
|
||||
),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, pages) =>
|
||||
lastPage.posts?.length ? pages.length + 1 : undefined,
|
||||
enabled:
|
||||
feedMode === "home" ||
|
||||
feedMode === "explore" ||
|
||||
!!resolvedUserId ||
|
||||
!!token,
|
||||
});
|
||||
|
||||
const posts: Post[] = useMemo(() => {
|
||||
const apiPosts =
|
||||
data?.pages
|
||||
?.flatMap((p) => p.posts)
|
||||
?.filter(
|
||||
(p) =>
|
||||
p.status === "accept" && (!videoOnly || p.type === "video")
|
||||
) ?? [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const merged: Post[] = [];
|
||||
|
||||
const addPost = (post: Post) => {
|
||||
const id = String(post._id);
|
||||
if (seen.has(id)) return;
|
||||
if (post.status !== "accept") return;
|
||||
if (videoOnly && post.type !== "video") return;
|
||||
seen.add(id);
|
||||
merged.push(post);
|
||||
};
|
||||
|
||||
if (seedPost) addPost(seedPost);
|
||||
|
||||
const restPosts =
|
||||
seedPost && (feedMode === "home" || feedMode === "explore")
|
||||
? reorderColdStartFeed(apiPosts, seedPost)
|
||||
: apiPosts;
|
||||
|
||||
restPosts.forEach(addPost);
|
||||
|
||||
return merged;
|
||||
}, [data, seedPost, videoOnly, feedMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrolledRef.current || !posts.length || !scrollRef.current) return;
|
||||
const idx = posts.findIndex((p) => p._id === initialPostId);
|
||||
if (scrolledRef.current || seedLoading || !posts.length || !scrollRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = posts.findIndex(
|
||||
(p) => String(p._id) === String(initialPostId)
|
||||
);
|
||||
const targetIdx = idx >= 0 ? idx : 0;
|
||||
|
||||
scrollRef.current.scrollTo({
|
||||
top: targetIdx * window.innerHeight,
|
||||
behavior: "auto",
|
||||
});
|
||||
scrolledRef.current = true;
|
||||
}, [posts, initialPostId]);
|
||||
setActivePostId(posts[targetIdx]?._id || initialPostId);
|
||||
}, [posts, initialPostId, seedLoading]);
|
||||
|
||||
if (!resolvedUserId && isLoading) {
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || !posts.length) return;
|
||||
|
||||
const index = Math.round(el.scrollTop / window.innerHeight);
|
||||
const post = posts[index];
|
||||
if (post && String(post._id) !== String(activePostId)) {
|
||||
setActivePostId(post._id);
|
||||
}
|
||||
|
||||
if (
|
||||
el.scrollTop + el.clientHeight >= el.scrollHeight - window.innerHeight * 0.5 &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [posts, activePostId, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => el.removeEventListener("scroll", handleScroll);
|
||||
}, [handleScroll]);
|
||||
|
||||
if (feedMode === "user" && !resolvedUserId && isLoading) {
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<Skeleton className="h-[70vh] w-full rounded-2xl" />
|
||||
@@ -93,6 +257,13 @@ export default function PostFeedView({
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
(isLoading && !data) ||
|
||||
(seedLoading && !seedPost)
|
||||
) {
|
||||
return <PageLoader className="min-h-[100dvh]" />;
|
||||
}
|
||||
|
||||
if (isError || posts.length === 0) {
|
||||
return (
|
||||
<p className="py-20 text-center text-neutral-500">پست یافت نشد.</p>
|
||||
@@ -101,7 +272,7 @@ export default function PostFeedView({
|
||||
|
||||
return (
|
||||
<MutedProvider>
|
||||
<div className="relative min-h-[100dvh] bg-background">
|
||||
<div className="relative min-h-[100dvh] bg-black">
|
||||
{showClose && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -114,19 +285,20 @@ export default function PostFeedView({
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="h-[100dvh] snap-y snap-mandatory overflow-y-auto scroll-smooth"
|
||||
className="h-[100dvh] snap-y snap-mandatory overflow-y-auto overscroll-y-contain scroll-smooth"
|
||||
>
|
||||
{posts.map((post) => (
|
||||
<article
|
||||
<ReelsPostCard
|
||||
key={post._id}
|
||||
id={`post-${post._id}`}
|
||||
className="flex min-h-[100dvh] snap-start items-start justify-center py-2"
|
||||
>
|
||||
<div className="w-full max-w-2xl px-2 sm:px-4">
|
||||
<MainModelCard postData={post} />
|
||||
</div>
|
||||
</article>
|
||||
postData={post}
|
||||
isActive={String(post._id) === String(activePostId)}
|
||||
/>
|
||||
))}
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex h-24 snap-start items-center justify-center">
|
||||
<PageLoader className="min-h-[80px]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</MutedProvider>
|
||||
|
||||
312
src/components/posts/ReelsPostActions.tsx
Normal file
312
src/components/posts/ReelsPostActions.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
"use client";
|
||||
|
||||
import { Post } from "@/types/types";
|
||||
import Image from "next/image";
|
||||
import React, { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import ReportModal from "@/components/main/ReportModal";
|
||||
import CommentsModal from "@/components/models/MainModelCard/CommentsModal";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import toast from "react-hot-toast";
|
||||
import ModelDetailModal from "@/components/models/ModelDetailModal";
|
||||
import ModelServiceModal from "@/components/models/ModelServiceModal";
|
||||
import { useUserById } from "@/hooks/getUserById";
|
||||
import { AxiosError } from "axios";
|
||||
import useInfiniteScroll from "@/hooks/useInfiniteScroll";
|
||||
import SharePostModal from "@/components/main/SharePostModal";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import Cookies from "js-cookie";
|
||||
import { trackExploreInteraction } from "@/api/trackExploreInteraction";
|
||||
|
||||
interface ReelsPostActionsProps {
|
||||
postData: Post;
|
||||
}
|
||||
|
||||
export default function ReelsPostActions({ postData }: ReelsPostActionsProps) {
|
||||
const {
|
||||
_id: postId,
|
||||
user_id,
|
||||
user_name,
|
||||
likesCount,
|
||||
is_liked,
|
||||
type: postType,
|
||||
} = postData;
|
||||
|
||||
const user = useUserById(user_id);
|
||||
const expertise = user?.expertise;
|
||||
const hair_color = user?.hair_color;
|
||||
const eye_color = user?.eye_color;
|
||||
const height = user?.height;
|
||||
const size = user?.size;
|
||||
const weight = user?.weight;
|
||||
const services = user?.services;
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [showReportModal, setShowReportModal] = useState(false);
|
||||
const [showCommentsModal, setShowCommentsModal] = useState(false);
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
const [showServicesModal, setShowServicesModal] = useState(false);
|
||||
const [liked, setLiked] = useState<boolean>(is_liked || false);
|
||||
const [likesCountState, setLikesCount] = useState<number>(likesCount || 0);
|
||||
|
||||
const { request, loading } = useAxios();
|
||||
|
||||
const toggleLike = async () => {
|
||||
if (!user_id || !postId) {
|
||||
toast.error("لطفاً وارد حساب کاربری خود شوید یا ثبتنام کنید");
|
||||
return;
|
||||
}
|
||||
if (loading) return;
|
||||
|
||||
const newLiked = !liked;
|
||||
const newLikesCount = newLiked ? likesCountState + 1 : likesCountState - 1;
|
||||
setLiked(newLiked);
|
||||
setLikesCount(newLikesCount);
|
||||
|
||||
try {
|
||||
const response = await request<{ likesCount?: number; is_liked?: boolean }>(
|
||||
"POST",
|
||||
"/posts/like",
|
||||
{ postId },
|
||||
{ noToast: true }
|
||||
);
|
||||
setLikesCount(response?.likesCount ?? newLikesCount);
|
||||
setLiked(response?.is_liked ?? newLiked);
|
||||
|
||||
const token = Cookies.get("token") || "";
|
||||
if (token && newLiked) {
|
||||
trackExploreInteraction(
|
||||
{
|
||||
targetType: "post",
|
||||
targetId: postId,
|
||||
authorId: user_id,
|
||||
contentType: postType === "video" ? "video" : "image",
|
||||
action: "like",
|
||||
},
|
||||
token
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
let errorMessage = "خطا در ارتباط با سرور";
|
||||
if (error instanceof Error) errorMessage = error.message;
|
||||
else if (error instanceof AxiosError)
|
||||
errorMessage = error.response?.data?.message ?? error.message;
|
||||
toast.error(errorMessage);
|
||||
setLiked(!newLiked);
|
||||
setLikesCount(likesCountState);
|
||||
}
|
||||
};
|
||||
|
||||
const shareUrl = postId
|
||||
? `https://modstagram.com/posts/${postId}`
|
||||
: user_name
|
||||
? `https://modstagram.com/users/${user_name}`
|
||||
: "";
|
||||
|
||||
const { data } = useInfiniteScroll({
|
||||
endpoint: "/users/comments",
|
||||
queryKey: ["comments", user_id, postId || "all"],
|
||||
params: postId
|
||||
? { user_id, post_id: postId }
|
||||
: { user_id },
|
||||
});
|
||||
|
||||
const commentCount =
|
||||
data?.pages
|
||||
?.flatMap((p) => p?.comments || [])
|
||||
.filter((c: { post_id?: string; postId?: string }) =>
|
||||
postId
|
||||
? c.post_id === postId || c.postId === postId || (!c.post_id && !c.postId)
|
||||
: true
|
||||
).length ?? 0;
|
||||
|
||||
const actionButtonClass =
|
||||
"flex flex-col items-center gap-1 text-white drop-shadow-md";
|
||||
|
||||
return (
|
||||
<>
|
||||
{showReportModal && (
|
||||
<ReportModal
|
||||
isOpen={showReportModal}
|
||||
onClose={() => setShowReportModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showCommentsModal && (
|
||||
<CommentsModal
|
||||
isOpen={showCommentsModal}
|
||||
onClose={() => setShowCommentsModal(false)}
|
||||
userId={user_id}
|
||||
postId={postId}
|
||||
/>
|
||||
)}
|
||||
<SharePostModal
|
||||
open={showShareModal}
|
||||
onClose={() => setShowShareModal(false)}
|
||||
url={shareUrl}
|
||||
/>
|
||||
{showServicesModal &&
|
||||
(expertise === "مدلینگ" ? (
|
||||
<ModelDetailModal
|
||||
hairColor={hair_color}
|
||||
eyeColor={eye_color}
|
||||
height={height}
|
||||
size={size}
|
||||
weight={weight}
|
||||
isOpen={showServicesModal}
|
||||
onClose={() => setShowServicesModal(false)}
|
||||
isMain
|
||||
userId={user_id}
|
||||
/>
|
||||
) : (
|
||||
<ModelServiceModal
|
||||
services={services}
|
||||
isOpen={showServicesModal}
|
||||
onClose={() => setShowServicesModal(false)}
|
||||
userId={user_id}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="absolute left-3 bottom-36 z-30 flex flex-col items-center">
|
||||
<AnimatePresence mode="wait">
|
||||
{!expanded ? (
|
||||
<motion.button
|
||||
key="trigger"
|
||||
type="button"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
onClick={() => setExpanded(true)}
|
||||
className="flex h-11 w-11 items-center justify-center rounded-full bg-black/40 backdrop-blur-md"
|
||||
aria-label="نمایش عملیات"
|
||||
>
|
||||
<BoldIcon name="grid-3" size={24} className="block dark:invert" />
|
||||
</motion.button>
|
||||
) : (
|
||||
<motion.div
|
||||
key="actions"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 12 }}
|
||||
transition={{ duration: 0.25, ease: "easeOut" }}
|
||||
className="flex flex-col items-center gap-5"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleLike}
|
||||
className={actionButtonClass}
|
||||
aria-label="لایک"
|
||||
>
|
||||
<Image
|
||||
width={28}
|
||||
height={28}
|
||||
alt="heart"
|
||||
src={liked ? "/images/icons/red-heart.svg" : "/images/icons/heart.svg"}
|
||||
className="drop-shadow-lg"
|
||||
/>
|
||||
<span className="text-xs font-semibold">{likesCountState}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCommentsModal(true)}
|
||||
className={actionButtonClass}
|
||||
aria-label="کامنت"
|
||||
>
|
||||
<Image
|
||||
width={28}
|
||||
height={28}
|
||||
alt="comment"
|
||||
src="/images/icons/message-text.svg"
|
||||
className="invert drop-shadow-lg"
|
||||
/>
|
||||
<span className="text-xs font-semibold">{commentCount}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (shareUrl) {
|
||||
const token = Cookies.get("token") || "";
|
||||
if (token) {
|
||||
trackExploreInteraction(
|
||||
{
|
||||
targetType: "post",
|
||||
targetId: postId,
|
||||
authorId: user_id,
|
||||
contentType: postType === "video" ? "video" : "image",
|
||||
action: "share",
|
||||
},
|
||||
token
|
||||
);
|
||||
}
|
||||
setShowShareModal(true);
|
||||
}
|
||||
}}
|
||||
className={actionButtonClass}
|
||||
aria-label="اشتراکگذاری"
|
||||
>
|
||||
<Image
|
||||
width={28}
|
||||
height={28}
|
||||
alt="share"
|
||||
src="/images/icons/share.svg"
|
||||
className="invert drop-shadow-lg"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(false)}
|
||||
className="mt-1 flex h-9 w-9 items-center justify-center rounded-full bg-black/40 backdrop-blur-md"
|
||||
aria-label="بستن"
|
||||
>
|
||||
<BoldIcon name="close-circle" size={20} tinted className="text-white" />
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** دکمه درخواست همکاری برای نوار پایین */
|
||||
export function ReelsCollaborationButton({
|
||||
postData,
|
||||
}: {
|
||||
postData: Post;
|
||||
}) {
|
||||
const user = useUserById(postData.user_id);
|
||||
const [showServicesModal, setShowServicesModal] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showServicesModal &&
|
||||
(user?.expertise === "مدلینگ" ? (
|
||||
<ModelDetailModal
|
||||
hairColor={user?.hair_color}
|
||||
eyeColor={user?.eye_color}
|
||||
height={user?.height}
|
||||
size={user?.size}
|
||||
weight={user?.weight}
|
||||
isOpen={showServicesModal}
|
||||
onClose={() => setShowServicesModal(false)}
|
||||
isMain
|
||||
userId={postData.user_id}
|
||||
/>
|
||||
) : (
|
||||
<ModelServiceModal
|
||||
services={user?.services}
|
||||
isOpen={showServicesModal}
|
||||
onClose={() => setShowServicesModal(false)}
|
||||
userId={postData.user_id}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowServicesModal(true)}
|
||||
className="shrink-0 rounded-lg border border-white/80 px-3 py-1.5 text-xs font-semibold text-white backdrop-blur-sm transition active:scale-95"
|
||||
>
|
||||
درخواست همکاری
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
323
src/components/posts/ReelsPostCard.tsx
Normal file
323
src/components/posts/ReelsPostCard.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
useContext,
|
||||
} from "react";
|
||||
import { Post } from "@/types/types";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import VerificationBadge from "@/components/main/VerificationBadge";
|
||||
import { useUserById } from "@/hooks/getUserById";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import ReelsPostActions, {
|
||||
ReelsCollaborationButton,
|
||||
} from "./ReelsPostActions";
|
||||
import { MutedContext } from "@/components/models/ModelPage/MainModelCardPost";
|
||||
import Cookies from "js-cookie";
|
||||
import { trackExploreInteraction } from "@/api/trackExploreInteraction";
|
||||
|
||||
interface ReelsPostCardProps {
|
||||
postData: Post;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export default function ReelsPostCard({
|
||||
postData,
|
||||
isActive = false,
|
||||
}: ReelsPostCardProps) {
|
||||
const {
|
||||
user_id,
|
||||
user_name,
|
||||
first_name,
|
||||
last_name,
|
||||
caption,
|
||||
files,
|
||||
type,
|
||||
} = postData;
|
||||
|
||||
const user = useUserById(user_id);
|
||||
const profile_image = user?.profile_image;
|
||||
const is_verified = user?.is_verified;
|
||||
|
||||
const [showFullCaption, setShowFullCaption] = useState(false);
|
||||
const [imageIndex, setImageIndex] = useState(0);
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const containerRef = useRef<HTMLElement | null>(null);
|
||||
const viewStartedAtRef = useRef<number | null>(null);
|
||||
const watchCompleteTrackedRef = useRef(false);
|
||||
const [ripple, setRipple] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
key: number;
|
||||
} | null>(null);
|
||||
|
||||
const { muted, setMuted } = useContext(MutedContext);
|
||||
const [videoMuted, setVideoMuted] = useState(true);
|
||||
|
||||
const displayCaption = showFullCaption ? caption : caption?.slice(0, 80);
|
||||
|
||||
const mediaFiles =
|
||||
files?.map((file) => ({
|
||||
...file,
|
||||
src: `${IMAGE_BASE_URL}${file.path.replace(
|
||||
"/root/modstagram-back/storage",
|
||||
""
|
||||
)}`,
|
||||
})) || [];
|
||||
|
||||
const handleVideoClick = (e: React.MouseEvent<HTMLVideoElement>) => {
|
||||
e.stopPropagation();
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
const rect = v.getBoundingClientRect();
|
||||
setRipple({
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
key: Date.now(),
|
||||
});
|
||||
|
||||
v.muted = !v.muted;
|
||||
setVideoMuted(v.muted);
|
||||
setMuted(v.muted);
|
||||
if (!v.muted) v.play().catch(() => {});
|
||||
setTimeout(() => setRipple(null), 600);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || type !== "video") return;
|
||||
|
||||
if (isActive) {
|
||||
video.play().catch(() => {});
|
||||
video.muted = muted;
|
||||
setVideoMuted(muted);
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
}, [isActive, muted, type]);
|
||||
|
||||
useEffect(() => {
|
||||
const token = Cookies.get("token") || "";
|
||||
const payload = {
|
||||
targetType: "post" as const,
|
||||
targetId: postData._id,
|
||||
authorId: postData.user_id,
|
||||
contentType: (postData.type === "video" ? "video" : "image") as
|
||||
| "video"
|
||||
| "image",
|
||||
};
|
||||
|
||||
if (!isActive) {
|
||||
if (viewStartedAtRef.current && token) {
|
||||
const dwellMs = Date.now() - viewStartedAtRef.current;
|
||||
if (dwellMs < 2500) {
|
||||
trackExploreInteraction({ ...payload, action: "skip", dwellMs }, token);
|
||||
} else if (!watchCompleteTrackedRef.current && dwellMs >= 5000) {
|
||||
trackExploreInteraction({ ...payload, action: "dwell", dwellMs }, token);
|
||||
}
|
||||
}
|
||||
viewStartedAtRef.current = null;
|
||||
watchCompleteTrackedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
viewStartedAtRef.current = Date.now();
|
||||
watchCompleteTrackedRef.current = false;
|
||||
|
||||
if (token) {
|
||||
trackExploreInteraction({ ...payload, action: "view" }, token);
|
||||
}
|
||||
|
||||
const completeDelay =
|
||||
type === "video"
|
||||
? Math.min(9000, Math.max(4000, (videoRef.current?.duration || 12) * 750))
|
||||
: 3500;
|
||||
|
||||
const completeTimer = window.setTimeout(() => {
|
||||
if (!token || watchCompleteTrackedRef.current) return;
|
||||
watchCompleteTrackedRef.current = true;
|
||||
trackExploreInteraction(
|
||||
{
|
||||
...payload,
|
||||
action: "watch_complete",
|
||||
dwellMs: completeDelay,
|
||||
},
|
||||
token
|
||||
);
|
||||
}, completeDelay);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(completeTimer);
|
||||
};
|
||||
}, [isActive, postData._id, postData.user_id, postData.type, type]);
|
||||
|
||||
const goToPrevImage = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setImageIndex((i) => (i > 0 ? i - 1 : mediaFiles.length - 1));
|
||||
};
|
||||
|
||||
const goToNextImage = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setImageIndex((i) => (i < mediaFiles.length - 1 ? i + 1 : 0));
|
||||
};
|
||||
|
||||
return (
|
||||
<article
|
||||
ref={containerRef}
|
||||
className="relative h-[100dvh] w-full snap-start snap-always overflow-hidden bg-black"
|
||||
>
|
||||
{/* Media */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{type === "image" && mediaFiles.length > 0 && (
|
||||
<>
|
||||
<Image
|
||||
src={mediaFiles[imageIndex].src}
|
||||
alt="post"
|
||||
fill
|
||||
className="object-contain"
|
||||
priority={isActive}
|
||||
onLoadingComplete={() => setImageLoading(false)}
|
||||
/>
|
||||
{mediaFiles.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToPrevImage}
|
||||
className="absolute right-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/30 p-2 text-white"
|
||||
aria-label="قبلی"
|
||||
>
|
||||
<BoldIcon name="arrow-right-3" size={20} tinted className="text-white" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToNextImage}
|
||||
className="absolute left-2 top-1/2 z-10 -translate-y-1/2 rounded-full bg-black/30 p-2 text-white"
|
||||
aria-label="بعدی"
|
||||
>
|
||||
<BoldIcon name="arrow-left-2" size={20} tinted className="text-white" />
|
||||
</button>
|
||||
<div className="absolute top-[calc(3.5rem+env(safe-area-inset-top))] right-4 z-10 rounded-full bg-black/40 px-3 py-1 text-xs text-white">
|
||||
{imageIndex + 1} / {mediaFiles.length}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "video" && mediaFiles.length > 0 && (
|
||||
<>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={mediaFiles[0].src}
|
||||
loop
|
||||
playsInline
|
||||
className="h-full w-full object-contain"
|
||||
onClick={handleVideoClick}
|
||||
onLoadedData={() => setImageLoading(false)}
|
||||
muted={muted}
|
||||
/>
|
||||
{ripple && (
|
||||
<span
|
||||
className="pointer-events-none absolute rounded-full bg-white/50 animate-ripple"
|
||||
style={{
|
||||
left: ripple.x - 25,
|
||||
top: ripple.y - 25,
|
||||
width: 50,
|
||||
height: 50,
|
||||
}}
|
||||
key={ripple.key}
|
||||
/>
|
||||
)}
|
||||
<div className="pointer-events-none absolute right-3 top-[calc(3.5rem+env(safe-area-inset-top))] z-20 rounded-full bg-black/50 p-2 backdrop-blur-md">
|
||||
{videoMuted ? (
|
||||
<BoldIcon name="volume-slash" size={20} tinted className="text-white" />
|
||||
) : (
|
||||
<BoldIcon name="volume-high" size={20} tinted className="text-white" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{imageLoading && isActive && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/40">
|
||||
<div className="h-10 w-10 animate-spin rounded-full border-4 border-white border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions — left side */}
|
||||
<ReelsPostActions postData={postData} />
|
||||
|
||||
{/* Bottom user info — Instagram Reels style */}
|
||||
<div className="absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/85 via-black/50 to-transparent px-4 pb-[calc(1.25rem+env(safe-area-inset-bottom))] pt-16">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href={`/users/${user_name}`}
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5"
|
||||
>
|
||||
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-full ring-2 ring-white/30">
|
||||
<Image
|
||||
src={
|
||||
profile_image
|
||||
? buildStorageUrl(profile_image)
|
||||
: "/images/fake-avatar.png"
|
||||
}
|
||||
alt={user_name}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-white">
|
||||
<div className="flex items-center gap-1 truncate text-sm font-semibold">
|
||||
<span className="truncate">{user_name}</span>
|
||||
<VerificationBadge isVerified={is_verified} />
|
||||
</div>
|
||||
<p className="truncate text-xs text-white/80">
|
||||
{first_name} {last_name}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<ReelsCollaborationButton postData={postData} />
|
||||
</div>
|
||||
|
||||
{caption && (
|
||||
<p className="mt-2 line-clamp-2 text-sm leading-relaxed text-white/95">
|
||||
{displayCaption}
|
||||
{caption.length > 80 && (
|
||||
<button
|
||||
type="button"
|
||||
className="mr-1 font-semibold text-white/70"
|
||||
onClick={() => setShowFullCaption(!showFullCaption)}
|
||||
>
|
||||
{showFullCaption ? "کمتر" : "بیشتر"}
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.animate-ripple {
|
||||
animation: ripple 600ms ease-out;
|
||||
}
|
||||
@keyframes ripple {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
transform: scale(4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -87,16 +87,22 @@ export default function InfiniteProjects({
|
||||
return (
|
||||
<div>
|
||||
<div className="mt-5 p-4">
|
||||
{data?.pages?.map((page, pageIndex) =>
|
||||
page?.projects?.map((item: Project) => (
|
||||
<div
|
||||
onClick={() => router.push(`/projects/${item?._id}/${item?.title}`)}
|
||||
className="cursor-pointer"
|
||||
key={`${item._id}-${pageIndex}`}
|
||||
>
|
||||
<MainProjectCard project={item} />
|
||||
</div>
|
||||
))
|
||||
{data?.pages?.some((page) => page?.projects?.length) ? (
|
||||
data.pages.map((page, pageIndex) =>
|
||||
page?.projects?.map((item: Project) => (
|
||||
<div
|
||||
onClick={() => router.push(`/projects/${item?._id}/${item?.title}`)}
|
||||
className="cursor-pointer"
|
||||
key={`${item._id}-${pageIndex}`}
|
||||
>
|
||||
<MainProjectCard project={item} />
|
||||
</div>
|
||||
))
|
||||
)
|
||||
) : (
|
||||
<p className="py-16 text-center text-sm text-neutral-500">
|
||||
پروژهای برای نمایش وجود ندارد.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div ref={observerRef} className="h-10 mt-4">
|
||||
|
||||
@@ -24,8 +24,6 @@ function MainProjectCard({
|
||||
isSample?: boolean;
|
||||
sampleType?: string;
|
||||
}) {
|
||||
console.log("sss");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full px-5 py-4 border bg-tertiary-light dark:bg-tertiary-dark border-border-secondary-dark dark:border-border-secondary-dark rounded-3xl my-4 md:my-5 relative text-xs md:text-sm font-semibold ${
|
||||
|
||||
@@ -19,11 +19,7 @@ function ProjectCreator({ creator }: { creator: IProjectCreator }) {
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>
|
||||
{creator?.user_type && creator?.user_type == "employer"
|
||||
? "کارفرما"
|
||||
: "مدل"}
|
||||
</span>
|
||||
<span>کاربر</span>
|
||||
<span>
|
||||
{creator?.first_name &&
|
||||
creator?.first_name + " " + creator?.last_name}
|
||||
|
||||
@@ -1,30 +1,35 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
"use client";
|
||||
import * as yup from "yup";
|
||||
import { useFormik } from "formik";
|
||||
|
||||
import RoundedInput from "@/components/elements/RoundedInput";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React from "react";
|
||||
import useAxios from "@/hooks/useAxios";
|
||||
import RoundedButton from "@/components/elements/RoundedButton";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useFormik } from "formik";
|
||||
import * as yup from "yup";
|
||||
import Cookies from "js-cookie";
|
||||
import toast from "react-hot-toast";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
|
||||
// Validation schema using Yup
|
||||
const schema = yup.object().shape({
|
||||
offerPrice: yup.string().required(" وارد کردن مبلغ پیشنهادی الزامی است"),
|
||||
projectTime: yup.string().required(" وارد کردن زمان پیشنهادی الزامی است"),
|
||||
});
|
||||
|
||||
function ProjectRequestForm({ projectId }: { projectId: string }) {
|
||||
function ProjectRequestForm({
|
||||
projectId,
|
||||
creatorId,
|
||||
}: {
|
||||
projectId: string;
|
||||
creatorId?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { request } = useAxios();
|
||||
const [usertype, setUsertype] = useState<string | null>(null);
|
||||
const user = useUser();
|
||||
|
||||
const isOwnProject =
|
||||
user?._id && creatorId && String(user._id) === String(creatorId);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setUsertype(localStorage.getItem("usertype"));
|
||||
}
|
||||
}, []);
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
offerPrice: "",
|
||||
@@ -32,6 +37,10 @@ function ProjectRequestForm({ projectId }: { projectId: string }) {
|
||||
},
|
||||
validationSchema: schema,
|
||||
onSubmit: async (values) => {
|
||||
if (!Cookies.get("token")) {
|
||||
toast.error("لطفا ابتدا وارد شوید.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await request("POST", "/projects/request", {
|
||||
project_id: projectId,
|
||||
@@ -39,62 +48,64 @@ function ProjectRequestForm({ projectId }: { projectId: string }) {
|
||||
offer_price: values.offerPrice,
|
||||
});
|
||||
router.push("/settings/workroom");
|
||||
} catch (err: any) {
|
||||
console.log("Unhandled error:", err?.message);
|
||||
} catch (err: unknown) {
|
||||
console.log("Unhandled error:", err);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (isOwnProject) {
|
||||
return (
|
||||
<p className="my-6 text-center text-sm text-neutral-500">
|
||||
این پروژه متعلق به شماست.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={formik.handleSubmit}
|
||||
className="w-full max-w-sm flex flex-col items-center mx-auto my-5"
|
||||
className="mx-auto my-5 flex w-full max-w-sm flex-col items-center"
|
||||
>
|
||||
{usertype == "user" ? (
|
||||
<>
|
||||
<RoundedInput
|
||||
type="number"
|
||||
name="projectTime"
|
||||
placeholder="زمان پیشنهادی"
|
||||
className={`mt-4 ${
|
||||
formik.touched.projectTime && formik.errors.projectTime
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.projectTime}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.projectTime && formik.errors.projectTime && (
|
||||
<small className="text-red-500 mt-1 block">
|
||||
{formik.errors.projectTime}
|
||||
</small>
|
||||
)}
|
||||
<RoundedInput
|
||||
type="number"
|
||||
name="offerPrice"
|
||||
placeholder="مبلغ پیشنهادی به تومان: 20،000،000 "
|
||||
className={`mt-4 ${
|
||||
formik.touched.offerPrice && formik.errors.offerPrice
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.offerPrice}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.offerPrice && formik.errors.offerPrice && (
|
||||
<small className="text-red-500 mt-1 block">
|
||||
{formik.errors.offerPrice}
|
||||
</small>
|
||||
)}
|
||||
<RoundedButton className="w-40 h-10 mt-4" type="submit">
|
||||
ارسال
|
||||
</RoundedButton>
|
||||
</>
|
||||
) : (
|
||||
""
|
||||
<RoundedInput
|
||||
type="number"
|
||||
name="projectTime"
|
||||
placeholder="زمان پیشنهادی"
|
||||
className={`mt-4 ${
|
||||
formik.touched.projectTime && formik.errors.projectTime
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.projectTime}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.projectTime && formik.errors.projectTime && (
|
||||
<small className="mt-1 block text-red-500">
|
||||
{formik.errors.projectTime}
|
||||
</small>
|
||||
)}
|
||||
<RoundedInput
|
||||
type="number"
|
||||
name="offerPrice"
|
||||
placeholder="مبلغ پیشنهادی به تومان: 20،000،000 "
|
||||
className={`mt-4 ${
|
||||
formik.touched.offerPrice && formik.errors.offerPrice
|
||||
? "border-red-500 dark:border-red-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
value={formik.values.offerPrice}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.offerPrice && formik.errors.offerPrice && (
|
||||
<small className="mt-1 block text-red-500">
|
||||
{formik.errors.offerPrice}
|
||||
</small>
|
||||
)}
|
||||
<RoundedButton className="mt-4 h-10 w-40" type="submit">
|
||||
ارسال درخواست
|
||||
</RoundedButton>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
import Image from "next/image";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
import { getCreateProjectPath } from "@/lib/getCreateContentPath";
|
||||
import FilterModal from "./FilterModal";
|
||||
|
||||
const expertiseList = [
|
||||
@@ -10,6 +12,10 @@ const expertiseList = [
|
||||
{ name: "عکاسی", color: "#dace63" },
|
||||
];
|
||||
|
||||
const TOOLBAR_ICON_SIZE = 25;
|
||||
const toolbarIconButtonClass =
|
||||
"inline-flex h-[25px] w-[25px] items-center justify-center shrink-0 p-0 leading-none";
|
||||
|
||||
interface ProjectsFilterProps {
|
||||
expertise: string;
|
||||
}
|
||||
@@ -21,6 +27,21 @@ function ProjectsFilter({ expertise }: ProjectsFilterProps) {
|
||||
const [filterRequestsPrice, setFilterRequestsPrice] = useState<string>("");
|
||||
const [filterAge, setFilterAge] = useState<string>("");
|
||||
const [filterGender, setFilterGender] = useState<string>("");
|
||||
const [userType, setUserType] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setUserType(localStorage.getItem("usertype"));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCreateProject = () => {
|
||||
if (!userType) {
|
||||
toast.error("لطفا ابتدا در سایت وارد شوید یا ثبت نام کنید.");
|
||||
return;
|
||||
}
|
||||
router.push(getCreateProjectPath());
|
||||
};
|
||||
|
||||
// مقداردهی اولیه از URL
|
||||
useEffect(() => {
|
||||
@@ -91,23 +112,30 @@ function ProjectsFilter({ expertise }: ProjectsFilterProps) {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => setShowFilterModal(true)}>
|
||||
<Image
|
||||
width={25}
|
||||
height={25}
|
||||
alt="filter icon"
|
||||
src="/images/icons/candle.svg"
|
||||
className="dark:invert"
|
||||
/>
|
||||
<div className="flex items-center gap-3 shrink-0 self-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateProject}
|
||||
aria-label="ثبت پروژه"
|
||||
className={toolbarIconButtonClass}
|
||||
>
|
||||
<BoldIcon name="add-square" size={TOOLBAR_ICON_SIZE} className="block" />
|
||||
</button>
|
||||
<button onClick={() => router.push("/search")}>
|
||||
<Image
|
||||
width={25}
|
||||
height={25}
|
||||
alt="filter icon"
|
||||
src="/images/icons/search-normal.svg"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/search")}
|
||||
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>
|
||||
</div>
|
||||
|
||||
192
src/components/stories/StoriesBar.tsx
Normal file
192
src/components/stories/StoriesBar.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Cookies from "js-cookie";
|
||||
import {
|
||||
fetchStoriesFeed,
|
||||
StoryFeedUser,
|
||||
} from "@/api/fetchStories";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import StoryViewer from "@/components/stories/StoryViewer";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
|
||||
function StoryRing({
|
||||
hasUnviewed,
|
||||
children,
|
||||
}: {
|
||||
hasUnviewed: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (hasUnviewed) {
|
||||
return (
|
||||
<div className="rounded-full bg-gradient-to-tr from-[#feda75] via-[#d62976] to-[#962fbf] p-[2.5px]">
|
||||
<div className="rounded-full bg-background p-[2px]">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded-full border-2 border-neutral-300 dark:border-neutral-600 p-[1px]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StoryAvatar({
|
||||
profileImage,
|
||||
userName,
|
||||
size = 56,
|
||||
}: {
|
||||
profileImage?: string;
|
||||
userName?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="relative shrink-0 overflow-hidden rounded-full bg-neutral-200 dark:bg-neutral-800"
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
<Image
|
||||
src={profileImage ? buildStorageUrl(profileImage) : "/images/fake-avatar.png"}
|
||||
alt={userName || "user"}
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StoriesBar() {
|
||||
const token = Cookies.get("token") || "";
|
||||
const queryClient = useQueryClient();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [viewerOpen, setViewerOpen] = useState(false);
|
||||
const [viewerStartIndex, setViewerStartIndex] = useState(0);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["stories-feed", token],
|
||||
queryFn: () => fetchStoriesFeed(token),
|
||||
enabled: !!token,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const feed = data?.feed ?? [];
|
||||
const viewerId = data?.viewer_id;
|
||||
const myStory = data?.my_story;
|
||||
const hasMyStory = Boolean(myStory?.stories?.length);
|
||||
|
||||
const openViewer = (index: number) => {
|
||||
setViewerStartIndex(index);
|
||||
setViewerOpen(true);
|
||||
};
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setViewerOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["stories-feed"] });
|
||||
}, [queryClient]);
|
||||
|
||||
if (!token) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border-b border-neutral-200/80 bg-background py-3 dark:border-neutral-800">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex gap-3 overflow-x-auto px-3 pb-1 scrollbar-hide"
|
||||
style={{ WebkitOverflowScrolling: "touch" }}
|
||||
>
|
||||
{/* Your story */}
|
||||
<div className="flex w-[72px] shrink-0 flex-col items-center gap-1">
|
||||
<div className="relative">
|
||||
{hasMyStory ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const idx = feed.findIndex((f) => f.user._id === viewerId);
|
||||
openViewer(idx >= 0 ? idx : 0);
|
||||
}}
|
||||
className="block"
|
||||
>
|
||||
<StoryRing hasUnviewed={false}>
|
||||
<StoryAvatar
|
||||
profileImage={myStory?.user.profile_image}
|
||||
userName={myStory?.user.user_name}
|
||||
/>
|
||||
</StoryRing>
|
||||
</button>
|
||||
) : (
|
||||
<Link href="/new-post?type=story" className="block">
|
||||
<StoryRing hasUnviewed={false}>
|
||||
<StoryAvatar profileImage={undefined} userName="شما" />
|
||||
</StoryRing>
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href="/new-post?type=story"
|
||||
className="absolute -bottom-0.5 -right-0.5 flex h-5 w-5 items-center justify-center rounded-full border-2 border-background bg-[#0095f6] text-white"
|
||||
aria-label="افزودن استوری"
|
||||
>
|
||||
<BoldIcon name="add" size={14} tinted className="text-white" />
|
||||
</Link>
|
||||
</div>
|
||||
<span className="max-w-[72px] truncate text-[11px] text-neutral-800 dark:text-neutral-200">
|
||||
استوری شما
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isLoading &&
|
||||
[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-14 w-14 shrink-0 animate-pulse rounded-full bg-neutral-200 dark:bg-neutral-800"
|
||||
/>
|
||||
))}
|
||||
|
||||
{!isLoading &&
|
||||
feed
|
||||
.filter((item) => item.user._id !== viewerId)
|
||||
.map((item, index) => {
|
||||
const realIndex = feed.findIndex(
|
||||
(f) => f.user._id === item.user._id
|
||||
);
|
||||
const displayName =
|
||||
item.user.user_name ||
|
||||
[item.user.first_name, item.user.last_name]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return (
|
||||
<button
|
||||
key={item.user._id}
|
||||
type="button"
|
||||
onClick={() => openViewer(realIndex >= 0 ? realIndex : index)}
|
||||
className="flex w-[72px] shrink-0 flex-col items-center gap-1"
|
||||
>
|
||||
<StoryRing hasUnviewed={item.has_unviewed}>
|
||||
<StoryAvatar
|
||||
profileImage={item.user.profile_image}
|
||||
userName={item.user.user_name}
|
||||
/>
|
||||
</StoryRing>
|
||||
<span className="max-w-[72px] truncate text-[11px] text-neutral-800 dark:text-neutral-200">
|
||||
{displayName}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewerOpen && feed.length > 0 && (
|
||||
<StoryViewer
|
||||
feed={feed}
|
||||
initialUserIndex={viewerStartIndex}
|
||||
onClose={handleClose}
|
||||
token={token}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
264
src/components/stories/StoryViewer.tsx
Normal file
264
src/components/stories/StoryViewer.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { StoryFeedUser } from "@/api/fetchStories";
|
||||
import { markStoryViewed } from "@/api/fetchStories";
|
||||
import { buildStorageUrl } from "@/components/main/BaseUrl";
|
||||
import BoldIcon from "@/components/ui/BoldIcon";
|
||||
|
||||
const IMAGE_DURATION_MS = 5000;
|
||||
|
||||
type StoryViewerProps = {
|
||||
feed: StoryFeedUser[];
|
||||
initialUserIndex: number;
|
||||
onClose: () => void;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export default function StoryViewer({
|
||||
feed,
|
||||
initialUserIndex,
|
||||
onClose,
|
||||
token,
|
||||
}: StoryViewerProps) {
|
||||
const [userIndex, setUserIndex] = useState(initialUserIndex);
|
||||
const [storyIndex, setStoryIndex] = useState(0);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const startRef = useRef<number>(0);
|
||||
const elapsedRef = useRef(0);
|
||||
|
||||
const currentUser = feed[userIndex];
|
||||
const currentStory = currentUser?.stories[storyIndex];
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (!currentUser) return;
|
||||
if (storyIndex < currentUser.stories.length - 1) {
|
||||
setStoryIndex((i) => i + 1);
|
||||
setProgress(0);
|
||||
elapsedRef.current = 0;
|
||||
return;
|
||||
}
|
||||
if (userIndex < feed.length - 1) {
|
||||
setUserIndex((i) => i + 1);
|
||||
setStoryIndex(0);
|
||||
setProgress(0);
|
||||
elapsedRef.current = 0;
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}, [currentUser, storyIndex, userIndex, feed.length, onClose]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
if (storyIndex > 0) {
|
||||
setStoryIndex((i) => i - 1);
|
||||
setProgress(0);
|
||||
elapsedRef.current = 0;
|
||||
return;
|
||||
}
|
||||
if (userIndex > 0) {
|
||||
const prevUser = feed[userIndex - 1];
|
||||
setUserIndex((i) => i - 1);
|
||||
setStoryIndex(Math.max(0, prevUser.stories.length - 1));
|
||||
setProgress(0);
|
||||
elapsedRef.current = 0;
|
||||
}
|
||||
}, [storyIndex, userIndex, feed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentStory?._id) return;
|
||||
markStoryViewed(currentStory._id, token);
|
||||
}, [currentStory?._id, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentStory || paused) return;
|
||||
|
||||
if (currentStory.media_type === "video") {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.currentTime = 0;
|
||||
video.play().catch(() => {});
|
||||
const onTimeUpdate = () => {
|
||||
if (video.duration) {
|
||||
setProgress(video.currentTime / video.duration);
|
||||
}
|
||||
};
|
||||
const onEnded = () => goNext();
|
||||
video.addEventListener("timeupdate", onTimeUpdate);
|
||||
video.addEventListener("ended", onEnded);
|
||||
return () => {
|
||||
video.removeEventListener("timeupdate", onTimeUpdate);
|
||||
video.removeEventListener("ended", onEnded);
|
||||
};
|
||||
}
|
||||
|
||||
startRef.current = Date.now();
|
||||
const tick = () => {
|
||||
const elapsed = elapsedRef.current + (Date.now() - startRef.current);
|
||||
const p = Math.min(elapsed / IMAGE_DURATION_MS, 1);
|
||||
setProgress(p);
|
||||
if (p >= 1) {
|
||||
goNext();
|
||||
return;
|
||||
}
|
||||
timerRef.current = window.requestAnimationFrame(tick);
|
||||
};
|
||||
timerRef.current = window.requestAnimationFrame(tick);
|
||||
return () => {
|
||||
if (timerRef.current) cancelAnimationFrame(timerRef.current);
|
||||
};
|
||||
}, [currentStory, paused, goNext]);
|
||||
|
||||
useEffect(() => {
|
||||
elapsedRef.current = 0;
|
||||
setProgress(0);
|
||||
}, [userIndex, storyIndex]);
|
||||
|
||||
const handlePointerDown = () => {
|
||||
setPaused(true);
|
||||
if (videoRef.current) videoRef.current.pause();
|
||||
elapsedRef.current += Date.now() - startRef.current;
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
setPaused(false);
|
||||
startRef.current = Date.now();
|
||||
if (videoRef.current && currentStory?.media_type === "video") {
|
||||
videoRef.current.play().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentUser || !currentStory) return null;
|
||||
|
||||
const mediaSrc = currentStory.media_path.startsWith("http")
|
||||
? currentStory.media_path
|
||||
: buildStorageUrl(currentStory.media_path);
|
||||
|
||||
const displayName =
|
||||
currentUser.user.user_name ||
|
||||
[currentUser.user.first_name, currentUser.user.last_name]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black">
|
||||
{/* Progress bars */}
|
||||
<div className="absolute left-0 right-0 top-0 z-20 flex gap-1 px-2 pt-[calc(0.5rem+env(safe-area-inset-top))]">
|
||||
{currentUser.stories.map((_, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="h-[2px] flex-1 overflow-hidden rounded-full bg-white/30"
|
||||
>
|
||||
<div
|
||||
className="h-full bg-white transition-all duration-100 ease-linear"
|
||||
style={{
|
||||
width:
|
||||
idx < storyIndex
|
||||
? "100%"
|
||||
: idx === storyIndex
|
||||
? `${progress * 100}%`
|
||||
: "0%",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="absolute left-0 right-0 top-[calc(1.25rem+env(safe-area-inset-top))] z-20 flex items-center justify-between px-3">
|
||||
<Link
|
||||
href={`/users/${currentUser.user.user_name}`}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className="relative h-8 w-8 overflow-hidden rounded-full ring-1 ring-white/40">
|
||||
<Image
|
||||
src={
|
||||
currentUser.user.profile_image
|
||||
? buildStorageUrl(currentUser.user.profile_image)
|
||||
: "/images/fake-avatar.png"
|
||||
}
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-white drop-shadow">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="text-xs text-white/70">
|
||||
{formatStoryTime(currentStory.createdAt)}
|
||||
</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-9 w-9 items-center justify-center text-white"
|
||||
aria-label="بستن"
|
||||
>
|
||||
<BoldIcon name="close-circle" size={24} tinted className="text-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Media */}
|
||||
<div className="relative h-full w-full max-w-lg">
|
||||
{currentStory.media_type === "video" ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={mediaSrc}
|
||||
className="h-full w-full object-contain"
|
||||
playsInline
|
||||
muted={false}
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={mediaSrc}
|
||||
alt=""
|
||||
fill
|
||||
className="object-contain"
|
||||
priority
|
||||
unoptimized
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tap zones */}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-0 top-0 z-10 h-full w-[35%]"
|
||||
aria-label="قبلی"
|
||||
onClick={goPrev}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-0 top-0 z-10 h-full w-[65%]"
|
||||
aria-label="بعدی"
|
||||
onClick={goNext}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatStoryTime(value?: string): string {
|
||||
if (!value) return "";
|
||||
const diff = Date.now() - Date.parse(value);
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
if (hours < 1) return "اکنون";
|
||||
if (hours < 24) return `${hours} س`;
|
||||
return "دیروز";
|
||||
}
|
||||
30
src/constants/exploreFilters.ts
Normal file
30
src/constants/exploreFilters.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export type ExploreFilterId =
|
||||
| "all"
|
||||
| "model"
|
||||
| "photographer"
|
||||
| "hairstylist"
|
||||
| "academy"
|
||||
| "trending"
|
||||
| "makeup"
|
||||
| "professional"
|
||||
| "best_month"
|
||||
| "near_me";
|
||||
|
||||
export type ExploreFilterItem = {
|
||||
id: ExploreFilterId;
|
||||
label: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export const EXPLORE_FILTERS: ExploreFilterItem[] = [
|
||||
{ id: "all", label: "همه", color: "#a3a3a3" },
|
||||
{ id: "model", label: "مدل", color: "#B89FFF" },
|
||||
{ id: "photographer", label: "عکاس", color: "#dace63" },
|
||||
{ id: "hairstylist", label: "آرایشگر", color: "#23bace" },
|
||||
{ id: "academy", label: "آموزشی", color: "#38bdf8" },
|
||||
{ id: "trending", label: "ترند", color: "#f97316" },
|
||||
{ id: "makeup", label: "میکاپ", color: "#ec4899" },
|
||||
{ id: "professional", label: "متخصص حرفهای", color: "#a78bfa" },
|
||||
{ id: "best_month", label: "بهترینهای این ماه", color: "#fbbf24" },
|
||||
{ id: "near_me", label: "نزدیک من", color: "#34d399" },
|
||||
];
|
||||
@@ -1,11 +1,11 @@
|
||||
import { IAdvertising, Project } from "@/types/types";
|
||||
|
||||
export const navLinks = [
|
||||
// {
|
||||
// title: "اتاق کار",
|
||||
// href: "/workroom",
|
||||
// icon: "box.svg",
|
||||
// },
|
||||
{
|
||||
title: "پروژههای من",
|
||||
href: "/workroom",
|
||||
icon: "box.svg",
|
||||
},
|
||||
{
|
||||
title: "بیلبورد",
|
||||
href: "/my-billboards",
|
||||
|
||||
@@ -9,14 +9,12 @@ import axios, {
|
||||
InternalAxiosRequestConfig,
|
||||
} from "axios";
|
||||
import toast from "react-hot-toast";
|
||||
import { BASE_URL as base } from "@/components/main/BaseUrl";
|
||||
import { getApiBaseUrl } from "@/components/main/BaseUrl";
|
||||
import Cookies from "js-cookie";
|
||||
import { clearAuthSession } from "@/lib/auth/session";
|
||||
|
||||
const BASE_URL = base;
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
timeout: 900000, // 5 دقیقه (300,000 میلیثانیه)
|
||||
timeout: 900000,
|
||||
maxContentLength: Infinity,
|
||||
maxBodyLength: Infinity,
|
||||
});
|
||||
@@ -29,11 +27,25 @@ const getToken = (): string => {
|
||||
return "";
|
||||
};
|
||||
|
||||
// اضافه کردن توکن به هر درخواست
|
||||
const isPublicAuthRequest = (url: string): boolean =>
|
||||
url.includes("/login") || url.includes("/register");
|
||||
|
||||
// اضافه کردن توکن به درخواستهای محافظتشده (نه لاگین/ثبتنام)
|
||||
axiosInstance.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const requestUrl = String(config.url ?? "");
|
||||
|
||||
if (config.headers) {
|
||||
config.headers["Authorization"] = getToken();
|
||||
if (isPublicAuthRequest(requestUrl)) {
|
||||
delete config.headers.Authorization;
|
||||
} else {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = token;
|
||||
} else {
|
||||
delete config.headers.Authorization;
|
||||
}
|
||||
}
|
||||
}
|
||||
return config;
|
||||
},
|
||||
@@ -54,8 +66,11 @@ axiosInstance.interceptors.response.use(
|
||||
if (response) {
|
||||
const { status, data } = response;
|
||||
const noToast = config?.headers?.["X-No-Toast"];
|
||||
const requestUrl = String(config?.url ?? "");
|
||||
const isAuthRequest =
|
||||
requestUrl.includes("/login") || requestUrl.includes("/register");
|
||||
|
||||
if (status === 401 && !noToast) {
|
||||
if (status === 401 && !noToast && !isAuthRequest) {
|
||||
handleUnauthorized();
|
||||
} else if (status === 403 && (data as { type?: string })?.type === "block") {
|
||||
if (!noToast && (data as { message?: string })?.message) {
|
||||
@@ -103,7 +118,16 @@ const handleUnauthorized = (): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const path = window.location.pathname;
|
||||
if (path.startsWith("/login") || path.startsWith("/register")) return;
|
||||
if (
|
||||
path.startsWith("/login") ||
|
||||
path.startsWith("/register") ||
|
||||
path.startsWith("/verify-otp") ||
|
||||
path.startsWith("/forget-password") ||
|
||||
path.startsWith("/verify") ||
|
||||
path.startsWith("/auth")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearAuthSession().finally(() => {
|
||||
window.location.href = "/login";
|
||||
@@ -136,6 +160,7 @@ const useAxios = () => {
|
||||
method,
|
||||
url,
|
||||
data,
|
||||
baseURL: getApiBaseUrl(),
|
||||
...config,
|
||||
headers: {
|
||||
|
||||
|
||||
50
src/lib/api/upstreamProxy.ts
Normal file
50
src/lib/api/upstreamProxy.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export const UPSTREAM_API =
|
||||
process.env.UPSTREAM_API_URL ?? "https://api.modstagram.ir";
|
||||
|
||||
type ProxyOptions = {
|
||||
stripAuth?: boolean;
|
||||
};
|
||||
|
||||
export async function proxyToUpstream(
|
||||
req: Request,
|
||||
upstreamPath: string,
|
||||
options?: ProxyOptions
|
||||
): Promise<Response> {
|
||||
const url = new URL(upstreamPath, UPSTREAM_API);
|
||||
const incoming = new URL(req.url);
|
||||
url.search = incoming.search;
|
||||
|
||||
const headers = new Headers();
|
||||
const contentType = req.headers.get("content-type");
|
||||
if (contentType) headers.set("content-type", contentType);
|
||||
|
||||
if (!options?.stripAuth) {
|
||||
const auth = req.headers.get("authorization");
|
||||
if (auth) headers.set("authorization", auth);
|
||||
}
|
||||
|
||||
const method = req.method;
|
||||
const body =
|
||||
method !== "GET" && method !== "HEAD" ? await req.arrayBuffer() : undefined;
|
||||
|
||||
const upstream = await fetch(url.toString(), {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const responseHeaders = new Headers();
|
||||
const upstreamType = upstream.headers.get("content-type");
|
||||
if (upstreamType) responseHeaders.set("content-type", upstreamType);
|
||||
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
export function isPublicAuthPath(pathSegments: string[]): boolean {
|
||||
const path = pathSegments.join("/");
|
||||
return path.startsWith("login") || path.startsWith("register");
|
||||
}
|
||||
82
src/lib/auth/postLogin.ts
Normal file
82
src/lib/auth/postLogin.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
|
||||
import type { IVerifyOtp } from "@/types/types";
|
||||
import { setAuthSession } from "@/lib/auth/session";
|
||||
import { AUTH_SUCCESS_DELAY_MS, pause } from "@/lib/auth/feedback";
|
||||
|
||||
export function getSafeRedirectPath(): string {
|
||||
if (typeof window === "undefined") return "/";
|
||||
const redirect = new URLSearchParams(window.location.search).get("redirect");
|
||||
if (redirect && redirect.startsWith("/") && !redirect.startsWith("//")) {
|
||||
return redirect;
|
||||
}
|
||||
return "/";
|
||||
}
|
||||
|
||||
export async function navigateIncompleteRegistration(
|
||||
router: AppRouterInstance,
|
||||
step?: string
|
||||
) {
|
||||
switch (step) {
|
||||
case "user_name":
|
||||
router.push("/register/username");
|
||||
break;
|
||||
case "password":
|
||||
router.push("/register/password");
|
||||
break;
|
||||
case "first_name":
|
||||
router.push("/register/fullname");
|
||||
break;
|
||||
case "user_type":
|
||||
router.push("/register/usertype");
|
||||
break;
|
||||
default:
|
||||
router.push("/verify/avatar");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export async function completeLogin(
|
||||
router: AppRouterInstance,
|
||||
response: IVerifyOtp,
|
||||
options?: { redirectTo?: string }
|
||||
): Promise<boolean> {
|
||||
if (!response?.token) return false;
|
||||
|
||||
await setAuthSession(response.token, {
|
||||
id: response.id,
|
||||
user_type: response.user_type,
|
||||
step: response.step,
|
||||
});
|
||||
|
||||
await pause(AUTH_SUCCESS_DELAY_MS);
|
||||
router.refresh();
|
||||
|
||||
if (response.page === "home") {
|
||||
router.push(options?.redirectTo ?? getSafeRedirectPath());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (response.page === "auth-page") {
|
||||
await navigateIncompleteRegistration(router, response.step);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getAxiosErrorMessage(error: unknown): string {
|
||||
const data = (
|
||||
error as {
|
||||
response?: { data?: { message?: string; en_message?: string } };
|
||||
}
|
||||
)?.response?.data;
|
||||
|
||||
if (data?.en_message === "Not allowed by CORS") {
|
||||
return "خطا در اتصال به سرور. صفحه را رفرش کنید و دوباره تلاش کنید.";
|
||||
}
|
||||
if (data?.message) return data.message;
|
||||
if ((error as Error)?.message === "Network Error") {
|
||||
return "خطا در ارتباط با سرور. اتصال اینترنت را بررسی کنید.";
|
||||
}
|
||||
return "نام کاربری یا کلمه عبور اشتباه است.";
|
||||
}
|
||||
98
src/lib/coldStartFeed.ts
Normal file
98
src/lib/coldStartFeed.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { Post } from "@/types/types";
|
||||
|
||||
const EXPERTISE_ALIASES: Record<string, string[]> = {
|
||||
مدل: ["مدل", "مدلینگ"],
|
||||
مدلینگ: ["مدل", "مدلینگ"],
|
||||
عکاس: ["عکاس", "عکاسی"],
|
||||
عکاسی: ["عکاس", "عکاسی"],
|
||||
آرایشگر: ["آرایشگر", "زیبایی"],
|
||||
زیبایی: ["آرایشگر", "زیبایی"],
|
||||
};
|
||||
|
||||
function expertiseMatches(a?: string, b?: string): boolean {
|
||||
if (!a || !b) return false;
|
||||
const aliases = EXPERTISE_ALIASES[a] || [a];
|
||||
return aliases.includes(b);
|
||||
}
|
||||
|
||||
function shuffle<T>(items: T[], seed = Date.now()): T[] {
|
||||
const copy = [...items];
|
||||
let state = seed % 2147483647 || 1;
|
||||
const random = () => {
|
||||
state = (state * 16807) % 2147483647;
|
||||
return (state - 1) / 2147483646;
|
||||
};
|
||||
for (let i = copy.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(random() * (i + 1));
|
||||
[copy[i], copy[j]] = [copy[j], copy[i]];
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
function interleaveWeighted<T>(primary: T[], secondary: T[]): T[] {
|
||||
if (!primary.length) return shuffle(secondary);
|
||||
if (!secondary.length) return shuffle(primary);
|
||||
|
||||
const result: T[] = [];
|
||||
let primaryIdx = 0;
|
||||
let secondaryIdx = 0;
|
||||
let primaryCount = 0;
|
||||
|
||||
while (primaryIdx < primary.length || secondaryIdx < secondary.length) {
|
||||
if (
|
||||
secondaryIdx < secondary.length &&
|
||||
(primaryIdx >= primary.length || primaryCount >= 3)
|
||||
) {
|
||||
result.push(secondary[secondaryIdx]);
|
||||
secondaryIdx += 1;
|
||||
primaryCount = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (primaryIdx < primary.length) {
|
||||
result.push(primary[primaryIdx]);
|
||||
primaryIdx += 1;
|
||||
primaryCount += 1;
|
||||
} else if (secondaryIdx < secondary.length) {
|
||||
result.push(secondary[secondaryIdx]);
|
||||
secondaryIdx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function matchesSeedCategory(post: Post, seed: Post): boolean {
|
||||
if (seed.expertise && expertiseMatches(seed.expertise, post.expertise)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
seed.sub_expertise?.length &&
|
||||
post.sub_expertise?.some((item) => seed.sub_expertise?.includes(item))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Cold-start reels order: random, but ~72% same category as clicked post */
|
||||
export function reorderColdStartFeed(posts: Post[], seed: Post): Post[] {
|
||||
const sameCategory: Post[] = [];
|
||||
const others: Post[] = [];
|
||||
|
||||
posts.forEach((post) => {
|
||||
if (matchesSeedCategory(post, seed)) {
|
||||
sameCategory.push(post);
|
||||
} else {
|
||||
others.push(post);
|
||||
}
|
||||
});
|
||||
|
||||
const seedKey = String(seed._id || "");
|
||||
return interleaveWeighted(
|
||||
shuffle(sameCategory, seedKey.length * 997),
|
||||
shuffle(others, seedKey.length * 499)
|
||||
);
|
||||
}
|
||||
44
src/lib/explore/buildPostFilters.ts
Normal file
44
src/lib/explore/buildPostFilters.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { ExploreFilterId } from "@/constants/exploreFilters";
|
||||
import { USER_LEVELS } from "@/lib/userLevel";
|
||||
|
||||
export type PostFetchFilters = {
|
||||
expertise?: string;
|
||||
userLevel?: string;
|
||||
exploreFilter?: string;
|
||||
subExpertise?: string;
|
||||
lat?: string;
|
||||
lng?: string;
|
||||
};
|
||||
|
||||
export function buildExplorePostFilters(
|
||||
filter: ExploreFilterId,
|
||||
coords?: { lat: number; lng: number } | null
|
||||
): PostFetchFilters {
|
||||
switch (filter) {
|
||||
case "model":
|
||||
return { exploreFilter: "model", expertise: "مدل" };
|
||||
case "photographer":
|
||||
return { exploreFilter: "photographer", expertise: "عکاس" };
|
||||
case "hairstylist":
|
||||
return { exploreFilter: "hairstylist", expertise: "آرایشگر" };
|
||||
case "makeup":
|
||||
return { exploreFilter: "makeup", subExpertise: "میکاپ" };
|
||||
case "professional":
|
||||
return { exploreFilter: "professional", userLevel: USER_LEVELS.PRO };
|
||||
case "trending":
|
||||
return { exploreFilter: "trending" };
|
||||
case "best_month":
|
||||
return { exploreFilter: "best_month" };
|
||||
case "near_me":
|
||||
if (coords) {
|
||||
return {
|
||||
exploreFilter: "near_me",
|
||||
lat: String(coords.lat),
|
||||
lng: String(coords.lng),
|
||||
};
|
||||
}
|
||||
return { exploreFilter: "near_me" };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
12
src/lib/getCreateContentPath.ts
Normal file
12
src/lib/getCreateContentPath.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function getCreatePostPath(): string {
|
||||
return "/new-post";
|
||||
}
|
||||
|
||||
export function getCreateProjectPath(): string {
|
||||
return "/new-project";
|
||||
}
|
||||
|
||||
/** @deprecated همه کاربران میتوانند پست و پروژه ثبت کنند */
|
||||
export function getCreateContentPath(_userType?: string | null): string {
|
||||
return getCreatePostPath();
|
||||
}
|
||||
23
src/lib/reelsSeedPost.ts
Normal file
23
src/lib/reelsSeedPost.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Post } from "@/types/types";
|
||||
|
||||
const KEY_PREFIX = "reels-seed-post-";
|
||||
|
||||
export function cacheReelsSeedPost(post: Post): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
sessionStorage.setItem(`${KEY_PREFIX}${post._id}`, JSON.stringify(post));
|
||||
} catch {
|
||||
/* ignore quota errors */
|
||||
}
|
||||
}
|
||||
|
||||
export function readReelsSeedPost(id: string): Post | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(`${KEY_PREFIX}${id}`);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as Post;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -431,7 +431,8 @@ export interface Post {
|
||||
type?: string;
|
||||
files: PostFile[];
|
||||
userId: string;
|
||||
|
||||
profile_image?: string;
|
||||
sub_expertise?: string[];
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user