diff --git a/.claude/launch.json b/.claude/launch.json deleted file mode 100644 index bc5aa17..0000000 --- a/.claude/launch.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "modstagram-next-dev", - "runtimeExecutable": "npm", - "runtimeArgs": ["run", "dev"], - "port": 3004 - } - ] -} diff --git a/.claude/worktrees/agent-af1dfc8fe9a6cb198 b/.claude/worktrees/agent-af1dfc8fe9a6cb198 new file mode 160000 index 0000000..3b89bbf --- /dev/null +++ b/.claude/worktrees/agent-af1dfc8fe9a6cb198 @@ -0,0 +1 @@ +Subproject commit 3b89bbfec327e50e9a1d5952f429864b48f058a3 diff --git a/_dead_code_quarantine/NewBillboard-duplicate-component/MultiStepForm.tsx b/_dead_code_quarantine/NewBillboard-duplicate-component/MultiStepForm.tsx deleted file mode 100644 index cad7483..0000000 --- a/_dead_code_quarantine/NewBillboard-duplicate-component/MultiStepForm.tsx +++ /dev/null @@ -1,24 +0,0 @@ -"use client"; - -import { useState } from "react"; -import Step1 from "./Step1"; -import Step2 from "./Step2"; -import Step3 from "./Step3"; -import Step4 from "./Step4"; -import Step5 from "./Step5"; - -const MultiStepForm = () => { - const [step, setStep] = useState(1); - - return ( -
- {step === 1 && setStep(2)} />} - {step === 2 && setStep(3)} />} - {step === 3 && setStep(4)} />} - {step === 4 && setStep(5)} />} - {step === 5 && } -
- ); -}; - -export default MultiStepForm; diff --git a/_dead_code_quarantine/NewBillboard-duplicate-component/Step1.tsx b/_dead_code_quarantine/NewBillboard-duplicate-component/Step1.tsx deleted file mode 100644 index a976c8b..0000000 --- a/_dead_code_quarantine/NewBillboard-duplicate-component/Step1.tsx +++ /dev/null @@ -1,151 +0,0 @@ -"use client"; - -import RoundedButton from "@/components/elements/RoundedButton"; -import { useFormik } from "formik"; -import * as Yup from "yup"; -import LocationSelector from "./step1/LocationSelector"; -import { useBillboardForm } from "@/contexts/BillboardFormContext"; -import { useEffect, useMemo, useState } from "react"; -import useAxios from "@/hooks/useAxios"; -import SelectBox from "@/components/elements/SelectBox"; -import { IAdvertisingCategory } from "@/types/types"; -import RoundedInput from "@/components/elements/RoundedInput"; -import ImageUploader from "./step1/ImageUploader"; -import { useTranslation } from "react-i18next"; - -const Step1 = ({ nextStep }: { nextStep: () => void }) => { - const { t } = useTranslation("common"); - const { formData, updateForm } = useBillboardForm(); - const [categories, setCategories] = useState([]); - const { request } = useAxios(); - - const validationSchema = useMemo( - () => - Yup.object({ - stateId: Yup.string().required(t("billboards.form.validation.provinceRequired")), - cityId: Yup.string().required(t("billboards.form.validation.cityRequired")), - categoryId: Yup.string().required(t("billboards.form.validation.categoryRequired")), - adsTitle: Yup.string().required(t("billboards.form.validation.titleRequired")), - description: Yup.string().required(t("billboards.form.validation.descriptionRequired")), - neighbourhood: Yup.string().required(t("billboards.form.validation.neighbourhoodRequired")), - address: Yup.string().required(t("billboards.form.validation.addressRequired")), - images: Yup.array() - .min(1, t("billboards.form.validation.minImages")) - .max(5, t("billboards.form.validation.maxImages")) - .required(t("billboards.form.validation.imagesRequired")), - markerCoordinate: Yup.array() - .of(Yup.number().required()) - .length(2, t("billboards.form.validation.mapLocationRequired")) - .required(t("billboards.form.validation.mapLocationRequired")), - }), - [t] - ); - - useEffect(() => { - const fetchData = async () => { - try { - const response = await request<{ categories: IAdvertisingCategory[] }>( - "GET", - "/advertising/categories" - ); - setCategories(response?.categories); - } catch (err) { - console.log(err); - } - }; - fetchData(); - }, [request]); - - const formik = useFormik({ - initialValues: { - cityId: formData.cityId || "", - stateId: formData.stateId || "", - adsTitle: formData.adsTitle || "", - categoryId: formData.categoryId || "", - images: formData.images || [], - address: formData.address || "", - neighbourhood: formData.neighbourhood || "", - description: formData.description || "", - markerCoordinate: formData.markerCoordinate || null, - }, - validationSchema, - onSubmit: (values) => { - updateForm(values); - nextStep(); - }, - }); - - return ( -
-
{t("billboards.createTitle")}
- formik.setFieldValue("categoryId", e.target.value)} - > - - {categories?.map((item: IAdvertisingCategory) => ( - - ))} - - {formik.touched.categoryId && formik.errors.categoryId && ( -

{formik.errors.categoryId}

- )} -
- - {formik.touched.adsTitle && formik.errors.adsTitle && ( -

{formik.errors.adsTitle}

- )} - - {formik.touched.address && formik.errors.address && ( -

{formik.errors.address}

- )} -
- - - {selectedLocation && ( - - location icon - - )} - - {formik.touched.markerCoordinate && formik.errors.markerCoordinate && ( -

- {formik.errors.markerCoordinate as string} -

- )} -
- - ); -}; - -export default LocationSelector; diff --git a/next.config.ts b/next.config.ts index 8152ad1..6d6b85f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -66,25 +66,31 @@ const nextConfig = { minimumCacheTTL: 31536000, }, - // ۳. ریدایرکت دائمی دامنه قدیمی .ir به دامنه اصلی .com + // ۳. ریدایرکت دائمی دامنه قدیمی .ir و نسخه بدون www به دامنه اصلی www.modstagram.com async redirects() { return [ { source: '/:path*', has: [{ type: 'host', value: 'modstagram.ir' }], - destination: 'https://modstagram.com/:path*', + destination: 'https://www.modstagram.com/:path*', permanent: true, }, { source: '/:path*', has: [{ type: 'host', value: 'www.modstagram.ir' }], - destination: 'https://modstagram.com/:path*', + destination: 'https://www.modstagram.com/:path*', permanent: true, }, { source: '/:path*', has: [{ type: 'host', value: 'app.modstagram.ir' }], - destination: 'https://modstagram.com/:path*', + destination: 'https://www.modstagram.com/:path*', + permanent: true, + }, + { + source: '/:path*', + has: [{ type: 'host', value: 'modstagram.com' }], + destination: 'https://www.modstagram.com/:path*', permanent: true, }, ]; diff --git a/public/images/og-image.png b/public/images/og-image.png new file mode 100644 index 0000000..61220e4 Binary files /dev/null and b/public/images/og-image.png differ diff --git a/public/robots.txt b/public/robots.txt index b5d3f20..409ded3 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -27,6 +27,9 @@ Disallow: /*/payment/ Disallow: /projects/payment/ Disallow: /billboards/payment/ Disallow: /academy/payment/ +Disallow: /shops/new +Disallow: /shops/checkout +Disallow: /shops/orders -Host: https://modstagram.com -Sitemap: https://modstagram.com/sitemap.xml +Host: https://www.modstagram.com +Sitemap: https://www.modstagram.com/sitemap.xml diff --git a/public/service-worker.js b/public/service-worker.js index 2a172c4..e7c61e1 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -22,10 +22,18 @@ const messaging = firebase.messaging.isSupported() ? firebase.messaging() : null if (messaging) { messaging.onBackgroundMessage((payload) => { const title = payload.notification?.title || "modstagram"; + // عکسِ فرستنده (یا بنرِ دستیِ ادمین برای admin_broadcast) — قبلاً همیشه لوگوی ثابتِ + // اپ بود، صرف‌نظر از اینکه نوتیف مربوط به کدوم کاربره؛ notification.image رو + // utils/pushNotification.js بک‌اند از قبل می‌فرسته (فقط این‌جا نادیده گرفته می‌شد) + const avatarOrBanner = payload.notification?.image; self.registration.showNotification(title, { body: payload.notification?.body, - icon: "/images/icons/192x192.png", + icon: avatarOrBanner || "/images/icons/192x192.png", badge: "/images/icons/192x192.png", + // بنرِ بزرگِ زیرِ متن (برای admin_broadcast که واقعاً قراره «بنر» باشه)، غیر از + // آیکونِ کوچیکِ گردِ کنارِ عنوان — مرورگرهایی که image رو ساپورت نکنن سرشون این رو + // نادیده می‌گیرن، پس بی‌خطره + image: avatarOrBanner || undefined, data: payload.data || {}, }); }); @@ -33,7 +41,10 @@ if (messaging) { self.addEventListener("notificationclick", (event) => { event.notification.close(); - const url = "/settings/notifications"; + // بک‌اند وقتی خودش دقیقاً می‌دونه مقصد کجاست (پیام چت، تگِ پست و...) یه مسیرِ نسبیِ + // آماده می‌فرسته (نگاه کن به utils/notifyUser.js/chatHelpers.js: data.link) — وگرنه + // مثلِ قبل می‌ریم صفحه‌ی عمومیِ اعلان‌ها + const url = event.notification.data?.link || "/settings/notifications"; event.waitUntil( self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientsArr) => { const existing = clientsArr.find((c) => c.url.includes(url)); diff --git a/src/api/fetchPostById.ts b/src/api/fetchPostById.ts index fcdabb1..0e33d5e 100644 --- a/src/api/fetchPostById.ts +++ b/src/api/fetchPostById.ts @@ -15,7 +15,7 @@ export async function fetchPostById( try { const res = await fetch(primaryUrl, { - cache: "no-store", + next: { revalidate: 45 }, headers, }); if (res.ok) { @@ -35,7 +35,7 @@ export async function fetchPostById( for (const url of fallbackUrls) { try { const res = await fetch(url, { - cache: "no-store", + next: { revalidate: 45 }, headers, }); if (!res.ok) continue; diff --git a/src/api/fetchPosts.ts b/src/api/fetchPosts.ts index 3cccc7e..0b279a4 100644 --- a/src/api/fetchPosts.ts +++ b/src/api/fetchPosts.ts @@ -20,8 +20,6 @@ export async function fetchPosts( seedPostId?: string; sort?: "latest"; reelsTab?: "for_you" | "following" | "saved" | "near_me"; - lat?: string; - lng?: string; heightMin?: string; heightMax?: string; weightMin?: string; diff --git a/src/app/(auth)/(register)/register/avatar/page.tsx b/src/app/(auth)/(register)/register/avatar/page.tsx new file mode 100644 index 0000000..6df505d --- /dev/null +++ b/src/app/(auth)/(register)/register/avatar/page.tsx @@ -0,0 +1,177 @@ +"use client"; + +import React, { useEffect, useState, useCallback } from "react"; +import AuthPageLayout, { + AuthFormFooter, + AuthPageContent, +} from "@/components/auth/AuthPageLayout"; +import AuthNextButton from "@/components/auth/AuthNextButton"; +import { IMAGE_BASE_URL } from "@/components/main/BaseUrl"; +import useAxios from "@/hooks/useAxios"; +import { IProfileData } from "@/types/types"; +import Image from "next/image"; +import { useRouter } from "next/navigation"; +import toast from "react-hot-toast"; +import Cropper from "react-easy-crop"; +import getCroppedImg from "@/helpers/cropImage"; +import { Area } from "react-easy-crop"; +import { useTranslation } from "react-i18next"; +import LocalePageShell from "@/components/i18n/LocalePageShell"; + +// عین verify/avatar/page.tsx (ویزارد تایید هویت)، با این تفاوت که این مرحله +// زودتر آورده شده: همین بعد از نام/نام‌خانوادگی (register/fullname) و قبل از +// صفحه‌ی انتخابِ «ادامه ثبت‌نام / ورود به برنامه» (register/complete) — چون عکس +// همین‌جا آپلود می‌شه، دیگه لازم نیست ویزارد تایید هویت دوباره همین قدم رو بپرسه +// (verify/avatar/page.tsx دیگه از fullname/complete لینک نمی‌شه) +function RegisterAvatarPage() { + const { t } = useTranslation("common"); + const router = useRouter(); + const { request } = useAxios(); + const [avatar, setAvatar] = useState(null); + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [loadingUpload, setLoadingUpload] = useState(false); + const [croppedAreaPixels, setCroppedAreaPixels] = useState(null); + + const mobile = + typeof window !== "undefined" ? localStorage.getItem("mobile") : null; + + const fetchData = async () => { + try { + const response = await request<{ user: IProfileData }>("GET", "/profile"); + setAvatar(response?.user?.profile_image || null); + } catch (err) { + console.log(err); + } + }; + + useEffect(() => { + fetchData(); + }, []); + + const selectImage = async (event: React.ChangeEvent) => { + if (event.target.files && event.target.files[0]) { + const file = event.target.files[0]; + const reader = new FileReader(); + reader.onload = (e) => { + const imageDataUrl = e.target?.result as string; + setAvatar(imageDataUrl); + }; + reader.readAsDataURL(file); + } + }; + + const onCropComplete = useCallback( + (_croppedArea: Area, croppedPixels: Area) => { + setCroppedAreaPixels(croppedPixels); + }, + [] + ); + + const createCroppedImage = async () => { + if (!avatar || !croppedAreaPixels) return null; + const croppedImage = await getCroppedImg(avatar, croppedAreaPixels); + return croppedImage; + }; + + const goNext = () => router.push("/register/complete"); + + const uploadImage = async () => { + if (!avatar) { + toast.error(t("auth.selectProfileImage")); + return; + } + setLoadingUpload(true); + const croppedBlob = await createCroppedImage(); + if (!croppedBlob) { + toast.error(t("auth.cropError")); + setLoadingUpload(false); + return; + } + + const formData = new FormData(); + formData.append("profile_image", croppedBlob, "avatar.jpg"); + formData.append("mobile", mobile as string); + + try { + await request("POST", "/verify/profile_image", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); + toast.success(t("auth.imageSavedSuccess")); + goNext(); + } catch (err) { + console.log("Upload error:", err); + toast.error(t("auth.imageUploadError")); + setLoadingUpload(false); + } + }; + + return ( + + + + + {t("auth.profileImageTitle")} + + +
+ {avatar ? ( + + ) : ( + + )} +
+ + + + + + +
+ + + + {t("auth.saveAndContinue")} + + +
+
+ ); +} + +export default RegisterAvatarPage; diff --git a/src/app/(auth)/(register)/register/complete/page.tsx b/src/app/(auth)/(register)/register/complete/page.tsx index ffc3fa2..bf4edb2 100644 --- a/src/app/(auth)/(register)/register/complete/page.tsx +++ b/src/app/(auth)/(register)/register/complete/page.tsx @@ -14,6 +14,7 @@ import toast from "react-hot-toast"; import { getSafeRedirectPath } from "@/lib/auth/postLogin"; import { useTranslation } from "react-i18next"; import LocalePageShell from "@/components/i18n/LocalePageShell"; +import { IProfileData } from "@/types/types"; function RegisterCompletePage() { const { t } = useTranslation("common"); @@ -21,6 +22,22 @@ function RegisterCompletePage() { const { request, loading } = useAxios(); const [action, setAction] = useState<"app" | "continue" | null>(null); + // مطابق درخواست: عکس پروفایل حالا زودتر، توی خودِ صفحه‌ی register/avatar گرفته + // می‌شه (قبل از این صفحه) — پس دیگه لازم نیست ویزارد تایید هویت با verify/avatar + // شروع بشه؛ مستقیم می‌ره سراغ قدم بعدی (عین navigateHandler توی verify/avatar/page.tsx) + const goToVerifyWizard = async () => { + try { + const response = await request<{ user: IProfileData }>("GET", "/profile"); + if (response?.user?.user_type === "user") { + router.push("/verify/expertise"); + return; + } + } catch (err) { + console.log(err); + } + router.push("/verify/gender"); + }; + const finishRegistration = async (next: "app" | "continue") => { setAction(next); try { @@ -30,7 +47,7 @@ function RegisterCompletePage() { localStorage.setItem("usertype", "user"); if (next === "continue") { - router.push("/verify/avatar"); + await goToVerifyWizard(); } else { router.push(getSafeRedirectPath()); } @@ -59,7 +76,7 @@ function RegisterCompletePage() { finishRegistration("continue")} - className=" w-full sm:w-auto" + className="!border-[#16a34a] !text-[#16a34a] w-full sm:w-auto" disabled={loading} loading={loading && action === "continue"} > diff --git a/src/app/(auth)/(register)/register/fullname/page.tsx b/src/app/(auth)/(register)/register/fullname/page.tsx index f1673db..3ce0c61 100644 --- a/src/app/(auth)/(register)/register/fullname/page.tsx +++ b/src/app/(auth)/(register)/register/fullname/page.tsx @@ -14,7 +14,6 @@ import * as yup from "yup"; import { useRouter } from "next/navigation"; import AuthNextButton from "@/components/auth/AuthNextButton"; import toast from "react-hot-toast"; -import { getSafeRedirectPath } from "@/lib/auth/postLogin"; import { useTranslation } from "react-i18next"; import LocalePageShell from "@/components/i18n/LocalePageShell"; import Image from "next/image"; @@ -32,9 +31,6 @@ function FullNamePage() { const router = useRouter(); const { request, loading } = useAxios(); const [nameSaved, setNameSaved] = useState(false); - const [choiceLoading, setChoiceLoading] = useState<"app" | "continue" | null>( - null - ); const [avatar, setAvatar] = useState(null); const isInstagramProvider = @@ -68,25 +64,18 @@ function FullNamePage() { [t] ); - const finishRegistration = async (action: "app" | "continue") => { - setChoiceLoading(action); + // فقط برای ثبت‌نامِ اینستاگرام صدا زده می‌شه (پایین‌تر) — چون اون کاربرها از قبل + // عکس دارن، همون ویزارد تایید هویت (verify/avatar) رو می‌بینن، نه صفحه‌ی جدیدِ + // register/avatar. بقیه‌ی کاربرها بعد از ثبت نام مستقیم به register/avatar می‌رن + const finishRegistrationInstagram = async () => { try { - await request("POST", "/register/user_type", { - user_type: "user", - }); + await request("POST", "/register/user_type", { user_type: "user" }); localStorage.setItem("usertype", "user"); - - if (action === "continue") { - router.push("/verify/avatar"); - } else { - router.push(getSafeRedirectPath()); - } + router.push("/verify/avatar"); } catch (err: any) { toast.error( err?.response?.data?.message || t("auth.registrationFinishFailed") ); - } finally { - setChoiceLoading(null); } }; @@ -117,7 +106,11 @@ function FullNamePage() { // no manual "enter app or continue" choice, matching the phone/Google flow's // choice screen being skipped only for this provider. if (isInstagramProvider) { - await finishRegistration("continue"); + await finishRegistrationInstagram(); + } else { + // بقیه‌ی کاربرها: به‌جای دکمه‌های «ادامه/ورود به برنامه» زیر همین فرم، + // اول مرحله‌ی آپلود عکس پروفایل میاد + router.push("/register/avatar"); } } catch (err: any) { toast.error(err?.response?.data?.message || t("auth.saveNameFailed")); @@ -196,48 +189,17 @@ function FullNamePage() { {formik.errors.family} )} - - {nameSaved && ( -

- {isInstagramProvider - ? t("auth.instagramContinuingToProfile") - : t("auth.nameSavedChoose")} -

- )} - {!nameSaved ? ( - - {t("auth.saveAndContinue")} - - ) : !isInstagramProvider ? ( -
- finishRegistration("continue")} - className=" w-full sm:w-auto" - disabled={Boolean(choiceLoading)} - loading={choiceLoading === "continue"} - > - {t("auth.continueRegistration")} - - finishRegistration("app")} - className="!border-[#FF0000] !text-[#FF0000] w-full sm:w-auto" - disabled={Boolean(choiceLoading)} - loading={choiceLoading === "app"} - > - {t("auth.enterApp")} - -
- ) : null} + + {t("auth.saveAndContinue")} +
diff --git a/src/app/(auth)/(register)/register/page.tsx b/src/app/(auth)/(register)/register/page.tsx index 040784a..b1db32f 100644 --- a/src/app/(auth)/(register)/register/page.tsx +++ b/src/app/(auth)/(register)/register/page.tsx @@ -46,12 +46,17 @@ function Register() { validationSchema: schema, onSubmit: async (values) => { const mobile = normalizeMobileInput(values.mobile); + // سیستم «معرفی به دوستان»: اگه کاربر قبل از این از یه لینکِ معرفی + // (/users/username?ref=CODE) اومده، ReferralCapture.tsx همین‌جا توی + // localStorage نگهش داشته — بک‌اند (registerController.js) این کد رو به + // referred_by وصل می‌کنه، پاداش بعد از تکمیلِ کاملِ ثبت‌نام پرداخت می‌شه + const referralCode = localStorage.getItem("referral_code"); try { await request( "POST", "/register", - { mobile }, + { mobile, ...(referralCode ? { referral_code: referralCode } : {}) }, { noToast: true } ); localStorage.setItem("mobile", mobile); diff --git a/src/app/(auth)/verify/sizes/page.tsx b/src/app/(auth)/verify/sizes/page.tsx index fc0389e..fbfb35f 100644 --- a/src/app/(auth)/verify/sizes/page.tsx +++ b/src/app/(auth)/verify/sizes/page.tsx @@ -101,7 +101,7 @@ function Sizes() { className={`p-1 rounded-2xl w-1/3 border text-sm flex items-center justify-center gap-1 ${ selectedButton === 1 ? "btn-modern--selected text-white" - : "bg-tertiary-light dark:bg-tertiary-dark text-black border-gray-400" + : "bg-tertiary-light dark:bg-tertiary-dark border-gray-400" }`} onClick={() => handleButtonPress(1)} > diff --git a/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx b/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx index 0e40ceb..796283b 100644 --- a/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx +++ b/src/app/(projects)/academy/[id]/[title]/CourseClient.tsx @@ -30,6 +30,7 @@ import { usePathname } from "next/navigation"; import Cookies from "js-cookie"; import { trackExploreInteraction } from "@/api/trackExploreInteraction"; import BillboardContactInfoModal from "@/components/billboards/BillboardPage/BillboardContactInfoModal"; +import WalletUsagePrompt from "@/components/payment/WalletUsagePrompt"; import { AcademyCourseDetailSkeleton } from "@/components/academy/AcademySkeletons"; import { useTranslation } from "react-i18next"; import BackButton from "@/components/ui/BackButton"; @@ -85,17 +86,28 @@ interface CourseDetailProps { initialIsPurchased?: boolean; } -interface MediaFile { - id: string; - title: string; - file: File | null; - preview?: string; +/** شکلِ واقعیِ ویدیوهایی که از /academy/academy/course/content میان و توی + * لیستِ سرفصل‌ها + پلیر رندر می‌شن (نه فرم‌آپلود — MOCK_VIDEOS[0] هم همین شکله) */ +interface CourseVideo { + _id: string; + file_name: string; course_video: string; - type: "image" | "video"; - isFree?: boolean; - is_free?: boolean; + is_free: boolean; + type: string; + duration: string; } +/** این اندپوینت‌ها ساختارهای مختلفی برمی‌گردونن (بعضاً حتی خودِ کد چند شکلِ + * احتمالی رو با || پشتِ‌سرِ‌هم چک می‌کنه) — به‌جای برگردوندنِ generic هرکدوم به + * یه interface دقیقِ جدا (که نیاز به rewrite بک‌اند برای یکدست‌کردن داشت)، یه + * تایپِ عمومیِ صریح که همون فیلدهایی که کد واقعاً استفاده می‌کنه رو پوشش می‌ده */ +type ApiResponse = { + success?: boolean; + message?: string; + data?: any; + [key: string]: any; +}; + export default function CourseDetail({ initialIsPurchased = false, }: CourseDetailProps) { @@ -105,6 +117,8 @@ export default function CourseDetail({ const [purchasing, setPurchasing] = useState(false); const [showContactInfoModal, setShowContactInfoModal] = useState(false); const [course] = useState(MOCK_COURSE); + const [walletBalance, setWalletBalance] = useState(0); + const [showWalletPrompt, setShowWalletPrompt] = useState(false); const pathname = usePathname(); const match = pathname.match(/\/academy\/([^\/]+)/); @@ -116,6 +130,13 @@ export default function CourseDetail({ const _id = courses[0]?._id; + useEffect(() => { + request<{ charged?: { available?: number } }>("GET", "/wallet/summary") + .then((res) => setWalletBalance(res?.charged?.available || 0)) + .catch(() => setWalletBalance(0)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const [comments, setComments] = useState([]); const [totalComments, setTotalComments] = useState(0); const [averageRate, setAverageRate] = useState(0); @@ -126,7 +147,7 @@ export default function CourseDetail({ const [sortBy, setSortBy] = useState("createdAt"); const [sortOrder, setSortOrder] = useState("desc"); const [usersCache, setUsersCache] = useState>({}); - const [videoFields, setVideoFields] = useState([]); + const [videoFields, setVideoFields] = useState([]); const [rating, setRating] = useState(0); @@ -138,11 +159,11 @@ export default function CourseDetail({ try { - const getAcademyCourses = (await request( + const getAcademyCourses = await request( "GET", `/academy/academy/course/content?courseId=${id}&page=1&limit=20`, {} - )) as MediaFile; + ); setVideoFields(getAcademyCourses.data.courses); setSelectedVideo(getAcademyCourses.data.courses[0]) @@ -158,7 +179,7 @@ export default function CourseDetail({ useEffect(() => { const getAcademyCourses = async () => { try { - const getCoursespayment = await request( + const getCoursespayment = await request( "GET", // تغییر به GET `/academy/course/checkCoursePurchase/${id}` // ارسال ID در مسیر ); @@ -187,7 +208,7 @@ export default function CourseDetail({ } try { - const response = await request("GET", `/profile/${userId}`); + const response = await request("GET", `/profile/${userId}`); if (response?.user) { const userData = response.user; setUsersCache((prev) => ({ ...prev, [userId]: userData })); @@ -204,7 +225,7 @@ export default function CourseDetail({ if (!_id) return; try { - const response = await request( + const response = await request( "GET", `/academy/course/${_id}/comments/count` ); @@ -223,15 +244,15 @@ export default function CourseDetail({ setLoading(true); try { - const response = await request( + const response = await request( "GET", `/academy/course/${_id}/comments?page=${pageNum}&limit=20&status=${statusFilter}&sortBy=${sortBy}&sortOrder=${sortOrder}` ); let sum = 0; let count = 0; - - response.data.stats.rateDistribution.forEach(element => { + + response.data.stats.rateDistribution.forEach((element: number) => { sum += Number(element); count++; }); @@ -242,7 +263,7 @@ export default function CourseDetail({ setRating(roundedRating); if (response?.success) { - let newComments = response.data.comments; + let newComments: any[] = response.data.comments; const pagination = response.data.pagination; if (newComments && newComments.length > 0) { @@ -340,7 +361,7 @@ export default function CourseDetail({ const getAcademyCourses = async () => { setCourseLoading(true); try { - const response = await request( + const response = await request( "GET", `/academy/academy/get/curses?page=1&limit=20&status=accept&_id=${id}` ); @@ -387,46 +408,7 @@ export default function CourseDetail({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [courses[0]?._id]); - // خرید دوره - // const handlePurchase = async () => { - // setPurchasing(true); - - // try { - // const response = await request( - // "POST", - // "/academy/academy/course/payment-web", - // { - // courseId: id, // یا _id که از courses[0]?._id می‌آید - // } - // ); - - // console.log("پاسخ کامل سرور:", response); - - // // بررسی موفقیت آمیز بودن درخواست - // if (response?.success && response?.paymentUrl) { - // // هدایت کاربر به درگاه پرداخت زرین‌پال - // window.location.href = response.paymentUrl; - // } else { - // // نمایش خطا به کاربر - // toast.error(response?.message || "خطا در اتصال به درگاه پرداخت"); - // setPurchasing(false); - // } - // } catch (error) { - // console.error("خطا در خرید:", error); - // toast.error("خطا در ارتباط با سرور"); - // setPurchasing(false); - // } - // }; - - - const handleBuyCourse = async () => { - const token = Cookies.get("token"); - - if (!token) { - toast.error(t("academy.course.loginToBuy")); - return; - } - + const startPayment = async (useWallet: boolean) => { setPurchasing(true); const loadingToastId = toast.loading( courses[0]?.is_free @@ -435,8 +417,9 @@ export default function CourseDetail({ ); try { - const response = await request("POST", "/academy/academy/course/payment-web", { + const response = await request("POST", "/academy/academy/course/payment-web", { courseId: id, + useWallet, }); toast.dismiss(loadingToastId); @@ -447,6 +430,12 @@ export default function CourseDetail({ return; } + if (response?.paidFromWallet) { + setIsPurchased(true); + toast.success(t("wallet.paidFromWalletSuccess")); + return; + } + const paymentUrl = response?.paymentUrl || response?.data?.paymentUrl || @@ -475,98 +464,23 @@ export default function CourseDetail({ } }; - const handlePurchase = async (plan: { - duration: string; - label: string; - price: number; - discount?: number; - }) => { - let loadingToastId = null; - - try { + // عین صفحه‌ی درخواست همکاری/سفارش فروشگاه: قبل پرداخت، اگه موجودی کیف پول داره + // بپرسه استفاده کنه یا نه + const handleBuyCourse = async () => { + const token = Cookies.get("token"); - - const token = Cookies.get("token"); - - if (!token) { - toast.error(t("academy.course.loginToBuy")); - return - } - - // نمایش لودینگ - loadingToastId = toast.loading(t("academy.course.connectingPayment")); - - const response = await request( - "POST", - "/academy/academy/course/payment-web", - { - courseId: id, // یا _id که از courses[0]?._id می‌آید - } - ); - - // بستن لودینگ - toast.dismiss(loadingToastId); - - // پردازش پاسخ (ساختارهای مختلف احتمالی) - let authority = null; - let paymentUrl = null; - - if (response?.data?.authority) { - authority = response.data.authority; - paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`; - } else if (response?.authority) { - authority = response.authority; - paymentUrl = `https://www.zarinpal.com/pg/StartPay/${authority}`; - } else if (response?.data?.paymentUrl) { - paymentUrl = response.data.paymentUrl; - } else if (response?.paymentUrl) { - paymentUrl = response.paymentUrl; - } - - if (paymentUrl) { - // window.open بعدِ یه await در سافاری (دسکتاپ و خصوصاً iOS) پاپ‌آپ‌بلاک می‌شه — - // چون دیگه synchronously به همون کلیکِ کاربر متصل نیست. window.location.href هم - // مطمئن‌تره، هم با بقیه‌ی مسیرهای پرداختِ این اپ (booking، پروژه، بیلبورد) یکی می‌مونه - window.location.href = paymentUrl; - return; - } else { - console.error("ساختار پاسخ نامعتبر:", response); - toast.error( - response?.data?.message || - response?.message || - t("academy.course.paymentStartError") - ); - } - - } catch (err: any) { - // بستن لودینگ در صورت خطا - if (loadingToastId) { - toast.dismiss(loadingToastId); - } - - console.error("خطا در شروع پرداخت:", err); - - // استخراج پیام خطا - let errorMessage = t("academy.course.paymentStartError"); - - if (err?.response?.data?.message) { - errorMessage = err.response.data.message; - } else if (err?.response?.data?.error) { - errorMessage = err.response.data.error; - } else if (err?.message) { - errorMessage = err.message; - } - - if (errorMessage.includes("amount") || errorMessage.includes("مبلغ")) { - errorMessage = t("academy.course.invalidAmount"); - } else if (errorMessage.includes("merchant")) { - errorMessage = t("academy.course.gatewayConfigError"); - } - - toast.error(errorMessage); + if (!token) { + toast.error(t("academy.course.loginToBuy")); + return; } - }; + if (!courses[0]?.is_free && walletBalance > 0) { + setShowWalletPrompt(true); + return; + } + + await startPayment(false); + }; // پخش ویدیو const handlePlayVideo = (video: (typeof MOCK_VIDEOS)[0]) => { @@ -591,7 +505,7 @@ export default function CourseDetail({ const finalPrice = courses[0]?.is_free ? 0 : courses[0]?.offerNumber > 0 - ? courses[0]?.price * (1 - courses[0]?.offerNumber / 100) + ? Number(courses[0]?.price) * (1 - courses[0]?.offerNumber / 100) : course.price; const courseContactInfo = courses[0]?.contactInfo; @@ -756,8 +670,8 @@ useEffect(() => { {/* آواتار کاربر */}
{ {/* اطلاعات کاربر و امتیاز */}
- {item?.user_id?.user_name || t("common.user")} - + {(typeof item?.user_id === "object" ? item.user_id?.user_name : undefined) || t("common.user")} + {item?.rate > 0 && ( @@ -855,12 +769,12 @@ useEffect(() => {
{Math.round( - courses[0]?.price * (1 - courses[0]?.offerNumber / 100) + Number(courses[0]?.price) * (1 - courses[0]?.offerNumber / 100) ).toLocaleString()}{" "} {t("settings.toman")} - {Math.round(courses[0]?.price).toLocaleString()}{" "} + {Math.round(Number(courses[0]?.price)).toLocaleString()}{" "} {t("settings.toman")} @@ -873,7 +787,7 @@ useEffect(() => {
) : ( - {Math.round(courses[0]?.price || course.price).toLocaleString()}{" "} + {Math.round(Number(courses[0]?.price) || course.price).toLocaleString()}{" "} {t("settings.toman")} )} @@ -1046,6 +960,16 @@ useEffect(() => { }} /> )} + + { + setShowWalletPrompt(false); + void startPayment(useWallet); + }} + />
); } \ No newline at end of file diff --git a/src/app/(projects)/academy/[id]/[title]/page.tsx b/src/app/(projects)/academy/[id]/[title]/page.tsx index 5de05ad..c8bf2ba 100644 --- a/src/app/(projects)/academy/[id]/[title]/page.tsx +++ b/src/app/(projects)/academy/[id]/[title]/page.tsx @@ -1,8 +1,11 @@ import { Metadata } from "next"; +import { cache } from "react"; +import { notFound } from "next/navigation"; import CourseDetail from "./CourseClient"; import { generatePageMetadata } from "@/utils/generatePageMetadata"; import { getApiBaseUrl } from "@/components/main/BaseUrl"; -import { buildCourseDetailSeo } from "@/lib/buildCourseDetailSeo"; +import { buildCourseDetailSeo, buildCourseJsonLd } from "@/lib/buildCourseDetailSeo"; +import { safeJsonLdString } from "@/lib/seo/safeJsonLd"; import { getServerLanguage } from "@/lib/i18n/server"; import { translateCommon } from "@/lib/i18n/translate"; @@ -10,20 +13,33 @@ type Props = { params: Promise<{ id: string; title: string }>; }; +// Deduped per request: generateMetadata() and the page body's JSON-LD both need +// this course — cache() avoids firing the fetch to the backend twice. +const loadCourse = cache(async (id: string) => { + try { + const res = await fetch( + `${getApiBaseUrl()}/academy/academy/get/curses?page=1&limit=20&status=accept&_id=${id}`, + { next: { revalidate: 45 } } + ); + + const response = await res.json(); + return ( + response?.data?.courses?.[0] || + response?.courses?.[0] || + response?.[0] || + null + ); + } catch (error) { + console.error("خطا در دریافت اطلاعات دوره:", error); + return null; + } +}); + export async function generateMetadata({ params }: Props): Promise { const { id, title: slugTitle } = await params; try { - const res = await fetch( - `${getApiBaseUrl()}/academy/academy/get/curses?page=1&limit=20&status=accept&_id=${id}`, - { cache: "no-store" } - ); - - const response = await res.json(); - const course = - response?.data?.courses?.[0] || - response?.courses?.[0] || - response?.[0]; + const course = await loadCourse(id); if (course) { const lang = await getServerLanguage(); @@ -53,5 +69,22 @@ export async function generateMetadata({ params }: Props): Promise { export default async function CoursePage({ params }: Props) { const { id } = await params; - return ; + const lang = await getServerLanguage(); + const course = await loadCourse(id); + + if (!course) notFound(); + + return ( + <> + {course && ( +