diff --git a/next.config.ts b/next.config.ts index 39d0fe4..7e2c51c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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 [ { diff --git a/src/api/fetchAcademyExplore.ts b/src/api/fetchAcademyExplore.ts new file mode 100644 index 0000000..249b060 --- /dev/null +++ b/src/api/fetchAcademyExplore.ts @@ -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 }; +} diff --git a/src/api/fetchBillboards.ts b/src/api/fetchBillboards.ts index 77f99b3..559c448 100644 --- a/src/api/fetchBillboards.ts +++ b/src/api/fetchBillboards.ts @@ -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); diff --git a/src/api/fetchPostById.ts b/src/api/fetchPostById.ts index 4716b73..fcdabb1 100644 --- a/src/api/fetchPostById.ts +++ b/src/api/fetchPostById.ts @@ -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 { + 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; } diff --git a/src/api/fetchPosts.ts b/src/api/fetchPosts.ts index 6ea9ad4..fcd834c 100644 --- a/src/api/fetchPosts.ts +++ b/src/api/fetchPosts.ts @@ -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", diff --git a/src/api/fetchProjects.ts b/src/api/fetchProjects.ts index 92099fb..a47b317 100644 --- a/src/api/fetchProjects.ts +++ b/src/api/fetchProjects.ts @@ -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", // غیرفعال کردن کش diff --git a/src/api/fetchSearch.ts b/src/api/fetchSearch.ts index d3e37c8..7c34806 100644 --- a/src/api/fetchSearch.ts +++ b/src/api/fetchSearch.ts @@ -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', // غیرفعال کردن کش diff --git a/src/api/fetchStories.ts b/src/api/fetchStories.ts new file mode 100644 index 0000000..4f7fcaf --- /dev/null +++ b/src/api/fetchStories.ts @@ -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 { + 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 { + 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 { + 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 || "خطا در انتشار استوری"); + } +} diff --git a/src/api/trackExploreInteraction.ts b/src/api/trackExploreInteraction.ts new file mode 100644 index 0000000..7425539 --- /dev/null +++ b/src/api/trackExploreInteraction.ts @@ -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 { + 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 */ + } +} diff --git a/src/app/(auth)/(login)/login-with-username/page.tsx b/src/app/(auth)/(login)/login-with-username/page.tsx index 1e3fe8f..c02f0e2 100644 --- a/src/app/(auth)/(login)/login-with-username/page.tsx +++ b/src/app/(auth)/(login)/login-with-username/page.tsx @@ -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} )} - + {authError && errorMessage && ( + + {errorMessage} + + )} + ورود diff --git a/src/app/(auth)/(login)/login/page.tsx b/src/app/(auth)/(login)/login/page.tsx index 63d9c5c..d629a96 100644 --- a/src/app/(auth)/(login)/login/page.tsx +++ b/src/app/(auth)/(login)/login/page.tsx @@ -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() { ارسال کد - + با نام کاربری و کلمه عبور خود وارد شوید diff --git a/src/app/(auth)/verify/confirm/page.tsx b/src/app/(auth)/verify/confirm/page.tsx index 0639436..e7fd5b4 100644 --- a/src/app/(auth)/verify/confirm/page.tsx +++ b/src/app/(auth)/verify/confirm/page.tsx @@ -18,8 +18,6 @@ function Confirm() { const router = useRouter(); const [showModal, setShowModal] = useState(false); const [navigating, setNavigating] = useState(false); - const userType: string | null = - typeof window !== "undefined" ? localStorage.getItem("usertype") : null; const [verifiedStatus, setVerifiedStatus] = useState(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 ( diff --git a/src/app/(projects)/new-project/layout.tsx b/src/app/(projects)/new-project/layout.tsx index 0291fbe..365ca55 100644 --- a/src/app/(projects)/new-project/layout.tsx +++ b/src/app/(projects)/new-project/layout.tsx @@ -6,7 +6,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
{children} - +
); } diff --git a/src/app/(projects)/projects/[id]/[title]/ProjectDetailClient.tsx b/src/app/(projects)/projects/[id]/[title]/ProjectDetailClient.tsx new file mode 100644 index 0000000..9217ede --- /dev/null +++ b/src/app/(projects)/projects/[id]/[title]/ProjectDetailClient.tsx @@ -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(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 ; + + if (error || !project) { + return ( + +

پروژه یافت نشد.

+
+ ); + } + + return ( + + + + + ); +} diff --git a/src/app/(projects)/projects/[id]/[title]/page.tsx b/src/app/(projects)/projects/[id]/[title]/page.tsx new file mode 100644 index 0000000..489962e --- /dev/null +++ b/src/app/(projects)/projects/[id]/[title]/page.tsx @@ -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 { + 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 ( + }> + + + ); +} diff --git a/src/app/(projects)/projects/layout.tsx b/src/app/(projects)/projects/layout.tsx new file mode 100644 index 0000000..23bdee3 --- /dev/null +++ b/src/app/(projects)/projects/layout.tsx @@ -0,0 +1,16 @@ +import Header from "@/components/main/Header"; +import TabNavigation from "@/components/TabNavigation"; + +export default function ProjectsLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+ {children} + +
+ ); +} diff --git a/src/app/(projects)/projects/page.tsx b/src/app/(projects)/projects/page.tsx new file mode 100644 index 0000000..e344399 --- /dev/null +++ b/src/app/(projects)/projects/page.tsx @@ -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 ( + +

پروژه‌های مد و زیبایی در مدستاگرام

+ + +
+ ); +} diff --git a/src/app/[[...slug]]/page.tsx b/src/app/[[...slug]]/page.tsx index bc9d0ed..8ba21a5 100644 --- a/src/app/[[...slug]]/page.tsx +++ b/src/app/[[...slug]]/page.tsx @@ -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) { {/* کامپوننت فیلتر با مقدار تخصص فعلی */} - + + + {/* نمایش پست‌ها با تمام فیلترهای استخراج شده */} }; + +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; diff --git a/src/app/explore/[id]/page.tsx b/src/app/explore/[id]/page.tsx index 662e456..92c7aec 100644 --- a/src/app/explore/[id]/page.tsx +++ b/src/app/explore/[id]/page.tsx @@ -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 ( + + ); +} + export default function ExploreReelPage({ params, }: { params: Promise<{ id: string }>; }) { const { id } = use(params); + return ( - +
}> - + - +
); } diff --git a/src/app/explore/page.tsx b/src/app/explore/page.tsx index eeca623..f4bddf7 100644 --- a/src/app/explore/page.tsx +++ b/src/app/explore/page.tsx @@ -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("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 ( <> +
-
- - - -

اکسپلور

-
- + +
diff --git a/src/app/new-post/layout.tsx b/src/app/new-post/layout.tsx index 46b44e0..81128eb 100644 --- a/src/app/new-post/layout.tsx +++ b/src/app/new-post/layout.tsx @@ -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 (
- {children} + }> + {children} +
); diff --git a/src/app/new-post/page.tsx b/src/app/new-post/page.tsx index d91b64b..a180f6d 100644 --- a/src/app/new-post/page.tsx +++ b/src/app/new-post/page.tsx @@ -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 = { + image: "عکس", + video: "ویدئو", + story: "استوری", +}; + +function fileToBase64(file: File): Promise { + 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([""]); - const [description, setDescription] = useState(""); + + const initialMode = (searchParams.get("type") as CreateMode) || "image"; + const [mode, setMode] = useState( + ["image", "video", "story"].includes(initialMode) ? initialMode : "image" + ); + const [file, setFile] = useState(null); + const [preview, setPreview] = useState(""); + const [extraImages, setExtraImages] = useState<(File | null)[]>([null]); + const [extraPreviews, setExtraPreviews] = useState([""]); + const [description, setDescription] = useState(""); const [taggedUsers, setTaggedUsers] = useState([]); - const fileToBase64 = (file: File): Promise => { - 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) => { - 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) => { + 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) => { + 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 = { + 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 ( - -
- ثبت پست - -
- selectPostType("image")} className="w-36 h-10 active:bg-white focus:bg-slate-800"> - ثبت تصاویر - - selectPostType("video")} className="w-36 h-10 active:bg-white focus:bg-slate-800"> - ثبت ویدئو - -
- - <> -
- {postType === "video" && previews.length === 0 ? ( -
- - -
- ) : ( - previews.map((preview, index) => ( -
- {preview ? ( - <> - {postFiles[index]?.type?.startsWith('video/') ? ( -
- )) - )} -
- - - -
-
-