From 200b3875041addd3a7f6c1a21e0b68c48dd34fd3 Mon Sep 17 00:00:00 2001
From: payacom
Date: Thu, 24 Sep 2026 20:56:23 +0330
Subject: [PATCH] full app
---
.claude/launch.json | 11 -
.claude/worktrees/agent-af1dfc8fe9a6cb198 | 1 +
.../MultiStepForm.tsx | 24 -
.../Step1.tsx | 151 ----
.../Step2.tsx | 89 --
.../Step3.tsx | 176 ----
.../Step4.tsx | 193 -----
.../Step5.tsx | 268 ------
.../step1/ImageUploader.tsx | 90 ---
.../step1/LocationSelector.tsx | 202 -----
next.config.ts | 14 +-
public/images/og-image.png | Bin 0 -> 38740 bytes
public/robots.txt | 7 +-
public/service-worker.js | 15 +-
src/api/fetchPostById.ts | 4 +-
src/api/fetchPosts.ts | 2 -
.../(register)/register/avatar/page.tsx | 177 ++++
.../(register)/register/complete/page.tsx | 21 +-
.../(register)/register/fullname/page.tsx | 74 +-
src/app/(auth)/(register)/register/page.tsx | 7 +-
src/app/(auth)/verify/sizes/page.tsx | 2 +-
.../academy/[id]/[title]/CourseClient.tsx | 238 ++----
.../(projects)/academy/[id]/[title]/page.tsx | 57 +-
.../[academyId]/AcademyProfileClient.tsx | 13 +-
.../(projects)/projects/[id]/[title]/page.tsx | 24 +-
src/app/billboards/[id]/[title]/page.tsx | 53 +-
src/app/billboards/profile/[...id]/page.tsx | 9 +-
.../billboards/profile/[id]/[title]/page.tsx | 9 +-
src/app/booking/[userId]/layout.tsx | 16 +
src/app/gift/[userId]/page.tsx | 49 +-
src/app/gift/layout.tsx | 7 +
src/app/gift/payment/failed/page.tsx | 15 +-
src/app/gift/payment/success/page.tsx | 19 +-
src/app/layout.tsx | 10 +-
src/app/new-post/page.tsx | 1 +
src/app/not-found.tsx | 25 +
src/app/offer/[id]/page.tsx | 136 ++--
src/app/offer/payment/failed/page.tsx | 20 +-
src/app/offer/payment/success/page.tsx | 17 +-
src/app/posts/[id]/[[...slug]]/page.tsx | 26 +-
src/app/robots.ts | 3 +
src/app/settings/academy/Components/Box.tsx | 2 +-
.../academy/Components/OrdersCart.tsx | 2 +-
src/app/settings/academy/my-courses/page.tsx | 10 +-
src/app/settings/academy/wallet/page.tsx | 11 +-
src/app/settings/ads/[id]/page.tsx | 135 ++++
src/app/settings/ads/new/page.tsx | 765 ++++++++++++++++++
src/app/settings/ads/page.tsx | 97 +++
src/app/settings/auto-direct/page.tsx | 58 ++
src/app/settings/booking/list/page.tsx | 9 +-
.../settings/chats/[username]/[id]/page.tsx | 41 +-
src/app/settings/chats/find-partner/page.tsx | 11 +-
src/app/settings/chats/nearby/page.tsx | 9 +-
src/app/settings/chats/page.tsx | 9 +-
.../settings/chats/rooms/[roomId]/page.tsx | 5 +-
src/app/settings/chats/rooms/page.tsx | 5 +-
src/app/settings/edit/sizes/page.tsx | 2 +-
src/app/settings/gifts/page.tsx | 9 +-
src/app/settings/my-billboards/page.tsx | 9 +-
src/app/settings/notifications/page.tsx | 9 +-
src/app/settings/offers/page.tsx | 9 +-
.../settings/profile/user-settings/page.tsx | 24 +
src/app/settings/profile/visitors/page.tsx | 9 +-
src/app/settings/shareable-links/page.tsx | 24 +
src/app/settings/tickets/page.tsx | 9 +-
src/app/settings/workroom/page.tsx | 9 +-
src/app/shops/[shopId]/layout.tsx | 15 +
.../[shopId]/products/[listingId]/page.tsx | 3 +-
src/app/shops/checkout/[orderId]/page.tsx | 48 +-
src/app/shops/discover/layout.tsx | 23 +
.../[listingId]/ListingDetailClient.tsx | 16 +-
src/app/shops/listing/[listingId]/page.tsx | 5 +-
src/app/shops/profile/[shopId]/page.tsx | 6 +-
src/app/sitemap.xml/route.ts | 12 +
src/app/sitemaps/[type]/[[...page]]/route.ts | 6 +
src/app/users/[username]/page.tsx | 15 +-
src/components/NumberedStarRating.tsx | 52 ++
src/components/PushNotificationListener.tsx | 26 +-
src/components/VpnWarningGate.tsx | 62 ++
src/components/academy/CommentsModal.tsx | 12 +-
.../academy/CourseCommentsContent.tsx | 46 +-
src/components/academy/InfinitePakage.tsx | 30 +-
src/components/academy/MainModelCard.tsx | 30 +-
.../academy/MainModelCardActions.tsx | 22 +-
src/components/auth/AuthOtpInput.tsx | 18 +
.../billboards/BillboardAdNotFound.tsx | 17 -
.../billboards/BillboardNotFound.tsx | 17 -
.../BillboardPage/BillboardDetails.tsx | 5 +-
.../billboards/InfiniteBillboard.tsx | 12 +
.../BillboardCommentsModal.tsx | 366 +++++----
.../NewBillboard/submitBillboard.ts | 5 +
.../billboards/Profile/AdProfileContent.tsx | 6 +-
.../booking/IncomingBookingsList.tsx | 9 +-
src/components/chat/ChatMessageList.tsx | 9 +-
src/components/chat/GiftInChatModal.tsx | 38 +-
.../chat/GiftPaymentResultModal.tsx | 54 ++
src/components/chat/MessageList.tsx | 7 +-
.../comments/CommentComposerBar.tsx | 43 +
src/components/comments/CommentDrawer.tsx | 167 ++++
.../comments/NumberedStarRating.tsx | 53 ++
src/components/course/CourseForm.tsx | 57 +-
src/components/course/CourseList.tsx | 9 +-
src/components/course/CourseManager.tsx | 25 +-
src/components/course/SubscriptionCard.tsx | 2 +-
src/components/elements/EmptyState.tsx | 45 ++
src/components/main/UserInfo.tsx | 9 +-
src/components/models/InfinitePosts.tsx | 9 +-
.../models/MainModelCard/CommentsModal.tsx | 12 +-
.../MainModelCard/MainModelCardActions.tsx | 2 +-
.../models/ModelPage/ModelContent.tsx | 6 +-
src/components/payment/WalletUsagePrompt.tsx | 67 ++
src/components/posts/PostCommentsContent.tsx | 33 +-
src/components/posts/PostFeedView.tsx | 5 +-
src/components/posts/PostLinkPicker.tsx | 114 +++
src/components/posts/ReelsImageCarousel.tsx | 7 +-
src/components/posts/ReelsPostActions.tsx | 2 +-
src/components/posts/ReelsPostCard.tsx | 142 +++-
src/components/posts/ReelsPostOptionsMenu.tsx | 38 +
src/components/profile/ReferralCapture.tsx | 68 ++
src/components/profile/ShareProfileModal.tsx | 64 +-
src/components/projects/InfiniteProjects.tsx | 22 +-
src/components/search/InfiniteSearch.tsx | 63 +-
.../settings/AutoDirectFormModal.tsx | 188 +++++
src/components/settings/AutoDirectList.tsx | 110 +++
src/components/settings/FavoritesGrid.tsx | 9 +-
src/components/settings/SettingsClient.tsx | 2 +
.../settings/ShareableLinksManager.tsx | 307 +++++++
.../settings/profile/ProfileContent.tsx | 64 +-
.../settings/settings/InviteFriends.tsx | 73 ++
src/components/shops/ShopCartTab.tsx | 9 +-
.../shops/ShopProductCommentsModal.tsx | 68 +-
src/components/stories/StoryRing.tsx | 10 +-
src/components/ui/IdeaComponent.tsx | 2 +-
src/components/ui/SocialStatButton.tsx | 2 +-
src/config/seoConfig.ts | 2 +-
src/config/site.ts | 2 +-
src/contexts/BillboardFormContext.tsx | 2 +-
src/lib/api/fetchApiJson.ts | 4 +-
src/lib/auth/registrationRoutes.ts | 6 +-
src/lib/buildAcademyListSeo.ts | 4 +-
src/lib/buildCourseDetailSeo.ts | 52 +-
src/lib/buildProjectsListSeo.ts | 4 +-
src/lib/downloadFile.ts | 22 +
src/lib/formatLikeDisplay.ts | 3 +-
src/lib/i18n/seo.ts | 33 +-
src/lib/i18n/server.ts | 12 +-
src/lib/postSlug.ts | 47 +-
src/lib/sitemap/builders.ts | 62 +-
src/lib/sitemap/config.ts | 2 +-
src/lib/ui/buttonStyles.ts | 7 +-
src/locales/en/common.json | 172 +++-
src/locales/fa/common.json | 172 +++-
src/middleware.ts | 86 +-
src/types/types.ts | 2 +-
154 files changed, 4984 insertions(+), 2169 deletions(-)
delete mode 100644 .claude/launch.json
create mode 160000 .claude/worktrees/agent-af1dfc8fe9a6cb198
delete mode 100644 _dead_code_quarantine/NewBillboard-duplicate-component/MultiStepForm.tsx
delete mode 100644 _dead_code_quarantine/NewBillboard-duplicate-component/Step1.tsx
delete mode 100644 _dead_code_quarantine/NewBillboard-duplicate-component/Step2.tsx
delete mode 100644 _dead_code_quarantine/NewBillboard-duplicate-component/Step3.tsx
delete mode 100644 _dead_code_quarantine/NewBillboard-duplicate-component/Step4.tsx
delete mode 100644 _dead_code_quarantine/NewBillboard-duplicate-component/Step5.tsx
delete mode 100644 _dead_code_quarantine/NewBillboard-duplicate-component/step1/ImageUploader.tsx
delete mode 100644 _dead_code_quarantine/NewBillboard-duplicate-component/step1/LocationSelector.tsx
create mode 100644 public/images/og-image.png
create mode 100644 src/app/(auth)/(register)/register/avatar/page.tsx
create mode 100644 src/app/booking/[userId]/layout.tsx
create mode 100644 src/app/not-found.tsx
create mode 100644 src/app/settings/ads/[id]/page.tsx
create mode 100644 src/app/settings/ads/new/page.tsx
create mode 100644 src/app/settings/ads/page.tsx
create mode 100644 src/app/settings/auto-direct/page.tsx
create mode 100644 src/app/settings/shareable-links/page.tsx
create mode 100644 src/app/shops/[shopId]/layout.tsx
create mode 100644 src/app/shops/discover/layout.tsx
create mode 100644 src/components/NumberedStarRating.tsx
create mode 100644 src/components/VpnWarningGate.tsx
delete mode 100644 src/components/billboards/BillboardAdNotFound.tsx
delete mode 100644 src/components/billboards/BillboardNotFound.tsx
create mode 100644 src/components/chat/GiftPaymentResultModal.tsx
create mode 100644 src/components/comments/CommentComposerBar.tsx
create mode 100644 src/components/comments/CommentDrawer.tsx
create mode 100644 src/components/comments/NumberedStarRating.tsx
create mode 100644 src/components/elements/EmptyState.tsx
create mode 100644 src/components/payment/WalletUsagePrompt.tsx
create mode 100644 src/components/profile/ReferralCapture.tsx
create mode 100644 src/components/settings/AutoDirectFormModal.tsx
create mode 100644 src/components/settings/AutoDirectList.tsx
create mode 100644 src/components/settings/ShareableLinksManager.tsx
create mode 100644 src/components/settings/settings/InviteFriends.tsx
create mode 100644 src/lib/downloadFile.ts
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 (
-
- );
-};
-
-export default Step1;
diff --git a/_dead_code_quarantine/NewBillboard-duplicate-component/Step2.tsx b/_dead_code_quarantine/NewBillboard-duplicate-component/Step2.tsx
deleted file mode 100644
index 372ceb9..0000000
--- a/_dead_code_quarantine/NewBillboard-duplicate-component/Step2.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-"use client";
-
-import RoundedButton from "@/components/elements/RoundedButton";
-import { useFormik } from "formik";
-import * as Yup from "yup";
-import { useBillboardForm } from "@/contexts/BillboardFormContext";
-import { useMemo, useState } from "react";
-import { Service } from "@/types/types";
-import ServiceItem from "@/components/main/Services/ServiceItem";
-import AddServiceModal from "@/components/main/Services/AddServiceModal";
-import { useTranslation } from "react-i18next";
-
-const Step2 = ({ nextStep }: { nextStep: () => void }) => {
- const { t } = useTranslation("common");
- const { formData, updateForm } = useBillboardForm();
- const [isModalOpen, setModalOpen] = useState(false);
-
- const validationSchema = useMemo(
- () =>
- Yup.object({
- services: Yup.array().min(1, t("billboards.form.validation.minServices")),
- }),
- [t]
- );
-
- const formik = useFormik({
- initialValues: {
- services: formData.services || [],
- },
- validationSchema,
- onSubmit: (values) => {
- updateForm(values);
- nextStep();
- },
- });
-
- const services = formik.values.services;
-
- const handleAddService = (newService: Service) => {
- formik.setFieldValue("services", [...services, newService]);
- };
-
- const handleDeleteService = (id: string) => {
- formik.setFieldValue(
- "services",
- services.filter((service) => service.id !== id)
- );
- };
-
- return (
-
- {t("billboards.createTitle")}
-
- {services.map((service) => (
-
- ))}
-
-
-
-
- {t("billboards.submitContinue")}
-
- setModalOpen(true)}
- className="!text-[#0066FF] !border-[#0066FF] w-32 h-9"
- type="button"
- >
- {t("billboards.add")}
-
-
- {isModalOpen && (
- setModalOpen(false)}
- onAdd={handleAddService}
- isModalOpen={isModalOpen}
- />
- )}
-
- );
-};
-
-export default Step2;
diff --git a/_dead_code_quarantine/NewBillboard-duplicate-component/Step3.tsx b/_dead_code_quarantine/NewBillboard-duplicate-component/Step3.tsx
deleted file mode 100644
index 5ac2cc7..0000000
--- a/_dead_code_quarantine/NewBillboard-duplicate-component/Step3.tsx
+++ /dev/null
@@ -1,176 +0,0 @@
-"use client";
-
-import RoundedButton from "@/components/elements/RoundedButton";
-import { useFormik } from "formik";
-import * as Yup from "yup";
-import { useBillboardForm } from "@/contexts/BillboardFormContext";
-import useAxios from "@/hooks/useAxios";
-import { useEffect, useMemo, useState } from "react";
-import { useTranslation } from "react-i18next";
-
-export interface IFeatureFetch {
- title?: string;
- value: boolean;
- _id: string;
-}
-
-const Step3 = ({ nextStep }: { nextStep: () => void }) => {
- const { t } = useTranslation("common");
- const { formData, updateForm } = useBillboardForm();
- const { request } = useAxios();
- const [features, setFeatures] = useState([]);
-
- const validationSchema = useMemo(
- () =>
- Yup.object({
- selectedFeatures: Yup.array().min(1, t("billboards.form.validation.minFeatures")),
- }),
- [t]
- );
-
- useEffect(() => {
- const fetchData = async () => {
- try {
- const response = await request<{ features: IFeatureFetch[] }>(
- "GET",
- "/advertising/features"
- );
- setFeatures(response.features);
- } catch (err) {
- console.log(err);
- }
- };
- fetchData();
- }, [request]);
-
- const formik = useFormik({
- initialValues: {
- selectedFeatures: formData.selectedFeatures || [],
- },
- validationSchema,
- onSubmit: (values) => {
- updateForm(values);
- nextStep();
- },
- enableReinitialize: true,
- });
-
- const handleSelect = (
- featureId: string,
- value: boolean,
- title: string | undefined
- ) => {
- formik.setFieldValue("selectedFeatures", [
- ...formik.values.selectedFeatures.filter(
- (feature) => feature._id !== featureId
- ),
- { _id: featureId, value, title: title },
- ]);
- };
-
- return (
-
- {t("billboards.createTitle")}
-
-
- {features.map((feature: IFeatureFetch) => {
- const isSelected = formik.values.selectedFeatures.some(
- (featureItem) =>
- featureItem._id === feature._id && featureItem.value === true
- );
- const isDeselected = formik.values.selectedFeatures.some(
- (featureItem) =>
- featureItem._id === feature._id && featureItem.value === false
- );
- return (
-
-
{feature.title}
-
-
-
- handleSelect(feature._id, true, feature?.title)
- }
- className="hidden"
- />
- {t("billboards.form.has")}
-
- {isSelected && (
-
-
-
- )}
-
-
-
-
-
- handleSelect(feature._id, false, feature?.title)
- }
- className="hidden"
- />
- {t("billboards.form.hasNot")}
-
- {isDeselected && (
-
-
-
- )}
-
-
-
-
- );
- })}
-
-
- {formik.touched.selectedFeatures &&
- formik.errors.selectedFeatures &&
- typeof formik.errors.selectedFeatures === "string" && (
- {formik.errors.selectedFeatures}
- )}
-
- {t("billboards.submitContinue")}
-
-
- );
-};
-
-export default Step3;
diff --git a/_dead_code_quarantine/NewBillboard-duplicate-component/Step4.tsx b/_dead_code_quarantine/NewBillboard-duplicate-component/Step4.tsx
deleted file mode 100644
index 6876bd0..0000000
--- a/_dead_code_quarantine/NewBillboard-duplicate-component/Step4.tsx
+++ /dev/null
@@ -1,193 +0,0 @@
-"use client";
-
-import RoundedButton from "@/components/elements/RoundedButton";
-import { useFormik } from "formik";
-import * as Yup from "yup";
-import { useBillboardForm } from "@/contexts/BillboardFormContext";
-import { useEffect, useState } from "react";
-import useAxios from "@/hooks/useAxios";
-import RoundedInput from "@/components/elements/RoundedInput";
-import toast from "react-hot-toast";
-import { useTranslation } from "react-i18next";
-
-const validationSchema = Yup.object({
- landline: Yup.string(),
- mobile: Yup.string(),
- telegram: Yup.string(),
- whatsapp: Yup.string(),
- instagram: Yup.string(),
- saveData: Yup.boolean(),
-});
-
-interface ContactInfo {
- landline?: string;
- mobile?: string;
- telegram?: string;
- whatsapp?: string;
- instagram?: string;
- saveData?: boolean;
-}
-
-interface ApiResponse {
- data: {
- contactInfo: ContactInfo;
- };
-}
-
-const Step4 = ({ nextStep }: { nextStep: () => void }) => {
- const { t } = useTranslation("common");
- const { formData, updateForm } = useBillboardForm();
- const { request } = useAxios();
- const [isCheckedOne, setIsCheckedOne] = useState(false);
-
- const formik = useFormik({
- initialValues: {
- landline: formData.landline || "",
- mobile: formData.mobile || "",
- telegram: formData.telegram || "",
- whatsapp: formData.whatsapp || "",
- instagram: formData.instagram || "",
- saveData: formData.saveData || false,
- },
- validationSchema,
- onSubmit: (values) => {
- if (
- !formik?.values?.landline &&
- !formik?.values?.mobile &&
- !formik?.values?.telegram &&
- !formik?.values?.whatsapp &&
- !formik?.values?.instagram
- ) {
- toast.error(t("billboards.form.contactRequired"));
- } else {
- updateForm(values);
- nextStep();
- }
- },
- });
-
- useEffect(() => {
- const fetchData = async () => {
- try {
- const response = await request(
- "GET",
- "/users/contact-info"
- );
- const contactInfo = response?.data?.contactInfo;
-
- if (contactInfo) {
- formik.setFieldValue("landline", contactInfo.landline || "");
- formik.setFieldValue("mobile", contactInfo.mobile || "");
- formik.setFieldValue("telegram", contactInfo.telegram || "");
- formik.setFieldValue("whatsapp", contactInfo.whatsapp || "");
- formik.setFieldValue("instagram", contactInfo.instagram || "");
- formik.setFieldValue("saveData", contactInfo.saveData || false);
- }
- } catch (err) {
- console.error("Error fetching contact info:", err);
- }
- };
-
- fetchData();
- }, [request]);
-
- const toggleCheckBox = () => {
- formik.setFieldValue("saveData", !isCheckedOne);
- setIsCheckedOne(!isCheckedOne);
- };
-
- return (
-
- {t("billboards.createTitle")}
-
-
- {formik.touched.landline && formik.errors.landline && (
- {formik.errors.landline}
- )}
-
- {formik.touched.mobile && formik.errors.mobile && (
- {formik.errors.mobile}
- )}
-
- {formik.touched.telegram && formik.errors.telegram && (
- {formik.errors.telegram}
- )}
-
- {formik.touched.whatsapp && formik.errors.whatsapp && (
- {formik.errors.whatsapp}
- )}
-
- {formik.touched.instagram && formik.errors.instagram && (
- {formik.errors.instagram}
- )}
-
-
-
- {t("billboards.form.saveContactInfo")}
-
-
-
-
- {t("billboards.submitContinue")}
-
-
- );
-};
-
-export default Step4;
diff --git a/_dead_code_quarantine/NewBillboard-duplicate-component/Step5.tsx b/_dead_code_quarantine/NewBillboard-duplicate-component/Step5.tsx
deleted file mode 100644
index aaa9a8b..0000000
--- a/_dead_code_quarantine/NewBillboard-duplicate-component/Step5.tsx
+++ /dev/null
@@ -1,268 +0,0 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-"use client";
-
-import RoundedButton from "@/components/elements/RoundedButton";
-import { useFormik } from "formik";
-import * as Yup from "yup";
-import { useEffect, useMemo, useState } from "react";
-import useAxios from "@/hooks/useAxios";
-import { IAdvertisingType } from "@/types/types";
-import { sampleAds, sampleAdsHighlight, sampleAdsSpecial } from "@/constants";
-import RoundedDiv from "@/components/elements/RoundedDiv";
-import { selectionCardClass } from "@/lib/ui/buttonStyles";
-import { useRouter } from "next/navigation";
-import MainBillboardCard from "@/components/billboards/MainBillboardCard/MainBillboardCard";
-import { dataURLtoBlob } from "@/helpers/helpers";
-import { useBillboardForm } from "@/contexts/BillboardFormContext";
-import { getBillboardDisplayTypeOptionLabel } from "@/lib/billboards/displayTypeLabel";
-import { useTranslation } from "react-i18next";
-
-interface CreateAdvertisingResponse {
- success: boolean;
- message: string;
- data: {
- id: string;
- };
-}
-
-const Step5 = () => {
- const { t } = useTranslation("common");
- const router = useRouter();
- const { request } = useAxios();
- const { formData, updateForm, isEditing } = useBillboardForm();
- const [selectedType, setSelectedType] = useState("normal");
- const [showDiscount, setShowDiscount] = useState(false);
- const [typeList, setTypeList] = useState(null);
-
- const validationSchema = useMemo(
- () =>
- Yup.object({
- selectedType: Yup.string().required(
- t("billboards.form.validation.displayTypeRequired")
- ),
- }),
- [t]
- );
-
- const {
- adsTitle,
- categoryId,
- description,
- stateId,
- cityId,
- neighbourhood,
- address,
- markerCoordinate,
- services,
- selectedFeatures,
- landline,
- mobile,
- telegram,
- whatsapp,
- instagram,
- saveData,
- images,
- } = formData;
-
- const toggleCheckBox = () => {
- setShowDiscount(!showDiscount);
- };
-
- const fetchStates = async () => {
- try {
- const response = await request<{ advertisingTypes: IAdvertisingType[] }>(
- "GET",
- "/advertising/types"
- );
- setTypeList(response?.advertisingTypes || null);
- } catch (err) {
- console.log(err);
- }
- };
-
- useEffect(() => {
- fetchStates();
- }, []);
-
- const formik = useFormik({
- initialValues: {
- selectedType: formData.selectedType || "",
- },
- validationSchema,
- onSubmit: async (values) => {
- updateForm(values);
-
- const data = new FormData();
- data.append("title", adsTitle);
- data.append("category", categoryId);
- if (description) {
- data.append("description", description);
- }
- data.append("province", stateId ? stateId : "");
- data.append("city", cityId ? cityId : "");
- if (neighbourhood) {
- data.append("neighbourhood", neighbourhood);
- }
- if (address) {
- data.append("address", address);
- }
- if (markerCoordinate) {
- data.append("lat", markerCoordinate[0]);
- data.append("lng", markerCoordinate[1]);
- }
- data.append("services", JSON.stringify(services));
- data.append("features", JSON.stringify(selectedFeatures));
- if (landline) {
- data.append("contactInfo[phone]", landline);
- }
- if (mobile) {
- data.append("contactInfo[mobile]", mobile);
- }
- if (telegram) {
- data.append("contactInfo[telegramLink]", telegram);
- }
- if (whatsapp) {
- data.append("contactInfo[whatsappNumber]", whatsapp);
- }
- if (instagram) {
- data.append("contactInfo[instagramLink]", instagram);
- }
- if (saveData !== null && typeof saveData === "boolean") {
- data.append("contactInfo[saveInfoForNextAds]", String(saveData));
- }
-
- if (showDiscount !== null && typeof showDiscount === "boolean") {
- data.append("showDiscount", String(showDiscount));
- }
- data.append("type", selectedType);
-
- const mostDiscountPercentage =
- services.length > 0
- ? services.reduce((max, service) => {
- const discount =
- typeof service.discountPercentage === "number"
- ? service.discountPercentage
- : 0;
-
- return discount > max ? discount : max;
- }, 0)
- : 0;
-
- if (mostDiscountPercentage) {
- data.append("mostDiscountPercentage", String(mostDiscountPercentage));
- }
-
- images?.forEach((image) => {
- if (typeof image === "string" && image.startsWith("/advertising/")) {
- data.append("existingImages[]", image);
- } else {
- const blob = dataURLtoBlob(image);
- data.append("images", blob);
- }
- });
-
- services.forEach((service) => {
- if (
- typeof service.image === "string" &&
- service.image.startsWith("/services/")
- ) {
- data.append(`existingServiceImages[${service.id}]`, service.image);
- } else if (service.image) {
- const blob = dataURLtoBlob(service.image);
- data.append(
- `serviceImages[${service.id}]`,
- blob,
- `service-${service.id}.jpg`
- );
- }
- });
- try {
- if (isEditing) {
- await request<{ id: string; message: string }>(
- "POST",
- `/advertising/edit/${formData?._id}`,
- data
- );
- localStorage.removeItem("billboardForm");
- router.push(`/settings/my-billboards`);
- } else {
- const response = await request(
- "POST",
- "/advertising/create",
- data
- );
- localStorage.removeItem("billboardForm");
- router.push(`/billboards/new/${response.data.id}`);
- }
- } catch (err: any) {
- console.log("Unhandled error:", err?.message);
- }
- },
- });
-
- useEffect(() => {
- if (isEditing && formik.values.selectedType) {
- formik.submitForm();
- }
- }, [isEditing, formik.values.selectedType]);
-
- return (
-
- {t("billboards.displayTypeTitle")}
- {t("billboards.displayTypeHint")}
-
-
- {t("billboards.showDiscount")}
-
- {typeList?.map((item: IAdvertisingType) => {
- return (
- {
- setSelectedType(item?.name);
- formik.setFieldValue("selectedType", item?.name);
- }}
- className="w-full flex flex-col items-center"
- key={item?._id}
- >
-
-
- {getBillboardDisplayTypeOptionLabel(t, item.name)}:
- {item.price !== 0
- ? !showDiscount
- ? Number(item.price).toLocaleString() + t("settings.toman")
- : (Number(item.price) + 20000).toLocaleString() +
- t("settings.toman")
- : ` ${t("billboards.free")}`}
-
-
- );
- })}
- {formik.touched.selectedType && formik.errors.selectedType && (
- {formik.errors.selectedType}
- )}
-
- {t("billboards.submitRequest")}
-
-
- );
-};
-
-export default Step5;
diff --git a/_dead_code_quarantine/NewBillboard-duplicate-component/step1/ImageUploader.tsx b/_dead_code_quarantine/NewBillboard-duplicate-component/step1/ImageUploader.tsx
deleted file mode 100644
index 0b0a806..0000000
--- a/_dead_code_quarantine/NewBillboard-duplicate-component/step1/ImageUploader.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-"use client";
-
-import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
-import { useState } from "react";
-import { useTranslation } from "react-i18next";
-
-type ImageUploaderProps = {
- formik: any;
-};
-
-const ImageUploader: React.FC = ({ formik }) => {
- const { t } = useTranslation("common");
- const [images, setImages] = useState(formik.values.images || []);
-
- const handleImageUpload = (event: React.ChangeEvent) => {
- if (!event.target.files) return;
- const files = Array.from(event.target.files);
-
- if (images.length + files.length > 5) {
- alert(t("billboards.form.maxImagesAlert"));
- return;
- }
-
- const newImages: string[] = [];
-
- files.forEach((file) => {
- const reader = new FileReader();
- reader.readAsDataURL(file);
- reader.onload = () => {
- if (typeof reader.result === "string") {
- newImages.push(reader.result);
- if (newImages.length === files.length) {
- const updatedImages = [...images, ...newImages];
- setImages(updatedImages);
- formik.setFieldValue("images", updatedImages);
- }
- }
- };
- });
- };
-
- const handleRemoveImage = (index: number) => {
- const newImages = images.filter((_, i) => i !== index);
- setImages(newImages);
- formik.setFieldValue("images", newImages);
- };
-
- return (
-
- {images.map((image, index) => (
-
-
-
handleRemoveImage(index)}
- >
- x
-
-
- ))}
- {images.length < 5 && (
-
-
- +
-
- )}
-
- );
-};
-
-export default ImageUploader;
diff --git a/_dead_code_quarantine/NewBillboard-duplicate-component/step1/LocationSelector.tsx b/_dead_code_quarantine/NewBillboard-duplicate-component/step1/LocationSelector.tsx
deleted file mode 100644
index 717387c..0000000
--- a/_dead_code_quarantine/NewBillboard-duplicate-component/step1/LocationSelector.tsx
+++ /dev/null
@@ -1,202 +0,0 @@
-/* eslint-disable @typescript-eslint/no-explicit-any */
-"use client";
-
-import { ICity, IProvince } from "@/types/types";
-import SelectBox from "@/components/elements/SelectBox";
-import { useState, useEffect } from "react";
-import useAxios from "@/hooks/useAxios";
-import RoundedInput from "@/components/elements/RoundedInput";
-import Map, { GeolocateControl, Marker } from "react-map-gl";
-import "mapbox-gl/dist/mapbox-gl.css";
-import Image from "next/image";
-import { useBillboardForm } from "@/contexts/BillboardFormContext";
-import { useTranslation } from "react-i18next";
-
-interface LocationSelectorProps {
- formik: any;
-}
-
-const LocationSelector: React.FC = ({ formik }) => {
- const { t } = useTranslation("common");
- const { formData } = useBillboardForm();
-
- const { request } = useAxios();
- const [allStates, setAllStates] = useState(null);
- const [cities, setCities] = useState(null);
- const [selectedLocation, setSelectedLocation] = useState<{
- lat: number;
- lng: number;
- } | null>(null);
-
- const fetchStates = async () => {
- try {
- const response = await request<{ provinces: IProvince[] }>(
- "GET",
- "/provinces"
- );
- setAllStates(response?.provinces || null);
- } catch (err) {
- console.log(err);
- }
- };
-
- const fetchCities = async (provinceId: string) => {
- try {
- const response = await request<{ cities: ICity[] }>(
- "GET",
- `/cities/${provinceId}`
- );
- setCities(response?.cities || []);
- } catch (err) {
- console.log(err);
- }
- };
-
- useEffect(() => {
- fetchStates();
- }, []);
-
- useEffect(() => {
- if (formik.values.stateId) {
- fetchCities(formik.values.stateId);
- }
-
- if (formData?.markerCoordinate && formData?.markerCoordinate[1]) {
- setSelectedLocation({
- lat: Number(formData?.markerCoordinate[1]),
- lng: Number(formData?.markerCoordinate[0]),
- });
- }
- }, [formik?.values?.markerCoordinate]);
-
- const handleProvinceChange = (e: React.ChangeEvent) => {
- const selectedProvinceId = e.target.value;
- formik.setFieldValue("stateId", selectedProvinceId);
- fetchCities(selectedProvinceId);
- };
-
- const handleMapClick = (event: any) => {
- const { lngLat } = event;
- setSelectedLocation({
- lat: lngLat.lat,
- lng: lngLat.lng,
- });
- formik.setFieldValue("markerCoordinate", [lngLat.lng, lngLat.lat]);
- };
-
- return (
- <>
-
-
- {t("billboards.province")}
-
- {allStates?.map((item: IProvince) => (
-
- {item?.name}
-
- ))}
-
- {formik.touched.stateId && formik.errors.stateId && (
-
- {formik.errors.stateId}
-
- )}
-
- formik.setFieldValue("cityId", e.target.value)}
- >
-
- {t("billboards.city")}
-
- {cities?.map((city: ICity) => (
-
- {city.name}
-
- ))}
-
- {formik.touched.cityId && formik.errors.cityId && (
-
- {formik.errors.cityId}
-
- )}
-
-
- {formik.touched.neighbourhood && formik.errors.neighbourhood && (
- {formik.errors.neighbourhood}
- )}
-
- {formik.touched.address && formik.errors.address && (
- {formik.errors.address}
- )}
-
-
-
- {selectedLocation && (
-
-
-
- )}
-
- {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 0000000000000000000000000000000000000000..61220e4c6309fe4b7ec81a2d1190066776030140
GIT binary patch
literal 38740
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 {
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.selectEditImage")}
+
+
+
+
+
+
+ {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 && (
+
+ )}
+
+ >
+ );
}
diff --git a/src/app/(projects)/academy/profile/[academyId]/AcademyProfileClient.tsx b/src/app/(projects)/academy/profile/[academyId]/AcademyProfileClient.tsx
index 5810c0f..dafdb91 100644
--- a/src/app/(projects)/academy/profile/[academyId]/AcademyProfileClient.tsx
+++ b/src/app/(projects)/academy/profile/[academyId]/AcademyProfileClient.tsx
@@ -39,6 +39,15 @@ interface AcademyOwner {
user_score?: string | number;
}
+/** این اندپوینتها ساختارهای مختلفی برمیگردونن — یه تایپِ عمومیِ صریح که همون
+ * فیلدهایی که کد واقعاً استفاده میکنه رو پوشش میده (بجای {} ضمنی) */
+type ApiResponse = {
+ academy?: any;
+ courses?: any[];
+ data?: any;
+ [key: string]: any;
+};
+
interface AcademyData {
_id: string;
userId?: string;
@@ -138,7 +147,7 @@ export default function AcademyProfileClient() {
setIsLoading(true);
try {
- const response = await request("POST", "/academy/findAcademyById", {
+ const response = await request("POST", "/academy/findAcademyById", {
_id: academyId,
});
const academyData =
@@ -159,7 +168,7 @@ export default function AcademyProfileClient() {
if (!academyId) return;
setIsLoadingCourses(true);
try {
- const response = await request(
+ const response = await request(
"GET",
`/academy/academy/get/getAcademyCourse/${academyId}?page=1&limit=50&status=accept`
);
diff --git a/src/app/(projects)/projects/[id]/[title]/page.tsx b/src/app/(projects)/projects/[id]/[title]/page.tsx
index 858a013..0906883 100644
--- a/src/app/(projects)/projects/[id]/[title]/page.tsx
+++ b/src/app/(projects)/projects/[id]/[title]/page.tsx
@@ -1,5 +1,5 @@
import { Metadata } from "next";
-import { Suspense } from "react";
+import { Suspense, cache } from "react";
import { cookies } from "next/headers";
import ProjectDetailClient from "./ProjectDetailClient";
import PageLoader from "@/components/ui/PageLoader";
@@ -15,13 +15,16 @@ type ProjectDetailPageProps = {
params: Promise<{ id: string; title: string }>;
};
+// Deduped per request — see loadUser in users/[username]/page.tsx for rationale.
+const loadProject = cache((id: string, token: string) => fetchProject(id, token));
+
export async function generateMetadata({
params,
}: ProjectDetailPageProps): Promise {
const { id, title } = await params;
const token = (await cookies()).get("token")?.value || "";
const lang = await getServerLanguage();
- const project = await fetchProject(id, token);
+ const project = await loadProject(id, token);
if (project) {
const seo = buildProjectDetailSeo(project, lang);
@@ -50,11 +53,20 @@ export async function generateMetadata({
export default async function ProjectDetailPage({
params,
}: ProjectDetailPageProps) {
- const { id } = await params;
+ const { id, title } = await params;
+ const token = (await cookies()).get("token")?.value || "";
+ const lang = await getServerLanguage();
+ const project = await loadProject(id, token);
+ const h1 = project
+ ? buildProjectDetailSeo(project, lang).title
+ : buildProjectDetailFallbackSeo(decodeURIComponent(title || ""), id, lang).title;
return (
- }>
-
-
+ <>
+ {h1}
+ }>
+
+
+ >
);
}
diff --git a/src/app/billboards/[id]/[title]/page.tsx b/src/app/billboards/[id]/[title]/page.tsx
index b865801..95b7a2d 100644
--- a/src/app/billboards/[id]/[title]/page.tsx
+++ b/src/app/billboards/[id]/[title]/page.tsx
@@ -1,13 +1,15 @@
-import { BASE_URL } from "@/components/main/BaseUrl";
+import { BASE_URL, IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import BillboardDetailContent from "@/components/billboards/BillboardDetailContent";
-import BillboardNotFound from "@/components/billboards/BillboardNotFound";
import { IAdvertising, IRate } from "@/types/types";
import { cookies } from "next/headers";
import { cache } from "react";
+import { notFound } from "next/navigation";
import { Metadata } from "next";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import { getLocaleBundle } from "@/lib/i18n/resources";
import { getServerLanguage } from "@/lib/i18n/server";
+import { safeJsonLdString } from "@/lib/seo/safeJsonLd";
+import { SITE_URL } from "@/config/site";
interface IBillboardProps {
params: Promise<{ id: string; title: string }>;
@@ -78,6 +80,39 @@ export async function generateMetadata({
});
}
+function buildBillboardJsonLd(billboard: IAdvertising, id: string) {
+ const address = [
+ billboard.province?.name,
+ billboard.city?.name,
+ billboard.neighbourhood,
+ ]
+ .filter(Boolean)
+ .join(" - ");
+ const url = `${SITE_URL}/billboards/${id}`;
+
+ return {
+ "@context": "https://schema.org",
+ "@type": "LocalBusiness",
+ "@id": url,
+ url,
+ name: billboard.title,
+ description: billboard.description || billboard.title,
+ image: billboard.images?.map((path) => `${IMAGE_BASE_URL}${path}`),
+ address: address
+ ? { "@type": "PostalAddress", addressLocality: address, addressCountry: "IR" }
+ : undefined,
+ ...(billboard.averageRating && billboard.ratingsCount
+ ? {
+ aggregateRating: {
+ "@type": "AggregateRating",
+ ratingValue: billboard.averageRating,
+ reviewCount: billboard.ratingsCount,
+ },
+ }
+ : {}),
+ };
+}
+
async function BillboardPage({ params }: IBillboardProps) {
const { id } = await params;
const token = (await cookies()).get("token")?.value || "";
@@ -85,13 +120,23 @@ async function BillboardPage({ params }: IBillboardProps) {
const data = await getBillboardData(id, token);
if (!data || !data.advertising) {
- return ;
+ notFound();
}
const billboard = data.advertising as IAdvertising;
const rate = data.rate as IRate;
- return ;
+ return (
+ <>
+
+
+ >
+ );
}
export default BillboardPage;
diff --git a/src/app/billboards/profile/[...id]/page.tsx b/src/app/billboards/profile/[...id]/page.tsx
index 90309b5..46d1de9 100644
--- a/src/app/billboards/profile/[...id]/page.tsx
+++ b/src/app/billboards/profile/[...id]/page.tsx
@@ -1,11 +1,12 @@
import { BASE_URL, IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import AdvertisingProfileContent from "@/components/billboards/AdvertisingProfileContent";
-import BillboardAdNotFound from "@/components/billboards/BillboardAdNotFound";
import { IAdvertisingProfile } from "@/types/types";
import { Metadata } from "next";
import { cache } from "react";
+import { notFound } from "next/navigation";
import { getLocaleBundle } from "@/lib/i18n/resources";
import { getServerLanguage } from "@/lib/i18n/server";
+import { SITE_URL } from "@/config/site";
interface IUserProps {
params: Promise<{ id: string[] }>;
@@ -57,14 +58,14 @@ export async function generateMetadata({ params }: IUserProps): Promise ;
+ if (!profile) notFound();
return ;
}
diff --git a/src/app/billboards/profile/[id]/[title]/page.tsx b/src/app/billboards/profile/[id]/[title]/page.tsx
index 00bea91..b897e52 100644
--- a/src/app/billboards/profile/[id]/[title]/page.tsx
+++ b/src/app/billboards/profile/[id]/[title]/page.tsx
@@ -1,11 +1,12 @@
import { BASE_URL, IMAGE_BASE_URL } from "@/components/main/BaseUrl";
import AdvertisingProfileContent from "@/components/billboards/AdvertisingProfileContent";
-import BillboardAdNotFound from "@/components/billboards/BillboardAdNotFound";
import { IAdvertisingProfile } from "@/types/types";
import { Metadata } from "next";
import { cache } from "react";
+import { notFound } from "next/navigation";
import { getLocaleBundle } from "@/lib/i18n/resources";
import { getServerLanguage } from "@/lib/i18n/server";
+import { SITE_URL } from "@/config/site";
interface IUserProps {
params: Promise<{ id: string }>;
@@ -56,14 +57,14 @@ export async function generateMetadata({ params }: IUserProps): Promise ;
+ if (!profile) notFound();
return ;
}
diff --git a/src/app/booking/[userId]/layout.tsx b/src/app/booking/[userId]/layout.tsx
new file mode 100644
index 0000000..98507eb
--- /dev/null
+++ b/src/app/booking/[userId]/layout.tsx
@@ -0,0 +1,16 @@
+import type { Metadata } from "next";
+
+// این صفحه یه فرم رزرو شخصیشده برای هر کاربره — محتوای یکتا و قابلرتبهبندی
+// نداره (خودِ پروفایل کاربر جای درستیه که ایندکس بشه)، پس noindex میکنیم تا
+// ایندکس گوگل با صدها صفحهی تقریباً یکسان پر نشه.
+export const metadata: Metadata = {
+ robots: { index: false, follow: true },
+};
+
+export default function BookingUserLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return children;
+}
diff --git a/src/app/gift/[userId]/page.tsx b/src/app/gift/[userId]/page.tsx
index 3503dc2..d62889f 100644
--- a/src/app/gift/[userId]/page.tsx
+++ b/src/app/gift/[userId]/page.tsx
@@ -11,6 +11,7 @@ import { User } from "@/types/types";
import React, { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
+import WalletUsagePrompt from "@/components/payment/WalletUsagePrompt";
const PRESET_AMOUNTS = [2000, 5000, 10000, 15000, 20000, 50000, 100000];
const CUSTOM_MIN = 5000;
@@ -33,6 +34,8 @@ function GiftPage({ params }: GiftPageProps) {
const [isCustom, setIsCustom] = useState(false);
const [message, setMessage] = useState("");
const [submitting, setSubmitting] = useState(false);
+ const [walletBalance, setWalletBalance] = useState(0);
+ const [showWalletPrompt, setShowWalletPrompt] = useState(false);
useEffect(() => {
const fetchUser = async () => {
@@ -43,6 +46,10 @@ function GiftPage({ params }: GiftPageProps) {
setUserDetail(response?.user);
};
void fetchUser();
+ 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
}, [userId, request]);
const finalAmount = useMemo(() => {
@@ -56,15 +63,24 @@ function GiftPage({ params }: GiftPageProps) {
const canSubmit = finalAmount > 0 && isCustomValid && !submitting;
- const handleSubmit = async () => {
+ const handleSubmit = async (useWallet: boolean) => {
if (!canSubmit) return;
setSubmitting(true);
try {
- const response = await request<{ authority?: string; paymentUrl?: string }>(
- "POST",
- "/gifts/initiate",
- { receiverId: userId, amount: finalAmount, message }
- );
+ const response = await request<{
+ authority?: string;
+ paymentUrl?: string;
+ paidFromWallet?: boolean;
+ }>("POST", "/gifts/initiate", {
+ receiverId: userId,
+ amount: finalAmount,
+ message,
+ useWallet,
+ });
+ if (response?.paidFromWallet) {
+ toast.success(t("wallet.paidFromWalletSuccess"));
+ return;
+ }
// لینک خارجی — router.push رفتارِ تضمینشدهای نداره؛ window.location.href
// مطمئنتره (خصوصاً سافاری)
if (response?.paymentUrl) {
@@ -84,6 +100,15 @@ function GiftPage({ params }: GiftPageProps) {
}
};
+ const handlePayClick = () => {
+ if (!canSubmit) return;
+ if (walletBalance > 0) {
+ setShowWalletPrompt(true);
+ return;
+ }
+ void handleSubmit(false);
+ };
+
return (
@@ -167,11 +192,21 @@ function GiftPage({ params }: GiftPageProps) {
variant="primary"
className="px-8 py-2"
disabled={!canSubmit}
- onClick={() => void handleSubmit()}
+ onClick={handlePayClick}
>
{t("giftPage.payButton")}
+
+ {
+ setShowWalletPrompt(false);
+ void handleSubmit(useWallet);
+ }}
+ />
);
diff --git a/src/app/gift/layout.tsx b/src/app/gift/layout.tsx
index 783ac58..a9986b7 100644
--- a/src/app/gift/layout.tsx
+++ b/src/app/gift/layout.tsx
@@ -1,6 +1,13 @@
+import type { Metadata } from "next";
import Header from "@/components/main/Header";
import TabNavigation from "@/components/TabNavigation";
+// فرم ارسال هدیه به یه کاربر خاص — محتوای یکتای قابلرتبهبندی نداره (خودِ
+// پروفایل کاربر جای درستیه که ایندکس بشه)، پس noindex میکنیم.
+export const metadata: Metadata = {
+ robots: { index: false, follow: true },
+};
+
export default function GiftLayout({
children,
}: {
diff --git a/src/app/gift/payment/failed/page.tsx b/src/app/gift/payment/failed/page.tsx
index 54a0562..0a9e11b 100644
--- a/src/app/gift/payment/failed/page.tsx
+++ b/src/app/gift/payment/failed/page.tsx
@@ -28,13 +28,20 @@ function GiftPaymentFailed() {
/>
{t("giftPage.paymentFailed.message")}
- {receiverId ? (
-
+
+ {receiverId ? (
+
+
+ {t("giftPage.paymentFailed.retry")}
+
+
+ ) : null}
+
- {t("giftPage.paymentFailed.retry")}
+ {t("giftPage.paymentFailed.back")}
- ) : null}
+
);
diff --git a/src/app/gift/payment/success/page.tsx b/src/app/gift/payment/success/page.tsx
index f89fea9..9886046 100644
--- a/src/app/gift/payment/success/page.tsx
+++ b/src/app/gift/payment/success/page.tsx
@@ -32,16 +32,15 @@ function GiftPaymentSuccess() {
- {receiverId && receiverUsername ? (
-
-
- {t("giftPage.paymentSuccess.sendMessage")}
-
-
- ) : null}
-
-
- {t("giftPage.paymentSuccess.later")}
+
+
+ {t("giftPage.paymentSuccess.backToChat")}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index c0affff..c9d72ad 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,7 +1,8 @@
import type { Metadata, Viewport } from "next";
import localFont from "next/font/local";
import { defaultSEOConfig } from "@/config/seoConfig";
-import { getSiteSeoMeta, getSeoPage } from "@/lib/i18n/seo";
+import { SITE_URL } from "@/config/site";
+import { getSiteSeoMeta, getSeoPage, buildHreflangAlternates } from "@/lib/i18n/seo";
import { getLanguageDefinition } from "@/lib/i18n/registry";
import { getServerLanguage } from "@/lib/i18n/server";
import SiteJsonLd from "@/components/seo/SiteJsonLd";
@@ -10,6 +11,7 @@ import "./globals.css";
import Layout from "@/components/Layout";
import RegisterSW from "@/components/RegisterSW";
import PushNotificationListener from "@/components/PushNotificationListener";
+import VpnWarningGate from "@/components/VpnWarningGate";
import ClientErrorBoundary from "@/components/ClientErrorBoundary";
const iranSansFont = localFont({
@@ -29,10 +31,11 @@ export async function generateMetadata(): Promise {
template: `%s | ${site.titleSuffix}`,
},
description: home.description,
- metadataBase: new URL("https://modstagram.com"),
+ metadataBase: new URL(SITE_URL),
keywords: site.keywords,
- authors: [{ name: "Modstagram", url: "https://modstagram.com" }],
+ authors: [{ name: "Modstagram", url: SITE_URL }],
creator: "Modstagram",
+ alternates: buildHreflangAlternates(home.path || "/", lang),
manifest: "/manifest.webmanifest",
applicationName: site.name,
icons: {
@@ -99,6 +102,7 @@ export default async function RootLayout({
+
{children}
diff --git a/src/app/new-post/page.tsx b/src/app/new-post/page.tsx
index 090be08..9d328b7 100644
--- a/src/app/new-post/page.tsx
+++ b/src/app/new-post/page.tsx
@@ -733,6 +733,7 @@ export default function NewPostPage() {
onShopsChange={setSelectedShops}
onShopProductsChange={setSelectedShopProducts}
onBookingChange={setSelectedBooking}
+ taggedUserIds={taggedUsers.map((u) => u._id)}
/>
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
new file mode 100644
index 0000000..7ed5e9c
--- /dev/null
+++ b/src/app/not-found.tsx
@@ -0,0 +1,25 @@
+import Link from "next/link";
+import Container from "@/components/elements/Container";
+import { getServerLanguage } from "@/lib/i18n/server";
+import { translateCommon } from "@/lib/i18n/translate";
+
+export default async function NotFound() {
+ const lang = await getServerLanguage();
+
+ return (
+
+
+ {translateCommon(lang, "notFoundPage.title")}
+
+
+ {translateCommon(lang, "notFoundPage.description")}
+
+
+ {translateCommon(lang, "notFoundPage.backHome")}
+
+
+ );
+}
diff --git a/src/app/offer/[id]/page.tsx b/src/app/offer/[id]/page.tsx
index 997fe7e..d3ad015 100644
--- a/src/app/offer/[id]/page.tsx
+++ b/src/app/offer/[id]/page.tsx
@@ -20,6 +20,7 @@ import Cookies from "js-cookie";
import * as Yup from "yup";
import { useTranslation } from "react-i18next";
import { useUser } from "@/hooks/useUser";
+import WalletUsagePrompt from "@/components/payment/WalletUsagePrompt";
type OfferTypeRow = IOfferType & {
disabled?: boolean;
unavailableReason?: string | null;
@@ -59,6 +60,8 @@ function TicketChat({ params }: ITicketChatProps) {
const [selectedType, setSelectedType] = useState("normal");
const [typeList, setTypeList] = useState
(null);
const [hasUsedFreeOffer, setHasUsedFreeOffer] = useState(false);
+ const [walletBalance, setWalletBalance] = useState(0);
+ const [showWalletPrompt, setShowWalletPrompt] = useState(false);
useEffect(() => {
const fetchUser = async () => {
@@ -69,6 +72,10 @@ function TicketChat({ params }: ITicketChatProps) {
setUserDetail(response?.user);
};
void fetchUser();
+ 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
}, [id, request]);
useEffect(() => {
@@ -106,6 +113,67 @@ function TicketChat({ params }: ITicketChatProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
+ const getToken = (): string => {
+ if (typeof window !== "undefined") {
+ const token = Cookies.get("token");
+ return token ? `Bearer ${token}` : "";
+ }
+ return "";
+ };
+
+ const startPayment = async (offerType: string, useWallet: boolean) => {
+ try {
+ const response = await axios.post(
+ `${BASE_URL}/offers/initiate-payment-web`,
+ {
+ offerType,
+ receiverId: id,
+ useWallet,
+ },
+ {
+ headers: {
+ Authorization: getToken(),
+ "Content-Type": "application/json",
+ },
+ }
+ );
+
+ const data: {
+ authority?: string;
+ paymentUrl?: string;
+ type?: string;
+ paidFromWallet?: boolean;
+ } = response.data;
+
+ if (offerType === "free" || data.type === "free") {
+ toast.success(t("offerPage.freeSuccess"));
+ setHasUsedFreeOffer(true);
+ router.push(`/offer/payment/success?userId=${id}`);
+ } else if (data.paidFromWallet) {
+ toast.success(t("wallet.paidFromWalletSuccess"));
+ router.push(`/offer/payment/success?userId=${id}`);
+ } else if (data.paymentUrl) {
+ // window.open بعدِ await در سافاری (دسکتاپ/iOS) پاپآپبلاک میشه چون دیگه
+ // synchronously به کلیکِ کاربر متصل نیست؛ ضمناً بازگشتِ ZarinPal (از طریق
+ // offer/payment-web/route.js) کلِ همون تب رو به "/" ریدایرکت میکنه، نه تبِ
+ // اصلی — پس با _blank دو تب باز میموند. window.location.href هم امنتره
+ // هم با بقیهی مسیرهای پرداختِ این اپ یکی میمونه
+ window.location.href = data.paymentUrl;
+ } else if (data.authority) {
+ window.location.href = `https://www.zarinpal.com/pg/StartPay/${data.authority}`;
+ } else {
+ toast.error(t("offerPage.paymentInfoError"));
+ }
+ } catch (err: any) {
+ console.error("Payment initiation error:", err);
+ toast.error(
+ err?.response?.data?.error ||
+ err?.response?.data?.message ||
+ t("offerPage.paymentStartError")
+ );
+ }
+ };
+
const formik = useFormik({
initialValues: {
selectedType: "normal",
@@ -124,58 +192,12 @@ function TicketChat({ params }: ITicketChatProps) {
return;
}
- const getToken = (): string => {
- if (typeof window !== "undefined") {
- const token = Cookies.get("token");
- return token ? `Bearer ${token}` : "";
- }
- return "";
- };
-
try {
- const response = await axios.post(
- `${BASE_URL}/offers/initiate-payment-web`,
- {
- offerType: values.selectedType,
- receiverId: id,
- },
- {
- headers: {
- Authorization: getToken(),
- "Content-Type": "application/json",
- },
- }
- );
-
- const data: {
- authority?: string;
- paymentUrl?: string;
- type?: string;
- } = response.data;
-
- if (values.selectedType === "free" || data.type === "free") {
- toast.success(t("offerPage.freeSuccess"));
- setHasUsedFreeOffer(true);
- router.push(`/offer/payment/success?userId=${id}`);
- } else if (data.paymentUrl) {
- // window.open بعدِ await در سافاری (دسکتاپ/iOS) پاپآپبلاک میشه چون دیگه
- // synchronously به کلیکِ کاربر متصل نیست؛ ضمناً بازگشتِ ZarinPal (از طریق
- // offer/payment-web/route.js) کلِ همون تب رو به "/" ریدایرکت میکنه، نه تبِ
- // اصلی — پس با _blank دو تب باز میموند. window.location.href هم امنتره
- // هم با بقیهی مسیرهای پرداختِ این اپ یکی میمونه
- window.location.href = data.paymentUrl;
- } else if (data.authority) {
- window.location.href = `https://www.zarinpal.com/pg/StartPay/${data.authority}`;
- } else {
- toast.error(t("offerPage.paymentInfoError"));
+ if (values.selectedType !== "free" && walletBalance > 0) {
+ setShowWalletPrompt(true);
+ return;
}
- } catch (err: any) {
- console.error("Payment initiation error:", err);
- toast.error(
- err?.response?.data?.error ||
- err?.response?.data?.message ||
- t("offerPage.paymentStartError")
- );
+ await startPayment(values.selectedType, false);
} finally {
setSubmitting(false);
}
@@ -272,6 +294,20 @@ function TicketChat({ params }: ITicketChatProps) {
{t("offerPage.submitRequest")}
+
+ item.name === selectedType)?.price
+ ) || 0
+ }
+ walletBalance={walletBalance}
+ onAnswer={(useWallet) => {
+ setShowWalletPrompt(false);
+ void startPayment(selectedType, useWallet);
+ }}
+ />
);
diff --git a/src/app/offer/payment/failed/page.tsx b/src/app/offer/payment/failed/page.tsx
index efa0f3c..b8ae509 100644
--- a/src/app/offer/payment/failed/page.tsx
+++ b/src/app/offer/payment/failed/page.tsx
@@ -29,14 +29,18 @@ function FailedBillboard() {
/>
{t("offerPage.paymentFailed.message")}
-
-
- {t("offerPage.paymentFailed.retry")}
-
-
+
+
+
+ {t("offerPage.paymentFailed.retry")}
+
+
+
+
+ {t("offerPage.paymentFailed.back")}
+
+
+
);
diff --git a/src/app/offer/payment/success/page.tsx b/src/app/offer/payment/success/page.tsx
index 8190d87..6df117f 100644
--- a/src/app/offer/payment/success/page.tsx
+++ b/src/app/offer/payment/success/page.tsx
@@ -50,18 +50,11 @@ function SuccessBillboard() {
username: userDetail?.user_name ?? "",
})}
-
-
-
- {t("offerPage.paymentSuccess.sendMessage")}
-
-
-
-
- {t("offerPage.paymentSuccess.later")}
-
-
-
+
+
+ {t("offerPage.paymentSuccess.sendMessage")}
+
+
diff --git a/src/app/posts/[id]/[[...slug]]/page.tsx b/src/app/posts/[id]/[[...slug]]/page.tsx
index 402bbed..c3704cc 100644
--- a/src/app/posts/[id]/[[...slug]]/page.tsx
+++ b/src/app/posts/[id]/[[...slug]]/page.tsx
@@ -1,17 +1,24 @@
import { Metadata } from "next";
+import { cache } from "react";
import { fetchPostById } from "@/api/fetchPostById";
import PostFeedView, { PostFeedSearchParams } from "@/components/posts/PostFeedView";
import { buildStorageUrl } from "@/components/main/BaseUrl";
import { generatePageMetadata } from "@/utils/generatePageMetadata";
import {
buildPostPath,
+ buildPostJsonLd,
buildPostSeoDescription,
buildPostSeoTitle,
} from "@/lib/postSlug";
+import { safeJsonLdString } from "@/lib/seo/safeJsonLd";
import { getSiteSeoMeta } from "@/lib/i18n/seo";
import { getServerLanguage } from "@/lib/i18n/server";
import { translateCommon } from "@/lib/i18n/translate";
+// Deduped per request: generateMetadata() and the page body's JSON-LD both need
+// this post — cache() avoids firing the fetch to the backend twice.
+const loadPost = cache((id: string) => fetchPostById(id));
+
type Props = {
params: Promise<{ id: string; slug?: string[] }>;
searchParams: Promise>;
@@ -50,7 +57,7 @@ function toFeedSearchParams(
export async function generateMetadata({ params }: Props): Promise {
const { id } = await params;
const lang = await getServerLanguage();
- const post = await fetchPostById(id);
+ const post = await loadPost(id);
const title = post
? buildPostSeoTitle(post, lang)
: translateCommon(lang, "postsMeta.defaultTitle");
@@ -84,6 +91,10 @@ export async function generateMetadata({ params }: Props): Promise {
].filter(Boolean),
publishedTime: post?.createdAt,
modifiedTime: post?.updatedAt,
+ // پستهای private/followers-only بدون توکن null برمیگردن ولی برای کاربر
+ // واقعی توی PostFeedView (کلاینتی) درست لود میشن — پس نباید notFound() کنیم،
+ // فقط نباید این حالت رو به گوگل بهعنوان محتوای معتبر ایندکس بدیم
+ index: Boolean(post),
lang,
});
}
@@ -93,9 +104,22 @@ export default async function PostPage({ params, searchParams }: Props) {
const { id } = await params;
const sp = await searchParams;
const feedSearchParams = toFeedSearchParams(sp);
+ const lang = await getServerLanguage();
+ const post = await loadPost(id);
return (
+ {post && (
+ <>
+ {buildPostSeoTitle(post, lang)}
+
+ >
+ )}
{
setIsLoading(true);
try {
- const response = await request(
+ const response = await request(
"GET",
`/academy/course/getUserPurchasedCoursesWithPopulate?page=${page}&limit=6`
);
diff --git a/src/app/settings/academy/wallet/page.tsx b/src/app/settings/academy/wallet/page.tsx
index 863ffa1..bb26acf 100644
--- a/src/app/settings/academy/wallet/page.tsx
+++ b/src/app/settings/academy/wallet/page.tsx
@@ -59,6 +59,13 @@ interface Payment {
};
}
+/** این اندپوینت ساختارهای مختلفی برمیگردونه — یه تایپِ عمومیِ صریح که همون
+ * فیلدهایی که کد واقعاً استفاده میکنه رو پوشش میده (بجای {} ضمنی) */
+type ApiResponse = {
+ data?: { payments?: Payment[]; [key: string]: any };
+ [key: string]: any;
+};
+
interface Transaction {
id: string;
type: "deposit" | "withdraw" | "expense";
@@ -86,7 +93,7 @@ const WalletDashboard = () => {
// دریافت پرداختهای موفق (فروشهای انجام شده)
const fetchSuccessfulPayments = async () => {
try {
- const response = await request(
+ const response = await request(
"GET",
`/academy/course/getAllAcademyPayments?status=success`
);
@@ -123,7 +130,7 @@ const WalletDashboard = () => {
// دریافت پرداختهای تسویه شده
const fetchSettledPayments = async () => {
try {
- const response = await request(
+ const response = await request(
"GET",
`/academy/course/getAllAcademyPayments?status=settled`
);
diff --git a/src/app/settings/ads/[id]/page.tsx b/src/app/settings/ads/[id]/page.tsx
new file mode 100644
index 0000000..baa5998
--- /dev/null
+++ b/src/app/settings/ads/[id]/page.tsx
@@ -0,0 +1,135 @@
+"use client";
+
+import Container from "@/components/elements/Container";
+import PageTitle from "@/components/settings/PageTitle";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import useAxios from "@/hooks/useAxios";
+import { IMAGE_BASE_URL } from "@/components/main/BaseUrl";
+import { useEffect, useState } from "react";
+import { useParams } from "next/navigation";
+import { useTranslation } from "react-i18next";
+
+type SponsoredContent = { title: string; image?: string | null };
+
+type AdDetail = {
+ _id: string;
+ ad_type: "first_post" | "first_story" | "notification" | "project" | "product" | "shop" | "billboard";
+ effective_status: "pending_payment" | "active" | "expired" | "failed";
+ price: number;
+ views_count: number;
+ clicks_count: number;
+ notification_title?: string;
+ notification_body?: string;
+ target_gender: "female" | "male" | "any";
+ target_expertise?: string | null;
+ target_sub_expertise?: string[];
+ started_at?: string;
+ expires_at?: string;
+};
+
+const STATUS_COLOR: Record = {
+ pending_payment: "text-amber-600",
+ active: "text-green-600",
+ expired: "text-neutral-400",
+ failed: "text-red-500",
+};
+
+export default function AdDetailPage() {
+ const { t } = useTranslation("common");
+ const { request } = useAxios();
+ const params = useParams<{ id: string }>();
+ const [ad, setAd] = useState(null);
+ const [content, setContent] = useState(null);
+
+ useEffect(() => {
+ if (!params?.id) return;
+ request<{ ad?: AdDetail; content?: SponsoredContent }>("GET", `/ads/${params.id}`, null, {
+ noToast: true,
+ })
+ .then((res) => {
+ setAd(res?.ad ?? null);
+ setContent(res?.content ?? null);
+ })
+ .catch(() => setAd(null));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [params?.id]);
+
+ const isLinkedContentType = ad
+ ? (["project", "product", "shop", "billboard"] as const).includes(
+ ad.ad_type as "project" | "product" | "shop" | "billboard"
+ )
+ : false;
+
+ const ctr =
+ ad && ad.views_count > 0 ? Math.round((ad.clicks_count / ad.views_count) * 1000) / 10 : 0;
+
+ return (
+
+
+ {t("ads.detailTitle")}
+
+ {!ad ? (
+ {t("common.loading")}
+ ) : (
+
+
+ {t(`ads.type.${ad.ad_type}`)}
+
+ {t(`ads.status.${ad.effective_status}`)}
+
+
+
+ {ad.ad_type === "notification" && (
+
+
{ad.notification_title}
+
{ad.notification_body}
+
+ )}
+
+ {isLinkedContentType && content && (
+
+ {content.image && (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+ )}
+
{content.title}
+
+ )}
+
+
+
+
{ad.views_count}
+
{t("ads.viewsLabel")}
+
+
+
{ad.clicks_count}
+
{t("ads.clicksLabel")}
+
+
+
{ctr}%
+
{t("ads.ctrLabel")}
+
+
+
+
+
{t("ads.targetingTitle")}
+
+ {t("ads.targetingGender")}:{" "}
+ {ad.target_gender === "any" ? t("ads.genderAny") : t(`ads.gender.${ad.target_gender}`)}
+
+
+ {t("ads.targetingExpertise")}: {ad.target_expertise || t("ads.genderAny")}
+
+
+
+
+ {Number(ad.price).toLocaleString()} {t("common.toman")}
+
+
+ )}
+
+
+ );
+}
diff --git a/src/app/settings/ads/new/page.tsx b/src/app/settings/ads/new/page.tsx
new file mode 100644
index 0000000..d5537e7
--- /dev/null
+++ b/src/app/settings/ads/new/page.tsx
@@ -0,0 +1,765 @@
+"use client";
+
+import Container from "@/components/elements/Container";
+import PageTitle from "@/components/settings/PageTitle";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import RoundedButton from "@/components/elements/RoundedButton";
+import RoundedInput from "@/components/elements/RoundedInput";
+import CountryProvinceCitySelect, {
+ type GeoSelection,
+} from "@/components/ui/CountryProvinceCitySelect";
+import WalletUsagePrompt from "@/components/payment/WalletUsagePrompt";
+import { IMAGE_BASE_URL, buildStorageUrl } from "@/components/main/BaseUrl";
+import useAxios from "@/hooks/useAxios";
+import { useUser } from "@/hooks/useUser";
+import { useRouter, useSearchParams } from "next/navigation";
+import { useEffect, useMemo, useState } from "react";
+import toast from "react-hot-toast";
+import { useTranslation } from "react-i18next";
+import { cn } from "@/lib/utils";
+import { toggleBtnClass } from "@/lib/ui/buttonStyles";
+
+type AdType =
+ | "first_post"
+ | "first_story"
+ | "notification"
+ | "project"
+ | "product"
+ | "shop"
+ | "billboard";
+
+type MyPost = {
+ _id: string;
+ post_images?: string[];
+ post_image?: string;
+ post_video?: string;
+ type?: "image" | "video";
+ files?: { path: string; type: "image" | "video" }[];
+ caption?: string;
+};
+
+type MyStory = {
+ _id: string;
+ media_path: string;
+ media_type?: string;
+};
+
+type Expertise = {
+ _id: string;
+ expertise: string;
+ sub_expertise?: { _id: string; name: string }[];
+};
+
+type MyProject = { _id: string; title?: string };
+type MyShop = { _id: string; name: string; logo?: string | null };
+type MyShopProduct = { _id: string; title: string; images?: string[]; primaryImageIndex?: number };
+type MyBillboard = { _id: string; title?: string; images?: string[] };
+
+const STEPS = ["type", "content", "location", "expertise", "gender", "review"] as const;
+type Step = (typeof STEPS)[number];
+
+/** تصویر بندانگشتی پست — post_images قدیمی + files جدید (و ویدیو) */
+function postThumb(post: MyPost): { src: string; isVideo: boolean } | null {
+ // ۱) تصاویر قدیمی
+ if (post.post_images?.[0]) {
+ return { src: buildStorageUrl(post.post_images[0]), isVideo: false };
+ }
+ // ۲) فایلهای جدید — اول تصویر، بعد هر فایلی
+ const imageFile = post.files?.find((f) => f.type === "image" && f.path);
+ if (imageFile?.path) {
+ return { src: buildStorageUrl(imageFile.path), isVideo: false };
+ }
+ const anyFile = post.files?.find((f) => f.path);
+ if (anyFile?.path) {
+ return {
+ src: buildStorageUrl(anyFile.path),
+ isVideo: anyFile.type === "video",
+ };
+ }
+ // ۳) فیلدهای قدیمی تکی
+ if (post.post_image) {
+ return { src: buildStorageUrl(post.post_image), isVideo: false };
+ }
+ if (post.post_video) {
+ return { src: buildStorageUrl(post.post_video), isVideo: true };
+ }
+ return null;
+}
+
+export default function NewAdPage() {
+ const { t } = useTranslation("common");
+ const { request } = useAxios();
+ const router = useRouter();
+ const user = useUser();
+ const searchParams = useSearchParams();
+
+ // از منوی ۳نقطهی ریلز («این پست رو آگهی کن») — نوع و پست از قبل معلومه، مستقیم
+ // میره سراغ مرحلهی لوکیشن (index دوم توی STEPS)
+ const presetPostId = searchParams.get("postId") || "";
+ const presetAdType = searchParams.get("type") === "first_post" ? "first_post" : "";
+
+ const [stepIndex, setStepIndex] = useState(presetPostId ? 2 : 0);
+ const step: Step = STEPS[stepIndex];
+
+ const [adType, setAdType] = useState(presetAdType as AdType | "");
+ const [selectedPostId, setSelectedPostId] = useState(presetPostId);
+ const [selectedStoryId, setSelectedStoryId] = useState("");
+ const [notifTitle, setNotifTitle] = useState("");
+ const [notifBody, setNotifBody] = useState("");
+ const [selectedProjectId, setSelectedProjectId] = useState("");
+ const [selectedShopId, setSelectedShopId] = useState("");
+ const [selectedBillboardId, setSelectedBillboardId] = useState("");
+ const [productShopId, setProductShopId] = useState("");
+ const [selectedShopProductId, setSelectedShopProductId] = useState("");
+
+ const [geo, setGeo] = useState({ countryId: null, provinceId: null, cityId: null });
+ const [expertise, setExpertise] = useState("");
+ const [subExpertise, setSubExpertise] = useState([]);
+ const [gender, setGender] = useState<"female" | "male" | "any">("any");
+
+ const [myPosts, setMyPosts] = useState(null);
+ const [myStories, setMyStories] = useState(null);
+ const [expertiseList, setExpertiseList] = useState([]);
+ const [myProjects, setMyProjects] = useState(null);
+ const [myShops, setMyShops] = useState(null);
+ const [myBillboards, setMyBillboards] = useState(null);
+ const [shopProducts, setShopProducts] = useState(null);
+
+ const [price, setPrice] = useState(null);
+ const [priceLoading, setPriceLoading] = useState(false);
+ const [walletBalance, setWalletBalance] = useState(0);
+ const [showWalletPrompt, setShowWalletPrompt] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+
+ useEffect(() => {
+ request<{ charged?: { available?: number } }>("GET", "/wallet/summary", null, { noToast: true })
+ .then((res) => setWalletBalance(res?.charged?.available || 0))
+ .catch(() => setWalletBalance(0));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ if (adType === "first_post" && user?._id && myPosts === null) {
+ request<{ posts?: MyPost[] }>("GET", `/posts/user-posts/web?user_id=${user._id}&page=1&limit=30`, null, {
+ noToast: true,
+ })
+ .then((res) => setMyPosts(res?.posts ?? []))
+ .catch(() => setMyPosts([]));
+ }
+ if (adType === "first_story" && myStories === null) {
+ request<{ my_story?: { stories?: MyStory[] } }>("GET", "/stories/feed", null, { noToast: true })
+ .then((res) => setMyStories(res?.my_story?.stories ?? []))
+ .catch(() => setMyStories([]));
+ }
+ if (adType === "project" && user?._id && myProjects === null) {
+ request<{ data?: { projects?: MyProject[] }; projects?: MyProject[] }>(
+ "GET",
+ `/projects/user-projects/web?user_id=${user._id}&page=1&limit=50&linkable=1`,
+ null,
+ { noToast: true }
+ )
+ .then((res) => setMyProjects(res?.data?.projects ?? res?.projects ?? []))
+ .catch(() => setMyProjects([]));
+ }
+ if ((adType === "shop" || adType === "product") && myShops === null) {
+ request<{ shops?: MyShop[] }>("GET", "/shops/mine", null, { noToast: true })
+ .then((res) => setMyShops(res?.shops ?? []))
+ .catch(() => setMyShops([]));
+ }
+ if (adType === "billboard" && myBillboards === null) {
+ request<{ advertisings?: MyBillboard[]; data?: { advertisings?: MyBillboard[] } }>(
+ "GET",
+ "/advertising/user-advertising?page=1&limit=50",
+ null,
+ { noToast: true }
+ )
+ .then((res) => setMyBillboards(res?.advertisings ?? res?.data?.advertisings ?? []))
+ .catch(() => setMyBillboards([]));
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [adType, user?._id]);
+
+ useEffect(() => {
+ if (!productShopId) return;
+ setShopProducts(null);
+ request<{ docs?: MyShopProduct[] }>("GET", `/shop-products?shopId=${productShopId}&limit=100`, null, {
+ noToast: true,
+ })
+ .then((res) => setShopProducts(res?.docs ?? []))
+ .catch(() => setShopProducts([]));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [productShopId]);
+
+ useEffect(() => {
+ request<{ expertises?: Expertise[] }>("GET", "/expertise", null, { noToast: true })
+ .then((res) => setExpertiseList(res?.expertises ?? []))
+ .catch(() => setExpertiseList([]));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // قیمتِ زنده — با هر تغییری توی نوع/هدفگذاری دوباره از سرور میگیره (utils/adPricing.js)
+ useEffect(() => {
+ if (!adType) return;
+ setPriceLoading(true);
+ const params = new URLSearchParams({ ad_type: adType });
+ if (geo.provinceId != null) params.set("target_province_id", String(geo.provinceId));
+ if (gender !== "any") params.set("target_gender", gender);
+ if (expertise) params.set("target_expertise", expertise);
+ if (subExpertise.length) params.set("target_sub_expertise", subExpertise.join(","));
+ request<{ price: number | null }>("GET", `/ads/pricing?${params.toString()}`, null, { noToast: true })
+ .then((res) => setPrice(res?.price ?? null))
+ .catch(() => setPrice(null))
+ .finally(() => setPriceLoading(false));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [adType, geo.provinceId, gender, expertise, subExpertise]);
+
+ const canGoNext = useMemo(() => {
+ if (step === "type") return Boolean(adType);
+ if (step === "content") {
+ if (adType === "first_post") return Boolean(selectedPostId);
+ if (adType === "first_story") return Boolean(selectedStoryId);
+ if (adType === "notification") return notifTitle.trim().length > 0 && notifBody.trim().length > 0;
+ if (adType === "project") return Boolean(selectedProjectId);
+ if (adType === "shop") return Boolean(selectedShopId);
+ if (adType === "product") return Boolean(selectedShopProductId);
+ if (adType === "billboard") return Boolean(selectedBillboardId);
+ return false;
+ }
+ return true;
+ }, [
+ step,
+ adType,
+ selectedPostId,
+ selectedStoryId,
+ notifTitle,
+ notifBody,
+ selectedProjectId,
+ selectedShopId,
+ selectedShopProductId,
+ selectedBillboardId,
+ ]);
+
+ const goNext = () => setStepIndex((i) => Math.min(i + 1, STEPS.length - 1));
+ const goBack = () => setStepIndex((i) => Math.max(i - 1, 0));
+
+ const buildPayload = (useWallet: boolean) => ({
+ ad_type: adType,
+ content_post_id: adType === "first_post" ? selectedPostId : undefined,
+ content_story_id: adType === "first_story" ? selectedStoryId : undefined,
+ notification_title: adType === "notification" ? notifTitle.trim() : undefined,
+ notification_body: adType === "notification" ? notifBody.trim() : undefined,
+ content_project_id: adType === "project" ? selectedProjectId : undefined,
+ content_shop_id: adType === "shop" ? selectedShopId : undefined,
+ content_shop_product_id: adType === "product" ? selectedShopProductId : undefined,
+ content_billboard_id: adType === "billboard" ? selectedBillboardId : undefined,
+ target_country_id: geo.countryId ?? undefined,
+ target_province_id: geo.provinceId ?? undefined,
+ target_city_id: geo.cityId ?? undefined,
+ target_expertise: expertise || undefined,
+ target_sub_expertise: subExpertise,
+ target_gender: gender,
+ useWallet,
+ });
+
+ const submit = async (useWallet: boolean) => {
+ setSubmitting(true);
+ try {
+ const res = await request<{
+ paidFromWallet?: boolean;
+ paymentUrl?: string;
+ authority?: string;
+ }>("POST", "/ads", buildPayload(useWallet));
+
+ if (res?.paidFromWallet) {
+ toast.success(t("wallet.paidFromWalletSuccess"));
+ router.push("/settings/ads");
+ return;
+ }
+ const payUrl = res?.paymentUrl || (res?.authority ? `https://www.zarinpal.com/pg/StartPay/${res.authority}` : null);
+ if (payUrl) {
+ window.location.href = payUrl;
+ } else {
+ toast.error(t("settings.paymentLinkNotReceived"));
+ }
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
+ t("common.error");
+ toast.error(msg);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleSubmitClick = () => {
+ if (!price) {
+ toast.error(t("ads.priceUnavailable"));
+ return;
+ }
+ if (walletBalance > 0) {
+ setShowWalletPrompt(true);
+ return;
+ }
+ void submit(false);
+ };
+
+ return (
+
+
+ {t("ads.newTitle")}
+
+
+ {step === "type" && (
+
+
{t("ads.chooseTypeTitle")}
+
+ {(
+ // "shop" فعلاً کامنت شده — طبق خواستهی کاربر، صفحهی عمومیِ گشتوگذارِ
+ // فروشگاهها وجود نداره، پس فعلاً فقط "product" (کالا) قابلانتخابه
+ [
+ "first_post",
+ "first_story",
+ "notification",
+ "project",
+ "product",
+ // "shop",
+ "billboard",
+ ] as AdType[]
+ ).map((type) => (
+ setAdType(type)}
+ className={toggleBtnClass(
+ adType === type,
+ "min-h-10 p-2 text-xs leading-tight text-center"
+ )}
+ >
+ {t(`ads.type.${type}`)}
+
+ ))}
+
+
+ )}
+
+ {step === "content" && adType === "first_post" && (
+
+
{t("ads.choosePostTitle")}
+ {myPosts === null ? (
+
{t("common.loading")}
+ ) : myPosts.length === 0 ? (
+
{t("ads.noPostsYet")}
+ ) : (
+
+ {myPosts.map((post) => {
+ const thumb = postThumb(post);
+ return (
+
setSelectedPostId(post._id)}
+ className={cn(
+ "relative aspect-square overflow-hidden rounded-xl border-2 bg-neutral-100 dark:bg-neutral-800",
+ selectedPostId === post._id ? "border-pink-500" : "border-transparent"
+ )}
+ >
+ {thumb?.isVideo ? (
+
+ ) : thumb?.src ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : null}
+
+ );
+ })}
+
+ )}
+
+ )}
+
+ {step === "content" && adType === "first_story" && (
+
+
{t("ads.chooseStoryTitle")}
+ {myStories === null ? (
+
{t("common.loading")}
+ ) : myStories.length === 0 ? (
+
{t("ads.noStoriesYet")}
+ ) : (
+
+ {myStories.map((story) => (
+
setSelectedStoryId(story._id)}
+ className={cn(
+ "aspect-[9/16] overflow-hidden rounded-xl border-2 bg-neutral-100 dark:bg-neutral-800",
+ selectedStoryId === story._id ? "border-pink-500" : "border-transparent"
+ )}
+ >
+ {story.media_type !== "video" ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : null}
+
+ ))}
+
+ )}
+
+ )}
+
+ {step === "content" && adType === "notification" && (
+
+
{t("ads.notificationContentTitle")}
+
setNotifTitle(e.target.value)}
+ placeholder={t("ads.notificationTitlePlaceholder")}
+ />
+ setNotifBody(e.target.value)}
+ placeholder={t("ads.notificationBodyPlaceholder")}
+ rows={3}
+ className="w-full resize-none rounded-2xl border border-neutral-200 bg-transparent px-4 py-3 outline-none dark:border-neutral-700"
+ />
+
+ )}
+
+ {step === "content" && adType === "project" && (
+
+
{t("ads.chooseProjectTitle")}
+ {myProjects === null ? (
+
{t("common.loading")}
+ ) : myProjects.length === 0 ? (
+
{t("ads.noProjectsYet")}
+ ) : (
+
+ {myProjects.map((p) => (
+ setSelectedProjectId(p._id)}
+ className={cn(
+ "w-full rounded-2xl border px-4 py-3 text-right",
+ selectedProjectId === p._id
+ ? "border-pink-500 bg-pink-50 dark:bg-pink-950/30"
+ : "border-neutral-200 dark:border-neutral-700"
+ )}
+ >
+ {p.title || t("ads.untitled")}
+
+ ))}
+
+ )}
+
+ )}
+
+ {step === "content" && adType === "shop" && (
+
+
{t("ads.chooseShopTitle")}
+ {myShops === null ? (
+
{t("common.loading")}
+ ) : myShops.length === 0 ? (
+
{t("ads.noShopsYet")}
+ ) : (
+
+ {myShops.map((shop) => (
+
setSelectedShopId(shop._id)}
+ className={cn(
+ "flex flex-col items-center gap-1 rounded-xl border-2 p-2",
+ selectedShopId === shop._id ? "border-pink-500" : "border-transparent"
+ )}
+ >
+
+ {shop.logo ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : null}
+
+ {shop.name}
+
+ ))}
+
+ )}
+
+ )}
+
+ {step === "content" && adType === "product" && (
+
+
{t("ads.chooseShopTitle")}
+ {myShops === null ? (
+
{t("common.loading")}
+ ) : (
+
+ {myShops.map((shop) => (
+ {
+ setProductShopId(shop._id);
+ setSelectedShopProductId("");
+ }}
+ className={cn(
+ "rounded-full border px-3 py-1.5",
+ productShopId === shop._id
+ ? "border-pink-500 bg-pink-50 dark:bg-pink-950/30"
+ : "border-neutral-200 dark:border-neutral-700"
+ )}
+ >
+ {shop.name}
+
+ ))}
+
+ )}
+
+ {productShopId && (
+ <>
+
{t("ads.chooseProductTitle")}
+ {shopProducts === null ? (
+
{t("common.loading")}
+ ) : shopProducts.length === 0 ? (
+
{t("ads.noProductsYet")}
+ ) : (
+
+ {shopProducts.map((product) => {
+ const img = product.images?.[product.primaryImageIndex ?? 0] ?? product.images?.[0];
+ return (
+
setSelectedShopProductId(product._id)}
+ className={cn(
+ "aspect-square overflow-hidden rounded-xl border-2 bg-neutral-100 dark:bg-neutral-800",
+ selectedShopProductId === product._id ? "border-pink-500" : "border-transparent"
+ )}
+ >
+ {img ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : null}
+
+ );
+ })}
+
+ )}
+ >
+ )}
+
+ )}
+
+ {step === "content" && adType === "billboard" && (
+
+
{t("ads.chooseBillboardTitle")}
+ {myBillboards === null ? (
+
{t("common.loading")}
+ ) : myBillboards.length === 0 ? (
+
{t("ads.noBillboardsYet")}
+ ) : (
+
+ {myBillboards.map((b) => (
+ setSelectedBillboardId(b._id)}
+ className={cn(
+ "w-full rounded-2xl border px-4 py-3 text-right",
+ selectedBillboardId === b._id
+ ? "border-pink-500 bg-pink-50 dark:bg-pink-950/30"
+ : "border-neutral-200 dark:border-neutral-700"
+ )}
+ >
+ {b.title || t("ads.untitled")}
+
+ ))}
+
+ )}
+
+ )}
+
+ {step === "location" && (
+
+
{t("ads.chooseLocationTitle")}
+
+
+ )}
+
+ {step === "expertise" && (
+
+
{t("ads.chooseExpertiseTitle")}
+
+ {
+ setExpertise("");
+ setSubExpertise([]);
+ }}
+ className={toggleBtnClass(
+ !expertise,
+ "min-h-10 p-2 text-xs leading-tight text-center"
+ )}
+ >
+ {t("ads.genderAny")}
+
+ {expertiseList.map((exp) => (
+ {
+ setExpertise(exp.expertise);
+ setSubExpertise([]);
+ }}
+ className={toggleBtnClass(
+ expertise === exp.expertise,
+ "min-h-10 p-2 text-xs leading-tight text-center"
+ )}
+ >
+ {exp.expertise}
+
+ ))}
+
+
+ {expertise &&
+ (expertiseList.find((e) => e.expertise === expertise)?.sub_expertise?.length ?? 0) > 0 && (
+
+
{t("ads.chooseSubExpertiseTitle")}
+
+ {(expertiseList.find((e) => e.expertise === expertise)?.sub_expertise ?? []).map(
+ (sub) => (
+
+ setSubExpertise((prev) =>
+ prev.includes(sub.name)
+ ? prev.filter((s) => s !== sub.name)
+ : [...prev, sub.name]
+ )
+ }
+ className={toggleBtnClass(
+ subExpertise.includes(sub.name),
+ "min-h-10 p-2 text-xs leading-tight text-center"
+ )}
+ >
+ {sub.name}
+
+ )
+ )}
+
+
+ )}
+
+ )}
+
+ {step === "gender" && (
+
+
{t("ads.chooseGenderTitle")}
+
+ {(["female", "male", "any"] as const).map((g) => (
+ setGender(g)}
+ className={toggleBtnClass(
+ gender === g,
+ "min-h-10 p-2 text-xs leading-tight text-center"
+ )}
+ >
+ {g === "any" ? t("ads.genderAny") : t(`ads.gender.${g}`)}
+
+ ))}
+
+
+ )}
+
+ {step === "review" && (
+
+
{t("ads.reviewTitle")}
+
+
{t(`ads.type.${adType}`)}
+
+
+
{t("ads.priceLabel")}
+
+ {priceLoading
+ ? "..."
+ : price != null
+ ? `${price.toLocaleString()} ${t("common.toman")}`
+ : t("ads.priceUnavailable")}
+
+
+ {/* dir=ltr تا در صفحه RTL هم قبلی سمت چپ فیزیکی و پرداخت سمت راست بماند */}
+
+
+ {t("ads.backAction")}
+
+
+ {submitting ? t("common.loading") : t("ads.payAndPublish")}
+
+
+
+ )}
+
+ {step !== "review" && (
+
+
+ {t("ads.nextAction")}
+
+
+ {t("ads.backAction")}
+
+
+ )}
+
+
+
+ {
+ setShowWalletPrompt(false);
+ void submit(useWallet);
+ }}
+ />
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/settings/ads/page.tsx b/src/app/settings/ads/page.tsx
new file mode 100644
index 0000000..9d84042
--- /dev/null
+++ b/src/app/settings/ads/page.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+import Container from "@/components/elements/Container";
+import PageTitle from "@/components/settings/PageTitle";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import EmptyState from "@/components/elements/EmptyState";
+import { staticIconUrl } from "@/components/main/BaseUrl";
+import useAxios from "@/hooks/useAxios";
+import Image from "next/image";
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+type AdRow = {
+ _id: string;
+ ad_type: "first_post" | "first_story" | "notification" | "project" | "product" | "shop" | "billboard";
+ effective_status: "pending_payment" | "active" | "expired" | "failed";
+ price: number;
+ views_count: number;
+ clicks_count: number;
+ createdAt: string;
+};
+
+const STATUS_COLOR: Record = {
+ pending_payment: "text-amber-600",
+ active: "text-green-600",
+ expired: "text-neutral-400",
+ failed: "text-red-500",
+};
+
+export default function AdsSettingsPage() {
+ const { t } = useTranslation("common");
+ const { request } = useAxios();
+ const [ads, setAds] = useState(null);
+
+ useEffect(() => {
+ request<{ ads?: AdRow[] }>("GET", "/ads/mine", null, { noToast: true })
+ .then((res) => setAds(res?.ads ?? []))
+ .catch(() => setAds([]));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+
+
+
+
+ }
+ >
+ {t("settings.nav.ads")}
+
+
+
+ {ads === null ? (
+
{t("common.loading")}
+ ) : ads.length === 0 ? (
+
+ ) : (
+ ads.map((ad) => (
+
+
+
{t(`ads.type.${ad.ad_type}`)}
+
+ {t("ads.viewsClicks", { views: ad.views_count, clicks: ad.clicks_count })}
+
+
+
+
+ {t(`ads.status.${ad.effective_status}`)}
+
+
{Number(ad.price).toLocaleString()} {t("common.toman")}
+
+
+ ))
+ )}
+
+
+
+ );
+}
diff --git a/src/app/settings/auto-direct/page.tsx b/src/app/settings/auto-direct/page.tsx
new file mode 100644
index 0000000..8dba741
--- /dev/null
+++ b/src/app/settings/auto-direct/page.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import Container from "@/components/elements/Container";
+import PageTitle from "@/components/settings/PageTitle";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import AutoDirectList from "@/components/settings/AutoDirectList";
+import { staticIconUrl } from "@/components/main/BaseUrl";
+import Image from "next/image";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import AutoDirectFormModal from "@/components/settings/AutoDirectFormModal";
+
+function AutoDirectSettingsPage() {
+ const { t } = useTranslation("common");
+ const [showCreate, setShowCreate] = useState(false);
+ const [refreshKey, setRefreshKey] = useState(0);
+
+ return (
+
+
+ setShowCreate(true)}
+ aria-label={t("autoDirect.addNew")}
+ className="p-2"
+ >
+
+
+ }
+ >
+ {t("settings.nav.autoDirect")}
+
+
+
+
+ {showCreate && (
+ setShowCreate(false)}
+ rule={null}
+ onSaved={() => setRefreshKey((k) => k + 1)}
+ />
+ )}
+
+ );
+}
+
+export default AutoDirectSettingsPage;
diff --git a/src/app/settings/booking/list/page.tsx b/src/app/settings/booking/list/page.tsx
index 2f9b33b..29d2365 100644
--- a/src/app/settings/booking/list/page.tsx
+++ b/src/app/settings/booking/list/page.tsx
@@ -1,6 +1,7 @@
"use client";
import Container from "@/components/elements/Container";
+import EmptyState from "@/components/elements/EmptyState";
import RoundedButton from "@/components/elements/RoundedButton";
import PageTitle from "@/components/settings/PageTitle";
import LocalePageShell from "@/components/i18n/LocalePageShell";
@@ -58,9 +59,11 @@ export default function BookingListPage() {
{t("common.loading")}
) : filteredConfigs.length === 0 ? (
-
- {t("booking.emptyList")}
-
+
) : (
filteredConfigs.map((config) => (
(null);
const [userTwoDetail, setUserTwoDetail] = useState();
const [pendingImages, setPendingImages] = useState([]);
const [showMultiModal, setShowMultiModal] = useState(false);
@@ -129,7 +131,7 @@ function TicketChat({ params }: ITicketChatProps) {
if (currentUserId && targetReceiverId) {
queryClient.setQueryData(
chatThreadQueryKey(currentUserId, targetReceiverId),
- (oldData) => upsertMessageInThreadCache(oldData, sentMsg)
+ (oldData: ChatThreadData | undefined) => upsertMessageInThreadCache(oldData, sentMsg)
);
}
@@ -250,14 +252,23 @@ function TicketChat({ params }: ITicketChatProps) {
useEffect(() => {
const giftId = searchParams.get("giftId");
- if (!giftId) return;
- const giftAmount = Number(searchParams.get("giftAmount") || 0);
- const giftMessage = searchParams.get("giftMessage") || "";
- sendGiftMessage({ _id: giftId, amount: giftAmount, message: giftMessage || null });
+ const giftFailed = searchParams.get("giftPaymentFailed");
+ if (!giftId && !giftFailed) return;
+
+ if (giftId) {
+ const giftAmount = Number(searchParams.get("giftAmount") || 0);
+ const giftMessage = searchParams.get("giftMessage") || "";
+ sendGiftMessage({ _id: giftId, amount: giftAmount, message: giftMessage || null });
+ setGiftResultStatus("success");
+ } else if (giftFailed) {
+ setGiftResultStatus("failed");
+ }
+
const params = new URLSearchParams(searchParams.toString());
params.delete("giftId");
params.delete("giftAmount");
params.delete("giftMessage");
+ params.delete("giftPaymentFailed");
const query = params.toString();
router.replace(`/settings/chats/${username}/${chatPartnerId}${query ? `?${query}` : ""}`);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -386,7 +397,8 @@ function TicketChat({ params }: ITicketChatProps) {
for (const key in messagePayload) {
formData.append(key, String(messagePayload[key]));
}
- formData.append("file", fileForUpload);
+ // isFileUpload بالا (که این بلوک زیرِ اونه) از قبل fileForUpload رو truthy چک کرده
+ formData.append("file", fileForUpload as File);
response = await request(
"post",
@@ -511,7 +523,7 @@ function TicketChat({ params }: ITicketChatProps) {
if (currentUserId && targetReceiverId) {
queryClient.setQueryData(
chatThreadQueryKey(currentUserId, targetReceiverId),
- (oldData) => {
+ (oldData: ChatThreadData | undefined) => {
if (!oldData) return oldData;
return {
...oldData,
@@ -542,7 +554,7 @@ function TicketChat({ params }: ITicketChatProps) {
if (updated && currentUserId && targetReceiverId) {
queryClient.setQueryData(
chatThreadQueryKey(currentUserId, targetReceiverId),
- (oldData) => upsertMessageInThreadCache(oldData, updated)
+ (oldData: ChatThreadData | undefined) => upsertMessageInThreadCache(oldData, updated)
);
}
closeActionMode();
@@ -550,7 +562,7 @@ function TicketChat({ params }: ITicketChatProps) {
if (currentUserId && targetReceiverId) {
queryClient.setQueryData(
chatThreadQueryKey(currentUserId, targetReceiverId),
- (oldData) => {
+ (oldData: ChatThreadData | undefined) => {
if (!oldData) return oldData;
return {
...oldData,
@@ -741,6 +753,15 @@ function TicketChat({ params }: ITicketChatProps) {
onWalletGiftSent={sendGiftMessage}
/>
+ setGiftResultStatus(null)}
+ onRetry={() => {
+ setGiftResultStatus(null);
+ setShowGiftModal(true);
+ }}
+ />
+
{users.length === 0 ? (
-
-
{t("findPartner.empty")}
-
{t("findPartner.emptyHint")}
-
+
) : (
users.map((item) => {
const age = ageFromBirthday(item.birthday);
diff --git a/src/app/settings/chats/nearby/page.tsx b/src/app/settings/chats/nearby/page.tsx
index e26bb46..c7de495 100644
--- a/src/app/settings/chats/nearby/page.tsx
+++ b/src/app/settings/chats/nearby/page.tsx
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import Map, { Marker } from "react-map-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import Container from "@/components/elements/Container";
+import EmptyState from "@/components/elements/EmptyState";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import VerificationBadge from "@/components/main/VerificationBadge";
import LocalePageShell from "@/components/i18n/LocalePageShell";
@@ -479,9 +480,11 @@ export default function NearbyFriendsPage() {
) : null}
{users.length === 0 ? (
-
- {t("nearbyFriends.empty")}
-
+
) : (
users.map((item) => (
{isEmpty ? (
-
- {t("chats.empty")}
-
+
) : (
data?.pages.map((page) =>
page.filteredUsersData?.map((item: IMessage) => {
diff --git a/src/app/settings/chats/rooms/[roomId]/page.tsx b/src/app/settings/chats/rooms/[roomId]/page.tsx
index 5471013..a11b562 100644
--- a/src/app/settings/chats/rooms/[roomId]/page.tsx
+++ b/src/app/settings/chats/rooms/[roomId]/page.tsx
@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Container from "@/components/elements/Container";
+import EmptyState from "@/components/elements/EmptyState";
import BackButton from "@/components/ui/BackButton";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import VerificationBadge from "@/components/main/VerificationBadge";
@@ -856,9 +857,7 @@ export default function ChatRoomThreadPage({
) : messages.length === 0 ? (
-
- {t("chats.threadEmpty")}
-
+
) : (
{messages.map((msg) => {
diff --git a/src/app/settings/chats/rooms/page.tsx b/src/app/settings/chats/rooms/page.tsx
index 8e051e0..3043f9f 100644
--- a/src/app/settings/chats/rooms/page.tsx
+++ b/src/app/settings/chats/rooms/page.tsx
@@ -2,6 +2,7 @@
import Container from "@/components/elements/Container";
import CreateChatRoomModal from "@/components/chat/CreateChatRoomModal";
+import EmptyState from "@/components/elements/EmptyState";
import ProfileAvatar from "@/components/main/ProfileAvatar";
import useAxios from "@/hooks/useAxios";
import { useUser } from "@/hooks/useUser";
@@ -106,9 +107,7 @@ export default function ChatRoomsPage() {
) : rooms.length === 0 ? (
-
- {t("chats.roomsEmpty")}
-
+
) : (
{rooms.map((room) => (
diff --git a/src/app/settings/edit/sizes/page.tsx b/src/app/settings/edit/sizes/page.tsx
index d9d2c6c..369e5aa 100644
--- a/src/app/settings/edit/sizes/page.tsx
+++ b/src/app/settings/edit/sizes/page.tsx
@@ -81,7 +81,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={() => setSelectedButton(1)}
>
diff --git a/src/app/settings/gifts/page.tsx b/src/app/settings/gifts/page.tsx
index 099a2b4..ba26514 100644
--- a/src/app/settings/gifts/page.tsx
+++ b/src/app/settings/gifts/page.tsx
@@ -1,6 +1,7 @@
"use client";
import Container from "@/components/elements/Container";
+import EmptyState from "@/components/elements/EmptyState";
import RoundedButton from "@/components/elements/RoundedButton";
import GiftItem from "@/components/settings/gifts/GiftItem";
import GiftReplyModal from "@/components/settings/gifts/GiftReplyModal";
@@ -59,9 +60,11 @@ function GiftsPage() {
{data?.pages.length === 0 ||
(data?.pages[0]?.gifts?.length === 0 && !isFetchingNextPage) ? (
-
- {t("settings.gifts.empty")}
-
+
) : (
data?.pages?.map((page, pageIndex) => (
diff --git a/src/app/settings/my-billboards/page.tsx b/src/app/settings/my-billboards/page.tsx
index a18d613..c72ab86 100644
--- a/src/app/settings/my-billboards/page.tsx
+++ b/src/app/settings/my-billboards/page.tsx
@@ -2,6 +2,7 @@
import MainBillboardCard from "@/components/billboards/MainBillboardCard/MainBillboardCard";
import Container from "@/components/elements/Container";
+import EmptyState from "@/components/elements/EmptyState";
import RoundedInput from "@/components/elements/RoundedInput";
import FilterModal from "@/components/settings/my-billboards/FilterModal";
import PageTitle from "@/components/settings/PageTitle";
@@ -97,9 +98,11 @@ function MyBillboard() {
{data?.pages.length === 0 ||
(data?.pages[0]?.advertisings?.length === 0 &&
!isFetchingNextPage) ? (
-
- {t("settings.billboards.empty")}
-
+
) : (
data?.pages?.map((page, pageIndex) => (
diff --git a/src/app/settings/notifications/page.tsx b/src/app/settings/notifications/page.tsx
index 04694bd..f586c44 100644
--- a/src/app/settings/notifications/page.tsx
+++ b/src/app/settings/notifications/page.tsx
@@ -1,6 +1,7 @@
"use client";
import Container from "@/components/elements/Container";
+import EmptyState from "@/components/elements/EmptyState";
import PageTitle from "@/components/settings/PageTitle";
import UserDetails from "@/components/settings/UserDetails";
import NotificationRow from "@/components/settings/NotificationRow";
@@ -229,9 +230,11 @@ function Notifications() {
{filteredItems.length === 0 && !isFetchingNextPage ? (
-
- {t("settings.notifications.empty")}
-
+
) : (
groupedItems.map((entry) =>
entry.type === "date" ? (
diff --git a/src/app/settings/offers/page.tsx b/src/app/settings/offers/page.tsx
index 5ba399d..6fd7c05 100644
--- a/src/app/settings/offers/page.tsx
+++ b/src/app/settings/offers/page.tsx
@@ -1,6 +1,7 @@
"use client";
import Container from "@/components/elements/Container";
+import EmptyState from "@/components/elements/EmptyState";
import RoundedButton from "@/components/elements/RoundedButton";
import CommentModal from "@/components/settings/offers/CommentModal";
import ConfirmModal from "@/components/settings/offers/ConfirmModal";
@@ -63,9 +64,11 @@ function Offers() {
{data?.pages.length === 0 ||
(data?.pages[0]?.offer?.length === 0 && !isFetchingNextPage) ? (
-
- {t("settings.offers.empty")}
-
+
) : (
data?.pages?.map((page, pageIndex) => (
diff --git a/src/app/settings/profile/user-settings/page.tsx b/src/app/settings/profile/user-settings/page.tsx
index 04c168a..d4a519d 100644
--- a/src/app/settings/profile/user-settings/page.tsx
+++ b/src/app/settings/profile/user-settings/page.tsx
@@ -223,6 +223,30 @@ export default function UserSettingsPage() {
›
+
+ {t("settings.nav.autoDirect")}
+ ›
+
+
+
+ {t("settings.nav.shareableLinks")}
+ ›
+
+
+
+ {t("settings.nav.ads")}
+ ›
+
+
{/* آنالیتیکس/کاربران مسدود/بایگانی/فعالیت شما و محتوای این آیتمها فقط توی
فارسی/عربی/انگلیسی نمایش داده میشن */}
{SETTINGS_ITEMS_VISIBLE_LANGUAGES.includes(language) ? (
diff --git a/src/app/settings/profile/visitors/page.tsx b/src/app/settings/profile/visitors/page.tsx
index f32ac6c..7661d09 100644
--- a/src/app/settings/profile/visitors/page.tsx
+++ b/src/app/settings/profile/visitors/page.tsx
@@ -2,6 +2,7 @@
import React, { useEffect, useMemo, useState } from "react";
import Container from "@/components/elements/Container";
+import EmptyState from "@/components/elements/EmptyState";
import PageTitle from "@/components/settings/PageTitle";
import VisitorRow, { type ProfileVisitor } from "@/components/settings/VisitorRow";
import ListDateSeparator from "@/components/settings/ListDateSeparator";
@@ -60,9 +61,11 @@ export default function ProfileVisitorsPage() {
) : error ? (
{error}
) : visitors.length === 0 ? (
-
- {t("settings.visitor.empty")}
-
+
) : (
{groupedVisitors.map((entry) =>
diff --git a/src/app/settings/shareable-links/page.tsx b/src/app/settings/shareable-links/page.tsx
new file mode 100644
index 0000000..50b6747
--- /dev/null
+++ b/src/app/settings/shareable-links/page.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+import Container from "@/components/elements/Container";
+import PageTitle from "@/components/settings/PageTitle";
+import LocalePageShell from "@/components/i18n/LocalePageShell";
+import ShareableLinksManager from "@/components/settings/ShareableLinksManager";
+import { useTranslation } from "react-i18next";
+
+function ShareableLinksSettingsPage() {
+ const { t } = useTranslation("common");
+
+ return (
+
+
+ {t("settings.nav.shareableLinks")}
+
+
+
+
+
+ );
+}
+
+export default ShareableLinksSettingsPage;
diff --git a/src/app/settings/tickets/page.tsx b/src/app/settings/tickets/page.tsx
index 2c76c14..1c8a144 100644
--- a/src/app/settings/tickets/page.tsx
+++ b/src/app/settings/tickets/page.tsx
@@ -1,6 +1,7 @@
"use client";
import Container from "@/components/elements/Container";
+import EmptyState from "@/components/elements/EmptyState";
import RoundedButton from "@/components/elements/RoundedButton";
import RoundedInput from "@/components/elements/RoundedInput";
import PageTitle from "@/components/settings/PageTitle";
@@ -57,9 +58,11 @@ function Tickets() {
{data?.pages.length === 0 ||
(data?.pages[0]?.tickets?.length === 0 && !isFetchingNextPage) ? (
-
- {t("settings.tickets.empty")}
-
+
) : (
data?.pages?.map((page, pageIndex) => (
diff --git a/src/app/settings/workroom/page.tsx b/src/app/settings/workroom/page.tsx
index 7dd4e87..a7c2e21 100644
--- a/src/app/settings/workroom/page.tsx
+++ b/src/app/settings/workroom/page.tsx
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Container from "@/components/elements/Container";
+import EmptyState from "@/components/elements/EmptyState";
import RoundedButton from "@/components/elements/RoundedButton";
import MainProjectCard from "@/components/projects/MainProjectCard";
import PageTitle from "@/components/settings/PageTitle";
@@ -162,9 +163,11 @@ const Workroom = () => {
) : data?.pages.length === 0 ||
(data?.pages[0]?.projects?.length === 0 &&
!isFetchingNextPage) ? (
-
- {t("settings.workroom.empty")}
-
+
) : (
data?.pages?.map((page, pageIndex) => (
diff --git a/src/app/shops/[shopId]/layout.tsx b/src/app/shops/[shopId]/layout.tsx
new file mode 100644
index 0000000..d251e51
--- /dev/null
+++ b/src/app/shops/[shopId]/layout.tsx
@@ -0,0 +1,15 @@
+import type { Metadata } from "next";
+
+// این مسیر داشبورد مدیریت محصولاتِ خودِ فروشندهست (نسخهی عمومیِ سئوشده در
+// /shops/profile/[shopId] هست) — پس noindex میکنیم تا محتوای تکراری ایندکس نشه.
+export const metadata: Metadata = {
+ robots: { index: false, follow: false },
+};
+
+export default function ShopManageLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return children;
+}
diff --git a/src/app/shops/[shopId]/products/[listingId]/page.tsx b/src/app/shops/[shopId]/products/[listingId]/page.tsx
index be6217f..26f2f42 100644
--- a/src/app/shops/[shopId]/products/[listingId]/page.tsx
+++ b/src/app/shops/[shopId]/products/[listingId]/page.tsx
@@ -40,7 +40,6 @@ function SpecRow({
{children}
-
);
}
@@ -439,7 +438,7 @@ export default function ProductManagePage() {
{matchedVariant.price.toLocaleString()} {t("settings.toman")}
-
+
{matchedVariant.discount_price.toLocaleString()} {t("settings.toman")}
diff --git a/src/app/shops/checkout/[orderId]/page.tsx b/src/app/shops/checkout/[orderId]/page.tsx
index 22f63bc..3eca7e5 100644
--- a/src/app/shops/checkout/[orderId]/page.tsx
+++ b/src/app/shops/checkout/[orderId]/page.tsx
@@ -11,6 +11,7 @@ import { useEffect, useState } from "react";
import Image from "next/image";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
+import WalletUsagePrompt from "@/components/payment/WalletUsagePrompt";
type OrderDetail = {
_id: string;
@@ -37,21 +38,31 @@ export default function CheckoutPage() {
const params = useParams<{ orderId: string }>();
const { request, loading } = useAxios();
const [order, setOrder] = useState(null);
+ const [walletBalance, setWalletBalance] = useState(0);
+ const [showWalletPrompt, setShowWalletPrompt] = useState(false);
+ const [paidFromWallet, setPaidFromWallet] = useState(false);
useEffect(() => {
request<{ order: OrderDetail }>("GET", `/orders/${params.orderId}`)
.then((res) => setOrder(res?.order || null))
.catch(() => setOrder(null));
+ 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
}, [params.orderId]);
- const handlePay = async () => {
+ const startPayment = async (useWallet: boolean) => {
try {
- const response = await request<{ authority?: string }>(
+ const response = await request<{ authority?: string; paidFromWallet?: boolean }>(
"POST",
"/payment/shop-order/initiate",
- { orderId: params.orderId }
+ { orderId: params.orderId, useWallet }
);
+ if (response?.paidFromWallet) {
+ setPaidFromWallet(true);
+ return;
+ }
if (response?.authority) {
// router.push (next/navigation) برای URLهای بیرونی رفتارِ تضمینشدهای نداره —
// window.location.href مطمئنترین راهه (خصوصاً سافاری) و با بقیهی مسیرهای
@@ -66,8 +77,29 @@ export default function CheckoutPage() {
}
};
+ const handlePay = () => {
+ if (walletBalance > 0) {
+ setShowWalletPrompt(true);
+ return;
+ }
+ void startPayment(false);
+ };
+
if (!order) return null;
+ if (paidFromWallet) {
+ return (
+
+
+ {t("shops.checkoutTitle")}
+
+ {t("wallet.paidFromWalletSuccess")}
+
+
+
+ );
+ }
+
const primaryImage =
order.listing.images?.[order.listing.primaryImageIndex] ||
order.listing.images?.[0];
@@ -151,6 +183,16 @@ export default function CheckoutPage() {
{t("shops.proceedToPayment")}
+
+
{
+ setShowWalletPrompt(false);
+ void startPayment(useWallet);
+ }}
+ />
);
diff --git a/src/app/shops/discover/layout.tsx b/src/app/shops/discover/layout.tsx
new file mode 100644
index 0000000..8fd64ee
--- /dev/null
+++ b/src/app/shops/discover/layout.tsx
@@ -0,0 +1,23 @@
+import type { Metadata } from "next";
+import { generatePageMetadata } from "@/utils/generatePageMetadata";
+import { getServerLanguage } from "@/lib/i18n/server";
+import { translateCommon } from "@/lib/i18n/translate";
+
+export async function generateMetadata(): Promise {
+ const lang = await getServerLanguage();
+
+ return generatePageMetadata({
+ title: translateCommon(lang, "shopsDiscoverMeta.title"),
+ description: translateCommon(lang, "shopsDiscoverMeta.description"),
+ path: "/shops/discover",
+ lang,
+ });
+}
+
+export default function ShopsDiscoverLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return children;
+}
diff --git a/src/app/shops/listing/[listingId]/ListingDetailClient.tsx b/src/app/shops/listing/[listingId]/ListingDetailClient.tsx
index 9b6796f..d592f83 100644
--- a/src/app/shops/listing/[listingId]/ListingDetailClient.tsx
+++ b/src/app/shops/listing/[listingId]/ListingDetailClient.tsx
@@ -44,7 +44,6 @@ function SpecRow({ label, children }: { label: string; children: React.ReactNode
{children}
-
);
}
@@ -285,12 +284,15 @@ export default function ListingDetailClient() {
{/* طبق خواستهی کاربر: دکمهی اشتراکگذاریِ خودِ کالا (نه فروشگاه) روبروی نام
- فروشگاه سمت چپ — عینِ ProductDetailScreen.tsx اندروید */}
+ فروشگاه سمت چپ — عینِ ProductDetailScreen.tsx اندروید. Link (آواتار+نام)
+ flex-1 داره تا فضای خالی رو خودش ببلعه؛ وگرنه justify-between روی سه فرزند
+ (این Link + دو دکمه) این فضا رو مساوی پخش میکرد و فاصلهی دو دکمه هم بهاندازهی
+ فاصلهشون تا نام فروشگاه (خیلی بیشتر از حد معمول) میشد — عینِ shops/[shopId]/page.tsx */}
{shopInfo?._id ? (
-
+
{shopInfo?.logo ? (
@@ -310,7 +312,7 @@ export default function ListingDetailClient() {
/>
)}
- {shopInfo?.name}
+ {shopInfo?.name}
{(matchedVariant.price * quantity + shippingCost).toLocaleString()} {t("settings.toman")}
-
+ {/* توی تم تاریک قرمزِ ثابت روی پسزمینهی تیره کنتراست کمی داره — طبق
+ خواستهی کاربر توی تاریک سفید بشه */}
+
{(matchedVariant.discount_price * quantity + shippingCost).toLocaleString()} {t("settings.toman")}
diff --git a/src/app/shops/listing/[listingId]/page.tsx b/src/app/shops/listing/[listingId]/page.tsx
index dbc8cb6..5c3df1b 100644
--- a/src/app/shops/listing/[listingId]/page.tsx
+++ b/src/app/shops/listing/[listingId]/page.tsx
@@ -26,7 +26,10 @@ function uniqueValues(values: (string | null | undefined)[]): string[] {
// Deduped per request — see loadUser in users/[username]/page.tsx for rationale.
const loadListing = cache(async (listingId: string) => {
- const { data } = await fetchApiJson(`/shop-products/${listingId}`);
+ const { data } = await fetchApiJson(
+ `/shop-products/${listingId}`,
+ { next: { revalidate: 45 } }
+ );
return data?.listing ?? null;
});
diff --git a/src/app/shops/profile/[shopId]/page.tsx b/src/app/shops/profile/[shopId]/page.tsx
index 4efb6cf..3f01043 100644
--- a/src/app/shops/profile/[shopId]/page.tsx
+++ b/src/app/shops/profile/[shopId]/page.tsx
@@ -30,7 +30,10 @@ type PublicShop = {
// this shop; cache() keeps them to a single backend fetch (see loadUser in
// users/[username]/page.tsx for the full rationale).
const loadShop = cache(async (shopId: string) => {
- const { data } = await fetchApiJson<{ shop?: PublicShop }>(`/shops/public/${shopId}`);
+ const { data } = await fetchApiJson<{ shop?: PublicShop }>(
+ `/shops/public/${shopId}`,
+ { next: { revalidate: 45 } }
+ );
return data?.shop ?? null;
});
@@ -97,6 +100,7 @@ export default async function ShopProfilePage({ params }: IShopProfilePageProps)
return (
<>
+ {shop && {shop.name} }
{jsonLd && (