Initial commit

This commit is contained in:
payacom
2026-07-09 15:26:46 +03:30
parent ff2009c402
commit edb094e323
65 changed files with 4083 additions and 803 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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", // غیرفعال کردن کش

View File

@@ -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
View 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 || "خطا در انتشار استوری");
}
}

View 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 */
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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("/")}`);
}

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>>;
}>({

View File

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

View File

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

View 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>
</>
);
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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}
/>
)}
</>
);
}

View 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 "دیروز";
}

View 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" },
];

View File

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

View File

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

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

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

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

View File

@@ -431,7 +431,8 @@ export interface Post {
type?: string;
files: PostFile[];
userId: string;
profile_image?: string;
sub_expertise?: string[];
}